Гаражная сигнализация досталась мне по наследству: кнопка постановки на охрану, светодиод-маяк и зуммер. Скетч я переписал «по-своему» за полчаса: маяк — digitalWrite HIGH, delay(400), LOW, delay(400); зуммер пикс тем же способом. Кнопку опрашивал в начале loop — один раз за оборот, а оборот длился 800 мс. В итоге, чтобы снять охрану, я держал кнопку по три секунды, а из десяти коротких нажатий на столе система ловила два-три.
Я был уверен, что виновата кнопка. Поставил подтяжку получше, поменял кнопку, перепаял провод — симптом не двигался. Диагноз прозвучал только на листе бумаги: за оборот loop длиной 800 мс кнопка опрашивалась один раз, нажатие длиной 200 мс попадало в это окно в одном случае из четырёх. Продублировать опрос после каждого delay — четыре копии — я отказался принципиально: обрывки одинакового кода я уже терял, а при пятом режиме их стало бы восемь.
Правильная формулировка оказалась короткой: у этого устройства нет ни одной задачи, которой нужен delay. Кнопка — это микросекунда на digitalRead, маяк — одна вспышка раз в 400 мс, зуммер — два переключения в секунду. Всё живёт в одном общем цикле, где ни одна функция никого не заставляет ждать. Такой стиль называется кооперативным: планировщика нет, и каждая задача сама обязана вернуть управление. В этом уроке собираем сигнализацию заново — с антидребезгом из урока про дребезг кнопки, маяком без delay и честной проверкой, что ни одна функция не держит loop дольше пары миллисекунд.
Коридор, в котором нельзя стоять
loop — это общий коридор. Пока маяк стоит посреди коридора с delay(400), никто мимо не проходит: кнопка, датчики, счётчики — всё ждёт. В кооперативном цикле у задач нет очереди и нет диспетчера: каждая пробегает свой маленький кусок — проверила условие, сделала мгновенную работу, отдала коридор дальше. Гейт вида millis() - last >= PERIOD — по одному на задачу, механика была разобрана в уроке про millis() вместо delay. Правило всего одно: ни одна функция не держит управление дольше пары миллисекунд. Мой личный порог жёстче — до сотни микросекунд на обычный прогон, и только печати в порт позволено жить подольше, раз в несколько секунд.
Внутренних «спящих», закрывающих коридор, в бытовых скетчах три. Первая и очевидная — delay(). Вторая — длинная печать: строка в шестьдесят символов на 9600 бод стоит около 62 мс, и печать в каждом обороте превращает loop в конвейер канала — подробный разбор с ценой каждого символа был в уроке про уровни логов. Третья — pulseIn() без таймаута: она ждёт импульс до целой секунды; в следующем уроке про micros() мы всегда будем ставить таймаут третьим аргументом, чтобы и эта дверь оставалась открытой.
Отдельная честность про звук. tone() играет частоту на аппаратном таймере Timer2, а часы millis() живут на Timer0 — друг другу они не мешают, и loop во время писка крутится как ни в чём не бывало. Но зуммер сам не замолчит: когда режим сменился, звук надо гасить noTone() руками — эта грабля уже встречалась в уроке про switch/case, где ночник уходил из тревоги продолжающим пищать.
| Что держит коридор | Сколько держит | Чем заменить |
|---|---|---|
| delay(400) в маяке | 400 мс | гейт millis(): вспышка по будильнику |
| Строка в 60 символов на 9600 бод | около 62 мс | печать раз в секунду и по частям |
| pulseIn(ECHO, HIGH) без таймаута | до 1000 мс | таймаут третьим аргументом, например 25000 мкс |
| Копии опроса кнопки после каждого delay | не спасают: копии забываются | один опрос в каждом обороте плюс антидребезг |
| while (!gotovo) {} | вечно | флаг проверили — вышли; зайдём снова на следующем обороте |
Кнопка в общем цикле — это две задачи в одном флаконе. Первая: читать пин каждый оборот — digitalRead стоит около 5 мкс, и даже сотни тысяч оборотов в секунду не делают её дорогой. Вторая: не верить каждому фронту — контакты дребезжат 5–20 мс, и без фильтра одно нажатие выглядит как серия. Антидребезг, который мы проходили, — это тоже millis-гейт: запоминаем момент последнего изменения уровня и признаём событие, только если с тех пор прошло хотя бы 15 мс. В общий цикл он вписывается без единого изменения — это просто функция, которая не спит.
Признанное событие дальше меняет не светодиод, а переменную состояния: ohrana, ноль или единица. Тот же приём, что в конечном автомате светофора, только состояний пока два. Флаг читают задачи маяка и писка — кнопка не обязана знать, как они устроены: события пишут состояние, задачи его читают. Когда состояний станет четыре, флаг сменится на переменную режима со switch, а сами функции не пошевелятся.
Когда прерывание всё-таки нужно: если loop занят долгой работой, которую не разрезать, — например, датчик DS18B20 со своими 750 мс ожидания преобразования, — кнопку выносят на прерывание, как в уроке про attachInterrupt. Для сигнализации это избыточно: в кооперативном цикле опрос стоит 5 мкс, и событий, которые не успевают, здесь нет.
const int PIN_KNOPKA = 2;const int PIN_LED = 12;const int PIN_ZUMMER = 8; int ohrana = 0; // состояние: 0 — тихо, 1 — охранаint gorim = 0; // фаза маякаint piskaet = 0; // фаза писка: 0 — тишина, 1 — звукunsigned long lastKnopka = 0; // момент последнего признанного фронтаunsigned long lastMayak = 0;unsigned long lastPisk = 0; void taskKnopka() { static int predidushee = HIGH; int sejchas = digitalRead(PIN_KNOPKA); if (sejchas != predidushee && millis() - lastKnopka >= 15) { predidushee = sejchas; // антидребезг: фронт признали, отметку обновили lastKnopka = millis(); if (sejchas == LOW) { ohrana = 1 - ohrana; Serial.print("Ohrana "); Serial.println(ohrana); if (ohrana == 0) { // выключаем чисто: гасим и глохнем digitalWrite(PIN_LED, LOW); noTone(PIN_ZUMMER); gorim = 0; piskaet = 0; } } }} void taskMayak() { if (ohrana == 0) return; // не охрана — маяк вообще не думает if (millis() - lastMayak >= 300) { lastMayak = millis(); gorim = 1 - gorim; digitalWrite(PIN_LED, gorim); }} void taskPisk() { if (ohrana == 0) return; if (piskaet == 0) { // фаза тишины: 600 мс, потом писк if (millis() - lastPisk >= 600) { lastPisk = millis(); piskaet = 1; tone(PIN_ZUMMER, 1200); } } else { // фаза звука: 150 мс, потом тишина if (millis() - lastPisk >= 150) { lastPisk = millis(); piskaet = 0; noTone(PIN_ZUMMER); } }} void setup() { Serial.begin(9600); pinMode(PIN_KNOPKA, INPUT_PULLUP); pinMode(PIN_LED, OUTPUT); pinMode(PIN_ZUMMER, OUTPUT); Serial.println("Ohrana: knopka 2, mayak 12, pisk 8");} void loop() { taskKnopka(); // каждая задача пробегает свой кусок taskMayak(); // и отдаёт коридор следующей taskPisk();}Три функции — три задачи, и ни в одной нет ни delay, ни while-ожидания. taskKnopka читает пин, проверяет фронт с гейтом 15 мс и только по признанному нажатию щёлкает ohrana. taskMayak раз в 300 мс инвертирует светодиод — и только когда охрана включена. taskPisk живёт двумя фазами: 600 мс тишины, 150 мс писк; фаза хранится в переменной piskaet, и обе границы — два отдельных гейта.
Смотрите на выключение: при ohrana = 0 скетч гасит светодиод, вызывает noTone() и сбрасывает gorim и piskaet в ноль. Без этого маяк уходил бы в выключенный режим посреди вспышки — замороженный светодиод, а зуммер продолжал бы играть частоту: звук сам не выключается, ночник из урока про switch/case ловил эту же граблю в тревоге. «Хвост состояния» — главная новая цена неблокирующего стиля: у задач появилась память, и её надо чистить при каждом переходе.
И последнее: печать — только по событию. Одна строка на нажатие кнопки, всё. Печать состояния в каждом обороте — способ за минуту превратить кооперативный цикл в очередь к 9600 бодам: канал сам станет планировщиком и начнёт терять нажатия за вас. Захотелось видеть ритм — заведите отдельную задачу с секундным периодом и короткой строкой; формат с метками уровней у нас разобран в уроке про уровни логов.
Сигнализация в симуляторе
- Щёлкайте кнопку в разных фазах маяка и посчитайте, сколько нажатий из десяти сработали. Сравните со старым delay-скетчем из начала урока: там ловились два-три из десяти.
- Ужмите гейт антидребезга с 15 до 2 мс. В симуляторе кнопка идеальная и разницы не покажет — запомните честно: на реальной плате дребезг при 2 мс начнёт пробивать фильтр, и режим станет листать сам. Мой рабочий диапазон — 15–20 мс.
- Добавьте второй режим — «тихая охрана»: маяк не мигает, зуммер пиксает раз в две секунды. Понадобится переменная режима вместо флага и аккуратный сброс gorim и piskaet при каждом переходе.
- Сломайте цикл нарочно: впишите delay(200) в taskMayak и пощёлкайте кнопку — посмотрите, как она слепнет: 200 мс — это окно, в котором половина нажатий не видна. Потом уберите.
- Вынесите печать состояния в отдельную задачу с периодом 1000 мс: строка вида ohrana=1 mayak=1 pisk=0. Убедитесь, что кнопка не слепнет, и сверьте ширину строки с ценой печати из таблицы.
- Кооперативный цикл — договор: ни одна функция не держит loop дольше пары миллисекунд; delay, длинная печать и pulseIn без таймаута — три двери, которыми коридор закрывается
- Кнопка в общем цикле — digitalRead каждый оборот плюс millis-гейт 15 мс: антидребезг не отдельный механизм, а такая же millis-задача
- События меняют состояние (ohrana, режим), задачи состояние читают: кнопка не знает про маяк, маяк не знает про кнопку
- При смене режима чистите хвост: noTone() и сброс фаз — иначе вспышка и звук уезжают в новый режим вместе с памятью; tone() играет на Timer2 и не мешает часам на Timer0, но и молчать без noTone() не станет
- Печать только по событию или с секундным гейтом: строка в 20 символов на 9600 бод — это 20 мс loop, и опрос кнопки уже не успевает
- 1. Почему в старом delay-скетче терялось примерно три нажатия из четырёх?
- 2. Что из этого НЕ держит коридор loop?
- 3. Сменили режим с охраны на тишину, а зуммер продолжает пикать. Что забыли?
- 4. Антидребезг 15 мс в общем цикле — это…
Частые вопросы по теме урока
- Почему в старом delay-скетче терялось примерно три нажатия из четырёх?
- Окно опроса открывалось раз в 800 мс, а нажатие живёт 200 мс: попадание в окно — один шанс из четырёх. Не электрика, а арифметика расписания. Оборот loop длился 800 мс из-за delay, кнопка опрашивалась раз за оборот: нажатие в 200 мс попадало в окно в одном случае из четырёх
- Что из этого НЕ держит коридор loop?
- digitalRead стоит около 5 мкс — это и есть нормальный вес задачи в кооперативном цикле. Всё остальное держит loop от десятков до тысяч миллисекунд. digitalRead кнопки в каждом обороте
- Сменили режим с охраны на тишину, а зуммер продолжает пикать. Что забыли?
- Частоту играет аппаратный таймер, сам она не гаснет. При смене режима чистим хвост состояния: noTone(), погасить светодиод, обнулить фазы. noTone() и сброс фазы писка при выключении
- Антидребезг 15 мс в общем цикле — это…
- Антидребезг — та же пара «отметка + порог», что и у всех millis-задач. Он не требует ни библиотек, ни прерываний — и потому вписывается в общий цикл без единого изменения. millis-гейт: событие признаётся, только если с последнего изменения уровня прошло не меньше 15 мс