can-lite: Architecture & Design Decisions

Status: Living document Last updated: 2026-08-20

1. Overview

can-lite is a lightweight CAN bus protocol library implementing a client-server model over CAN 2.0B (29-bit extended identifiers). It is designed for bare-metal embedded systems with strict constraints: no heap allocation, bounded memory, and deterministic timing.

2. Design Principles

Principle Rationale
No heap allocation Target MCUs have limited SRAM; all containers use infra::BoundedVector, infra::BoundedDeque, infra::Function, etc. from embedded-infra-lib.
Type-safe server/client separation Prevents accidental registration of a client-side category handler on the server (and vice versa) at compile time.
Observer pattern over callbacks Consistent notification mechanism using infra::Subject / infra::SingleObserver. Avoids storing infra::Function objects for event dispatch; observers auto-attach and auto-detach on construction/destruction.
Fixed-point encoding Floating-point values are transmitted as scaled integers to avoid FPU dependencies and ensure deterministic wire representation.
Domain-neutral library can-lite ships only protocol-level categories (System, Firmware Upgrade). Anything application-specific lives in the consuming project as an application category.
Extensible via categories New functionality is added by implementing a category handler — no protocol core changes required. See Extending can-lite with categories.

3. Category Type Hierarchy

A key architectural decision is the compile-time separation of server-side and client-side categories. This prevents a MyCategoryClient from being accidentally registered on a CanProtocolServer.

CanCategory holds the shared logic: a list of CanMessageType handlers, message dispatch via HandleMessage(), and the pure virtual Id() and RequiresSequenceValidation().

CanCategoryServer and CanCategoryClient each carry their own IntrusiveList node type, making them incompatible with each other’s lists. CanProtocolServer holds an IntrusiveList<CanCategoryServer> and CanProtocolClient holds an IntrusiveList<CanCategoryClient>, so type safety is enforced at the compiler level.

Both base classes own the CanFrameTransport reference and expose protected send helpers that fill in the category ID and priority, so a concrete category never touches the frame layer directly. Client categories additionally take a CanSequenceSource, which supplies the per-server sequence byte — this keeps categories independent of CanProtocolClient, so they link can_lite.core only.

Default sequence validation: - Server categories default to true — incoming commands carry a sequence byte for replay protection. - Client categories default to false — responses do not require sequence validation.

4. Message Type Dispatch

Each category contains a set of CanMessageType handlers, registered via AddMessageType() / AddMessageTypes() in the category constructor. In practice these are CanMessageHandler<Owner> instances, which bind a message type ID to a member function of the owning category — an ID, a reference and a member pointer, with no allocation and no nested class per message. When a frame arrives:

  1. CanProtocolServer/CanProtocolClient extracts the category ID and message type from the 29-bit CAN identifier.
  2. The corresponding category’s HandleMessage() is called.
  3. HandleMessage() iterates the registered message types and dispatches to the matching handler.
  4. The handler parses the payload and notifies the observer.

5. Observer Pattern

All category handlers use infra::Subject<Observer> / infra::SingleObserver<Observer, Subject> for event notification. This replaces earlier infra::Function callbacks.

Key properties: - Single observer for categories: Every category subject supports exactly one observer (infra::SingleObserver). This matches the 1:1 relationship between a category instance and its consumer. - Multiple observers at the protocol layer: CanProtocolServerObserver and CanProtocolClientObserver derive from infra::Observer, so a CanProtocolServer/CanProtocolClient accepts more than one. The protocol subject legitimately has two interested parties — the application and an optional TracingCanProtocol*Observer (§13) — whereas a category has one consumer. infra::Subject selects its single- or multi-observer specialisation from the observer’s SingleHelper typedef, so the two forms differ only in the base class; NotifyObservers() and Attach()/Detach() are identical. - Auto-attach/detach: The observer attaches in its constructor and detaches in its destructor — no manual registration needed. - Zero-cost when unobserved: NotifyObservers() checks for a null observer pointer (single) or iterates an empty list (multiple) before dispatching, making it safe to call with no observer attached.

Example — an application category (server side):

