Порог вентилятора теплицы я держал структурой из пяти полей и был уверен, что знаю её раскладку наизусть: magic, версия, число. Потом захотелось глянуть глазами — второй скетч-просмотрщик на другой плате читал память через EEPROM.read и вместо аккуратного порога 210 показал два байта подряд: 210 и 0. Полчаса я подозревал просмотрщик, кривые адреса и карму, пока не вспомнил про little-endian. Поломки не было вовсе: число из двух байтов лежит в памяти по правилам компилятора, и правила эти стоит увидеть однажды своими глазами. С тех пор дамп я снимаю до того, как поверить чужому скетчу.
API сверху мы уже прошли: в уроке про put и get — сами вызовы и magic с версией внутри ящика, в уроке про проектирование Config — поля, резервы и цена добавления. Здесь спускаемся этажом ниже: после put в ячейках лежит ровно то, что сказал компилятор, и умение это увидеть стоит пятнадцати строк кода. Заодно закроем вопросы, на которые форум отвечает противоречиво: сколько байт занимает структура, почему int лежит задом наперёд и почему дамп с Uno не читают на ESP32. И ещё одна причина для дампа: чужие скетчи из форумов пишут в EEPROM вслепую, и полезно видеть, что именно они оставили в ваших ячейках.
План такой: снимаем дамп EEPROM после put, разбираем его по полям, сверяем картинку с sizeof и offsetof, потом говорим про выравнивание на 32-битных платах. В симуляторе структур и EEPROM нет — раскладку соберём руками из масок и сдвигов; это как раз тот случай, когда руками полезно.
Дамп после put: взгляд в ячейки
Дамп — это просто цикл по адресам: EEPROM.read(a) и печать в монитор. Я печатаю адрес, затем значение — по одному на строку, чтобы не запутаться в колонках. До записи смотрите, что отвечает свежая плата: все ячейки с 0 до 1023 на Uno (на Mega карта вчетверо больше — 4096) выдают одно и то же число 255. Тот же цикл — половина моего дежурного скетча: подключил плату, открыл монитор, и память рассказала свою биографию. Чтение ячейки — микросекунды, весь килобайт пробегается за доли секунды, спешить некуда.
255 — не мусор, а заводское состояние: стёртая ячейка читается единицами во всех восьми битах. Именно на это опирается проверка magic-байта из урока про первый запуск: свежая плата отвечает числом, которое контрольным значением не выберешь случайно. Пока в дампе сплошные 255 — ни один скетч здесь ещё не хозяйничал. Число 255 — приятный ориентир: ячейка, отвечающая 255, ещё не тронута ни одной записью.
После put дамп меняется. Мой бокс — magic 170, версия 1, порог 210, коэффициент 1.25, режим 3 — ложится девятью байтами с нулевого адреса, и каждый байт стоит там, куда указал компилятор. Девять байт — меньше процента килобайтной карты: места для соседей остаётся с запасом, но границу бокса лучше знать до того, как кто-то в неё врежется. Дальше самое интересное: сверим эту картинку с полями структуры и посчитаем её руками.
#include <EEPROM.h> const int addrBox = 0; struct Settings { byte magic; // 0: 170 byte version; // 1: 1 int porog; // 2-3: 210 float koeff; // 4-7: 1.25 byte mode; // 8: 3}; Settings box; void dumpEEPROM(int ot, int kolvo) { for (int a = ot; a < ot + kolvo; a++) { Serial.print("addr "); Serial.print(a); Serial.print(" = "); Serial.println(EEPROM.read(a)); }} void setup() { Serial.begin(9600); box.magic = 170; box.version = 1; box.porog = 210; box.koeff = 1.25; box.mode = 3; EEPROM.put(addrBox, box); // раскладку делает компилятор Serial.print("sizeof="); Serial.println(sizeof(Settings)); // 9 на Uno // смещения полей от начала бокса: 0, 1, 2, 4, 8 Serial.print("offset porog="); Serial.println((int)((byte*)&box.porog - (byte*)&box)); Serial.print("offset koeff="); Serial.println((int)((byte*)&box.koeff - (byte*)&box)); dumpEEPROM(0, 9);} void loop() {}Разбираем печать по адресам. Адрес 0 — байт 170, это magic, он и должен быть 170. Адрес 1 — версия. А вот порог 210 занял два адреса: 210 по адресу 2 и 0 по адресу 3. Тип int на Uno — два байта, число 210 в шестнадцатеричном виде 0x00D2, и плата кладёт младший байт по меньшему адресу. Такой порядок зовётся little-endian; тот же у процессоров x86, поэтому привычка переносится с компьютера на плату без переучивания. Если бы старший байт ложился первым, дамп показал бы 0 и 210 — big-endian встречается в сетевых протоколах, но не в памяти AVR.
Коэффициент 1.25 расползся на четыре байта: 0, 0, 192, 63. Это IEEE-754, внутренний формат float: глаз такие байты не читает, зато get собирает обратно без потерь — в этом и смысл контейнера. Проверяется тем же get: прочитали коэффициент обратно — получили ровно 1.25, ни один бит не потерялся. Полный разбор того, как int и float распадаются на байты, я делал в отдельном уроке; здесь достаточно видеть, что float — это всегда четыре ячейки подряд.
Сумма сходится: 1 + 1 + 2 + 4 + 1 = 9 — столько же печатает sizeof(Settings). На AVR поля лежат вплотную, без дырок, поэтому ручная арифметика совпадает с компиляторной. Сомневаетесь в каком-то поле — спросите у разницы указателей, как в скетче: компилятор ответит смещением от начала бокса, и карта адресов перестанет быть предметом веры. Разница указателей выглядит страшнее offsetof, но делает то же самое и не требует ни одного include.
| Поле | Адрес на Uno | Байты дампа | Комментарий |
|---|---|---|---|
| magic (byte) | 0 | 170 | контрольный байт бокса |
| version (byte) | 1 | 1 | версия раскладки |
| porog (int) | 2 и 3 | 210, 0 | little-endian: младший байт первым |
| koeff (float) | 4-7 | 0, 0, 192, 63 | IEEE-754 для 1.25, глазом не читается |
Чужие платы: int толще, поля с дырками
Всё вышесказанное — про восьмибитные AVR. На ESP32 или любой ARM-плате раскладка своя: int там занимает четыре байта, а компилятор выравнивает многобайтовые поля на кратные четырём адреса, вставляя между ними пустые байты. Бокс из девяти байт на Uno превращается на ESP32 в двенадцать: сам int растолстел вдвое, плюс два выравнивающих байта легли перед ним. Единственный честный способ перетащить значения — прочитать их на исходной плате в переменные и записать на целевой, проверив каждое на диапазон: числа влезают в два направления по-разному, и переполнение здесь проморгать проще всего.
Практический вывод жёсткий: дамп, снятый с Uno, на ESP32 читается как бессмыслица — те же поля стоят по другим адресам и занимают другое число ячеек. Переносят содержимое не копированием ячеек, а через API: на старой плате get в переменную, на новой — put из неё. Размер бокса и смещения проверяйте на целевой плате, а не в чужой статье: у автора могла быть другая архитектура.
К дампу я возвращаюсь при каждой ревизии карты памяти: put бокса, дамп — и за минуту видно, с кем бокс граничит и не заехал ли кто в чужие адреса. Килобайтная карта Uno разбиралась в уроке про карту памяти, правила расселения соседей — в уроке про адресное пространство. Свежие 255 вокруг бокса — признак нетронутых соседей; любые другие числа там — повод посчитать границы руками. Отсюда мой ритуал при подключении чужого модуля: сначала дамп его адресов, потом запись.
И последнее, ради чего дамп стоит держать под рукой: повторите put дважды и снимите дампы до и после. Вы увидите, какие адреса реально поменялись, — обычно не все девять. Это мост к главному вопросу ресурса: put пишет бокс целиком или бережно? Ответ — в следующем уроке, где побайтовый пересчёт мы выписываем на экран и меряем в миллисекундах. Это же дешёвый тест при ревизии прошивки: раскладка не поехала, если во втором дампе изменились только те адреса, что вы меняли намеренно.
В симуляторе движок не умеет ни struct, ни put, ни дамп — поэтому раскладку соберём руками: число 210 разложим масками и сдвигами на два байта, сложим в модель ячеек и соберём обратно. LED подтвердит, что обратная сборка сошлась с оригиналом. Сдвиги и маски — ровно те операции, которые компилятор делает за вас; написать их руками стоит один раз, чтобы перестать путаться в адресах.
- put и get режут переменную на sizeof байтов и кладут поле за полем: раскладку ведёт компилятор, а не ваша карта адресов
- int 210 на Uno лежит парой 210 и 0 — младший байт по меньшему адресу (little-endian)
- float в дампе глазом не читается: 1.25 — это байты 0, 0, 192, 63 в формате IEEE-754
- свежая EEPROM читается числом 255; бокс с AVR не переносится на 32-битные платы: там другой int и выравнивание
- разница указателей (аналог offsetof) показывает смещение каждого поля — проверка раскладки одной строкой
- 1. Порог 210 в дампе занимает два байта: 210 и 0. Почему?
- 2. Что прочитает EEPROM.read из ячейки, которую ни разу не писали?
- 3. Структура: два byte, int, float, byte. Чему равен sizeof на Uno?
- 4. Тот же бокс дампят на ESP32 и видят другой sizeof. Почему?
- 5. Что показывает разница указателей (аналог offsetof) для поля структуры?
Частые вопросы по теме урока
- Порог 210 в дампе занимает два байта: 210 и 0. Почему?
- 210 влезает в один байт, но тип int занимает два: 0x00D2 раскладывается как 210 и 0. Порядок — little-endian, младший байт первым. int на Uno — два байта, младший ложится по меньшему адресу
- Что прочитает EEPROM.read из ячейки, которую ни разу не писали?
- Заводское стирание оставляет единицы: свежая EEPROM читается как 255. На этом держится проверка magic-байта при первом запуске. 255 — стёртая ячейка читается единицами во всех битах
- Структура: два byte, int, float, byte. Чему равен sizeof на Uno?
- На AVR поля кладутся без дырок: 1+1+2+4+1 = 9. Выравнивание — история 32-битных плат, не Uno. 9 байтам — поля лежат вплотную
- Тот же бокс дампят на ESP32 и видят другой sizeof. Почему?
- На 32-битных платах int — четыре байта, а поля выравниваются: между байтами встают дырки. Бокс из 9 байт с Uno превращается в 12. int там четыре байта, плюс выравнивание полей — sizeof растёт
- Что показывает разница указателей (аналог offsetof) для поля структуры?
- Смещение от начала ящика — то, что компилятор подставил вместо ручной карты адресов. EEPROM для проверки не нужен: это свойство типа. смещение поля от начала структуры — его место в раскладке