blog.hirnschall.net
home

Contents

Subscribe for New Projects

Motivation

If you have ever spent an afternoon chasing a CAN bug that turned out to be a wrong bit offset or a missing filter, you know how frustrating and time consuming it can be. The code compiles, the system runs, and yet the behavior is wrong in ways that are difficult to track.

The main problem is that working with CAN at a low level forces the user to manually manage details that are tedious, repetitive and error prone. Constructing messages requires bit packing into raw data buffers. Non standard field sizes such as 3 bit integers require manual masking and type casting. Filters must be configured correctly or messages will silently disappear. At the same time there is nothing preventing a node from sending or receiving messages it should never touch.

These issues are most often not really obvious when reading the code. The implementation looks fine, which makes debugging hard. Problems only show up during system/integration testing or on real hardware, where observability is limited and iteration is slow.

As the system grows, this approach does not scale. Adding or modifying a message requires updating multiple nodes and keeping them in sync. A small inconsistency between two nodes can lead to failures that are hard to detect and hard to reproduce.

This post presents a different approach. Instead of treating these problems as runtime concerns, we move them into compile time. By generating a type safe transport layer from a single CAN map, entire classes of bugs such as incorrect packing, invalid message usage, and missing handlers can be eliminated before the code ever runs.

The full implementation is available on GitHub.

Solution

The solution to this problem is to introduce a single source of truth for the entire CAN system: a CAN map. This map defines all messages, their structure, and which nodes are allowed to send and receive them. From this CAN map file, the complete transport layer is auto generated.

The generated transport layer sits between the application layer (user code) and the hardware abstraction layer (CAN driver). Its purpose is to remove all manual handling of message layout, filtering, and access control, replacing it with a type safe interface that is enforced at compile time.

For this to work well we have the following requirements for the generated code:

From the user's perspective, interaction with the system becomes straightforward.

Send:

To send a message, the user creates an instance of a generated message struct, assigns values to its fields, and passes it to a send function. The transport layer takes care of serialization and transmission.

Receive:

To receive messages, the user implements handler functions for the messages they are subscribed to. Incoming frames are automatically deserialized and dispatched to the correct handler. The application interacts only with typed data, never with raw byte buffers.

The result is a system where correctness is enforced by the type system, and where the complexity of CAN communication is contained entirely within generated code. Hard to debug runtime issues become compile-time errors.

Architecture

To understand where the transport layer fits into the system, it helps to look at the overall software architecture.

At the top sits the application layer. This is where the user implements the actual logic of the system. It operates purely on typed data structures and does not deal with CAN specifics such as message IDs, byte layouts, or filters.

Below the application layer is the transport layer. It is responsible for converting between the application level data structures and the underlying CAN representation. On one side, it exposes a clean and type safe interface to the application. On the other side, it interacts with the CAN driver using raw frames.

The transport layer handles several responsibilities. It serializes structured data into CAN frames and deserializes incoming frames back into typed messages. It enforces which messages a node is allowed to send or receive. It also configures hardware filters so that only relevant messages reach the software layer.

Below the transport layer sits the hardware abstraction layer which provides access to the CAN peripheral and related hardware features. The transport layer relies on this layer for transmitting and receiving frames but does not depend on its internal implementation.

At the bottom is the CAN bus itself, which connects all nodes in the system.

The structure described above is shown in fig. 1 below. It shows how the application layer interacts exclusively (for CAN) with the generated transport layer, which in turn interfaces with the hardware abstraction layer and the CAN bus.

CAN transport layer architecture diagramm
Figure 1: Software Architecture

CAN-Map

At the core of this approach is the CAN map. It defines the complete communication contract of the system in a single place. Every message, its structure, and the responsibilities of each node are described here, making it the single source from which all code is generated.

Implementing it is not hard as long as the structure is well-defined. For this project I decided to go with yaml but we could also switch to a database as the project gets more complex.

The key points the CAN-map has to include are

By explicitly listing senders and subscribers, the CAN map defines which nodes are allowed to interact with each message. This information is later used to enforce access restrictions at compile time.

A minimal example is shown below. In this configuration, the SensorNode publishes two messages while the ECU subscribes to them. From this single description, the generator can derive message types, serialization logic, node specific interfaces, and hardware filter configuration.

can: can0
version: 1

nodes:
  - ECU
  - SensorNode
  - Diagnosis

