Binary files
A binary file stores the bytes of objects instead of their printed form. One fwrite call moves a whole record, and fread brings it back into a struct of the same type. The format is the memory layout from lesson 36, including its padding and its byte order, which is the catch.
Writing records
A record is a struct with a fixed-size name. The mode "wb" opens for writing in binary, which matters on systems that translate line endings. fwrite takes the address, the size of one element, the count and the stream, and returns how many complete elements it wrote.
#include <stdio.h>
struct Record {
int id;
char name[16];
};
int main(void)
{
struct Record records[2] = {{1, "alpha"}, {2, "beta"}};
FILE *file = fopen("records.bin", "wb");
size_t written;
if (file == NULL) {
return 1;
}
written = fwrite(records, sizeof records[0], 2, file);
fclose(file);
printf("written: %zu\n", written);
return 0;
}
gcc -Wall -Wextra write.c -o write
./write
written: 2
The file is small: each record is twenty bytes on this machine, four for the int and sixteen for the name. The count returned is in elements, not bytes, so comparing it with two proves that both records went out. The buffer is flushed by fclose.
Reading them back
fread mirrors fwrite and returns the number of complete elements read. A loop that stops when the result is not one reads every record without knowing the count in advance. The struct must have exactly the layout that was written, or the fields land in the wrong bytes.
#include <stdio.h>
struct Record {
int id;
char name[16];
};
int main(void)
{
struct Record record;
FILE *file = fopen("records.bin", "rb");
size_t got;
if (file == NULL) {
return 1;
}
while ((got = fread(&record, sizeof record, 1, file)) == 1) {
printf("%d %s\n", record.id, record.name);
}
fclose(file);
return 0;
}
gcc -Wall -Wextra read.c -o read
./read
1 alpha
2 beta
The assignment inside the condition is deliberate and parenthesised, so the compiler does not warn about it. The name in the struct is a terminated string only because the initialiser zeroed the rest of the sixteen bytes; a name of exactly sixteen characters would have no terminator.
Seeking to a record
Because every record has the same size, the position of record n is n times the size. fseek moves the read position, ftell reports it, and the next fread starts there. This is the random access that a text file with variable-length lines cannot give.
#include <stdio.h>
struct Record {
int id;
char name[16];
};
int main(void)
{
struct Record record;
FILE *file = fopen("records.bin", "rb");
if (file == NULL) {
return 1;
}
if (fseek(file, (long) sizeof record, SEEK_SET) != 0) {
fclose(file);
return 1;
}
if (fread(&record, sizeof record, 1, file) == 1) {
printf("second: %d %s\n", record.id, record.name);
}
printf("position: %ld\n", ftell(file));
fclose(file);
return 0;
}
gcc -Wall -Wextra seek.c -o seek
./seek
second: 2 beta
position: 40
The cast (long) is not decoration: the offset argument is a long while sizeof is unsigned, and the compiler warns about the mixed types under -Wconversion, as the last section shows. The position after reading the second record is 40, the end of the file.
Bytes and endianness
A binary file has no types: it has bytes. Writing the integer 0x01020304 and reading the file as four bytes shows the order the machine uses. On x86_64 the least significant byte comes first, so the file starts with 04.
#include <stdint.h>
#include <stdio.h>
int main(void)
{
uint32_t value = 0x01020304u;
unsigned char bytes[4] = {0};
FILE *file = fopen("word.bin", "wb");
if (file == NULL) {
return 1;
}
fwrite(&value, sizeof value, 1, file);
fclose(file);
file = fopen("word.bin", "rb");
if (file == NULL) {
return 1;
}
if (fread(bytes, 1, sizeof bytes, file) == 4) {
printf("%02X %02X %02X %02X\n", bytes[0], bytes[1], bytes[2], bytes[3]);
}
fclose(file);
return 0;
}
gcc -Wall -Wextra endian.c -o endian
./endian
04 03 02 01
A file meant for another machine writes each field in a defined byte order, usually big-endian, with shifts and masks from lesson 11. Writing a struct directly also writes the padding, which a different compiler may place differently.
A signed offset
The offset argument of fseek is a long, and an offset computed from size_t is unsigned. The conversion is harmless for a small file and silently wrong for an offset above the signed range, and -Wconversion reports the mixed arithmetic before the file is opened:
#include <stdio.h>
struct Record {
int id;
char name[16];
};
int main(void)
{
struct Record record;
size_t index = 1;
FILE *file = fopen("records.bin", "rb");
if (file == NULL) {
return 1;
}
fseek(file, sizeof record * index, SEEK_SET);
if (fread(&record, sizeof record, 1, file) == 1) {
printf("%d\n", record.id);
}
fclose(file);
return 0;
}
gcc -Wall -Wextra -Wconversion seekwarn.c -o seekwarn
seekwarn.c: In function 'main':
seekwarn.c:17:31: warning: conversion to 'long int' from 'long unsigned int' may change the sign of the result [-Wsign-conversion]
17 | fseek(file, sizeof record * index, SEEK_SET);
| ~~~~~~~~~~~~~~^~~~~~~
The program is not run here: the warning is the lesson, and the fix is the cast to long used in the seek section. A file larger than two gigabytes is where the difference becomes a real failure on a 32-bit long.
Exercises
- Add a third record with a name of fifteen characters and read the file back; check that the name still has a terminator.
- Write a function that updates record n in place with fseek and fwrite.
- Store the record count in the first four bytes of the file and use it to allocate the array before reading.
Questions
A new set of questions in a new order every time. Choose one answer for each, then check.
-
What does the count returned by
fwritemeasure?Answer
Complete elements written, not bytes
fwritetakes an element size and a count, and the return value counts the whole elements that went out. -
How does the read loop know it reached the end of the file?
Answer
freadreturns a count below oneA partial or empty read returns zero for one requested element, so the loop stops at the end of the records.
-
Where does record n start in a binary file of fixed records?
Answer
At n times the size of one record
Every record has the same size, so the byte offset is the index multiplied by that size.
-
Why can a struct written on one machine read wrongly on another?
Answer
Padding and byte order may differ
A binary file holds bytes, and the layout of a struct depends on the compiler and on the machine's endianness.
-
What does this program report for two records?
#include <stdio.h> struct R{int id;char name[16];}; int main(void){struct R r[2]={{1,"alpha"},{2,"beta"}};FILE*f=fopen("/tmp/records.bin","wb");size_t n=fwrite(r,sizeof r[0],2,f);fclose(f);printf("%zu\n",n);return 0;}Answer
2
The count is in elements, and both records were written, so the return value is 2.
-
What does this program print?
#include <stdio.h> struct R{int id;char name[16];}; int main(void){struct R r[2]={{1,"alpha"},{2,"beta"}};FILE*f=fopen("/tmp/records.bin","wb");fwrite(r,sizeof r[0],2,f);fclose(f);f=fopen("/tmp/records.bin","rb");struct R x;while(fread(&x,sizeof x,1,f)==1)printf("%d %s\n",x.id,x.name);fclose(f);return 0;}Answer
1 alpha / 2 beta
The records come back in the order they were written, with their ids and names intact.
-
What does this program print?
#include <stdio.h> struct R{int id;char name[16];}; int main(void){struct R r[2]={{1,"alpha"},{2,"beta"}};FILE*f=fopen("/tmp/records.bin","wb");fwrite(r,sizeof r[0],2,f);fclose(f);f=fopen("/tmp/records.bin","rb");fseek(f,(long)sizeof(struct R),SEEK_SET);struct R x;fread(&x,sizeof x,1,f);printf("second: %d %s\n",x.id,x.name);printf("position: %ld\n",ftell(f));fclose(f);return 0;}Answer
second: 2 beta / position: 40
The offset skips one twenty-byte record, and the read leaves the position at the end, 40.
-
What bytes does this program print for
0x01020304?#include <stdint.h> #include <stdio.h> int main(void){uint32_t v=0x01020304u;unsigned char b[4];FILE*f=fopen("/tmp/word.bin","wb");fwrite(&v,sizeof v,1,f);fclose(f);f=fopen("/tmp/word.bin","rb");fread(b,1,4,f);printf("%02X %02X %02X %02X\n",b[0],b[1],b[2],b[3]);fclose(f);return 0;}Answer
04 03 02 01
The machine is little-endian, so the least significant byte is stored at the lowest address.
-
What does GCC warn about for
fseek(file, sizeof record * index, SEEK_SET)?Answer
A sign conversion from unsigned to long
sizeofis unsigned and the offset is along, so-Wconversionreports the possible sign change. -
What happens when a name fills all sixteen bytes of the record field?
Answer
There is no zero terminator for
%sThe field has room for sixteen characters and no terminator, so printing it as a string reads past the field.
The code on this page was run with gcc (Alpine 15.2.0) 15.2.0 on Alpine Linux 3.24.3 (x86_64) in a Docker container.
Article author: Arthur Isaev