Actors with CMSIS OS in Cpp

2024 06 03 head

The actor library defines the actor abstraction with mailboxes and concrete implementation for various target platforms.

The CMSIS-RTOS v2 platform provides an actor implementation for all realtime kernels supporting the CMSIS API. The API is under the stewardship of the ARM company.

Any kernel with a CMSIS-RTOS v2 compatible layer is eligible.

STM provides the CMSIS integration with freeRTOS for all STM32 microcontrollers.

Library

The library defines key abstractions as abstract classes [1] [2].

The implementation for a target platform defines the concrete classes for all abstract entities below.

actors

Recipes

Define Message Structure

We advocate the following message structure:

struct Data;                                                    (1)

struct ActorData {                                              (2)
    int msgId;
};

typedef std::variant<ActorData> Variants;                       (3)

struct Data {                                                   (4)
    Variants variants;

    ActorData* data() {                                         (5)
        return std::get_if<ActorData>(&variants);
    };
};

typedef Message<Data> MyMessage;                                (6)
1 Forward declaration of the structure containing all message variants.
2 Define the payload for the actors of your application. You can define one or multiple payload structures.
3 Create the variant with all payloads to prove a type checked union abstraction.
4 Define the forward struct declaration. This approach is necessary to satisfy the C++ compiler.
5 Optionally, you can provide helper methods to access a specific payload type with runtime checks enabled.
6 Optionally, you can define a type declaration for your messages with the defined payload.

Do not use smart pointers in your message definitions.

CMSIS is C based and will wreak havoc on the smart pointer contract.

Use only records, variants, and primitive types. Consider using a message pool to optimize memory usage and to eliminate heap allocations.

Define Your Actors

MyActorCmsis::MyActorCmsis(const char* name, int queueSize,
                            MessagePool<Data>& pool)
    : ActorCmsis<Data>(name, queueSize, threadAttributes), _pool{pool}  {
private:
    MessagePool<Data>& _pool;
}

bool MyActorCmsis::processMsg(Message<Data>* msg) {                            (1)
    bool continues = true;
    Variants variants = msg->data().data;
    if (const ActorData* data = std::get_if<ActorData>(&variants)) {           (2)
        switch (data->cmd) {                                                   (3)
            case ActorData::ACTOR_COMMUNICATION:
                std::cout << "received message " << data->msgId
                            << " from " << data->sender->name() << " to "
                            << data->receiver->name()
                            << "[[" << payload << "]]" << std::endl;
                continues = (data->msgId < 20);
                Actor<Data>::send(*data->sender,
                        build({ActorData::ACTOR_COMMUNICATION,
                        data->msgId + 1, payload, data->receiver, data->sender}));
                break;
            ...
        }
    }
    _pool.release(msg);                                                        (4)
    return continues;
};
1 The body of your actor class responsible for the process received messages. You do not need to handle the communication with CMSIS or how the actor communicates with the CMSIS thread. The C++ implementation takes care of these quirks.
2 Retrieve the payload variant you are interested in and check if it is available.
3 Process the message payload
4 Release the message back to the pool.

The actor can still publish a programmatic interface to its users.

The services are provided as regular public methods. The constraint is that they should not have a return value.

Each service method is implemented as the creation of a command message and sent to the actor mailbox. The command message creation only uses local variables and therefore does not need to be protected with synchronization primitives.

The implementation of the actor is slightly more expensive. The users can use the actor as a regular class and do not have to bother with filling messages and sending them.

Implementation Details

A CMSIS actor is shadowed with a CMSIS task. A CMSIS task is an opaque C construct in the kernel space [1].

A CMSIS concurrent queue is shadowed with a CMSIS message queue. A CMIS message queue is an opaque C construct in the kernel space. We are using a message pool, the CMSIS message queue only stores 32-bit pointers.

actor-msg-sequence

The connection between the concurrent queue to the underlying CMSIS C queue is simply done. The CMSIS queue is created and the reference identifier in the C++ is stored into the concurrent queue.

When a new C++ actor is created, it must be connected to the underlying CMSIS C thread. This approach does the trick.

class ActorCmsis {
        /**
         * Synthetic method to connect the C++ class with the CMSIS OS thread.
         * @param self pointer to the class instance
         */
        static void run(void* self);                                           (1)

        /**
         * Process the next received message. Overwrite the method to implement
         * the business logic of your actor. The returned message can be nullptr
         * if the waitDelay is set to a value bigger than 0.
         * @param  msg message to process
         * @return flag indicating if the processing should continue
         */
        virtual bool processMsg(Message<T>* msg) = 0;
}

template<typename T>
ActorCmsis<T>::ActorCmsis(const char* name, int queueSize,
    osThreadAttr_t& attributes, MsgPoolCmsis<T>& pool)
        : Actor<T>(name, pool), _queue{queueSize} {
    strncpy(attributes.name, name, Actor<T>::ACTOR_NAME_SIZE);
    _task = osThreadNew(&ActorCmsis<T>::run, this, &attributes);               (2)
}

    template<typename T>
    void ActorCmsis<T>::run(void* self) {
        ActorCmsis* instance = reinterpret_cast<ActorCmsis*>(self);            (3)
        instance->runBody();                                                   (4)
    }

    template<typename T>
    void ActorCmsis<T>::runBody() {                                            (5)
        bool continues = true;
        while (continues) {
            Message<T>* msg = message();
            continues = processMsg(msg);                                       (6)
        }
    }
1 Declare a static function which will be executed by the newly created CMSIS thread
2 Create the CMSIS thread with the static method run and as parameter the this pointer of the actor object.
3 Interpret the void pointer back to the this pointer passed previously as parameter to the CMSIS thread.
4 Now we can call the actor instance method listening to messages.
5 The method blocks until a message is available in the actor queue.
6 The method is specific to each actor and defines how messages are processed based on the actor state and logic.

Lessons Learnt

The message pool hugely simplifies the handling of messages between actors without having to juggle with scopes. The message pool uses constructs compatible with interrupt routines. Therefore, you can acquire and release messages from a regular actor instance or from an interrupt routine.

The variant abstraction from the standard library provides runtime checks that the correct variant is accessed.

Your actor class only needs to override the processMsg(Message<Data>* msg) method. The body of this method should process all expected message types. Remember to release each message you retrieved from the message queue with the message() function.

If you have a complex logic, consider implementing a flat finite state machine as a double nested switch in the method.


1. An opaque object is only a handle, not a pointer to the instance. The structure and internal values are hidden.