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:
CanProtocolServer/CanProtocolClientextracts the category ID and message type from the 29-bit CAN identifier.- The corresponding category’s
HandleMessage()is called. HandleMessage()iterates the registered message types and dispatches to the matching handler.- The handler parses the payload, notifies the observer and returns
true. If the payload is invalid, it returnsfalseinstead and sends nothing. HandleMessage()reportshandled,rejected(a handler returnedfalse) orunknownMessageType(no handler matched). The server answersrejectedwithinvalidPayloadandunknownMessageTypewithunknownCommand. 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 category: Registers handlers for
commands (IDs
0x00–0x7F). Uses the inheritedSendResponse()/SendTelemetry()/SendCategoryError()helpers, which fill in the category ID and priority. - Client category: Registers handlers for
responses (IDs
0x80–0xFF). Uses the inheritedSendCommand(uint16_t targetNodeId, ...)helper, which directs each frame to a specific server and prepends the per-server sequence byte.
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:
- The server tracks the last accepted sequence number.
- A command is accepted if its sequence number equals
(previous + 1) mod 256. - On sequence error, the server sends a
CommandAckwithsequenceErrorstatus. - The sequence counter wraps from 255 → 0.
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:
ReserveSequence(nodeId, category, messageType)takes the next sequence byte for the node, records the command as awaiting acknowledgement and starts its command-ack timeout (§9.4), all before the frame is handed to the send queue. An acknowledgement that a driver delivers while the frame is still being sent therefore finds the command it acknowledges, and asequenceErrorsets a counter nothing advances afterwards.- If the payload leaves no room for the sequence byte or the queue
refuses the frame,
CancelReservation(nodeId, sequence)restores the slot exactly as it was: counter, awaited command and its deadline, and any server the reservation evicted. A refused frame does not burn a sequence number. - If the queue accepts the frame but it then fails to leave the node,
its completion calls
ReturnSequence(nodeId, sequence). The number is given back unless a later command to that server has already taken the next one, and the awaited command stays recorded so the command-ack timeout reports its loss.
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:
- Every received frame with a non-broadcast source node ID is passed
to
MarkServerAlive(nodeId). - On first reception from a node,
CanProtocolClientObserver::OnServerOnline(nodeId)is called. - A
TimerSingleShot(configurable, default 3 s) is (re)started on each frame. If no frame is received before the timer fires,CanProtocolClientObserver::OnServerOffline(nodeId)is called. - Up to 8 server liveness slots are tracked simultaneously.
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:
- address filter;
- 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);
- command range and category lookup;
- empty-payload and sequence checks (skipped for emergency priority);
- handler dispatch;
- only for a
handledresult: 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.
IsoTpSenderandIsoTpReceivertake aBoundedVector<uint8_t>&for the PDU being sent or reassembled. TheirWithStorage<MaxPduSize>is EMIL’sinfra::WithStorage, which provides the buffer.IsoTpChannelImpltakes anIsoTpSender&and anIsoTpReceiver&. ItsWithStorage<MaxPduSize>owns one sender and one receiver of that size and is anIsoTpChannelImpl.IsoTpTransportImpltakes a range ofIsoTpChannel*. ItsWithStorage<MaxPduSize, MaxChannels>owns the channels and the pointer table it passes in, and is anIsoTpTransportImpl.
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
- CMake presets are the primary interface (see
CMakePresets.json). - Libraries follow the
can_lite.<component>naming convention. - Category libraries link
can_lite.core; protocol libraries link the relevant category libraries. can_lite.categories.firmware_upgrade.sessionis the one library above a category: it links the firmware upgrade category and embedded-infra-lib’supgrade.stagingandbootloader, and only a product that uses the upgrade session links it.- Unit tests use GoogleTest/GMock and run on the host.
- Standalone builds fetch
embedded-infra-libvia FetchContent.
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.
- Asynchronous, like a driver. Delivery to the peer
and the send completion are scheduled on the event dispatcher, in send
order, rather than run inside
SendData. So the scenarios exercise the call graph a hardware driver produces, not a re-entrant one no target can. - Delivery between steps. An after-step hook runs the
dispatcher, so everything a step set in motion has been delivered before
the next step starts.
ForwardTime()delivers as time passes. - Recording. Every frame sent is recorded
(
SentFrames(),LastSent(),sendCount), so Then steps can assert that nothing was sent, or exactly what was. - Injection.
InjectFrame()delivers a frame straight to the node’s own receive callback, for frames no correct peer would send. - Faults.
LoseFramesOn(id)drops every frame sent on one identifier while reporting it sent,FailFramesOn(id)reports its transmission as failed, andDeliverEveryFrame()clears both.
ApplicationFixture
ApplicationFixture inherits from
infra::ClockFixture (providing
EventDispatcher, TimerService, and
ForwardTime()) and composes:
- A pair of connected
VirtualCaninstances (server-side and client-side). CanProtocolServerandCanProtocolClientwired to their respective CAN interfaces.StrictMockobservers for the server, the client and (optionally) the categories.- Optional demo category components
(
DemoCategoryServer/Client, observers) activated viaRegisterDemoCategory(). The client side uses the client’s ownTransport(), as an application must. The demo category (0x3) is the reference application category used to validate the extension API end to end. - Optional Firmware Upgrade components
(
FirmwareUpgradeCategoryServer/Client, observers) activated viaRegisterFirmwareUpgrade(). - Dynamic test categories (
SequencedTestCategory,SimpleTestCategory) for sequence and discovery testing. - Optional ISO-TP wiring activated via
ConnectIsoTp(): anIsoTpTransportImplattached to each protocol object, a receive channel on the server for thePduTestCategory(0x4), and abort callbacks recorded on both nodes.
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:
- the client’s online state as the server sees it;
- the number of servers the client sees online;
- command acknowledgement timeouts.
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
context.Emplace<T>(args...)creates the fixture (returnsstd::shared_ptr<T>).context.Get<T>()retrieves it by reference (returnsT&).- Captured
shared_ptrin lambdas on fixture members must be avoided to prevent circular references that leak the fixture across scenarios.
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.