namespaces:
  - name: SensorNode
    messages:
      - name: StrainData
        id: 0x201
        dlc: 7
        subscribers:
          - ECU
        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 }

      - name: Temperature
        id: 0x202
        dlc: 2
        subscribers:
          - ECU
        fields:
          - { name: value, type: float, bits: 16, scale: 0.1 }

Once the CAN map is defined, there is no need to manually keep different parts of the system in sync. Any change to a message or node configuration is made in one place and updated automatically through code generation.

Message Structs

The message structs themselves are also not complicated. The CAN-map explicitly states all the information we need. Each message becomes a struct containing its fields, along with static metadata such as the message identifier and data length. The message names are included for debugging.

What we want to achieve here is that the user sees CAN messages as plain C++ structs. From his perspective, a CAN message now behaves like any other data structure. Fields can be read and written directly, without worrying about offsets, masks, or casting. Instead of constructing raw byte arrays, the user creates and manipulates strongly typed objects, and the system ensures that they are transmitted correctly.

Below are the generated structs for the example above.

namespace can0 {
    enum class Node : uint8_t {
        ECU,
        SensorNode,
        Diagnosis,
    };

    namespace SensorNode {

    struct StrainData {
        static constexpr uint16_t id  = 513;
        static constexpr uint8_t  dlc = 7;

    #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 bits
    };

    struct Temperature {
        static constexpr uint16_t id  = 514;
        static constexpr uint8_t  dlc = 2;

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

        float value; // packed: 16 bits
        static constexpr float value_scale = 0.1f;
    };

    } // namespace SensorNode
} // namespace can0

As we can see, each CAN-bus has its own namespace and so does each node.

Now that we have defined message structs, we need to ensure only these structs can be used when working with the transport layer. If any other type is used we want the compiler to throw an error. We can achieve this by using C++ 20 concepts. First, we register each generated struct such that CanMessage is true. For any other type CanMessage will be false. The generated functions can then use this concept to restrict their input to valid can messages. Using a wrong type will result in a compile-time error.

template<typename T>
struct IsCanMessage : std::false_type {};

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

template<>
struct IsCanMessage<can0::SensorNode::StrainData> : std::true_type {};

template<>
struct IsCanMessage<can0::SensorNode::Temperature> : std::true_type {};

Next, we define a list of types and a way to check if a type is contained in that list. This is done using C++ template parameter packs. TypeList is a list of types and TypeListContains checks if a type is contained in a TypeList. Both are evaluated at compile time.

Building on this, we define the allowed Rx and Tx messages of each node. For every node, the CAN map specifies which messages it is allowed to transmit and which it can receive. This information is encoded into two TypeLists contained in the NodeTraits struct.

At this point we can also start to add optional messages like Diagnosis::Request and Diagnosis::DebugResponse which add diagnosis functionality to the system without any user input.

This diagnosis can be used for many things like checking compatible versions or built in git hashes (using CMake) to get the current software version.

template<typename... Ts>
struct TypeList {};

template<typename List, typename T>
struct TypeListContains;

template<typename T>
struct TypeListContains<TypeList<>, T> : std::false_type {};

template<typename Head, typename... Tail, typename T>
struct TypeListContains<TypeList<Head, Tail...>, T>
    : std::conditional_t<
        std::is_same_v<Head, T>,
        std::true_type,
        TypeListContains<TypeList<Tail...>, T>> {};



template<can0::Node>
struct NodeTraits;

template<>
struct NodeTraits<can0::Node::ECU>
{
    using TxMessages = TypeList<
        
    >;

    using RxMessages = TypeList<
        SensorNode::StrainData,
        SensorNode::Temperature,
        Diagnosis::Request,
        Diagnosis::DebugResponse
    >;
};

template<>
struct NodeTraits<can0::Node::SensorNode>
{
    using TxMessages = TypeList<
        SensorNode::StrainData,
        SensorNode::Temperature
    >;

    using RxMessages = TypeList<
        Diagnosis::Request,
        Diagnosis::DebugResponse
    >;
};

The important detail here and also the reason this code looks unnecessarily complex is that everything is evaluated at compile time using templates. This is important as it prevents runtime/logic errors in the code.

Serialization and Bit Packing

Next, let's look at bit packing and serialization. What we need to do, is convert the message structs c++ type members into the message frames 8 bytes according to the CAN-map specification. Again, we will do so using templates.

As we have these templates specialized for each message type, we can ensure that the serialization is done correctly at compile time. We will therefore just take a look at the implementation of one message type.

template<CanMessage Message>
inline void serialize(const Message& ,uint8_t*);

template<CanMessage Message>
inline void deserialize(Message& , const uint8_t*);

