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, so the server rejects duplicated and out-of-order commands. This is an ordering check, not a security mechanism (spec §11 and §16). - 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, notifies the observer and returns true. If the payload is invalid, it returns false instead and sends nothing.
  5. HandleMessage() reports handled, rejected (a handler returned false) or unknownMessageType (no handler matched). The server answers rejected with invalidPayload and unknownMessageType with unknownCommand. Handlers never acknowledge an invalid payload themselves, so the single-frame and ISO-TP paths report outcomes the same way, and the server knows whether a command was accepted.

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.

An application category is the subject; its observer interface derives from infra::SingleObserver over that category, and the application attaches by constructing its implementation of the interface with the category as the argument. The authoring guide, §7, describes the interface’s shape; examples/setpoint/ is a worked example.

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 Counts as an accepted command (§9.2.1)
Status request Sends a heartbeat; requests arriving while one is pending are answered by it
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 sends the category list request (RequestCategoryList(nodeId)) and has no observer. Both answers a server sends it — command acknowledgements (§9.4) and category list responses — are handled in CanProtocolClient itself, because they need the source node ID, which the category dispatch layer does not pass down to individual message handlers.

An application client category that needs the sender of the response it is handling reads it from CanFrameOriginTracker, a hal::Can decorator in core that records the source node of each frame while it is being delivered (0 for an 11-bit identifier). The client’s protocol object is constructed over the tracker, and the category holds it as a CanFrameOrigin. This keeps the source node out of every handler’s signature.

The application reaches discovery through CanProtocolClient::DiscoverCategories(nodeId, onDone), because it may want to enumerate a server’s capabilities. One discovery is pending at a time; only the asked server’s answer completes it, with the category IDs, and Config::discoveryTimeout (default 1 s) completes it without an answer. A request while one is pending, or to the broadcast address, returns false.

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

The identifier layout and the priority levels are specified in the protocol specification, §5 and §6. Library and category code builds and takes identifiers apart only through MakeCanId() and the ExtractCan*() helpers in CanProtocolDefinitions.hpp, so the layout is encoded in one place.

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.

The counter only moves for accepted commands (REQ-CAN-043). CanProtocolServer records the incoming sequence byte before dispatching to the handler and restores the previous value if the handler rejects the payload or no handler matches. Recording first matters on buses that deliver synchronously: an accepted handler’s acknowledgement can make the client send its next command before dispatch returns. A rejecting handler sends nothing, so nothing can re-enter between the record and the restore.

Commands at emergency priority skip the check and, once accepted, set the counter to their own sequence byte (REQ-CAN-042). That keeps an emergency stop independent of whatever sequence state earlier traffic left behind. Because the client’s SendCommand() counted that byte, the next ordinary command follows on without a 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, so commands directed to different servers do not share or interfere with each other’s sequence state. CanCategoryClient::SendCommand drives it in three steps:

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 every command it accepts, not only heartbeats. It does this only after the category, the command type, the sequence and the handler have all accepted the frame. A frame that is rate-limited, discarded or answered with an error proves nothing about the client, and counting it would let a flood of invalid traffic keep a departed client “online” and suppress the application’s offline fallback. The CAN identifier has no source field, so this rests on the single-trusted-controller assumption stated in the protocol specification (§8.1.1). The first accepted command after start-up or after an outage notifies CanProtocolServerObserver::Online(), and later ones do not; if the timer fires without further traffic from the client, CanProtocolServerObserver::Offline() is notified. The application sees one notification per transition, alternating. A server tracks liveness for one client only, consistent with the single sequence counter (§9).

9.2.2 Receive Admission Order

CanProtocolServer runs the direct-frame and ISO-TP paths through the same steps, so the two paths are equally safe:

  1. address filter;
  2. rate limit, charged per CAN frame to the ordinary or the emergency budget depending on the identifier’s priority (a reassembled PDU is not charged again);
  3. command range and category lookup;
  4. empty-payload and sequence checks (skipped for emergency priority);
  5. handler dispatch;
  6. only for a handled result: record the sequence and restart the client liveness timer.

