Таймлапс-платформа из старого проекта довела меня до этой темы. Серво ползла по градусу красиво, ровно — но кнопка «стоп» на столе отказывалась работать: жмёшь, а платформа невозмутимо доезжает до края. Три вечера я подозревал провод, потом кнопку, потом себя. Виноват оказался рецепт из урока про плавное движение и потенциометр: for с delay внутри держал loop() мёртвой хваткой, и до проверки кнопки дело просто не доходило.
Рецепт тот честно делает свою работу: цикл for гуляет по углам, delay(10) даёт механике доехать до каждого промежуточного градуса, движение получается плавным. Но за кадром арифметика: полный ход 0-180 — это 180 шагов по 10 мс, то есть 1,8 секунды, в которые плата занята только циклом. Кнопка, Serial, датчик — всё это время ждёт в очереди, которой не существует.
Здесь разберём вторую версию того же движения — шаг по градусу на millis(). Цикла for больше нет: есть условие «пора ли на шаг», и каждый проход loop() занимает микросекунды. Родственный приём с плавным догоном цели уже разобран в уроке про интерполяцию без блокировки; наша цель проще и приземлённее — понять сам скелет, чтобы потом вешать на него что угодно: скорость из ручки, режимы, Serial.
Два скелета одного движения
Оба скетча двигают вал одинаково: по одному градусу за такт. Разница в том, кто ведёт счёт времени. В for-версии часы заменяет delay(10): процессор стоит у таймера и смотрит на него. В millis-версии часы тикают сами собой, а скетч на каждом проходе задаёт один короткий вопрос: «прошло ли 10 мс с прошлого шага?». Нет — loop() проскакивает дальше за микросекунды и возвращается снова.
Сама проверка — три строки. unsigned long now = millis(); затем if (now - tStep >= STEP_MS), внутри которого шаг и tStep = now. Переменная tStep хранит момент прошлого шага, STEP_MS — паузу. Вычитание и сравнение в unsigned long не боятся переполнения millis() на 49,7 суток: разность остаётся верной даже через рубеж. Настоящая ловушка другая — объявить tStep как int: такой счётчик лопается уже через 32 секунды работы, и ход замирает посреди дня без единого сообщения.
Цена вопроса считается на пальцах. Один проход loop() с парой таких проверок — единицы микросекунд на 16 МГц; за те же 10 мс между шагами loop успевает пробежать сотни раз, и в каждый из этих проходов кнопка читается, светодиод мигает, Serial слушает. Вал разницы не видит: он едет теми же градусами с тем же темпом. Живёт всё вокруг — и это единственное, что мы меняли.
Привычка писать for приходит с первых уроков программирования, где цикл — единственный знакомый способ повторить действие. С millis взгляд переворачивается: цикл уже написан за вас — это loop(), который крутится вечно, а ваша задача не «прокрутить серво», а «не пропустить момент». Отсюда простое правило планирования: каждое периодическое дело получает свою переменную-метку и свой интервал, и скетч собирается из нескольких одинаковых блоков if, каждый из которых живёт по своим часам. Кнопка из скетча ниже — просто ещё один такой блок, только интервал у неё не период, а минимальная пауза между нажатиями.
#include <Servo.h> Servo srv; const int PIN_BTN = 2; // стоп и пускconst int PIN_LED = 12; // статус-огонёкconst unsigned long STEP_MS = 10; // пауза на градус unsigned long tStep = 0; // момент прошлого шагаunsigned long tBlink = 0;unsigned long tBtn = 0;int angle = 0;int dir = 1;int running = 1;int ledOn = 0;int lastBtn = HIGH; void setup() { srv.attach(11); pinMode(PIN_BTN, INPUT_PULLUP); pinMode(PIN_LED, OUTPUT); Serial.begin(9600); Serial.println("millis-hod: D2 pauza, LED migayet sam");} void loop() { unsigned long now = millis(); int b = digitalRead(PIN_BTN); if (lastBtn == HIGH && b == LOW && now - tBtn > 250) { running = 1 - running; tBtn = now; if (running == 1) { Serial.println("pusk"); } else { Serial.println("pauza"); } } lastBtn = b; if (now - tBlink >= 400) { if (ledOn == 1) { ledOn = 0; } else { ledOn = 1; } digitalWrite(PIN_LED, ledOn); tBlink = now; } if (running == 1 && now - tStep >= STEP_MS) { angle = angle + dir; if (angle >= 180) { angle = 180; dir = -1; } if (angle <= 0) { angle = 0; dir = 1; } srv.write(angle); tStep = now; }}Проговорим три места. Кнопка на D2 висит на INPUT_PULLUP: нажатие — LOW, механика фронта разобрана в уроке про кнопку и digitalRead. Чтобы одно нажатие с дребезгом не переворачивало режим десяток раз, я не ставлю delay(50) — это вернуло бы блокировку обратно. Вместо него повторное срабатывание разрешено не раньше чем через 250 мс: кнопка проверяется каждый проход loop, но флипает не чаще раза в четверть секунды.
Светодиод на D12 мигает собственным таймером на 400 мс, никак не связанным с серво. Это тест живости loop(): если где-то в коде затесался delay, огонёк начинает заикаться в такт задержке. На реальной плате такое мигание — бесплатный осциллограф для планировщика; годится оно и для диагностики чужих скетчей, где блокировку ищут глазами по коду, а тут видно сразу.
Ловушка при переносе старого скетча на скелет — забыть, где теперь «конец хода». В for-версии конец задан границей цикла: доехал до 180 — и всё, прокрутка закончилась. В millis-версии loop() не кончается никогда, поэтому упоры 0 и 180 превращаются в разворот — условие внутри шага. Кто-то поставит вместо разворота running = 0 и получит одноразовый ход; я ставлю разворот для таймлапса и стоп для шлагбаума — механика одна, разница в двух строках.
| Ситуация | for + delay(10) | millis-скелет |
|---|---|---|
| Реакция на кнопку «стоп» | до 1,8 с — после доезда до края | в ближайший проход loop, мгновенно |
| Светодиод-статус | стоит на месте всю прокрутку | мигает ровным тактом 400 мс |
| Вторая серво рядом | вложенный цикл, темп плывёт | ещё один блок if со своим tStep |
| Смена паузы на лету | константа зашита в delay | переменная STEP_MS — правится в любом проходе |
Что зарабатывает неблокирующий скелет
Кнопка из этого урока только замирает и пускает ход, но скелет тянет больше: в тот же loop влезает опрос датчика, Serial-команда, второй ход. Если сравнить с кнопочным уроком про шаги на угол — там кнопки двигают вал дискретными ступенями, — то здесь непрерывный ход и кнопка рулит не положением, а самим фактом движения. Оба подхода живут на одном скелете, меняется только смысл переменных.
Равномерность хода удобно проверить графиком плоттера: печатайте угол на каждом шаге — линия выйдет идеально прямой лесенкой. Ломаная с полками и прыжками означает, что какой-то сосед по loop() съедает время между шагами. Для систематического изложения всей техники таймеров есть отдельный урок про millis вместо delay; здесь же остаётся добавить, что серво — самый наглядный ученик этой школы: её ход либо плавный и-loop-живой, либо плавный и мёртвый.
Есть и цена, о которой честно стоит сказать: скелет длиннее for-версии втрое, и в нём легче промахнуться — перепутать метки, оставить int вместо unsigned long, забыть про упоры. За эти строки плата расплачивается свободой: тот же loop() принимает вторую серво, датчик и Serial без переписывания. Я переехал на скелет после третьего проекта с блокировкой и обратно не смотрел: рецепты на delay остаются для первого знакомства с серво, дальше — только таймеры.
На реальной плате добавляется одна проверка, которой нет в симуляторе: дрожь вала на ходу. Если серво проходит градусы с подрагиванием — пауза великовата и вал успевает остановиться на каждом шаге, либо питание проседает на стартах. Первое лечится укорочением STEP_MS до 5-8 мс, второе — схемой из урока про внешнее питание. У моих SG90 ход на 10 мс чистый, у одного залежавшегося экземпляра чистым оказался только 15.
И последнее наблюдение из практики: скелет не прощает лишних гостей. Как только в loop попадает тяжёлая работа — печать в Serial по строке на каждый шаг, длинная математика — интервалы начинают плавать, и ход визуально «пульсирует». Печать я оставляю для отладки, а в рабочей прошивке убираю: серво обновляется кадрами 50 Гц, о которых пойдёт речь в третьем уроке, и половина печатей всё равно пропадает между кадрами.
- for + delay(10) на полном ходе держит loop() в плену 1,8 с: кнопка, Serial и датчики всё это время не опрашиваются
- millis-скелет — три строки: now, проверка now - tStep >= STEP_MS, шаг; переменные меток только unsigned long
- int для tStep лопается через 32 с работы; unsigned long переживает переполнение millis() на 49,7 сутки
- Дребезг кнопки глушится повторным интервалом 250 мс на millis, без delay — loop остаётся свободным
- Светодиод с собственным таймером — тест живости loop: заикание мигания выдаёт любую притаившуюся задержку
- 1. Сколько времени loop() заблокирован в for-версии с delay(10) на полный ход 0-180?
- 2. Что хранит переменная tStep в millis-скелете?
- 3. Почему разность now - tStep не ломается на переполнении millis()?
- 4. Как в скетче урока защищено от дребезга переворачивание режима кнопкой?
- 5. Что произойдёт с миганием светодиода, если паузу серво поднять с 10 до 40 мс?
Частые вопросы по теме урока
- Сколько времени loop() заблокирован в for-версии с delay(10) на полный ход 0-180?
- 180 write-ов с паузой 10 мс между ними — это 1,8 секунды сплошного цикла. Всё это время кнопки и Serial не опрашиваются: loop занят только прокруткой. 1,8 с — 180 шагов по 10 мс подряд
- Что хранит переменная tStep в millis-скелете?
- tStep — метка времени прошлого шага. Проверка now - tStep >= STEP_MS спрашивает именно: сколько прошло с того момента. Угол живёт в отдельной переменной. Момент прошлого шага в миллисекундах
- Почему разность now - tStep не ломается на переполнении millis()?
- Беззнаковая арифметика замыкается по модулю: вычитание в unsigned long даёт правильный интервал даже когда millis() пересёк максимум. Ломается всё ровно наоборот, если tStep объявить int — счётчик умрёт через 32 секунды. Оба числа в unsigned long — разность верна и через рубеж
- Как в скетче урока защищено от дребезга переворачивание режима кнопкой?
- Защита — на millis: метка tBtn запоминается при срабатывании, и следующий флип возможен только через 250 мс. delay здесь сознательно не используется — он вернул бы блокировку loop. Повторное срабатывание разрешено не раньше 250 мс
- Что произойдёт с миганием светодиода, если паузу серво поднять с 10 до 40 мс?
- У каждого таймера своя метка: tBlink не связан с tStep. Ход станет медленнее, мигание останется ровным — именно так проверяется, что задачи в loop действительно независимы. Ничего — у светодиода свой таймер на 400 мс