Новогодняя гирлянда на подоконнике: кнопка листает пять режимов, от спокойного свечения до бегущих огней. Работало отлично ровно до первого выключения удлинителя. Двадцать шестого декабря домашние выставили четвёртый режим, двадцать седьмого я включил гирлянду — и она уверенно светила по кругу, как в первый день. Через неделю листать режимы заново после каждой розетки стало лично моей проблемой.
Причина — одна строка, которой нет в скетче. Номер режима жил в обычной переменной, переменная живёт в SRAM, SRAM без питания пуста. Лечится всё одним байтом в EEPROM: при каждом нажатии кнопки записали номер режима в ячейку, при старте прочитали его обратно. Три строки кода — и гирлянда начинает помнить себя сама.
Команд тут всего две: EEPROM.read(адрес) возвращает байт из ячейки, EEPROM.write(адрес, значение) кладёт байт в ячейку. Но вокруг этих двух строк притаились четыре грабли, на которых я собрал весь урожай синяков: пустая память читается как 255, в ячейку не влезает ничего больше 255, запись занимает около 3,3 миллисекунды, а дребезг кнопки умудряется насчитать несколько нажатий из одного.
План урока: скетч гирлянды с памятью, разбор по строкам, таблица цен команд и честный эксперимент с перезапуском. Симулятор хранит EEPROM между запусками, как настоящая плата между выключениями, — гирлянду можно выключать и включать сколько угодно, не вставая.
read, write и цена одной записи
Чтение — EEPROM.read(адрес): возвращает байт из ячейки с адресом 0–1023 мгновенно и без всякого ущерба для памяти, ресурс тратит только запись. Байт — восемь бит и числа 0–255; весь спектр типов и их потолки мы уже раскладывали по полкам в уроке про переменные и типы — EEPROM живёт в тех же рамках, только надёжнее. Запись — EEPROM.write(адрес, значение): значение обязано быть байтом, 0–255. Передать 300 нельзя — не будет ни ошибки, ни 300: ячейка молча сохранит остаток от деления на 256, то есть 44. Это самая тихая ловушка раздела, ниже разберём её отдельно.
Запись — физический процесс: заряд перекачивается в плавающий затвор, и на это уходит около 3,3 миллисекунды, всё это время процессор занят. Для гирлянды, которая пишет номер режима по нажатию кнопки, — незаметно: 3,3 мс один раз за вечер меньше, чем мигание глаза. Для скетча, который должен тикать каждые сто микросекунд, — катастрофа. Ещё одна причина не писать без нужды.
Записывать надо по событию, а не каждым проходом loop(): ресурс ячейки — 100000 циклов, и скетч с холостой записью в цикле сжигает его за часы. Мы это уже проходили на ночнике с сохранением яркости: гистерезис и запись только при изменении — обязательная пара к любой EEPROM.write.
Адреса я держу в константах с говорящими именами: const int addrRezhim = 5. Пятая ячейка выбрана не по магии, а по карте памяти: ноль и единицу в этом разделе мы оставили счётчику включений, двойку — яркости ночника, а настройки легко продолжают список. Через полгода доработок скетч без карты адресов не прочтёт сам себя: где-то в ячейке 5 лежит режим, где-то яркость, а вы уже не помните, где именно. Карта адресов — комментарий в шапке скетча — стоит одной строки и экономит вечер.
| Команда | Что делает | Время | Ресурс ячейки |
|---|---|---|---|
| EEPROM.read(addr) | Возвращает байт 0–255 из ячейки | Мгновенно | Не тратит |
| EEPROM.write(addr, val) | Пишет байт; лишнее отрежется по модулю 256 | Около 3,3 мс, процессор ждёт | Тратит цикл всегда |
| EEPROM.update(addr, val) | Пишет только при отличии — разбор в следующем уроке | 3,3 мс при записи, иначе чтение | Тратит только при изменении |
| EEPROM.length() | Возвращает размер EEPROM платы | Мгновенно | Не тратит |
#include <EEPROM.h> // Карта EEPROM: 5 - номер режима (1..5)const int addrRezhim = 5;const int knopka = 2; int rezhim = 1; void setup() { Serial.begin(9600); pinMode(knopka, INPUT_PULLUP); rezhim = EEPROM.read(addrRezhim); if (rezhim == 255 || rezhim < 1 || rezhim > 5) { rezhim = 1; // первый запуск или мусор: ставим дефолт } Serial.print("vosstanovlen rezhim="); Serial.println(rezhim);} void loop() { if (digitalRead(knopka) == LOW) { // кнопка прижала пин к земле rezhim = rezhim + 1; if (rezhim > 5) { rezhim = 1; } EEPROM.write(addrRezhim, rezhim); // запись по событию Serial.print("rezhim="); Serial.println(rezhim); delay(250); // грубая пауза против дребезга }}Чтение живёт в setup() и происходит до любых других дел: скетч сначала вспоминает, кем он был до выключения. Тройная проверка при чтении — равно 255, меньше единицы, больше пяти — закрывает сразу два сценария: свежую плату (255) и мусор от старой прошивки, когда в ячейке 5 осталось, скажем, 137 от давно удалённой яркости. Ветвление здесь элементарное, но отсутствие любой из трёх проверок — это готовый баг в продакшене.
Кнопка подключена на D2 с INPUT_PULLUP: у механической кнопки нет своего мнения о уровне, и неподтянутый пин читает наводки из воздуха. Подробно про схему — в уроке про кнопку и digitalRead. Запись строго внутри условия нажатия: одно нажатие — одна запись — один потраченный цикл ячейки.
delay(250) после записи — грубый, но рабочий антидребезг: без него одно нажатие считается три-четыре раза, и режим улетает через два-три щелчка. Аккуратную версию с замером времени разобрали в уроке про дребезг. Если кроме номера режима нужен и уровень яркости — это второй байт с отдельным адресом и analogWrite из урока про ШИМ: одна настройка — одна ячейка.
Потолок байта: 255 и тихое переполнение
Ячейка — восемь бит, диапазон 0–255, и это жёсткий потолок. EEPROM.write(5, 300) сохранит 300 минус 256, то есть 44, молча и без ошибки; на границе работает так же: 256 превратится в 0. Отсюда правило: приводите значение к байту до записи — делением, map или проверкой диапазона, — а не надейтесь, что «библиотека разберётся». Библиотека разберётся единственным доступным ей способом: отрежет старший байт.
Поэтому при чтении проверяю не только 255, но и разумный диапазон значения. Сценарий, который спасает тройная проверка: плату перепрошили, раскладка EEPROM сменилась, в ячейке 5 от прошлой жизни осталось 137 — и без проверки диапазона гирлянда попыталась бы включить сто тридцать седьмой режим. Числа, которых не может быть, — это всегда сигнал «читаем мусор», а не «применяем как есть».
Числа больше 255 в EEPROM живут парами байтов: старший и младший по соседним адресам, разрез через деление и остаток, сборка обратно умножением на 256. Полный разбор с картой памяти — в уроке про счётчик включений. Пока же запомните: один байт — одна простая настройка, и этого хватает чаще, чем кажется.
И про сам эксперимент с розеткой. Нажатие Reset на реальной плате выживание данных не проверяет: питание не пропадало, скетч просто стартует заново. Честная проверка — выдернуть питание целиком и включить снова. В симуляторе то же самое — Стоп и повторный запуск: EEPROM между запусками сохраняется, и гирлянда вернёт последний режим, а не первый.
- Держите кнопку нажатой секунд пять: режим будет крутиться по кругу 1–2-3-4-5. Посчитайте по строкам Serial, сколько записей получила ячейка за эти пять секунд, — и сколько таких сессий выдержат 100000 циклов.
- Добавьте в loop() холостую EEPROM.write(addrRezhim, rezhim) без условия и сравните частоту строк записи с кнопочной версией: так выглядит трата ресурса впустую.
- Запишите EEPROM.write(6, 300) и прочитайте ячейку 6: вернётся 44. Теперь уложите 300 честно — байты 1 и 44 по адресам 6 и 7.
- Уберите из setup() проверку на 255 и очистите память (на реальной плате — свежая плата из упаковки): проследите, какой режим вернётся при старте и почему.
- 1. Что вернёт EEPROM.read(5), если в ячейку 5 ещё никогда не писали?
- 2. Сколько времени процессор занят одной записью EEPROM.write?
- 3. Что окажется в ячейке после EEPROM.write(7, 300)?
- 4. Почему запись режима делается внутри условия нажатия кнопки, а не каждым проходом loop()?
- 5. Кнопку подключили без INPUT_PULLUP, пин висит в воздухе. Что произойдёт?
- EEPROM.read(addr) возвращает байт мгновенно и ресурса не тратит; EEPROM.write(addr, val) пишет за ~3,3 мс и тратит цикл всегда
- Чтение — в setup(), запись — по событию (нажатие, изменение значения), никогда не в холостом loop()
- Ячейка — 0–255; лишнее отрезается молча по модулю 256: write(5, 300) сохранит 44
- Тройная проверка при чтении: 255 — пусто, значение вне рабочего диапазона — мусор, только потом применяем
- Кнопке нужен INPUT_PULLUP: неподтянутый вход генерирует ложные нажатия и лишние записи
- Выживание данных проверяет полное отключение питания, а не Reset
Байт записан, пережил розетку, вернулся после включения — первый настоящий акт энергонезависимости. Но у сегодняшнего скетча есть ахиллесова пята: EEPROM.write тратит цикл ячейки даже тогда, когда записывает то же самое значение. В гирлянде записи редкие, а вот скетч, который сохраняет показание на всякий случай каждые десять секунд, убьёт ячейку за неполные двенадцать суток.
Лекарство — EEPROM.update(), который перед записью сравнивает значение с лежащим в ячейке. В следующем уроке вскроем его механику, посчитаем на пальцах, сколько ресурса сжирает холостая запись, и выведем три правила бережной записи, после которых ваши настройки проживут десятилетия. А читаемость экспериментов держится на парах имя=значение в Serial — привычке из первого урока про монитор порта: по строке rezhim=4 видно всё без всякого плоттера.
Частые вопросы по теме урока
- Что вернёт EEPROM.read(5), если в ячейку 5 ещё никогда не писали?
- Пустая EEPROM — все единичные биты, то есть 255 в каждой ячейке. По 255 ловят первый запуск и подставляют дефолт. 255
- Сколько времени процессор занят одной записью EEPROM.write?
- Запись — физический перенос заряда, около 3,3 мс, и всё это время процессор ждёт. Чтение в тысячи раз быстрее и ресурса не тратит. Около 3,3 миллисекунды
- Что окажется в ячейке после EEPROM.write(7, 300)?
- Ячейка хранит байт 0–255; лишнее молча отрезается по модулю 256: 300 − 256 = 44. Приводите значение к байту до записи. 44
- Почему запись режима делается внутри условия нажатия кнопки, а не каждым проходом loop()?
- Каждая запись тратит один цикл ресурса. Запись в холостом loop() выжигает ячейку за часы; по событию — по нажатию — ячейка живёт годами. Иначе ячейка быстро израсходует свои 100000 циклов записи
- Кнопку подключили без INPUT_PULLUP, пин висит в воздухе. Что произойдёт?
- Висящий в воздухе вход ловит наводки и читает мусор. Каждое ложное срабатывание — лишняя запись в ячейку и скакующий режим. digitalRead вернёт случайные уровни, скетч наделает лишних записей в EEPROM