Гирлянда на окне управляется у меня по проводу длиной в полкомнаты, и у этого провода есть сосед: блок питания ленты, который гудит и греется. Год всё было тихо, а потом однажды в два часа ночи гирлянда взмыла в полную яркость сама. В журнале ведущей платы нашлась строчка: отправлено pwm 64, а ведомый честно отчитался OK pwm: pwm=192. Команда приехала битой: в дороге перевернулся один бит, 0b01000000 стал 0b11000000, и из «тускло» получилась «полная».
Дьявол в том, что 192 — абсолютно законное значение: парсер из урока про командный парсер обязан принять его как свою команду, и проверка диапазона проходит молча. Валидность не защищает от искажения: канал может испортить байт так, что получится другая правильная команда. Журнал с эхом, который мы приучили печатать в уроке про уровни логов, помог найти виновника — но не помешал аварии: разбор происходит после доставки, когда бит уже перевёрнут.
Против искажений у каналов связи есть оружие старше Arduino на полвека: контрольная сумма. Отправитель считает её по байтам посылки и едет рядом; приёмник пересчитывает и сверяет — цифры разошлись, значит, посылка битая, в корзину. В этом уроке строим фрейм целиком: заголовок для синхронизации, длина нагрузки, сама нагрузка и контрольная сумма; сравним три вида сумм и устроим шумную линию в симуляторе.
Анатомия фрейма: заголовок, длина, груз
Фрейм начинается с заголовка — байта-метки, по которому приёмник узнаёт начало посылки: 0xAA или другой заранее уговоренный код. Зачем: приёмник включается когда угодно — в середине чужого пакета, после помехи, после подачи питания, — и без метки он не отличит начало посылки от её середины. Услышал 0xAA — всё, что дальше, считается посылкой. За заголовком идёт байт длины: сколько байтов нагрузки читать.
Длина нужна не для красоты: приёмник должен знать, где посылка кончается, не надеясь на паузы. Пауза — ненадёжный терминатор: канал сжимает и растягивает промежутки, разбивает посылку на куски, которые приедут с разным опозданием, а буфер на 64 байта из урока про буфер UART ждать не любит. Дальше — полезная нагрузка: команда или телеметрия из прошлого урока про структуры, то самое, ради чего всё затевалось.
Замыкает фрейм контрольная сумма — результат подсчёта по всем предыдущим байтам. Порядок строг: сумма считается по заголовку, длине и нагрузке, но не по самой себе — иначе уравнение замыкается на себя. Приёмник прогоняет ту же арифметику над теми же байтами и сравнивает: совпало — посылка исполняется, не совпало — ERR в журнал с эхом, что именно пришло и что ожидалось, как мы печатали диагнозы в уроке про отладку через Serial. Проверка целостности идёт до разбора команды: сначала «доезжает ли», потом «что делать».
Три суммы: простая, XOR и CRC8
Видов сумм много, но паре Arduino хватает трёх. Простая сумма байтов, свёрнутая в один байт остатком от деления на 256, — это три строки кода с циклом for. Ловит одиночные перевёрнутые биты и почти любой одиночный сбой. Слепа к перестановке байтов — сумма не смотрит на порядок — и к редким компенсациям, когда в одном месте байт отяжелел на два, а в другом полегчал на столько же. Для команд по проводу в квартире, по-моему, оптимум.
XOR — «сумма без переносов»: байты складываются побитово, исключающее ИЛИ, одна строка кода. Быстрее некуда, зато слепа к двойной беде: два перевёрнутых бита в одной позиции уравновешивают друг друга. Хороший второй фильтр в паре с суммой: обе стоят три строки, а ловят разное, — и одна посылка может ехать с обеими сразу.
CRC8 — младший брат больших CRC: арифметика с полиномом, по сути деление байтового потока по модулю. Ловит практически всё одиночное и парное, стоит либо таблицей на 256 байтов, либо циклом на восемь сдвигов. Моя граница применимости: длинные линии возле моторов и реле и телеметрия, которую жалко терять. Для пульта-гирлянды сумма с XOR перекрывает беду с запасом — а это две строчки, которые невозможно поломать.
// === Передатчик: шлёт команду pwm с параметром ===void shliKomandu(byte cmd, byte param) { byte paket[5]; paket[0] = 0xAA; // заголовок: начало посылки paket[1] = 2; // длина нагрузки: cmd и param paket[2] = cmd; paket[3] = param; byte sum = 0; for (int i = 0; i < 4; i++) { // по заголовку, длине и грузу sum += paket[i]; // сам байт суммы в подсчёте не участвует } paket[4] = sum; Serial.write(paket, 5);} // === Приёмник: собирает посылку и проверяет ===byte buf[64];int polucheno = 0; void setup() { Serial.begin(9600); pinMode(9, OUTPUT);} void loop() { while (Serial.available() > 0) { byte b = Serial.read(); if (polucheno == 0 && b != 0xAA) continue; // мусор вне посылки buf[polucheno++] = b; if (polucheno >= 2 && polucheno >= 2 + buf[1] + 1) { byte sum = 0; for (int i = 0; i < polucheno - 1; i++) { sum += buf[i]; } if (sum == buf[polucheno - 1]) { // доехало целым: buf[2] — команда, buf[3] — параметр if (buf[2] == 1) { analogWrite(9, buf[3]); Serial.print("OK cmd=1 pwm="); Serial.println(buf[3]); } } else { Serial.println("ERR sum: posylka bitaya, v korzinu"); } polucheno = 0; // посылка кончилась — ждём новый 0xAA } }}| Проверка | Цена на Uno | Ловит | Пропускает |
|---|---|---|---|
| Простая сумма (mod 256) | 3 строки: цикл for | одиночные перевёрнутые биты, почти любой один битый байт | перестановку байтов, парные компенсации |
| XOR | 1 строка | нечётное число инверсий | два перевёрнутых бита в одной позиции |
| Сумма + XOR парой | 4 строки | разное у каждой: пересечение дырок почти пустое | редкие согласованные сбои |
| CRC8 | таблица на 256 байтов или цикл на 8 сдвигов | практически все одиночные и парные искажения | в рамках одной посылки — практически ничего |
Шумная линия в симуляторе
Снова честная оговорка: движок не передаёт байты между платами и не шумит, поэтому линия моделируется строками. Полезная нагрузка — строка вида pwm 64; контрольная сумма — накопление кодов символов циклом по s[i]: индексация строки возвращает символ, его код едет в копилку, свёрнутую остатком от деления на 256, — та же арифметика, что у настоящей суммы байтов. Рядом живёт XOR-вариант, чтобы сравнить оба числа на живых посылках.
Шум в модели учебный и предсказуемый: с включённым шумом каждая вторая посылка теряет последний символ — на его место встаёт решётка, код 35. В настоящем UART портится случайный байт случайным образом; в модели важно другое: посылка едет битой, и её надо поймать. Приёмник делает ровно то, что расписано в анатомии: пересчитал сумму по полученному, сверил с присланной — разошлось, ERR, груз не исполняется.
Управление в мониторе: shum 1 включает шум, kontrol 1 включает проверку суммы на приёмнике, kontrol 0 выключает. Посылка ходит раз в две секунды сама: pwm по кругу 64, 96, 128, 160, и после успешного исполнения на плате видна яркость — лампа на пине 9. Сначала попробуйте шум без проверки: битые посылки исполнятся как есть, и яркость спрыгнет невпопад. Потом включите контроль: битые уйдут в ERR, лампа успокоится. Числа идут парами имя=значение — ряд yarkost= в плоттере покажет правду.
- Запустите с shum 0 и kontrol 0: посылки ходят парами VED> и OK pwm, ряд yarkost= в плоттере — ровная лесенка 64-96-128-160.
- Включите shum 1 и оставьте kontrol 0: каждая вторая посылка получает строку SHUM> с решёткой, приёмник исполняет её как есть — pwm 96 превращается в pwm 9, яркость проваливается. Это авария из начала урока в миниатюре.
- Отправьте kontrol 1: те же битые посылки упрутся в ERR sum с парой zhdem= и poluchili=, лампа больше не дёргается. Сравните плоттер до и после — разница и есть цена проверки.
- Посчитайте руками sum8 для pwm 64: коды p, w, m, пробел, 6, 4 — это 112+119+109+32+54+52=478, сворачиваем в байт: 478-256=222. Сверьте с sum8= в журнале.
- Поменяйте учебный сбой: пусть портится не последний символ, а средний — s.substring(0, 1) плюс решётка плюс s.substring(2). Убедитесь, что сумма ловит и это: она смотрит на все байты посылки.
- Валидность команды не спасает: битый байт превращает pwm 64 в pwm 192, и обе команды законны — проверка целостности идёт до разбора
- Фрейм: заголовок 0xAA для синхронизации, байт длины, груз, контрольная сумма; сумма считается по всем байтам, кроме самой себя
- Простая сумма mod 256 ловит одиночные бит-флипы, но не перестановку байтов; XOR ловит нечётные инверсии; CRC8 — почти всё, ценой таблицы или цикла
- Не сошлась сумма — посылка целиком в корзину, в журнал идёт ERR с парой zhdem= и poluchili=, груз не исполняется
- Контрольный байт едет по тому же шумному проводу: сомневаешься — отбрасывай и жди повторную посылку
- 1. Почему pwm 192, приехавшая вместо pwm 64, не была поймана парсером?
- 2. Что простая сумма байтов ловит хуже всего?
- 3. Где в фрейме стоит контрольная сумма и что она покрывает?
- 4. Приёмник получил посылку с неверной суммой. Правильное поведение?
- 5. Почему XOR-сумма пропускает два перевёрнутых бита в одной позиции?
Частые вопросы по теме урока
- Почему pwm 192, приехавшая вместо pwm 64, не была поймана парсером?
- Проверка диапазона видит только законность значения, а не его путь по проводу. Один перевёрнутый бит дал другую правильную команду — против этого есть только контрольная сумма до разбора. 192 — законное значение из диапазона 0–255: валидность не ловит искажения, нужна проверка целостности
- Что простая сумма байтов ловит хуже всего?
- Сложение не следит за позициями: переставленные байты дают ту же сумму, а редкие компенсации плюс-минус тоже проходят. Порядок видит CRC или пара сумма+XOR. Перестановку байтов: сумма не смотрит на порядок, и посылка в другом порядке даёт ту же сумму
- Где в фрейме стоит контрольная сумма и что она покрывает?
- Сумма замыкает фрейм и считается по всем предыдущим байтам. Включи она себя — уравнение замкнулось бы само на себя. В конце; считается по заголовку, длине и грузу — но не по самой себе
- Приёмник получил посылку с неверной суммой. Правильное поведение?
- Сомнительная посылка не исполняется никогда: неизвестно, что именно испортилось. ERR с эхом zhdem= и poluchili= — и ведущий повторит. Отбросить посылку целиком, напечатать ERR и ждать повторную
- Почему XOR-сумма пропускает два перевёрнутых бита в одной позиции?
- Исключающее ИЛИ без переносов: две инверсии одного бита гасят друг друга. Поэтому XOR гоняют в паре с суммой — у них слепые зоны не совпадают. Две инверсии в одной позиции компенсируют друг друга: бит возвращается в исходное состояние