Fanlab

Робототехника · Микроконтроллеры · 8 класс

millis() и многозадачность

Два светодиода мигают с разной скоростью, кнопка срабатывает сразу — почему delay() всё замораживает?

Открыть эксперимент

Ситуация

В роботе-машинке два светодиода должны мигать с разной скоростью, а при нажатии кнопки сигнал должен загореться сразу. Первая программа пишется легко: включить — delay(500) — выключить — delay(500). Но если нажать кнопку, часто ничего не происходит: программа «спит». Что делает микроконтроллер внутри delay()? Есть ли на плате часы — как работает millis() и почему через 49,7 суток она возвращается к 0? Как управлять двумя светодиодами и кнопкой в одном loop() совсем без delay?

Тема

millis() и многозадачность: программа без delay(). Два светодиода мигают с разной скоростью, а кнопка отвечает сразу — сравниваем время

Ключевые понятия

  • delay() останавливает процессор
  • millis() — часы
  • Конечный автомат

Формулы

Tloop=∑𝚍𝚎𝚕𝚊𝚢(…),tотв≤Tloop

С delay(): период loop() равен сумме всех delay; кнопка читается лишь в одной точке, в худшем случае ответ запаздывает на весь период, а короткое нажатие пропадает.

𝚖𝚒𝚕𝚕𝚒𝚜()=t−t0

millis() — число миллисекунд с момента включения платы (или RESET): 32-битный `unsigned long`, считается в фоне по прерыванию Timer0 и не останавливает программу.

𝚗𝚘𝚠−tпосл≥T⇒переключить,tпосл=𝚗𝚘𝚠

Конечный автомат: каждая задача помнит своё время «последний раз»; loop() при каждом обороте только проверяет — никогда не ждёт, поэтому кнопка читается при каждом обороте.

232 мс=4294967296 мс≈49,71 сут

Переполнение: после 4 294 967 295 `unsigned long` возвращается к 0 — через 49 суток 17 часов 2 минуты 47 секунд.

(5−4294967290)mod232=11

Вычитание работает правильно: беззнаковое вычитание тоже идёт по модулю 232 — даже если часы вернулись к 0, 𝚗𝚘𝚠−t даёт настоящее прошедшее время. А 𝚗𝚘𝚠≥t+T при переполнении ошибается.

Теория

Что делает 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 через 232 = 4 294 967 296 мс ≈ 49,71 суток. Простое сравнение `if (now >= tRed + 250)` в этот момент ошибается: `tRed + 250` тоже переполняется и становится малым числом, а `now` ещё велико — условие истинно при каждом обороте, светодиод мерцает со скоростью loop, пока часы тоже не вернутся к 0. Правильный способ — разность: `now - tRed >= 250`. Беззнаковое вычитание тоже идёт по модулю 232: если 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 мс, есть небольшой разброс). «Висящий» вход смоделирован случайной последовательностью. Ток светодиода (5−ULED)/(220+25) Ом: красный ≈ 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 мс.

Что внутри эксперимента

Настоящая плата в рисованной изометрии: вы поворачиваете её, ставите детали, собираете цепь, пишете и загружаете код — ток и напряжение считаются сразу, а при ошибке деталь сгорает. Внутри — задания, теория, тест и экскурсия.

Другие эксперименты раздела