template<>
inline void serialize(const can0::SensorNode::StrainData& msg, uint8_t* data)
{
    std::memset(data, 0, can0::SensorNode::StrainData::dlc);

    packSigned<0,16>(data, msg.fx);
    packSigned<16,16>(data, msg.fy);
    packSigned<32,16>(data, msg.fz);
    pack<48,1>(data, msg.valid);
}

template<>
inline void deserialize(can0::SensorNode::StrainData& msg, const uint8_t* data)
{
    msg.fx = unpackSigned<0,16>(data);
    msg.fy = unpackSigned<16,16>(data);
    msg.fz = unpackSigned<32,16>(data);
    msg.valid = unpack<48,1>(data);
}

The pack/unpack functions are done using templates to keep timing predictable. Nothing CAN-specific happens here, we just mask and shift the values into the correct byte positions. If you have worked with CAN before you will probably have written something similar. They are not auto-generated and you can swap in your own implementation without touching anything else. The signed variants are omitted here but can be found on GitHub.

template <size_t Offset, size_t Width>
inline void pack(uint8_t* data, uint32_t value)
{
    static_assert(Width > 0 && Width <= 32,
                  "Width must be between 1 and 32");

    constexpr uint32_t mask =
        (Width == 32) ? 0xFFFFFFFFu : ((1u << Width) - 1u);

    value &= mask;

    constexpr size_t byteOffset = Offset / 8;
    constexpr size_t bitOffset  = Offset % 8;

    uint64_t tmp = 0;
    std::memcpy(&tmp, data + byteOffset, sizeof(uint64_t));

    tmp &= ~(static_cast<uint64_t>(mask) << bitOffset);
    tmp |=  (static_cast<uint64_t>(value) << bitOffset);

    std::memcpy(data + byteOffset, &tmp, sizeof(uint64_t));
}

template <size_t Offset, size_t Width>
inline uint32_t unpack(const uint8_t* data)
{
    static_assert(Width > 0 && Width <= 32,
                  "Width must be between 1 and 32");

    constexpr uint32_t mask =
        (Width == 32) ? 0xFFFFFFFFu : ((1u << Width) - 1u);

    constexpr size_t byteOffset = Offset / 8;
    constexpr size_t bitOffset  = Offset % 8;

    uint64_t tmp = 0;
    std::memcpy(&tmp, data + byteOffset, sizeof(uint64_t));

    return static_cast<uint32_t>((tmp >> bitOffset) & mask);
}

CAN Manager Base Class

Now that we have all the necessary functions implemented, we can create a CAN Manager base class. This class will act as a base class for managing CAN messages. Each node will get its own CAN Manager that inherits from this base class.

This is where CRTP (Curiously Recurring Template Pattern) comes in. The base class takes the derived class as a template parameter, allowing it to cast itself to the derived type and call the correct message handler directly. No virtual functions, no vtable lookups, everything resolved at compile time. The key benefit here is that a missing handler becomes a compiler error rather than a silent fallback to a base class default.

The base class is built on top of modm, an open-source C++ Hardware Abstraction Layer and driver library for embedded targets (like e.g. stm). modm abstracts hardware peripherals like FDCAN, GPIO, and timers behind consistent C++ interfaces, which is what the FdcanPeriph, RxPin, and TxPin template parameters refer to throughout this section. If you're using a different HAL, the base class is the only thing that needs adapting; the generator, the CAN map, and all application code stay the same.

Note that this implementation has comments and some modm specific code removed for clarity and readability. The full code is on GitHub.

Let's start with the template parameter list, the constructor, destructor, copy constructor, assignment operator, and the hardware initialization.

As mentioned we will use CRTP so the last template argument is the derived class. The constructor initializes the FDCAN device from modm and configures the filters.

One interesting aspect is instance. It is used as the modm CAN error callback only works for static instances.

template<
    Node ThisNode,
    typename FdcanPeriph,
    typename RxPin,
    typename TxPin,
    uint32_t NominalBitrate,
    uint32_t DataBitrate,
    modm::percent_t Tolerance,
    typename CanManagerDerived  // For CRTP, not used in base class
>
class CanManagerBase{
public:
    CanManagerBase(uint32_t interruptPriority = INTERRUPT_PRIORITY_DEFAULT,
        typename FdcanPeriph::Mode startupMode = FdcanPeriph::Mode::Normal, 
        bool overwriteOnOverrun = OVERWRITE_ON_OVERRUN_DEFAULT){
        instance() = this;  // Register instance for static callback access
        initHardware(interruptPriority, startupMode, overwriteOnOverrun);
        configureFilters();
    }

