У любого прибора есть унылый период: три секунды после подачи питания, когда датчик греется, сеть коннектится, а пользователь уже тянется к кнопке сброса, потому что «висит». Лечится полосой загрузки — баром, который растёт по кадрам и сообщает: живой, работаю. Но как только в скетче встаёт delay(40) между кадрами, кнопки перестают читаться, а прибор теряет главное свойство — отзывчивость. Выход один: кадры по такту millis, и это тот редкий случай, когда анимация и архитектура скетча решаются одним движением. Заодно бар экономит нервы: прибор, который молчит три секунды, выглядит сломанным, даже когда идеально работает.
Разберём окно такта, от которого пляшет всё. Один кадр — это полный цикл clearDisplay, рисование и display(); на быстрой шине 400 кГц последняя стадия стоит около 23 мс, плюс микросекунды на рисование. Такт 30-50 мс даёт 20-33 кадра в секунду: шина успевает, а глаз уже не различает ступенек на баре — полоса едет, как цельная. Такт короче 30 мс смысла не имеет: display() просто не успеет отработать, кадры начнут наезжать друг на друга. Такт длиннее 100 мс уже виден глазу как рывок. Мой рабочий выбор — 40 мс: круглое число, 25 FPS, запас по шине есть.
Требования к бару я сформулировал из практики и заодно превратил в чек-лист: кадр обновляется сам по такту, кнопка срабатывает всегда, ритм в мониторе порта ровный. Если все три выполняются, цикл выстроен правильно; если бар дёргается при нажатии кнопки — где-то затесался блокирующий вызов, и третья проверка его найдёт. Ничего из этого не требует библиотек поверх Adafruit: один таймер на millis, один буфер кадра и честное измерение собственным портом.
Такт на millis: каркас
Каркас анимации умещается в четыре строки логики: если millis() минус метка прошлого кадра больше или равна 40 — обнови метку, продвинь прогресс, нарисуй кадр. Между кадрами loop крутится впустую тысячи раз, и именно в эти свободные проходы читается кнопка: зажали D2 — прогресс сбросился в ноль, бар поехал заново. Ничего не блокируется, отклик на кнопку — мгновенный в пределах одного прохода loop. Механику такого планировщика я подробно разбирал в уроке про millis вместо delay — здесь она применяется к экрану без изменений.
С арифметикой прогресса тоже всё честно. Пусть процент растёт на единицу за кадр: от 0 до 100 при такте 40 мс — четыре секунды на полную загрузку, и столько же на паузу с перезапуском. Ширина столба получается через map(pct, 0, 100, 0, 122): 122 пикселя — шкала с учётом полей, как в первом уроке серии. На каждый процент приходится больше пикселя, ступеньки внутри столба глаз не видит — плавность не требует ни дробных чисел, ни промежуточных кадров. Если нужна фора медленнее или быстрее — меняйте такт или шаг, формула не трогается.
Паузу на конце цикла тоже делают тактом, а не delay: когда процент упёрся в сотню, скетч переключается в короткое состояние ожидания — кадр остаётся на экране, кнопка читается, а через восемь сотен миллисекунд всё начинается с нуля. Получается крошечный автомат из двух состояний: «едем» и «держим». Если загрузка на приборе настоящая, а не декоративная, вместо счётчика в тот же каркас встаёт реальная работа: инициализация датчика — первый сегмент, коннект к сети — второй; бар двигают выполненные шаги, а не таймер. Декоративная версия годится для отладки каркаса, честная — для финала.
| Такт | Кадров в секунду | Что происходит | Вердикт |
|---|---|---|---|
| 16 мс | 60+ | display() на 400 кГц не успевает, кадры наезжают | не для I2C OLED |
| 33 мс | 30 | гладко, шина загружена впритык | верхняя граница окна |
| 40-50 мс | 20-25 | плавно, запас по шине, кнопка мгновенно живая | рабочее окно |
| 100+ мс | меньше 10 | бар едет ступенями, глаз видит рывки | только для статичных индикаторов |
В скетче ниже — полная сборка: бар с рамкой, процент в верхнюю строку, кнопка на D2 с чтением фронта, Serial-отчёт каждого кадра для проверки такта в мониторе порта. Фронт кнопки обязателен: по уровню удержание сбрасывало бы прогресс десятки раз в секунду. Дребезг здесь почти безвреден — лишний сброс на долю секунды раньше не меняет картинку, — но если кнопка с длинными проводами начнет подкидывать фантомные нажатия, лечение разобрано в уроке про дребезг.
Про фронт кнопки подробнее — это самое деликатное место каркаса. Логика такая: прошлым проходом запомнили HIGH, сейчас прочитали LOW — значит, палец дошёл до кнопки, сбрасываем прогресс и запоминаем новое состояние. Без памяти о прошлом уровне удержание превращалось бы в очередь сбросов по числу проходов loop. Дребезг здесь почти безвреден по одной причине: двойной сброс подряд меняет только метку времени, а картинка не меняется вовсе; лечить болтанку контакта стоит, когда кнопка управляет чем-то накопительным — счётчиком или меню.
#include <Wire.h>#include <Adafruit_GFX.h>#include <Adafruit_SSD1306.h> Adafruit_SSD1306 display(128, 64, &Wire, -1); unsigned long lastFrame = 0;int pct = 0;bool lastBtn = HIGH; void setup() { Serial.begin(9600); pinMode(2, INPUT_PULLUP); display.begin(SSD1306_SWITCHCAPVCC, 0x3C); Wire.setClock(400000); // без этой строки такт 40 мс не удержать display.setTextColor(SSD1306_WHITE); display.setTextSize(1);} void loop() { // фронт кнопки: HIGH -> LOW = нажали, сбрасываем прогресс bool btn = digitalRead(2); if (lastBtn == HIGH && btn == LOW) { pct = 0; lastFrame = millis(); } lastBtn = btn; // кадровый такт: не раньше чем через 40 мс после прошлого кадра if (millis() - lastFrame >= 40) { lastFrame = millis(); display.clearDisplay(); display.setCursor(0, 0); display.print("Zagruzka "); display.print(pct); display.print("%"); display.drawRect(2, 24, 124, 16, SSD1306_WHITE); display.fillRect(3, 25, map(pct, 0, 100, 0, 122), 14, SSD1306_WHITE); display.setCursor(0, 56); display.print("D2 - restart"); display.display(); Serial.println(pct); // в мониторе виден ровный такт 25 строк/с pct++; if (pct > 100) pct = 0; // 4 секунды на цикл - и заново }}Что здесь важно, кроме каркаса. Стирание перед кадром обязательно: бар каждый раз длиннее прошлого, и без clearDisplay старые пиксели заливки остались бы справа — столб никогда не сокращался бы. Мерцания при этом нет: кадр собирается в буфере и уезжает на панель цельным, механика без мерцания разобрана в уроке про анимацию без мерцания. Если нужен не бесконечный цикл, а одна честная загрузка с картинками-кадрами, у меня есть урок про boot-анимацию — там кадры задаются массивом, а прогресс настоящим делом: инициализацией датчиков.
Serial.println в каждом кадре — это не украшение, а измерительный щуп: в мониторе порта строки идут ровным потоком с частотой около 25 в секунду, и провал такта виден глазом как сбой ритма. Уберите его в финальной версии — порт прибора не дело, — но на этапе отладки две сотни строк за восемь секунд экономят час гаданий. И да, кнопку можно повесить на прерывание вместо опроса, но здесь это лишнее: loop между кадрами пуст, и опрос срабатывает быстрее, чем вы отпустите палец.
Два независимых такта в одном loop живут без конфликтов — на этом стоит остановиться, потому что шаблон пригодится постоянно. У бара метка на 40 мс, у Serial-отчёта можно завести свою на 1000 мс: две переменные, две проверки, ни одного delay, и ритмы не сбивают друг друга, потому что ни одна ветка ничего не блокирует. Так же потом добавится мигающее двоеточие в часах с тактом 500 мс и опрос датчика раз в две секунды; к четвёртому таймеру скетч выглядит как таблица расписаний — нормальная форма для прибора с экраном.
От бара к любому движению
Каркас «такт 40 мс — продвинь состояние — перерисуй кадр» не меняется от того, что именно движется: полоса загрузки, вращающаяся черта из drawLine, бегущая точка или график с датчика, который доползает до правого края и уезжает влево. Меняется только одна строка — как из номера кадра получается картинка. Сам бар как элемент интерфейса с управлением слайдером разобран в уроке про бар и слайдер, а цена кадра в байтах и миллисекундах — в уроке про буфер 1024 байта из этой же серии.
Когда экранов и задач станет больше, тот же тактовый каркас растягивается на несколько независимых анимаций с разными периодами: у бара один такт, у мигающего двоеточия другой, у графика третий. В одном loop они живут параллельно, потому что никто никого не блокирует — это и есть главный дивиденд отказа от delay. Начните с одного такта из скетча выше, а потребность в втором придёт сама, вместе с первым настоящим прибором.
И последнее: этот же каркас — готовая база для графиков с датчика, к которым серия подойдёт дальше. Точка добавляется по такту, столбец сдвигается влево, кадр перерисовывается — механика ровно та же, что у полосы загрузки, сложнее только состояние. Скетч выше я держу в заготовках как скелет: около сорока строк, которые всегда собирают живой экран, и ни одна из них не содержит delay.
- 1. Почему delay(40) между кадрами ломает кнопку рестарта?
- 2. Какой FPS даёт такт 40 мс?
- 3. Шина осталась на 100 кГц, такт 40 мс. Что увидите в мониторе порта?
- 4. Почему кнопка читается по фронту HIGH->LOW, а не по уровню?
- 5. Что изменится в каркасе, если вместо бара гонять бегущую точку?
- Рабочее окно такта — 30-50 мс: 20-33 FPS; ниже — шина не успевает, выше — видны рывки.
- Каркас: millis() - lastFrame >= 40, внутри — обновить метку, продвинуть состояние, нарисовать кадр.
- delay между кадрами глушит опрос кнопки; millis оставляет loop свободным между кадрами.
- Кнопку читать фронтом HIGH->LOW: по уровню удержание даст сотни срабатываний.
- На 100 кГц такт 40 мс не удержать: либо Wire.setClock(400000), либо такт от 120 мс.
Частые вопросы по теме урока
- Почему delay(40) между кадрами ломает кнопку рестарта?
- delay крутит пустой цикл: между кадрами скетч глух и слеп. Кадровый такт строится на millis, чтобы loop оставался свободным. delay блокирует loop, и опрос кнопки не выполняется
- Какой FPS даёт такт 40 мс?
- 1000 мс делим на 40 — 25 кадров в секунду. Глаз видит плавное движение, шина на 400 кГц успевает с запасом. 25
- Шина осталась на 100 кГц, такт 40 мс. Что увидите в мониторе порта?
- display() занимает около 90 мс — кадры выстраиваются в очередь, такт растягивается неровно. Либо setClock(400000), либо такт от 120 мс. Строки с провалами и бар рывками по 2-3 процента
- Почему кнопка читается по фронту HIGH->LOW, а не по уровню?
- loop пролетает тысячи раз в секунду: по уровню одно удержание превратилось бы в сотни сбросов. Фронт ловит одно нажатие как одно событие. Уровень вызвал бы сброс десятки раз за одно удержание
- Что изменится в каркасе, если вместо бара гонять бегущую точку?
- Каркас «такт — состояние — кадр» универсален: меняется одна функция отрисовки. Стирание перед кадром остаётся обязательным. Такт и кнопка не тронутся — сменится только строка, рисующая картинку