Эта история про то, как два устройства говорили на одном протоколе, отправляли друг другу одни и те же данные, и всё равно не могли друг друга понять из-за четырёх байтов, записанных в другом порядке.
Контекст
Я работал над системой оповещения, которая в случае ЧС включает сирены, отправляет голосовые сообщения и текстовые бегущие строки на оконечные устройства. Всё по ГОСТу 42.3.05-2023, с чёткими форматами пакетов, квитанциями и таймингами.
Архитектура: пульт оператора отправляет команды по TCP, а звук по UDP. На другом конце сервер принимает команды, запускает оповещение и ретранслирует звук на устройства. Между ними бинарный протокол с 8-байтными пакетами и аудио-датаграммами по 84 байта.
У меня была задача написать заглушку (эмулятор пульта), покрыть всё интеграционными тестами и добиться сквозной работы с реальным пультом от другого производителя.
Заглушка работает, реальный пульт нет
Первое что я сделал это написал заглушку на Node.js. Интерактивное меню, все команды по ГОСТу, UDP-поток с аудио, подключился к тестовому серверу, прогнал полный сценарий, квитанции приходят, звук записывается в файл, файл воспроизводится.
Потом подключили реальный пульт. TCP-команды ок. Квитанции ок. Запуск оповещения ок. А вот звука нет, файл вроде как создаётся, но внутри какой-то монотонный фон и щелчок в конце. Хотя поставщики пультов говорят что отправляли тестовое голосовое оповещение…
Первые подозрения
Сначала я подумал, что проблема в сети. Пульт стоял за NATом, подключался через внешний IP. TCP проходил, а UDP мог и не доходить. Подключился к серваку проверил tcpdump , он показал, что UDP-пакеты приходят, 84 байта, правильный порт.
Сервер принимал UDP-пакеты и логировал счётчик каждого. По ГОСТу формат датаграммы такой:
| |
Счётчик должен начинаться с нуля и увеличиваться на единицу с каждым пакетом: 0, 1, 2, 3… Сервер использует его для обнаружения потерь и восстановления порядка.
Я посмотрел логи и увидел:
| |
Вместо 0, 1, 2, 3 сервер видел 838860801, 855638016, 872415232. И разрыв между пакетами стабильные 16 777 215 вместо единицы.
16 777 215
16 777 215 в шестнадцатеричной записи это 0x00FFFFFF. Ровно три байта, заполненных единицами, уже звоночек
Я перевёл первый счётчик в hex:
| |
Разложил по байтам: [0x32, 0x00, 0x00, 0x01].
А потом перевернул: [0x01, 0x00, 0x00, 0x32] и получил 0x01000032 = число 50 в десятичной. Следующий пакет 51, потом 52, 53… Счётчик оказывается шёл нормально, но в другом порядке байтов.
Big Endian vs Little Endian
Одно и то же 32-битное число можно записать двумя способами:
Big Endian старший байт первый. Число 1 записывается как [0x00, 0x00, 0x00, 0x01].
Little Endian младший байт первый. Число 1 записывается как [0x01, 0x00, 0x00, 0x00]. Как Интел процессоры хранят данные в памяти.
Наш сервер читал счётчик как Big Endian. Пульт отправлял как Little Endian, и вместо нормального счётчика 50, 51, 52 сервер видел 838860801, 855638016, 872415232.
А при чём тут звук?
Сам по себе перевёрнутый счётчик не катастрофа, он логировал ошибки, но пакеты всё равно принимал, звуковые данные (байты 4–83) лежали на правильных позициях.
С мусорными значениями счётчика он решал, что пакеты приходят в неправильном порядке, пытался их переставить, и в результате аудио-поток рассыпался. На выходе данные есть, но перемешаны так, что декодер A-Law выдаёт постоянный уровень сигнала вместо речи, и получаем тишину с щелчком.
Кто прав по ГОСТу?
Я полез в стандарт искать, какой порядок байтов правильный. В пункте 5.9.3.2 который описывает формат датаграммы, порядок байтов не указывается явно. Просто указано «32-битный инкрементальный счётчик».
Зато в Приложении А, раздел А.1 нашлась фраза: «Передача начинается с младшего байта». Это Little Endian. Формально пульт был прав.
Но сервер был написан под Big Endian, и все наши тесты (заглушка, интеграционные тесты) тоже использовали Big Endian. Выкатили фикс, поменяли на Little и всё заработало.
Что я вынес
В очередной раз убедился, что в тупиковых ситуациях нужно десять раз перечитать логи, даже если они не показывают явных ошибок. Именно строка с gap равным 16777215 стала поворотной точкой всего расследования, и если бы сервер логировал только факт потери пакета без конкретного числа, я бы ковырялся в hex-дампах ещё очень долго.
Ещё раз подтвердилось, что собственная заглушка в таких проектах незаменима. Когда у тебя есть эталонный клиент, который гарантированно работает, любая проблема с реальным устройством сразу локализуется, достаточно сравнить логи двух подключений и посмотреть, где расходятся данные. В моём случае разница в порядке байтов стала очевидной буквально за секунду, когда я поставил рядом два лога и увидел, что счётчики отличаются на порядки.