Порог срабатывания жил в ячейке 5, режим — в 6, журнал начинался с 10. Однажды мне понадобилось поле «длительность паузы», и я без долгих раздумий вставил его между порогом и режимом: порог остался в 5, режим переехал с 6 на 7, журнал поехал с 11. Прошивка обновилась на трёх платах, и все три продолжили работать — только порог теперь читался из ячейки, где лежало его старое значение, режим — из ячейки, где лежала запись журнала, а журнал писал поверх режима. Две недели странного поведения, один вечер с распечаткой адресов и честный вывод: я нарушил собственную раскладку, потому что раскладка жила у меня в голове.
Причина — адреса жили в коде россыпью: цифры 5, 6, 10 в разных строках. Для компилятора это просто числа, никакой связи между ними он не видит и подсказать не может. Имена ADDR_ при поиске по коду находятся мгновенно: поиск по префиксу показывает каждое место обращения к памяти. Лекарство — объявить всё адресное пространство в одном месте: константы с говорящими именами, блоки-регионы с границами, проверка пересечений на этапе компиляции. Тогда добавление поля — это одна строка в конце списка, один плюс к ревизии и ноль сюрпризов на платах пользователей.
В этом уроке соберём шапку-карту, которой я пользуюсь теперь во всех проектах с EEPROM: const-адреса полей, #define границы регионов, enum для типов записей журнала, self-check при старте и правило ревизии. Плюс решим, где константная карта заканчивается и пора передавать адреса компилятору через struct.
Симулятор не понимает #if и #define, поэтому проверку пересечений в нём выполняет обычный if при старте — логика та же, что у препроцессора в реальном скетче. EEPROM, как и во всём разделе, представлен массивом в скетче с функциями myPut и myGet.
Одна правда: константы в шапке скетча
Форма, к которой я пришёл: шапка объявляет каждый адрес константой — const uint8_t ADDR_THRESHOLD = 2; — и в коде не остаётся ни одного голого числа. Тип uint8_t честнее int: он ровно байт, как ячейка EEPROM, и компилятор напомнит, если значение не влезет. Префикс ADDR в имени — быстрый маркер: перед вами адрес в памяти настроек, а не локальная переменная. Прочитать первый сохранённый байт такой константой — тот же вызов EEPROM.read из урока про read и write, только вместо цифры — имя.
Дальше — регионы. Между блоками констант в шапке живёт комментарий-карта: что лежит по адресам 0-9, что с 16. Границы регионов — тоже константы, но объявленные через #define: SET_LAST и JRN_FIRST. И на стыке — самая сильная строка всей схемы: #if JRN_FIRST <= SET_LAST с #error отказывается компилировать скетч, в котором журнал наехал на настройки. Конфликт раскладки ловится в момент сборки, за секунду, а не через две недели на живой плате.
Для журнала — enum вместо магических чисел: типы записей получают имена — enum RecType { REC_TEMP = 1, REC_BTN = 2 };. В ячейку уходит один байт типа, а в дампе сразу видно, что запись про температуру, а не про кнопку. Кольцо из двенадцати записей занимает двадцать четыре байта журнала: адрес следующей записи всегда J_BASE плюс указатель, умноженный на два, и никакой арифметики в основном коде. Добавление нового типа не двигает адреса — это просто новая строка в списке. Именованные числа с смыслом ложатся на материал урока про переменные и типы один в один.
#include <EEPROM.h> // ================== КАРТА ПАМЯТИ (ревизия 2) ==================// Настройки: 0-9 magic (0), версия (1), поля с 2// Журнал: 16-39 12 записей по 2 байта: тип + значение// Резерв: 40-63 под будущие поля настроек// Правило: новое поле - в конец настроек, ревизия +1// ==============================================================#define SET_FIRST 0#define SET_LAST 9#define JRN_FIRST 16#define JRN_LAST 39#define REV_LAYOUT 2 #if JRN_FIRST <= SET_LAST#error "Карта памяти: журнал наезжает на настройки"#endif const uint8_t ADDR_MAGIC = 0;const uint8_t ADDR_VERSION = 1;const uint8_t ADDR_THRESHOLD = 2; // byte: порог 0-255const uint8_t ADDR_MODE = 3; // byte: режим 0/1const uint8_t ADDR_LAST = 3; enum RecType { REC_TEMP = 1, REC_BTN = 2}; const uint8_t J_SLOTS = 12; const int PIN_BTN = 2;const int PIN_LED = 12; uint8_t recPtr = 0; // указатель журнала - в RAM, не в EEPROM void journalAppend(uint8_t type, uint8_t val) { uint8_t addr = JRN_FIRST + recPtr * 2; EEPROM.update(addr, type); // байт типа из enum EEPROM.update(addr + 1, val); recPtr = (recPtr + 1) % J_SLOTS; // кольцо} void setup() { Serial.begin(9600); pinMode(PIN_BTN, INPUT_PULLUP); pinMode(PIN_LED, OUTPUT); if (EEPROM.read(ADDR_MAGIC) != 170) { EEPROM.update(ADDR_THRESHOLD, 150); EEPROM.update(ADDR_MODE, 0); EEPROM.update(ADDR_VERSION, REV_LAYOUT); EEPROM.update(ADDR_MAGIC, 170); Serial.println("first run: defaults"); } if (EEPROM.read(ADDR_VERSION) != REV_LAYOUT) { Serial.println("layout changed: check map"); } Serial.print("threshold="); Serial.println(EEPROM.read(ADDR_THRESHOLD));} void loop() { static int prevBtn = HIGH; int btn = digitalRead(PIN_BTN); if (btn == LOW && prevBtn == HIGH) { uint8_t val = analogRead(A0) / 4; EEPROM.update(ADDR_THRESHOLD, val); // по константе, не цифрой journalAppend(REC_TEMP, val); // запись с типом из enum digitalWrite(PIN_LED, HIGH); delay(50); digitalWrite(PIN_LED, LOW); Serial.print("saved thr="); Serial.print(val); Serial.print(" rec="); Serial.println(recPtr); delay(200); } prevBtn = btn;}Проверка #if с #error — то место, где карта перестаёт быть комментарием. Условие вычисляет препроцессор до компиляции, поэтому границы регионов объявлены #define-ом: препроцессор видит только свои символы и не смотрит на const. Это тонкость, на которой спотыкаются: адреса полей — const, границы регионов — #define, и оба живут в шапке рядом, каждый для своего уровня проверки. Компиляция упала — значит, раскладку нарушили вы сами секунду назад, а не прошивка через месяц.
Правило добавления поля: новое место — в конец региона настроек, после ADDR_LAST, никогда в середину. Вставка в середину сдвигает все последующие адреса, и обновлённая прошивка читает старые данные из новых адресов — та самая история с тремя платами. Если полю позарез нужно место в середине, это миграция: прочитать старую раскладку, переложить, поднять ревизию. Механизм версий мы собирали в уроке про put и get — байт version внутри структуры; здесь та же идея, только раскладку ведут константы, а не sizeof.
Пара скетчей на одной плате — отдельная глава. Загрузчик и основной код, прошивка и заводская утилита — оба пишут одну EEPROM, и их карты обязаны совпадать байт в байт. Держать две копии карты в двух файлах — самообман: правка в одном месте забудется во втором. Вынесите шапку-карту в отдельный заголовочный файл memory_map.h и подключите к обоим скетчам: одна правда на два проекта, изменение в одном файле.
Самопроверка при старте и границы метода
Рантайм-проверка при старте не заменяет #if, но страхует: if (JRN_FIRST <= ADDR_LAST) — зажечь светодиод, напечатать нарушение в Serial и не писать в память до разбирательств. Она обязательна там, где препроцессор бессилен: скетч собирается в разных конфигурациях, карта подключается из внешнего файла, адреса считаются при запуске. Стоит проверка два байта RAM под флаг и строчку печати — дешевле, чем один затёртый журнал. Роль у неё другая, чем у #error: не поймать нарушение при сборке, а не допустить записи в чужие ячейки на работающей плате. Я ставлю обе — и в моих проектах они ни разу не понадобились, что и есть их главный результат.
Границы метода: константная карта хороша до пяти-шести полей. Дальше каждому полю нужны тип, дефолт и место в блоке, и руками это вести утомительно — пора передавать раскладку компилятору: struct с полями, sizeof сам считает размер, put и get сами раскладывают байты, вам остаётся magic и ревизия. Порог шесть полей не магия: сверх него у каждого поля появляется ещё дефолт и проверка диапазона, и констант на них уходит больше, чем заняла бы структура. Мой ориентир: больше шести полей или хотя бы один многобайтовый тип — беру struct. Константная карта остаётся для журналов, счётчиков и всего, что растёт по кругу и структурам не ложится.
Сохранение порога по кнопке в этом скетче — update по событию: пишется один байт при нажатии, ресурсу ячейки безразлично, почему именно — посчитано в уроке про update. Ввод порога крутилкой и кнопкой без delay-пауз собирается на millis-планировщике из урока про millis, а если порогов и команд станет много — текстовое меню уже описано в уроке про командный парсер: карта памяти и командный протокол живут рядом отлично.
| Подход | Стоимость внедрения | Где он дома |
|---|---|---|
| Голые числа в вызовах | ноль минут | один-два поля в скетче-однодневке; правки — только глазами |
| Константы в шапке + #define границы | пять минут на шапку | до шести полей, счётчики, журналы — мой основной вариант |
| struct + put/get + ревизия | структура и версия | много полей, многобайтовые типы, частые изменения раскладки |
- Все адреса — константы в одном месте шапки: const uint8_t ADDR_... вместо голых цифр по коду
- Границы регионов — #define, и #if с #error роняет компиляцию при пересечении: конфликт ловится за секунду до платы
- enum даёт типам записей журнала имена: байт типа читается в дампе, новый тип не двигает адреса
- Новое поле — в конец региона, ревизия +1; вставка в середину = миграция со сменой раскладки
- Проверка при старте не заменяет #error — она не даёт писать в чужие ячейки на живой плате
- Больше шести полей или многобайтовые типы — переходите на struct с put/get и версией раскладки
- 1. Почему адреса объявляют константами в шапке, а не цифрами по коду?
- 2. Куда вставлять новое поле настроек при добавлении?
- 3. Что делает связка #if JRN_FIRST <= SET_LAST и #error?
- 4. Чем плоха только рантайм-проверка раскладки при старте?
- 5. На плате работают два скетча: основной и утилита. Как держать одну карту адресов?
Раздел сложился в полный набор инструментов: в первые настройки легли байты и magic, дальше — ресурс, побайтовая арифметика, структуры, калибровка, карта килобайта и вот теперь адресное пространство в шапке скетча. Соберите свою карту до первой строки кода, защитите границы на компиляции и печатайте раскладку при старте — и мои две недели с тремя сбитыми платами останутся предостережением, а не вашим опытом.
Частые вопросы по теме урока
- Почему адреса объявляют константами в шапке, а не цифрами по коду?
- Голые числа 5, 6, 10 не связаны ни для компилятора, ни для вас через месяц. Одна константа с именем правится в одном месте и проверяется препроцессором. Компилятор не видит связи между голыми числами, а именованная константа — одна правда об адресе
- Куда вставлять новое поле настроек при добавлении?
- Вставка в конец не сдвигает адреса соседей. Вставка в середину — сдвиг всех последующих адресов и мусор в старых прошивках. В конец региона, после последнего занятого адреса
- Что делает связка #if JRN_FIRST <= SET_LAST и #error?
- Препроцессор сравнивает границы до компиляции, и скетч с нарушенной раскладкой просто не соберётся. Конфликт пойман за секунду, а не на плате. Роняет компиляцию, если журнал наехал на регион настроек
- Чем плоха только рантайм-проверка раскладки при старте?
- Рантайм-проверка выполняется, когда прошивка уже работает и запись могла случиться. Поэтому парой к ней идёт #if с #error на этапе сборки. Она ловит пересечение уже на плате, когда данные могли пострадать
- На плате работают два скетча: основной и утилита. Как держать одну карту адресов?
- Две копии карты — две правды, рано или поздно они разойдутся. Общий заголовочный файл даёт одну правду и #error-проверку в каждом потребителе. Вынести константы и границы в общий memory_map.h и подключить к обоим