Коптильня горячего копчения осталась самым дотошным моим проектом: для термометра я снял восемь калибровочных точек по лабораторному градуснику и каждой точке отвёл свою пару ячеек в EEPROM. Раскладка была простая и, как мне казалось, надёжная: p0 в ячейках 2-3, p1 в 4-5, p2 в 6-7 — и так до седьмой точки. Восемь переменных, восемь одинаковых блоков сохранения, размноженных копипастой. На шестой точке я в спешке оставил адрес соседней — поправка писалась в чужую ячейку, печь держала 118 вместо 110, а я три вечера искал дрейф в датчике: перепаивал провода, менял щепу и грешил на сквозняк.
Тупик был не в памяти и не в железе — в ручном размножении кода. Восемь одинаковых блоков с вписанными адресами — это восемь мест, где можно промахнуться на единицу, и рано или поздно это случается с каждым, у кого руки доходят до доработки в полночь. Лечится не внимательностью, а арифметикой: однотипным значениям нужен один базовый адрес и индекс, а адрес каждого элемента должна считать формула, не моя память и не мои пальцы.
Кандидаты на такой массив встречаются в каждом втором проекте, просто их редко собирают вместе. Таблица поправок датчика по диапазону. Пороги гистерезиса по шести каналам — по байту на канал. Расписание полива по дням недели — семь байт. Номера пинов сцен гирлянды. Общий признак один: значения однотипные, а их количество известно заранее и иногда растёт. Как только в скетче появилась вторая переменная с именем вроде point1 и point2 — пора складывать их в массив и городить цикл.
В этом уроке соберём калибровочную таблицу из восьми int правильно: for-цикл с шагом sizeof(int), адресная арифметика addr + i * sizeof(int), чтение любого элемента по индексу и правка одной точки без перезаписи соседних. Заодно посчитаем, сколько таких таблиц влезает в килобайт памяти и где проходит граница раскладки.
EEPROM в симуляторе не эмулируется — модель памяти это массив в скетче. В сим-блоке ниже восемь int-ячеек живут в массиве cells, кнопка D2 прибавляет единицу к выбранному элементу, тумблер D3 переводит выбор на следующий. Serial печатает адрес каждой записи и всю таблицу целиком — механика ровно та, что на реальной плате делает EEPROM.put.
Таблица — это базовый адрес плюс индекс
Весь приём держится на одной константе и одной формуле. Базовый адрес addrTable — точка входа таблицы в памяти. Адрес i-го элемента: addrTable + i * sizeof(int). На Uno sizeof(int) равен двум, поэтому элементы стоят вплотную: байты 1-2, 3-4, 5-6 и дальше. Цикл for из урока про циклы перебирает индексы, формула переводит индекс в адрес — и ни одного ручного числа в коде сохранения не остаётся. Восьмая точка и восемьдесят восьмая считаются одинаково честно.
Внутри каждого элемента живёт знакомая раскладка двух байтов из счётчика включений и побайтовой записи: старший байт по меньшему адресу, младший по большему. Только руками её делать не нужно — EEPROM.put(addr, points[i]) принимает целое и раскладывает сам, а внутри у него update-семантика из урока про put и get: не изменившийся байт не пишется и ресурс не тратится.
Правка одного элемента — то, ради чего всё затевалось. Поправилась пятая точка — put в адрес addrTable + 5 * sizeof(int), и из шестнадцати байт таблицы реальную запись получают один-два. Соседние ячейки не трогаются, а шанс обесточить плату ровно посреди блока падает в восемь раз против перезаписи всей таблицы. Когда точка ценная, а правки частые, расчёт ресурса начинают именно с этого места.
Массив в SRAM и таблица в EEPROM живут параллельной жизнью: при старте цикл get наполняет оперативный массив из памяти, при изменении — цикл put сливает обратно. Это тот же паттерн из урока про массивы, только с энергонезависимым тылом. Объём считается на пальцах: восемь int — шестнадцать ячеек, двадцать поправок — сорок, массив byte из шестнадцати значений — те же шестнадцать. Килобайт безжалостен: таблица на сто int уже ест двести ячеек, и соседям остаётся восемьсот.
#include <EEPROM.h> // Карта памяти: 0 - magic, 1..16 - таблица из 8 int (по 2 байта)const int addrMagic = 0;const int addrTable = 1;const int POINTS = 8; int points[POINTS] = {102, 201, 305, 402, 512, 610, 705, 801}; int addrOf(int i) { // адрес i-го элемента return addrTable + i * sizeof(int); // шаг = размеру элемента} void saveAll() { for (int i = 0; i < POINTS; i++) { EEPROM.put(addrOf(i), points[i]); // put сам разложит 2 байта }} void loadAll() { for (int i = 0; i < POINTS; i++) { EEPROM.get(addrOf(i), points[i]); }} void writePoint(int i, int value) { // правка одной точки EEPROM.put(addrOf(i), value); // соседние элементы не трогаются} void setup() { Serial.begin(9600); if (EEPROM.read(addrMagic) != 170) { // первый запуск saveAll(); EEPROM.update(addrMagic, 170); } loadAll(); for (int i = 0; i < POINTS; i++) { Serial.print("p"); Serial.print(i); Serial.print("="); Serial.println(points[i]); }} void loop() { // меню кнопок меняет points[i] и вызывает writePoint(i, points[i]) delay(100);}В адресной функции addrOf спрятана вся ценность приёма. Написать addrTable + i * 2 короче, но двойка там захардкожена: перенесёте скетч на плату с четырёхбайтовым int — и таблица прочитается со сдвигом через элемент, значения перепутаются попарно. sizeof(int) ничего не стоит во время работы — это константа времени компиляции — и снимает весь класс проблем переносимости: шаг всегда равен размеру элемента. Поменяли тип таблицы с int на long — формула сама удвоила шаг, версия с литералом молча испортила бы все адреса.
Ту же формулу используйте и для границ. Таблица кончается на addrTable + POINTS * sizeof(int) — в раскладке выше это адрес 17. Всё, что живёт в EEPROM дальше, — другие настройки, счётчики, моточасы, — ставьте после границы, а не по памяти о старых адресах. Одна строка проверки перед загрузкой не помешает: если addrTable + POINTS * sizeof(int) перевалило за EEPROM.length(), таблица не влезает — это уже вопрос архитектуры, компилятор здесь не помощник. На Mega с её 4096 ячейками тот же скетч получает запас вчетверо — но раскладку это не меняет, меняет только потолок.
И про раскладку соседей. Таблице комфортно с нулевого адреса или сразу после magic-байта — я кладу её первой, потому что правки таблицы самые частые, а частых жильцов выгодно селить там, где их удобно искать. Счётчики и настройки, которые меняются раз в месяц, ставьте дальше — после addrTable + POINTS * sizeof(int). Когда таблиц несколько, каждой — своя константа базы и своя константа длины, и никогда не считайте базу второй таблицы в голове: один sizeof в формуле дешевле одной ночной отладки.
Чтение по индексу в цикле работы — та же формула, только без цикла по всей таблице. Меню из двух кнопок держит переменную sel: кнопка вправо гонит sel от нуля до семи и печатает текущую точку, кнопка «плюс» вызывает writePoint(sel, новое) — и на экране всегда живёт одна правленная точка, а не весь массив. Это ровно тот интерфейс, под который я переделал коптильню после истории с шестой точкой: правка по месту, без перезаливки всей таблицы и без риска тронуть соседей.
Проверяем вживую
| Задача | Россыпь отдельных переменных | Массив с базовым адресом |
|---|---|---|
| Поменять пятую точку | вспомнить имя и адрес пятой переменной | writePoint(5, value) — адрес из формулы |
| Добавить девятую точку | новая переменная, новые адреса, проверка наезда | POINTS = 9 — правка в одном месте |
| Перенести скетч на ESP32 | пересчитывать все адреса руками | sizeof в шаге — код подстроится сам |
| Перепутанный адрес | ждал на шестой копипасте — и дождался | исключён: адрес считает арифметика |
- Адрес i-го элемента — addrTable + i * sizeof(int): шаг всегда равен размеру элемента, раскладка переносится между платами без правок
- Вся таблица сохраняется и читается одним for-циклом; правка одного элемента — put в один адрес, соседи не трогаются
- put внутри пропускает неизменившиеся байты: перезапись таблицы ради одной точки стоит один-два байта ресурса
- Восемь int — 16 ячеек, восемь long — 32; граница addrTable + N * sizeof(int) не должна пересекать EEPROM.length()
- Число элементов — одна константа для всех циклов: литерал с лишними итерациями молча перезапишет соседей по памяти
- 1. Как вычислить адрес третьего элемента int-таблицы с базовым адресом 10?
- 2. Таблицу правят по одному элементу в неделю. Сколько байт реально перезапишет EEPROM.put на правке одной точки int?
- 3. Почему в шаге адреса sizeof(int), а не двойка-литерал?
- 4. Цикл сохранения таблицы на восемь точек случайно бежит до десяти. Что произойдёт?
- 5. Сколько ячеек EEPROM займёт таблица из восьми long?
Массив закрывает случай, когда значений много и они однотипные: таблицы поправок, порогов, расписаний. Заведите привычку селить такие наборы одним циклом с базовым адресом — и половина граблей с адресами исчезнет навсегда, а вторая половина перейдёт к соседям по мастерской.
У счётчика наработки характер другой: значение одно, зато оно растёт каждую секунду работы мотора. Писать его часто — убить ячейку за сутки, писать редко — терять часы наработки при каждом сбое питания. Между этими границами есть период, при котором ячейка живёт годами, а потеря измеряется минутами, — его и найдём в следующем уроке про моточасы.
Частые вопросы по теме урока
- Как вычислить адрес третьего элемента int-таблицы с базовым адресом 10?
- Шаг равен размеру элемента: int занимает два байта, третий элемент лежит по адресу 10 + 6 = 16. Ячейки 13-15 в этой раскладке — хвост второго элемента, они трогаются только при его правке. 10 + 3 * sizeof(int) — на Uno это адрес 16
- Таблицу правят по одному элементу в неделю. Сколько байт реально перезапишет EEPROM.put на правке одной точки int?
- Put пишет по update-семантике: в двухбайтовом int перезапишутся только изменившиеся байты, соседние элементы не трогаются. Поэтому правку одной точки и называют правкой без перезаписи таблицы. Один-два байта изменённого элемента
- Почему в шаге адреса sizeof(int), а не двойка-литерал?
- На Uno int — два байта, на ESP32 — четыре. Формула с sizeof сохраняет раскладку при переносе, литерал ломает её молча. На других платах int занимает другое число байт — код подстроится сам
- Цикл сохранения таблицы на восемь точек случайно бежит до десяти. Что произойдёт?
- EEPROM не знает о ваших массивах: записи по адресам за границей лягут в чужие ячейки — например, в счётчик, живущий сразу после таблицы. Признаков поломки не будет до первого чтения чужих данных. Две лишние записи перезапишут ячейки за границей таблицы
- Сколько ячеек EEPROM займёт таблица из восьми long?
- long — четыре байта: восемь элементов по четыре — тридцать две ячейки, шаг в цикле даст sizeof(long) и расставит адреса сам. Тридцать два