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.
fwritereturning less thancountmeans an error.freadreturning less thancountmeans end of file or an error; usefeof/ferrorto tell which. A fread that returns 0 has read no complete item.
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:
| whence | new position |
|---|---|
SEEK_SET | offset bytes from the beginning |
SEEK_CUR | current position plus offset (can be negative) |
SEEK_END | end 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:
- Sizes:
intis 4 bytes on today's common platforms, but C only guarantees at least 2, andlongis 8 bytes on 64-bit Linux and 4 on 64-bit Windows. Useint32_t,uint16_tand friends from<stdint.h>in file formats. - Byte order: a file written on a little-endian x86 machine reads back with bytes reversed on a big-endian system. Formats pick an order and convert explicitly; the bit manipulation module shows how.
- Padding: the struct above has no padding, but
struct { char c; int n; }has 3 invisible bytes afterc, with unspecified contents. They get written to the file too, and another compiler might lay the struct out differently. - Pointers: writing a struct that contains a pointer saves an address, which is meaningless in the next run.
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
- Opening without
b, which works on Linux and corrupts data on Windows. - Passing the value instead of its address:
fwrite(n, sizeof n, 1, f)withint npasses the number as a pointer. gcc warnspassing argument 1 of 'fwrite' makes pointer from integer without a cast, and the program crashes. - Confusing size and count:
fread(buf, 1, 100, f)returns bytes read;fread(buf, 100, 1, f)returns 1 or 0 and tells you nothing about a partial read. -sizeof xas a seek offset, which is a huge positive number. Cast tolongfirst.- Assuming a seek past the end fails.
fseekbeyond the end succeeds (writing there would create a gap of zero bytes); it is the nextfreadthat returns 0. - Viewing binary files with
cat, which dumps raw bytes into the terminal and can garble it. Useodorhexdump.
Recap
- Binary files hold values as raw bytes from memory; their size depends on types, not values.
fwrite/freadcopycountitems ofsizebytes and return how many complete items moved.- Every stream has a position;
fseekwithSEEK_SET,SEEK_CURorSEEK_ENDmoves it,ftellreports it,rewindgoes back to the start. - Fixed-size records give random access: record
iis ati * sizeof(record). - Raw structs on disk depend on type sizes, byte order and padding; use
<stdint.h>types and an explicit format for anything shared.
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.