Порог проветривания теплицы я три недели таскал в скетче константой: сменилась погода — перепрошил, появилась вторая грядка — снова ноутбук и USB-кабель тянуть до самой теплицы. А в июле я сделал по-взрослому, как мне тогда казалось: собрал конфиг через serializeJson в переменную и гордо напечатал его в Serial. Ночью прошёл ливень, узел обесточило, а при старте переменная оказалась пустой: всё, что я собрал в оперативной памяти, жило ровно до первого отключения питания. JSON-документ без постоянной памяти — записка на запотевшем стекле: пока дышишь — читается.
EEPROM решает это в лоб: килобайт памяти, который не теряет содержимое без питания, — байты и счётчики туда мы уже клали в уроке про первые настройки. Идея этого урока — упаковать в ту же память не отдельные значения, а целый документ: собрал конфиг в JsonDocument, сериализовал в текст, записал текст байт за байтом. При старте — обратная дорога: собрал текст из ячеек, скормил deserializeJson, и в документе снова все поля по именам. Схема одинаково работает на Uno и на ESP32, а в итоговом проекте раздела она уже стоит как штатный узел.
Заранее договоримся о слове «конфиг»: я имею в виду небольшой набор полей, который меняется руками — порог, имя сети, номер телефона, периодичность опроса. Показания датчиков и журналы в EEPROM не кладут вовсе: потоковые данные убивают ячейки за недели, и место им на SD-карте или сразу на сервере. Конфиг же пишется раз в сезон — ресурс ячеек при такой частоте переживёт и вас, и плату.
Почему документ, а не россыпь байтов по адресам? Россыпь дешевле, но у неё нет имени: настроек становится пять, десять — и каждая требует своей ячейки, своего смещения, своей документации. JSON-блок читается по ключам: добавили поле — старые записи остаются читаемыми, как мы разберём в конце урока. Для этого в документе и живёт serializeJson из урока про сериализацию: она превращает документ в компактную строку, которую можно класть в память хоть в эфир.
Разметка блока: метка, длина, текст
Сырые байты в ячейках молчат о своём смысле, поэтому блок размечают вручную. Мой расклад из трёх зон: ячейка ноль — метка, константа 0x4A, буква J от JSON; она говорит «здесь лежит наш конфиг», а не мусор. Ячейка один — длина текста в байтах, чтобы на чтении знать, сколько ячеек собирать. Дальше по возрастанию адресов — сам JSON-текст. Три ячейки служебных плюс текст — на фоне килобайта EEPROM Uno это ничего, но замерить длину всё равно не поленитесь: measureJson из урока про сериализацию даёт её до записи, до того как байты пошли в память.
Метка нужна из-за состояния свежей платы: непрограммированная EEPROM на Uno читается сплошными 255, и без метки скетч примет этот мусор за конфиг, попытается разобрать — и получит хаос. Метка не совпала — значит, памяти ещё нет, ставим дефолты и записываем их, первый запуск любого узла всегда живёт в этой ветке. Длина нужна не меньше: текст, оборванный посреди ключа, уводит deserializeJson в IncompleteInput из урока про первый разбор — документ останется пустым, и скетч без проверки будет читать нули и думать, что так и задумано.
Запись идёт ячейка за ячейкой, и тут сразу два правила из урока про ресурс памяти. Первое: только EEPROM.update() — он читает ячейку перед записью и пропускает неизменённые байты; одна запись занимает около 3,3 мс, и конфиг из шестидесяти байтов пишется за две сотни миллисекунд — в setup терпимо, в каждом проходе loop это приговор. Второе: пишем блок целиком и по событию — нажали кнопку «сохранить», поменялся порог, пришла команда по Serial. Изменился один порог — всё равно переписываем весь текст: патчить середину строки нельзя, длины соседних полей съедут, и блок станет нечитаемым.
#include <ArduinoJson.h>#include <EEPROM.h> const int ADDR_MAGIC = 0; // метка блока: 0x4A - буква J от JSONconst int ADDR_LEN = 1; // длина текста в байтахconst int ADDR_TEXT = 2; // сам текст, дальше по возрастанию адресов DynamicJsonDocument doc(768); bool konfigChitaem() { if (EEPROM.read(ADDR_MAGIC) != 0x4A) return false; // метки нет int len = EEPROM.read(ADDR_LEN); if (len < 2 || len > 200) return false; // бредовая длина char buf[200]; for (int i = 0; i < len; i++) buf[i] = EEPROM.read(ADDR_TEXT + i); buf[len] = '\0'; // хвост строки DeserializationError err = deserializeJson(doc, buf, sizeof(buf)); return err == DeserializationError::Ok;} void konfigPishem() { doc.clear(); doc["ver"] = 1; doc["ssid"] = "dacha"; doc["porog"] = 24; char buf[200]; size_t n = serializeJson(doc, buf, sizeof(buf)); EEPROM.update(ADDR_MAGIC, 0x4A); EEPROM.update(ADDR_LEN, n); for (size_t i = 0; i < n; i++) EEPROM.update(ADDR_TEXT + i, buf[i]); Serial.print("zapisano baitov: "); Serial.println(n);} void setup() { Serial.begin(9600); if (!konfigChitaem()) { Serial.println("konfiga net - pishu defolty"); konfigPishem(); } Serial.print("porog: "); Serial.println(doc["porog"].as<int>());} void loop() {}Обратите внимание на двойную защиту в konfigChitaem: метка плюс проверка длины. Метка защищает от чистой памяти, длина — от собственных ошибок: если однажды вы напишете в ячейку длины ноль или двести при буфере на сто, скетч не полезет за пределы буфера, а молча уйдёт в ветку дефолтов. Проверка err перед обращением к документу — та самая привычка из урока про первый разбор: без неё сорвавшийся разбор тихо обнулит все поля, и теплица будет проветриваться по нулям.
Чтение — зеркало записи в обратную сторону. Метка на месте — берём длину, собираем текст в буфер побайтно, ставим терминающий ноль и отдаём deserializeJson. На плате источник удобнее всего держать char-массивом: deserializeJson(doc, buf) с буфером в двести байт спокойно живёт на Uno, куча не участвует. Прошли проверку — в документе все поля по именам, и дальше это обычный разбор из урока про чтение полей: containsKey для страховки по необязательным ключам, значения по именам, дефолты через операцию «или».
Если в проекте больше одного блока — например, конфиг узла и журнал сбоев рядом, — ведите таблицу адресов в комментарии в шапке скетча: ячейки с нуля до пятнадцати под конфиг, с шестнадцатой — резерв. Через полгода без такой таблички вы будете гадать, чей байт лежит в ячейке двадцать. Одна договорённость — и никакие блоки не наступают друг другу на пятки; та же дисциплина, что и с адресами устройств на шине I2C.
Чтение после перезагрузки: полный цикл
Самая убедительная проверка этой схемы — выключить питание и включить обратно. В симуляторе это делается кнопкой перезапуска скетча: память EEPROM движка живёт между запусками, так что после перезапуска скетч найдёт метку, соберёт текст из ячеек и разберёт его обратно в документ. На плате то же самое происходит после обесточивания узла: конфиг, записанный неделю назад, читается так же свежо, как вчера. Этот цикл — документ, текст, байты, снова текст, снова документ — и есть вся идея урока.
В сим-скетче две ветки сплошняком: нет метки — пишем дефолты, есть метка — читаем и разбираем. Побайтовый цикл записи использует приём text.charAt(i) - 0: символ минус ноль даёт его код, и в ячейку ложится байт. При чтении симметрично: char c = EEPROM.read(...), затем text += c — байт превращается обратно в символ. Это чистый C++: на плате строка работает точно так же, я за полгода на трёх узлах ни разу не увидел расхождения.
- Сломайте метку. В ветке чтения сравните ячейку не с 74, а с 75 и перезапустите: скетч решит, что конфига нет, и перезапишет дефолты — так защита от мусора прячет ваши же данные.
- Добавьте поле. В ветку записи впишите doc["tel"] = "79001234567" и перезапустите: блок с полем перезапишется, а чтение старого блока без него даст ноль — модель миграции настроек без стирания.
- Убейте длину. Уменьшите длину на десять при чтении: разбор упрётся в обрубок, и вы увидите ветку «разбор сорвался» — на плате это IncompleteInput.
- Замерьте свой конфиг. Соберите пакет из своих полей и сравните text.length() с килобайтом EEPROM: посчитайте, сколько конфигов влезает в одну память.
| Способ | Цена и размер | Когда беру |
|---|---|---|
| Байты по адресам | Один байт на настройку, служебных ячеек нет | Пара значений: яркость, режим, счётчик пусков |
| JSON-блок этого урока | Около 60-100 байт на узел с пятью полями, плюс метка с длиной | Набор настроек с именами, который будет расти: ssid, порог, телефон |
| struct и EEPROM.put | Столько байт, сколько полей в структуре, раскладка фиксирована | Набор полей, который точно не поменяется; детали в уроке про struct |
| SD-карта | Мегабайты, но карта ест пины SPI и питание | Большие конфиги и журналы: разбор конфига с SD — тот же синтаксис |
Сколько это занимает: Uno отводит под EEPROM 1024 байта, узел с пятью полями занимает около шестидесяти-ста, запас остаётся под второй блок — например, конфиг и журнал сбоев рядом. А почему JSON, а не структура через EEPROM.put из урока про struct? Мой критерий — эволюция настроек. Когда добавляю поле, JSON-блок читается по именам, и старый конфиг остаётся пригодным: новое поле получит дефолт, блок допишется целиком. Раскладка struct при добавлении поля сдвигает смещения байтов — и «старый» конфиг прочитается как чушь, хотя байты-то лежат целые.
Поле ver с номером формата превращает этот вопрос в явную проверку: при чтении скетч смотрит ver и решает, чей перед ним блок — первого или второго поколения. Старый блок можно дополнить недостающими полями дефолтами и перезаписать, и настройка переживёт обновление прошивки без чистки памяти. Это те же пять минут проектирования, что и контрольная сумма в уроке про протоколы: дешевле один раз заложить, чем потом разгребать молча испортившиеся данные.
- 1. Что хранит ячейка с меткой 0x4A?
- 2. Почему для записи блока берут EEPROM.update(), а не EEPROM.write()?
- 3. Свежая, никогда не писанная EEPROM на плате читается как...
- 4. Правильная дорога чтения конфига из EEPROM:
- 5. Поле ver в конфиге нужно, чтобы...
- Блок размечают тремя зонами: метка 0x4A, длина текста, сам JSON — без метки свежая EEPROM со своими 255 притворится конфигом
- Запись: serializeJson в строку, затем побайтовый EEPROM.update; чтение: сборка буфера по длине, терминающий ноль, deserializeJson, проверка результата
- update вместо write бережёт сто тысяч циклов ячейки; пишем только по событию и блоком целиком, патчить середину строки нельзя
- Длину считаем байтами, не буквами: кириллица в UTF-8 — два байта на букву, число букв обрежет хвост конфига
- Поле ver и чтение по именам делают блок переживаемым для обновлений: добавленное поле не ломает старый конфиг
Частые вопросы по теме урока
- Что хранит ячейка с меткой 0x4A?
- 0x4A — буква J от JSON, сигнал «блок занят валидным конфигом». Без метки свежая EEPROM со своими 255 притворилась бы конфигом при первом старте. Метка блока: здесь лежит наш конфиг
- Почему для записи блока берут EEPROM.update(), а не EEPROM.write()?
- Каждая запись тратит цикл ресурса, около 100000 на ячейку. update читает ячейку и пишет только при отличии — перезаписи неизменённых байтов не расходуют ресурс. update пропускает неизменённые ячейки и бережёт ресурс
- Свежая, никогда не писанная EEPROM на плате читается как...
- Незапрограммированная ячейка читается 255. Именно поэтому нужен первый запуск с записью дефолтов и метка, отличающая «наш блок» от заводского мусора. 255 во всех ячейках
- Правильная дорога чтения конфига из EEPROM:
- deserializeJson принимает строку или буфер, а не EEPROM-адрес: сначала байты собираются в текст с терминающим нулём, потом идёт разбор и проверка результата. Собрать текст в буфер по длине, затем deserializeJson
- Поле ver в конфиге нужно, чтобы...
- Формат настроек меняется со временем. Номер версии при чтении подсказывает, чей перед нами блок, и старый конфиг можно дополнить дефолтами, а не стирать. отличить старый формат блока от нового