Гаражный сторож я собирал по наработанной схеме: Bluetooth-модуль HC-05, телефон вместо монитора порта, команды on и off для лампы. Параллельно плата раз в секунду сама рапортовала в тот же порт: температура, напряжение аккумулятора. Несколько вечеров всё работало, а потом начались странности, которые я не мог объяснить: отправляешь «on» с телефона — лампа дёрнется и погаснет; отправляешь «status» — плата молчит или отвечает обрубком вроде «sta». Каждая вторая команда доходила как огрызок: «on» превращалось в «o», и скетч делал не то, что я просил. Три вечера я подозревал китайский Bluetooth-модуль, паяные хвосты и телефонную клавиатуру — пока не понял, что виноват мой же delay().
Причина обрезков — приёмный буфер UART: очередь на 64 байта, о существовании которой легко забыть, пока она не переполнится. В этом уроке разберём, куда физически падают принятые байты, сколько места в очереди и за какое время оно кончается при 9600 бод; почему буфер при переполнении молчит и теряет именно свежие байты; как посчитать, какие паузы в скетче безопасны, а какие — дыра; и три приёма надёжного приёма: читать чаще малыми порциями, копить строку своей копилкой и — когда всё остальное не помогло — расширять сам буфер, помня цену каждого байта RAM.
Ящик на 64 байта: где живут принятые байты
Аппаратный UART на плате — это приёмник, который ловит байты из провода независимо от скетча: вы спите в delay(), мигаете светодиодом, считаете показания — а железо всё равно принимает каждый байт и складывает его в очередь. Очередь устроена честно: кто пришёл первым, тот первым и выйдет; Serial.read() вынимает из головы, приёмник дописывает в хвост. Эта очередь и называется приёмным буфером. На Uno и Nano в ней 64 места — константа SERIAL_RX_BUFFER_SIZE в файлах ядра; у других плат число своё, у Mega основной порт побольше, так что все цифры в уроке — про Uno, с оговоркой «зависит от платы и версии ядра».
Теперь скорость. При 9600 бод линия передаёт примерно 960 байт в секунду: на каждый байт уходит старт-бит, восемь бит данных и стоп-бит — десять бит на байт, 9600 делим на 10, получаем 960. Значит, один байт занимает примерно миллисекунду, а 64-местный буфер при непрерывном потоке заполняется за 64 миллисекунды — скажу честно до конца: расчёт даёт около 67 мс, если не терять десятые доли, поэтому в моих конспектах записано «около 67 мс потока». На 115200 бод поток в двенадцать раз быстрее, и тот же ящик полон уже через пять-шесть миллисекунд. Все эти числа — про непрерывную передачу; рваный трафик с клавиатуры растянет буфер на минуты.
Из арифметики следует главный вывод урока: буфер — амортизатор на короткие рывки, а не склад. Пока скетч вычитывает байты хотя бы так же часто, как они приходят, ничего не теряется — даже если разбор строки отложен до конца такта режима, как было в уроке про меню: команда спокойно ждала в буфере своей очереди. Проблема начинается там, где приём идёт быстрее чтения: пишем больше, чем вынимаем, — и с каждым лишним байтом очередь подбирается к потолку.
Переполнение: буфер не выталкивает старое — он отказывает новому
Что происходит, когда все 64 места заняты, а по проводу идёт шестьдесят пятый байт? Интуиция подсказывает: старое выдавилось, новое легло поверх. В аппаратном UART Arduino всё ровно наоборот: очередь стоит на месте, и байт, которому не нашлось места, выбрасывается. Страдают самые свежие данные, а старый хвост лежит себе дальше и читается как ни в чём не бывало. И главное — всё это молча: ни ошибки, ни флага, ни исключения, available() не пожалуется. Данные исчезают бесшумно, и снаружи это выглядит как «связь иногда барахлит», а не как «буфер кончился» — потому переполнение и ходит по проектам незамеченным.
Теперь моя история с телефоном разборчива по буквам. Скетч спал в delay(3000), а показания шли каждую секунду строкой в тридцать с небольшим байт: за три секунды сна натекало около девяноста байт. Первые 64 легли в очередь, последние два с половиной десятка выбросились — и в тот раз среди выброшенных оказался хвост только что набранной команды. «on\n» — три байта: «o» влезла в последние свободные места, «n» и перевод строки отвалились. Скетч проснулся, вычерпал старьё, прочёл «o» — совпадения с «on» не случилось, сработала ветка «не понял», которая у меня гасила свет как безопасное состояние. Лампа «дёргалась и гасла» ровно по коду: он делал ровно то, что написано, с теми байтами, что доехали.
const int LED_PIN = 13; void setup() { Serial.begin(9600); pinMode(LED_PIN, OUTPUT); Serial.println("Migayu i splyu: otchet raz v 2 sekundy");} void loop() { digitalWrite(LED_PIN, HIGH); delay(1000); digitalWrite(LED_PIN, LOW); delay(1000); int lezhit = Serial.available(); // сколько байт скопилось за сон if (lezhit > 0) { Serial.print("Prosnyalis: v bufere "); Serial.print(lezhit); Serial.println(" bajt"); while (Serial.available() > 0) { // вычерпываем всё до дна Serial.read(); } }}Прогоните скетч в симуляторе и отправьте строку прямо во время паузы — по пробуждении available() докладывает точное число. Оговорка, ради которой я готов повториться: в симуляторе приёмный буфер бездонный, поэтому вы увидите ровно столько, сколько послали, хоть триста байт подряд. На настоящей Uno при 9600 бод за две секунды паузы придёт до 1900 байт, но уже первые 64 заполнят очередь, а всё, что пришло после 67-й миллисекунды сна, выброшено на пол. Симулятор показывает механику накопления честно, а потерю добавило бы железо — потому масштаб бедствия здесь считают на бумажке, и этот скетч — ваш карманный счётчик для таких прикидок.
Отсюда и репутация delay() как главного виновника потерь — заслуженная, но по делу: плох не сам delay(), а любая пауза длиннее времени заполнения буфера. При 9600 бод и живом потоке это около 67 мс: мигалки с delay(1000) при молчащем порте безвредны, а при потоке данных с датчика или телефона становятся дырой размером «секунда минус 67 миллисекунд». На 115200 порог падает до нескольких миллисекунд, и там почти ничего не спасает, кроме чтения в каждом проходе. Лечение в духе урока про millis() вместо delay: не спать скопом, а работать по таймеру — и вычерпывать буфер малыми порциями, о чём ниже. А пока заметка на полях: занятой скетч, который хотя бы считает available(), видит масштаб бедствия — бездонный ящик в симуляторе для того и оставлен.
Что делать: читать чаще, копить строку, расширять ящик
Приём первый — читать чаще, малыми порциями. Правило простое: в каждом проходе loop() забрать всё, что накопилось, — while (Serial.available() > 0) Serial.read(); — и не оставлять в ящике ни байта. Разбор можно отложить, а вот вычерпывание — никогда: буфер, который опустошается каждые несколько миллисекунд, физически не успевает переполниться, даже если сами данные вы будете обдумывать потом. В моём стороже лечение свелось к двум строчкам в начале loop() — и «on» перестало превращаться в «o» ещё до того, как я переписал хоть что-то ещё.
Приём второй — копить в свою строку. Байты по одному складываются в String-копилку, команда считается готовой при терминаторе — весь механизм с фильтром '\r' и разбором я уже собирал в уроке про Serial.available и Serial.read, там же диммер с toInt(). Копилка ценна именно в связке с первым приёмом: каждый проход loop() вынимает по байту-два, очередь не растёт, а разбор случается один раз — когда строка собрана. Это и есть надёжный приём в чистом виде: чтение размазано тонким слоем по всему времени работы скетча, вместо того чтобы вычерпывать ковшом раз в секунду.
Приём третий — расширить сам ящик: поднять SERIAL_RX_BUFFER_SIZE с 64 до 128 или 256 байт. Сразу оговорки, их немало. Константа живёт в файлах ядра, в HardwareSerial.h, и просто #define в начале скетча обычно не срабатывает: Arduino.h успевает подключиться раньше и уже выбрал значение по умолчанию. Рабочие пути — правка ядра или флаг компиляции вида -DSERIAL_RX_BUFFER_SIZE=128 через platform.local.txt в IDE 2; зависит от версии ядра, проверяйте на своей сборке. Приём нестандартный, и прежде чем к нему идти, взвесьте цену: у Uno 2048 байта RAM, и каждый байт буфера — байт из общего котла, из которого пьют и дисплей, и FastLED, и ваши переменные. Порядок лечения всегда один: сначала читать чаще, потом копить аккуратнее, и только потом расширять.
К слову, у порта есть и передающий буфер — тот же механизм в обратную сторону, для байтов, которые плата шлёт наружу. Метод Serial.availableForWrite() возвращает, сколько свободных мест осталось в ящике исходящих; пригодится, когда скетч вываливает большие порции и не должен застревать в flush(). У приёма проблема серьёзнее — там переполнение теряет данные, а у передачи лишь подтормаживает отправитель, поэтому ограничусь упоминанием и вернусь к приёму.
| Ситуация | Симптом в мониторе | Лечение |
|---|---|---|
| Сон delay() дольше ~67 мс при активном потоке | Команды доходят обрубками, хвосты строк пропали | Millis-таймер вместо delay, вычерпывание в каждом проходе loop() |
| Поток данных быстрее чтения | В логе старые строки, свежих не видно | Малые порции: while с Serial.read() на каждый проход |
| Скорость в мониторе не совпала с Serial.begin() | Каша из символов вместо команд | Сверить бод с обеих сторон |
| Строки длиной под 64 байта | Хвост длинной строки стабильно отсутствует | Дробить строки; расширять буфер — ценой RAM |
| SoftwareSerial на 9600 и выше рядом с Servo | Байты портятся под нагрузкой | Аппаратный UART или скорость ниже |
- Приёмный буфер UART на Uno — 64 байта; при 9600 бод непрерывный поток заполняет его примерно за 67 мс (один байт — примерно миллисекунда)
- При переполнении выбрасываются самые свежие байты, а не старые: очередь стоит, отказывает новеньким — и всё это молча, без флагов
- Любая пауза длиннее времени заполнения буфера — дыра в приёме; delay() лишь самый частый способ устроить такую паузу
- Порядок лечения: вычерпывать буфер в каждом проходе loop(), копить строку до терминатора, и лишь затем расширять SERIAL_RX_BUFFER_SIZE — ценой RAM, которой на Uno 2048 байта
- Serial.availableForWrite() — зеркальная метрика передающего буфера: сколько свободных мест у исходящих
- 1. Скетч спит в delay(3000), а датчик шлёт данные по 9600 бод без остановки. Что будет с байтами?
- 2. Куда при переполнении деваются байты?
- 3. Какой приём лечит переполнение прежде всего?
- 4. Что возвращает Serial.availableForWrite()?
Частые вопросы по теме урока
- Скетч спит в delay(3000), а датчик шлёт данные по 9600 бод без остановки. Что будет с байтами?
- 64 места при примерно 960 байт в секунду заполняются за 67 мс. Дальше очередь стоит, новенькие отбрасываются — и всё молча. Буфер заполнится примерно за 67 мс, дальше свежие байты будут выбрасываться
- Куда при переполнении деваются байты?
- Очередь не выталкивает старое: место кончилось — новый байт молча отбрасывается. Симптом — обрезанный хвост свежей команды при старом хвосте в голове очереди. Выбрасываются самые свежие — очередь не двигается
- Какой приём лечит переполнение прежде всего?
- Пока скетч вычитывает чаще, чем приходят байты, буфер не заполняется. Расширение буфера — крайняя мера: каждый его байт съедает байт из 2048 байт RAM Uno. Читать буфер малыми порциями в каждом проходе loop()
- Что возвращает Serial.availableForWrite()?
- Это метрика передающего буфера, зеркальный аналог available(). Полезна, когда скетч шлёт большие порции и не должен застревать в flush(). Сколько свободных мест осталось в передающем буфере