К весне на одной Uno у меня по очереди жили три скетча: ночник со счётчиком включений, термостат и журнал замеров. Писал я их вразнобой: счётчик — в нулевой и первой ячейках, порог — в пятой, журнал — с десятой. Каждый следующий проект честно работал со своими адресами и сносил чужие. Когда понадобился снова счётчик, он показал ноль: на его месте год жил порог термостата. Данные не испортились — они были выселены без уведомления, и виноват был я с раскладкой в голове.
EEPROM на Uno — тысяча двадцать четыре байта, на Mega — четыре тысячи девяносто шесть. По меркам flash это ничто, по меркам настроек — целое хранилище: в килобайт помещается 512 значений int или 256 long. Но без плана тысяча ячеек превращается в стихийную свалку, где каждый новый скетч строит на руинах предыдущего. Свежая плата к тому же встречает вас всеми ячейками по 255 — это стёртое состояние, поэтому первый запуск узнаётся по magic-байту, а не по нулям.
Урок о том, как раскладывать тысячу ячеек заранее: регионы для настроек, счётчиков и журнала, зачем между ними резерв и почему переезд поля — это не перестановка цифры в коде, а миграция с сохранением данных. В конце — таблица-карта, с которой я теперь начинаю любой проект с EEPROM, до первой строки кода.
В симуляторе EEPROM не эмулируется — модель памяти это массив в скетче, заполненный, как настоящая чистая EEPROM, числом 255. Кнопка добавляет записи в журнал, Serial печатает раскладку и следит за счётчиком. Логика регионов на плате и в модели одинаковая.
Раскладка: настройки, счётчики, журнал, резерв
Правило простое: память делится на регионы, у региона есть первый адрес, последний адрес и назначение. Моя стандартная раскладка для Uno: настройки с 0 по 63 — magic, версия и все поля; счётчики с 64 по 71 — числа, которые растут сами; резерв с 72 по 127 — пустота под поля, о которых я ещё не подумал; журнал с 128 по 511 — записи по кругу; всё с 512 и дальше — свободно. Порядок неслучаен: то, что меняется часто и растёт, живёт выше, а редкие поля — ниже. Частота изменений задаёт этаж: настройки меняются руками раз в месяц, счётчики — сами при каждом событии, журнал — по нажатию, поэтому именно журнал держу выше всех, в самом быстром регионе.
Почему не абы где: адрес — часть формата данных, договор между всеми версиями прошивки, которые когда-либо работали с этой платой. Порог, переехавший с адреса 5 на 6, для нового скетча — просто другая ячейка. Но в ячейке 6 может лежать чужое значение, записанное старой прошивкой, и настройки после обновления читаются как мусор или как 255. Переезд поля — миграция: прочитать из старого адреса, записать в новый, поднять номер версии раскладки, обновить magic. В коде это три-четыре строки в setup(), и только после них работает основной цикл. Пять строк, если продумано заранее, и потерянные настройки пользователя, если нет.
Вторая классика беды — пересечение: int живёт в ячейках 64-65, а сосед-поле записан в 65. Запись одного тихо портит другой, и ничего не горит: числа просто странно скачут при чтении. Защита одна — карта, где у каждого поля точный адрес, и константы вместо голых чисел в коде; константам посвящён следующий урок. Счётчики я раскладываю парами байтов той же арифметикой, что в уроке про счётчик включений: старший байт, младший байт, сборка в ёмкую переменную.
Откуда вообще взялись эти три памяти и чем EEPROM отличается от flash и SRAM по устройству чипа — я раскладывал по полочкам в уроке про PROGMEM и память; здесь же нас интересует только килобайт адресов 0-1023.
| Регион | Адреса | Что лежит | Вместимость |
|---|---|---|---|
| Настройки | 0–63 | magic 170, версия, поля настроек | 64 байта — около 10 полей со всеми запасами |
| Счётчики | 64–71 | включения, наработка, события | 8 байт — четыре счётчика по два байта |
| Резерв | 72–127 | пусто, под будущие поля | 56 байт — 28 новых int |
| Журнал | 128–511 | записи по 2 байта, кольцо | 384 байта — 192 записи |
| Свободно | 512–1023 | под крупные таблицы и профили | 512 байт |
#include <EEPROM.h> // ------- Карта памяти Uno: 1024 байта, адреса 0..1023 -------// 0-63 настройки: magic (0), версия (1), поля с 2// 64-71 счётчики: включения 64-65, указатель журнала 66-67// 72-127 резерв// 128-511 журнал: записи по 2 байта (значение + номер), кольцо// 512-1023 свободно#define SET_LAST 63#define CNT_FIRST 64#define CNT_LAST 71#define JRN_FIRST 128#define JRN_LAST 511 const uint8_t ADDR_MAGIC = 0;const uint8_t ADDR_ON_HI = 64; // счётчик включенийconst uint8_t ADDR_ON_LO = 65;const uint8_t ADDR_JP_HI = 66; // указатель журналаconst uint8_t ADDR_JP_LO = 67;const uint8_t REC_SIZE = 2; const int PIN_BTN = 2;const int PIN_LED = 12; #if JRN_FIRST <= CNT_LAST#error "Карта памяти: журнал наезжает на счётчики"#endif int readPair(uint8_t hiAddr, uint8_t loAddr) { int hi = EEPROM.read(hiAddr); int lo = EEPROM.read(loAddr); if (hi == 255 && lo == 255) { return 0; // свежая память } return hi * 256 + lo;} void writePair(uint8_t hiAddr, uint8_t loAddr, int v) { EEPROM.update(hiAddr, v / 256); EEPROM.update(loAddr, v % 256);} void printMap() { Serial.println("map: 0-63 settings"); Serial.println("map: 64-71 counters"); Serial.println("map: 72-127 reserve"); Serial.println("map: 128-511 journal"); Serial.println("map: 512-1023 free");} void setup() { Serial.begin(9600); printMap(); if (EEPROM.read(ADDR_MAGIC) != 170) { EEPROM.update(ADDR_MAGIC, 170); writePair(ADDR_ON_HI, ADDR_ON_LO, 0); writePair(ADDR_JP_HI, ADDR_JP_LO, 0); Serial.println("first run: defaults"); } int on = readPair(ADDR_ON_HI, ADDR_ON_LO); writePair(ADDR_ON_HI, ADDR_ON_LO, on + 1); // счётчик включений Serial.print("power ups="); Serial.println(on + 1); pinMode(PIN_BTN, INPUT_PULLUP); pinMode(PIN_LED, OUTPUT);} int prevBtn = HIGH; void loop() { int btn = digitalRead(PIN_BTN); if (btn == LOW && prevBtn == HIGH) { int jp = readPair(ADDR_JP_HI, ADDR_JP_LO); int addr = JRN_FIRST + jp * REC_SIZE; if (addr + REC_SIZE - 1 > JRN_LAST) { jp = 0; // кольцо: пишем с начала addr = JRN_FIRST; } EEPROM.update(addr, analogRead(A0) / 4); EEPROM.update(addr + 1, jp); writePair(ADDR_JP_HI, ADDR_JP_LO, jp + 1); digitalWrite(PIN_LED, HIGH); delay(50); digitalWrite(PIN_LED, LOW); Serial.print("rec addr="); Serial.println(addr); delay(200); } prevBtn = btn;}Печать карты в setup() стоит пять строк и окупается в первый же месяц: открываете монитор порта и видите, кто где живёт, не листая код и не сверяясь с комментарием полугодовой давности. Счётчик включений читается из своего региона и растёт при каждом старте. Запись журнала добавляется по кнопке: адрес следующей записи считается из указателя, по достижении конца региона указатель обнуляется — это кольцо, о котором мы говорили в расчёте ресурса.
Журнал на 384 байта при двухбайтовых записях — 192 записи. Пишется он по событию, кнопкой, а не по расписанию: даже сотня нажатий в день сжигает меньше четырёх тысяч циклов в год на всю область — по чуть-чуть на каждую ячейку, что на порядок спокойнее одиночной ячейки из урока про износ. Прикинем кольцо числами: пятьдесят записей в день — это полный проход за четыре дня, около девяноста пяти циклов на каждую ячейку в год, то есть тысячу лет до предела в сто тысяч. Такой журнал умирает от старости платы, а не от износа. Когда двухбайтовых записей перестаёт хватать — нужен третий байт на время или тип, — журнал уезжает на карту памяти, а настройки остаются; разделение труда описано в уроке про логирование на SD.
Резерв и когда килобайта мало
Резерв — регион, который я целенаправленно не заполняю, и это самое полезное место в карте. Новое поле настроек встаёт в конец своего региона, в резерв, и адреса счётчиков с журналом не сдвигаются ни на байт — старые прошивки продолжают читать свои данные, миграция сводится к версии и magic из урока про структуры. Пятьдесят шесть байтов резерва — это 28 новых int или 56 байтовых полей: для настроек рост на годы.
Когда килобайта всё же мало, помните про две вещи. Первая: Mega2560 держит 4096 байт — та же раскладка с более широкими регионами. Вторая: на ESP32 EEPROM эмулируется во flash, размер задаётся вручную вызовом begin с числом байт, а запись требует commit — механика та же, скорость и ресурс другие. Большой поток данных и там и там уезжает на SD или во flash с износостойкой прослойкой, а килобайт EEPROM остаётся зоне маленького и ценного: настройки, счётчики, калибровки.
Свободная половина с 512 и дальше не обязана пустовать: таблица калибровок по каналам, расписание полива, профиль пользователя. Каждое новое занятое место вписывается в карту в шапке скетча — с первым адресом, последним и комментарием, — а не берётся с потолка. И проверьте карту на предмет роста: журнал, приросший байтом времени, — это уже не два байта на запись, а три-четыре, и запас после журнала должен это учесть. Карта в комментариях стоит ноль байт RAM и предотвращает ровно те грабли, с которых начался урок.
- EEPROM Uno — 1024 байта (Mega — 4096): в килобайт помещается 512 int или 256 long
- Раскладка по регионам: настройки 0-63, счётчики 64-71, резерв 72-127, журнал 128-511, свободно с 512
- Адрес — договор между версиями прошивки: переезд поля = миграция с чтением из старого адреса и новой версией
- Пересечение адресов не вызывает видимых сбоев — числа просто странно портят друг друга; защита — карта и константы
- Резерв — место для будущих полей: новое поле встаёт в конец региона, соседние адреса не двигаются
- Журнал — выше настроек, пишется по событию и кольцом: 384 байта при двухбайтовых записях дают 192 записи
- 1. Сколько значений int целиком помещается в 1024 байта EEPROM Uno?
- 2. Новое поле вставили в середину раскладки настроек. Что получат платы с обновлённой прошивкой?
- 3. int живёт в ячейках 64-65, а сосед записан в 65. Чем это кончится?
- 4. Зачем между настройками и журналом оставляют резерв?
- 5. Журнал занимает 384 байта, запись — 2 байта, пишем по кругу. Сколько записей влезает в кольцо?
Карта из таблицы — не догма: границы двигаются под проект, но три вещи держите всегда — настройки ниже счётчиков, счётчики ниже журнала, резерв между ними, и печать карты в Serial при старте. Осталась последняя деталь, которая превращает карту из комментария в рабочую защиту: адреса должны жить не в голове и не россыпью цифр, а в константах одного места. Как устроить адресное пространство скетча, чтобы добавление поля занимало одну строку и не ловило пересечения на живой плате — финальный урок раздела.
Частые вопросы по теме урока
- Сколько значений int целиком помещается в 1024 байта EEPROM Uno?
- int занимает два байта, значит 1024 / 2 = 512 значений. long туда же помещается 256 штук, float — те же 256. 512
- Новое поле вставили в середину раскладки настроек. Что получат платы с обновлённой прошивкой?
- Сдвиг раскладки ломает договор адресов: скетч читает порог там, где старая прошивка писала режим. Отсюда правило: новое поле — в конец региона и миграция. Старые поля прочитаются из чужих адресов — мусор или 255
- int живёт в ячейках 64-65, а сосед записан в 65. Чем это кончится?
- Пересечение не вызывает видимых сбоев: оба поля записываются нормально, но запись одного затирает половину другого. Числа странно скачут — и только. Поля будут тихо портить друг друга при каждой записи
- Зачем между настройками и журналом оставляют резерв?
- Резерв принимает новые поля на себя: адреса после него не меняются, старые прошивки продолжают работать, миграция сводится к номеру версии. Чтобы новое поле вставало в конец настроек, не сдвигая адреса счётчиков и журнала
- Журнал занимает 384 байта, запись — 2 байта, пишем по кругу. Сколько записей влезает в кольцо?
- 384 / 2 = 192 записи. После указатель обнуляется и записи идут поверх старых — это кольцевой буфер, привычный приём и для EEPROM, и для UART. 192