185. Dart for the firmware engineer: bytes, numbers, frame parsing
A device almost always grows an app around it: to look at readings, to configure it, to update the firmware. Part XXIII is about this second half of the work. We start not with buttons but with what a firmware engineer needs first in a new language: bytes and numbers.
The example runs through the whole part: the same frame 0x55 — length — data — CRC-8 that
the firmware parsed in section 184. Now the app parses it, and
both sides must compute the same results — this is tested, not promised.
Terms used in the documentation
Term Meaning Uint8List, ByteData a byte array; byte access with an explicit byte order endianness, Endian.little byte order; least significant byte first sealed class a closed hierarchy: all subclasses are known pattern matching, switch expression matching against patterns; a switch that returns a value record a tuple with named fields null safety, late, ?. ?? protection against null package, pubspec a package and its description file unit test, dart testunit tests
185.1 Numbers: where Dart is not like C
The native int in Dart is a signed 64-bit integer, and it overflows
by wrapping around, just as on a microcontroller:
| What we compute | Value |
|---|---|
0x7FFFFFFFFFFFFFFF |
9223372036854775807 |
| the same plus 1 | −9223372036854775808 |
But Dart has a second target — the browser, and when compiled to JavaScript int is
a double: integers are exact only up to 2⁵³ (when compiled to WebAssembly int
is 64-bit again, but a protocol is designed for the worst case). This breaks
a device timestamp in nanoseconds:
| What we compute | Value |
|---|---|
| 2⁵³ + 1 == 2⁵³ (in double) | true |
| timestamp, ns | 1758013245123456789 |
| the same after double | 1758013245123456768 |
| lost | 21 ns |
How coarse the rounding is shows in the step between neighbouring double values: for milliseconds since 1970 the step is 0.00024 — integers are exact; for microseconds the step is 0.25; for nanoseconds it is already 256, so the timestamp jumps by a quarter of a microsecond.
The conclusion for the protocol: do not send 64-bit counters and
identifiers to the app as numbers. Milliseconds are fine (2⁵³ ms is 285 thousand
years); nanoseconds and serial numbers go only as a string or as a pair of
32-bit words; for exact arithmetic there is BigInt.
Dart has no unsigned types at all, so you apply the mask by hand:
| Expression | Value |
|---|---|
-1 & 0xFF |
255 |
(0x80 << 1) & 0xFF |
0 |
-1 >> 56 |
−1 |
-1 >>> 56 |
255 |
>> extends the sign, >>> does not. This is exactly the difference that in C
is set by the type of the operand, and here by the choice of operator.
The shifts in the table are correct only in native code. On the web, when compiled
to JavaScript, bitwise operations work on 32 bits, and their result
is unsigned: the same program prints -1 >> 56 = 4294967295 there,
-1 >>> 56 = 0, ~0 and -1 | 0 = 4294967295, 1 << 32 = 0, and the top
byte of the 40-bit (0x123456789A >> 32) & 0xFF is 0 instead of 18. The masks
from the two top rows give the same as in native code. Fields longer than
32 bits are assembled on the web from two halves or computed in BigInt.
185.2 Bytes and byte order
A byte array is a Uint8List, fields are read with ByteData. The byte order is specified
always: without the second argument ByteData reads the most significant
byte first (Endian.big), the opposite of ARM, and there will be no
error at all:
final ByteData view = ByteData.sublistView(frame);
final int ms = view.getUint32(0, Endian.little);
final double volts = view.getInt16(4, Endian.little) / 1000.0;
The same four bytes E8 03 00 00 give:
| Order | Value |
|---|---|
Endian.little |
1000 |
Endian.big |
3892510720 |
Hence the rule: byte order is part of the protocol description, not an implementation detail. In ARM firmware it is little-endian by default, in the app you write it in the code, and this junction is where mistakes happen most often.
185.3 Frame parsing: sealed classes instead of return codes
In C the parser returned 1 / 0 / −1 (section 184.2). Dart has a
closed hierarchy for this: the compiler itself makes sure that every case is handled.
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; }
Handling an event is a switch expression with patterns:
final String what = switch (event) {
FrameReady(data: final d) => 'кадр ${d.length} байт',
FrameError(reason: final r) => 'ошибка: $r',
FrameNone() => 'ждём',
};
You cannot forget a case: sealed forces you to list all subclasses,
otherwise the program will not build. For a protocol where an "error" is a normal
state, this is noticeably more reliable than if (r == -1).
The measurement itself is convenient to return as a record — a tuple with names:
typedef Measurement = ({int ms, double volts, double amps});
No class and no constructor are needed: fields by name, comparison by value.
185.4 One algorithm in two languages — and the proof
CRC-8 in the app and in the firmware must match. This is not checked
by eye: the same program computes 256 byte sets in C (from
_verify/test184/frame.c) and in Dart, and the results are compared in full.
| What was compared | Value |
|---|---|
| byte sets | 256 |
| matching CRC values | 256 |
| mismatches | 0 |
The technique is general: any algorithm that exists on both ends of the wire is checked by a cross run, not by reading the code. For CRC, for converting ADC codes to volts, for the time format.
185.5 Tests: the same as in 184, only shorter
dart test works with the test package, which you add to the package description
with one command, dart pub add dev:test; no separate build is needed:
test('CRC-8 совпадает с прошивкой', () {
expect(crc8(<int>[0x31, 0x32, 0x33]), 0xC0);
expect(crc8(<int>[]), 0x00);
});
The number 0xC0 is the same independent value from section 184.5: it catches
a corrupted polynomial, while a value computed "by the same code" does not.
A set of six tests covers everything we did here: CRC, a whole frame, a corrupted CRC, a length out of range, byte order and pattern matching. The run takes a fraction of a second and works on any machine without a board.
185.6 Pitfalls
A 64-bit identifier arrived as a number. On the web it is silently rounded: a nanosecond timestamp loses 21 ns on the very first conversion. Use a string or two 32-bit words.
Byte order "whatever happens". Without the argument ByteData takes
Endian.big: the same four bytes give 3892510720 instead of 1000.
A sign instead of zero on a shift. -1 >> 56 is −1, not 255. Bytes
from the wire need >>> or a mask.
int in Dart is not uint8_t. After << you apply the mask yourself, otherwise
the value runs past the byte and diverges from the firmware.
Return codes instead of types. sealed + switch do not let you forget a
case; if (r == -1) does.
The algorithm was rewritten "from memory". Until there is a cross run, a CRC match is only a hope.
185.7 Summary
- The native
intis 64 bits and wraps around on overflow, but on the web it is adouble: 2⁵³ + 1 equals 2⁵³, and a nanosecond timestamp loses 21 ns. - There is no unsigned arithmetic: the
& 0xFFmask and the>>>operator are the programmer's job. - Always specify the byte order — by default
ByteDatareadsEndian.big:E8 03 00 00is 1000 or 3892510720, depending onEndian. - Protocol parsing fits into a
sealedhierarchy and a switch expression, and a measurement into a record with named fields. - That an algorithm is identical in two languages is proved by a run: 256 byte sets, 256 matches, 0 mismatches.
Next: section 186 "Asynchrony: frame stream, timeouts, reconnection, isolate".