Поливалка на балконе разрослась до пяти настроек: время старта, длительность, порог сухости почвы, летний режим, калибровка насоса. Каждая жила в своей ячейке — ноль, один, два, три, четыре — и в шапке скетча у меня была карта памяти, честное слово, была. Потом понадобилась шестая настройка, я вставил её в середину раскладки, сдвинул адреса и неделю поливал балкон по расписанию прошлого месяца: скетч читал новые адреса, а там лежали байты старых значений. Мусор выглядел убедительно — числа были похожи на правду.
Это второй тупик с EEPROM за мою практику, и оба случились не из-за железа, а из-за ручной бухгалтерии. Россыпь ячеек — это пять настроек и пять мест, где можно ошибиться: адрес, тип, порядок байтов, дефолт, проверка пустой памяти. Когда настроек становится больше трёх, россыпь пора складывать в один ящик.
Ящик называется struct, а переносят его в память две функции: EEPROM.put и EEPROM.get. Один вызов пишет всю структуру целиком, один вызов читает её обратно, раскладку по байтам делает компилятор. В этом уроке соберём блок настроек с контрольным и версионным байтами внутри, посчитаем его размер и научимся менять раскладку, не теряя данные пользователей.
Честная оговорка про симулятор: put, get и sizeof структур движок не поддерживает — он умеет read, write, update и length. Поэтому в сим-блоке мы соберём ручную модель блока настроек на отдельных ячейках с обменом парами «адрес — значение» через Serial: на ней видна вся механика, которая под капотом put и get.
struct: один ящик на все настройки
Структура — это тип, склеенный из полей любых других типов: байты, целые, дробные, массивы фиксированной длины. С точки зрения EEPROM она просто кусок памяти известной длины: сколько байт занимает структура, подсказывает sizeof, и на платах AVR это почти всегда сумма размеров полей. Схема работы: в setup() читаем ящик get-ом, в момент изменения настроек пишем его put-ом целиком. Никаких /256, никаких пар адресов — имена полей вместо адресов.
Самое приятное — внутри. EEPROM.put разбирает структуру на байты и пишет их через update-семантику: неизменившиеся байты пропускаются, ресурс ячеек не тратится. То есть перезаписать весь ящик из восьми байт ради одного изменившегося поля — нормальная практика: реально перезапишутся один-два байта. Это снимает страх износа, о котором мы считали в уроке про ресурс.
Внутрь структуры сразу кладём два служебных поля: magic-байт и версию раскладки. Magic отличает первый запуск от данных: свежая EEPROM читается нулями-единичками, и если magic не совпал — ящик пуст, ставим дефолты. Версия пригодится, когда раскладка поменяется: именно об этом моя история с поливалкой.
Пара слов о происхождении, чтобы вы не удивлялись чужому коду. Раньше библиотека умела только read и write, и в каждом втором проекте жила скопированная функция EEPROM_writeAnything — шаблон, который резал структуру на байты указателем. С версии IDE 1.6.0 put и get вошли в библиотеку и сделали приём стандартным: те же байты, тот же порядок, но без ручных указателей и без копипасты из форума десятилетней давности. Старые скетчи при этом пишут те же байты, что и put, — раскладка полей не менялась, так что настройки, записанные старой функцией, прочитаются новым get-ом.
#include <EEPROM.h> const int addrBox = 0; // структура живёт с нулевого адреса struct Settings { byte magic; // контрольный байт 170 byte version; // версия раскладки int target; // целевая температура, может быть минус byte durationMin; // длительность, минуты float koeff; // калибровочный коэффициент}; Settings box; void loadSettings() { EEPROM.get(addrBox, box); // компилятор сам разложит по полям if (box.magic != 170) { // свежая плата - дефолты box.magic = 170; box.version = 1; box.target = -18; box.durationMin = 12; box.koeff = 1.0; saveSettings(); }} void saveSettings() { EEPROM.put(addrBox, box); // внутри - побайтовый update} void setup() { Serial.begin(9600); loadSettings(); Serial.print("target="); Serial.print(box.target); Serial.print(" koeff="); Serial.println(box.koeff, 4);} void loop() { delay(100);}Разбор одной строки, самой важной: EEPROM.get(addrBox, box). Функция смотрит на тип box, вычисляет его размер — sizeof(Settings) на Uno даст восемь байт: один на magic, один на version, два на int, четыре на float — и читает ровно столько ячеек подряд, раскладывая по полям. AVR кладёт поля структуры вплотную, без выравнивающих дырок, поэтому ручная арифметика из урока про побайтовую запись здесь не нужна. Но заметим: дамп структуры с одной платы нельзя вслепую читать на плате с другой архитектурой — там внутри структуры возможны выравнивающие байты, и раскладка поплывёт.
Проверка box.magic != 170 заменяет все проверки «а пустая ли память»: get на свежей плате вернёт в magic что-то другое — чаще всего 255 — и код сам поставит дефолты и тут же запишет их put-ом. Первый запуск самообслуживается, все следующие читают то, что пользователь настроил. Ровно та же идея magic-байта из первого урока, только теперь он едет внутри ящика и не занимает отдельный адрес вне раскладки.
Где это пригождается кроме настроек: профиль пользователя, расписание полива, таблица калибровок, последний экран меню. Везде, где данных больше одного байта и они меняются вместе. Для пересылки таких же структур между платами взгляните на урок про передачу структур по UART — там те же struct и sizeof, но вместо EEPROM — сериал.
И про безопасность раскладки на будущее. Байт version в структуре — страховка от моего балконного конфуза. Правило такое: любое изменение состава или смысла полей — новый номер версии. При старте читаем ящик и смотрим обе контрольки: magic говорит «здесь вообще были настройки», version говорит «они в той раскладке, которую ждёт этот скетч». Не сошлось — не читаем поля наугад, а конвертируем или сбрасываем. Именно сброс честнее всего, если старые данные нельзя однозначно распознать: пользователь потратит минуту на настройку, а не неделю на поиск причины странных порогов.
И да, put перезаписывает весь ящик — все восемь байт при изменении одного поля. Но вспоминаем update-семантику: реально перезапишутся только отличившиеся байты. При записи раз в час такой блок переживёт и вас, и ваших внуков.
Миграция на практике: пять шагов
- Положите в структуру magic-байт 170 и поле version с текущей версией раскладки.
- При старте: get, затем проверка magic. Не совпал — дефолты и немедленный put.
- Magic совпал, version нет — конвертируйте старые поля в новые или сбросьте на дефолты и запишите новый ящик.
- Каждое изменение состава полей увеличивает version на единицу — в момент редактирования структуры, не потом.
- Карту адресов и полей держите в шапке скетча: адрес старта, sizeof структуры, что в каком поле лежит.
Присмотритесь к ответам симулятора: ok=1 addr=10 val=220 — это ровно то, что делает put, только в микромасштабе. Один байт по одному адресу за один вызов. Настоящий put делает то же самое восемь раз подряд — по числу байт структуры, — но адреса и границы вычисляет компилятор из sizeof, а не ваша голова. Если протокол «адрес — значение» показался удобным, в уроке про командный парсер на его основе собирается полноценное текстовое меню: имена команд вместо чисел, валидация, ответы.
И границы метода. EEPROM идеален для ящика на десятки байт, который меняется от раза в минуту до раза в месяц. Когда настроек становится сотни или их нужно отдавать наружу — выгружать на компьютер, редактировать в файле, — то же самое содержимое удобнее держать в CSV на карте памяти, как в уроке про конфиги на SD. EEPROM — для маленького и горячего, SD — для большого и переносимого. Между ними целый класс гибридов: настройки в struct на EEPROM, журнал измерений — в файл, и скетч при старте первым делом проверяет оба источника.
И не забудьте про соседей: ящик настроек занимает адреса с нулевого и дальше на sizeof байт. Всё, что живёт в EEPROM помимо структуры — счётчики, калибровки, ротационные блоки, — располагайте после addrBox плюс sizeof(Settings), а не по привычным адресам с прошлого скетча. Один sizeof в константе — и добавление нового поля в структуру не сломает соседей по памяти. Карту памяти руками мы вели в прежних уроках — здесь её ведёт компилятор, пользуйтесь этим.
- EEPROM.put(addr, struct) пишет структуру целиком, EEPROM.get(addr, struct) читает; раскладку по байтам делает компилятор
- sizeof(struct) на AVR равен сумме полей: magic+version+int+float — восемь байт; кладутся вплотную, без выравнивания
- put внутри использует update-семантику: перезапись ящика ради одного поля тратит циклы только на изменившиеся байты
- Magic-байт и version едут внутри структуры: первый отличает пустую память, второй защищает от смены раскладки
- String в структуру для EEPROM не кладут — только массивы фиксированной длины вроде char name[16]
- Симулятор не поддерживает put/get и sizeof структур — в симе блок настроек собирают из read/update вручную
- 1. Что произойдёт при EEPROM.put(0, box), если изменилось только одно поле структуры?
- 2. Откуда EEPROM.get знает, сколько байт читать?
- 3. Скетч прочитал структуру: magic совпал, а version равна 1, хотя скетч ждёт 2. Что делать?
- 4. Почему String нельзя хранить в структуре, которую пишут в EEPROM?
- 5. Что вернёт sizeof для структуры из полей byte, byte, int и float на Arduino Uno?
Раздел сложился в полный конструктор: в первом уроке — байтовые настройки и гистерезис, во втором уроке — счётчики и двухбайтовая арифметика, здесь — ресурс памяти и ящик-структура одним вызовом. Дальше по курсу эти знания работают в связке: логгер, который хранит калибровки в EEPROM и пишет поток на SD-карту, переживает и отключения питания, и десятки тысяч записей — потому что каждый тип данных живёт там, где ему по ресурсу комфортно. Соберите свой блок настроек на struct — и мои балконные грабли останутся только в истории.
Частые вопросы по теме урока
- Что произойдёт при EEPROM.put(0, box), если изменилось только одно поле структуры?
- Внутри put работает побайтовая update-семантика: изменённые байты пишутся, совпавшие пропускаются. Перезаписывать весь ящик целиком безопасно. Функция сама пропустит неизменившиеся байты — ресурс потратят только они
- Откуда EEPROM.get знает, сколько байт читать?
- Аргумент передаётся по типу: компилятор вычисляет sizeof и читает ровно столько ячеек. Поэтому get нельзя вызвать для переменной не того типа. Из второго аргумента: тип переменной даёт размер через sizeof
- Скетч прочитал структуру: magic совпал, а version равна 1, хотя скетч ждёт 2. Что делать?
- Несовпадение версии означает, что байты могут означать не то, что ждёт скетч. Честнее конвертировать или сбросить, чем гадать о смысле старых байтов. Конвертировать старую раскладку в новую или сбросить на дефолты и записать новый ящик
- Почему String нельзя хранить в структуре, которую пишут в EEPROM?
- Для EEPROM нужны данные фиксированного размера: char-массив внутри структуры честно попадает в ячейки, а String — это указатель на динамический буфер. В структуре хранится ссылка на буфер в SRAM — в ячейки уедет адрес, а не текст
- Что вернёт sizeof для структуры из полей byte, byte, int и float на Arduino Uno?
- Один плюс один плюс два плюс четыре — восемь байт: AVR кладёт поля вплотную, без выравнивающих промежутков. 8