// Observer interface (pure virtual callbacks for each command)
class MyCategoryServerObserver
    : public infra::SingleObserver<MyCategoryServerObserver, MyCategoryServer>
{
public:
    virtual void OnSetParameters(int16_t first, const infra::Function<void()>& onDone) = 0;
};

// Subject (the category handler)
class MyCategoryServer
    : public CanCategoryServer
    , public infra::Subject<MyCategoryServerObserver>
{ ... };

// Application attaches by constructing an observer
class MyHandler : public MyCategoryServerObserver
{
public:
    explicit MyHandler(MyCategoryServer& server)
        : MyCategoryServerObserver(server) {}

    void OnSetParameters(int16_t first, const infra::Function<void()>& onDone) override { ... }
};

6. System Category Design

The System category (ID 0x00) is a built-in category that handles protocol-level concerns: heartbeat, status request, command acknowledgement, and category discovery. It is split into server and client variants.

Server side: CanSystemCategoryServer

The system category on the server is fully automatic — no public API is exposed to the application developer. CanProtocolServer creates an internal observer that reacts to system messages:

Message Internal behavior
Heartbeat received Notifies CanProtocolServerObserver::Online()
Status request Sends heartbeat response
Category list request Sends list of registered category IDs

RegisterCategory() returns false, without registering, if the category’s ID is already registered on that server or if canMaxRegisteredCategories (8) categories are already registered. The application interacts with CanProtocolServer only through RegisterCategory() and the CanProtocolServerObserver (Online/Offline). All system plumbing is hidden.

Client side: CanSystemCategoryClient

The system category on the client exposes only category discovery through its observer:

Exposed via observer Description
OnCategoryListResponse(categoryIds) Notifies when a category list is received from a server

Command acknowledgement handling (§9.4) happens in CanProtocolClient itself, not in CanSystemCategoryClient, because it needs the source node ID, which the category dispatch layer does not pass down to individual message handlers. Category discovery is exposed because the application may want to enumerate a server’s capabilities.

7. Bidirectional Category Pattern

Each application category is split into two classes that mirror the client-server protocol:

Server categories take a CanFrameTransport& in their constructor. Client categories take a CanFrameTransport& and a CanSequenceSource&; CanProtocolClient implements the latter, so categories depend only on can_lite.core.

See Extending can-lite with categories for the full authoring guide.

8. CAN Identifier Layout

All 29 bits of the extended CAN ID encode routing information:

[28:24] Priority     (5 bits)  — message urgency
[23:20] Category     (4 bits)  — functional group (0x0 = System)
[19:12] Message Type (8 bits)  — specific command/response within category
[11:0]  Node ID      (12 bits) — target server address (0x000 = broadcast)

Priority values (lower = higher priority on the CAN bus):

Priority Value Usage
Emergency 0 Reserved for safety-critical messages
Command 4 Client-to-server commands
Response 8 Server-to-client responses
Telemetry 12 Periodic data streams
Heartbeat 16 Presence detection

9. Sequence Validation

Server-side categories validate an 8-bit sequence number in data[0] of every command frame:

Client-side categories skip sequence validation (responses are stateless).

The server keeps one counter, shared by all sequence-validated categories. This is deliberate: the supported topology is one client to many servers, and a server serves exactly one client (REQ-CAN-006.1). Two clients commanding the same server concurrently interleave their counters and are rejected with sequenceError.

9.1 Per-Server Sequence Tracking

CanProtocolClient maintains independent sequence counters per server node in a fixed-size array (maxServers = 8) and exposes them through the CanSequenceSource interface. PeekSequence(nodeId) returns the next sequence byte for the given node, and CommitSequence(nodeId, category, messageType) advances it once the frame has been accepted by the send queue — so a rejected frame does not burn a sequence number. This ensures that commands directed to different servers do not share or interfere with each other’s replay protection state. CommitSequence also starts that server’s command-ack timeout (§9.4), which is why it takes the category and message type of the command just sent.

Sequence Resynchronization

