blog.hirnschall.net
home

CNC Mill Concept: Software Architecture & CAN

Contents

Subscribe for New Projects

Introduction

This post is part of the CNC Mill Concept hub. 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.

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

Sensor Framework

Shared across all sensor PCBs.

Responsibilities:

sensor.update();

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

Transport Layer

CAN manager:

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:

Implementation is deferred.

Low-Level Drivers

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:

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:

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:

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

Responsibilities

Raw frame APIs must remain private.

Determinism

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()
Figure 2: CanManager software stack: user application works with CAN map structs via send() and processMessages()

Layer Descriptions

User Application Layer

CanManager (Transport Layer)

Generated Code Layer

modm HAL Layer

Hardware Layer

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:

What is hidden:

Diagnosis

Memory Strategy

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:

C++ enforces:

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:

Optional:

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

Get Notified of New Articles

Subscribe to get notified about new projects. Our Privacy Policy applies.
Sebastian Hirnschall
Article by: Sebastian Hirnschall
Updated: 19.05.2026

License

This project (with exceptions) is published under the CC Attribution-ShareAlike 4.0 International License.