# [CNC Mill Concept: Software Architecture & CAN](https://blog.hirnschall.net/cnc-mill-concept/software-architecture-can/)

author: [Sebastian Hirnschall](https://blog.hirnschall.net/about/)

meta description: Firmware for distributed sensor nodes on STM32G4: YAML-driven CAN map code generation, type-safe CanManager transport, deterministic serialization, and DBC export.

meta title: CNC Mill Concept — Software Architecture & CAN

date published: 25.03.2026 (DD.MM.YYYY format)
date last modified: 19.05.2026 (DD.MM.YYYY format)

---

Introduction
------------

This post is part of the [CNC Mill Concept hub](https://blog.hirnschall.net/cnc-mill-concept/). It covers the firmware architecture for the distributed sensor nodes: the design invariants, the layered software stack, the YAML-driven CAN map and code generation workflow, the type-safe CanManager transport layer, and the testing strategy.

The type-safe CAN transport layer concept described here has already been implemented in the [CAN transport layer post](https://blog.hirnschall.net/can-transport-layer/).

Note: this section is work in progress. Code snippets are conceptual and for reference only.

Design Invariants
-----------------

1. Sensors return CAN message types defined in the CAN map.
2. Only CAN-map messages may be transmitted.
3. Any CAN map change affecting message layout must break compilation.
4. No raw CAN frame sending.
5. Strict separation of sensor acquisition and transport.
6. Prefer static allocation for fixed-size objects.
7. No dynamic allocation in real-time paths.
8. CAN map is the single source of truth for message layout.
9. Serialization must be deterministic and compiler-independent.

Software Stack
--------------

Fig. 1 shows the full software stack from user application down to hardware.

![Software stack: application layer (control logic, sensor framework, diagnosis), transport layer (CanManager, generated CAN map), low-level modm drivers, and STM32G4 hardware.](https://blog.hirnschall.net/cnc-mill-concept/software-architecture-can/resources/img/software-stack.jpg)


Figure 1: Software stack: application layer (control logic, sensor framework, diagnosis), transport layer (CanManager, generated CAN map), low-level modm drivers, and STM32G4 hardware.

### Application Layer

* Control logic
* State estimation
* Supervision
* Diagnosis handling
* No knowledge of serialization

### Sensor Framework

Shared across all sensor PCBs.

Responsibilities:

* Call `init()`
* Periodically call `update()`
* Poll `hasDataChanged()`
* Forward typed CAN message to CAN manager
* Node supervision
* Heartbeat handling

```
sensor.update();

if(sensor.hasDataChanged())
{
    auto msg = sensor.getProcessed();
    canManager.send(msg);
}
```

### Transport Layer

CAN manager:

* Uses `Message::id`
* Uses `Message::dlc`
* Calls generated `serialize()`
* Calls generated `deserialize()`
* Never accesses individual fields
* Never performs bit shifting
* Never constructs payload manually

### Node-Specific CAN Manager

The CAN manager will later be templated on node identity:

```
using Node = Nodes::ECU;

CanManager<Node> canManager;
```

Node identity will be defined in the CAN map YAML and code-generated. Future specialization will allow:

* Compile-time filter configuration
* Compile-time dispatch selection
* Elimination of unused message handling
* No runtime branching based on node type

Implementation is deferred.

### Low-Level Drivers

* Hardware abstraction via modm
* FDCAN peripheral configuration
* DMA / timers / SPI access

Compile-Time Enforcement
------------------------

Only message types registered in the CAN map may be transmitted.

```
template<typename T>
concept CanMessage = IsCanMessage<T>::value;
```

CAN manager API:

```
template<CanMessage Message>
void send(const Message& msg);
```

If a message is not registered, compilation fails. Any change to field layout in YAML modifies the generated header and forces downstream recompilation.

CAN Map Specification
---------------------

The CAN map is the single source of truth for all messages on the CAN-FD bus. Messages are declared in YAML and code is generated. Messages are grouped by logical node namespace.

### YAML Specification

```
can: can0
version: 1

namespaces:
  - name: SensorNode
    messages:
      - name: StrainData
        id: 0x201
        dlc: 8
        fields:
          - { name: fx,    type: int32,  bits: 16 }
          - { name: fy,    type: int32,  bits: 16 }
          - { name: fz,    type: int32,  bits: 16 }
          - { name: valid, type: uint8, bits: 1 }
```

The YAML file defines: output header name, version, namespaces, message ID, DLC, field names, bit width, type, optional scale (float), and optional enum values.

### Strict DLC Rule

Total bits are tightly packed. Required bytes:

```
required_bytes = ceil(total_bits / 8)
```

Validation rule:

```
required_bytes == dlc
```

Unused bits inside the final byte are allowed. Unused bytes are not allowed.

### Nodes and Subscribers

The CAN map defines all nodes explicitly. YAML must contain a `nodes` list and a `subscribers` list per message.

```
nodes:
  - ECU
  - SensorNode
  - Diagnosis

namespaces:
  - name: SensorNode
    messages:
      - name: StrainData
        id: 0x201
        dlc: 7
        subscribers:
          - ECU
          - Diagnosis
```

Rules:

* `subscribers` is mandatory
* Publisher is NOT implicitly a subscriber
* If no subscribers are defined, generation fails
* Subscriber names must exist in `nodes` list
* Generation validates spelling and consistency

Future generator extension will use this to produce node-specific filter tables, dispatch specialization, and compile-time elimination of unused message handlers.

### Generated C++ Message

Generator produces `can0.hpp`:

```
namespace SensorNode {

struct StrainData {

    static constexpr uint16_t id  = 0x201;
    static constexpr uint8_t  dlc = 8;

#ifdef CANMAP_INCLUDE_MESSAGE_NAMES
    static constexpr const char* name = "SensorNode::StrainData";
#endif

    int32_t fx;    // packed: 16 bits
    int32_t fy;    // packed: 16 bits
    int32_t fz;    // packed: 16 bits
    uint8_t valid; // packed: 1 bit
};

}
```

The struct represents physical values only. Packing is handled separately.

### Scaled Float Support

YAML:

```
- name: temperature
  type: float
  bits: 16
  scale: 0.1
```

Generated struct:

```
float temperature; // packed: 16 bits, scale: 0.1
static constexpr float temperature_scale = 0.1f;
```

Serialization:

```
int32_t raw =
    static_cast<int32_t>(
        std::round(msg.temperature / temperature_scale));

packSigned<offset,bits>(data, raw);
```

Float is never transmitted as IEEE-754. It is transported as a scaled integer.

### Enum Support

YAML:

```
- name: state
  type: uint8
  bits: 3
  enum:
    0: Idle
    1: Running
    2: Error
```

Generated struct:

```
enum class State : uint32_t {
    Idle = 0,
    Running = 1,
    Error = 2,
};

State state;
```

Serialization uses the underlying integer value.

### Serialization Model

Generator produces `serialize()` and `deserialize()`:

```
template<>
inline void serialize(const Message& msg, uint8_t* data);

template<>
inline void deserialize(Message& msg, const uint8_t* data);
```

All packing uses `pack_utils.hpp`. Properties:

* Width ≤ 32 bits per field
* Offset + Width ≤ 64
* Little-endian deterministic layout
* No `reinterpret_cast`
* No compiler bitfields

### DBC Generation

Generator also produces `can0.dbc`, including message definitions, signal scaling, enum value mappings, and a CAN map version comment. Used for SavvyCAN or other tools.

CAN Manager
-----------

The CAN manager is the transport layer between application logic and FDCAN. It is strictly transport-only.

It does not:

* Construct payloads
* Interpret physical values
* Perform bit shifting
* Access individual fields

### Transmit Path

Public API:

```
template<CanMessage Message>
void send(const Message& msg);
```

Transmit sequence:

1. Compile-time check: `Message` satisfies `CanMessage` concept.
2. Allocate modm CAN frame.
3. Set frame ID to `Message::id`.
4. Set frame length to `Message::dlc`.
5. Call generated `serialize(msg, frame.data)`.
6. Submit frame to FDCAN driver.

```
template<CanMessage Message>
void CanManager::send(const Message& msg)
{
    modm::can::Message frame(
        static_cast<uint32_t>(Message::id),
        Message::dlc);

    frame.setExtended(false);

    serialize(msg, frame.data);

    transmit(frame);
}
```

The CAN manager never touches payload fields directly.

### Receive Path

Reception is type-safe and compile-time constrained:

1. FDCAN interrupt receives raw frame.
2. CAN manager matches frame ID against generated message list.
3. Matching message type is instantiated.
4. `deserialize()` reconstructs typed message.
5. Typed message is dispatched.

Future implementation will use compile-time node specialization:

```
template<typename Node>
struct NodeDispatch;

template<>
struct NodeDispatch<Nodes::ECU>
{
    static void handle(const SensorNode::StrainData& msg);
    static void handle(const Diagnosis::Request& msg);
};
```

CAN manager will forward deserialized messages to:

```
NodeDispatch<Node>::handle(msg);
```

This avoids runtime switch statements, dynamic dispatch, and branch-heavy receive logic. Only messages subscribed by the node will generate handlers. No raw buffer handling is exposed to the application layer.

### Reception Filtering

* Only CAN IDs defined in the CAN map are accepted
* Unknown IDs:
  + ignored
  + or counted as diagnostic error
  + never forwarded as raw frame

### Responsibilities

* Initialize FDCAN
* Configure filters
* Handle TX queue
* Handle RX interrupt
* Perform serialize/deserialize
* Monitor bus state (error passive / bus-off)
* Provide error counters for diagnosis

Raw frame APIs must remain private.

### Determinism

* No dynamic allocation in send/receive path
* Bounded execution time
* No blocking in interrupt context
* No protocol logic inside CAN manager

CanManager Implementation
-------------------------

### Design Goals

1. Complete abstraction of FDCAN hardware from application code.
2. Zero-cost compile-time type checking of message permissions.
3. Automatic hardware initialization via template parameters.
4. Automatic filter configuration from CAN map metadata.
5. No dynamic allocation in transmission or reception paths.
6. Deterministic bounded execution time.
7. Separation of transport logic from application logic.

### Software Stack

Fig. 2 shows the CanManager stack from user application down to hardware, with the YAML-to-generated-code flow and the send/receive data flows.

![CanManager software stack: user application works with CAN map structs via send() and processMessages()](https://blog.hirnschall.net/cnc-mill-concept/software-architecture-can/resources/img/can-impl-software-stack.jpg)


Figure 2: CanManager software stack: user application works with CAN map structs via send() and processMessages()

### Layer Descriptions

#### User Application Layer

* Direct field access: `msg.fx = 123`
* Type-safe API: `can.send(msg)`
* Automatic message processing: `can.processMessages()`
* No bit manipulation required
* No hardware knowledge needed

#### CanManager (Transport Layer)

* **send<Message>(msg)** — type-checked message transmission with automatic serialization
* **processMessages()** — polls hardware FIFOs, deserializes, and dispatches to handlers
* **Constructor** — automatic hardware initialization and filter configuration

#### Generated Code Layer

* **can0.hpp** — message struct definitions with metadata
* **can0\_serialize.hpp** — deterministic pack/unpack functions
* **NodeTraits** — compile-time permissions (TxMessages, RxMessages)

#### modm HAL Layer

* FDCAN peripheral drivers
* GPIO configuration
* Interrupt handling
* Platform-specific implementations

#### Hardware Layer

* STM32G4 FDCAN peripheral
* CAN bus interface
* 1 Mbit/s nominal bitrate
* 5 Mbit/s data phase (CAN-FD)

### Data Flows

#### Send Flow (TX)

1. User creates message struct
2. Calls `can.send(msg)`
3. CanManager checks compile-time permissions
4. Automatic serialization via generated code
5. modm transmits frame
6. Hardware sends to CAN bus

#### Receive Flow (RX)

1. CAN bus receives frame
2. Hardware stores in RX FIFO
3. User calls `can.processMessages()`
4. CanManager polls FIFO
5. Matches ID to message type
6. Automatic deserialization
7. Calls user's `onMessage(msg)` handler

#### Initialization Flow

1. GPIO pin connection
2. FDCAN peripheral initialization
3. CAN-FD mode configuration
4. Reads RxMessages from NodeTraits
5. Configures hardware filters automatically

### Key Abstraction Principle

What the user sees:

* CAN map structs (`SensorNode::StrainData`)
* Normal C++ types (`int32_t`, `float`, `uint8_t`)
* Simple API (`send()`, `processMessages()`)

What is hidden:

* `modm::can::Message`
* Bit shifting and masking
* Hardware register access
* Filter configuration
* Serialization logic

Diagnosis
---------

* Diagnosis messages are part of the CAN map
* Design principles:
  + central controller requests diagnostic data
  + no ad-hoc diagnostic frames
  + deterministic bounded execution
  + no dynamic allocation in real-time path
* Version reporting:
  + firmware embeds Git commit hash
  + CAN map version embedded
  + board version embedded
  + returned via diagnosis request

Memory Strategy
---------------

* Prefer static allocation
* No dynamic allocation in real-time paths
* Heap usage allowed during initialization
* Allocation failures must be handled explicitly

Software Tests
--------------

Testing is mandatory for serialization correctness and protocol stability. Testing is split into compile-time validation, host-side unit tests, and integration tests.

### Compile-Time Validation

The generator enforces:

* Unique CAN IDs
* Strict DLC rule (`ceil(bits/8) == dlc`)
* Enum value range validity
* Float requires scale

C++ enforces:

* `CanMessage` concept restriction
* Message registration
* Struct regeneration on CAN map change

Any change to YAML must trigger full recompilation.

### Serialization Unit Tests (Host)

Host-side tests verify bit packing correctness, bit offsets, signed extension correctness, scaled float conversion, enum conversion, boundary conditions, and zero initialization of unused bits.

```
Message msg{};
msg.fx = 123;

uint8_t buffer[Message::dlc];
serialize(msg, buffer);

Message decoded{};
deserialize(decoded, buffer);

assert(decoded.fx == msg.fx);
```

Round-trip serialization must be lossless within scaling precision.

### Edge Case Tests

For each field: minimum value, maximum value, zero, negative (if signed), and float rounding boundary. Float tests must verify:

```
abs(original - decoded) <= scale
```

### Integration Tests

Integration test stub verifies:

* Message ID matches expected
* DLC matches expected
* Serialization matches DBC definition
* No padding beyond required bytes
* CAN manager rejects non-registered types

Optional:

* Loopback FDCAN test
* Stress test at full bus rate
* Bus-off recovery behavior

### Regression Safety

The CAN map is versioned. Any change to bit width, field order, scaling, or enum definition changes generated headers and forces rebuild. Host tests must run in CI before deployment.

### Test Philosophy

* Deterministic serialization
* No undefined behavior
* No compiler-dependent layout
* Explicit validation of protocol contract