Embedded HandbookApps
All sections › XXIII. The device's companion app: Dart and Flutter
STM32F103, STM32F407, STM32G431, STM32L431 · level intermediate · 6 min read
Open in the interactive handbook

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 test unit 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.

An integer in a double: exact up to 2⁵³, rounded beyond it

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.

A device frame: the same bytes in the firmware and in the app


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

Next: section 186 "Asynchrony: frame stream, timeouts, reconnection, isolate".

Open in the interactive handbook