Designing a Reliable Stop-and-Wait Transport Protocol on CAN FD for Deterministic Embedded Systems

Designing a Reliable Stop-and-Wait Transport Protocol on CAN FD for Deterministic Embedded Systems

CAN FD bus transmitting thermal sensor data between embedded nodes

In high-performance embedded systems, data integrity and deterministic timing matter more than almost anything else. Controller Area Network has been the backbone of reliable automotive and industrial communication for decades. CAN FD gave us higher bandwidth and larger payloads, up to 64 bytes, but standard CAN FD, while solid for short control messages, has no native transport layer for reliably moving large datasets, thermal images, firmware updates, across a noisy bus without data loss.

This post covers how we designed and built a reliable stop-and-wait transport protocol on top of CAN FD: the resource constraints we had to work around, how the design evolved, and the architecture that gets us zero packet loss for critical sensor data in a deterministic real-time environment.

The problem: beyond standard CAN FD

Our application transmits thermal sensor arrays, roughly 3KB of data, from a sensor node to a main controller. 3KB sounds trivial in most contexts, but inside a hard real-time embedded system with limited RAM and shared bus bandwidth, it raises real problems.

The fire-and-forget risk. Standard CAN is a broadcast protocol. Putting a frame on the bus successfully doesn't guarantee the receiver processed it. If the receiving node is busy, erasing flash or handling a high-priority interrupt, its receive buffers can overflow, causing silent packet loss. Losing even one fragment of a larger dataset invalidates the entire frame.

Burst mode congestion. Transmitting the entire dataset in one continuous burst saturates the bus, blocking other critical command-and-control messages and effectively freezing the system's communication for the duration of the transfer.

Verification gaps. Without an application-level acknowledgment, the sender operates blindly. We needed a definite contract, a guarantee that sender and receiver stay synchronized throughout the transfer.

Deterministic latency requirements. Our system manages multiple high-speed sensors and safety loops. A transport protocol that monopolizes the bus unpredictably isn't acceptable. We needed priority granularity: the ability to pause or cancel a large transfer instantly if something higher-priority, like a user-triggered snapshot, comes along.

The mechanics of reliability

Why stop-and-wait? Stop-and-wait is often considered less efficient than sliding-window protocols like TCP, since it waits for an acknowledgment after every packet. That apparent inefficiency buys us something valuable: state simplicity.

In a memory-constrained environment like a Cortex-M33, a full sliding window needs complex reassembly buffers and state tracking for out-of-order packets. Stop-and-wait flips the script. The sender manages the complexity, retries, timeouts, state, while the receiver stays stateless and idempotent, processing one packet at a time, immediately. This eliminates the need for large RAM buffers on the receiver and makes system behavior highly predictable.

The hardware foundation. Our implementation runs on a modern microcontroller series paired with a robust CAN FD transceiver. The physical layer's Signal Improvement Capability lets us push the data-phase bit rate to 5 Mbps reliably, and we configure CAN FD timing with Transceiver Delay Compensation to account for propagation delays at these speeds. A solid physical layer is the prerequisite for any reliable software protocol built on top of it.

Protocol architecture

The protocol structures data into a hierarchy to manage flow and storage. Chunks divide the large dataset into manageable blocks, aligned with flash memory page sizes. Packets slice each chunk further into CAN FD frames, using the maximum 64-byte payload.

Chunk-to-packet decomposition diagram for CAN FD sensor data transfer

Every packet carries a lightweight header for context: sequence metadata (current packet index, total packets, chunk ID), a command or type field identifying the data stream (thermal, firmware), and a system timestamp for global time synchronization. That header lets the receiver identify duplicate packets or detect missing sequences immediately.

The logic flow

The core reliability mechanism is a nested state machine on the sender side, following a Chunks -> Packets -> Retries structure:

Initialize Transfer
For Each Chunk:
    For Each Packet in Chunk:
        Reset Retry Counter
        While (ACK Not Received) AND (Retry Count < Max):
            1. Construct Packet (Header + Data)
            2. Transmit via CAN FD
            3. Wait for ACK (with Timeout)

            If ACK Received:
                Break Loop (Success)
            Else:
                Increment Retry Count
                Backoff Delay (Give receiver time)

        If Timeout Reached:
            Abort Transfer (Error)

        Small Delay (Flow Control)

When the receiver successfully processes a packet, staging it for a flash write, it replies with a specific ACK message, and the sender waits for that ACK before proceeding. This handshake closes the verification loop for every single segment of data.

