13. Files and Streams
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. 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 an opaque data structure managed by the C runtime. When you open a stream, fopen() allocates a FILE structure containing:
- An underlying integer file descriptor (
fileno(stream)); - An internal user-space I/O buffer;
- Read/write buffer positions and remaining byte counts;
- Error and End-of-File (EOF) status flags.
#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.
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).
An 8-bit char cannot represent 257 distinct values. If you assign the return value of fgetc() directly to a char:
- 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 prohibitively slow.
Standard I/O solves this by caching data in an internal buffer in user memory:
flowchart TD
APP["Program calls fgetc()"] --> BUF["Internal stdio input buffer"]
BUF -->|"When buffer empty"| KERNEL["Kernel read() system call (e.g. 4096 bytes)"]
APP2["Program calls fputc()"] --> OBUF["Internal stdio output buffer"]
OBUF -->|"When full, flushed, or on newline"| KERNEL2["Kernel write() system call"]
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.
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.
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;
}Common Mistakes#
- Storing
fgetc()in achar: TruncatesEOFor misinterprets0xFFbyte as EOF. - Using
gets(): Unsafe, unbounded reading that leads to buffer overflows. - Forgetting that
"w"truncates: Opening an existing configuration file with"w"wipes it. - Using text functions for binary data:
fputs()and%sstop at'\0', corrupting binary streams. - Calling
fflush(stdin): Non-standard and undefined behaviour. - Forgetting to flush before
fork(): Causes unwritten buffers to be duplicated in child processes. - Mixing
read()/write()withfgetc()/fread()on the same open file without coordination: Can desynchronise the apparent position because standard I/O buffers data in user space. Follow the POSIX active-handle rules and use the required flush or repositioning operation before changing interfaces. - Closing with
close(fileno(stream)): Bypasses buffer flushing; always usefclose(stream).
Practice#
- Why does
fgetc()return anintrather than achar? - What are the three standard I/O buffering modes, and which one does
stderruse by default? - How does
fgets()handle newline characters compared togets()? - If
fread(buf, sizeof(int), 10, fp)returns7, what does that mean? - Why must you call
fflush(stdout)before callingfork()whenstdoutcontains unflushed output?
Note
- Answers
- To accommodate all 256 possible byte values (
0to255) as well as the special out-of-bandEOFvalue (typically-1). - Unbuffered (
_IONBF), line buffered (_IOLBF) and fully buffered (_IOFBF). In the course's Unix environment,stderris normally unbuffered; the portable guarantee is that it is not initially fully buffered. fgets()respects buffer size limits and keeps the trailing\nin the destination string;gets()is unbounded and discards the\n.- It successfully read 7 complete
intvalues, or7 * sizeof(int)bytes. That is 28 bytes on a platform with four-byteint. It encountered either EOF or an error before reading the remaining 3. - Unflushed bytes in the user-space buffer are copied into the child process's address space, causing both parent and child to flush and print duplicate output upon exit.
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.