Панель контроля зарядки: сверху бар из клеток и проценты, снизу статус и счётчик циклов. Первая версия переписывала всё целиком на каждом проходе loop: lcd.clear(), потом тридцать две ячейки заново. Экран стробил так, что коллега спросил, не бракованный ли дисплей. Браком оказался алгоритм: тридцать две записи ради двух изменившихся цифр, каждый кадр, без пауз.
Анатомии мерцания посвящён отдельный урок — там и clear() под микроскопом, и перерисовка поверх; классический бар из своих символов собран в уроке про прогресс, а спиннер с процентами — в уроке про загрузку. Здесь я разбираю дисциплину кадра: кэш прошлого состояния, поля фиксированной ширины и счётчик записей, который покажет, сколько шины вы съели зря.
Правило укладывается в одну фразу: пишем ячейку только тогда, когда её содержимое изменилось. Всё остальное в уроке — производные этого правила и цифры, чтобы спорить с собой аргументами, а не вкусами.
Свои цифры снять несложно. Запишите millis() до кадра и после, разницу печатайте в Serial раз в секунду — это и есть ваш бюджет шины в миллисекундах. На моём стенде с Uno и клоном PCF8574 полный кадр с clear занимал порядка тридцати-сорока миллисекунд, точечный апдейт спокойной панели — две-четыре. Сравните кадр с периодом обновления: если кадр съедает треть периода, вы уже на полпути к стробу и зависшим транзакциям соседей по шине.
Цена ячейки на шине
Начнём с даташита. Команда clear исполняется 1,52 миллисекунды — это голое время контроллера HD44780, ещё до всякой шины. Дальше шина: печать одного символа через PCF8574 — это несколько байтовых транзакций на полубайты со стробами, при 100 кГц на ячейку уходит порядка одной-двух миллисекунд в зависимости от библиотеки. Полный кадр 16×2 с clear перед ним — на практике десятки миллисекунд шины; точечный апдейт из четырёх-пяти ячеек — единицы.
Разница становится весом, когда на той же шине заводится жилец: датчик, часы, второй экран. При десяти обновлениях в секунду полная перерисовка держит шину занятой заметную часть времени и влезает в чужие транзакции; точечный апдейт — единицы процентов. Условие честности: цифры — для типичного клона на 100 кГц; на 400 кГц, куда некоторые гонят шину, всё делится на четыре, но клоны PCF8574 на повышенной частоте начинают чудить — я не поднимаюсь выше 100 кГц на шинах с двумя устройствами. Метеостанция из итогового проекта раздела как раз держала LCD и датчики на одном I2C, и часть странностей чтения ушла не со схемой, а вместе с лишними перерисовками.
Адресация при этом дешёвая: setCursor исполняется 37 микросекунд — как почти любая команда контроллера, кроме clear и home. Двигать курсор хоть на каждую ячейку — не трата; трата — переписывать неизменное. Поэтому в скетче ниже курсор дёргается часто, а за записями следит счётчик.
Как это выглядит у пользователя: полная перерисовка на общих проводах с датчиком даёт редкие, но регулярные подвисания чужих чтений, которые обычно списывают на плохой контакт. Точечный апдейт делает поток шины разреженным — в логическом анализаторе это видно сразу, а без анализатора его показывает тот самый счётчик записей за кадр.
Стратегий обновления три, и я держу их в голове как лестницу — от грубой к точной:
| Подход | Записей за кадр | Когда беру |
|---|---|---|
| clear() плюс вся панель заново | 32 ячейки и clear | Смена экрана: из меню в панель |
| Печать поверх без clear | 10-20 | Один виджет, редкие обновления |
| Точечный дифф с кэшем кадра | 1-8 | Панель из трёх-пяти виджетов |
| Перерисовка по таймеру без проверок | 32 | Не беру: то, от чего уходим |
Кэш прошлого кадра
Паттерн — на три переменные: prevPercent, prevStatus, prevSpin. Перед записью сравниваем новое значение с кэшем: совпало — пропускаем, изменилось — пишем и запоминаем. Для бара добавляется арифметика клеток: переписываем только диапазон между старым и новым заполнением, клетка за клеткой. На спокойной панели девяносто процентов кадров заканчиваются нулём записей — шина молчит вместе с экраном, и это не оптимизация ради оптимизации, а запас на случай, когда шина понадобится чему-то ещё.
На моём зарядном приборе за минуту в спокойном режиме счётчик показывал три-пять записей на кадр и добрую сотню кадров с нулём: проценты в паузе не меняются, спиннер погашен, статус прежний. Такие нулевые кадры — цель дисциплины: экран живой для глаза, когда есть что показать, и невидимый для шины, когда показывать нечего. Раньше я мерил качество интерфейса картинкой; теперь мерю числом записей за кадр — картинка у всех одинаковая, а шине от неё дышится по-разному.
Таймеры кадра — на millis, без delay: в продвинутом разделе этому посвящён целый урок, а delay здесь съел бы и реакцию кнопки, и спиннер. Процесс растёт на свой тик, спиннер крутится на свой, кнопка опрашивается каждый проход loop — три независимых ритма на одной петле.
Вторая половина дисциплины — поля фиксированной ширины. Проценты живут в трёх знаках: 5 печатается как пробел-пробел-5, 100 — как есть. Без поля двузначное число после трёхзначного оставит прилипший хвост — та же механика, что с ведущими нулями в уроке про числа на LCD. Ширина поля известна заранее, хвосты затираются пробелами в момент записи, а не отдельным проходом по экрану.
Со статусом забота обратная: слово укорачивается. RABOTA — шесть знаков, PAUZA — пять, и без затирания от старого слова остаётся буква. Поэтому статусное поле пишется одной строкой фиксированной длины: PAUZA с запасным пробелом в конце. А спиннер в углу живёт по своему таймеру и меняет одну ячейку раз в 250 миллисекунд — единственная анимация, которая панели действительно нужна: палочка крутится, значит loop не завис.
Честности ради: точечный дифф нужен не всегда. Один виджет с обновлением раз в секунду — как термометр из урока про показания без мерцания — обходится печатью поверх без всяких prev-переменных: десяток ячеек раз в секунду не заметит ни глаз, ни шина. Дифф окупается, когда виджетов несколько, кадры частые, а шина общая.
Есть и цена. Каждая новая полоска панели — новая пара кэш-значение и ветка сравнения; на трёх-пяти виджетах это комфортно, на пятнадцати — россыпь переменных. В больших приборках я перехожу на массив прошлого кадра по всем ячейкам и сравниваю в цикле: тот же дифф, но одна структура вместо пятнадцати переменных. Порог перехода у каждого свой, мой — около восьми виджетов.
Три ритма на одной петле
Панель живёт в трёх ритмах на одной петле: процесс тикает каждые 150 мс, спиннер — каждые 250, кнопка опрашивается каждый проход. delay() разрушил бы все три сразу, поэтому вся петля стоит на millis. Спиннер у меня гаснет в паузе сознательно: пауза — тихий экран, и палочка, которая крутится просто по таймеру, а не по делу, лжёт ровно как фейковый процент.
Кэш после clear() — тонкое место. После полной перерисовки prev-переменные уже не совпадают с экраном, и дифф оставит дыры там, где решил, что ячейка не менялась. Поэтому при перезапуске я не просто обнуляю значения, а ставлю prevPercent и prevStatus в -1: любое реальное значение не совпадёт с минус единицей, и панель перерисуется вся — один раз, честно, без строба.
Кнопка в этой дисциплине ведёт себя прилично: статус печатается через тот же кэш, шесть ячеек один раз при смене. Если после нажатия в Serial прилетело KADR 32 IZ 32 — вы где-то потеряли prev-переменную. Счётчик работает детектором дыр в дисциплине, и за это его терпят в рабочих логах. Заодно ритмы задают и отладку: паузу видно по Serial, спиннер — по экрану, кнопку — по счётчику, и ни один из трёх признаков не мешает двум другим.
#include <Wire.h>#include <LiquidCrystal_I2C.h> LiquidCrystal_I2C lcd(0x27, 16, 2); const int pot = A0;const int btn = 2;const int led = 12; int percent = 0;int prevPercent = -1; // -1 заставит переписаться после смены экранаint status = 1; // 0 - pauza, 1 - rabota, 2 - gotovoint prevStatus = -1;int spin = 0;unsigned long tickP = 0;unsigned long tickS = 0;int prevBtn = HIGH;int writes = 0; // счётчик ячеек за кадр void printPct(int p) { // поле фиксированной ширины: три знака lcd.setCursor(13, 0); if (p < 100) { lcd.print(" "); } if (p < 10) { lcd.print(" "); } lcd.print(p); writes = writes + 3;} void printStatus(int st) { lcd.setCursor(0, 1); if (st == 0) { lcd.print("PAUZA "); } if (st == 1) { lcd.print("RABOTA"); } if (st == 2) { lcd.print("GOTOVO"); } writes = writes + 6;} void drawFrame() { // статичная рамка - один раз при смене экрана lcd.setCursor(10, 0); lcd.print("| | ");} void setup() { Serial.begin(9600); pinMode(btn, INPUT_PULLUP); pinMode(led, OUTPUT); lcd.init(); lcd.backlight(); drawFrame(); Serial.println("PANEL GO");} void loop() { int nowBtn = digitalRead(btn); if (prevBtn == HIGH && nowBtn == LOW) { if (status == 2) { // после финиша кнопка перезапускает экран percent = 0; prevPercent = -1; status = 1; prevStatus = -1; digitalWrite(led, LOW); lcd.clear(); // смена экрана - clear() уместен drawFrame(); Serial.println("PANEL RESTART"); } else { if (status == 0) { status = 1; } else { status = 0; } } } prevBtn = nowBtn; unsigned long now = millis(); if (now - tickP >= 150) { // ритм процесса tickP = now; if (status == 1 && percent < 100) { int shag = map(analogRead(pot), 0, 1023, 1, 4); percent = percent + shag; if (percent > 100) { percent = 100; } if (percent >= 100) { status = 2; digitalWrite(led, HIGH); } } if (percent != prevPercent) { // бар: переписываем только разницу клеток int oldCells = -1; if (prevPercent >= 0) { oldCells = prevPercent / 10; } int newCells = percent / 10; if (oldCells < 0) { for (int i = 0; i < 10; i++) { lcd.setCursor(i, 0); if (i < newCells) { lcd.print("#"); } else { lcd.print("-"); } writes = writes + 1; } } else { if (newCells > oldCells) { for (int i = oldCells; i < newCells; i++) { lcd.setCursor(i, 0); lcd.print("#"); writes = writes + 1; } } else { for (int i = newCells; i < oldCells; i++) { lcd.setCursor(i, 0); lcd.print("-"); writes = writes + 1; } } } printPct(percent); prevPercent = percent; Serial.print("KADR "); Serial.print(writes); Serial.println(" IZ 32"); writes = 0; } if (status != prevStatus) { printStatus(status); prevStatus = status; } } if (now - tickS >= 250) { // спиннер: своя частота, одна ячейка tickS = now; if (status == 1) { spin = spin + 1; if (spin > 3) { spin = 0; } lcd.setCursor(11, 0); if (spin == 0) { lcd.print("|"); } if (spin == 1) { lcd.print("/"); } if (spin == 2) { lcd.print("-"); } if (spin == 3) { lcd.print("*"); } writes = writes + 1; } }}- 1. Сколько по даташиту исполняется команда clear у HD44780?
- 2. Почему setCursor на каждую ячейку не считают расточительством?
- 3. Панель изменила проценты и одну клетку бара. Сколько ячеек перепишет точечный дифф?
- 4. Зачем полю процентов фиксированная ширина в три знака?
- 5. Когда в дифф-панели уместен lcd.clear()?
- clear исполняется 1,52 мс по даташиту, полный кадр по I2C — десятки миллисекунд: считайте записи, а не кадры
- Кэш прошлого кадра: ячейка переписывается только при изменении, бар — только разница клеток
- Поля фиксированной ширины: проценты в трёх знаках, статус с запасным пробелом — хвосты не прилипают
- setCursor — 37 микросекунд: курсор двигать дёшево, дорого переписывать неизменное
- Счётчик записей за кадр в Serial — метрика вместо вкуса: единицы за кадр норма, тридцать две — сигнал
Частые вопросы по теме урока
- Сколько по даташиту исполняется команда clear у HD44780?
- Clear и home — самые медленные команды контроллера: 1,52 мс исполнения ещё до трафика шины. Остальные команды укладываются в 37 микросекунд. 1,52 миллисекунды
- Почему setCursor на каждую ячейку не считают расточительством?
- Адресация — самая дешёвая операция контроллера. Дорогое — переписывать неизменные ячейки, а не двигать курсор к нужной. Он исполняется за 37 микросекунд — дешевле любой записи
- Панель изменила проценты и одну клетку бара. Сколько ячеек перепишет точечный дифф?
- Три знака поля процентов плюс одна клетка бара. Полный кадр — 32 ячейки: разница в разы по трафику шины. 3-4
- Зачем полю процентов фиксированная ширина в три знака?
- Без фиксированной ширины двузначное число после трёхзначного оставляет прилипший символ. Поле с пробелами затирает хвост в момент записи. Чтобы после 100 не оставался хвост от двузначных чисел
- Когда в дифф-панели уместен lcd.clear()?
- clear стирает весь экран и сбивает кэш прошлого кадра. Внутри одного экрана обновляют ячейки поверх; clear — только при смене экрана. При смене экрана целиком — из меню в панель