Embedded HandbookПриложения
Все разделы › IX. Язык C: от основ до продвинутого
STM32F103, STM32F407, STM32G431 · уровень базовый · 12 мин чтения
Открыть в интерактивном справочнике

70. Структуры и файлы

Структура собирает несколько значений разного типа под одним именем: запись с датчика, настройки порта, кадр протокола. Это главный способ описывать данные в C — и на микроконтроллере им же описаны регистры периферии (раздел 3.3).

У структур есть свойство, которое на компьютере можно не замечать, а на камне нельзя: компилятор вставляет между полями пустые байты. От порядка полей зависит размер, а от упаковки — скорость и даже возможность прочитать поле. Раздел показывает это на настоящем машинном коде и заканчивается файлами — где эти самые пустые байты тоже ложатся на диск.

Как это называется в документации

Оригинал Перевод
structure, member, field структура, поле
designated initializer инициализация по имени поля
alignment, padding выравнивание, выравнивающие байты
packed structure упакованная структура
unaligned access невыровненное обращение
union объединение
type punning прочтение одних байтов как другого типа
bit-field битовое поле
serialization сериализация, превращение в байты
stream, file position поток, позиция в файле
text, binary mode текстовый, двоичный режим
end of file (EOF) конец файла

70.1 Структура: несколько полей под одним именем

struct sensor {
    uint8_t  day;
    uint8_t  month;
    uint16_t year;
    int8_t   t;         /* температура, градусы */
};

struct sensor rec = { .day = 10, .month = 9, .year = 2026, .t = -5 };
rec.t = 3;              /* обращение к полю — точка */

Инициализация по имени поля — .year = 2026 — не зависит от порядка полей и не ломается, когда в структуру добавят новое. Незаданные поля, как и в массиве, становятся нулями (раздел 68.2).

Через указатель к полю обращаются стрелкой: p->t — это то же, что (*p).t. Скобки нужны, потому что точка сильнее звёздочки.

typedef даёт структуре короткое имя: typedef struct { … } sensor_t; — и дальше пишут sensor_t rec; без слова struct.

Присваивание копирует все поля, включая массивы внутри структуры: после s2 = s1 у s2 та же строка в поле имени, что и у s1. На Cortex-M3 копирование структуры в 16 байт — это пачечное чтение и запись ldm/stm, несколько команд.

Сравнивать структуры через == нельзя — это ошибка компиляции. Сравнивают по полям. И не через memcmp — почему, в следующем подразделе.


70.2 Выравнивание и дыры

Процессору удобнее читать 32-разрядное число с адреса, кратного 4. Поэтому компилятор выравнивает поля, вставляя между ними пустые байты. Одни и те же четыре поля в разном порядке:

struct str1 { uint32_t u; uint8_t c1; int32_t i; uint8_t c2; };  /* 16 байт */
struct str2 { uint32_t u; int32_t i; uint8_t c1; uint8_t c2; };  /* 12 байт */

Полезных данных в обеих — 10 байт. В первой после c1 три пустых байта, чтобы i встало на смещение 8, и ещё три после c2, чтобы массив таких структур не сбил выравнивание следующего элемента. Во второй мелкие поля стоят рядом, и потеря — всего два байта в конце.

Правило: поля располагают от больших к меньшим. Для массива из тысячи таких записей это 4000 байт ОЗУ.

Запись датчика из 70.1 занимает 6 байт при пяти байтах полей: год выровнен на чётный адрес, а в конце — один пустой байт.

Пустые байты содержат мусор — то, что было в памяти раньше. Отсюда ловушка: две структуры с равными полями memcmp может признать разными, потому что сравнивает и дыры. Проверено: заполнили память под двумя структурами разным мусором, записали одинаковые значения во все поля — поля равны, а memcmp возвращает «не равны».

Структуры str1 и str2 в памяти: поля, дыры и мусор в них


70.3 Упакованные структуры

Иногда дыры недопустимы: формат пакета или заголовка файла задан побайтно. Тогда структуру упаковывают:

struct __attribute__((packed)) str3 { uint32_t u; uint8_t c1; int32_t i; uint8_t c2; };
/* 10 байт, поле i — по смещению 5 */

Поле i оказалось по нечётному адресу. Что это стоит — зависит от ядра:

Ядро Чтение p->i из упакованной структуры
Cortex-M3, M4 одна команда ldr: невыровненное чтение одиночными ldr и ldrh разрешено
Cortex-M0 (серия F0) 11 команд: четыре чтения байтов и сборка числа
любое, поле выровнено одна команда

