Год я честно начинал каждый loop со строки if (Serial.available() > 0) — приём из первого урока про чтение команд. Потом в примерах самой Arduino IDE встретил serialEvent() — функцию, которую скетч не зовёт ни разу, а данные она почему-то принимает. Решив, что открыл настоящее прерывание, я порадовался: наконец-то приём не будет зависеть от того, чем занят основной цикл.
Тупик вскрылся на проекте с бегущей строкой: loop рисовал её кусочками по четыреста миллисекунд, и команды срабатывали с запозданием почти до секунды. Я полез в исходники ядра и нашёл правду: serialEvent — не прерывание. Это обычная функция, которую main() вызывает после каждого прохода loop(), и спящий loop тормозит её ровно как любую свою часть. Будь там настоящее прерывание из урока про attachInterrupt — задержки бы не было вовсе.
Полезной привычкой serialEvent всё равно остался: приём вынесен из loop в отдельную функцию, основной цикл читается чище, а механика проходов — между какими итерациями приезжают данные — видна как на ладони. В этом уроке разбираем, что именно ядро делает после loop(), где serialEvent не работает вовсе, и как отличить медленный приём от потерянного. А заодно посчитаем, сколько байт переживает тишина занятого loop — потому что буфер не бесконечен, и торговля за него идёт на каждой секунде сна.
Не прерывание, а вежливый вызов
Схема на Uno двухэтажная. Нижний этаж — аппаратное прерывание UART: каждый принятый байт оно кладёт в 64-байтовый кольцевой буфер, тот самый из урока про буфер UART, и на этом его работа кончается. Верхний этаж — main(): выполнил loop(), спросил, есть ли байты в буфере, и если есть — позвал вашу serialEvent(). Один вызов на один проход цикла, не чаще и не реже.
Из схемы следуют три вещи. Первая: пока loop живёт — delay там или честный millis-планировщик, без разницы, — serialEvent не вызовется ни разу. Вторая: байты при этом не пропадают, нижний этаж работает всегда, но буфер ограничен: на 9600 бод байт стоит около миллисекунды, и 64 байта набегают за 64 мс молчания скетча. Третья: внутри serialEvent действуют те же запреты, что и в loop, — заняли его на секунду разбором, и весь скетч встал на секунду.
Где это живёт: ядро AVR — Uno, Nano, Mega — зовёт serialEvent всегда, если вы её описали. Ядро ESP32 такой функции не знает: скетч с serialEvent соберётся без единого предупреждения, но вызываться она не будет никогда. Моё мнение после одного молчащего переезда: приём пишу прямо в loop, а serialEvent считаю удобной локальной привычкой AVR-проектов — переносимый код не должен зависеть от вежливости ядра. У Mega, кстати, таких функций три: serialEvent, serialEvent1 и serialEvent2 — по одной на каждый UART, и зовутся они в том же проходе, одна за другой.
// Лампа-дежурка на пине 9, мигалка-свидетель на 13void setup() { Serial.begin(9600); pinMode(9, OUTPUT); pinMode(13, OUTPUT);} void serialEvent() { while (Serial.available() > 0) { // вычерпываем всё, что накопилось String cmd = Serial.readStringUntil(10); cmd.trim(); if (cmd == "on") { digitalWrite(9, HIGH); Serial.println("OK on"); } if (cmd == "off") { digitalWrite(9, LOW); Serial.println("OK off"); } if (cmd == "ping") Serial.println("OK pong"); }} void loop() { // своя работа: мигаем. delay здесь — специально: показывает, // что serialEvent случится только после конца прохода digitalWrite(13, HIGH); delay(400); digitalWrite(13, LOW); delay(400);}| Способ | Когда срабатывает | Что блокирует | Переносимость |
|---|---|---|---|
| if (Serial.available()) в начале loop | каждый проход loop | ничего: проверка стоит микросекунды | все ядра без исключений |
| serialEvent() | между проходами loop, зовёт ядро | спящий loop глохит её до конца прохода | AVR-ядра; на ESP32 не вызывается |
| RX-прерывание внутри ядра | каждый байт, даже во сне loop | ничего: байты копятся в буфере всегда | есть везде, наружу торчит только через available() |
Полезен, когда приём — отдельная профессия
Приём команд — это вычерпывание буфера, сборка строки, парсер и исполнение — добрый десяток строк, как в уроке про командный парсер. Без serialEvent вся эта простыня открывает loop, и main-логика уезжает вниз экрана. serialEvent разделяет профессии: слушает — одна функция, работает — другая. В маленьких скетчах разница косметическая, в скетче на пятьсот строк — уже удобство. Заодно приём получает своё место в журнале: строки из одной функции легко искать глазами среди чужих и фильтровать при разборе.
Правило гигиены, которое я вывел из собственных грабель: в serialEvent только вычерпать буфер, накопить строку и поставить флаг; разбор и исполнение — в loop. Один раз я исполнял команду прямо в serialEvent — с пересчётом шкалы и печатью в пять строк, — и получил двойной вывод и зависший проход: serialEvent с длинным телом — это второй loop, только с сюрпризной точкой входа. Флаг решает: поймал строку, записал, вернулся.
Когда serialEvent бесполезен: если loop честный — без delay, всё на millis, как учил урок про планировщик, — то он ровно эквивалентен первой строке loop. Разница не в скорости, а в словах. Я выбираю по признаку «сколько чужих людей будут читать скетч»: для себя — if в начале loop, для урока или коллеги, привыкших к примерам IDE, — serialEvent, помеченный комментарием о переносимости.
Теперь заглянем в буфер во время тяжёлой работы. Пока loop занят, RX-прерывание продолжает складывать байты; как только 64 байта набрались, буфер перестаёт принимать: свежий байт отбрасывается на входе, старые лежат где лежали. Пропадает всегда хвост потока — самое новое. Поэтому спать надолго безопасно только при редком вводе: команда в десять-двадцать символов доживёт, поток телеметрии на 9600 бод порежется уже с первой сотни миллисекунд.
Второе следствие: длинный сон рвёт строки посреди слова. Пришло шестьдесят байт, буфер полон, а хвост команды ещё в дороге — добор отбросится, и вы соберёте обрубок. Лечится проверкой на внятность: обрубок не проходит словарь и получает ERR с эхом вместо исполнения. Задержку приёма помогает увидеть счётчик проходов: строка PRIEM n= показывает, между какими проходами приехала команда, — разница с моментом отправки и есть цена занятого цикла.
Между итерациями в симуляторе
Движок симулятора serialEvent не вызывает сам — и мы не будем притворяться. В скетче ниже вызов вписан вручную последней строкой loop(), ровно в то место, где на реальной плате его ставит AVR-ядро: выполнили работу — спросили буфер — обработали. В журнале идут номера проходов loop n=: по ним видно, между какими проходами приехала команда, и что приём случился не мгновенно, а в конце текущей итерации. Разница между номером в loop n= и номером в PRIEM — это число проходов, которые loop прожил, пока ваша строка ждала.
Команда spat 3 укладывает loop в delay(3000) — изображает тяжёлую работу без планировщика. Отправьте svet 0 прямо во время сна: ответ придёт после пробуждения, потому что всё время сна serialEvent не звался, а строка ждала в буфере. Десять символов в 64 байтах доживают спокойно; поток телеметрии за три секунды сна не доживёт: 64 байта на 9600 бод полны за 64 мс, и всё, что сверх, отбрасывается на входе — старые байты лежат, свежие пропадают. Сон loop — это всегда торговля: сколько байт вы готовы потерять и чем это кончится для хвоста потока.
- serialEvent зовёт ядро после каждого прохода loop, когда в буфере есть байты; это обычная функция, а не прерывание
- delay() в loop глохит serialEvent: данные лежат в 64-байтовом буфере, который на 9600 бод полон за 64 мс тишины
- Внутри serialEvent те же запреты, что в loop: заняли её на секунду — встал весь скетч
- AVR-ядро вызывает serialEvent всегда; ядро ESP32 — никогда, и компилятор молчит: проверка одной печатью в первой строке
- Гигиена: в serialEvent только вычерпать буфер и поставить флаг; разбор и исполнение команд — в loop
- 1. Кто вызывает serialEvent()?
- 2. loop спит в delay(3000), приходит строка из десяти символов. Что случится?
- 3. Скетч с serialEvent переехал на ESP32 и приём молчит. Причина?
- 4. Что правильнее положить внутрь serialEvent?
- 5. Чем serialEvent лучше if (Serial.available()) первой строкой loop?
Частые вопросы по теме урока
- Кто вызывает serialEvent()?
- Прерывание лишь кладёт байт в кольцевой буфер. serialEvent — обычная функция: ядро спрашивает available() после loop и зовёт её, если есть что читать. Отсюда и все её ограничения по времени. main() ядра после каждого прохода loop, если в буфере есть данные
- loop спит в delay(3000), приходит строка из десяти символов. Что случится?
- Пока loop спит, serialEvent не зовётся, но RX-прерывание ядра кладёт байты в буфер. Десять символов в 64 байтах доживают спокойно; поток телеметрии — нет, первыми пропадают самые свежие байты. Байты лягут в 64-байтовый буфер, обработка — после пробуждения
- Скетч с serialEvent переехал на ESP32 и приём молчит. Причина?
- Ядро ESP32 не поддерживает serialEvent, и компилятор об этом не предупреждает. Проверка — печать в первой строке; лечение — приём в loop. ESP32-ядро serialEvent не вызывает: код собрался, но функция мёртвая
- Что правильнее положить внутрь serialEvent?
- serialEvent делит с loop общий бюджет времени: долгий разбор тормозит проходы основного цикла. Принцип один: вычерпал, накопил, поставил флаг, вернулся. Только вычерпать буфер и накопить строку; разбор — в loop по флагу
- Чем serialEvent лучше if (Serial.available()) первой строкой loop?
- Оба способа срабатывают между работой loop и дают одинаковые задержки. serialEvent лишь держит приём в отдельной функции — это читаемость, а не скорость; выбирая, помните и про переезд на другое ядро. Ничем по времени: разница в организации кода — приём живёт отдельной функцией