    ~CanManagerBase(){
        // Unregister on destruction
        if (instance() == this) {
            instance() = nullptr;
        }
    }

    //Prevent copying (only one instance per template instantiation)
    CanManagerBase(const CanManagerBase&) = delete;
    CanManagerBase& operator=(const CanManagerBase&) = delete;

    void initHardware(uint32_t 	interruptPriority,
            typename FdcanPeriph::Mode startupMode, 
            bool overwriteOnOverrun){
        // Connect GPIO pins to FDCAN peripheral
        
        // Initialize FDCAN peripheral with template parameters
        
        // Enable CAN-FD mode
        
        // Enable automatic retransmission

        //set callback for error handling, 
        // use static function to call member function since modm requires a function pointer
        FdcanPeriph::setErrorCallback(&CanManagerBase::errorCallbackStatic);
    }

Send

send checks the node traits template defined earlier to see if ThisNode is a valid sender for the message that we want to send. It does so using the TypeListContains template. If CANMAP_INCLUDE_MESSAGE_NAMES is defined, we can raise a more descriptive error message.

Using static_assert ensures we get a compile-time error when we try to send a message that is not listed in the node traits for ThisNode.

The transmit function itself tries to send the message with retry counter if the CAN device is ready.

    template<CanMessage Message>
    void send(const Message& msg)
    {
        #if defined(CANMAP_INCLUDE_MESSAGE_NAMES)
            static_assert(
                TypeListContains<
                    typename NodeTraits<ThisNode>::TxMessages,
                    Message>::value,
                "This node is not allowed to transmit " + std::string(Message::name)); 
        #else
            static_assert(
                TypeListContains<
                    typename NodeTraits<ThisNode>::TxMessages,
                    Message>::value,
                "This node is not allowed to transmit this message");
        #endif

        modm::can::Message frame(
            static_cast<uint32_t>(Message::id),
            Message::dlc);

        //configure for e.g. CAN-FD     

        serialize(msg, frame.data);

        transmit(frame);
    }

    void transmit(const modm::can::Message& frame){
        uint8_t retry_count = 0;

        while (retry_count < CAN_RETRY_CNT_DEFAULT){
            // if CAN is ready, send with retry counter
            modm::delay_us(1);
            ++retry_count;
        }

        // Failed after all retries
        ++error_counters_.tx_timeout;
    }

Receive

To receive messages the user calls processMessages. If a message is available on the CAN, handleFrame is called which in turn calls dispatch with the frame and the current nodes valid Rx messages as a TypeList.

dispatch iterates through the TypeList of valid Rx messages for this node (resolved at compile time, dispatched at runtime). If the received message ID matches, it deserializes the frame into the correct struct and calls the onMessage handler in the derived class via CRTP. Since the CAN frame is now a message struct, onMessage can use the struct type as a template parameter.

If the current node is not subscribed to the received message's ID, it will call the onUnknown implementation.

    uint8_t processMessages(size_t maxNMessages = MAX_NUMBER_OF_MSG_TO_PROCESS_DEFAULT)
    {
        uint8_t processed = 0;
        modm::can::Message rx_msg;
        
        // Read messages from FIFO
        // call handleFrame(rx_msg) for each message
        // increment processed
        
        return processed;
    }

    void handleFrame(const modm::can::Message& frame)
    {
        dispatch(typename NodeTraits<ThisNode>::RxMessages{},
            frame);
    }

    template<typename Head, typename... Tail>
    void dispatch(TypeList<Head, Tail...>,
                  const modm::can::Message& frame){
        if (frame.getIdentifier() == Head::id)
        {
            Head msg{};
            deserialize(msg, frame.data);
            //use CRTP to call the derived class's onMessage specialization
            static_cast<CanManagerDerived*>(this)->onMessage(msg);
            return;
        }

        if constexpr (sizeof...(Tail) > 0)
        {
            dispatch(TypeList<Tail...>{}, frame);
        }
        else
        {
            onUnknown(frame);
        }
    }

    template<typename Message>
    void onMessage(const Message& msg); //needs to be implemented by user. specialization on a per message level is advised. compile time error if not implemented.