A sequenceError acknowledgement carries the sequence number the server expected (§9.4). CanProtocolClient parses every acknowledgement frame as it arrives (before category dispatch, since it still has the source node ID at that point) and, on sequenceError, sets that server’s counter to the reported value. This recovers a client whose sequence state has drifted from the server’s — for example after the client process restarts and its in-memory counter resets to 0 while the server, unaware of the restart, still expects its old counter to continue — without requiring the server to also reset.

9.2 Server Liveness Detection

CanProtocolClient detects server online/offline transitions:

Applications connect a CanProtocolClientObserver to receive these events and react accordingly (e.g., stop issuing commands, alert the UI).

9.2.1 Client Liveness Detection (Server-side)

CanProtocolServer detects the reverse direction: whether its client is still present. CanProtocolClient broadcasts its own heartbeat (node ID 0x000) following the same quiet-period rule as the server’s heartbeat (§9.3); since that heartbeat is deferred by any other outgoing traffic, a client sending commands continuously might not emit a dedicated heartbeat frame for a long time. CanProtocolServer therefore restarts its clientLivenessTimer (configurable, default 3 s via Config::clientTimeout) on any frame correctly addressed to it, not only heartbeats — mirroring how CanProtocolClient::MarkServerAlive treats any received frame as proof of a server’s liveness (§9.2). Only a received heartbeat notifies CanProtocolServerObserver::Online(), since that is the specific, meaningful signal; if the timer fires without further traffic from the client, CanProtocolServerObserver::Offline() is notified. A server tracks liveness for one client only, consistent with the single sequence counter (§9).

9.3 Heartbeat Timer (Silence Guard)

Both CanProtocolServer and CanProtocolClient use a TimerSingleShot instead of a TimerRepeating for heartbeat emission. The timer is restarted (ResetHeartbeatTimer()) after every outgoing frame on that side. This means a heartbeat is only sent when that side has been silent for the full heartbeat interval, preventing unnecessary heartbeat traffic on active buses. The client’s heartbeat is a broadcast, since a client has no node ID of its own on the bus.

9.4 Command Acknowledgement Timeout

When CanCategoryClient::SendCommand sends a sequence-validated command, CanProtocolClient::CommitSequence starts a per-server ackTimer (configurable, default 1 s via Config::commandAckTimeout) alongside the category and message type of the command sent. Any acknowledgement frame from that server matching that (category, messageType) cancels the timer, regardless of its status. If the timer fires first, CanProtocolClientObserver::OnCommandAckTimeout(nodeId, category, messageType) is notified; the command is not automatically retried. Because a server can have at most one outstanding command tracked this way, sending a second sequence-validated command to the same server before the first is acknowledged replaces the tracked (category, messageType) — an acknowledgement for the first command that arrives afterward will not match and will not cancel the timer for the second.

10. Directory Structure

can-lite/
├── core/                          # Protocol primitives
│   ├── CanCategory.hpp/cpp        # Base class + Server/Client subclasses
│   ├── CanMessageType.hpp         # Message handler interface
│   ├── CanMessageHandler.hpp      # Binds a message type ID to a member function
│   ├── CanPayload.hpp/cpp         # Bounds-checked big-endian payload reader/writer
│   ├── CanSequenceSource.hpp      # Per-server sequence supply for client categories
│   ├── CanProtocolDefinitions.hpp # CAN ID layout, constants, enums
│   ├── CanFrameCodec.hpp/cpp      # Fixed-point encoding helpers
│   ├── CanFrameTransport.hpp/cpp  # Async send queue over hal::Can
│   └── test/                      # Unit tests + can_lite.test_util doubles
├── categories/
│   ├── system/                    # Built-in System category (0x00)
│   │   ├── CanSystemCategoryServer.hpp/cpp
│   │   └── CanSystemCategoryClient.hpp/cpp
│   └── firmware_upgrade/          # Firmware Upgrade category (0x01)
│       ├── FirmwareUpgradeDefinitions.hpp
│       ├── FirmwareUpgradeCategoryServer.hpp/cpp
│       ├── FirmwareUpgradeCategoryClient.hpp/cpp
│       └── test/
├── transport/                     # ISO-TP segmentation layer
│   ├── IsoTpTransport.hpp         # Abstract interface
│   ├── IsoTpTransportImpl.hpp/cpp # Non-template concrete impl (WithStorage)
│   └── iso-tp/                    # ISO 15765-2 internals
│       ├── IsoTpChannel.hpp       # Non-template abstract channel interface
│       ├── IsoTpChannelImpl.hpp/cpp # Non-template concrete channel (WithStorage<MaxPduSize>)
│       ├── IsoTpSender.hpp/cpp    # Non-template transmit FSM (WithStorage<MaxPduSize>)
│       ├── IsoTpReceiver.hpp/cpp  # Non-template receive FSM (WithStorage<MaxPduSize>)
│       ├── IsoTpFrameCodec.hpp/cpp # PCI encoding/decoding
│       └── IsoTpTypes.hpp         # Enums, constants (FrameType, FlowStatus, AbortReason)
├── server/                        # CanProtocolServer
│   ├── CanProtocolServer.hpp/cpp
│   └── test/
├── client/                        # CanProtocolClient
│   ├── CanProtocolClient.hpp/cpp
│   └── test/
├── tracing/                       # Optional tracing decorators (§13)
│   ├── TracingCan.hpp/cpp                        # hal::Can decorator
│   ├── TracingIsoTpTransport.hpp/cpp             # IsoTpTransport decorator
│   ├── TracingCanProtocolServerObserver.hpp/cpp  # protocol-layer events
│   ├── TracingCanProtocolClientObserver.hpp/cpp
│   └── test/
└── drivers/                       # Hardware driver adapters

