Input, output and files · about 50 min

Binary files: fread, fwrite and fseek

Why this matters

Text files are easy for humans, but many files are not meant to be read by people: images, audio, compiled programs, database pages, save games, network packet captures. They store values in the same raw byte form the CPU uses in memory. Binary files are compact, fast to read and write (no number-to-text conversion), and allow random access: jumping straight to record 5000 without reading the 4999 before it. They also carry real traps around portability. This lesson covers both.

Text versus binary: the same numbers two ways

#include <stdio.h>

int main(void)
{
    int nums[4] = {1, 255, 256, -1};

    FILE *t = fopen("nums.txt", "w");
    FILE *b = fopen("nums.bin", "wb");
    if (t == NULL || b == NULL)
        return 1;
    for (int i = 0; i < 4; i++)
        fprintf(t, "%d\n", nums[i]);
    size_t written = fwrite(nums, sizeof nums[0], 4, b);
    printf("fwrite wrote %zu items\n", written);
    fclose(t);
    fclose(b);

    int back[4];
    FILE *r = fopen("nums.bin", "rb");
    if (r == NULL)
        return 1;
    size_t got = fread(back, sizeof back[0], 4, r);
    fclose(r);
    printf("fread read %zu items:", got);
    for (size_t i = 0; i < got; i++)
        printf(" %d", back[i]);
    printf("\n");
    return 0;
}
$ ./bin
fwrite wrote 4 items
fread read 4 items: 1 255 256 -1
$ wc -c nums.txt nums.bin
13 nums.txt
16 nums.bin
29 total

Look inside with od (octal dump), asking for hexadecimal bytes (-t x1) and decimal offsets (-A d):

$ od -A d -t x1 nums.bin
0000000 01 00 00 00 ff 00 00 00 00 01 00 00 ff ff ff ff
0000016
$ od -A d -c nums.txt
0000000   1  \n   2   5   5  \n   2   5   6  \n   -   1  \n
0000013

The text file holds characters: the digit 2 is the byte 0x32. The binary file holds the four ints exactly as they sit in memory, 4 bytes each:

  offset:   0  1  2  3 | 4  5  6  7 | 8  9 10 11 | 12 13 14 15
  bytes:   01 00 00 00 | ff 00 00 00 | 00 01 00 00 | ff ff ff ff
  value:        1      |     255     |     256     |     -1

Two things you learned in the types module are visible here. The least significant byte comes first (x86 is little-endian: 256 is 00 01 00 00), and -1 is all ones in two's complement. hexdump -C nums.bin is another common viewer, and it is installed in the sandbox.

fwrite and fread

size_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream);
size_t fread(void *ptr, size_t size, size_t count, FILE *stream);

Both copy count items of size bytes between memory at ptr and the stream, and return the number of complete items transferred. The void * parameters accept a pointer to anything: an array, a single variable (&n), a struct. No formatting happens; bytes are copied as they are.

The usual loop for a file of records:

struct record r;
while (fread(&r, sizeof r, 1, f) == 1) {
    /* use r */
}

Always open binary files with "rb", "wb" and so on. On Linux the b does nothing, but on Windows text mode turns \n into \r\n on output and back on input, and treats byte 0x1A as end of file, which corrupts binary data.

Moving around: fseek, ftell, rewind

Every open stream has a file position: the byte offset where the next read or write happens. Reads and writes advance it. You can move it yourself:

int  fseek(FILE *stream, long offset, int whence);
long ftell(FILE *stream);
void rewind(FILE *stream);

whence says what offset is relative to:

whencenew position
SEEK_SEToffset bytes from the beginning
SEEK_CURcurrent position plus offset (can be negative)
SEEK_ENDend of file plus offset (usually 0 or negative)

ftell returns the current position, and rewind(f) is fseek(f, 0, SEEK_SET) plus clearing the error flag. fseek returns 0 on success. A neat consequence: fseek(f, 0, SEEK_END) followed by ftell(f) gives the file's size.

Fixed-size records and random access

If every record has the same size, record number i starts at byte i * sizeof(record). That turns a file into an array on disk:

#include <stdio.h>

struct record {
    int id;
    int stock;
    double price;
};