    void onUnknown(const modm::can::Message& frame){
        ++error_counters_.unknown_id;
        (void)frame;
    }

Filters

CAN controllers use hardware filters to decide which message IDs are passed up to software and which are discarded at the peripheral level. Without them, every node would receive every message on the bus and have to sort it out in software, something that we want to avoid as it can be resource-intensive. Configuring them manually is a common source of bugs: a missing filter means a subscribed message is silently dropped, while an overly broad filter lets in messages the node has no handler for. Here the filters are derived automatically from the node's RxMessages list, so they are always consistent with what the node is actually subscribed to. We use a TypeList to call configureSingleFilter iteratively for each message ID.

    void configureFilters()
    {
        filter_index_ = 0;
        configureFilterList(
            typename NodeTraits<ThisNode>::RxMessages{}
        );
    }

    // Base case: empty list
    template<typename List>
    void configureFilterList(List)
    {
        // No messages to filter
    }

    // Recursive case: process head, then tail
    template<typename Head, typename... Tail>
    void configureFilterList(TypeList<Head, Tail...>)
    {
        configureSingleFilter(Head::id);

        if constexpr (sizeof...(Tail) > 0)
        {
            configureFilterList(TypeList<Tail...>{});
        }
    }

    void configureSingleFilter(uint16_t id)
    {
        // Configure FDCAN standard ID filter
        // All messages go to FIFO0 with exact match (0x7FF mask)
        FdcanPeriph::setStandardFilter(
            filter_index_++,
            FdcanPeriph::FilterConfig::Fifo0,
            modm::can::StandardIdentifier(id),
            modm::can::StandardMask(0x7FF)  // Exact match
        );
    }

    uint8_t filter_index_{0};

Error Handling and Diagnostics

The rest of the code is error handling/counting, diagnostics, and several helper functions to access the FDCAN device's member functions/variables. The user can also provide an error callback that is called whenever a CAN error occurs. This is optional but can be useful for debugging. This code is modm specific and can be adapted or extended as needed.

    struct ErrorCounters {
        uint32_t rx_overrun{0};
        uint32_t tx_timeout{0};
        uint32_t unknown_id{0};
        uint32_t error_warning{0};
        uint32_t error_passive{0};
        uint32_t bus_off{0};
    };

    // Static callback function - unique per template instantiation
    static void errorCallbackStatic()
    {
        if (instance()) {
            instance()->handleModmCanErrorCallback();
        }
    }

    static CanManagerBase*& instance(){
        static CanManagerBase* inst = nullptr;
        return inst;
    }

    const ErrorCounters& getErrorCounters() const {
        return error_counters_;
    }

    void resetErrorCounters() {
        error_counters_ = {};
    }

    bool isMessageAvailable() const{
        return FdcanPeriph::isMessageAvailable();
    }

    inline typename FdcanPeriph::BusState getMODMBusState() const{
        return FdcanPeriph::getBusState();
    }

    inline uint8_t getMODMTransmitErrorCounter() const{
        return FdcanPeriph::getTxErrorCounter();
    }

    inline uint8_t getMODMReceiveErrorCounter() const{
        return FdcanPeriph::getRxErrorCounter();
    }

    void handleModmCanErrorCallback()
    {
        // get error state from modm, increment ErrorCounters
        // add call to user-defined callback if needed
    }

    ErrorCounters error_counters_{};
};

Node CAN Manager

Each node gets its own CAN manager. This way the user can simply use e.g. "can0::ECU" when programming the ECU.

So, let's take a look at how this works for the ECU as an example. Again, comments are removed for clarity and readability.

namespace can0 {
	template<typename FdcanPeriph, typename RxPin, typename TxPin, typename AppState, uint32_t NominalBitrate=CAN_BITRATE_DEFAULT, uint32_t DataBitrate=CAN_FD_DATA_BITRATE_DEFAULT, modm::percent_t Tolerance=TOLERANCE_DEFAULT>
	class ECU : public CanManagerBase<
	    Node::ECU,
	    FdcanPeriph,
	    RxPin,
	    TxPin,
	    NominalBitrate,
	    DataBitrate,
	    Tolerance,
	    ECU<FdcanPeriph, RxPin, TxPin, AppState, NominalBitrate, DataBitrate, Tolerance>  // CRTP
	>
	{
	public:
	    using Base = CanManagerBase<
	        Node::ECU,
	        FdcanPeriph,
	        RxPin,
	        TxPin,
	        NominalBitrate,
	        DataBitrate,
	        Tolerance,
	        ECU<FdcanPeriph, RxPin, TxPin, AppState, NominalBitrate, DataBitrate, Tolerance>
	    >;

	    ECU(AppState& app_state, uint32_t interruptPriority = INTERRUPT_PRIORITY_DEFAULT) : Base(interruptPriority), app_state_(app_state){}

	    // Message handlers - implement these in your .cpp file
	    void onMessage(const SensorNode::StrainData& msg);
	    void onMessage(const SensorNode::Temperature& msg);
	    void onMessage(const Diagnosis::Request& msg);
	    void onMessage(const Diagnosis::DebugResponse& msg);

	private:
	    AppState& app_state_;
	};
} // namespace can0