На Cortex-M3 и M4 упаковка почти бесплатна. На Cortex-M0 каждое обращение к невыровненному полю разворачивается в побайтную сборку — и это ещё удачный случай: если взять адрес такого поля и передать его в функцию, ожидающую обычный указатель, та прочитает его одной командой и на M0 получит отказ процессора (раздел 35.2). Об этом компилятор предупреждает без ключей: адрес упакованного поля «may result in an unaligned pointer value».

На M3 и M4 такой отказ тоже бывает. Невыровненный адрес там прощают только одиночным ldr, ldrh, str и strh; команды пар и пачек (ldrd, ldm) и вещественная vldr требуют выровненного адреса всегда. Для упакованной struct { uint8_t tag; float t; int64_t stamp; } armclang под M4 читает r->t и r->stamp обычными ldr по смещениям 1, 5 и 9, а те же поля через const float * и const int64_t * — командами vldr и ldrd. Адрес упакованного поля float, double или int64_t, переданный в обычную функцию, роняет программу и на F4.

Практика: упакованные структуры используют для описания формата, а работают с данными в обычных. Или вовсе собирают поля из байтов вручную — это заодно решает вопрос порядка байтов (раздел 3а.8).


70.4 Структура в функцию

Структуру можно передать по значению — тогда функция получит копию:

int by_value(struct str1 s) { return s.c2; }

Структура в 16 байт на Cortex-M целиком помещается в четыре регистра аргументов r0–r3 (раздел 66.3): поле c2 оказалось в r3, и функция свелась к одной команде. Структура побольше копируется в стек при каждом вызове.

Поэтому обычная практика — передавать адрес, а если функция не должна менять данные, — адрес на константу:

void log_record(const struct sensor *rec);

Это четыре байта вместо копии, а const гарантирует, что функция ничего не испортит (раздел 67.6).


70.5 Объединения

В объединении все поля начинаются с одного адреса, и размер его — размер самого большого поля:

union intbytes {
    uint32_t number;
    float    real;
    uint8_t  bytes[4];
};

Записали одно поле — прочитали другое. number = 0x12345678 побайтно выглядит как 78 56 34 12 — младший байт вперёд (раздел 3а.8). А записав в real число 3,14, в number увидим 0x4048F5C3 — его битовый образ (раздел 69.1).

В C такое прочтение одних байтов как другого типа через объединение разрешено — в отличие от приведения указателей (раздел 67.9).

Второе применение — сообщение с вариантами. Поле-признак говорит, какой вариант лежит в объединении:

struct msg {
    uint8_t kind;               /* что внутри */
    union {
        struct { uint16_t ch; uint16_t value; } adc;
        struct { uint8_t code; } error;
    } u;
};

Размер — по самому большому варианту, а не по сумме. Так передают сообщения разных типов через одну очередь (раздел 27).


70.6 Битовые поля

Поле можно объявить шириной в несколько бит:

struct point {
    unsigned char x : 5;        /* 0 … 31 */
    unsigned char y : 3;        /* 0 … 7  */
};                              /* вся структура — 1 байт */

И на компьютере, и в armclang для ARM x ложится в младшие пять бит байта: при y = 5 байт равен 0xA0, а после x = 2 — 0xA2.

Но стандарт этого не гарантирует: порядок бит, ширина обращения к памяти и выравнивание полей оставлены компилятору. Два компилятора, которые здесь совпали, могли и не совпасть. Отсюда правило из раздела 3а.7: битовые поля хороши для своих флагов, где раскладка никому не видна. Для регистров периферии и для форматов обмена — только маски и сдвиги. И ещё одно: запись в одно поле — это чтение всего слова, правка и запись назад. Если соседнее поле того же слова меняет прерывание, его изменение может пропасть, поэтому флаги, которые пишут и основная программа, и прерывание, держат в отдельных переменных (раздел 3.4).

Одни и те же четыре байта под тремя именами — и восемь бит под двумя полями


70.7 Записи: пример с датчиком

Типичная задача — хранить журнал измерений и упорядочивать его. Записи держат в массиве структур, а для сортировки по дате удобно собрать из полей один ключ:

uint32_t date_key(const struct sensor *r)
{
    return (uint32_t)r->year << 16 | (uint32_t)r->month << 8 | r->day;
}

