13. Files and Streams
In File Systems, we explored the POSIX system call interface (open, read, write, lseek, close) which operates on low-level integer file descriptors. While descriptors offer direct access to the operating system's kernel services, crossing the user/kernel boundary for every single byte incurs noticeable overhead.
The C standard library provides the <stdio.h> Standard I/O library, which wraps low-level file descriptors in an opaque FILE * stream abstraction. Opaque means application code uses a FILE * through library functions rather than inspecting or changing its fields directly. This layer introduces automatic user-space buffering, formatted reading/writing, and character/line helpers.
| Feature | Low-Level POSIX I/O (<unistd.h>, <fcntl.h>) |
Standard C I/O (<stdio.h>) |
|---|---|---|
| Handle type | Integer file descriptor (int fd) |
Stream pointer (FILE *) |
| Open / Close | open(), close() |
fopen(), fclose() |
| Transfer functions | read(), write(), lseek() |
fgetc(), fgets(), fread(), fwrite(), fseek() |
| Buffering | Unbuffered in user space (kernel managed) | Automatic user-space buffering (unbuffered, line, full) |
| Data format | Raw byte buffers (void *, unsigned char *) |
Formatted text (fprintf, fscanf) or binary (fread, fwrite) |
| Detailed in | File Systems | This note |
The FILE * Stream Abstraction#
A FILE object is managed by the C runtime. On the POSIX systems used in this course, the runtime state associated with a stream normally includes:
- an underlying integer file descriptor, available through
fileno(stream)when that POSIX interface is permitted; - an internal user-space I/O buffer;
- read/write buffer positions and remaining byte counts;
- error and End-of-File (EOF) status indicators.
These are properties of the abstraction, not fields which portable application code should access itself.
#include <stdio.h>
FILE *stream = fopen("results.txt", "r");
if (stream == NULL) {
perror("results.txt");
return 1;
}If fopen() fails, it returns NULL and sets errno. Always verify that stream != NULL before performing any operations.
Stream Access Modes#
The second argument to fopen() is a mode string:
| Mode | Semantics | Creates File? | Truncates File? | Initial Position |
|---|---|---|---|---|
"r" |
Open for reading only | No (fails if absent) | No | Start |
"w" |
Open for writing only | Yes | Yes (to 0 bytes) | Start |
"a" |
Open for appending | Yes | No | End |
"r+" |
Open for reading and writing | No (fails if absent) | No | Start |
"w+" |
Open for reading and writing | Yes | Yes (to 0 bytes) | Start |
"a+" |
Open for reading and appending | Yes | No | End for writes |
Adding 'b' (e.g. "rb", "wb") requests binary mode. On POSIX/Linux systems, binary and text files are treated identically (as raw byte sequences), but specifying 'b' is good practice for portable code across operating systems.
An update stream is one opened with +, so both reading and writing are permitted. Permission to do both does not mean they can be alternated without coordination. The stream may hold unread input or unwritten output in its buffer:
FILE *stream = fopen("record.bin", "r+b");
int old = fgetc(stream);
if (old != EOF) {
if (fseek(stream, -1L, SEEK_CUR) != 0) {
perror("fseek");
} else if (fputc('X', stream) == EOF) {
perror("fputc");
}
}The fseek is not decorative. After reading, a successful positioning operation establishes where the following write occurs. In the other direction, output must be followed by fflush or a positioning operation before input begins. The narrow exception is that input which has reached end-of-file may be followed by output. A useful default is: when an update stream changes direction, explicitly reposition it.
Important
- Changing direction
A stream was opened with "r+". The program reads one character and immediately calls fputc without an intervening positioning operation. Why is the + in the mode string not enough to make this correct, and what operation can establish the intended write position?
Note
- Reasoned answer
+ permits both kinds of operation, but stdio still buffers them and requires a defined transition between directions. After input which has not reached EOF, call a positioning function such as fseek before writing. This both satisfies the sequencing rule and makes the position used by the write explicit.
Warning
Opening an existing file with "w" or "w+" immediately truncates the file to 0 bytes. Ensure your pathnames and logic are correct before opening.
Closing Streams with fclose#
Always close streams with fclose(), never close():
if (fclose(stream) == EOF) {
perror("fclose");
}fclose() first flushes any unwritten buffered data to the kernel and then closes the underlying file descriptor. Because pending writes occur during the flush, closing can legitimately fail if disk space is exhausted or a hardware error occurs.
Character I/O: fgetc and fputc#
For reading and writing individual bytes or ASCII characters:
int fgetc(FILE *stream);
int fputc(int c, FILE *stream);
int getchar(void); // equivalent to fgetc(stdin)
int putchar(int c); // equivalent to fputc(c, stdout)Why fgetc returns int, not char#
A byte can hold any value from 0 to 255 (0x00 to 0xFF). To signal that no more bytes remain or an error occurred, fgetc() must return a distinct out-of-band indicator: EOF (typically -1).
“Out-of-band” just means a value kept outside the range of genuine byte results. There are 256 possible bytes, and EOF is a 257th possible result. An int has room for all of them; an 8-bit char does not.
If you assign the return value of fgetc() directly to a char before comparing it:
- On systems where
charis signed, a valid byte value of0xFF(such as255in a binary file orÿin ISO-8859-1) is sign-extended to-1, prematurely triggeringEOF. - On systems where
charis unsigned,-1converts to255, and the loop never terminates onEOF.
// Correct: store return value in an int
int byte;
while ((byte = fgetc(stream)) != EOF) {
printf("%02x\n", (unsigned char)byte);
}Line-Oriented I/O: fgets and fputs#
When processing text line by line, use fgets():
char *fgets(char *s, int size, FILE *stream);
int fputs(const char *s, FILE *stream);fgets() reads characters from stream into the buffer s until:
size - 1characters have been read;- A newline character
\nis encountered (the\nis stored in the buffer); or - End-of-file or an error occurs.
fgets() always appends a terminating null byte '\0'. On success, it returns s; on EOF or error, it returns NULL.
char line[1024];
while (fgets(line, sizeof line, stream) != NULL) {
// line contains the newline character '\n' if the entire line fitted
fputs(line, stdout);
}Caution
Never use gets(). It provides no bound on the destination buffer and was removed from the C standard because it causes buffer overflow vulnerabilities.
Block / Binary I/O: fread and fwrite#
For reading and writing binary data or arrays:
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);size: The size of each element in bytes (e.g.sizeof(struct record)or1).nmemb: The number of elements to transfer.- Return value: The number of complete elements successfully transferred.
If fread() returns a value smaller than nmemb, it indicates either end-of-file or a read error. Use feof(stream) and ferror(stream) to distinguish between the two.
struct student {
int zid;
double wam;
};
struct student students[50];
size_t read_count = fread(students, sizeof(struct student), 50, input_file);
if (read_count < 50) {
if (feof(input_file)) {
printf("Reached end of file after reading %zu students\n", read_count);
}
if (ferror(input_file)) {
perror("fread error");
}
}Reading a native C structure this way is suitable only when the file was produced for the same representation. Structure padding, byte order and type sizes can differ between compilers and machines. A portable persistent format encodes each field explicitly rather than treating the in-memory structure as a universal file format.
Formatted In-Memory I/O: snprintf and sscanf#
Rather than reading and writing formatted text directly against streams, it is often safer to read lines into memory using fgets() and then parse or format them in memory:
int sscanf(const char *str, const char *format, ...);
int snprintf(char *str, size_t size, const char *format, ...);Safe parsing with sscanf#
sscanf() scans and parses data from an existing null-terminated string. It returns the number of input items successfully matched and assigned.
char input_line[] = "z5555555 85.5 2026";
int zid, year;
float wam;
if (sscanf(input_line, "z%d %f %d", &zid, &wam, &year) == 3) {
printf("Successfully parsed student %d (WAM: %.1f, Year: %d)\n", zid, wam, year);
} else {
fprintf(stderr, "Malformed input line: %s\n", input_line);
}When reading strings with %s, always specify an explicit maximum field width to prevent buffer overflow (e.g. %31s into a 32-byte array).
Safe formatting with snprintf#
snprintf() formats data into a fixed-size character buffer, writing at most size bytes (including the terminating null byte).
char message[64];
int written = snprintf(message, sizeof message, "User %s logged in at %s", username, timestamp);
if (written < 0) {
fprintf(stderr, "Formatting failed\n");
} else if ((size_t)written >= sizeof message) {
fprintf(stderr, "Warning: message was truncated\n");
}snprintf() returns the total number of characters that would have been written if the buffer had been large enough. A negative value reports an encoding error; otherwise, if (size_t)return_value >= sizeof buffer, the output was truncated.
Caution
Avoid sprintf(), which takes no buffer size and can easily overwrite memory.
Note
Checkpoint
- A
FILE *wraps a descriptor with buffering and higher-level text or block operations. - Character input must remain an
intuntil it has been checked againstEOF. - Every formatted or block operation has a return value that says how much work actually succeeded.
Stream Buffering#
Invoking a kernel system call (read/write) crosses the user/kernel privilege boundary, checks arguments and updates kernel state. This mode transition has overhead, although it does not necessarily cause the scheduler to switch to another process. Calling read() or write() for every single byte is therefore needlessly expensive and can dominate the work of a byte-at-a-time program.
Standard I/O solves this by caching data in an internal buffer in user memory:
Input path
flowchart LR
APP["fgetc()"] --> BUF["stdio input buffer"]
BUF -->|"buffer empty"| KERNEL["read()<br/>e.g. 4096-byte request"]
Output path
flowchart LR
APP["fputc()"] --> BUF["stdio output buffer"]
BUF -->|"full · flushed · newline"| KERNEL["write()"]
Buffering Modes#
<stdio.h> supports three buffering modes:
- Unbuffered (
_IONBF): Bytes are passed to the underlying system interface as soon as they are written. In the course's Unix environment,stderris normally unbuffered so that diagnostics appear immediately. Portably, the initialstderrstream is guaranteed not to be fully buffered, but an implementation need not describe it more specifically as unbuffered. - Line buffered (
_IOLBF): Output is held in user space until a newline character'\n'is emitted, the buffer fills up, or input is requested fromstdin. Terminal output (stdoutconnected to an interactive terminal) defaults to line buffering. - Fully buffered (
_IOFBF): Output is written to the kernel only when the buffer is completely full (e.g. 4096 or 8192 bytes), when explicitly flushed, or when the file is closed. Files on disk and redirected streams default to full buffering.
You can configure buffering explicitly using setvbuf() before any I/O operations occur:
setvbuf(stdout, NULL, _IONBF, 0); // make stdout unbufferedFlushing with fflush#
fflush() forces any unwritten data in a stream's user-space output buffer to be written to the kernel:
printf("Enter password: ");
fflush(stdout); // ensures prompt appears on screen before getchar()
int ch = getchar();Note
fflush() is only defined for output and update streams. Calling fflush(stdin) is undefined behaviour in standard C.
The fork() and Buffering Trap#
Because the stdio buffer lives in the process's user memory, calling fork() duplicates any unwritten buffered data into the child process. If both parent and child later flush their buffers, the text is printed twice:
printf("Starting computation..."); // No '\n' -> remains in stdout buffer if redirected
fork();
// Both parent and child exit, flushing the buffer -> "Starting computation..." printed twice!To prevent duplicate output, call fflush(stdout) immediately before calling fork(). See Processes for more details on process creation.
Practice: Quick Checks#
- Why must the result of
fgetc()be stored inintuntil it has been compared withEOF? - Name the three buffering modes. How does terminal
stdoutusually differ from redirectedstdoutin the course's Unix environment?
Note
- Quick Check Answers
- The return type must represent every possible byte value plus a distinct out-of-band
EOF. Converting tocharfirst can confuse byte0xFFwith EOF or convert EOF into an ordinary byte. - Unbuffered, line buffered and fully buffered. Terminal
stdoutis normally line buffered; redirectedstdoutis normally fully buffered.
Practice: Tracing and Calculation#
fread(buf, sizeof(int), 10, fp)returns 7. Exactly what is known, and what two indicators distinguish why it stopped early?printf("ready"); fork();runs whilestdoutis fully buffered, and both processes later callexit(). Assuming no earlier flush and a successful fork, how many copies ofreadycan appear? What changes iffflush(stdout)succeeds beforefork()?
Note
- Tracing and Calculation Answers
3. Seven complete int elements were read successfully. A partial eighth element may also have been consumed or stored before the short result, so the return value alone does not prove that exactly 7 * sizeof(int) bytes moved. Check feof(fp) and ferror(fp) to distinguish end-of-file from an error; any partial element is indeterminate and must not be used.
4. The buffered bytes are duplicated, so two copies can be flushed. Flushing before the fork leaves no copied pending output, so one copy appears.
Stream Positioning: fseek and ftell#
For seekable streams, <stdio.h> provides fseek() and ftell():
#include <stdio.h>
int fseek(FILE *stream, long offset, int whence);
long ftell(FILE *stream);
void rewind(FILE *stream);whenceacceptsSEEK_SET(start),SEEK_CUR(current position), orSEEK_END(end of file).fseek()returns0on success and a non-zero value on failure.ftell()returns the current file position in bytes, or-1Lon error.rewind(stream)moves back to the beginning like(void)fseek(stream, 0L, SEEK_SET)and also clears the stream's error indicator.
// Determine the file size using stdio.
if (fseek(stream, 0, SEEK_END) != 0) {
perror("fseek");
// handle error
}
long file_size = ftell(stream);
if (file_size == -1L) {
perror("ftell");
// handle error
}
if (fseek(stream, 0, SEEK_SET) != 0) {
perror("fseek");
// handle error
}Always check both fseek() and ftell() before using the result. This pattern is useful for ordinary seekable binary files in the course's Unix environment, but it is not a universal portable way to measure every stream: pipes are not seekable, and the meaning of positions in a text stream is implementation-dependent.
fseek() automatically flushes pending writes and synchronises the internal stream buffer with the requested file position.
Note
Checkpoint
- Buffering reduces system-call overhead but creates state that must be flushed or repositioned deliberately.
fork()duplicates user-space buffers, which is why pending output can appear twice.- Seeking is meaningful only for seekable streams, and every positioning call must be checked.
Practice: Writing and Debugging#
fgets(line, sizeof line, fp)succeeds, butlinecontains no\n. Give two possible explanations and state what a line-oriented parser should do.- A program alternates
fgetc(stream)andread(fileno(stream), ...)without flushing or repositioning. Why can the apparent file position be surprising?
Note
- Writing and Debugging Answers
5. The physical line may be longer than the buffer, or the file may end after a final unterminated line. The parser must distinguish these cases if completeness matters and continue collecting a long line rather than silently treating a fragment as a whole line.
6. stdio may already have read bytes ahead into its user-space buffer, while the descriptor's kernel offset has advanced beyond the character just returned. Under POSIX, switching between a stream and its underlying descriptor without the required flush or positioning coordination violates the active-handle rules and gives undefined behaviour.
Worked Example: Byte-for-Byte Stream Copy#
This complete example uses buffered Standard I/O to copy a file byte-for-byte:
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[]) {
if (argc != 3) {
fprintf(stderr, "Usage: %s <source> <destination>\n", argv[0]);
return EXIT_FAILURE;
}
FILE *input = fopen(argv[1], "rb");
if (input == NULL) {
perror(argv[1]);
return EXIT_FAILURE;
}
FILE *output = fopen(argv[2], "wb");
if (output == NULL) {
perror(argv[2]);
fclose(input);
return EXIT_FAILURE;
}
unsigned char buffer[4096];
size_t bytes_read;
int error_occurred = 0;
while ((bytes_read = fread(buffer, 1, sizeof buffer, input)) > 0) {
if (fwrite(buffer, 1, bytes_read, output) != bytes_read) {
perror(argv[2]);
error_occurred = 1;
break;
}
}
if (ferror(input)) {
perror(argv[1]);
error_occurred = 1;
}
if (fclose(input) == EOF) {
perror(argv[1]);
error_occurred = 1;
}
if (fclose(output) == EOF) {
perror(argv[2]);
error_occurred = 1;
}
return error_occurred ? EXIT_FAILURE : EXIT_SUCCESS;
}Practice: Challenge#
- Why is writing a
structdirectly withfwrite(&record, sizeof record, 1, fp)not a portable persistent format? fseek(stream, 0, SEEK_END)returns non-zero. Is it valid to callftell()and use that value as a size? Give one reason the seek might fail.
Note
- Challenge Answers
7. Padding, type widths, floating-point representation and byte order can vary between implementations and machines. A portable format encodes each field explicitly.
8. No. The failed seek means the position was not established, so the later value must not be used as the requested size. Pipes and terminals are common non-seekable streams.
For low-level POSIX file operations and descriptor mechanics, see File Systems. For filesystem structure and directories, see File System Metadata and Directories. For inter-process communication pipelines, see Pipes and File Redirection.