Руководство · Начальный · 30 мин

Почему delay() ломает роботов: синхронизация с помощью millis()

Заблокированный цикл ничего не видит: во что delay() обходится роботу, какой шаблон на millis() его заменяет и где скрыта ошибка переполнения.

Введение

Почти каждый робот на этом сайте выполняет цикл, в котором нужно делать несколько дел одновременно: считывать датчик, обновлять регулятор, управлять двигателями, возможно, отвечать по радио. delay() — первый инструмент синхронизации, который изучает каждый, и именно он делает всё это невозможным.

Проблема не в том, что delay() медленный. Проблема в том, что во время delay() робот вообще ничего не делает — не читает датчики, не управляет движением, не замечает, что вот-вот ударится о стену. Это программа, которая добровольно остановилась.

На этой странице рассматривается, во что это обходится, какой шаблон его заменяет и какая ошибка всё ещё остаётся в очевидной замене.

Что на самом деле делает delay()

delay(50) крутится в плотном цикле, наблюдая за таймером, пока не пройдут 50 ms. Ваш код не продвигается. Продолжают происходить только прерывания: таймер, который управляет millis(), прерывание приёма по последовательному порту и всё, что вы подключили через attachInterrupt().

Это исключение важно, но одновременно является ловушкой: энкодер, подсчитываемый в прерывании, продолжает считать во время delay(), поэтому число остаётся правильным, пока поведение робота останавливается. Из-за этого неисправность становится только труднее заметной.

Во что это обходится, в миллиметрах

Цикл управления может реагировать только с частотой дискретизации. Объект, появившийся сразу после считывания, не будет замечен до следующего считывания.

Две наложенные временные шкалы длительностью 220 мс. Верхняя, с подписью о задержке 50 мс между считываниями, показывает считывания датчика высокими отметками каждые 50 мс, а промежутки заштрихованы и отмечены как заблокированные, когда ничего другого не выполняется. Нижняя, с подписью millis и циклом 5 мс, показывает считывания каждые 5 мс. Пунктирная красная линия отмечает появление препятствия на 51 мс; верхняя шкала замечает его через 49 мс, нижняя — через 4 мс.
Препятствие появляется сразу после считывания — это близко к худшему случаю для обоих циклов, и именно это число важно, поскольку цикл управления оценивают по худшей задержке, а не по средней. При 0.5 m/s заблокированный робот проезжает 24 mm, прежде чем узнает о препятствии; опрашиваемый — 2 mm. Скачать SVG

Двадцать четыре миллиметра — это почти вся линейка датчиков робота, следующего по линии. Этого также более чем достаточно, чтобы выйти из ячейки лабиринта, перескочить через разветвление или вывести балансирующего робота за пределы области, из которой он может восстановиться: ему нужен внутренний цикл 200 Hz, то есть бюджет 5 ms на всё, а одна delay(50) расходует десять циклов. Задержка всегда выражается расстоянием — как далеко робот проходит до того, как сможет среагировать, решает, остановится ли он вовремя или съедет с края.

Шаблон, который его заменяет

Вместо ожидания до нужного времени спрашивайте при каждом проходе, не пришло ли время:

unsigned long lastRead = 0;
const unsigned long READ_INTERVAL = 50;

void loop() {
  unsigned long now = millis();

  if (now - lastRead >= READ_INTERVAL) {
    lastRead = now;
    readSensor();
  }

  // everything else keeps running, every pass
  updateMotors();
  checkBumper();
}

Теперь loop() выполняется тысячи раз в секунду, а считывание датчика происходит по расписанию внутри него. Ничто не блокируется, а добавление второй задачи по времени требует всего ещё четырёх строк, а не переписывания.

Ошибка переполнения

millis() возвращает unsigned long — 32 бита на AVR — и считает миллисекунды с момента загрузки. Он исчерпывается через 2³² ms и возвращается к нулю, то есть через 49.7 дней.

Одно и то же сравнение можно записать двумя способами, но переживает переполнение только один:

if (now - lastRead >= READ_INTERVAL)   // correct
if (now >= lastRead + READ_INTERVAL)   // wrong

Возьмём lastRead = 4294967290 с интервалом 100 ms незадолго до переполнения. lastRead + 100 переполняется до 94, и now >= 94 сразу становится истиной — таймер перестаёт ждать и срабатывает на каждом проходе, пока счётчик не догонит его. Вариант с вычитанием не имеет этой проблемы, поскольку беззнаковое вычитание переполняется именно так, что разность получается правильной.

Отсюда следуют три правила:

  • Всегда вычитайте, никогда не прибавляйте. now - last >= interval.
  • Храните все метки времени в unsigned long. Запись millis() в int или знаковый long немедленно возвращает ошибку, а на AVR int переполняется через 32 секунды.
  • Никогда не проверяйте равенство. now == target — лотерея; при загруженном цикле значение проскочит, и событие никогда не сработает.

Для micros() нужно отдельное предупреждение. Он переполняется через 2³² микросекунды, то есть через 71.6 минут — совершенно обычное время работы робота. Правила те же, но переполнение наступает намного раньше.

Несколько задач с разными частотами