Для 10 сентября 2026 года ключ — 0x07EA090A: год в старших разрядах, день в младших. Сравнение ключей — это сравнение дат, одной операцией. Сортировка массива структур — те же алгоритмы, что для чисел (раздел 68.6), только обмен меняет местами целые записи.


70.8 Файлы

Стандартная библиотека работает с файлами через указатель на FILE:

FILE *f = fopen("in.txt", "r");
if (f == NULL) { /* файла нет или нет доступа */ }
...
fclose(f);
Режим Что делает
"r" чтение существующего
"w" запись; существующий файл обнуляется
"a" дописывание в конец
"r+", "w+", "a+" чтение и запись
с буквой b — "rb", "wb" двоичный режим

fopen возвращает NULL, если открыть не удалось, — проверять обязательно. fclose дописывает на диск то, что ещё лежит в буфере.

Текстовый файл читают и пишут форматными функциями:

int sum = 0, n;
while (fscanf(f, "%d", &n) == 1)    /* пока число прочитано */
    sum += n;

Для файла с числами 10 20 30 и -5 7 сумма — 62. Условие цикла — результат fscanf, как и у sscanf (раздел 68.11).

Двоичный файл хранит байты ровно как в памяти:

struct sensor log[3] = { … };
fwrite(log, sizeof log[0], 3, f);   /* вернёт 3 — число записанных элементов */

Три записи по 6 байт дают файл в 18 байт, и fread читает их обратно без изменений. Размер файла узнают так: fseek(f, 0, SEEK_END) — перейти в конец, ftell(f) — позиция в байтах.

Ловушка с концом файла. fgetc возвращает int: байт от 0 до 255 или EOF, то есть −1. Переменная для него обязана быть int:

Тип переменной Файл из байтов 41 FF 42
int c прочитаны все 3 байта
signed char c чтение оборвалось на FF — он совпал с −1
char c на ARM не кончится никогда: char беззнаковый, 255 не равно −1

Последняя строка — буквально: сравнение char с EOF на ARM компилятор превращает в «всегда ложь» и предупреждает об этом без ключей.


70.9 Файлы на микроконтроллере

У камня файловой системы нет — пока её не добавишь. Для карты памяти или внешней Flash это делает FatFS (раздел 42), и её функции повторяют стандартные: f_open, f_read, f_write, f_close, f_printf (раздел 42.5). Всё сказанное о режимах, проверке результата и закрытии файла переносится один к одному — а о том, что происходит при отключении питания посреди записи, отдельно в разделе 42.6.

Главный вопрос на камне — формат данных. Записать массив структур одним fwrite удобно, но в файл уйдут и пустые байты, и родной порядок байтов, и раскладка конкретного компилятора. Прочитать такой файл на компьютере другой программой — значит повторить все эти детали. Поэтому для обмена записи сериализуют: пишут поля по одному, в заданном порядке байтов, без дыр. Кода чуть больше, зато формат описан, а не подразумевается. Если формат всё же описан структурой, его закрепляют проверкой при компиляции: _Static_assert(sizeof(struct str3) == 10, "формат пакета"); и _Static_assert(offsetof(struct str3, i) == 5, "смещение i");. При другой раскладке сборка остановится (раздел 74.9).


70.10 Грабли

Структура оказалась больше суммы полей. Выравнивание; переставьте поля от больших к меньшим.

memcmp считает равные структуры разными. Сравниваются и пустые байты с мусором.

Программа падает при чтении поля пакета. Невыровненное обращение через указатель на поле упакованной структуры: на F0 — любого, на F4 — float, double или 64-битного.

Структура по значению тормозит вызов. Большая структура копируется в стек; передавайте адрес.

Битовые поля в регистре периферии легли не туда. Раскладку выбирает компилятор; для регистров — маски.

fopen вернул NULL, программа упала дальше. Результат не проверен.

Файл открыли на запись — данные пропали. Режим "w" обнуляет существующий файл; для дописывания — "a".

Данные не записались. Не вызван fclose, и буфер остался в памяти.

Чтение файла не кончается. Результат fgetc положен в char.

Чтение обрывается на середине двоичного файла. Результат fgetc в signed char: байт 0xFF принят за конец.

Файл, записанный на камне, на компьютере читается с ошибками. Записаны структуры как есть — с дырами и родным порядком байтов.


70.11 Итоги

Дальше: раздел 71 «Многомодульные программы, препроцессор, аргументы командной строки».

Открыть в интерактивном справочнике