Конфиг, который меняется раз в час, гарантия ячейки переживает спокойно — 11.4 года по прикидке прошлого урока. Но стоило уличному светильнику пересчитывать расписание каждые десять минут, как счётчик перезаписей показал 52560 за первый год: до конца гарантии полтора года. EEPROM.update здесь бессилен — значение меняется по-настоящему, цикл тратится честно. Уменьшать частоту было нельзя: расписание и есть функция устройства.
Выход известен со времён первых флешек: не бить в одну ячейку, а размазать запись по нескольким. Это и есть wear leveling, выравнивание износа. В фирменных библиотеках он встроен всегда; для внутренней EEPROM Uno его несложно собрать руками, и это один из самых благодарных приёмов раздела: восемь слотов дают восьмикратный запас, цена — пара десятков байтов и полчаса кода. Под словами «примерно в N раз» скрывается одна оговорка про сам маркер — к ней идём сразу.
Разберём механику по шагам: круг из слотов, маркер последней записи, который сам не должен износиться первым, порядок записи на случай пропадания питания и арифметику запаса. Родственник приёма у нас уже есть — кольцевой журнал показаний: правила те же, но журналу простительно битое место в истории, а конфиг обязан быть валиден в каждый момент. Разница видна на границе пропадания питания: журнал может потерять последнюю строку, а конфиг обязан пережить её вообще без потерь.
Круг из слотов и маркер
Идея помещается в одну строку: под конфиг отводится N слотов одинакового размера, каждая запись идёт в следующий по кругу, ресурс растёт примерно в N раз. После включения питания плате нужно найти свежий слот — значит, в памяти живёт маркер. Самый соблазнительный вариант — отдельная ячейка с номером последнего слота — плохой: она перезаписывается при каждом сохранении и умирает первой, раньше всех слотов круга. Круг, размазавший износ по восьми ячейкам, убитый маркером в девятой, — типовой промах первой реализации.
Рабочее решение — маркер внутри самого слота: поле «поколение», порядковый номер записи. Свежий слот — тот, у которого поколение старше; после старта это проход по восьми байтам. Отдельная ячейка-счётчик не нужна, и износ размазался в том числе по маркерам. Именно так устроены библиотеки: в лаборатории WearLeveling на docs.arduino.cc позиция помечается даже не байтом, а одиночными битами контрольных байтов, у библиотеки EEPROMWearLevel с GitHub та же идея.
Сколько слотов брать — компромисс байтов и лет. Круг из четырёх стоит 12 байтов и даёт четырёхкратный запас: его хватает, если записи реже раза в десять минут. Восемь — моя рабочая цифра для конфигов: 24 байта на Uno не жалко, а жизнь растягивается на порядок. Больше шестнадцати на внутренней памяти смысла мало — дешевле переехать на внешнюю микросхему, где слоты меряются сотнями.
Первая грабля — порядок записи внутри слота. Значение пишем раньше поколения: если питание пропадёт между двумя записями, слот останется со старым поколением и недописанным конфигом, и поиск свежего его проигнорирует. Платформа продолжит работать на прошлом валидном конфиге — просадка питания перестаёт быть катастрофой. Кто хочет гарантий байт-в-байт — вешает поверх поколения контрольную сумму, механика из урока про CRC настроек.
Отдельно про время. Запись одного байта внутренней EEPROM занимает около 3.3 мс по даташиту ATmega328P, и круг этого не меняет: за раз вы пишете один слот. Но если конфиг из десятков полей, цикл записи растягивается на десятки миллисекунд, и loop это почувствует: тяжёлые конфиги пишите страницей и планируйте запись через millis, а не посреди замера датчика.
Вторая грабля — потолок поколения. Байт вмещает номера до 255, и на подходе к потолку круг обнуляют: маркеры всех слотов стираются, нумерация начинается с единицы. При восьми слотах это случается раз на 250 оборотов, то есть на два миллиона записей — при записи раз в десять минут первые 38 лет обнуления не дождаться. Третья грабля — размещение: восемь слотов по три байта — 24 байта, и их стоит развести с журналом и калибровками по карте памяти на 1 КБ, чтобы круг не наступил на соседей.
#include <EEPROM.h> // Восемь слотов по три байта: [поколение, порог, резерв].// Свежий слот ищем по старшему поколению - маркер лежит в самом слоте,// отдельной ячейки-счётчика нет, умирать первой некому.// Кнопка D2 меняет порог и сохраняет по кругу, LED D12 горит после// первого полного круга. const int SLOTOV = 8;int tekushij = -1; // индекс свежего слотаint pokolenie = 0; // номер последней записиuint8_t porog = 40; int adresSlota(int i) { return i * 3;} void chitajSvezhij() { tekushij = -1; int luchshee = 0; for (int i = 0; i < SLOTOV; i++) { byte gen = EEPROM.read(adresSlota(i)); if (gen != 0xFF && gen >= luchshee) { // 0xFF - слот пуст luchshee = gen; tekushij = i; } } if (tekushij >= 0) { porog = EEPROM.read(adresSlota(tekushij) + 1); if (porog > 100) porog = 40; pokolenie = luchshee; }} void sohranitPoKrugu() { int sled = (tekushij < 0) ? 0 : (tekushij + 1) % SLOTOV; pokolenie = pokolenie + 1; if (pokolenie > 250) { // потолок байта: обнуляем круг for (int i = 0; i < SLOTOV; i++) { EEPROM.update(adresSlota(i), 0); } pokolenie = 1; } EEPROM.update(adresSlota(sled) + 1, porog); // значение раньше маркера EEPROM.update(adresSlota(sled), pokolenie); // поколение - последним tekushij = sled; Serial.print("slot="); Serial.print(sled); Serial.print(" pokolenie="); Serial.println(pokolenie); if (pokolenie >= SLOTOV) digitalWrite(12, HIGH);} void setup() { Serial.begin(9600); pinMode(2, INPUT_PULLUP); pinMode(12, OUTPUT); chitajSvezhij(); Serial.print("posle starta: slot="); Serial.print(tekushij); Serial.print(" pokolenie="); Serial.print(pokolenie); Serial.print(" porog="); Serial.println(porog);} void loop() { if (digitalRead(2) == LOW) { porog = porog + 5; if (porog > 100) porog = 20; sohranitPoKrugu(); delay(350); }}| Стратегия | Ресурс конфига | Цена |
|---|---|---|
| Одна ячейка | 100000 циклов | 0 байт сверху |
| Круг из 8 слотов с поколением | около 800000 циклов | 24 байта |
| Круг из 16 слотов | около 1600000 циклов | 48 байтов |
| Внешняя 24C256 с кругом | миллионы: износ по 32 КБ | провода и код |
Арифметика круга
Арифметика запаса линейная: N слотов умножают и ресурс, и годы. Восемь слотов — 800000 циклов: конфиг с записью раз в десять минут растягивается с 1.9 года до 15, раз в минуту — с 70 дней до года и четырёх месяцев. Шестнадцать слотов — 1600000 циклов и 48 байтов: на Uno это уже ощутимо, но всё ещё честно. Дальше по кругу: вдвое больше байтов — вдвое дольше жизнь, и никакой магии между ними нет.
Обратная ситуация: если конфиг меняется только в меню настройки раз в сезон, круг — лишний код. Счётчик перезаписей из прошлого урока дешевле и честнее покажет, что ресурс уходит со скоростью, которой хватит на десятилетия. Я ставлю круг тогда, когда частота записи переваливает за раз в сутки, а не раньше: до этого порога хватает update и счётчика.
Отдельная строка про ESP32: там самодельный wear leveling не нужен. И EEPROM-обёртка, и Preferences на ESP32 сидят поверх NVS, которая размазывает износ сама — как устроено это хранилище и что оно умеет сверх байтов, разобрано в уроке про Preferences на ESP32. Приём этого урока — для восьмибитных плат и внешних микросхем, где раскладкой памяти распоряжаетесь вы.
- Wear leveling размазывает запись по кругу из N слотов: ресурс конфига растёт примерно в N раз
- Маркер в отдельной ячейке умирает первым: поколение хранят внутри слота, свежий ищут по старшему номеру
- Значение пишут раньше поколения: при обрыве питания недописанный слот проигнорируется, конфиг останется прежним
- Потолок поколения-байта — 250-255: при обнулении круга стирают маркеры всех слотов
- На ESP32 самодельный wear leveling не нужен: NVS под EEPROM и Preferences размазывает износ сама
- 1. Почему маркер последней записи нельзя держать в отдельной ячейке?
- 2. Восемь слотов, конфиг меняется раз в 10 минут. Сколько лет запаса?
- 3. Питание пропало между записью значения и записью поколения в новый слот. Что прочитает плата после старта?
- 4. Чем маркер в официальной лаборатории WearLeveling отличается от поколения-байта из урока?
- 5. На ESP32 конфиг хранится через Preferences. Что вы делаете с wear leveling?
Когда ресурс нужен на порядки выше, запись уезжает на внешнюю микросхему — страница записи и раскладка на 24C256 разобраны в уроке про страницы записи: износ размазывается уже по тридцати двум килобайтам, и круг перестаёт быть насущным.
Перед переездом проверьте круг на столе: отключите питание платы посреди сохранения — после старта конфиг должен вернуться прежним, а не мусором. У меня именно этот тест однажды показал, что я писал поколение раньше значения: восемь месяцев круг молча хранил испорченный слот в самом свежем поколении. А последний типовой вопрос серии ждёт каждый новый проект: что делает скетч, когда память вообще ещё не трогали, — свежие 0xFF, версия конфига и дефолты первого запуска разбираются в следующем уроке.
Частые вопросы по теме урока
- Почему маркер последней записи нельзя держать в отдельной ячейке?
- Круг размазал износ по N слотам, а маркер принимает каждую запись в одну ячейку — он и станет первым трупом. Поколение хранят внутри самого слота. Она перезаписывается при каждом сохранении и умирает первой
- Восемь слотов, конфиг меняется раз в 10 минут. Сколько лет запаса?
- 52560 записей в год при 800000 циклов дают примерно 15 лет — в восемь раз дольше одной ячейки, по числу слотов. Около 15 лет
- Питание пропало между записью значения и записью поколения в новый слот. Что прочитает плата после старта?
- Значение пишется раньше поколения. Слот без нового поколения старше по номеру, поиск свежего его пропустит — плата продолжит на прежнем конфиге. Прошлый валидный слот: недописанный проигнорируется
- Чем маркер в официальной лаборатории WearLeveling отличается от поколения-байта из урока?
- Позиция помечается одним битом в контрольном байте: байт стирается не при каждом сохранении, а раз на восемь позиций. Пишется одиночными битами: контрольные байты изнашиваются в разы медленнее
- На ESP32 конфиг хранится через Preferences. Что вы делаете с wear leveling?
- И EEPROM-обёртка, и Preferences на ESP32 сидят поверх NVS, где выравнивание износа уже встроено. Ручной круг — приём для восьмибитных плат и внешних микросхем. Ничего: износ размазывает NVS