1. Today's topic
We are designing a binary protocol over UART, RS-485, TCP, UDP, CAN FD, MQTT binary payloads and a Raspberry Pi HIL link. Main topics:
framing
COBS / delimiter
length
CRC
endianness
versioning
TLV
sequence/request_id
stream parser
resynchronizationThe main idea: a transport read() is not a message. TCP and UART deliver a byte stream, so first extract a message from the stream, check its boundaries, lengths and CRC, and only then pass it to the application layer.
2. Why this matters in projects
For HIL, a Raspberry Pi can send:
SET_INPUT A03 HIGH
INJECT_MODEM_URC
READ_DIAGNOSTICS
RESET_FAULT_COUNTERS
START_TEST
GET_TEST_RESULTA text CLI is convenient for a person, but a binary protocol is more convenient for automated tests:
- fixed types;
- smaller size;
- sequence/correlation ID;
- CRC;
- strict limits;
- easier fuzzing.
For remote input modules, a binary protocol can transmit:
raw input mask
confirmed phase
ADC samples
conflict flags
sequence
measurement timestamp
power statusIf one byte is lost, the parser must discard the damaged frame and resynchronise at the next one.
3. Theory
Protocol layers
transport adapter
UART/TCP/UDP/CAN
frame decoder
delimiter, COBS, length, CRC
message decoder
version, type, TLV, schema
application service
authorization, execution, state changesThe frame parser must not operate GPIO, MQTT, NVS or reset.
Proposed format
For UART/RS-485:
COBS(encoded logical frame) + 0x00 delimiterAfter COBS decoding:
Offset Size Field
0 2 Magic = 0x4550 "EP"
2 1 Protocol major version
3 1 Header length
4 2 Message type
6 2 Flags
8 4 Sequence
12 4 Payload length
16 ... Optional header extensions
... N Payload
... 4 CRC32CWhy include magic, delimiter, length and CRC together:
- the delimiter locates the physical frame boundary;
- COBS guarantees that the delimiter does not occur inside the payload;
- magic filters out noise and other protocols;
- length prevents reads beyond the buffer;
- CRC filters out damaged data.
Do not transmit C structures directly
Poor approach:
typedef struct {
uint8_t version;
uint32_t sequence;
uint16_t value;
} packet_t;
uart_write_bytes(UART_NUM_1, &packet, sizeof(packet));Problems: padding, endianness, enum size, ABI, unaligned access and uninitialised bytes. The correct approach is to write fields explicitly, in a fixed order and endianness.
static void write_u32_be(uint8_t *p, uint32_t value)
{
p[0] = (uint8_t)(value >> 24U);
p[1] = (uint8_t)(value >> 16U);
p[2] = (uint8_t)(value >> 8U);
p[3] = (uint8_t)value;
}A safe parsing order
1. Minimum size.
2. Magic.
3. Version.
4. Header length.
5. Payload maximum.
6. Exact total size.
7. CRC.
8. Flags/schema.
9. Dispatch.Application logic is not called before CRC validation.
TLV
TLV = Type-Length-Value.
Type: uint16 big-endian
Length: uint16 big-endian
Value: Length bytesYou can reserve the most significant bit of Type:
type & 0x8000 == 0 -> optional unknown TLV can be skipped
type & 0x8000 != 0 -> critical unknown TLV rejects message4. Common mistakes
- Treating one uart_read() as one frame.
- Using packed C structures as the wire format.
- Passing a network-supplied payload_length directly to malloc().
- Checking CRC after parsing the payload.
- Continuing the current frame after a UART overflow.
- Using only magic, without CRC/length.
- Using a length prefix on a noisy UART without resynchronisation.
- Changing an old field's meaning in a minor update.
- Failing to limit the number of TLVs in the TLV parser.
- Mistaking sequence numbers for cryptographic protection.
5. A practical task for 30–60 minutes
Create PROTOCOL_SPEC.md:
# Embedded Protocol v1
## Transport
UART and RS-485:
- COBS encoded
- 0x00 frame delimiter
TCP:
- same COBS stream for initial implementation
UDP:
- one decoded logical frame per datagram
## Limits
Maximum header: 64 bytes
Maximum payload: 1024 bytes
Maximum decoded frame: 1092 bytes
No runtime allocation in parser
## Byte order
All multi-byte integers are big-endian.
## Validation order
1. COBS
2. Minimum length
3. Magic
4. Version
5. Header length
6. Payload limit
7. Exact total length
8. CRC32C
9. Message schema
10. Application authorizationImplement these modules:
cobs.c / cobs.h
protocol_frame.c / protocol_frame.h
protocol_stream.c / protocol_stream.hUnit tests:
1. A complete valid frame.
2. The same frame one byte at a time.
3. Two frames in one feed().
4. Empty delimiters.
5. A changed byte -> BAD_CRC.
6. Header length < 16 -> reject.
7. Payload length > max -> reject.
8. After an invalid frame, the next valid frame is accepted.
9. Unknown optional TLV is skipped.
10. Unknown critical TLV is rejected.6. What to read or try next
- ESP-IDF UART driver: event queue, RX timeout, FIFO overflow and ring-buffer overflow.
- STM32 ReceiveToIdle_DMA for UART + DMA.
- RFC 8949 CBOR if a more flexible format is needed.
- Protocol Buffers wire format, taking deterministic encoding into account for signatures.
Criteria: Keep framing and validation ahead of application side effects.
Exercise
A damaged UART frame is followed by a valid frame. Describe the expected parser result and two tests that demonstrate recovery without invoking application logic for the damaged frame.
Self-check criteria: Include bounded validation, resynchronisation and equivalent results across chunk boundaries; CRC rejection must precede dispatch.
Show the supplied answer
Discard the damaged frame, resynchronise at the next frame boundary and accept the following valid frame after length/CRC validation. Test the pair in one feed() and then with the same bytes split across several feed() calls. Only the valid frame should be dispatched.