COMP1521 1,898 words·10 min read

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.
C
#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():

C
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:

C
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 char is signed, a valid byte value of 0xFF (such as 255 in a binary file or ÿ in ISO-8859-1) is sign-extended to -1, prematurely triggering EOF.
  • On systems where char is unsigned, -1 converts to 255, and the loop never terminates on EOF.
C
// 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():

C
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:

  1. size - 1 characters have been read;
  2. A newline character \n is encountered (the \n is stored in the buffer); or
  3. 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.

C
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:

C
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) or 1).
  • 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.

C
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:

C
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.

C
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).

C
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 int until it has been checked against EOF.
  • 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:

Buffering Modes#

<stdio.h> supports three buffering modes:

  1. Unbuffered (_IONBF): Bytes are passed to the underlying system interface as soon as they are written. In the course's Unix environment, stderr is normally unbuffered so that diagnostics appear immediately. Portably, the initial stderr stream is guaranteed not to be fully buffered, but an implementation need not describe it more specifically as unbuffered.
  2. Line buffered (_IOLBF): Output is held in user space until a newline character '\n' is emitted, the buffer fills up, or input is requested from stdin. Terminal output (stdout connected to an interactive terminal) defaults to line buffering.
  3. 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:

C
setvbuf(stdout, NULL, _IONBF, 0); // make stdout unbuffered

Flushing with fflush#

fflush() forces any unwritten data in a stream's user-space output buffer to be written to the kernel:

C
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:

C
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():

C
#include <stdio.h>

int fseek(FILE *stream, long offset, int whence);
long ftell(FILE *stream);
void rewind(FILE *stream);
  • whence accepts SEEK_SET (start), SEEK_CUR (current position), or SEEK_END (end of file).
  • fseek() returns 0 on success and a non-zero value on failure.
  • ftell() returns the current file position in bytes, or -1L on error.
  • rewind(stream) moves back to the beginning like (void)fseek(stream, 0L, SEEK_SET) and also clears the stream's error indicator.
C
// 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:

C
#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 a char: Truncates EOF or misinterprets 0xFF byte 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 %s stop 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() with fgetc()/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 use fclose(stream).

Practice#

  1. Why does fgetc() return an int rather than a char?
  2. What are the three standard I/O buffering modes, and which one does stderr use by default?
  3. How does fgets() handle newline characters compared to gets()?
  4. If fread(buf, sizeof(int), 10, fp) returns 7, what does that mean?
  5. Why must you call fflush(stdout) before calling fork() when stdout contains unflushed output?

Note

- Answers

  1. To accommodate all 256 possible byte values (0 to 255) as well as the special out-of-band EOF value (typically -1).
  2. Unbuffered (_IONBF), line buffered (_IOLBF) and fully buffered (_IOFBF). In the course's Unix environment, stderr is normally unbuffered; the portable guarantee is that it is not initially fully buffered.
  3. fgets() respects buffer size limits and keeps the trailing \n in the destination string; gets() is unbounded and discards the \n.
  4. It successfully read 7 complete int values, or 7 * sizeof(int) bytes. That is 28 bytes on a platform with four-byte int. It encountered either EOF or an error before reading the remaining 3.
  5. 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.