На сверлильном участке у знакомого мастера я ставил счёт готовых деталей: фотодатчик ловит проход, событие летит в журнал EEPROM, вечером хозяин смотрит выработку. Деталь появлялась каждые пять-шесть секунд, и журнал я честно вёл подряд: событие за событием, слот за слотом. Когда тридцать два слота кончились, я дописал сдвиг — новое событие всегда в конце, старые ползут к началу. Через шесть дней первые слоты начали врать: коды залипали, время не менялось. Ячейки, на которые ложился каждый сдвиг, выработали ресурс — а их ресурс, как мы считали в уроке про износ EEPROM, конечен и измеряется записями, а не днями.
Вечером я пересчитал на калькуляторе и стало не по себе. Сдвиг тридцати двух слотов — это 124 перезаписанных байта на каждое событие, плюс четыре байта нового слота: итого 128 операций записи. Деталь каждые шесть секунд — примерно 720 событий в час — и первые ячейки блока получали по 720 записей в час. Сто тысяч циклов делятся на это за 139 часов — около шести суток. Я убил журнал ровно за неделю, ни разу не перезаписав его «лишний» раз руками.
Лечение от этого старое, как сами flash-чипы: кольцевой буфер. Индекс head показывает, куда писать следующее событие; после последнего слота запись идёт в первый — поверх самого старого. Журнал всегда хранит последние N событий, каждая ячейка блока изнашивается одинаково и в 32 раза медленнее, а процессор на каждое событие тратит четыре записи вместо 128. В этом уроке соберём такой журнал: 32 слота по 4 байта, код события плюс время — и заодно решим, где после перезагрузки платы искать head.
Честная оговорка про симулятор: EEPROM в симуляторе не эмулируется — модель памяти это массив mem в скетче, и свежие ячейки я заполняю единицами-255, как на настоящей плате. Зато механика кольца видна вчистую: кнопка и тумблер генерируют события, после восьмого слота модель пойдёт по второму кругу и скажет об этом в Serial.
Индекс head: куда писать следующее событие
Слот журнала — четыре байта. Первый хранит код события: 1 — кнопка, 2 — тумблер, 3 — перезапуск платы. Остальные три — время в секундах: millis(), делённое на тысячу, разложенное на старший, средний и младший байты. Как millis отмеряет время без delay, мы уже разбирали в уроке про millis вместо delay — здесь берём то же самое, но сбрасываем в память. Один байт секунд дал бы всего 255 секунд — четыре минуты журнала, маловато. Три байта — это 16777215 секунд, почти 194 дня непрерывной работы: запас с лихвой, потому что сам millis переполняется раньше, через 49,7 суток.
Теперь head. Это просто номер следующего слота: записали событие в слот head — и head = head + 1, а после последнего слота снова 0. В EEPROM хранить head невыгодно: его ячейка приняла бы по записи на каждое событие и стала бы концентратором износа — тем самым, против которого всё затевалось. Приём проще: пустой слот помечен кодом 255, а свежая EEPROM читается всеми единицами, как я разбирал в уроке про память без питания. Поэтому при включении head — номер первого слота, где байт кода равен 255. Пустых не нашлось — буфер полон, пишем с нулевого поверх самого старого, ради этого кольцо и делали.
Каждую запись делаем через EEPROM.update: он сам читает ячейку и пропускает байт, если там уже лежит то же значение, — эту экономию мы включали ещё в уроке про update и износ ячеек. В слоте она работает на удивление хорошо: старший байт времени меняется раз в 65536 секунд — раз в восемнадцать часов, средний — раз в 256 секунд. Реально перезаписываются код события и младший байт времени, то есть из четырёх вызовов update многие вообще не трогают ячейки.
#include <EEPROM.h> const int SLOTS = 32; // слотов в журналеconst int SLOTB = 4; // байт в слоте: код + три байта секундconst int BASE = 200; // журнал с адреса 200, настройки не трогаем int head = 0; // номер следующего слота для записиint prevBtn = HIGH;int prevTgl = LOW; int findHead() { // после перезагрузки: первый пустой слот for (int i = 0; i < SLOTS; i++) { if (EEPROM.read(BASE + i * SLOTB) == 255) { return i; } } return 0; // буфер полон - пишем поверх самого старого} void logEvent(int code) { int slot = head; int base = BASE + slot * SLOTB; long t = millis() / 1000; // секунды с включения EEPROM.update(base, code); EEPROM.update(base + 1, t / 65536); EEPROM.update(base + 2, (t / 256) % 256); EEPROM.update(base + 3, t % 256); head = head + 1; if (head >= SLOTS) { head = 0; // кольцо замкнулось } Serial.print("evt code="); Serial.print(code); Serial.print(" slot="); Serial.println(slot);} void setup() { Serial.begin(9600); pinMode(2, INPUT_PULLUP); pinMode(3, INPUT_PULLUP); pinMode(12, OUTPUT); head = findHead(); Serial.print("head="); Serial.println(head);} void loop() { int b = digitalRead(2); if (prevBtn == HIGH && b == LOW) { logEvent(1); digitalWrite(12, HIGH); delay(60); digitalWrite(12, LOW); } prevBtn = b; int tg = digitalRead(3); if (prevTgl == HIGH && tg == LOW) { logEvent(2); } prevTgl = tg; delay(30);}Разберём два места, где этот скетч обычно ломают. Первое — события по фронту, а не по уровню: проверка prevBtn ловит момент нажатия, иначе пока кнопка зажата, журнал набьётся сотнями одинаковых записей и кольцо обнулится само. Второе — границы: slot всегда получается делением по модулю от 0 до 31, за BASE плюс 128 байт индекс не выходит. Это не перестраховка: EEPROM.write на платах AVR не знает о вашей карте памяти, и ловушка с адресом за краем стоит отдельной заметки ниже.
Теперь деньги — сравнение с тем сдвигом, с которого я начал. Деталь каждые шесть секунд, 720 событий в час. Сдвиг: 128 байт записи на событие, 720 умножить на 128 — больше 92 тысяч записей в час распределяются неравномерно, первые ячейки получают все 720, и ресурс кончается за 139 часов. Кольцо: 4 байта на событие, каждая ячейка блока принимает 720/32 — примерно 22 записи в час, и живёт 100000/22 — около 4400 часов, почти полгода непрерывной работы. Ровно в 32 раза дольше — по числу слотов; та же ротация, что в уроке про ресурс памяти, только по кругу вместо по порядку.
Скорость тоже в подарок. Сдвиг 32 слотов — это последовательность из 128 чтений и записей по 3,3 мс каждая: около 0,4 секунды занятого процессора на каждое событие, в течение которых не работают ни кнопки, ни экран. Кольцо делает те же четыре update-а за 13 мс — плате нечего заметить. Полное стирание журнала при заполнении, кстати, тоже проигрывает: 32 записи кода 255 раз в 32 события — по износу как кольцо, но история при стирании теряется вся, а в кольце она просто стареет.
Размер блока — вопрос мнения, и моё такое: 32 слота на 128 байт — хороший компромисс при настройках по соседству. Если журнал важнее настроек, берите 64 слота — четверть всей EEPROM Uno, — но тогда сначала посчитайте частоту событий, как мы это делали выше, и убедитесь, что году вы жертвовать не готовы. Каждый слот стоит одного байта из тысячи: жадничать тут особо нечего.
Один редкий случай в схеме всё же остаётся: буфер, замкнувшийся до перезагрузки. findHead не найдёт пустого слота и вернёт ноль, а в полном кольце нулевой слот не обязательно самый старый. На практике это почти не мешает: журнал редко успевает замкнуться до первого перезапуска, а если замкнулся, порядок первых слотов восстанавливается по времени — секунды-то лежат в каждом слоте. Для педантичных проектов ищите при старте слот с самым ранним временем, для моих дачных приборов лишние два десятка строк кода не окупились ни разу: перепутать местами два события из тридцати двух — цена, которую я плачу за простоту.
Цена каждого варианта в числах
| Стратегия журнала | Записей байт на событие | История при заполнении | Жизнь первых ячеек при 720 событиях в час |
|---|---|---|---|
| Писать подряд без круга | 4 | запись уходит за край блока | зависит от того, куда попало |
| Сдвиг слотов, новое в конце | 128 | сохраняется, но дорого | около 139 часов |
| Стирать всё при заполнении | 32 раз в 32 события | теряется целиком | как кольцо, но с обнулением |
| Кольцевой буфер с head | 4 | старое затирается по одному слоту | около 4400 часов |
- Слот журнала — 4 байта: код события и три байта секунд; uint24 вмещает почти 194 дня непрерывного счёта
- head — номер следующего слота; после записи head = head + 1 по модулю SLOTS; хранить его в EEPROM не нужно
- Пустой слот — байт 255: свежая EEPROM вся из единиц, поэтому head после перезагрузки ищется первым пустым
- Кольцо против сдвига: 4 записи на событие против 128 — износ и пауза в loop меньше в 32 раза
- EEPROM.update пропускает неизменённые байты: старший байт времени реально перезаписывается раз в 18 часов
- За границей EEPROM на AVR адрес оборачивается в начало памяти — границы блока держите в константах
- 1. Сколько байт перезаписывает журнал со сдвигом при каждом новом событии, если в блоке 32 слота по 4 байта?
- 2. Что происходит после записи события в последний слот кольца?
- 3. Как скетч находит индекс head после перезагрузки платы?
- 4. Сколько времени вмещают три байта счётчика секунд?
- 5. Почему пустой слот ищут именно по байту 255?
Журнал пишет — но польза от него появляется, когда его можно прочитать глазами. Байты 1 12 0 37 2 12 0 40 глазам не товарищ, и переводить их на бумажке, как сделал я в ту грозовую неделю, — работа не для человека. Следующий урок учит плату отвечать по кнопке: обход буфера от старых событий к новым, коды словами, время в чч:мм:сс — весь отчёт за секунду. Проверьте себя по ходу: если на бумажке у вас уже есть такая расшифровка, значит, её место — в скетче, а не в столе.
Частые вопросы по теме урока
- Сколько байт перезаписывает журнал со сдвигом при каждом новом событии, если в блоке 32 слота по 4 байта?
- Сдвиг двигает 31 слот — 124 байта — и пишет новый слот: итого 128 операций на каждое событие. Кольцу хватает четырёх. 128
- Что происходит после записи события в последний слот кольца?
- head = head + 1 по модулю SLOTS: после последнего слота индекс возвращается к нулевому, и самое старое событие затирается. В этом смысл кольца. Следующее событие пишется в слот 0 — поверх самого старого
- Как скетч находит индекс head после перезагрузки платы?
- Хранить head в EEPROM невыгодно — его ячейка изнашивалась бы на каждом событии. Свежая память читается 255, поэтому первый пустой слот и есть голова записи. Ищет первый слот с байтом 255 — признаком пустого
- Сколько времени вмещают три байта счётчика секунд?
- Три байта — это 16777215: почти 194 дня секунд. Меньше, чем кажется, не хватает одного байта — там всего 255 секунд, четыре минуты журнала. Около 16,7 миллиона секунд, почти 194 дня
- Почему пустой слот ищут именно по байту 255?
- Пустая ячейка EEPROM физически хранит единицы — при чтении это 255. Коды событий поэтому начинают с 1, оставляя 255 под признак пустоты. Свежая EEPROM читается всеми единицами: 255 значит сюда ещё не писали