Программы, которые никогда не ждут: millis() и состояния
Почему delay() ослепляет робота, как таймеры на millis() ведут несколько дел сразу, почему шаблон таймера не боится переполнения и как конечный автомат упорядочивает объезд препятствий.
Главное
- delay() останавливает весь скетч: робот, который ждёт, не может ни измерять, ни реагировать.
- millis() считает миллисекунды с момента запуска; храните время в переменных типа unsigned long.
- Шаблон if (now - timer >= interval) никогда не ждёт и остаётся верным, когда millis() примерно через 49,7 дня переполняется.
- Флаг — это переменная типа bool, которая помнит ответ «да» или «нет» между проходами loop().
- Конечный автомат хранит одну переменную состояния; каждое состояние делает свою работу и проверяет, не пора ли его сменить.
Arduino Uno R3Есть в симуляторе× 1Цену смотрите в магазине
Модуль светодиода 5 мм, красныйЕсть в симуляторе× 1Цену смотрите в магазине
delay() легко использовать, но эта функция останавливает весь скетч. Робот, который ждёт, ничего не видит. Решение — не ждать, а поглядывать на часы. Для этого есть три инструмента: таймеры на millis(), флаги и конечный автомат.
Что на самом деле делает delay()
Во время delay(1000) Uno целую секунду не выполняет ни одной другой строки вашего скетча. Плата не может ни прочитать датчик, ни переключить мотор, ни вывести сообщение. Продолжается только фоновая работа, например счёт времени.
Для простой задачи это нормально. Скетч из проекта «Первый заезд» использует delay(STRAIGHT_MS), то есть 1200 мс, для каждой стороны квадрата. У того робота нет датчика, и больше ему делать нечего. А теперь представьте, что на нём стоит датчик расстояния. На каждой стороне робот 1,2 секунды не смотрел бы вперёд. Он врезался бы в стену, которая появилась за это время.
Кроме того, с delay() трудно делать два дела сразу. Попробуйте с помощью delay() мигать одним светодиодом каждые 500 мс, а другим — каждые 170 мс. Каждый вызов delay() задерживает оба светодиода. Пришлось бы вручную составлять общее расписание. И оно сломалось бы, как только вы добавили бы третье дело.
Некоторые функции немного ждут и сами. В скетчах наших роботов pulseIn() ждёт эхо до 25 мс. Такие короткие ожидания допустимы. Проблема — долгое ожидание в тот момент, когда робот должен смотреть.
millis(): часы, на которые можно посмотреть
millis() возвращает число миллисекунд с момента запуска платы. Счёт идёт сам, в фоне, пока работает ваш скетч. Результат имеет тип unsigned long: это целое число от 0 до 4 294 967 295. Любое значение времени тоже храните в переменной типа unsigned long. int не подойдёт: на Uno он вмещает числа только до 32 767. В миллисекундах это меньше 33 секунд.
Вспомните, как варят рис. Вы не стоите у кастрюли все 20 минут. Вы засекаете время, когда начали. Потом время от времени смотрите на часы: прошло ли 20 минут? А в промежутках занимаетесь другими делами. Таймер на millis() работает точно так же.
Шаблон таймера
Вот один из двух таймеров скетча TwoTimers:
unsigned long now = millis();
if (now - moduleTimer >= MODULE_MS) {
moduleTimer = now;
moduleOn = !moduleOn;
digitalWrite(LED_MODULE, moduleOn);
}now - moduleTimer— время, прошедшее с последнего переключения светодиода.>= MODULE_MSспрашивает: прошло ли хотя бы 500 мс?- Если да, скетч сохраняет новое время в
moduleTimer, переключает флаг и выставляет уровень на пине. - Если нет, он пропускает этот блок. Он не ждёт: переходит к следующему делу и спрашивает снова при следующем проходе
loop().
Используйте >=, а не ==. Ровно в 500 мс скетч может быть занят чем-то другим, например ждать внутри pulseIn(). С >= таймер всё равно сработает, пусть и чуть позже. С == он может пропустить свой момент и не сработать никогда.
Почему вычитание не боится переполнения
Через 4 294 967 295 мс, то есть примерно через 49,7 дня, millis() снова начинает счёт с 0. Это называется переполнением (overflow).
Беззнаковые числа идут по кругу, как стрелки на циферблате. На 12-часовом циферблате от 11 часов до 2 часов проходит 3 часа, хотя 2 меньше 11. Вычитание в unsigned long работает так же: оно всегда даёт прошедшее время, отсчитанное вперёд.
Пример с настоящими числами:
moduleTimerравен 4 294 967 000: это за 296 мс до переполнения.nowравен 204: это через 204 мс после переполнения.- На самом деле прошло 296 + 204 = 500 мс.
- Uno вычисляет 204 - 4 294 967 000 в
unsigned longи получает ровно 500. Таймер срабатывает вовремя.
А другая запись, now >= moduleTimer + MODULE_MS, здесь не работает. Сумма 4 294 967 000 + 500 переполняется и становится равной 204. Сразу после того, как таймер запустили, now всё ещё около 4 294 967 000. Это больше 204, поэтому проверка сразу оказывается истинной — и так на каждом проходе, пока millis() не переполнится.
Робот редко работает 49 дней подряд. Но правильный шаблон написать ничуть не сложнее, а некоторые устройства, например контроллер теплицы, работают месяцами.
Два светодиода, два таймера
// Two timers: two LEDs blink at different speeds, and loop() never waits.
// Wiring: LED module S -> D2, - -> GND. The Uno's own L LED is on D13.
const byte LED_MODULE = 2;
const byte LED_BOARD = 13;
const unsigned long MODULE_MS = 500; // the module toggles every 500 ms
const unsigned long BOARD_MS = 170; // the L LED every 170 ms
unsigned long moduleTimer = 0;
unsigned long boardTimer = 0;
bool moduleOn = false;
bool boardOn = false;
void setup() {
pinMode(LED_MODULE, OUTPUT);
pinMode(LED_BOARD, OUTPUT);
}
void loop() {
unsigned long now = millis();
// "Has enough time passed?" Subtracting stays right even when millis() wraps round after 49.7 days.
if (now - moduleTimer >= MODULE_MS) {
moduleTimer = now;
moduleOn = !moduleOn;
digitalWrite(LED_MODULE, moduleOn);
}
if (now - boardTimer >= BOARD_MS) {
boardTimer = now;
boardOn = !boardOn;
digitalWrite(LED_BOARD, boardOn);
}
// Anything else can run here, because nothing above ever waits.
}В TwoTimers у каждого светодиода свой таймер: переменная, в которой хранится время последнего переключения, и постоянный интервал.
| Светодиод | Пин | Переключается каждые | Одно полное мигание |
|---|---|---|---|
| Модуль светодиода | D2 | 500 мс (MODULE_MS) | 1 с: раз в секунду |
| Светодиод L на Uno | D13 | 170 мс (BOARD_MS) | 340 мс: почти три раза в секунду |
loop() выполняется много тысяч раз в секунду. На большинстве проходов обе проверки отвечают «ещё нет», и ничего не происходит. Иногда одна из них срабатывает, переключает свой светодиод и запоминает время. Ни один таймер не ждёт другого.
Подключение такое же, как в проекте Blink: пин S модуля светодиода — на D2, а его пин «-» — на GND.
Обратите внимание, где «живут» переменные. moduleTimer, boardTimer, moduleOn и boardOn объявлены вверху, вне loop(). Переменная, объявленная внутри loop(), создаётся заново на каждом проходе и забывает своё значение.
Флаги: запоминаем «да» или «нет»
moduleOn и boardOn — это флаги: переменные типа bool, которые помнят ответ «да» или «нет». moduleOn = !moduleOn переключает флаг: true превращается в false, а false — в true. Затем digitalWrite(LED_MODULE, moduleOn) выставляет на пине HIGH при true и LOW при false.
Флаги хороши для простых фактов: горит ли светодиод, запустился ли робот? Но если у робота несколько режимов, во множестве флагов легко запутаться. Ничто не мешает двум из них одновременно быть true, даже когда это бессмысленно. Для режимов используйте конечный автомат.
Что вы увидите в симуляторе
Соберите в симуляторе настольного робота из проекта Blink: Uno и модуль светодиода, S на D2. Вставьте скетч TwoTimers выше в «Код» и нажмите «Загрузить». На столе Uno работает от USB ноутбука. Модуль светодиода мигает раз в секунду, а светодиод L — почти три раза в секунду. Так как 500 не кратно 170, светодиоды переключаются в разные моменты, и рисунок их мигания всё время смещается. Ни один светодиод никогда не ждёт другого.
Конечный автомат: одна переменная для того, чем занят робот
У робота, объезжающего препятствия, три дела, которые сменяют друг друга: ехать вперёд, ехать назад, поворачивать. Каждое дело — это состояние. Робот всегда находится ровно в одном состоянии.
enum class State : byte { Drive, Back, Turn };
State state = State::Drive;enum class создаёт новый тип с тремя именованными значениями. Благодаря : byte он занимает один байт оперативной памяти. Переменная state хранит текущее состояние. Почему не обычные числа? С int запись state = 2 ничего не сказала бы читателю, а state = State::Turn понятна сразу. Кроме того, enum class строгий: компилятор не примет простое число в качестве State. А имена остаются внутри State, поэтому не смешиваются с другими именами в скетче.
В loop() конструкция switch (state) переходит к ветке case текущего состояния. Каждая ветка делает две вещи: задаёт работу моторов и проверяет, не пора ли сменить состояние. break завершает ветку.
Функция enter(next) меняет состояние. Ещё она сохраняет время в stateStart, чтобы каждое состояние могло само отсчитывать своё время. Если следующее состояние — Drive, она обнуляет lastPing. Благодаря этому робот сразу измеряет расстояние заново, а не едет дальше со старым значением.
Измерение — это не состояние. У него свой таймер в начале loop(): каждые 60 мс, в любом состоянии. Поэтому робот продолжает измерять, даже когда едет назад и поворачивает.
// Obstacle avoider: drive ahead; when something is close, back off and turn.
// Written without delay() in loop(): a state machine and millis() timers,
// so the robot keeps measuring while it moves.
// L298N: ENA -> D5, IN1 -> D4, IN2 -> D7 (left), ENB -> D6, IN3 -> D8, IN4 -> D12 (right)
// HC-SR04: TRIG -> D9, ECHO -> D10
const byte ENA = 5, IN1 = 4, IN2 = 7;
const byte ENB = 6, IN3 = 8, IN4 = 12;
const byte TRIG = 9, ECHO = 10;
const int CRUISE = 190; // forward speed, 0..255
const int STOP_CM = 25; // closer than this: avoid it
const unsigned long PING_MS = 60; // the HC-SR04 needs a pause between pings
const unsigned long BACK_MS = 300;
const unsigned long TURN_MS = 380;
enum class State : byte { Drive, Back, Turn };
State state = State::Drive;
unsigned long stateStart = 0;
unsigned long lastPing = 0;
int distanceCm = 400;
void setMotor(byte enablePin, byte inA, byte inB, int speed) {
speed = constrain(speed, -255, 255);
digitalWrite(inA, speed > 0);
digitalWrite(inB, speed < 0);
analogWrite(enablePin, abs(speed));
}
void drive(int left, int right) {
setMotor(ENA, IN1, IN2, left);
setMotor(ENB, IN3, IN4, right);
}
// Distance in centimetres; 400 when no echo came back (nothing within about 4 m).
int readDistanceCm() {
digitalWrite(TRIG, LOW);
delayMicroseconds(2);
digitalWrite(TRIG, HIGH); // a 10 microsecond pulse starts a measurement
delayMicroseconds(10);
digitalWrite(TRIG, LOW);
unsigned long us = pulseIn(ECHO, HIGH, 25000UL); // wait at most 25 ms
if (us == 0) return 400;
return us / 58; // sound goes there and back: 58 us per cm
}
void enter(State next) {
state = next;
stateStart = millis();
if (next == State::Drive) lastPing = 0; // measure again before driving on
}
void setup() {
const byte pins[] = {ENA, IN1, IN2, ENB, IN3, IN4, TRIG};
for (byte pin : pins) pinMode(pin, OUTPUT);
pinMode(ECHO, INPUT);
Serial.begin(9600);
}
void loop() {
unsigned long now = millis();
if (now - lastPing >= PING_MS) {
lastPing = now;
distanceCm = readDistanceCm();
Serial.println(distanceCm);
}
switch (state) {
case State::Drive:
drive(CRUISE, CRUISE);
if (distanceCm < STOP_CM) enter(State::Back);
break;
case State::Back:
drive(-160, -160);
if (now - stateStart >= BACK_MS) enter(State::Turn);
break;
case State::Turn:
drive(170, -170); // spin right on the spot
if (now - stateStart >= TURN_MS) enter(State::Drive);
break;
}
}Диаграмма состояний в виде таблицы
На бумаге каждое состояние можно нарисовать кружком, а каждый переход — стрелкой. Таблица содержит то же самое, а набирать её проще:
| Состояние | Что делает робот | Когда меняется | Следующее состояние |
|---|---|---|---|
| Drive | drive(CRUISE, CRUISE): вперёд со скоростью 190 |
расстояние меньше STOP_CM (25 см) | Back |
| Back | drive(-160, -160): назад со скоростью 160 |
через BACK_MS (300 мс) после входа в Back | Turn |
| Turn | drive(170, -170): разворот вправо на месте |
через TURN_MS (380 мс) после входа в Turn | Drive |
Когда планируете своего робота, сначала заполните такую таблицу: по строке на каждое состояние. Затем напишите по одному case на каждую строку. Столбец «Когда меняется» становится условием if, а столбец «Следующее состояние» — вызовом enter().
Новое поведение — это новая строка. Например, состояние Stop могло бы держать робота на месте, пока что-то ближе 10 см, и возвращать его в Drive, когда путь свободен. Вы добавляете одну строку для Stop и одно новое условие в строку Drive. Всё остальное остаётся прежним.
Запустите робота, объезжающего препятствия, на арене и следите за монитором порта (Serial Monitor). Расстояния продолжают приходить примерно каждые 60 мс, даже когда робот едет назад и поворачивает. Если бы в этих состояниях стоял delay(), поток чисел прерывался бы. Полностью этот робот описан в проекте «Робот, объезжающий препятствия».
Проверьте себя
1Чем delay() плох для робота с датчиком?
Во время delay() не выполняется ни одна другая строка скетча. Пока задержка не закончится, робот не может ни измерять, ни реагировать.
2Почему проверка now - timer >= interval надёжна, когда millis() переполняется и снова начинает с 0?
Вычитание в unsigned long идёт по кругу, как стрелки на циферблате, поэтому результат всё равно равен времени, которое прошло на самом деле.
3Что заставляет робота, объезжающего препятствия, выйти из состояния Turn?
Время: когда с момента входа в Turn пройдёт 380 мс (TURN_MS), вызов enter(State::Drive) вернёт его в Drive.
Задание
Измените TwoTimers так, чтобы модуль светодиода раз в секунду давал короткую вспышку: 100 мс горит, затем 900 мс не горит. Светодиод L должен по-прежнему переключаться каждые 170 мс. Загрузите скетч в Uno на столе и проверьте, что оба светодиода всё так же работают одновременно.
Подсказка
Время ожидания может зависеть от того, горит светодиод сейчас или нет.