185. Dart для инженера прошивки: байты, числа, разбор кадра
Прибор почти всегда обрастает приложением: посмотреть показания, настроить, обновить прошивку. Часть XXIII — про эту вторую половину работы. Начинаем не с кнопок, а с того, что инженеру прошивки в новом языке нужно в первую очередь: байты и числа.
Пример сквозной: тот самый кадр 0x55 — длина — данные — CRC-8, который
разбирала прошивка в разделе 184. Теперь его разбирает приложение, и
обе стороны обязаны считать одинаково — это проверено, а не обещано.
Как это называется в документации
Оригинал Перевод Uint8List, ByteData массив байт, доступ к байтам с указанием порядка endianness, Endian.little порядок байт, младший байт первым sealed class закрытая иерархия: все наследники известны pattern matching, switch expression сопоставление по образцу, switch-выражение record запись: кортеж с именованными полями null safety, late, ?. ?? защита от null package, pubspec пакет и его описание unit test, dart testмодульные тесты
185.1 Числа: где Dart не похож на C
Родной int в Dart — знаковое 64-разрядное целое, и переполняется оно
по кругу, как на микроконтроллере:
| Что считаем | Значение |
|---|---|
0x7FFFFFFFFFFFFFFF |
9223372036854775807 |
| то же плюс 1 | −9223372036854775808 |
Но у Dart есть вторая цель — браузер, и при сборке в JavaScript int —
это double: целые точны только до 2⁵³ (при сборке в WebAssembly int
снова 64-разрядный, но протокол рассчитывают на худший случай). Отметка времени прибора в наносекундах это
ломает:
| Что считаем | Значение |
|---|---|
| 2⁵³ + 1 == 2⁵³ (в double) | true |
| отметка времени, нс | 1758013245123456789 |
| она же после double | 1758013245123456768 |
| потеряно | 21 нс |
Насколько грубо округляется — видно по шагу между соседними значениями double: на миллисекундах от 1970 года шаг 0,00024 — целые точны; на микросекундах шаг 0,25; на наносекундах уже 256, то есть отметка времени прыгает через четверть микросекунды.
Вывод для протокола: не гнать в приложение 64-разрядные счётчики и
идентификаторы как числа. Миллисекунды — можно (2⁵³ мс это 285 тысяч
лет), наносекунды и серийные номера — только строкой или парой
32-разрядных слов; для точной арифметики есть BigInt.
Беззнаковых типов в Dart нет вовсе, маску ставим руками:
| Выражение | Значение |
|---|---|
-1 & 0xFF |
255 |
(0x80 << 1) & 0xFF |
0 |
-1 >> 56 |
−1 |
-1 >>> 56 |
255 |
>> тянет знак, >>> — не тянет. Это ровно та разница, которая в C
задаётся типом операнда, а здесь — выбором оператора.
Сдвиги из таблицы верны только в машинном коде. На вебе, при сборке
в JavaScript, побитовые операции идут над 32 битами, и результат у них
беззнаковый: та же программа там печатает -1 >> 56 = 4294967295,
-1 >>> 56 = 0, ~0 и -1 | 0 = 4294967295, 1 << 32 = 0, а старший
байт 40-разрядного (0x123456789A >> 32) & 0xFF — 0 вместо 18. Маски
из двух верхних строк дают то же, что в машинном коде. Поля длиннее
32 бит на вебе собирают из двух половин или считают в BigInt.
185.2 Байты и порядок байт
Массив байт — Uint8List, чтение полей — ByteData. Порядок байт
указывают всегда: без второго аргумента ByteData читает старшим
байтом вперёд (Endian.big), наоборот по сравнению с ARM, и никакой
ошибки не будет:
final ByteData view = ByteData.sublistView(frame);
final int ms = view.getUint32(0, Endian.little);
final double volts = view.getInt16(4, Endian.little) / 1000.0;
Те же четыре байта E8 03 00 00 дают:
| Порядок | Значение |
|---|---|
Endian.little |
1000 |
Endian.big |
3892510720 |
Отсюда правило: порядок байт — часть описания протокола, а не мелочь реализации. В прошивке ARM он little-endian по умолчанию, в приложении его пишут в коде, и на этом стыке ошибаются чаще всего.
185.3 Разбор кадра: sealed-классы вместо кодов возврата
В C разборщик возвращал 1 / 0 / −1 (раздел 184.2). В Dart на это есть
закрытая иерархия: компилятор сам следит, что разобраны все случаи.
sealed class FrameEvent {}
class FrameNone extends FrameEvent {}
class FrameReady extends FrameEvent { FrameReady(this.data); final Uint8List data; }
class FrameError extends FrameEvent { FrameError(this.reason); final String reason; }
Разбор события — switch-выражение с образцами:
final String what = switch (event) {
FrameReady(data: final d) => 'кадр ${d.length} байт',
FrameError(reason: final r) => 'ошибка: $r',
FrameNone() => 'ждём',
};
Забыть случай нельзя: sealed заставляет перечислить всех наследников,
иначе программа не соберётся. Для протокола, где «ошибка» — обычное
состояние, это заметно надёжнее, чем if (r == -1).
Само измерение удобно вернуть записью — кортежем с именами:
typedef Measurement = ({int ms, double volts, double amps});
Не нужен ни класс, ни конструктор: поля по имени, сравнение по значению.
185.4 Один алгоритм на двух языках — и доказательство
CRC-8 в приложении и в прошивке обязаны совпадать. Проверяется это не
глазами: одна и та же программа считает 256 наборов байт на C (из
_verify/test184/frame.c) и на Dart, и результаты сверяются целиком.
| Что сверяли | Значение |
|---|---|
| наборов байт | 256 |
| совпало значений CRC | 256 |
| расхождений | 0 |
Приём общий: любой алгоритм, который есть по обе стороны провода, проверяется перекрёстным прогоном, а не чтением кода. Для CRC, для перевода кодов АЦП в вольты, для формата времени.
185.5 Тесты: то же, что в 184, только короче
dart test работает с пакетом test, который добавляют в описание пакета
одной командой dart pub add dev:test; отдельная сборка не нужна:
test('CRC-8 совпадает с прошивкой', () {
expect(crc8(<int>[0x31, 0x32, 0x33]), 0xC0);
expect(crc8(<int>[]), 0x00);
});
Число 0xC0 — то самое, независимое, из раздела 184.5: оно ловит
испорченный полином, а расчёт «тем же кодом» — нет.
Набор из шести проверок покрывает всё, что мы тут сделали: CRC, целый кадр, испорченную CRC, длину вне предела, порядок байт и сопоставление по образцу. Прогон занимает доли секунды и идёт на любой машине без платы.
185.6 Грабли
64-разрядный идентификатор пришёл числом. На вебе он молча округлится: наносекундная отметка теряет 21 нс уже на первом приведении. Строка или два 32-разрядных слова.
Порядок байт «как получится». Без аргумента ByteData берёт
Endian.big: те же четыре байта дают 3 892 510 720 вместо 1000.
Знак вместо нуля при сдвиге. -1 >> 56 это −1, а не 255. Для байт
из провода нужен >>> или маска.
int в Dart — не uint8_t. После << маску ставим сами, иначе
значение уедет за пределы байта и разойдётся с прошивкой.
Коды возврата вместо типов. sealed + switch не дают забыть
случай; if (r == -1) даёт.
Алгоритм переписан «по памяти». Пока нет перекрёстного прогона, совпадение CRC — только надежда.
185.7 Итоги
- Родной
int— 64 разряда и переполнение по кругу, но на вебе этоdouble: 2⁵³ + 1 равно 2⁵³, а наносекундная отметка теряет 21 нс. - Беззнаковой арифметики нет: маска
& 0xFFи оператор>>>— забота программиста. - Порядок байт указывают всегда — по умолчанию
ByteDataчитаетEndian.big:E8 03 00 00— это 1000 или 3 892 510 720, в зависимости отEndian. - Разбор протокола укладывается в
sealed-иерархию и switch-выражение, а измерение — в запись с именованными полями. - Одинаковость алгоритма на двух языках доказывается прогоном: 256 наборов байт, 256 совпадений, 0 расхождений.
Дальше: раздел 186 «Асинхронность: поток кадров, таймауты, переподключение, изолят».