Уличный термостат я собрал за вечер, а вот панель к нему приделывал неделю. Первый вариант экрана мигал, как вывеска дешёвого бара: в каждом проходе loop() я вызывал lcd.clear(), дисплей гас целиком и рисовал буквы заново — и так двадцать раз в секунду. Второй вариант дребезжал: кнопка то листала меню на два пункта за нажатие, то не реагировала вовсе, а я грешал на «бракованную» кнопку. Оба тупика разобраны в этом уроке, потому что они совершенно типовые: через них проходит каждый, кто делает на LCD первый интерфейс.
Финальная версия той панели пережила три дачных сезона: три пункта меню, стрелка-курсор слева, значения обновляются точечно. Здесь мы соберём её в симуляторе шаг за шагом: сначала поймём, что setCursor() — это просто координаты X и Y, потом прицепим кнопку с защитой от дребезга, а в конце окончательно избавимся от мерцания, печатая только то, что изменилось.
Координаты экрана: setCursor — это X и Y
Экран 16x2 — это сетка: 16 столбцов с номерами от 0 до 15 и 2 строки с номерами от 0 до 1. lcd.setCursor(столбец, строка) ставит невидимый курсор в ячейку, и следующий print() пишет начиная с неё. Два правила, которые экономят часы отладки. Первое: печать не стирает остальную строку — она только кладёт свои символы поверх. Напечатали «Rezhim 1», а потом в той же позиции «Rezhim 2» — на экране останется «Rezhim 21»: двойка легла поверх единицы, а единица никуда не делась. Лечится хвостом из пробелов, которые затирают всё, что было напечатано раньше.
Второе правило: строка длиннее шестнадцати символов перетекает на соседнюю строку — внутренний курсор контроллера продолжает идти по памяти экрана. Этим иногда пользуются, чтобы выводить длинные фразы, но чаще перетекание становится сюрпризом, который портит вёрстку. Сам курсор дисплея по умолчанию невидим: мигающий подчёркивающий символ включается отдельными командами, но для меню он не нужен — роль курсора у нас сыграет стрелка из обычного символа «>», поставленная в первый столбец активной строки.
Пробелы в конце строки — не лень, а техника. print("Start ") с запасом из шести пробелов гарантированно затирает всё, что было напечатано в этой зоне: и «Stop», и «Pause», и мусор от прошлой прошивки. Строки с запасом до конца экрана я пишу с тех пор, как однажды полчаса смотрел на «призрак» прошлой надписи и не понимал, откуда он взялся. Чуть дороже по времени печати — зато ноль сюрпризов.
#include <Wire.h>#include <LiquidCrystal_I2C.h> LiquidCrystal_I2C lcd(0x27, 16, 2); void setup() { lcd.init(); lcd.backlight(); lcd.setCursor(4, 0); // пятый столбец первой строки lcd.print("Menu:"); lcd.setCursor(2, 1); // третий столбец второй строки lcd.print("Start "); // пробелы — с запасом на слово "Stop"} void loop() {}Меню из трёх пунктов на одной кнопке
Логика меню — это маленький конечный автомат. Заводим переменную int menuIndex — номер активного пункта от 0 до 2. Кнопка на пине 2 при нажатии увеличивает его на единицу по кругу: 0, 1, 2, снова 0 — за это отвечает остаток от деления: menuIndex = (menuIndex + 1) % 3. В первом столбце печатаем стрелку «>» — она и есть наш курсор, отметка активного пункта. Значения каждого пункта — температура с A0, аптайм в секундах, счётчик нажатий — живут своей жизнью и обновляются отдельным блоком кода.
Кнопку защищаем от дребезга: механические контакты при замыкании дрожат несколько миллисекунд и дают десяток ложных срабатываний — отсюда и «меню листает по два пункта». Классическое решение без библиотек: засечь время последнего переключения через millis() и игнорировать нажатия чаще, чем раз в 200 мс. Для меню этого достаточно; тонкие случаи вроде тактильных энкодеров требуют отдельного разговора. Подтяжку берём встроенную — pinMode(BTN, INPUT_PULLUP) — и помним, что при нажатии кнопка отдаёт LOW: она замыкает пин на землю.
И главный приём урока, ради которого всё затевалось: печать по изменению. Храним то, что сейчас показано на экране, — имя пункта и его значение. Перед печатью сравниваем: изменилось — рисуем, нет — молчим. Экран перестаёт перерисовывать одно и то же, мерцание исчезает в принципе, ему просто не из чего возникнуть.
#include <Wire.h>#include <LiquidCrystal_I2C.h> LiquidCrystal_I2C lcd(0x27, 16, 2); const int BTN = 2; // кнопка между пином 2 и GNDString names[3] = {"Temp", "Time", "Press"}; int menuIndex = 0; // активный пункт: 0..2int presses = 0; // счётчик нажатийunsigned long lastPress = 0; // время последнего нажатияint shownIndex = -1; // какой пункт сейчас на экранеint shownValue = -999999; // какое значение напечатано void setup() { pinMode(BTN, INPUT_PULLUP); // нажатие даёт LOW lcd.init(); lcd.backlight();} void loop() { if (digitalRead(BTN) == LOW && millis() - lastPress > 200) { lastPress = millis(); presses++; menuIndex = (menuIndex + 1) % 3; // 0, 1, 2, снова 0 } // имя пункта печатаем, только если пункт сменился if (menuIndex != shownIndex) { lcd.setCursor(0, 0); lcd.print(">"); lcd.print(names[menuIndex]); lcd.print(" "); // затираем хвост прошлого имени shownIndex = menuIndex; shownValue = -999999; // заставим перерисовать значение } // текущее значение активного пункта int value = 0; if (menuIndex == 0) { value = map(analogRead(A0), 0, 1023, -20, 50); // температура } else if (menuIndex == 1) { value = millis() / 1000; // аптайм, секунды } else { value = presses; // счётчик нажатий } // значение печатаем, только если оно изменилось if (value != shownValue) { lcd.setCursor(8, 0); lcd.print(" "); // стираем старое число lcd.setCursor(8, 0); lcd.print(value); shownValue = value; }}Собираем интерфейс в симуляторе
Три места в скетче делают всю магию. Сравнение menuIndex != shownIndex заставляет рисовать имя пункта только после нажатия кнопки, а не двадцать раз в секунду. Присваивание shownValue = -999999 после смены пункта — маленький трюк: значение нового пункта гарантированно не совпадёт с этим невозможным числом, и экран перерисует его ровно один раз. Наконец, проверка value != shownValue печатает только изменившееся. А счётчик нажатий тут ещё и самоирония: он честно считает собственные нажатия, листающие меню.
Эксперименты
- Добавьте четвёртый пункт — например, «Bip» с частотой из tone(). Понадобится расширить массив, удлинить цепочку if/else и ничего больше: перерисовка уже универсальна.
- Сократите паузу от дребезга с 200 мс до 20 и пощёлкайте кнопку быстро: часть нажатий начнёт слипаться. Верните 200 — меню снова честное. Вот вам цена одной настройки.
- Выводите пункт Time в формате MM:SS. Заведите функцию print2(int n), которая печатает ведущий ноль числам меньше десяти, и печатайте минуты и секунды через неё.
- Добавьте вторую кнопку на пине 3 для движения меню назад: menuIndex = (menuIndex + 2) % 3 — остаток от деления закольцует и обратный ход.
- setCursor(столбец, строка) — координаты от нуля; печать кладёт символы поверх, не стирая остальную строку
- Хвост из пробелов затирает остатки прошлой печати: пробел — такой же символ LCD, как буква
- lcd.clear() в каждом кадре вызывает мерцание; очищайте точечно, а clear() оставьте для смены экрана целиком
- Меню — конечный автомат: menuIndex, кнопка с паузой 200 мс через millis(), стрелка «>» в первом столбце активной строки
- Печать по изменению: храним показанное значение и сравниваем перед выводом; после смены пункта сбрасываем его в невозможное число
- INPUT_PULLUP: нажатая кнопка замыкает пин на землю и отдаёт LOW
- 1. Что останется на экране после печати «Rezhim 2» в позиции, где было «Rezhim 1»?
- 2. Почему экран мерцает, если вызывать lcd.clear() в каждом loop()?
- 3. Куда поставит курсор вызов setCursor(15, 1) на экране 16x2?
- 4. Зачем сравнивать новое значение с показанным перед печатью?
- 5. Что делает строка menuIndex = (menuIndex + 1) % 3?
- 6. Кнопка, настроенная через pinMode(BTN, INPUT_PULLUP), при нажатии отдаёт…
Готово: у вас есть работающий интерфейс — курсор, меню, кнопка и картинка, которая не мигает. Дальше этот каркас легко наращивать: пункты превращаются в настройки, значения — в графики, а второй ряд кнопок добавляет навигацию назад. Прежде чем идти дальше, поиграйте с симулятором: сломайте паузу дребезга, уберите затирание пробелами и посмотрите на последствия своими глазами — с ними любая будущая отладка пойдёт быстрее.
Частые вопросы по теме урока
- Что останется на экране после печати «Rezhim 2» в позиции, где было «Rezhim 1»?
- print() только рисует символы поверх существующих. Хвост старого текста убирают пробелами или точечной перерисовкой. «Rezhim 21» — печать не стирает предыдущие символы
- Почему экран мерцает, если вызывать lcd.clear() в каждом loop()?
- Полная очистка всего экрана в каждом кадре заставляет дисплей гаснуть и рисоваться заново. Правильно обновлять только изменённые ячейки. clear() стирает весь экран, и он заново перерисовывается десятки раз в секунду
- Куда поставит курсор вызов setCursor(15, 1) на экране 16x2?
- Столбцы нумеруются 0–15, строки 0–1. Значит, столбец 15 на строке 1 — правый нижний угол. Вторая строка, последний столбец
- Зачем сравнивать новое значение с показанным перед печатью?
- Печать только изменений — основа спокойной картинки на LCD: экран не перерисовывает неизменные ячейки. Чтобы не печатать одно и то же: меньше нагрузки и никакого мерцания
- Что делает строка menuIndex = (menuIndex + 1) % 3?
- Остаток от деления на 3 закольцовывает значения 0–2. Это стандартный приём циклического меню. Листает пункты по кругу: 0, 1, 2, снова 0
- Кнопка, настроенная через pinMode(BTN, INPUT_PULLUP), при нажатии отдаёт…
- Внутренняя подтяжка держит пин на HIGH, а нажатие замыкает его на землю: digitalRead возвращает LOW. LOW