This code is much simpler than the base class. Its only job is to provide type-safe message handling for the ECU node. For this we declare the message handlers for each message type the Node can receive. If the user does not implement a handler for a particular message type, the compiler will generate an error. This way the user cannot forget to handle a message the Node is supposed to handle.

The class has several modm specific template parameters that allow for hardware initialization. They could be removed if not needed.

The node CAN manager also stores a pointer to the AppState. This way we can access and modify the application state within its message handlers and access the results from inside the main loop (also through the AppState pointer).

All in all, the user just needs to instantiate a node CAN manager for the node he is currently programming and implement the necessary message handlers. It is not possible to receive a message the node is not subscribed to.

What the User Actually Does

We now give a short example of how the user can use the CAN transport layer. First, define the application state and instantiate the CAN manager for this node:

    // AppState holds the live application state the handlers need to read from and write to
struct AppState {
    uint32_t strain_event_counter{0};
    float    temperature_threshold{80.0f};  // set from main loop
    bool    overtemperature{false};
};

AppState app_state;

can0::ECU<Fdcan1, GpioA11, GpioA12, AppState> can_manager(app_state);

Next, the user implements the message handlers for each message the ECU is subscribed to. Here he can work with the message struct as discussed, using msg.value or msg.valid without caring about packing, bit sizes, etc.

    void can0::ECU<Fdcan1, GpioA11, GpioA12, AppState>::onMessage(const can0::SensorNode::StrainData& msg)
{
    if (!msg.valid) return;

    // handler does real work, uses and updates app state as needed
    ++app_state_.strain_event_counter;
}

void can0::ECU<Fdcan1, GpioA11, GpioA12, AppState>::onMessage(const can0::SensorNode::Temperature& msg)
{
    // check against a threshold that may have been updated from the main loop
    app_state_.overtemperature = msg.value > app_state_.temperature_threshold;
}

Finally, the user calls processMessages() in the main loop to process incoming messages and access the updated app state.

    while (true)
{
    can_manager.processMessages();

    if (app_state.overtemperature)
    {
        // react - this flag was set from inside onMessage
    }

    // main loop can also write into app_state, handlers will see the updated values
    app_state.temperature_threshold = compute_threshold();
}

If the user forgets to implement a message handler, the compiler will catch it!

Code Generation

The generator itself is a straightforward Python script that reads the YAML CAN-map and outputs the C++ headers described above. It is not particularly interesting on its own. The interesting thing is what it produces, not how it produces it.

What matters is that the CAN-map is the single source of truth. Any time a message is added, removed, or modified, regenerating the headers guarantees that every node's message structs, serialization, node traits, and hardware filters are all updated consistently. There is no manual step where one node gets updated and another does not.

The generator also validates the CAN-map before generating any code - catching issues like DLC mismatches, unknown subscribers, or missing fields before they ever reach the compiler.

Besides the C++ headers, the generator also produces a DBC file for each CAN bus, which can be loaded directly into tools like SavvyCAN for monitoring and debugging on the PC side.

The full generator and all generated output for the example above can be found on GitHub.

Conclusion

What we end up with is a CAN transport layer where wrong message IDs, incorrect packing, missing handlers, and misconfigured filters are all compile-time errors rather than runtime surprises. The YAML file is the single source of truth. Add a message, regenerate, and every node is updated consistently.

The main tradeoff worth being aware of is that the heavy use of templates increases compile times, and the generated code is tied to the modm HAL in its current form. Porting to a different HAL requires adapting the base class but not the generator or the application code. Other than that, it is hard to argue that manually managing all of this is preferable. The generated code does strictly more, with fewer opportunities for mistakes.

Get Notified of New Articles

Subscribe to get notified about new projects. Our Privacy Policy applies.

Downloads

Sebastian Hirnschall
Article by: Sebastian Hirnschall
Updated: 01.03.2026

License

The content published on this page (with exceptions) is published under the CC Attribution-NonCommercial 3.0 Unported License. You are free to share on commercial blogs/websites as long as you link to this page and do not sell material found on blog.hirnschall.net/* or products with said material.