Датчик уровня в моём баке отчитывался строкой вида uroven=255 всякий раз, когда я дёргал кран, — и я гордился, что всё читается глазами. Пока не добавил вторую плату-приёмник и не увидел в графике зазоры: приёмник не успевал. Разбор показал унизительное: строка uroven=255 — это одиннадцать байтов на каждое показание, а само значение — один байт 0xFF. При 9600 бод, где байт стоит около миллисекунды, я платил за удобство глаз десятью миллисекундами тишины на каждое число — плюс парсинг: выделить цифры после знака равенства и превратить их в целое, как в уроке про strtok и atoi. Мой второй тупик был тихим: я переписывал разбор на strchr, ужимал буферы — выигрывал проценты; переход на байты дал ускорение разом в пять, и для потока данных я больше не возвращался к строкам.
Двоичный протокол платит за скорость читаемостью: в мониторе байты превращаются в кракозябры, зато пакет из пяти байтов прилетает за пять миллисекунд и разбирается индексом массива вместо строковых функций. План урока: честная таблица «текстовый против двоичного», конструкция пакета — маркер 0xAA 0x55, длина, escaping, контрольная сумма — и приёмник, который не глухнет, когда маркер потерялся. В итоговом протоколе гирлянды мы договаривались словами; здесь договоримся байтами.
Текст против байтов
Разница живёт в паре вызовов. Serial.print(255) гонит три символа: двойку, пятёрку и ещё одну пятёрку; Serial.write(255) кладёт в линию один байт 0xFF — ровно то значение, которое и было. Обратно Serial.read() возвращает байт как есть, без превращения в символ и обратно. Если показание укладывается в диапазон 0-255 — а датчики почти всегда отдают именно это, — текстовая обёртка становится чистым налогом: три-четыре байта на число, разделитель, перевод строки.
Когда значения шире байта, раскладку надо оговорить заранее. int на AVR уезжает двумя байтами, младшим вперёд — little-endian; передатчик шлёт байт & 0xFF и потом (байт >> 8) & 0xFF, приёмник собирает обратно: младший + (старший << 8). Это же правило живёт внутри структур из урока про передачу структур по UART, просто там компилятор делает раскладку за вас. В собственном пакете вы раскладываете вручную — зато пакет весит ровно столько, сколько несёт данных, и не зависит от выравнивания полей в памяти.
Побайтовый увод целиком — приём из мира структур: блок памяти уезжает как есть. Но сырые байты без разметки приёмнику не помогут: он не знает, где начинается показание, если подключился посреди потока или после сбоя. Отсюда три обязательных элемента двоичного пакета: маркер начала, длина и контрольная сумма. Без маркера приёмник после включения питания собирает мусор в правильные на вид пакеты — я так однажды получил уровень бака 187 из пустого.
| Критерий | Текст: uroven=255 | Двоичный: AA 55 01 FF xx |
|---|---|---|
| Байтов на показание | 11 плюс перевод строки | 5 |
| Разбор на плате | indexOf, substring, toInt — RAM под строки | индексы массива и арифметика |
| Читаемость в мониторе | всё видно глазами сразу | нужен HEX-дамп: FF выглядит как мусор |
| Правка вручную | печатается в терминале | без спецутилиты не сделаешь |
Маркер 0xAA 0x55, длина и escaping
Маркер начала я всегда делаю 0xAA 0x55, и это не эстетика. 0xAA — это 10101010, 0x55 — 01010101: пара с максимальным числом перепадов уровня. Если приёмник подключился к линии в середине маркера или один байт доехал битым, шанс, что обрывок другого поля сойдёт за маркер, минимален: случайные данные редко чередуются так аккуратно. Одиночный байт я не ставлю ещё и потому, что 0x00 и 0xFF в покоящейся линии встречаются слишком часто.
Дальше пакет выглядит так: два байта маркера, байт длины данных, сами данные и контрольная сумма — длина плюс сумма байтов данных, обрезанная до байта. Сумма наивная, но для коротких пакетов её хватает; более строгие варианты — с XOR и CRC — мы считали в уроке про контрольную сумму. Загвоздка одна: данным разрешено содержать 0xAA — и тогда приёмник рискует принять их за начало следующего пакета.
Лечится escaping, он же byte stuffing: встретив в данных 0xAA, передатчик отправляет вместо него пару 0xAA 0x00. Настоящий маркер — это всегда 0xAA, за которым следует 0x55, а escape — всегда 0xAA 0x00: приёмник различает их по второму байту. Цена — переменная длина пакета: каждое 0xAA в данных добавляет один байт. В поле длины я пишу логическое значение — сколько байтов было до stuffing, — приёмник распаковывает и сверяет сумму с распакованными. Байт 0x55 внутри данных при этом безвреден: маркер открывает только пара AA 55, а данные гарантированно начинаются лишь после байта длины.
Приёмник: автомат и таймаут
Приёмник — конечный автомат из трёх состояний: ищу первый 0xAA, жду 0x55, собираю пакет. В состоянии сборки каждая пара 0xAA 0x00 распаковывается в один байт данных, а одиночный 0xAA в потоке данных — нарушение формата: пакет бракуется, автомат возвращается к поиску маркера. Дошли до байта суммы — сверили с расчётом; сошлось — пакет отдан в работу, не сошлось — брошен целиком: частично чинить двоичный пакет бессмысленно, один битый байт портит весь смысл. Брак при этом не выбрасывает весь входной буфер: автомат возвращается к поиску маркера со следующего байта — если помеха зацепила один пакет из потока, соседние доезжают.
Грабля, на которой я оставил два вечера: маркер потерялся — провод дёрнули посреди пакета, помеха съела 0x55 — и приёмник застрял в состоянии «жду 0x55» навсегда. Новые пакеты упираются в замерший автомат и отбрасываются: приёмник глух, хотя линия жива. Лечение — таймаут: сборка не завершилась за 500 миллисекунд — автомат сбрасывается в поиск маркера. При потоке раз в секунду полсекунды достаются бесплатно; при плотном потоке таймаут ставят по длительности самого длинного пакета с запасом. Буфер приёма при этом не резиновый — 64 байта на Uno, как в уроке про буфер UART, — так что затяжную тишину переживаем без накопления мусора.
// Пакет: AA 55 | dlina | dannye (0xAA уезжает парой AA 00) | summa.// Проверка на одной плате — перемычка TX->RX; на двух платах приёмная// половина переезжает на вторую, порты крест-накрест, общий GND.unsigned long posledniy = 0;byte uroven = 100; void shliPaket(byte dannyh) { byte suma = (1 + dannyh) & 0xFF; // длина участвует в сумме Serial.write(0xAA); // маркер: 10101010 Serial.write(0x55); // 01010101 Serial.write(1); // логическая длина данных if (dannyh == 0xAA) { Serial.write(0xAA); // escaping: данные 0xAA Serial.write(0x00); } else { Serial.write(dannyh); } Serial.write(suma);} // --- приёмник ---byte priem[32];int sost = 0; // 0 ищу AA, 1 жду 55, 2 собираю, 3 жду хвост парыint dlin = 0;int vzjal = 0;int suma = 0;unsigned long startPaketa = 0; void sbros() { sost = 0; dlin = 0; vzjal = 0; suma = 0;} int priemBait(byte b) { // 1 — пакет собран, -1 — брак, 0 — жду ещё if (sost == 0) { if (b == 0xAA) { sost = 1; startPaketa = millis(); } } else if (sost == 1) { if (b == 0x55) { sost = 2; dlin = 0; vzjal = 0; suma = 0; } else if (b != 0xAA) sost = 0; // два AA подряд — всё ещё ждём 55 } else if (sost == 2) { if (dlin == 0) { dlin = b; if (dlin > 16) { Serial.println("BRAK: dlina"); sbros(); return -1; } suma = dlin; } else if (b == 0xAA) { sost = 3; // начало пары AA 00 } else if (vzjal < dlin) { priem[vzjal] = b; vzjal = vzjal + 1; suma = (suma + b) & 0xFF; } else { if (b == suma) { Serial.print("OK uroven="); Serial.println(priem[0]); sbros(); return 1; } Serial.println("BRAK: summa ne soshlas"); sbros(); return -1; } } else if (sost == 3) { if (b == 0x00 && vzjal < dlin) { priem[vzjal] = 0xAA; vzjal = vzjal + 1; suma = (suma + 0xAA) & 0xFF; sost = 2; } else { Serial.println("BRAK: escape"); sbros(); return -1; } } return 0;} void setup() { Serial.begin(9600);} void loop() { while (Serial.available() > 0) { priemBait(Serial.read()); } if (sost != 0 && millis() - startPaketa > 500) { Serial.println("TAIMAUT 500 ms: sbros, ishu marker"); sbros(); } if (millis() - posledniy > 1000) { posledniy = millis(); uroven = uroven + 7; // «датчик» меняется if (uroven > 250) uroven = 10; shliPaket(uroven); }}Движок симулятора не гоняет сырые байты между устройствами, поэтому передатчик и приёмник живут в одном скетче: «провод» — это массив, в который передатчик кладёт байты, а приёмник разбирает их по тем же правилам, что и настоящий UART-поток. Дамп пакета печатается в HEX парами — как его видел бы логический анализатор; правила перевода чисел повторяет урок про BIN, HEX и DEC.
Потенциометр A0 задаёт уровень датчика. Команда send отправляет пакет вручную: в журнале появится TX> с дампом AA 55 01 и далее, затем вердикт приёмника OK uroven= со значением. Команда bitoe портит один байт в линии — сумма не сойдётся, и пакет уйдёт в брак целиком. Команда aa прячет в данные сам байт 0xAA: в дампе вырастет пара AA 00, а приёмник распакует её обратно в 170. Команда tishina оставляет приёмник на середине пакета — через три секунды таймаут вернёт его к поиску маркера.
Перенос на две реальные платы отсюда короткий. В shliPaket вызов kladBait заменяется на Serial.write — по одному вызову на байт, порядок тот же; приёмник уже читает байты по одному, меняется только источник: вместо массива — Serial.available и Serial.read. Приёмник неблокирующий: он собирает байты по мере прихода и не мешает loop вести свои дела, поэтому на второй плате он встаёт рядом с датчиком без отдельного цикла ожидания. Передатчик — на плате с датчиком, приёмник — на плате с исполнительным механизмом, и протокол между ними после этого уже ни разу не менялся.
- Число 0-255 текстом — это 3-4 байта плюс парсинг; байтом — один байт и индекс массива: на 9600 бод разница — миллисекунды тишины на каждое показание
- Пакет: маркер 0xAA 0x55 с максимальным чередованием бит, байт длины, данные, сумма (длина + данные) & 0xFF
- Byte stuffing: данные 0xAA уезжают парой 0xAA 0x00 — маркер и данные перестают путаться, длина пишется логическая
- Приёмник — автомат из трёх состояний; бракованный пакет бросается целиком, частично чинить нельзя
- Таймаут 500 мс на незавершённую сборку обязателен: иначе потерянный маркер сделает приёмник глухим навсегда
- 1. Уровень 255 уезжает текстом uroven=255 и байтом. Насколько дороже текст?
- 2. Почему маркер — именно пара 0xAA 0x55?
- 3. В данных встретился байт 0xAA. Что отправит передатчик?
- 4. Маркер посреди пакета съела помеха, приёмник застрял в ожидании 0x55. Что его спасает?
- 5. Что кладёт в линию Serial.write(255) по сравнению с Serial.print(255)?
Частые вопросы по теме урока
- Уровень 255 уезжает текстом uroven=255 и байтом. Насколько дороже текст?
- Одиннадцать символов плюс перевод строки против одного байта 0xFF. На 9600 бод это разница между 12 мс и 1 мс тишины на показание. 11 байтов против 1 — дороже в одиннадцать раз
- Почему маркер — именно пара 0xAA 0x55?
- 0xAA — 10101010, 0x55 — 01010101. Случайные данные редко чередуются так аккуратно, поэтому ложные маркеры почти не встречаются. Максимальное чередование бит: обрывок потока редко сойдёт за маркер
- В данных встретился байт 0xAA. Что отправит передатчик?
- Это byte stuffing: данные 0xAA уезжают парой 0xAA 0x00, приёмник распаковывает пару обратно. Длина в заголовке — логическая, до распаковки. Пару 0xAA 0x00
- Маркер посреди пакета съела помеха, приёмник застрял в ожидании 0x55. Что его спасает?
- Без таймаута приёмник глухнет навсегда: новые пакеты упираются в замерший автомат. Полсекунды тишины — сигнал начать сборку заново. Таймаут: через 500 мс автомат сбрасывается в поиск маркера
- Что кладёт в линию Serial.write(255) по сравнению с Serial.print(255)?
- write отправляет байт как есть — ровно значение; print превращает число в текстовые символы. Для протокола между платами нужен write. Один байт 0xFF против трёх символов у print