examples/                          # Not built by default (CAN_LITE_BUILD_EXAMPLES)
└── foc_motor/                     # Reference application category

Application categories live in the consuming project, not in can-lite/categories/.

10.1 ISO-TP Transport Layer

The transport layer provides multi-frame PDU segmentation and reassembly following ISO 15765-2 without coupling it to any specific application category. It is an optional, orthogonal mechanism: categories that need large payloads attach IsoTpTransportImpl to the server/client; categories that fit in 8 bytes continue using raw CanFrameTransport directly.

Key classes:

Class Role
IsoTpTransport Abstract interface — RegisterReceiveChannel, SendPdu, ProcessFrame, SetOnPduReceived
IsoTpTransportImpl Non-template concrete implementation; channel pool via WithStorage<MaxPduSize, MaxChannels>
IsoTpChannel Non-template abstract channel interface used by IsoTpTransportImpl
IsoTpChannelImpl Non-template concrete channel; composes IsoTpSender + IsoTpReceiver via WithStorage<MaxPduSize>
IsoTpSender Non-template transmit FSM (SF → FF → wait-for-FC → CFs; N_Bs timeout); WithStorage<MaxPduSize> provides buffer
IsoTpReceiver Non-template receive FSM (SF dispatch or FF → wait-for-CFs; N_Cr timeout); WithStorage<MaxPduSize> provides buffer
IsoTpFrameCodec Stateless PCI encode/decode helpers

WithStorage pattern — zero-heap construction chain:

All ISO-TP classes are non-template at their API surface. Sizes are injected via nested WithStorage type aliases that use EMIL’s infra::WithStorage to compose storage ownership:

// IsoTpSender takes BoundedVector<uint8_t>& — WithStorage provides the buffer
IsoTpSender::WithStorage<MaxPduSize>  // IS-A IsoTpSender

// IsoTpReceiver — same pattern
IsoTpReceiver::WithStorage<MaxPduSize>  // IS-A IsoTpReceiver

// IsoTpChannelImpl composes sender + receiver storage in a nested struct
IsoTpChannelImpl::WithStorage<MaxPduSize>  // IS-A IsoTpChannelImpl IS-A IsoTpChannel

// IsoTpTransportImpl composes a BoundedVector of channels
IsoTpTransportImpl::WithStorage<MaxPduSize, MaxChannels>  // IS-A IsoTpTransportImpl

Attachment pattern:

IsoTpTransportImpl::WithStorage<64, 4> isoTp{ canFrameTransport };
protocolServer.AttachIsoTpTransport(isoTp);