Crucially, the "wait for ACK" phase isn't a blocking sleep, it's an active check that also monitors for system cancellation requests. If a high-priority event like a snapshot trigger occurs, the protocol detects it inside the wait loop and aborts immediately. This guarantees the background transfer never blocks a critical foreground action for more than the time of a single packet, a matter of milliseconds.

The receiver's role: idempotency

The receiving side is designed to be idempotent, handling the same packet multiple times without corruption. Consider a packet that gets received and written, but its ACK is lost on the bus. The sender times out and retransmits. The receiver checks the packet index: if it's already been processed, it discards the data but resends the ACK anyway, since the sender is evidently stuck waiting for it. That unblocks the sender without corrupting the data stream.

Why not ISO-TP?

We considered ISO-TP (ISO 15765-2), the standard for automotive diagnostics, but opted for a custom solution for a few reasons. ISO-TP's flow control can be overly chatty for unidirectional sensor streaming. A compliant ISO-TP stack is heavy, ours is streamlined at under 300 lines and easier to validate. And we needed deep hooks for priority cancellation; integrating application-layer abort logic into a third-party ISO-TP stack tends to be invasive and messy.

For our specific case, unidirectional high-bandwidth sensor streaming, a custom "ISO-TP light" approach gave us better performance and simpler integration.

Performance and optimization

At 5 Mbps, a theoretical 3KB transfer would take about 5ms. In practice, with stop-and-wait overhead, ACK round-trips, and intentional flow-control delays, the real number is closer to 30 to 40ms. That's slower than the theoretical maximum, but it's a calculated trade-off. For a system running at 4Hz, a 250ms period per frame, spending 40ms on reliable transfer leaves 84 percent of the CPU budget free. We're trading raw speed for 100 percent reliability and system stability.

To maximize throughput further, we apply domain-specific compression. Thermal data is often floating-point, but sensors have a real noise floor. Converting 32-bit floats to 16-bit fixed-point values, scaling by 100, doubles effective bandwidth while keeping enough precision for the application. A range like -327.68 to +327.67 degrees with 0.01 degree resolution covers what a thermal sensor actually needs, and the 2x bandwidth comes essentially free.

Why 256-value chunks and 16-bit fixed point

Flash memory pages on our target are often 256 bytes or multiples of it. Aligning chunks to 256 values, which pack to 512 bytes, matches naturally with the receiver's storage cadence, so it's a number derived from hardware reality rather than picked arbitrarily.

On the fixed-point side, thermal sensors typically carry noise in the 0.1 to 0.4 degree range, so transmitting full 32-bit floats provides more precision than the application will ever use. Scaling by 100 and casting to int16_t covers a wide practical range with 0.01 degree resolution, and that alone creates roughly a 2x bandwidth multiplier for free.

The priority cancellation pattern

In standard protocols, a transfer is usually all-or-nothing. We inserted a cancellation check, g_transmission_cancel_requested, into the deepest loops in the system, specifically the ACK wait loop. That ensures if the user presses "Capture Photo," the thermal background task yields within milliseconds rather than seconds. That's the actual difference between a product that feels laggy and one that feels snappy.

Evolution of the protocol

This wasn't version one. Version 1, "the firehose," blasted CAN FD frames as fast as the hardware queue would allow. The receiver's FIFO overflowed immediately, and we lost roughly 40 percent of packets at random. Version 2, "the blind wait," added a small fixed delay between packets. Reliability improved to about 99 percent, but we still hit hiccups whenever the receiver was busy erasing flash sectors, and 99 percent isn't good enough for a data stream where one missing byte corrupts the file. Version 3, the handshake we run today, introduced the return channel. That closed the loop completely: if the receiver pauses for 50ms to erase flash, the sender simply waits, up to 20 seconds if needed. No data gets lost, it just gets delayed, and that elasticity turned out to be essential for workloads where tasks take variable time to finish.

Conclusion

Building a reliable transport protocol on CAN FD means respecting the physical realities of the bus and the resource constraints of the microcontroller. By accepting the overhead of headers and ACKs, we trade a small amount of throughput, roughly 3ms in theory against 30 to 40ms in practice, for an absolute guarantee of delivery. The result is a system that feels solid: thermal images arrive correctly every time, regardless of what else the CPU happens to be doing.

For engineers working on similar problems: stop trusting the bus. Verify everything. Reliability isn't an accident, it's an engineered feature.