Свой второй секундомер на LCD я убил сам, из лучших побуждений. Экран должен был показывать счётчик секунд, и я честно рисовал его в loop(): clear(), печать числа, delay(1000). Секунды шли красиво, ровно по одной, и я уже собирался хвастаться, когда прикрутил кнопку сброса — и она сработала с пятого нажатия. С десятого — с первого, потом снова молчание. Светодиод активности, который я повесил рядом для солидности, замирал на целую секунду и отмирал тоже по-секундно. Сборка выглядела живой ровно до первого касания. Тогда я грешил на кнопку и менял её трижды. Кнопка была отличная — глухим был скетч.
В этом уроке разберём главный паттерн работы с дисплеем в реальных проектах: loop() не спит никогда, а экран обновляется по тику таймера millis(). Покажу антипаттерн с delay() и посчитаю, во что он обходится кнопке, соберу скетч, в котором счётчик, кнопка и светодиод-пульс живут в одном цикле и не мешают друг другу, и выберем частоту обновления, при которой цифры читаются без мельтешения. База про паузы без delay() уже была в уроке про millis() вместо delay() — сегодня применяем её именно к LCD, а сам экран подключается по схеме из урока про первый текст.
Почему delay() и дисплей не дружат
Посмотрим на плохой скетч глазами платы. Каждый проход loop() делает три дела: очищает экран, печатает число и засыпает на 100 миллисекунд. Очистка и печать по I2C занимают единицы миллисекунд — при 32 символах и стандартной частоте шины 100 кГц это порядка трёх-четырёх миллисекунд с учётом адресации. Остальные 96 миллисекунд из каждого прохода плата стоит внутри delay(). Арифметика безжалостна: кнопка опрашивается один раз за 100 мс, и чтобы попасть в это окно, нажатие должно совпасть с мгновением digitalRead. Нажали между окнами — нажатие исчезло целиком: digitalRead видит только мгновенное состояние пина, копить «недочитанные» нажатия плата не умеет.
Вторая беда — clear() в каждом проходе. Очистка гасит все знакоместа, потом буквы рисуются заново, и глаз ловит это как микро-мигание. При интервале в секунду я его почти не замечал, но стоило захотеть «поживее» и поставить delay(100) — экран замигал как стробоскоп. Отсюда два правила, которые я вывесил над столом после той недели: дисплей обновляется без clear(), старые символы затираются пробелами; и обновляется он не чаще, чем это нужно глазу, а не чем может плата.
#include <Wire.h>#include <LiquidCrystal_I2C.h> LiquidCrystal_I2C lcd(0x27, 16, 2); void setup() { lcd.init(); lcd.backlight();} void loop() { lcd.clear(); // микро-мигание каждый проход lcd.setCursor(0, 0); lcd.print(millis() / 1000); if (digitalRead(2) == LOW) { // кнопка опрашивается раз в 100 мс lcd.setCursor(0, 1); lcd.print("SBROS"); } delay(100); // 96% времени кнопки не существует}Правильный скетч устроен наоборот: delay() в нём обслуживает только дебаунс кнопки, а перерисовка живёт по расписанию из millis(). Каркас один и тот же во всех моих проектах с экраном. Заводим переменную lastTick и интервал тика. В каждом проходе loop() сравниваем millis() с lastTick: прошло ли 500 мс с прошлой перерисовки. Прошло — рисуем экран и запоминаем новое время. Не прошло — идём дальше по списку дел, не задерживаясь ни на микросекунду. Кнопка и светодиод в этой схеме обслуживаются каждый проход, то есть десятки тысяч раз в секунду, и никакого окна совпадений у них больше нет.
Теперь про частоты, с условиями, потому что универсальной цифры нет. Глаз уверенно читает цифры, меняющиеся раз в 500 мс, — чаще уже не информативнее, а суетнее, я проверял на трёх поколениях своих приборов. Температуру с DHT11 нет смысла дёргать чаще раза в две секунды: сам датчик выдаёт новое показание не чаще 1 Гц, всё промежуточное — повтор старого. Секундные счётчики на экране меняю раз в 200–500 мс: секунды различимы, бег не утомляет. Кнопки и любые срочные вещи — вне тиков, каждый проход loop(). Если же дел на плате становится больше, чем интервалов, я вписываю каждое новое в таблицу обновлений на бумаге: строчка на дело, справа — интервал и причина. Пять минут с ручкой заменяют вечер с осциллографом, когда что-то начинает дёргаться. А когда строк в такой таблице набирается с десяток, расписание переезжает в настоящий планировщик задач, об этом был разбор мини-планера задач на millis().
| Что обновляем | Интервал | Почему именно такой |
|---|---|---|
| Кнопки и концевики | каждый проход loop() | нажатие живёт десятки миллисекунд — пропустили окно, потеряли нажатие |
| Цифры и счётчики на экране | 200–500 мс | глаз читает без мельтешения, а loop() остаётся свободным |
| Показания DHT11 на экране | 2000 мс | датчик выдаёт новое значение не чаще 1 Гц, чаще читать бессмысленно |
| Светодиод-пульс, зуммер | 100–1000 мс | фоновая индикация не требует точности, лишь бы ритм держался |
Три дела в одном loop(): собираем в симуляторе
Соберём правильный вариант на стенде. В скетче три независимых дела. Первое — счётчик проходов цикла: он растёт на единицу каждый проход, и по скорости его роста видно, что loop() крутится без пауз. Второе — кнопка: переключает, что показывает первая строка, и срабатывает с первого нажатия, потому что опрашивается каждые десять миллисекунд. Третье — светодиод-пульс: меняет состояние раз в секунду по своему таймеру. А экран обновляется строго по тику в 500 мс: счётчик на нём меняется не сплошным потоком, а дискретно, дважды в секунду, — и это не лаг, а правильная экономия усилий глаза.
Обратите внимание на распределение ролей: самое медленное дело (экран) получает самое редкое обслуживание, самое быстрое (кнопка) — самое частое. Перевернув это соотношение, я и получил свой глухой секундомер.
Вернусь к арифметике из начала урока. В глухом секундомере кнопка обслуживалась 4% времени — остальное съедал delay(100). В тикающем скетче кнопка живёт в каждом проходе, а проход при delay(10) занимает десять миллисекунд, то есть кнопка опрашивается сто раз в секунду. Человеческий палец физически не способен нажимать быстрее пары раз в секунду, так что запас — пятидесятикратный. При этом процессорное время почти не тратится: сам digitalRead занимает микроскопические доли миллисекунды, всё остальное — ожидание в delay(10), которое легко ужать до пары миллисекунд, если понадобится ещё более резкая реакция.
Отдельно про цену самой перерисовки, чтобы тик не казался расточительством. Полный экран 16x2 — это тридцать два символа плюс адресация; по шине I2C на стандартных 100 кГц это несколько миллисекунд, то есть перерисовка раз в 500 мс съедает меньше процента времени платы. Антипаттерн из начала урока гонял по шине вдесятеро больше трафика и создавал стробоскоп. Считайте сами: одна и та же работа, раскиданная по времени, отличается от аврала ровно тем, что в первом случае кнопка успевает за пальцем, а во втором — нет.
И мнение, которое я вынес из пары сорванных сроков: не наращивайте на тиках слишком много слоёв. Два-три независимых интервала в одном loop() живут отлично и читаются с листа. Когда интервалов становится семь, а переменных last-что-то — полтора десятка, я переношу расписание в планировщик задач, про который писал выше. Следующий шаг для вашего экрана — меню из двух строк с курсором: в уроке про меню на LCD это ровно тот же тик, плюс отрисовка указателя.
- Уменьшите тик с 500 до 100 мс и верните clear() в начало перерисовки — посмотрите на экран издалека: мерцание станет заметным, это ваш личный эталон антипаттерна.
- Дайте второй строке собственный тик в 1000 мс: пусть показывает аптайм в секундах, пока первая тикает по полсекунды, — два независимых интервала в одном скетче.
- Заведите счётчик нажатий кнопки и выводите его с затиранием пробелами: после десяти нажатий хвостов от старых цифр быть не должно ни на одном шаге.
- Сравните скорость роста счётчика проходов при delay(10) и delay(100): разница покажет, сколько проходов loop() съедает каждая пауза.
- loop() без длинных delay(): паузы живут в таймерах millis(), а не в остановке цикла
- Экран обновляется по тику: сравнили millis() - lastTick с интервалом — перерисовали и запомнили время
- 500 мс для цифр достаточно; DHT11 чаще раза в 2 секунды читать бессмысленно — датчик быстрее не отвечает
- Старые символы затираются пробелами в той же строке, clear() — только при смене экрана целиком
- Кнопка и срочные дела опрашиваются каждый проход loop() — десятки тысяч раз в секунду
- 1. loop() содержит delay(1000) и опрос кнопки. Как часто кнопка реально опрашивается?
- 2. Чем заменить clear() при обновлении счётчика на экране?
- 3. Какой тик выбрать для показа температуры с DHT11 на LCD?
- 4. Почему светодиод-пульс не сбивается при перерисовке экрана?
- 5. Где держать состояние «прошёл ли тик обновления»?
Частые вопросы по теме урока
- loop() содержит delay(1000) и опрос кнопки. Как часто кнопка реально опрашивается?
- Внутри delay() код стоит на месте: digitalRead выполнится один раз в секунду, между паузами. Каждые 1000 мс
- Чем заменить clear() при обновлении счётчика на экране?
- Пробелы после числа стирают следы старого значения, а очистка всего экрана вызывает микро-мигание. Затереть хвосты пробелами после числа
- Какой тик выбрать для показа температуры с DHT11 на LCD?
- DHT11 выдаёт новое показание не чаще 1 Гц, читать чаще — получать повтор; глазу и двух секунд хватает. 2000 мс
- Почему светодиод-пульс не сбивается при перерисовке экрана?
- Каждое дело держит своё расписание: у пульса тик в 1000 мс, у экрана свой. Они пересекаются, но не зависят друг от друга. Перерисовка занимает единицы миллисекунд, а пульс живёт собственным таймером
- Где держать состояние «прошёл ли тик обновления»?
- lastTick — обычная глобальная переменная: после каждой перерисовки в неё пишут millis(), и на следующем проходе сравнение идёт с ней. В переменной lastTick, которую обновляют после перерисовки