COMP1521 2,348 words·12 min read

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.

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.

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:

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

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

“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 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 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

Output path

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.

Practice: Quick Checks#

  1. Why must the result of fgetc() be stored in int until it has been compared with EOF?
  2. Name the three buffering modes. How does terminal stdout usually differ from redirected stdout in the course's Unix environment?

Note

- Quick Check Answers

  1. The return type must represent every possible byte value plus a distinct out-of-band EOF. Converting to char first can confuse byte 0xFF with EOF or convert EOF into an ordinary byte.
  2. Unbuffered, line buffered and fully buffered. Terminal stdout is normally line buffered; redirected stdout is normally fully buffered.

Practice: Tracing and Calculation#

  1. fread(buf, sizeof(int), 10, fp) returns 7. Exactly what is known, and what two indicators distinguish why it stopped early?
  2. printf("ready"); fork(); runs while stdout is fully buffered, and both processes later call exit(). Assuming no earlier flush and a successful fork, how many copies of ready can appear? What changes if fflush(stdout) succeeds before fork()?

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

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.

Practice: Writing and Debugging#

  1. fgets(line, sizeof line, fp) succeeds, but line contains no \n. Give two possible explanations and state what a line-oriented parser should do.
  2. A program alternates fgetc(stream) and read(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:

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;
}

Practice: Challenge#

  1. Why is writing a struct directly with fwrite(&record, sizeof record, 1, fp) not a portable persistent format?
  2. fseek(stream, 0, SEEK_END) returns non-zero. Is it valid to call ftell() 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.