Робототехника · Микроконтроллеры · 8 класс
millis() и многозадачность
Два светодиода мигают с разной скоростью, кнопка срабатывает сразу — почему delay() всё замораживает?
Ситуация
В роботе-машинке два светодиода должны мигать с разной скоростью, а при нажатии кнопки сигнал должен загореться сразу. Первая программа пишется легко: включить — delay(500) — выключить — delay(500). Но если нажать кнопку, часто ничего не происходит: программа «спит». Что делает микроконтроллер внутри delay()? Есть ли на плате часы — как работает millis() и почему через 49,7 суток она возвращается к 0? Как управлять двумя светодиодами и кнопкой в одном loop() совсем без delay?
Тема
millis() и многозадачность: программа без delay(). Два светодиода мигают с разной скоростью, а кнопка отвечает сразу — сравниваем время
Ключевые понятия
- delay() останавливает процессор
- millis() — часы
- Конечный автомат
Формулы
С delay(): период loop() равен сумме всех delay; кнопка читается лишь в одной точке, в худшем случае ответ запаздывает на весь период, а короткое нажатие пропадает.
millis() — число миллисекунд с момента включения платы (или RESET): 32-битный `unsigned long`, считается в фоне по прерыванию Timer0 и не останавливает программу.
Конечный автомат: каждая задача помнит своё время «последний раз»; loop() при каждом обороте только проверяет — никогда не ждёт, поэтому кнопка читается при каждом обороте.
Переполнение: после 4 294 967 295 `unsigned long` возвращается к 0 — через 49 суток 17 часов 2 минуты 47 секунд.
Вычитание работает правильно: беззнаковое вычитание тоже идёт по модулю — даже если часы вернулись к 0, даёт настоящее прошедшее время. А при переполнении ошибается.
Теория
Что делает delay()
`delay(500)` — 500 мс микроконтроллер только считает время: не читает кнопку, не управляет другим светодиодом, не пишет в Serial. Поэтому в loop(), написанном с delay, кнопка читается только в одной строке — когда доходит очередь проверки. В примере loop() длится 2 секунды: если нажать кнопку на 150 мс, с вероятностью 93 % программа этого не увидит. На временной диаграмме это видно: серые блоки — процессор «спит», нажатие с красной штриховкой — не замечено.
millis() — часы на плате
В ATmega328P Timer0 примерно каждую 1 мс вызывает прерывание, а оно увеличивает 32-битный счётчик на единицу — что бы программа ни делала, даже внутри delay(). `millis()` возвращает этот счётчик: миллисекунды с момента включения платы. Она ничего не ждёт — вы просто спрашиваете «который час?». Тип — `unsigned long` (от 0 до 4 294 967 295); если записать значение в `int`, через 32 секунды получится ошибка.
Конечный автомат: мигание без delay
Идея: не ждём — проверяем. Для каждой задачи запоминаем время «когда был последний раз»: `if (millis() - tRed >= 300) { tRed = millis(); red = !red; digitalWrite(RED, red); }`. loop() крутится тысячи раз в секунду: при каждом обороте — пришло ли время для красного, для зелёного — и нажата ли кнопка? Поэтому два светодиода мигают с независимыми периодами (300 и 700 мс — с delay это написать трудно), а ответ на кнопку приходит в пределах одного оборота — миллисекунды. Это конечный автомат: программа хранит состояние (red, tRed) и меняет его в зависимости от времени. Тот же приём позволяет объединить в одном loop() сервопривод, датчик и Serial.
49,7 суток: переполнение
32-битный счётчик возвращается к 0 через = 4 294 967 296 мс ≈ 49,71 суток. Простое сравнение `if (now >= tRed + 250)` в этот момент ошибается: `tRed + 250` тоже переполняется и становится малым числом, а `now` ещё велико — условие истинно при каждом обороте, светодиод мерцает со скоростью loop, пока часы тоже не вернутся к 0. Правильный способ — разность: `now - tRed >= 250`. Беззнаковое вычитание тоже идёт по модулю : если now = 5, tRed = 4 294 967 290, то 5 − 4 294 967 290 = −4 294 967 285, а по модулю получается 11 — ровно прошедшее время. В эксперименте режим «Начать за 6 с до переполнения часов» сжимает 49 суток до 6 секунд.
Кнопка: INPUT_PULLUP
`pinMode(BTN, INPUT_PULLUP)` «подтягивает» вывод к 5 В через внутренний резистор ≈ 20–50 кОм: когда кнопка не нажата — HIGH, когда нажата (вывод соединяется с GND) — LOW, поэтому условие `digitalRead(BTN) == LOW`. При обычном `INPUT` вывод «висит в воздухе»: как антенна, он собирает шум и показывает случайные 0/1 — жёлтый светодиод сам по себе мерцает (попробуйте). Дребезг контактов (debounce) и прерывания — в экспериментах «Кнопка и цифровой вход» и «Прерывания».
О модели
Программа работает на часах симуляции: за каждый шаг 8,3 мс loop() успевает много раз прокрутиться (≈ 13 тысяч раз в секунду; на настоящей Uno для этой программы — несколько десятков тысяч), состояние кнопки обновляется на том же шаге — поэтому ответ выглядит как 0–8 мс (на самом деле — микросекунды). delay() точен: 500 мс — ровно 500 мс. millis() считается шагом 1 мс (на настоящей плате тики по 1,024 мс, есть небольшой разброс). «Висящий» вход смоделирован случайной последовательностью. Ток светодиода Ом: красный ≈ 12,7 мА, зелёный ≈ 11,4, жёлтый ≈ 12,2.
Знаете ли вы?
- Точно такая же ошибка была в Windows 95 и 98: система считала миллисекунды с момента включения в 32-битном числе и после 49,7 суток непрерывной работы «зависала»; Microsoft исправила это в 1999 году (KB216641).
- В Arduino millis() приводит в движение прерывание Timer0 каждые 1,024 мс (16 МГц / 64 / 256); код ядра (wiring.c) отдельно считает накопившиеся 0,024 мс и каждые 41–42 тика добавляет одну миллисекунду — поэтому millis() в среднем точна, но некоторые её шаги равны 2 мс.
Что внутри эксперимента
Настоящая плата в рисованной изометрии: вы поворачиваете её, ставите детали, собираете цепь, пишете и загружаете код — ток и напряжение считаются сразу, а при ошибке деталь сгорает. Внутри — задания, теория, тест и экскурсия.
Другие эксперименты раздела
- Arduino Uno: первая программа — 7 класс
- Кнопка и цифровой вход — 7 класс
- Аналоговый вход и «Монитор порта» — 7 класс
- ШИМ: яркость и звук — 8 класс