Rate limiting stays first because it exists to bound processing cost, and giving emergency traffic its own small budget keeps that bound. Every refusal is counted and exposed through CanProtocolServer::Statistics().

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::ReserveSequence 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.

9.5 Outbound Frame Priority

CanFrameTransport orders its send queue by the identifier’s priority field instead of by arrival. Bus arbitration can only rank frames that have already reached the controller, so a first-in-first-out queue would let queued telemetry delay an emergency frame, and a queue full of telemetry would refuse it. Two slots of the fixed eight are reserved for emergency frames. When the queue is full, an emergency frame displaces the newest frame of the lowest queued priority, and that frame’s completion reports failure. A failed emergency transmission is retried a bounded number of times. Everything is counted and exposed through CanFrameTransport::Statistics(), and server categories send fault frames through SendEmergency(). The exact rules are REQ-CAN-039 to REQ-CAN-041. Displacement was chosen over a separate emergency queue so that the storage stays one fixed-size container and the order of same-priority frames stays FIFO.

10. Directory Structure

The repository layout is in the README. Each component directory holds its unit tests in a test/ subdirectory, and 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, ReleaseChannel, SendPdu, ProcessFrame, SetOnPduReceived, SetOnAbort
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_As and N_Bs timeouts); 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: their constructors take references to the storage they work on, and sizes appear only in the nested WithStorage classes that own that storage and construct the class on top of it.

Attachment pattern: an IsoTpTransportImpl::WithStorage, sized for the largest PDU and the number of channels, is constructed over the protocol object’s own CanFrameTransport and handed to AttachIsoTpTransport().

AttachIsoTpTransport stores a raw pointer and installs the PDU callback on the transport, so the transport must outlive the attachment. It leaves the abort callback to the application and never releases channels: a failed transfer is reported and discarded, and the channel stays open. Because it is constructed from the protocol object’s own CanFrameTransport, it is necessarily destroyed first; DetachIsoTpTransport() breaks the attachment in that case, and the protocol object’s destructor deliberately does not reach into a transport it cannot know is still alive.

The same ownership rule governs the hal::Can receive callback. The interface holds one callback and cannot report who installed it, so CanProtocolServer and CanProtocolClient claim it unconditionally at construction and release it unconditionally at destruction. One protocol object owns a given hal::Can, and nothing else may register on it while that object is alive — a second registration silently stops the first from receiving. See the design booklet’s corner-case chapter, §10.

A channel is a connection with one peer: it receives on one identifier and transmits data and flow control on the other, so its sender and receiver can run concurrently. The receiver follows ISO 15765-2 for unexpected frames, ignoring the malformed and unsolicited ones rather than aborting.

Abort reasons are enumerated in IsoTpTypes.hpp and catalogued in the design booklet’s glossary. sendFailed is distinct from unexpectedFrame: the first is a local transmit condition, the second a statement about the peer’s frame.

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.

ApplicationFixture

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

StrictMock Everywhere

All mock observers use testing::StrictMock. Category observers carry explicit per-step expectations, so an unexpected call fails the scenario.

The protocol objects’ observers are notified as peers come and go and as acknowledgements time out, which no step sets up explicitly. The fixture therefore records those notifications:

Then steps assert the recorded state. Rate-limiting scenarios count the heartbeats they inject less the server’s rate-limited statistic. An after-scenario hook fails any scenario that ends with a command acknowledgement timed out.

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.

TracingBypassCan is not a decorator but a router for the same seam: it sends the frames a consumer-supplied predicate selects on an untraced hal::Can and every other frame, and all reception, through the traced one. It is for a stream whose trace lines would outrun the trace port, such as high-rate telemetry; those frames still reach the bus, they only leave no line. It lives in can_lite.tracing and needs no tracer itself.

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

Each decorator wraps the seam it instruments and takes the tracer as a constructor argument. TracingCan wraps the real hal::Can, and the protocol object is constructed over the decorator instead. TracingIsoTpTransport wraps the ISO-TP transport, and the decorator is what gets attached to the protocol object. TracingCanProtocolServerObserver and TracingCanProtocolClientObserver attach to the protocol object like any other protocol observer, beside the application’s own.

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.