Радуга из базового урока об эффектах жила своей жизнью: delay(30) между кадрами, красота. Потом я приделал кнопку, которая переключает эффекты, — и кнопка стала срабатывать раз в пять нажатий. Причина лежала на поверхности: loop() большую часть времени спал внутри delay(30), потом внутри delay(500) для антидребезга, и до digitalRead дело доходило случайно — когда таймеры сна случайно совпадали с нажатием. delay() замирает весь цикл целиком: и анимацию, и кнопки, и пульт, и Serial. Лечится одним шаблоном — кадр анимации рисуется не спящим ожиданием, а таймером millis(). Каркас мы собирали в уроке про millis() вместо delay(); здесь приложим его к FastLED, посчитаем бюджет кадра и сделаем скорость анимации честной ручкой, которую можно крутить на лету.
Кадр по таймеру: период и есть скорость
Скелет один и тот же: if (millis() − last >= период) — внутри рисуем кадр и обновляем метку. Всё, что раньше управляло delay, теперь живёт в переменной период, и скорость анимации становится произведением двух чисел: шаг изменения за кадр и частота кадров. Радуга с шагом оттенка 2 при кадре 30 мс проходит полный круг за 128 кадров, то есть 3,84 секунды; тот же шаг при кадре 50 мс — уже 6,4 секунды; шаг 4 при кадре 20 мс — 1,28 секунды и перед глазами шторм. Я подбираю эти два числа, начиная с 30 мс и шага 2, а потом двигаю каждое в свою сторону: частота кадров отвечает за плавность, шаг — за темп. Для волн со своей скоростью есть ещё beatsin8 из урока про волны — но ручка «период кадра» у него точно такая же.
| Период кадра | Что так анимируется | Что учесть |
|---|---|---|
| 10–15 мс | Шторм: бегущие искры, быстрые волны | show() на 60 пикселях (1,8 мс) съедает до 15 % кадра |
| 20–30 мс | Радуга, вода, VU-метр | Рабочий диапазон 33–50 кадров/с — глаз видит плавность |
| 40–60 мс | Дыхание, медленные переливы | Ток ниже, Uno свободнее для кнопок и пульта |
| 100 мс и больше | Дозорные режимы, ночной полусон | По-настоящему экономно, но движение уже видно рывками |
Теперь бюджет кадра. FastLED.show() шлёт на ленту 24 бита на пиксель при 800 кГц — это 30 мкс на пиксель: 16 пикселей ≈ 0,5 мс, 60 ≈ 1,8 мс, 300 ≈ 9 мс. На AVR во время передачи прерывания прикрыты, и на длинных лентах таймер millis() может потерять тик-другой за кадр — для анимации это неважно, но знать про эти 9 миллисекунд стоит: они вычитаются из времени, которое остаётся кнопкам и пульту. А времени у них, если loop не спит, полно: между кадрами loop прокручивается десятки раз, и каждый проход стоит единицы микросекунд — проверка таймеров, чтение кнопки, опрос decode(). Кнопка перестаёт умирать не потому, что её стали опрашивать «сильнее», а потому, что её начали опрашивать каждый проход.
#include <FastLED.h> #define LED_PIN 6#define NUM_LEDS 30#define BTN_PIN 2 CRGB leds[NUM_LEDS];const int speeds[3] = {50, 20, 8}; // период кадра: медленно, обычно, штормint speedIndex = 1;int prevBtn = HIGH;unsigned long lastFrame = 0;unsigned long lastBtn = 0;int hue = 0; void setup() { pinMode(BTN_PIN, INPUT_PULLUP); FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS); FastLED.setBrightness(170);} void loop() { // кнопка опрашивается каждый проход loop — потому и не умирает int nowBtn = digitalRead(BTN_PIN); if (prevBtn == HIGH && nowBtn == LOW && millis() - lastBtn > 150) { lastBtn = millis(); speedIndex = speedIndex + 1; if (speedIndex > 2) { speedIndex = 0; } } prevBtn = nowBtn; // кадр анимации — по таймеру if (millis() - lastFrame >= speeds[speedIndex]) { lastFrame = millis(); hue = hue + 2; fill_rainbow(leds, NUM_LEDS, hue, 12); FastLED.show(); }}Три детали разбора. Первая: кнопка читается по фронту — сравнением прошлого и текущего состояния, — а не по уровню, и кулдаун 150 мс глушит дребезг; схема та же, что в уроке про кнопку, только без единого delay. Вторая: у FastLED есть макрос EVERY_N_MILLISECONDS(30) { ... } — он делает ровно то же самое, что наш if с меткой, и в официальных примерах вроде DemoReel100 написан именно он; внутри тот же millis(), никакой магии. Третья: почему сравнение написано вычитанием millis() − last и не сломается через 49,7 суток, когда millis() переполнится и пойдёт с нуля: оба числа беззнаковые, и беззнаковое вычитание честно даёт прошедший интервал даже через границу переполнения. Писать millis() >= last + период при таком раскладе нельзя — вот эта версия как раз взорвётся на границе.
Ещё одна тонкость кадра, о которой спросят педанты: last = millis() внутри кадра слегка «плывёт» — каждый кадр начинается на пару микросекунд позже расчётного. Точнее держит средний период запись last += период. Для ленты разница невидима; я вспомнил об этом, только когда та же схема поехала на звуковом генераторе, где дрожь периода слышна.
Симулятор: скорость крутится из монитора порта
В симуляторе ниже ручкой скорости служит монитор порта: наберите число — оно станет новым периодом кадра. На плате ту же роль сыграет потенциометр, пульт или тот же Serial — строка кода одна и та же. Разбор входа построен по правилам из урока про parseInt: сначала проверяем available(), и только потом читаем, потому что parseInt без данных ждёт таймаут и подвешивает цикл. Второй таймер — контрольный: раз в две секунды печатает аптайм и счётчик кадров. Если оба счётчика тикают без пропусков, значит loop никуда не спит — именно это и доказывает неблокирующий цикл. Задач больше пяти — заводите массивы меток и периодов, как в уроке про планировщик: принцип тот же, растёт только таблица.
Что дальше по этой дорожке. Второй таймер в симуляторе — уже готовый микропланировщик: каждая задача несёт свою метку и свой период, и новых задач можно добавлять сколько угодно, пока бюджет кадра позволяет. В итоговом проекте этого раздела — умной гирлянде с пультом — тем же способом заведутся режимы анимации, опрос пульта и микрофонный VU, и никакого delay там не будет вовсе: это финальная сборка всего, что мы тут разобрали.
- 1. Почему кнопка при delay(30) в loop срабатывает через раз?
- 2. Сколько времени занимает FastLED.show() на 60 пикселях?
- 3. Что задаёт скорость вращения радуги в millis-цикле?
- 4. Почему сравнение millis() - last >= период не ломается через 49,7 суток?
- 5. Что произойдёт, если оставить delay(30) внутри рисования кадра?
- Кадр анимации рисует if (millis() - last >= период) — период кадра и есть ручка скорости
- Скорость эффекта = шаг оттенка × частота кадров: шаг 2 при 30 мс — полный круг за 3,84 с
- show() — это 30 мкс на пиксель: 60 px ≈ 1,8 мс, 300 px ≈ 9 мс; длинная лента съедает кадр
- Кнопки, пульт и Serial живут только когда loop не спит: ни delay, ни parseInt без available()
- EVERY_N_MILLISECONDS(30) из FastLED — тот же millis-таймер, свёрнутый в макрос
Частые вопросы по теме урока
- Почему кнопка при delay(30) в loop срабатывает через раз?
- delay() замирает весь цикл целиком. Если loop спит 30 мс из каждых 33, нажатие должно случайно попасть в короткое окно бодрствования — отсюда и «через раз». Неблокирующий цикл опрашивает кнопку каждым проходом. loop большую часть времени спит, и до digitalRead дело доходит редко
- Сколько времени занимает FastLED.show() на 60 пикселях?
- 24 бита на пиксель при 800 кГц дают 30 мкс на пиксель: 60 × 30 мкс = 1,8 мс. На 300 пикселях передача растягивается до 9 мс — это уже заметная доля 30-мс кадра, и на AVR в это время прикрыты прерывания. Около 1,8 мс — 30 мкс на пиксель
- Что задаёт скорость вращения радуги в millis-цикле?
- Шаг 2 при кадре 30 мс — это 64 оттенка в секунду, полный круг за 3,84 секунды. Меняете период кадра или шаг — меняется и скорость; оба параметра независимы: период отвечает за плавность, шаг за темп. Произведение шага оттенка за кадр на частоту кадров
- Почему сравнение millis() - last >= период не ломается через 49,7 суток?
- Оба числа беззнаковые, и разность беззнаковых корректна при переполнении: арифметика по модулю 2 в 32-й степени. А вот запись millis() >= last + период при переполнении last взорвётся — поэтому пишут вычитанием. Беззнаковое вычитание даёт верный интервал даже через границу переполнения
- Что произойдёт, если оставить delay(30) внутри рисования кадра?
- delay внутри кадра спит в каждом кадре — блокировка возвращается, просто спрятана глубже. Схема без delay работает только когда в loop нет ни одного задерживающего вызова, включая parseInt без available(). Кнопки и пульт снова перестанут опрашиваться вовремя