Когда задач по времени становится больше двух-трёх, задайте для них таблицу вместо набора переменных:

struct Task {
  unsigned long last;
  unsigned long interval;
  void (*run)();
};

Task tasks[] = {
  { 0,   5, readSensors   },
  { 0,  10, updateControl },
  { 0, 200, reportStatus  },
};

void loop() {
  unsigned long now = millis();
  for (Task &t : tasks) {
    if (now - t.last >= t.interval) {
      t.last = now;
      t.run();
    }
  }
}

Это кооперативный планировщик, и большинству роботов этого достаточно. Два замечания к нему:

t.last = now перезапускает часы с момента фактического выполнения задачи, поэтому опоздавший проход сдвигает и следующий, а интервал постепенно смещается. t.last += t.interval сохраняет интервал, но хуже, если задача когда-либо превысит его время выполнения: тогда она попытается догнать просроченные интервалы и выполнится несколько раз подряд. Используйте += для цикла, точность времени которого важна, и = now для всего остального.

Кроме того, вся конструкция зависит от того, что ни одна задача не блокируется. Одна delay() внутри reportStatus() задержит все остальные задачи.

Когда блокировка всё же правильный ответ

Не каждое ожидание является ошибкой. Проверьте, короче ли оно одного прохода цикла.

  • delayMicroseconds(10) для формирования импульса запуска HC-SR04 — нормально. Десять микросекунд — ничто, и неблокирующего способа сформировать его нет.
  • pulseIn() — нехорошо. Он блокируется до возврата эха или до истечения тайм-аута. HC-SR04 на пределе 4 m выполняет путь туда-обратно 8 m, что при 343 m/s занимает 23 ms, а пропущенное эхо стоит полного заданного тайм-аута. Поэтому в том руководстве настаивают на его установке.
  • Serial.print() скрывает задержку. Пока в 64-байтовом буфере есть место, он возвращается сразу, а когда места нет — блокируется. При 9600 бод буфер освобождается 67 ms, поэтому печать 40-символьной строки на каждом проходе с частотой 100 Hz неизбежно остановит цикл. Используйте 115200, печатайте меньше и делайте это по медленному таймеру.

Время цикла — реальное число

Перестаньте угадывать его:

unsigned long worst = 0;

void loop() {
  unsigned long t0 = micros();

  // ... the whole loop body ...

  unsigned long dt = micros() - t0;
  if (dt > worst) worst = dt;   // report this on a slow timer, not here
}

Отслеживайте худший проход, а не средний. Среднее значение показывает, что циклу комфортно; худшее — что циклу управления приходится выдерживать на самом деле, и обычно это Serial.print, тайм-аут pulseIn или чтение I²C, которому пришлось ждать.

Это особенно важно для ПИД-регулятора (PID). Дифференциальная составляющая делит изменение ошибки на время между отсчётами, поэтому дрожание этого интервала превращается прямо в шум D, а коэффициенты, настроенные при одной частоте цикла, перестают подходить при другой. Либо запускайте цикл с фиксированным интервалом и считайте dt константой, либо измеряйте dt на каждом проходе и используйте реальное значение. Делая ни то, ни другое, вы получаете регулятор, который на стенде работал нормально, но начинает вести себя иначе после добавления кода радиосвязи.

Что использовать вместо этого

Вместо Используйте
delay() для задания темпа считывания датчика Проверку интервала millis()
delay() для мигания светодиодом (LED) Таймер в том же цикле
delay() для ожидания прибытия сервопривода Оценку времени хода и проверку millis()
delay() для устранения дребезга кнопки Метку времени фронта и игнорирование изменений в течение 20 ms
delay() между ступенями скорости двигателя Профиль движения, управляемый по прошедшему времени
delay() для замедления всего цикла Ничего — работайте быстро и отдельно ограничивайте время задач
pulseIn() без тайм-аута pulseIn(pin, HIGH, 30000) и ветку «нет эха»

Когда что-то идёт не так

Симптом Обычно
Робот игнорирует бампер во время поворота delay() внутри поворота
Показания датчика приходят пачками Всё использует один таймер вместо отдельных интервалов
На стенде всё хорошо, а при включённом логировании — медленно Serial.print блокируется после заполнения буфера
Таймер непрерывно срабатывает примерно через 50 дней Форма now >= last + interval
Таймер начинает работать неправильно примерно через час Переполнение micros() на 71.6 минут
Таймер вообще не срабатывает Метка времени хранится в int или используется проверка равенства
ПИД хорошо настроен, но ведёт себя иначе Изменилась частота цикла; dt не соответствует допущениям коэффициентов
Энкодер считает правильно, но робот реагирует с задержкой Прерывания продолжают выполняться во время delay() — проблема не в счёте

Полезная привычка — считать loop() процедурой, которая всегда должна быстро возвращаться. Когда это так, добавление функции означает добавление задачи, а робот сохраняет свои рефлексы. Когда это не так, каждая новая возможность делает робота немного слепее, а причина никогда не находится там, где находится симптом.

Исследуйте граф

Входит в эти сборки

Проекты и учебные пути, в которые входит это руководство.

Дополнительные материалы

Источники