Плата в щитке на третьем этаже, а порог сработки меняется по телефону: договорились, что thr будет 26, вписали в переменную через монитор и разошлись. Через неделю мигнуло питание — и плата проснулась с заводским значением: переменные живут в RAM и со стартом обнуляются. Конфиг одной JSON-строкой закрывает это: при изменении настроек сериализуем документ в строку и кладём её в EEPROM, при старте — вычитываем и разбираем. Сценарий один и тот же от термостата до счётчика: поменялся порог — записали; мигнуло питание — прочитали; пришла новая плата — выкатили дефолты и доработали. Ячейки EEPROM и их ресурс — 1024 байта на Uno и около 100 000 циклов записи на ячейку — разобраны в уроке про конфиг в EEPROM; здесь я не про ячейки, а про конвейер строка → документ → настройки.
Почему строкой, а не готовой структурой в памяти? Строка самонесущая: добавили в конфиг новое поле — старый блоб всё равно разбирается, а недостающие ключи закрываются дефолтами. Структура в EEPROM жёстко связана с раскладкой полей конкретного компилятора: поменяете порядок или тип — прочитаете мусор, и никакой containsKey не спасёт. Версию формата тоже держите в строке: поле v с номером — одна буква в документе, а при будущей миграции вы будете знать, с чем имеете дело, без раскопок в истории правок. Цена вопроса копеечная: конфиг из трёх полей — строка под 45 байт, в 1024 байтах EEPROM Uno она занимает один кадр из двадцати с небольшим.
Конвейер загрузки
Четыре шага, всегда в этом порядке. Вычитать строку из памяти — на плате это цикл по ячейкам EEPROM до нуля-терминатора, в симуляторе — переменная-эмулятор. Разобрать: deserializeJson либо даёт документ, либо нет, и третий шаг раздваивается. На удаче — проверить каждый ключ через containsKey и применить найденное; на неудаче — выкатить полный набор дефолтов и пометить это в мониторе, чтобы спор с памятью был виден глазами. Четвёртый шаг один в обоих случаях: применить настройки к железу — порог в переменную, режим в состояние выхода. Пока конвейер не отработал целиком, к железу ничего не применяется: наполовину загруженный конфиг хуже заводского, потому что смешивает старое с новым.
Стёртая EEPROM — самый частый гость на новой плате: ни разу не писанные ячейки читаются байтами 0xFF, то есть 255 в каждой позиции. Строка из таких байтов парсером не переварится — и это отличная новость: сработает ветка дефолтов, а не случайные значения из мусора. Симулятор ниже делает ровно этот сценарий честной кнопкой: память очищается в пустой объект, конвейер выкатывает заводские значения, и светодиод показывает, что режим вернулся в manual. Запись в ячейки движок не рисует — это граница модели, а на плате она зеркальна чтению: собрать документ, serializeJson в буфер, дописать ноль-терминатор и разложить байты по ячейкам update-ом.
На настоящей плате дефолты ставятся тремя способами, и полезно держать в голове все три. Оператор по умолчанию: int thr = doc["thr"] | 24; — если ключа нет или в нём null, вернётся 24; коротко и покрывает большую часть случаев. containsKey честнее там, где ноль легитимное значение: порог в ноль градусов, счётчик в ноль событий — разница между «ключа нет» и «ноль в ключе» становится существенной. Третий способ — проверка типа is<int>(): ключ есть, но в нём строка вместо числа; механика в уроке про is и as. Три способа не враги: оператор покрывает простые поля, containsKey — критичные, is — подозрительные; я смешиваю их в одном конвейере свободно, и код от этого только честнее.
И правило, которое я выучил на той самой плате в щитке: писать конфиг в EEPROM можно только по событию — команда из монитора, изменение кнопкой. Запись в каждом loop убивает ячейки арифметически: 100 000 циклов при записи раз в 100 мс — меньше трёх часов непрерывной работы, раз в секунду — около суток. EEPROM.update() вместо write() помогает частично: он пропускает перезапись байта тем же значением, но реальные изменения порога изнашивают ячейку так же честно, как и пустые перезаписи. Практический маркер: если запись конфига стоит внутри loop без условия — это уже недоработка, а не стилистика, и видна при чтении кода за секунду; в ревью чужого скетча это мой первый вопрос.
#include <ArduinoJson.h>#include <EEPROM.h> DynamicJsonDocument doc(768); const char* ETALON = "{\"name\":\"terra\",\"thr\":26,\"mode\":\"avto\"}"; String saved = ""; // сюда ложится конфиг из EEPROMint thr = 20; // дефолтыbool avto = false; void setup() { Serial.begin(9600); pinMode(12, OUTPUT); saved = readConfig(0, 96); // 96 байт под строку конфига loadConfig(); applyConfig();} void loop() {} String readConfig(int from, int len) { String s = ""; for (int i = 0; i < len; i++) { char c = EEPROM.read(from + i); if (c == 0) break; // терминатор: строка кончилась if (c == 255) break; // стёртая ячейка: дальше мусор s = s + c; } return s;} void loadConfig() { DeserializationError err = deserializeJson(doc, saved); if (err) { // битая или пустая строка Serial.println("konfig ne razobralsya: defolty"); thr = 20; avto = false; return; } // там, где ноль легитимен, вместо "|" берём containsKey thr = doc.containsKey("thr") ? (int)doc["thr"] : 20; avto = doc.containsKey("mode") ? (String(doc["mode"]) == "avto") : false; Serial.print("konfig: thr="); Serial.print(thr); Serial.print(" mode="); Serial.println(avto ? "avto" : "manual");} void applyConfig() { digitalWrite(12, avto ? HIGH : LOW); // режим виден на светодиоде}Спор с памятью
Печать при старте — половина отладки. Я печатаю сырую строку из памяти и разобранный итог: первая реплика показывает, что лежит в памяти, вторая — что конвейер из этого понял. Сырую строку печатаю в кавычках и с длиной в байтах — тогда обрезка видна сразу: строка в 45 байт, а в памяти лежит 38, значит хвост съеден. Спорные случаи из жизни: строка обрезана нулём посреди поля — ячейка с терминатором оказалась раньше конца; в поле порога попала запятая вместо точки после правки с телефона; в name уехала кириллица, и монитор показал кракозябры. Каждое из этих состояний видно в печати за секунду и невидимо в цифрах на выходе — пока не начнёшь сравнивать строку и итог, они живут отдельной жизнью.
Размер строки под буфер считайте через measureJson: методика в уроке про оценку памяти — я отводлю под конфиг 96 байт при фактических 45 и не жалею: запас съедает рост конфига на пару полей. Если конфиг правится человеком, помните про экранирование кавычек и спецсимволов в строках — урок про экранирование показывает, как одна непарная кавычка в имени выключает половину настроек. А полный порядок инициализации с сохранёнными настройками собран в уроке про конфиг файлом — там же и про версию конфига в самом документе. Реплика про дефолт при старте — не повод для паники, а приглашение проверить, чем именно память ответила: с двумя печатями из совета выше спор решается за минуту.
| Место хранения | Сильная сторона | Подводный камень |
|---|---|---|
| EEPROM Uno: 1 КБ | живёт без питания, пишется байтом | около 100 000 циклов на ячейку: пишем только по событию |
| Флеш ESP32 через Preferences | места хватит на сотни конфигов, износ сглажен носителем | запись идёт секторами и на порядок медленнее RAM |
| SD-карта | гигабайты и правка файла на ПК | держатель, проводка и питание — лишний слой железа |
| RAM плюс ввод с монитора | ноль износа и мгновенная запись | любой сброс питания возвращает заводские значения |
- 1. Что читается в ни разу не писанной ячейке EEPROM?
- 2. Строка из стёртых байтов пришла на deserializeJson. Что дальше?
- 3. Почему конфиг в EEPROM пишут только по событию?
- 4. Что делает int thr = doc["thr"] | 24; когда ключа thr нет?
- 5. Сколько места занимает конфиг из трёх полей вида {"name":"terra","thr":26,"mode":"avto"}?
- Конфиг — одна JSON-строка: при изменении сериализовали и записали, при старте вычитали и разобрали
- Дефолт живёт в двух местах: битая строка целиком и отсутствующий ключ по containsKey
- Стёртая EEPROM читается байтами 0xFF: парсер её не переварит, и ветка дефолтов вернёт заводские значения
- На плате удобен оператор по умолчанию doc["thr"] | 24; там, где ноль легитимен, — containsKey и проверка типов
- Запись в EEPROM — только по событию: 100 000 циклов на ячейку при записи в loop выгорают за часы
Частые вопросы по теме урока
- Что читается в ни разу не писанной ячейке EEPROM?
- Свежая EEPROM из завода читается байтами 0xFF. Строка из таких байтов парсером не переварится — и это хорошо: сработает ветка дефолтов, а не случайные значения. Байт 0xFF — то есть 255
- Строка из стёртых байтов пришла на deserializeJson. Что дальше?
- Мусорная строка — не JSON ни в каком виде, разбор заканчивается неудачей, и конвейер выкатывает полный набор заводских значений, а не пытается применять половину найденного. Именно поэтому дефолты — часть конвейера на равных с разбором, а не костыль после него. Разбор не пройдёт — сработает ветка дефолтов
- Почему конфиг в EEPROM пишут только по событию?
- Ресурс ячейки — порядка 100 000 циклов: при записи раз в 100 мс это меньше трёх часов, раз в секунду — около суток. По событию конфиг меняется дважды в год, и ресурса хватает на годы. Ячейка живёт около 100 000 циклов — запись в loop выжигает её за часы
- Что делает int thr = doc["thr"] | 24; когда ключа thr нет?
- Оператор по умолчанию в ArduinoJson: на отсутствии ключа или null возвращается правая часть. Там, где ноль легитимное значение, берите containsKey — разница между «нет ключа» и «ноль в ключе» существенна. Вернёт 24 как дефолт
- Сколько места занимает конфиг из трёх полей вида {"name":"terra","thr":26,"mode":"avto"}?
- Половина строки — имена ключей и кавычки; значения экономнее держать короткими. Точный размер даёт measureJson — им и отводите буфер с запасом на пару будущих полей. Под 40-45 байт: кавычки и ключи стоят места