File Handling · intermediate · ~8 min
**What you will learn** - Open files in binary mode (`"rb"`, `"wb"`, `"rb+"`) and explain how binary mode differs from text mode. - Serialize an in-memory value to disk with `fwrite` and read it back correctly with `fread`, always checking the return count. - Understand why dumping a raw struct (`fwrite(&s, sizeof s, 1, f)`) is convenient but **not portable** across machines. - Identify the two traps that break cross-system binary data: **endianness** (byte order) and **struct padding** (hidden alignment bytes). - Define a simple, explicit on-disk format and serialize field-by-field in a fixed byte order so any machine can read it. - Use `fseek` and `ftell` to move around inside a binary file and read or update a single fixed-size record.
A binary file stores raw bytes exactly as they sit in memory. Unlike a text file, nothing is translated: no newline conversion, no character encoding, no human-readable formatting. When you write the integer 300 to a text file you get the three characters 3, 0, 0; when you write it to a binary file you get the raw 4 bytes that represent the number 300 inside the CPU.
This lesson builds directly on fwrite for binary output, where you learned the mechanics of fwrite. Here we focus on the why and the correctness: why binary files exist, how to read your data back without corrupting it, and the portability pitfalls that catch almost every beginner the first time they share a file between two computers.
The core idea is serialization — turning an in-memory value (a number, a struct, an array) into a sequence of bytes you can save, and deserialization — turning those bytes back into a value later. Binary files are used everywhere this matters: save games, image and audio formats (PNG, WAV), database storage engines, network protocols, and configuration caches. They are compact and fast because there is no parsing of text.
We start with the simplest case (dump a struct, read it back on the same machine), then expose the two reasons that approach is fragile across systems — endianness and padding — and finish with the professional fix: define your own format and write each field explicitly.
Real software constantly turns memory into bytes and back. A game saves your progress; a photo app reads a JPEG; a database writes rows to disk; a server sends a packet over the network. All of these are binary serialization problems.
Getting it wrong has concrete consequences. If you read a file with fread and ignore the return value, a short or failed read leaves your variables holding garbage — and in C, acting on uninitialized data is undefined behaviour. If you dump a raw struct and ship it to users on different hardware, some of them load corrupted data because their CPU orders bytes differently or their compiler pads the struct differently. Bugs like these are silent: the program does not crash, it just produces wrong numbers.
Binary formats are also where many real security problems live. A parser that trusts a length field from a file and then mallocs or copies that many bytes is a classic buffer-overflow target. Even though this lesson is not a security lesson, the habits you build here — validate every read, never trust a size from the file blindly, define a strict format — are exactly the habits that keep file parsers safe.
Definition. When you call fopen, the mode string's b flag selects binary mode; without it you get text mode.
Why it matters. On Windows, text mode translates \n to the two bytes \r\n on write and back on read, and may treat a Ctrl-Z byte as end-of-file. That translation silently corrupts binary data (a 0x0A byte that happens to appear inside an integer would get mangled). On Linux and macOS there is no translation, so the bug only appears when your file reaches a Windows user — which makes it a nasty portability surprise. Always use b for non-text data.
Text mode ("w"): value 10 (0x0A) inside data --> may become 0x0D 0x0A (CORRUPTED)
Binary mode ("wb"): value 10 (0x0A) inside data --> stays 0x0A (CORRECT)
Pitfall. Opening a binary file with "r"/"w" works fine on your Linux laptop and breaks for someone on Windows. Use "rb"/"wb".
Knowledge check: On Linux, is there any byte-level difference between "w" and "wb"? Why might your binary code still appear to work with "w" during development?
Definition. size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *f) writes nmemb elements of size bytes each and returns the number of elements actually written. fread has the same signature and returns the number actually read.
How it works internally. Both functions copy raw bytes between your buffer and the file's I/O buffer. They do not know or care what the bytes mean — to them everything is unsigned char. The return value is your only signal of success: a value less than nmemb means a short write/read (disk full, file truncated, end of file).
When to use. Use them for fixed-size records and blocks of binary data. When not to: do not use fwrite to store data that must be read by other programs, languages, or machines unless you control the exact byte format.
Pitfall. Ignoring the return value. fread(&x, sizeof x, 1, f) returning 0 means x was not filled — using it is undefined behaviour.
fread(&n, sizeof(int), 1, f);
reads 4 bytes from file --> copies them into the 4 bytes of n
returns 1 on success, 0 if fewer than one full element was available
Knowledge check (predict the output): A file contains only 2 bytes. You call fread(&n, sizeof(int), 1, f) where int is 4 bytes. What does fread return, and what is the value of n afterward?
Definition. Endianness is the order in which a CPU stores the bytes of a multi-byte number. Little-endian stores the least-significant byte first (x86, ARM by default); big-endian stores the most-significant byte first (some network and older systems; network byte order is big-endian).
How it works. Take the 32-bit integer 0x01020304. Its four bytes are 01 02 03 04.
Value 0x01020304 in memory:
Little-endian (x86): 04 03 02 01
Big-endian: 01 02 03 04
If machine A (little) writes the raw bytes 04 03 02 01
and machine B (big) reads them back as an int,
B interprets them as 0x04030201 --> WRONG NUMBER
When it matters. Any time bytes leave the machine that produced them: shared files, network packets, cross-platform formats. When it does not: a scratch file written and read by the same program on the same machine.
Pitfall. Assuming the integer you wrote is the integer you'll read on another box. The fix is to choose one byte order for your format (commonly big-endian / network order) and convert explicitly.
Knowledge check: If both machines are little-endian, will a raw fwrite of an int round-trip correctly between them? Does that make the approach safe in general?
Definition. The compiler may insert unused padding bytes between or after struct members so each member sits at an address its type prefers (alignment). This makes sizeof(struct) larger than the sum of the member sizes, and the exact layout can differ between compilers/platforms.
struct Rec { char tag; int value; }; // not necessarily 5 bytes
One common layout (int needs 4-byte alignment):
offset: 0 1 2 3 4 5 6 7
field: tag [pad pad pad] value value value value
sizeof = 8, with 3 hidden padding bytes after tag
Why it matters. Those padding bytes are part of what fwrite(&rec, sizeof rec, 1, f) writes. A different compiler may pad differently, so the file no longer lines up. Worse, padding bytes are uninitialized memory — writing them can leak old stack/heap contents into your file.
When it matters / not. Matters for any format shared across builds or machines; harmless for same-build same-machine scratch data. Pitfall: assuming a struct is "just its fields" laid end to end. It is not.
Knowledge check (explain in your own words): Why can two correct C compilers produce different sizeof for the same struct, and how does that break a raw-struct file format?
Definition. Instead of dumping memory, you decide exactly which bytes go where: each field, in a fixed order, in a fixed byte order, with no implicit padding. You write each field with its own fwrite (or pack into a byte buffer) and read it back the same way.
How it works. You serialize field by field. For multi-byte integers you convert to a chosen byte order (e.g. big-endian) before writing and convert back after reading. The result is independent of compiler and CPU.
When to use. Any persisted or transmitted format. Pitfall: mixing the write order and read order. The reader must consume fields in exactly the same sequence and sizes the writer produced — a format is a contract.
Opening, the I/O calls, and seeking, with the important details annotated.
FILE *f = fopen("data.bin", "wb"); // b = binary; w = create/truncate for writing
// "rb" = read existing binary file
// "rb+" = read AND write, file must exist, no truncate
// "ab" = append binary
size_t n = fwrite(&value, sizeof value, 1, f); // returns elements written (here: 1 on success)
size_t m = fread(&value, sizeof value, 1, f); // returns elements read; 0 at EOF / short read
fseek(f, (long)index * sizeof(Record), SEEK_SET); // jump to a fixed-size record by index
long pos = ftell(f); // current byte offset, or -1L on error
fclose(f); // flushes buffered writes to disk; always check it for write streams
Key points: always check the return value of fwrite/fread against the count you asked for; fseek offsets are in bytes; and fclose can fail (a delayed write error surfaces here), so check it after writing.
Binary files store raw bytes exactly as they are in memory. There is no newline translation and no text encoding applied.
To work with them, open the file in binary mode:
"rb" to read"wb" to writeA common use is serialization — saving an in-memory value to disk so it can be loaded back later. For example, you can write a whole struct at once:
fwrite(&obj, sizeof obj, 1, f);
Writing a struct directly works on one machine, but it is not safe to share across different systems. Two issues break it:
Because of these, the raw bytes one machine writes may not match what another machine expects.
Rule: for data exchanged across systems, do not dump a struct directly. Instead, serialize it explicitly, byte by byte, in a defined order.
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
/* A fixed on-disk record format, written explicitly in big-endian order so
the file is portable across machines and compilers. We never fwrite a
raw struct to disk; the struct is only an in-memory convenience. */
typedef struct {
uint32_t id;
int32_t score;
} Record;
/* Write a 32-bit value to f as 4 big-endian bytes. Returns 0 on success. */
static int put_u32_be(FILE *f, uint32_t v) {
unsigned char b[4];
b[0] = (unsigned char)(v >> 24); /* most-significant byte first */
b[1] = (unsigned char)(v >> 16);
b[2] = (unsigned char)(v >> 8);
b[3] = (unsigned char)(v);
return fwrite(b, 1, 4, f) == 4 ? 0 : -1;
}
/* Read 4 big-endian bytes from f into *out. Returns 0 on success. */
static int get_u32_be(FILE *f, uint32_t *out) {
unsigned char b[4];
if (fread(b, 1, 4, f) != 4) return -1; /* short read or EOF */
*out = ((uint32_t)b[0] << 24) | ((uint32_t)b[1] << 16)
| ((uint32_t)b[2] << 8) | (uint32_t)b[3];
return 0;
}
static int save_record(const char *path, const Record *r) {
FILE *f = fopen(path, "wb");
if (!f) { perror("fopen(wb)"); return -1; }
int rc = 0;
/* int32_t score reinterpreted as uint32_t bits for transport; the bit
pattern is preserved and decoded back on read. */
if (put_u32_be(f, r->id) != 0) rc = -1;
if (put_u32_be(f, (uint32_t)r->score) != 0) rc = -1;
if (fclose(f) != 0) { perror("fclose"); rc = -1; } /* catch flush errors */
return rc;
}
static int load_record(const char *path, Record *r) {
FILE *f = fopen(path, "rb");
if (!f) { perror("fopen(rb)"); return -1; }
uint32_t id, score_bits;
int rc = 0;
if (get_u32_be(f, &id) != 0 || get_u32_be(f, &score_bits) != 0) rc = -1;
fclose(f);
if (rc == 0) {
r->id = id;
r->score = (int32_t)score_bits;
}
return rc;
}
int main(void) {
const char *path = "record.bin";
Record out = { .id = 42, .score = -1500 };
if (save_record(path, &out) != 0) {
fprintf(stderr, "save failed\n");
return EXIT_FAILURE;
}
Record in;
if (load_record(path, &in) != 0) {
fprintf(stderr, "load failed\n");
return EXIT_FAILURE;
}
printf("loaded id=%u score=%d\n", in.id, in.score);
remove(path); /* clean up the temporary file */
return EXIT_SUCCESS;
}
What it does. It defines a tiny record (id, score), saves it to record.bin by writing each field as 4 explicit big-endian bytes, then loads it back and prints it. Because the byte order is fixed in code, the file reads identically on a little-endian laptop and a big-endian server, and there are no padding bytes in the file at all.
Expected output:
loaded id=42 score=-1500
Edge cases. If the file is shorter than 8 bytes, get_u32_be returns -1 and load_record reports failure instead of using garbage. The (uint32_t)r->score cast transports the negative value's bit pattern safely; we cast back to int32_t on read. Using fixed-width types (uint32_t, int32_t) avoids the ambiguity of int sizes across platforms.
Walkthrough of the key path: save_record then load_record for id = 42, score = -1500.
main builds out = {42, -1500} in memory and calls save_record.fopen(path, "wb") creates/truncates record.bin in binary mode. If it fails (e.g. permissions), perror prints the reason and we return early.put_u32_be(f, 42) splits 42 (0x0000002A) into bytes most-significant first: 00 00 00 2A, and fwrites those 4 bytes. It checks the write count is exactly 4.put_u32_be(f, (uint32_t)-1500) reinterprets -1500's two's-complement bits as 0xFFFFFA24 and writes FF FF FA 24.fclose flushes the buffer to disk and is checked — a disk-full error can surface only here.The file now contains exactly these 8 bytes:
offset: 0 1 2 3 4 5 6 7
bytes: 00 00 00 2A FF FF FA 24
\---id=42--/ \-score---/
load_record opens with "rb" and calls get_u32_be twice.get_u32_be reads 00 00 00 2A, reassembles (0<<24)|(0<<16)|(0<<8)|0x2A = 0x2A = 42 into id.get_u32_be reads FF FF FA 24, reassembles 0xFFFFFA24 into score_bits.(int32_t)0xFFFFFA24 yields -1500 again, stored in r->score.main prints loaded id=42 score=-1500, and remove deletes the temp file.| Step | Bytes on disk | id | score |
|---|---|---|---|
| after put id | 00 00 00 2A |
42 | — |
| after put score | ... FF FF FA 24 |
42 | -1500 |
| after read id | — | 42 | — |
| after read score | — | 42 | -1500 |
Notice the result never depends on the host CPU's endianness, because every byte position is chosen explicitly.
1. Ignoring the fread return value.
/* WRONG */
int n;
fread(&n, sizeof n, 1, f); /* if file is empty/short, n is garbage */
printf("%d\n", n); /* undefined behaviour: using uninitialized n */
Why it's wrong: fread may read fewer elements than requested; it does not throw. Using the unfilled variable is undefined behaviour.
/* CORRECT */
int n;
if (fread(&n, sizeof n, 1, f) != 1) {
fprintf(stderr, "read failed or EOF\n");
/* handle error, do not use n */
} else {
printf("%d\n", n);
}
Prevent it: always compare the return count to the count you asked for.
2. Dumping a raw struct and expecting portability.
/* WRONG for shared files */
fwrite(&rec, sizeof rec, 1, f); /* writes padding + native byte order */
Why it's wrong: padding bytes and endianness differ across compilers/CPUs, so another machine reads corrupted fields. (This is the exact issue the lesson quiz asks about.) Fix: serialize field by field in a fixed byte order, as in the example.
3. Forgetting binary mode on Windows.
/* WRONG */
FILE *f = fopen("data.bin", "w"); /* text mode: \n -> \r\n corrupts data */
Why it's wrong: text-mode newline translation mangles any data byte equal to 0x0A (and 0x0D). It "works" on Linux during testing, then fails for Windows users. Fix: use "wb"/"rb" always for binary.
4. Mismatched write/read order. Writing id then score but reading score then id swaps the fields silently. Recognize it when values look transposed; prevent it by keeping the format in one documented place (or a single read/write helper pair).
Inspect the actual bytes. When a binary file misbehaves, look at it. On Linux/macOS:
xxd record.bin # hex + ASCII view
od -An -tx1 record.bin
Compare the bytes to what your format says they should be. For the example file you should see 00 00 00 2a ff ff fa 24.
Common compiler errors.
fread/fwrite (e.g. fread(n, ...) instead of fread(&n, ...)) gives a type warning or wrong behaviour. The first argument is always void * to the buffer.#include <stdint.h> when using uint32_t causes "unknown type name".Common runtime errors.
fopen returns NULL (bad path, permissions). Check it and call perror to see why.Common logic errors.
0x2A000000): an endianness assumption is wrong; switch to explicit byte order.Questions to ask when it doesn't work. Did I open with b? Did I check every fread/fwrite count? Do my write order and read order match exactly? Are the bytes on disk what I expect (check with xxd)? Am I assuming a CPU byte order?
Binary I/O is a frequent source of undefined behaviour and memory bugs, even outside security contexts:
fread returns short, the destination bytes are indeterminate. Never use a buffer the read did not fully fill. Always gate on the return count.count = 4000000000 can drive an over-large malloc or an out-of-bounds loop. Bound the value to something sane before trusting it.fwrite(&struct, sizeof struct, 1, f) writes uninitialized padding bytes, which may contain leftover stack/heap data. Field-by-field serialization avoids writing those bytes at all.fseek(f, (long)index * sizeof(Record), SEEK_SET) can overflow long for huge indices, producing a wrong or negative offset. Validate index against the file size (ftell after fseek(f, 0, SEEK_END)).fopen needs a matching fclose, including on error paths. Leaked FILE*s exhaust descriptors in long-running programs. For write streams, check fclose's return — a buffered write error may only appear there.sizeof(int) is not guaranteed to be 4. Use fixed-width types from <stdint.h> (uint32_t, int32_t) in any format you persist so the byte count is unambiguous.Concrete uses. Binary serialization underlies game save files, media containers (PNG, WAV, MP4), database storage engines (rows and indexes on disk), font files (TrueType), executable formats (ELF, PE), and most network protocols. Each defines a strict byte layout precisely so any reader on any machine decodes it identically — which is exactly the explicit-format discipline this lesson teaches. Standard formats specify byte order (often big-endian, a.k.a. network order) and exact field widths for this reason.
Professional best practices.
Beginner rules:
b.fread/fwrite and of fopen/fclose.Advanced habits:
Beginner 1 — Round-trip one integer.
Objective: write a single int32_t to num.bin with fwrite, then read it back with fread and print it. Requirements: open with "wb"/"rb", check both return values, and report an error if the read is short. Example: store 12345, output read 12345. Concepts: binary mode, fwrite/fread, checking return counts.
Beginner 2 — Save and load an array.
Objective: write an array of 5 int32_t values to a file in one fwrite, then read them back in one fread. Requirements: verify the returned element count equals 5 on both calls; print all five after loading. Hint: pass nmemb = 5. Concepts: block I/O, element count, arrays.
Intermediate 1 — Explicit big-endian writer.
Objective: implement put_u32_be/get_u32_be (as in the lesson) and use them to store two values, then dump the file with xxd and confirm the bytes are in big-endian order. Requirements: no raw struct writes; round-trip a negative value correctly using int32_t. Input/output: store id=7, score=-3; the first 4 bytes must read 00 00 00 07. Concepts: endianness, explicit serialization, fixed-width types.
Intermediate 2 — Indexed record file.
Objective: write 10 fixed-size records (each id + score, big-endian) to one file, then use fseek to read back only record index 6 without reading the others. Requirements: compute the byte offset as index * record_size; validate the index against the file size first. Hint: fseek(f, (long)6 * 8, SEEK_SET). Concepts: random access, fseek/ftell, offset validation.
Challenge — Versioned format with a header.
Objective: design a file that begins with a header: a 4-byte magic number (e.g. the ASCII bytes for "REC1"), a 1-byte version, and a 4-byte big-endian count, followed by that many records. Requirements: on read, reject the file if the magic does not match or the count is implausibly large (bound it), and handle a truncated file (short read) gracefully without using uninitialized data. Constraints: all multi-byte fields big-endian; no raw struct dumps. Concepts: format design, magic/version, validation, defensive reads, endianness, fseek.
"rb"/"wb" (the b matters on Windows).fwrite/fread copy bytes and return the element count actually transferred — always check it; a short read leaves your data uninitialized (undefined behaviour to use).fwrite(&obj, sizeof obj, 1, f) is fine for same-machine scratch data but not portable, because of two traps:uint32_t.fseek/ftell for random access to fixed-size records; validate any offset, count, or length read from a file before trusting it.fopen with fclose and check fclose on write streams. When debugging, inspect the actual bytes with xxd/od.