Термостат уехал на объект, через неделю заказчик на связи: интервал опроса поменяйте на пять секунд. Ехать за ящиком с инструментами ради одной константы — путь в никуда. Выход один: настройки, которые редактирует человек без IDE, должны лежать на карте в текстовом файле, и плата обязана переварить их при каждом старте. Как только вы это сделали, сразу всплывает второй вопрос — что делать, когда карты нет: вынута, умерла, забыта на столе после последнего обслуживания.
Здесь раскладываю иерархию, которую ставлю во все устройства с картой: SD, EEPROM, зашитый в код дефолт. Покажу парсинг config.txt без тяжёлых библиотек, валидацию значений на границах и запасной слой, из-за которого устройство стартует с приемлемыми настройками всегда, а не только в идеальном мире. Формат key=value сам по себе — тема урока про key-value конфиг; моя забота — не строки, а порядок, в котором источники опрашиваются.
Три слоя и порядок их опроса
Порядок не случайный, и у каждого слоя своя роль. Карта — верхний слой: файл правится блокнотом на ноутбуке, без компиляции и шнура, и это единственный слой, доступный монтажнику на объекте. EEPROM — средний: правится только скетчем, зато сидит прямо на плате и переживает не только выключение, но и выдёргивание модуля SD. Дефолт — нижний: const в коде, меняется перепрошивкой, зато не может не ответить. Логика чтения идёт сверху вниз: карта, если молчит — EEPROM, если и там пусто — дефолт. Каждый следующий слой неудобнее в правке, зато доступнее в моменте. В проектах без карты — термостат без логирования, термометр в инкубаторе — вся иерархия вырождается в два слоя, и это нормально: каркас опроса остаётся тем же, просто верхний слой отсутствует.
Валидация обязательна на каждом слое, потому что в файле может лежать мусор, а в EEPROM — чистый 0xFF. У интервала опроса есть разумные границы: скажем, от 300 миллисекунд до 10 секунд. Границы задавайте из здравого смысла предметной области, а не из возможностей типов: у int запаса хватает на века, у пользователя — нет. Значение вне границ считается отсутствующим — слой словно промолчал, и опрос уходит ниже. Это простое правило превращает «устройство повисло после правки конфига» в «молча взяло предыдущий слой»: человек опечатался, плата даже не вздрогнула, а в мониторе честно сказано, откуда пришло значение.
Парсинг при этом примитивный и штатный: readStringUntil построчно в цикле с available, indexOf для знака равенства, toInt для значения — готовый разбор я показывал в уроке про readStringUntil. Для вложенных структур и десятков ключей есть JSON-путь с ArduinoJson в отдельном уроке — иерархия источников поверх него собирается точно так же, меняется только функция чтения верхнего слоя. Строки с решёткой в начале пропускайте сразу: люди любят оставлять в конфигах пометки для себя, и парсер, спотыкающийся о комментарий, не переживёт первую же смену обслуживающего персонала.
Средний слой хранит копию конфига, и копию пишет сам скетч: после удачного чтения с карты значения уходят в EEPROM. Тогда вынутая карта не откатывает устройство к заводским значениям, а оставляет последние рабочие — разница между «работает» и «объект стоит» может быть именно в этом. Свежая плата вернёт из EEPROM 0xFF по каждому байту: это штатное состояние первой записи, его механику разбирал в уроке про первый запуск и 0xFF, а как уложить конфиг структурой и не разнести границы — в уроке про struct-настройки. Ваша защита проще некуда: значение вне границ — слой считается пустым, берём следующий. Порядок ключей в файле не важен, повторяющиеся — читайте первым совпадением и зафиксируйте это в комментарии к парсеру.
Один нюанс, который экономит ресурс платы: запись в EEPROM делайте только при изменении. Сравнили прочитанное с тем, что лежит, и put сработал лишь при расхождении — ячейки EEPROM не перетираются при каждом старте впустую. При честных перезапусках раз в сутки даже голый put съедает ячейку за столетие, но скетч, упавший в цикл перезагрузки с записью внутри, убьёт её за считанные дни — сравнение перед put закрывает и этот случай.
#include <SPI.h>#include <SD.h>#include <EEPROM.h> const int CS_PIN = 10;const char* CONF_FILE = "/config.txt"; int readFromSd() { if (!SD.begin(CS_PIN)) return 0; // 0 - слой не ответил File f = SD.open(CONF_FILE); if (!f) return 0; // файла нет - тоже молчание while (f.available()) { String line = f.readStringUntil(10); // 10 - перевод строки line.trim(); if (line.startsWith("#")) continue; // строки-комментарии пропускаем int eq = line.indexOf("="); if (eq < 0) continue; String key = line.substring(0, eq); key.trim(); if (key != "interval") continue; int v = line.substring(eq + 1).toInt(); if (v < 300 || v > 10000) continue; // мусор вне границ пропускаем return v; } return 0;} int readFromEeprom() { int v; EEPROM.get(0, v); if (v < 300 || v > 10000) return 0; // свежая плата вернёт 0xFFFF return v;} void setup() { Serial.begin(9600); int interval = readFromSd(); int source = 2; // 2 - карта, 1 - EEPROM, 0 - дефолт if (interval == 0) { interval = readFromEeprom(); source = 1; } if (interval == 0) { interval = 2000; // дефолт, зашитый в код source = 0; } if (source == 2) { // удачное чтение с карты int old; EEPROM.get(0, old); if (old != interval) EEPROM.put(0, interval); // пишем только изменение } Serial.print("источник: "); if (source == 2) Serial.println("карта SD"); else if (source == 1) Serial.println("EEPROM"); else Serial.println("дефолт"); Serial.print("интервал, мс: "); Serial.println(interval);} void loop() { // дальше ваша рабочая логика с interval}Кто отвечает за какой случай
Прогоните глазами все режимы, в которых устройство встречается с миром. Карта есть, файл в порядке — верхний слой, всё ясно. Карту вынули или модуль отошёл — слой молчит, стартует EEPROM с последней копией. Файл есть, но ключ не нашёлся или число за границами — слой формально жив, но ответа нет: идём ниже. EEPROM свежая — 0xFFFF вне границ, берём дефолт. Дефолт по определению не может промолчать, поэтому цепочка всегда доходит до конца, и устройство стартует с чем-то разумным в любом сценарии. Отдельный случай — модуль, отошедший в переходнике: SD.begin вернёт false, иерархия молча уйдёт в EEPROM, и всё будет «работать» с устаревшими значениями — поэтому источник нужно не только вычислять, но и показывать.
Отладьте сценарии на столе, а не на объекте: вытащите карту при включённой плате, сотрите файл, напишите в нём ерунду вроде interval=abc — и убедитесь, что каждый раз в мониторе назван честный источник. Правило печати источника стоит десятка строк кода, а экономит часы на телефоне с монтажником: он читает монитор, вы слышите, какой слой промолчал. Заодно проверьте нижнюю границу: interval=abc даёт toInt ноль, а ноль вне границ — тот же тихий отказ слоя. Точно так же parseInt ловит числа из любого потока — приёмы из урока про parseInt в Serial переносятся на файлы один в один.
В модели сначала включите тумблер и пришлите число — увидите копию в EEPROM; затем нажмите кнопку — источник сменится на средний слой; снимите тумблер — останется дефолт, и светодиод вернётся к двухсекундному ритму. Три шага, и иерархия перестаёт быть абстракцией. Кнопку и тумблер переключайте по ходу, не перезапуская симулятор: скетч перекраивает источник на живую, как и на реальной плате. Светодиод здесь не украшение: стартовый сигнал числом миганий по интервалу из конфига рассказывает монтажнику всё, пока вы на другом конце города. Такой же индикатор я оставляю и на реальных платах.
Граница метода: иерархия держит три источника, потому что больше обычно не нужно. Если у вас десятки ключей и версии структур — берите JSON с карты и версию схемы в EEPROM, это уже уровень отдельного разговора о конфиге на ArduinoJson; каркас опроса слоёв при этом остаётся ровно тем же.
| Источник | Когда срабатывает | Риск, который вы купили |
|---|---|---|
| Карта SD, файл config.txt | карта встала и файл разобрался | модуль выдёргивается без предупреждения |
| EEPROM, адрес 0 | карты нет или файл не разобрался | свежая плата вернёт 0xFFFF: без проверки получите ерунду |
| Дефолт, const в коде | оба носителя молчат | значения заморожены до следующей прошивки |
| Копия SD в EEPROM | скетч пишет её после удачного чтения | ресурс EEPROM конечен: пишите только при изменении |
- 1. Какой порядок опроса источников даёт иерархия из урока?
- 2. Карту вынули из работающего устройства. Что оно возьмёт при следующем старте?
- 3. В config.txt написано interval=15. Границы 300-10000. Что сделает загрузчик?
- 4. Свежая плата читает int из EEPROM по адресу 0. Что она получит?
- 5. Зачем сравнивать старое значение EEPROM перед записью нового?
- Иерархия конфига: SD как редактируемый слой, EEPROM как запасной, const в коде как последний рубеж.
- Значение вне границ — слой промолчал: валидация превращает опечатку в безболезненный откат на слой ниже.
- Копию с карты в EEPROM пишет скетч после удачного чтения — и только при изменении значения.
- Свежая EEPROM вернёт 0xFFFF по int: одна проверка границ защищает весь механизм.
- Печатайте источник при каждом старте: строка в мониторе экономит сеанс удалённой отладки на объекте.
Частые вопросы по теме урока
- Какой порядок опроса источников даёт иерархия из урока?
- Сверху самый редактируемый источник — карта, ниже EEPROM, внизу дефолт, который не может не ответить. Первый ответивший слой выигрывает, остальные даже не опрашиваются. Поменяете местами — файл с одной опечаткой начнёт перетирать EEPROM, и запасной слой потеряет смысл. Карта, затем EEPROM, затем зашитый дефолт
- Карту вынули из работающего устройства. Что оно возьмёт при следующем старте?
- Скетч копирует значения с карты в EEPROM при каждом удачном чтении. Без карты устройство стартует с последними рабочими настройками — и только при чистой EEPROM упадёт на дефолт. Именно поэтому карту можно выдёргивать без страха отката к заводским. Копию конфига из EEPROM, записанную после удачного чтения карты
- В config.txt написано interval=15. Границы 300-10000. Что сделает загрузчик?
- Значение вне границ приравнивается к отсутствию ответа: слой пропущен, опрос идёт ниже. Constrain здесь не подходит — он бы молча подменил опечатку правдоподобным числом. Сочтёт слой промолчавшим и уйдёт к следующему источнику
- Свежая плата читает int из EEPROM по адресу 0. Что она получит?
- Первичное состояние EEPROM — 0xFF в каждом байте, int из двух таких байтов равен 65535. Проверка границ отправляет это значение в утиль, и загрузчик берёт дефолт. 65535: каждый байт равен 0xFF при первой записи
- Зачем сравнивать старое значение EEPROM перед записью нового?
- Каждый put стирает и пишет ячейки, а ресурс EEPROM — порядка ста тысяч циклов на ячейку. Запись только при изменении растягивает ресурс на годы работы устройства. Чтобы EEPROM не перетиралась при каждом старте впустую — ресурс ячеек конечен