Счётчик прокачанных литров на поливе я повесил на геркон: импульс — инкремент и EEPROM.write по адресу пять, скетч в четыре строки. Работало около недели. В воскресенье показания замерли на одном числе и дальше не двигались ни на литр: ячейка честно выработала ресурс — сто тысяч циклов записи при импульсе раз в пять секунд, это 100000 умножить на 5 секунд, около 5,8 суток непрерывной прокачки. Память не сломалась — я износил её по расписанию.
Сначала арифметика. Цифра 100000 из даташита ATmega328P относится к одной ячейке, а не к чипу: тысяча ячеек живёт тысячей параллельных жизней, и убить можно одну, не тронув остальные. Запись через write() тратит цикл при каждом вызове: байт стирается и программируется заново, даже если значение не изменилось ни на бит. Отсюда две экономики, вокруг которых построен урок: не писать лишний раз — update-ом, и не бить всё время в одну ячейку — wear leveling-ом.
План такой: посчитаем бюджет износа для типовых сценариев, проверим, где update спасает, а где вырождается в write, и соберём раскатку счётчика по N ячейкам — приём из старого аппнота AVR101, который Atmel писала ещё под первые микроконтроллеры. Базовую дисциплину записи мы проходили в уроке про update и износ ячеек, расчёт по частоте записей — в уроке про износ и частоту; здесь добавляется последний этаж — таблица сценариев и живой тренажёр.
update вместо write: экономия с условием
update внутри библиотеки устроен просто: перед записью он читает ячейку и пропускает программирование, если там уже лежит то же значение. Для статуса, который скетч прилежно переписывает каждую минуту, но меняет раз в сутки, экономия гигантская: из 1440 вызовов в сутки реальную запись получают один-два. Сто тысяч реальных записей при таком темпе — 274 года. Тот же скетч с write() сжёг бы ячейку за 69 дней — 100000 минут, ровно по числу вызовов.
Условие, на котором всё это стоит: между вызовами значение действительно не меняется. Конфиг, статус «всё работает», уставка — да. Растущий счётчик — нет: инкремент меняет байт при каждом сохранении, сравнение внутри update всегда даёт расхождение, и update вырождается в write без единой сэкономленной ячейки. Худший клиент EEPROM — именно счётчик, и дисциплина вызовов его не спасает: спасает только смена схемы хранения.
Цена одного вызова — около 3,3 мс программирования по даташиту, и это время loop простаивает на записи. Но убивает не медлительность, а повторяемость. С ресурсом тоже есть нюанс: гарантированные 100000 циклов Atmel задаёт для 85 °C, а в релайабилити-таблицах для комнатной температуры фигурирует миллион. Десятикратным запасом нельзя проектировать, а радоваться ему после факта — можно.
#include <EEPROM.h> // Счётчик импульсов с раскаткой по 16 ячейкам (приём AVR101):// значение кодируется числом единичных битов в 16 ячейках,// каждый инкремент гасит один бит. Свежая ячейка = 0xFF.const int BAZA = 0; // ячейки счётчика: адреса 0..15const int YACHEEK = 16;volatile unsigned long schetchik = 0; int pervayaEdinica() { for (int i = 0; i < YACHEEK; i++) { if (EEPROM.read(BAZA + i) != 0) { return i; } } return -1; // все ячейки нулевые - цикл пройден} void inkrement() { int cel = pervayaEdinica(); if (cel < 0) { // счётчик дошёл до нуля: новый цикл for (int i = 0; i < YACHEEK; i++) { EEPROM.update(BAZA + i, 255); } cel = 0; } int val = EEPROM.read(BAZA + cel); EEPROM.update(BAZA + cel, val & (val - 1)); // гасим младший единичный бит schetchik++;} void setup() { Serial.begin(9600); pinMode(2, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(2), inkrement, FALLING); // НАИВНЫЕ ВАРИАНТЫ - ради сравнения: // EEPROM.write(20, EEPROM.read(20) + 1); // одна ячейка: 28 часов при раз в секунду // EEPROM.write(21, 0); // write тратит цикл даже на то же значение Serial.println("gerkon na D2: impuls = inkrement");} void loop() { Serial.print("schetchik="); Serial.println(schetchik); delay(5000);}Закомментированные строки — те варианты, которые я встречаю в готовых скетчах чаще, чем хотелось бы. Одна ячейка с write при каждом импульсе живёт: при раз в секунду — 27,8 часа, при раз в пять секунд — 5,8 суток, при раз в минуту — 69 дней. Переезд на update ничего не меняет: инкрементное значение новое каждый раз. Единственное, что реально растягивает срок, — размазать записи по нескольким ячейкам.
Для конфига wear leveling выглядит как круг из слотов с маркером поколения — такой круг мы собирали в уроке про wear leveling своими руками. Для счётчика есть решение без всяких маркеров: не хранить значение, а кодировать его формой битов. Тогда «указатель» — позиция первого единичного бита — живёт в самих данных и не тратит ни одной ячейки.
Раскатка счётчика: указатель по битам
Схема: под счётчик отводим N ячеек, на свежей памяти во всех лежит 0xFF. Значение счётчика — число единичных битов во всех N ячейках суммарно: все единицы — счётчик на максимуме, 8 × N. Каждый инкремент гасит ровно один бит — младший единичный бит первой ячейки, где единицы ещё остались. Ячейка принимает восемь записей подряд, по одной на бит, потом отдыхает, пока не доработают соседи.
За полный цикл из 8 × N инкрементов каждая ячейка получает ровно восемь записей — поровну, без концентраторов износа. Ресурс 100000 циклов на ячейку даёт 12500 циклов на ячейку за цикл счётчика, а всего — 100000 × N инкрементов: раскатка по 16 ячейкам хранит 1,6 миллиона инкрементов, по 32 — 3,2 миллиона. Ни маркера, ни отдельного указателя: первый единичный бит и есть текущая позиция записи, он спрятан в данных.
Цена — диапазон: 16 ячеек кодируют счётчик по модулю 128, после чего цикл начинается заново. Поэтому взрослые схемы комбинируют: раскатывают младшую, самую быструю часть, а старшие байты, меняющиеся в сотни раз реже, живут в обычных ячейках и экономят ресурс сами по себе. Средний байт растущего счётчика переписывается раз в 256 инкрементов — его срок автоматически в 256 раз дольше младшего: при импульсе раз в пять секунд и раскатке на 16 ячеек младшей части хватает на 93 суток непрерывной прокачки, среднему байту — на четыре года.
На тренажёре видно то, что в абзацах остаётся словом: в режиме одной ячейки дамп износа показывает вечно растущий счётчик у нулевой позиции, в режиме раскатки полоса выравнивается в ровную лесенку. Поменяйте N ручкой — и поле сбрасывается, потому что раскладка битов привязана к числу ячеек. В живом устройстве N выбирают один раз, до первой прошивки, и не трогают до конца проекта.
Бюджет циклов под конкретный проект удобно сводить в таблицу до написания кода — как в уроке про бюджет циклов. Честная оговорка про симулятор: движок эмулирует EEPROM массивом ячеек без счётчика усталости, износ в тренажёре считает модель в переменных. Но раскладка битов и порядок записей настоящие: на плате они уедут байт-в-байт такими же, добавится только стартовый проход по 16 ячейкам при включении.
Отдельная грабля — «очистить память» при каждом старте: цикл for по всем 1024 ячейкам с write(adr, 255), чтобы «точно знать, что там чисто». На свежей плате операция бесполезна, на обжитой — каждая перезагрузка списывает по циклу с каждой ячейки: сотня перезапусков — минус десятая часть ресурса всего чипа разом. Время тоже платит: по 3,3 мс на байт это 3,4 секунды молчания при старте. Страниц и секторов у EEPROM ATmega328P нет — она стирает и пишет одиночные байты, и вся «подготовка памяти» приехала из мира flash, где решает задачу, которой здесь не существует. Сброс к заводским тем же грехом страдает: переписывайте шестнадцать байтов блока, а не весь килобайт — в 64 раза меньше циклов и времени.
Чтобы код раскатки перестал быть шаманством: val & (val - 1) гасит младший единичный бит, 0b00001100 превращается в 0b00001000, и за восемь таких операций ячейка доходит до нуля и передаёт эстафету соседней. put, которым многие пишут структуры, в AVR-ядре свёрнут в цикл update по sizeof байтов — неизменившиеся байты он пропускает так же, как ручной update, так что «put стирает весь блок целиком» — миф. Правда, пишет он ровно sizeof, а не «сколько занято»: дырки выравнивания прогоняются через сравнение на общих основаниях, и что с ними не так, разбирает урок про sizeof и выравнивание.
Моё мнение после трёх убитых счётчиков: раскатка нужна не всегда. Темп ниже одной записи в час — и одна ячейка проживёт 100000 часов, одиннадцать лет непрерывной работы, и вся раскатка там — усложнение ради запаса, который не понадобится. Раз в минуту — 69 дней до могилы, раскатка обязательна. Раз в пять секунд — 5,8 суток, вопрос выживания проекта. Если темп плавает, считайте по пиковому часу: счётчик, пишущий раз в минуту ночью и раз в десять секунд днём, живёт по дневному расписанию — около 12 суток на одну ячейку. Граница проходит примерно у раза в час: ниже — дисциплина вызовов и update, выше — раскатка, и никакое смешивание не спасёт скетч, который пишет при каждом проходе loop.
Сценарии износа: кто и когда умирает
| Сценарий | Запись в ячейку | Ячейка иссякнет |
|---|---|---|
| Счётчик «раз в секунду», write в одну ячейку | 86400 записей в сутки в одну ячейку | 27,8 часа — классика учебных примеров |
| Счётчик литров, импульс раз в 5 секунд, write | 17280 записей в сутки в одну ячейку | 5,8 суток непрерывной прокачки |
| Статус каждую минуту, update, реально меняется раз в сутки | одна-две записи в сутки | 274 года |
| Счётчик раз в 5 секунд, раскатка по 16 ячейкам | по 8 записей на ячейку за цикл из 128 импульсов | 93 суток непрерывной прокачки |
- 1. На что в даташите указан ресурс 100000 циклов записи?
- 2. Счётчик инкрементируется раз в секунду и пишется write() в одну ячейку. Когда она иссякнет?
- 3. Почему update не экономит ресурс инкрементируемого счётчика?
- 4. Что кодируют N ячеек битовой раскатки счётчика?
- 5. Раскатка счётчика по 32 ячейкам меняет ресурс так:
- 100000 циклов — это на ячейку: тысяча ячеек живёт параллельными жизнями, и убить можно одну
- write() тратит цикл на каждом вызове; update пишет только изменившиеся байты — для статусов это экономия в сотни раз
- Инкрементируемый счётчик меняет байт при каждой записи: update вырождается в write, нужен wear leveling
- Битовая раскатка AVR101: значение равно числу единиц в N ячейках, инкремент гасит один бит, ресурс растёт в N раз
- При импульсе раз в 5 секунд одна ячейка живёт 5,8 суток, раскатка по 16 ячейкам — уже 93 суток непрерывной прокачки
Частые вопросы по теме урока
- На что в даташите указан ресурс 100000 циклов записи?
- У ATmega328P тысяча независимых ячеек, у каждой свой ресурс 100000 циклов. Убить можно одну, не тронув соседние, — именно так умирают счётчики, пишущие в фиксированный адрес. На одну ячейку
- Счётчик инкрементируется раз в секунду и пишется write() в одну ячейку. Когда она иссякнет?
- 100000 секунд — это 27,8 часа. Каждая запись меняет байт, поэтому ни update, ни дисциплина вызовов тут не помогают — нужна раскатка по ячейкам. Около 28 часов
- Почему update не экономит ресурс инкрементируемого счётчика?
- update сравнивает новый байт со старым и пишет только при расхождении. Растущий счётчик каждый раз даёт новое значение — сравнение бесполезно, износ как у write. Инкремент меняет байт при каждой записи: сравнение всегда даёт расхождение
- Что кодируют N ячеек битовой раскатки счётчика?
- Значение счётчика — суммарное число единичных битов по N ячейкам; инкремент гасит ровно один бит. Указатель записи спрятан в данных и не тратит отдельную ячейку. Единичные биты: их суммарное число равно значению
- Раскатка счётчика по 32 ячейкам меняет ресурс так:
- Каждая ячейка получает по 8 записей за цикл из 256 инкрементов, ресурс 100000 циклов на ячейку даёт 100000 × 32 инкремента — 3,2 миллиона. Растёт до 3,2 млн инкрементов — в 32 раза