CanProtocolServer::ProcessReceivedMessage offers each incoming frame to the ISO-TP layer first (isoTpTransport->ProcessFrame(canId, frame)). If the transport claims it (a registered channel matches), normal category dispatch is skipped. This keeps the transport layer transparent to existing category handlers.

11. Build System

12. Integration Testing

Integration tests validate end-to-end behavior across components using cucumber-cpp-runner v4.0.0 (BDD / Gherkin). Feature files are in integration_tests/features/; step definitions in integration_tests/steps/.

Single Common Fixture

All scenarios share a single fixture type — ApplicationFixture — that simulates a real application with a server, client, and virtual CAN bus. This ensures tests exercise the same initialization and interaction paths as production code.

integration_tests/
├── features/                      # Gherkin .feature files
├── hooks/                         # Scenario lifecycle hooks
├── steps/                         # Step definitions (GIVEN/WHEN/THEN)
└── support/
    └── ApplicationFixture.hpp      # VirtualCan, ApplicationFixture, mocks

VirtualCan

VirtualCan is a concrete hal::Can implementation that replaces hardware drivers in integration tests. Two VirtualCan instances are connected via ConnectTo(): frames sent by one are delivered to the other’s receive callback, simulating a shared CAN bus without mocking SendData/ReceiveData. InjectFrame() allows direct frame injection for testing error paths.

ApplicationFixture

ApplicationFixture inherits from infra::ClockFixture (providing EventDispatcher, TimerService, and ForwardTime()) and composes:

StrictMock Everywhere

All mock observers use testing::StrictMock. Unexpected calls cause immediate test failure, ensuring every interaction is explicitly expected.

Cucumber Context API

13. Observability

Tracing is provided by decorators the consumer opts into in the composition root. Nothing in the library holds a services::Tracer&; a build that never constructs a decorator pays nothing. Threading a tracer through CanFrameTransport, the protocol classes and every category was rejected: it would change every constructor signature and add a dependency to code that currently links can_lite.core only. .github/linters/goodcheck.yml enforces the same conclusion by forbidding services::GlobalTracer() in production code.

Layers and seams

Layer Seam Class
HAL hal::Can TracingCan
Transport IsoTpTransport TracingIsoTpTransport
Protocol CanProtocolServerObserver / CanProtocolClientObserver TracingCanProtocolServerObserver / TracingCanProtocolClientObserver

TracingCan sees all traffic in both directions, including frames dropped later by node-ID filtering, rate limiting, or unregistered categories. The protocol observers cover what the frame log structurally cannot show: OnCommandAckTimeout and both Offline transitions are timer-driven and put nothing on the bus, so without them a quiet bus and a bus whose acknowledgements are being missed look identical.

The category layer has no decorator. CanCategory::HandleMessage and HandlePduMessage are non-virtual, so decorating a category would mean making dispatch virtual in core — for information already derivable from the frame trace and the decoded commandAck line.

Wiring

services::TracingCan tracingCan{ realCan, tracer };
services::CanProtocolServer server{ tracingCan, config };

services::TracingIsoTpTransport tracingIsoTp{ isoTp, tracer };
server.AttachIsoTpTransport(tracingIsoTp);

services::TracingCanProtocolServerObserver serverTrace{ server, tracer };

Line format

Every line is <ClassName>: <message>, so a mixed log stays attributable to a layer and filters with a single grep Tracing:

TracingCan: TX id 0x4001123 prio command cat 0x0 system type 0x1 heartbeat node 0x123 dlc 3 data 010203
TracingCan: ack cat 0x3 type 0x5 status sequenceError expectedSeq 0x7
TracingIsoTpTransport: Abort dataId 0x18db33f1 reason nCrTimeout
TracingCanProtocolClientObserver: CommandAckTimeout node 0x123 cat 0x3 type 0x5

Targets

One target per layer, so a consumer links only what it instruments: can_lite.tracing (core), can_lite.tracing_iso_tp (transport), can_lite.tracing_protocol (server + client). All three additionally link services.tracer.

services::Tracer::Trace() returns infra::TextOutputStream under EMIL_ENABLE_TRACING and a no-op type under EMIL_DISABLE_TRACING, so every trace line is written as a single chained expression — the result is never stored, and Continue() is never used to append a conditional part.