Теплица на моём подоконнике прожила на Uno два месяца спокойно, а сломалась в тот вечер, когда я решил добавить ей кнопку «полей сейчас». Схема была простая: аналоговый датчик влажности на A0, индикаторный светодиод, мигавший раз в полсекунды, чтобы было видно, что плата жива, и новая кнопка на пине 2. Мигалку я тогда писал честно, как умел: digitalWrite, delay(500), инверсия. Кнопку опрашивал в начале loop — один раз за оборот. Итог: из трёх нажатий срабатывало примерно одно. Палец держит кнопку 150–200 мс, а между опросами лежала пауза почти в 600 мс — кнопка жила в глухом окне, пока loop спал в delay.
Первое моё исправление было в духе заплатки: опрос кнопки я продублировал после каждого delay — ещё два раза. Стало лучше, но код превратился в три одинаковых обрывка, и через месяц, дописывая четвёртую задачу, один обрывок я нечаянно снёс. Кнопка снова начала пропускать нажатия, причём только днём, когда мигал индикатор. Полдня я подозревал кнопку, потом блок питания, и лишь вечером заметил, что опросов осталось два. Это и есть главная цена delay-архитектуры: задачи живут не вместе, а по очереди, и каждая новая обходится дороже предыдущей.
Выход, к которому я пришёл, помещается в одну строку на задачу. В уроке про millis() вместо delay() мы держали одну пару «отметка lastRun + период» для одной мигалки. Планировщик — это несколько таких пар рядом: массив периодов PERIODY, массив отметок lastRun и один loop, который раздаёт ход задачам по расписанию. Ниже — как это выглядит целиком, сколько стоит каждая задача в микросекундах и правило, по которому заранее видно, что четвёртая и пятая задача ничего не сломают.
Одна петля, много будильников
Механика — будильники на одном столе. millis() не знает про задачи: она просто возвращает миллисекунды с подачи питания. Таймером её делает пара переменных: отметка, когда задача последний раз запускалась, и период, через который её пора запускать снова. Условие millis() - lastRun >= PERIOD читается как «переросло — пора». Мигалка из урока про millis — один такой будильник; планировщик — три, пять, десять, но у каждой задачи своя пара, и они не знают друг о друге.
Почему «переросло», а не «равно». loop на Uno без delay крутится со скоростью порядка сотни тысяч оборотов в секунду, а период у датчика — 250 мс. Момент строгого равенства millis() - lastRun == PERIOD почти никогда не наступает: millis() тикает миллисекундами, а между двумя тиками loop успевает провернуть десятки оборотов. Условие со знаком «больше или равно» не требует поймать мгновение: как только разница доросла до периода — запускай.
Теперь про вычитание, которое выглядит опасно. millis() переполняется через 49,7 суток — об этом уже был разговор в уроке про millis. В беззнаковой арифметике unsigned long разность millis() - lastRun даёт правильный интервал и через момент переполнения: счётчик как будто склеивается, а расстояние между «было» и «сейчас» остаётся верным. Ломается здесь только прямое сравнение millis() > lastRun — его в планировщике нет и быть не должно.
И где здесь массивы. Задач три — пар «период + отметка» тоже три, и это буквально два массива по три ячейки: PERIODY и lastRun. Индекс — номер задачи. Цикл for перебирает номера, условие решает, пора ли, и вызов передаёт работу нужной функции. Каркас я взял из урока про массивы: там массив был партитурой маяка, здесь — расписанием.
| Задача | Период | Что делает | Сколько стоит один прогон |
|---|---|---|---|
| Опрос влажности (A0) | 250 мс | analogRead и сравнение с порогом | около 112 мкс |
| Индикатор-маяк (D12) | 500 мс | инверсия светодиода | около 5 мкс |
| Отчёт в порт | 5000 мс | строка в 25 символов | около 25 мс на 9600 бод |
| Кнопка (D2) | каждый оборот | digitalRead и антидребезг | около 5 мкс |
const int PIN_LED = 12;const unsigned long PERIODY[3] = {250, 500, 5000}; // датчик, маяк, отчётunsigned long lastRun[3] = {0, 0, 0}; // отметки последнего прогона void zamerDatchika() { int v = analogRead(A0); Serial.print("v="); // пара имя=значение — ложится на плоттер Serial.println(v);} void migalka() { static int svetit = 0; svetit = 1 - svetit; digitalWrite(PIN_LED, svetit);} void otchyot() { Serial.print("t="); Serial.print(millis() / 1000); Serial.println("s: tri zadachi zhivy");} void zapuskZadachi(int i) { // работа задачи по её номеру if (i == 0) { zamerDatchika(); } else if (i == 1) { migalka(); } else { otchyot(); }} void setup() { Serial.begin(9600); pinMode(PIN_LED, OUTPUT); Serial.println("Planner: 3 zadachi v odnom loop");} void loop() { for (int i = 0; i < 3; i = i + 1) { if (millis() - lastRun[i] >= PERIODY[i]) { lastRun[i] = millis(); // перевод стрелок — не забыть! zapuskZadachi(i); } }}Добавление четвёртой задачи — это ровно три действия: ячейка в PERIODY, ячейка в lastRun и ветка в zapuskZadachi. Сам loop не меняется никогда, и это главный признак того, что каркас собран правильно. Порядок обхода имеет смысл: задачи с коротким периодом ставьте в начало массива — они получают ход раньше и с меньшим сдвигом.
Про сдвиг честно. Отчёт в 25 символов на 9600 бод держит loop около 25 мс, и всё это время соседние задачи стоят в очереди: замер датчика, совпавший с печатью, доедет на 25 мс позже. Для периода 250 мс это десятая часть — на плоттере Serial Plotter такое видно как редкие зазубрины на ряде v=, но на поведение не влияет. Рабочее правило я вывел такое: суммарная работа всех задач за период должна быть в разы меньше самого короткого периода. У моей теплицы за секунду набирается около шести миллисекунд — два с небольшим процента от короткого периода в 250 мс, запас огромный.
И обратная сторона той же честности: девяносто девять процентов времени Uno крутится вхолостую — опрос кнопки по 5 мкс, повторённый сотни тысяч раз за секунду, съедает больше времени, чем все три задачи вместе. Это нормально и чинить это не надо: показатель здоровья цикла — не проценты загрузки, а самая длинная непрерывная работа. Пока она короче 100 мс, нажатие в 150–200 мс не потеряется никогда. Вырастет работа длиннее — а если появятся события, которые нельзя ждать вовсе, кнопку выносят на прерывание, как в уроке про attachInterrupt.
Последняя развилка — чем обновлять отметку. Вариант lastRun[i] = millis() простой: задача проснулась, отметилась, следующий период отсчитывается от «сейчас». Вариант lastRun[i] += PERIODY[i] держит средний период ровнее, но после любого зависания начинает догонять серией мгновенных прогонов подряд. Я всюду ставлю первый: задачи могут чуть опаздывать, но не мстят. А когда задачам понадобится собственная память состояний — сетка светофора, очередность фаз — следующий шаг по лестнице делает конечный автомат из урока про светофор, планировщик при этом остаётся прежним. Ниже — живая лаборатория: две одинаковые платы-мигалки, левая на delay, правая на millis, — пощёлкайте кнопки и посчитайте, кто из них теряет нажатия.
Блокирующая пауза против неблокирующего таймера: нажмите кнопку и сравните.
Подгружаем интерактив…
Планировщик в симуляторе
- Посчитайте за десять секунд строки v= и t= в мониторе и сверьте с массивом PERIODY: датчик должен опроситься около сорока раз, отчёт — выйти дважды.
- Ужмите PERIODY[0] со 250 до 100 мс и посмотрите на ряд v= в плоттере: он станет в два с половиной раза гуще. Мигалка и отчёт при этом не сдвинутся ни на миг — проверьте по светодиоду.
- Добавьте четвёртую задачу — тихий писк зуммером при v ниже 300: одна ячейка в PERIODY, одна в lastRun, ветка в zapuskZadachi, и внутри — tone(PIN_ZUMMER, 1000) с noTone() через гейт. Помните: звук сам не замолчит.
- Сломайте планировщик нарочно: закомментируйте lastRun[i] = millis() в ветке отчёта. Отчёт начнёт печататься на каждом обороте loop, канал на 9600 бод задавит все остальные задачи. Почините и запомните симптом.
- Поднимите ползунок выше порога и убедитесь, что задачи друг другу не мешают: сверьте мигание маяка в момент, когда отчёт печатается, — по плоттеру интервалы v= рвутся на 25 мс, а период вспышек остаётся ровным.
- Планировщик — пара массивов: PERIODY[] с периодами и lastRun[] с отметками; всё расписание живёт в одном условии millis() - lastRun[i] >= PERIODY[i]
- «>=», а не «==»: loop проворачивает десятки оборотов между тиками millis(), строгого равенства дождаться нельзя; беззнаковое вычитание корректно и через переполнение на 49,7-е сутки
- Новая задача — одна ячейка в каждом массиве и одна ветка в разборе: сам loop не меняется никогда
- Здоровье цикла меряется самой длинной непрерывной работой, а не процентами загрузки: пока она короче 100 мс, нажатие в 150–200 мс не теряется
- lastRun[i] = millis() внутри ветки — перевод стрелок: забытый, он превращает задачу в поток на каждом обороте loop и задавит канал
- 1. Почему в планировщике условие millis() - lastRun >= PERIOD, а не строгое равенство?
- 2. В ветке задачи забыли lastRun[i] = millis(). Что будет?
- 3. Вы добавили задачу с периодом 50 мс, и один её прогон занимает 30 мс. Что происходит с соседями?
- 4. Чем lastRun[i] = millis() отличается от lastRun[i] += PERIODY[i]?
Частые вопросы по теме урока
- Почему в планировщике условие millis() - lastRun >= PERIOD, а не строгое равенство?
- millis() тикает миллисекундами, а loop — в сотни тысяч оборотов в секунду: между двумя тиками успевают пройти десятки проверок. Ловить мгновение равенства бессмысленно, разница должна «дорасти» до периода. loop делает десятки оборотов между тиками millis(): момент равенства почти никогда не наступает
- В ветке задачи забыли lastRun[i] = millis(). Что будет?
- Отметка не двигается, разность millis() - lastRun остаётся больше периода, и условие истинно в каждом обороте. Канал печати задавит все остальные задачи. Задача начнёт выполняться на каждом обороте loop: разница уже навсегда «переросла»
- Вы добавили задачу с периодом 50 мс, и один её прогон занимает 30 мс. Что происходит с соседями?
- Каждый прогон держит loop 30 мс, а период — 50 мс: задачи встают в очередь почти на весь период. Правило: суммарная работа за период должна быть в разы меньше короткого периода. Соседние задачи получают рваные интервалы — джиттер; при худшем раскладе прогон влезает в период почти целиком
- Чем lastRun[i] = millis() отличается от lastRun[i] += PERIODY[i]?
- Оба варианта рабочие. «Плюс-равно» держит средний период ровнее, но после любого зависания запускает серию догоняющих прогонов; я беру «равно» — чуть опаздывать безопаснее, чем мстить серией. «Равно» проще: задача может чуть опаздывать, но не догоняет серией; «плюс-равно» ровнее в среднем, но после задержки выплачивает долг подряд