Бинарный файл — файл, в который программа пишет байты как есть, без преобразования в текст: double со значением 120.0 ложится на диск восемью байтами машинного представления, а не символами «120». В C с такими файлами работают функции <stdio.h>: fopen с режимом "rb"/"wb", fwrite, fread, fseek и ftell.
Содержание
- Текстовый и бинарный режим: в чем разница
- Минимальный полный пример: записать и прочитать массив структур
- Разбор: fopen, fwrite, fread, fclose
- Прямой доступ: fseek и ftell
- Типичная ошибка: цикл while (!feof(f))
- Выравнивание и порядок байт: почему struct на диск — не формат
- Чужой файл: не доверять данным из него
- Бинарные файлы в C++ и C
- Выводы
- Где применяется / связь с практикой
- FAQ
Примеры проверены 23.09.2026 на Apple clang 21 (macOS, arm64) и gcc 13.3 (Ubuntu 24.04, arm64, с -fsanitize=address,undefined), -std=c11 -Wall -Wextra.
Не путайте файл (данные на диске), поток (FILE * — объект библиотеки со своим буфером и позицией) и режим открытия (строка вроде "rb").
Текстовый и бинарный режим: в чем разница
Разница не в файле, а в потоке: в текстовом режиме реализация может преобразовывать концы строк, в бинарном байты идут без изменений.
| Платформа | Текстовый режим ("r", "w") |
Бинарный режим ("rb", "wb") |
|---|---|---|
| Linux, macOS (POSIX) | байты не меняются, буква b ни на что не влияет |
байты не меняются |
| Windows (CRT Microsoft) | \n при записи становится \r\n, при чтении обратно; байт 0x1A при чтении может считаться концом файла |
байты не меняются |
Поэтому для бинарных данных пишите b всегда: на Linux код без b работает, но на Windows файл испортят лишние байты 0x0D.
fgets и fscanf для бинарных данных не подходят: fgets останавливается на байте 0x0A (код \n), а нулевой байт внутри данных обрывает строковые функции вроде strlen и printf("%s").
Минимальный полный пример: записать и прочитать массив структур
Программа пишет три записи в records.bin и читает их обратно по одной.
#include <stdio.h>
#include <stdlib.h>
struct record { int id; double price; };
int main(void)
{
struct record out[3] = {{1, 9.5}, {2, 120.0}, {3, 0.25}};
FILE *f = fopen("records.bin", "wb");
if (f == NULL) { perror("fopen records.bin"); return EXIT_FAILURE; }
if (fwrite(out, sizeof out[0], 3, f) != 3) {
perror("fwrite");
fclose(f);
return EXIT_FAILURE;
}
if (fclose(f) != 0) { perror("fclose"); return EXIT_FAILURE; } /* здесь буфер уходит в ОС */
f = fopen("records.bin", "rb");
if (f == NULL) { perror("fopen records.bin"); return EXIT_FAILURE; }
struct record r;
while (fread(&r, sizeof r, 1, f) == 1)
printf("id=%d price=%.2f\n", r.id, r.price);
if (ferror(f))
perror("fread");
fclose(f);
printf("sizeof(struct record) = %zu\n", sizeof(struct record));
return EXIT_SUCCESS;
}
Сборка и запуск: cc -std=c11 -Wall -Wextra -o records records.c && ./records. В консоли появятся три записи и размер структуры:
id=1 price=9.50
id=2 price=120.00
id=3 price=0.25
sizeof(struct record) = 16
Файл records.bin занимает 48 байт: три записи по 16.
Разбор: fopen, fwrite, fread, fclose
fopen(path, mode) возвращает FILE * или NULL (причину печатает perror). Бинарные режимы:
| Режим | Файл не существует | Файл существует | Что можно |
|---|---|---|---|
"rb" |
ошибка | читается с начала | только чтение |
"wb" |
создается | обрезается до нуля | только запись |
"ab" |
создается | сохраняется | запись только в конец, даже после fseek |
"r+b" |
ошибка | сохраняется | чтение и запись |
"w+b" |
создается | обрезается до нуля | чтение и запись |
"wbx" (C11) |
создается | ошибка, файл не трогается | запись |
В режимах с + стандарт C требует разделять операции: после записи перед чтением — fflush или fseek; после чтения перед записью — fseek, rewind или fsetpos (кроме случая, когда чтение дошло до конца файла); fflush для этого не годится.
fwrite(ptr, size, n, f) пишет n элементов по size байт и возвращает число записанных элементов, а не байт; меньше n — ошибка записи.
fread(ptr, size, n, f) возвращает число прочитанных элементов; меньше n — конец файла или ошибка, их различают feof(f) и ferror(f) после неудачного чтения.
fclose(f) тоже может вернуть ошибку: данные из буфера библиотеки часто уходят в ОС именно при закрытии, поэтому результат проверяют. Физическую запись на диск fclose не гарантирует — для этого в POSIX есть fsync.
Прямой доступ: fseek и ftell
При записях фиксированного размера к записи номер i можно перейти сразу: смещение = индекс x размер записи. Пример читает records.bin из первого раздела.
#include <stdio.h>
#include <stdlib.h>
struct record { int id; double price; };
int main(void)
{
FILE *f = fopen("records.bin", "rb");
if (f == NULL) { perror("fopen records.bin"); return EXIT_FAILURE; }
struct record r;
long size = -1, index = 2; /* индекс 2 = третья запись */
if (fseek(f, 0L, SEEK_END) == 0) /* размер: встать в конец */
size = ftell(f);
long count = size < 0 ? 0 : size / (long)sizeof r;
printf("bytes=%ld records=%ld\n", size, count);
if (index >= count || fseek(f, index * (long)sizeof r, SEEK_SET) != 0
|| fread(&r, sizeof r, 1, f) != 1) {
fprintf(stderr, "no record %ld\n", index);
fclose(f);
return EXIT_FAILURE;
}
printf("record[%ld]: id=%d price=%.2f\n", index, r.id, r.price);
fclose(f);
return EXIT_SUCCESS;
}
bytes=48 records=3
record[2]: id=3 price=0.25
Третий аргумент fseek — точка отсчета: SEEK_SET (начало), SEEK_CUR (текущая позиция), SEEK_END (конец). Смещение — в байтах, поэтому индекс умножают на sizeof.
Границы приема. ftell возвращает long, а в 64-битной Windows он 32-битный: для файлов больше 2 ГБ нужны _fseeki64/_ftelli64, в POSIX — fseeko/ftello (на 32-битном Linux еще и -D_FILE_OFFSET_BITS=64). Стандарт C не обязывает бинарный поток осмысленно поддерживать SEEK_END; на Linux, macOS и Windows это работает, но надежнее узнать размер через stat (POSIX) или _stat64 (Windows).
Типичная ошибка: цикл while (!feof(f))
Неверный цикл — проверка конца файла стоит до чтения:
/* НЕВЕРНО: замените этим цикл чтения в первом примере */
while (!feof(f)) { /* проверка ДО чтения */
fread(&r, sizeof r, 1, f);
printf("id=%d price=%.2f\n", r.id, r.price);
}
Фактический результат — последняя запись напечатана дважды:
id=1 price=9.50
id=2 price=120.00
id=3 price=0.25
id=3 price=0.25
sizeof(struct record) = 16
Флаг конца файла ставится только после попытки прочитать за концом. Четвертый fread вернул 0, r осталась от прошлой итерации (стандарт этого не гарантирует), и printf вывел ее повторно; gcc в Ubuntu с -O2 предупреждает ignoring return value of 'fread'. Исправление — условие на результат чтения: while (fread(&r, sizeof r, 1, f) == 1), как в первом примере.
Выравнивание и порядок байт: почему struct на диск — не формат
В первом примере полезных данных 12 байт (int 4 + double 8), а sizeof дал 16 (x86-64 и ARM64; на 32-битном x86 Linux было бы 12). Компилятор вставил 4 байта выравнивания (padding) после id, чтобы double лежал по адресу, кратному 8. Содержимое этих байт стандартом не определено.
Запись структуры целиком работает, пока файл читает та же программа на той же платформе. Между платформами могут не совпасть ширина int и long, выравнивание (а с ним sizeof) и порядок байт: на x86-64 и ARM64 младший байт идет первым (little-endian), в сетевых протоколах и на платформах вроде IBM Z — старший (big-endian). Указатель в файле бессмыслен после перезапуска программы.
Переносимый подход — описать формат по байтам и собирать значения вручную типами фиксированной ширины из <stdint.h>. Формат PRC1: сигнатура из 4 байт, число записей, записи по 8 байт; числа в little-endian, цена — целым числом копеек, чтобы не зависеть от формата double.
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Формат: "PRC1" | count (u32 LE) | count x { id (u32 LE), cents (u32 LE) } */
static void put_u32le(unsigned char *p, uint32_t v)
{
for (int i = 0; i < 4; i++) p[i] = (v >> (8 * i)) & 0xFF;
}
static uint32_t get_u32le(const unsigned char *p)
{
return (uint32_t)p[0] | (uint32_t)p[1] << 8
| (uint32_t)p[2] << 16 | (uint32_t)p[3] << 24;
}
static int save(const char *path, const uint32_t ids[], const uint32_t cents[], uint32_t n)
{
FILE *f = fopen(path, "wb");
if (f == NULL) { perror(path); return -1; }
unsigned char buf[8];
memcpy(buf, "PRC1", 4);
put_u32le(buf + 4, n);
int ok = fwrite(buf, 1, 8, f) == 8;
for (uint32_t i = 0; ok && i < n; i++) {
put_u32le(buf, ids[i]);
put_u32le(buf + 4, cents[i]);
ok = fwrite(buf, 1, 8, f) == 8;
}
if (fclose(f) != 0 || !ok) { fprintf(stderr, "%s: write failed\n", path); return -1; }
return 0;
}
static int load(const char *path)
{
FILE *f = fopen(path, "rb");
if (f == NULL) { perror(path); return -1; }
unsigned char buf[8];
int rc = -1;
if (fread(buf, 1, 8, f) != 8 || memcmp(buf, "PRC1", 4) != 0) {
fprintf(stderr, "%s: not a PRC1 file\n", path);
goto done;
}
uint32_t n = get_u32le(buf + 4);
if (n > 100000) { /* не доверяем длине из файла */
fprintf(stderr, "%s: count %u is too large\n", path, (unsigned)n);
goto done;
}
for (uint32_t i = 0; i < n; i++) {
if (fread(buf, 1, 8, f) != 8) {
fprintf(stderr, "%s: truncated at record %u\n", path, (unsigned)i);
goto done;
}
uint32_t cents = get_u32le(buf + 4);
printf("id=%u price=%u.%02u\n", (unsigned)get_u32le(buf),
(unsigned)(cents / 100), (unsigned)(cents % 100));
}
rc = 0;
done:
fclose(f);
return rc;
}
int main(int argc, char *argv[])
{
if (argc > 1) /* ./portable file.bin - только чтение */
return load(argv[1]) == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
uint32_t ids[] = {1, 2, 3}, cents[] = {950, 12000, 25};
return save("prices.bin", ids, cents, 3) == 0 && load("prices.bin") == 0
? EXIT_SUCCESS : EXIT_FAILURE;
}
Запуск без аргументов пишет и читает prices.bin; xxd показывает, что файл совпадает с форматом байт в байт — 32 байта на любой платформе:
id=1 price=9.50
id=2 price=120.00
id=3 price=0.25
00000000: 5052 4331 0300 0000 0100 0000 b603 0000 PRC1............
00000010: 0200 0000 e02e 0000 0300 0000 1900 0000 ................
Чужой файл: не доверять данным из него
Файл может прийти от пользователя или из сети, может оказаться обрезанным. Число записей в заголовке — такие же входные данные: выделив память под count без проверки, программа упадет или исчерпает память на испорченном файле. Поэтому load проверяет сигнатуру, ограничивает count и сверяет каждый fread. Ответы на испорченные входы (cut.bin — первые 20 байт prices.bin, evil.bin — заголовок с count = 0x7FFFFFFF):
$ ./portable cut.bin > /dev/null
cut.bin: truncated at record 1
$ ./portable evil.bin
evil.bin: count 2147483647 is too large
$ ./portable records.bin
records.bin: not a PRC1 file
$ ./portable nope.bin
nope.bin: No such file or directory
Во всех случаях код возврата 1. Это базовая защита, а не полная: реальные форматы добавляют версию, контрольную сумму, лимит на размер файла и тесты на случайных входах (fuzzing).
Бинарные файлы в C++ и C
Приемы те же, отличается API.
| Задача | C | C++ | C# |
|---|---|---|---|
| Открыть для чтения | fopen(p, "rb") |
std::ifstream in(p, std::ios::binary) |
File.OpenRead(p) |
| Записать байты | fwrite |
out.write(ptr, n) |
BinaryWriter.Write, Stream.Write |
| Прочитать байты | fread |
in.read(ptr, n), затем in.gcount() |
BinaryReader, Stream.Read |
В C# BinaryWriter пишет числа в little-endian на любой платформе, для явного порядка есть BinaryPrimitives. В C++ запись структуры через reinterpret_cast несет те же риски, что и в C.
Выводы
- Бинарные данные открывают в режимах с
b("rb","wb"), иначе на Windows они искажаются. fwriteиfreadвозвращают число элементов, а не байт; цикл чтения строят на этом результате, а не наfeof.fseekсо смещениеминдекс * sizeofдает прямой доступ к записи; для файлов больше 2 ГБ —fseekoили_fseeki64.- Структура целиком годится для одной платформы; для обмена формат описывают по байтам с явным порядком, а длины из чужого файла проверяют.
Где применяется / связь с практикой
Освойте тему на практике
Бинарные файлы — основа форматов изображений и архивов, журналов баз данных, сохранений игр и прошивок: везде, где важны компактность и прямой доступ. Разобраться глубже, от стандартной библиотеки C до системных вызовов Linux, поможет курс Otus по системному программированию. Попробовать формат занятий бесплатно можно на открытых уроках Otus.
FAQ
Чем fread отличается от системного вызова read?
fread — функция стандартной библиотеки C с буфером внутри программы, переносима между ОС. read — вызов POSIX: работает с файловым дескриптором, без буфера библиотеки.
Как дописать запись в конец существующего файла?
Открыть его в режиме "ab": запись пойдет в конец даже после fseek. Если нужно и читать, и дописывать — "a+b".