int main(void)
{
    struct record items[3] = {
        {101, 12, 2.50},
        {102, 0, 9.99},
        {103, 7, 0.75},
    };
    FILE *f = fopen("items.db", "w+b");
    if (f == NULL) {
        perror("items.db");
        return 1;
    }
    fwrite(items, sizeof items[0], 3, f);
    printf("record size %zu, file size %ld\n", sizeof(struct record), ftell(f));

    /* jump straight to record 2 (the third one) and read it */
    struct record r;
    fseek(f, 2 * (long)sizeof r, SEEK_SET);
    fread(&r, sizeof r, 1, f);
    printf("record 2: id=%d stock=%d price=%.2f\n", r.id, r.stock, r.price);

    /* change record 1 in place */
    fseek(f, 1 * (long)sizeof r, SEEK_SET);
    fread(&r, sizeof r, 1, f);
    r.stock = 40;
    fseek(f, -(long)sizeof r, SEEK_CUR);
    fwrite(&r, sizeof r, 1, f);

    rewind(f);
    while (fread(&r, sizeof r, 1, f) == 1)
        printf("id=%d stock=%d price=%.2f\n", r.id, r.stock, r.price);
    fclose(f);
    return 0;
}
$ ./rec
record size 16, file size 48
record 2: id=103 stock=7 price=0.75
id=101 stock=12 price=2.50
id=102 stock=40 price=9.99
id=103 stock=7 price=0.75
  items.db (48 bytes)
  byte 0              16              32              48
       | record 0     | record 1     | record 2     |
       | 101 12 2.50  | 102 40 9.99  | 103 7 0.75   |
                      ^ fseek(f, 1*16, SEEK_SET)

Mode "w+b" creates the file and allows both reading and writing. The update step reads record 1, which moves the position to its end, then seeks back 16 bytes with SEEK_CUR and overwrites it. When a stream is open for both reading and writing, C requires an fseek (or fflush) between switching from reading to writing and back; that rule is easy to forget and causes data to land in strange places.

The casts to long are there because sizeof yields an unsigned size_t, and negating an unsigned value does not give a negative number. -(long)sizeof r is -16; -sizeof r would be a huge positive number.

Binary formats are not portable by accident

Writing a struct straight to disk is fast, but the bytes depend on the machine and compiler that wrote them:

For data that only your own program on your own machine will read back (a cache, a temporary file), raw structs are fine. For files that are exchanged or kept for years, define the format byte by byte, or use text.

Common mistakes

Recap

Going further: man 3 fseek covers fgetpos/fsetpos for files larger than a long can address. Try hexdump -C /bin/ls | head -2: the first four bytes of every Linux executable are 7f 45 4c 46, "\x7fELF", the magic number of the ELF format.

Exercises

Exercise 1 · quiz

How big is the file?

On the sandbox (where int is 4 bytes), a program creates a new file and runs

