Паяльная станция на Uno жила с конфигом из пяти полей: режим, уставка, гистерезис, сон, калибровка. Запись была устроена скучно и надёжно: собрал структуру, EEPROM.put по адресу 10 — и живи. Пока я не стал чистить карту памяти и не заметил арифметику: по наивному счёту поля занимали семь байтов, а put уносил девять. Два байта в середине блока принадлежали никому — их не заполнял ни один оператор присваивания в моём скетче, но ячейки они получали при каждой записи.
Вторая находка вышла дороже. Контрольная сумма блока сходилась в момент записи и расходилась после перезагрузки — при том, что поля читались правильные. Причина оказалась в тех самых ничьих байтах: компилятор кладёт между полями заполнители, в RAM там лежит мусор со стека, и этот мусор честно уезжает в EEPROM вместе с конфигом. Один и тот же конфиг после двух включений давал два разных образа в памяти.
В этом уроке разберём, как компилятор раскладывает структуру по байтам, куда встают дырки выравнивания, чем они грозят карте памяти и контрольным суммам — и какие три лечения существуют, у каждого своя цена. В симуляторе раскладку можно будет подвигать руками: увидеть дырку глазами дороже любого абзаца. Базовые вызовы put и get с структурой мы проходили в уроке про EEPROM.put и get, раскладку конфига по адресам — в уроке про struct Config; здесь я не повторяю их, а показываю то, что живёт между полями.
Что put пишет на самом деле
У put нет волшебства: он берёт структуру в RAM и копирует в ячейки ровно sizeof байтов, один за другим. Сколько их — решает не сумма размеров полей, а компилятор, и у него свои правила: многобайтовое поле встаёт на выровненный адрес. На Uno int — два байта и живёт только на чётном смещении, на 32-битных платах четырёхбайтовые типы требуют адрес, кратный четырём. Если предыдущее поле оставило нечётный хвост, компилятор молча вставляет между ними дырку-заполнитель — байт, который не принадлежит ни одному полю.
Минимальный пример: struct с byte rezhim и int porog. По наивной арифметике — три байта. Реальный sizeof на Uno — четыре: rezhim занимает смещение ноль, смещение один — дырка, porog встаёт на смещение два, потому что чётное. Добавьте int gus — и структура съест шесть байтов при наивных пяти. Каждый такой байт при записи проходит через ячейку с ценой около 3,3 мс и циклом ресурса; экономия для ленивых — положиться на то, что put внутри работает через update и пропускает неизменившиеся байты, о чём мы считали бюджет в уроке про выборочную запись.
Размер и раскладку нельзя угадывать — их нужно печатать. Один вызов Serial.print(sizeof(Config)) в setup даёт больше правды, чем час разглядывания объявления структуры. Типы полей и их потолки мы уже раскладывали по полкам в курсе основ; здесь важно одно: поменяли тип поля с int на long, и sizeof уехал, а с ним и вся раскладка блока.
#include <EEPROM.h> // Конфиг: byte + int + int. Наивная арифметика даёт 5 байтов,// реальный sizeof на Uno - 6: после rezhim стоит дырка выравнивания.struct Config { byte rezhim; // смещение 0 int porog; // смещение 2: int встаёт на чётный адрес int gus; // смещение 4}; // смещение 1 - дырка, sizeof = 6 const int ADRES = 10;Config cfg; void dumpBlok() { for (byte i = 0; i < sizeof(Config); i++) { Serial.print("adres "); Serial.print(ADRES + i); Serial.print(" = "); Serial.println(EEPROM.read(ADRES + i)); }} void setup() { Serial.begin(9600); Serial.print("sizeof(Config) = "); Serial.println(sizeof(Config)); // 6, а не 5 cfg.rezhim = 3; cfg.porog = 500; cfg.gus = 20; EEPROM.put(ADRES, cfg); // пишутся все 6 байтов, включая дырку dumpBlok(); // адрес 11 на свежей плате читается 255} void loop() {}Дырка, которая врёт
Сама по себе дырка безобидна: два лишних байта из тысячи. Безобидна ровно до того места, где вы решаете посчитать по блоку контрольную сумму — приём из урока про CRC блока конфига. Сумма по sizeof байтам включает заполнитель, а заполнитель в RAM — это мусор: остатки прошлых вызовов, хвост стека, что угодно. В момент записи там лежало одно, после перезагрузки и пересборки структуры — другое. Поля целы, сумма плачет, скетч честно бракует свежий конфиг и заливает дефолты — и вы неделю ищете призрака.
Моя паяльная станция именно так и жила: сумма сходилась в тот же вечер и расходилась утром. Лечение заняло одну строку, но сначала пришлось понять диагноз. С тех пор правило простое: если считаете сумму по RAM-образу структуры, дырка обязана быть детерминированной — иначе сумма не сверяет конфиг, а сверяет случайность.
Лечений три, и они не исключают друг друга. Первое: memset(&cfg, 0, sizeof cfg) перед заполнением — все заполнители становятся нулями, образ стабилен навсегда. Второе: раскладывать поля от больших к меньшим — многобайтовые встают вплотную, дырки внутри исчезают, остаётся только хвостовой добивочный байт, и его обнуляет тот же memset. Третье: атрибут packed — компилятор втискивает поля вплотную без всяких условий, sizeof падает до суммы полей; цена у него отдельная, о ней ниже.
Порядок полей — самое дешёвое, но у него есть честная граница: он убирает дырки внутри блока, а не сам принцип выравнивания. Размер структуры кратен самому строгому выравниванию среди полей, поэтому вариант int, int, byte всё равно займёт шесть байтов — байт в хвосте останется, просто он будет последним и его проще контролировать. Ставьте длинные поля первыми, короткие — хвостом, и печатайте sizeof после каждой перестановки: глазом порядок выравнивания не виден.
На ESP32 всё строже: int — четыре байта, float требует адрес, кратный четырём, и та же структура раздаётся шире. Атрибут packed там опаснее, чем на Uno: на 8-битном AVR любой доступ всё равно побайтовый, а на 32-битном Xtensa чтение четырёх байтов с нечётного адреса может уронить программу — особенно если взять указатель на поле packed-структуры и разыменовать его. Правило границы: на Uno packed почти безвреден, на ESP32 — только с пониманием. А если настройки переезжают на ESP32 целиком, честнее сменить механизм на ключи Preferences, как разобрано в уроке про NVS: там раскладкой занимается библиотека, а не ваша рука.
Правила перевозки структуры
Первое правило: sizeof — единственная константа раскладки. Границу блока на карте памяти считайте как адрес + sizeof, а не как адрес + сумма полей: карта из урока про килобайт ATmega328 терпит только одну арифметику, и это та, которую исполняет компилятор. Печатайте sizeof в setup при каждом запуске на новой плате — перенос скетча с Uno на Mega или ESP32 меняет раскладку незаметно для глаза.
Второе правило: поменялся состав полей — сменилась версия. Добавили поле в середину структуры, и все конфиги, записанные прошлой прошивкой, читаются со сдвигом: старые байты попадают в новые поля. Лечится байтом версии рядом с блоком и миграцией, как разобрано в уроке про версию структуры — выравнивание лишь добавляет этому сюжету ставок: сдвиг на дырку виден даже в дампе, а вот сдвиг на перестановку полей одинакового размера — нет.
Третье правило старое, но повторю его при случае: String внутри структуры для EEPROM запрещён — указатель на два байта вместо текста, о чём сказано ещё в уроке про put и get. И последнее: размер структуры — это ваш расход ячеек при каждой перезаписи; шесть байтов конфига при записи раз в час живут век, а шесть байтов журнала при записи раз в минуту — год. Считать такие сроки мы учились на первых уроках раздела, и ни одно из правил не отменяет sizeof.
| Лечение дырок | Что даёт | Цена |
|---|---|---|
| memset(&cfg, 0, sizeof cfg) | Заполнители всегда нулевые: дамп и CRC стабильны между включениями | Одна строка перед заполнением, нельзя забывать при правках |
| Большие поля вперёд | Дырки внутри блока исчезают, остаётся хвостовой байт | Порядок надо держать в голове при добавлении полей |
| __attribute__((packed)) | sizeof равен сумме полей: пять вместо шести | На 32-битных платах указатель на поле может уронить программу |
| CRC по образу в EEPROM | Сумма не зависит от RAM-мусора в дырке | Диапазон суммы обязан совпадать с диапазоном записи |
- 1. Сколько байтов займёт struct { byte rezhim; int porog; } на Uno?
- 2. Что EEPROM.put записывает в дырку выравнивания?
- 3. Почему контрольная сумма по RAM-байтам структуры может расходиться после перезагрузки?
- 4. Чем опасен __attribute__((packed)) на ESP32?
- 5. Конфиг не меняется, скетч вызывает EEPROM.put в том же цикле. Сколько циклов ресурса уходит на дырку?
- put копирует sizeof байтов, а не сумму полей: раскладку решает компилятор, а не ваш глаз
- int на Uno встаёт на чётное смещение: byte + int — это 4 байта и одна дырка
- Дырка при записи уносит RAM-мусор — именно она ломает контрольные суммы между включениями
- Три лечения: memset перед заполнением, большие поля вперёд, packed — у каждого своя цена
- На ESP32 packed опасен указателями, а переезд настроек честнее делать на Preferences и ключи
Частые вопросы по теме урока
- Сколько байтов займёт struct { byte rezhim; int porog; } на Uno?
- int на Uno занимает два байта и встаёт на чётное смещение: после byte остаётся дырка, sizeof равен 4. Наивная арифметика даёт 3 — она всегда врёт в сторону экономии. 4 — между полями встанет дырка выравнивания
- Что EEPROM.put записывает в дырку выравнивания?
- put копирует sizeof байтов образа из RAM, не разбирая, чей это байт. В заполнителе — мусор со стека, поэтому образ одного конфига в разных запусках различается. То, что в этот момент лежало в RAM рядом со структурой
- Почему контрольная сумма по RAM-байтам структуры может расходиться после перезагрузки?
- Поля при перезагрузке восстанавливаются точно, но заполнители собираются заново из случайного содержимого RAM. Считайте сумму по образу в EEPROM или обнуляйте структуру memset-ом. Сумма включает дырку, а мусор в ней при каждом запуске другой
- Чем опасен __attribute__((packed)) на ESP32?
- Хуже — именно указатели. На AVR доступ побайтовый и packed безвреден, на 32-битном Xtensa невыровненное чтение четырёх байтов приводит к падению. Обращайтесь к полям по имени, не через указатель. Указатель на поле с нечётного адреса при 32-битном чтении может уронить программу
- Конфиг не меняется, скетч вызывает EEPROM.put в том же цикле. Сколько циклов ресурса уходит на дырку?
- put внутри библиотеки сравнивает байты и пишет только отличившиеся — тот же механизм, что у update. Неизменная дырка не расходует ресурс, но место на карте она занимает всегда. Ноль: put работает через update и пропускает неизменившиеся байты