int data[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
fwrite(data, sizeof data[0], 10, f);
fclose(f);

How many bytes long is the file? Answer with a number.

Answer

fwrite copies 10 items of sizeof(int) = 4 bytes each, straight from memory: 40 bytes, no matter what the values are. As text with one number per line the same data would be 20 bytes ("0\n" to "9\n"), but the value 1000000 would take 8 characters as text and still 4 bytes in binary. Binary size depends only on the type; text size depends on the values.

Exercise 2 · write code

Save numbers in binary

Read integers from standard input until end of input (with scanf("%d", &n) == 1). Write each one to the file nums.bin as a raw int, using fwrite, in the order they were read. Open the file in mode "wb". When done, close the file (checking for errors) and print exactly:

wrote 3 numbers (12 bytes)

with the actual count and size. The check reads nums.bin back and verifies its bytes.

File: main.c

Starting point

#include <stdio.h>

int main(void)
{
    FILE *out = fopen("nums.bin", "wb");
    if (out == NULL) {
        perror("nums.bin");
        return 1;
    }
    int n;
    int count = 0;
    while (scanf("%d", &n) == 1) {
        fprintf(out, "%d\n", n);   /* this writes text, not binary */
        count++;
    }
    fclose(out);
    printf("wrote %d numbers (%zu bytes)\n", count, count * sizeof(int));
    return 0;
}

Example

input

1 2 3

expected output

wrote 3 numbers (12 bytes)
Hint 1

fwrite takes a pointer to the data, the size of one item, the number of items, and the stream.

Hint 2

To write the single int n: fwrite(&n, sizeof n, 1, out). It returns the number of items written, so compare with 1.

Reference solution
#include <stdio.h>

int main(void)
{
    FILE *out = fopen("nums.bin", "wb");
    if (out == NULL) {
        perror("nums.bin");
        return 1;
    }
    int n;
    int count = 0;
    while (scanf("%d", &n) == 1) {
        if (fwrite(&n, sizeof n, 1, out) != 1) {
            perror("nums.bin");
            fclose(out);
            return 1;
        }
        count++;
    }
    if (fclose(out) == EOF) {
        perror("nums.bin");
        return 1;
    }
    printf("wrote %d numbers (%zu bytes)\n", count, count * sizeof(int));
    return 0;
}

Exercise 3 · write code

How long is this file?

Write a program that prints the size in bytes of the file named by argv[1], by opening it in binary mode, seeking to the end with fseek(f, 0, SEEK_END) and asking ftell for the position. Output format, exactly:

hello.txt: 6 bytes

If the file cannot be opened, call perror with the file name and exit with 1. If the argument is missing, print usage: prog FILE to stderr and exit with 2.

File: main.c

Starting point

#include <stdio.h>

int main(int argc, char *argv[])
{
    if (argc != 2) {
        fprintf(stderr, "usage: prog FILE\n");
        return 2;
    }
    /* open, fseek to the end, ftell, print, close */
    return 0;
}

Example with arguments hello.txt

expected output

hello.txt: 6 bytes

Example with arguments poem.txt

expected output

poem.txt: 33 bytes
Hint 1

FILE *f = fopen(argv[1], "rb"); and check for NULL with perror.

Hint 2

After fseek(f, 0, SEEK_END), the position is the size: long size = ftell(f);

Reference solution
#include <stdio.h>

int main(int argc, char *argv[])
{
    if (argc != 2) {
        fprintf(stderr, "usage: prog FILE\n");
        return 2;
    }
    FILE *f = fopen(argv[1], "rb");
    if (f == NULL) {
        perror(argv[1]);
        return 1;
    }
    if (fseek(f, 0, SEEK_END) != 0) {
        perror(argv[1]);
        fclose(f);
        return 1;
    }
    long size = ftell(f);
    fclose(f);
    printf("%s: %ld bytes\n", argv[1], size);
    return 0;
}

Exercise 4 · write code

Jump to a record

The file scores.db is a sequence of fixed-size binary records, each written from this struct (8 bytes, no padding):

struct score {
    int id;
    int points;
};

Write a program that takes a record number N (counting from 0) as argv[1] and prints that record without reading the ones before it: compute its offset, fseek there, and fread one struct. Output format, exactly:

record 2: id=7003 points=88

Errors:

The checker creates a scores.db with several records and runs your program with different numbers, including ones past the end and invalid ones like -1 and two.

File: main.c

Starting point

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>

struct score {
    int id;
    int points;
};

int main(int argc, char *argv[])
{
    if (argc != 2) {
        fprintf(stderr, "usage: prog N\n");
        return 2;
    }
    FILE *f = fopen("scores.db", "rb");
    if (f == NULL) {
        perror("scores.db");
        return 1;
    }
    struct score s;
    /* read and print record number argv[1] */
    fread(&s, sizeof s, 1, f);
    printf("record 0: id=%d points=%d\n", s.id, s.points);
    fclose(f);
    return 0;
}
Hint 1

Parse argv[1] with strtol and an end pointer, as in the previous lesson, and reject negative numbers.

Hint 2

Record N starts at byte N * sizeof(struct score): fseek(f, n * (long)sizeof s, SEEK_SET).

Hint 3

Seeking past the end is allowed; it is the fread afterwards that returns 0. Check fread(&s, sizeof s, 1, f) != 1.

Reference solution
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>

struct score {
    int id;
    int points;
};

int main(int argc, char *argv[])
{
    if (argc != 2) {
        fprintf(stderr, "usage: prog N\n");
        return 2;
    }
    char *end;
    errno = 0;
    long n = strtol(argv[1], &end, 10);
    if (end == argv[1] || *end != '\0' || errno == ERANGE || n < 0) {
        fprintf(stderr, "no record %s\n", argv[1]);
        return 1;
    }
    FILE *f = fopen("scores.db", "rb");
    if (f == NULL) {
        perror("scores.db");
        return 1;
    }
    struct score s;
    if (fseek(f, n * (long)sizeof s, SEEK_SET) != 0 || fread(&s, sizeof s, 1, f) != 1) {
        fprintf(stderr, "no record %s\n", argv[1]);
        fclose(f);
        return 1;
    }
    fclose(f);
    printf("record %ld: id=%d points=%d\n", n, s.id, s.points);
    return 0;
}