Sensors and Actuators

2023 12 04 head

Embedded design maps domain abstractions to the underlying hardware.

A temperature sensor is a domain abstraction of a physical hardware temperature sensor.

A valve is a domain abstraction of a physical hardware valve. A physical valve can be a simple opened-closed device, or a proportional device.

The microcontroller board reads the temperature sensor through an I2C or SPI bus. It controls the valve through a GPIO, a PWM, or a CAN bus controller.

These hardware components map the domain object to the electronics controlling the physical device.

Model

The C++ declaration of a valve object could be:

device-model

The designers should decide if a progressive valve is a kind of on/off valve or not.

All instances of the namespaces HAL and BSP are static and created when the application starts. Our machines are not hot-pluggable. All components are wired and fixed with screws.

Relations between objects are defined through constructor parameters. This approach is a clean and legible Dependency Injection implementation.

The algorithms should ideally only access the abstractions defined in the model namespace.

The instantiation of objects should define the initialization of abstractions and the underlying physical devices.

Do we start our system and bring all devices to a well-defined initial state?

Or do we start our system, retrieve the state of all devices, and initialize our objects accordingly?

This approach is only possible if each hardware component has sensors providing its current state.

C++ Approach

A physical device shall be mapped to a C++ class. Instances of the class map to existing devices.

Therefore, the copy constructor and the copy operator should be disabled. The physical devices are defined through the machine the software controls.

The software accesses the physical device through an electronics interface such as a GPIO, PWM, or CAN controller. The mapping between the electronic component and the physical device is hardwired at construction.

class ValveOnOff {
public:
    void open();
    void close();
    bool isOpen() const;
private:
    ValveOnOff(const ValveOnOff&) = delete;                           (1)
    const ValveOnOff& operator=(const ValveOnOff&) = delete;          (2)
    bool _opened = false;                                             (3)
};
1 A physical device cannot be replicated.
2 A physical device cannot be copied.
You should evaluate if a default constructor does make sense. Often the constructor needs other objects connecting the abstraction to the hardware. This pattern is a hand-coded dependency injection through constructor parameters.
The default move constructor and move operator can be useful.
3 Imply that the application resets the system when booting.

Constant mapping between the electronic wiring and logical representation shall be provided. The application should only need to change the mapping when the hardware layout is changed or a microcontroller revision is used.

Interrupt Routines

Output devices do not need interrupts. A driver provides a function to output the new value on the electronics. As an example, the function writes the new value to a memory-mapped register. The designer is responsible to avoid concurrent access to the register.

Input devices should always support interrupts. Otherwise, the application must poll registers to detect changes. Polling is cumbersome and expensive. Either the programmer guesses a polling frequency and sometimes misses data. Or he chooses a high-frequency sampling rate and wastes computing resources.

Interrupt routines are written in C or in assembler. The C++ language is not supported in interrupt routines.

Three major interrupt approaches exist:

  • The interrupt routine reads the new value of the sensor and stores the data at a defined location. The application accesses the sensor stored value when needed.

    This approach decouples the application from the external sensors. The drawback is that the program is not informed when a new value is received.

  • The interrupt routine reads the new value and sends the value as a message to interested parties.

    The approach is a reactive system triggering the application each time input data is received.

  • The interrupt routine calls a callback function. The callback function is a function pointer to a static C++ function and often has the reference of the object abstracting the sensor. Below is a potential implementation of the callback function, and callback registration is shown.

    /**
     * @brief Register a callback function to be called when data is received.
     * @param callback The callback function.
     * @param self The reference to the object.
     */
    void registerCallback(void (*callback)(void* self, byte* data, int length), void* self);
    
    class Sensor {
    public:
    /**
     * @brief Declares the static callback function.
     * @param self The reference to the object.
     * @param data The data received.
     * @param length The length of the data.
     */
     static void callback(void* self, byte* data, int length) {
        Sensor* sensor = reinterpret_cast<Sensor*>(self);
        sensor->callback(data, length);
    }
    
    /**
     * @brief Object callback function.
     * @param data The data received.
     * @param length The length of the data.
     */
    void callback(byte* data, int length) {
        // 1. do something with the data. If the sensor is a thread, protect the object data with a mutex or a lock.
        // 2. if the sensor is an actor, create a message with the payload and send it to own mailbox.
    }

The interrupt routine or the underlying hardware can handle sensor inputs in two ways:

  • Data is retrieved regularly. This synchronous approach gives a rhythm to the system but often uses computing resources without gains.

  • Data is retrieved when a value has changed. Either the hardware detects the change or the interrupt routine compares the received data with the stored value.

    This approach is purely reactive. Algorithms are only triggered when the external system has changed.

I recommend interrupting triggering input data gathering and reacting to input changes.

This design approach minimizes microcontroller resource consumption and simplifies the control algorithms.

Tasks and Synchronization

Actuators

Actuators are often plain old objects. Modern microcontrollers provide memory-mapped registers to propagate the value to the actuator hardware. The update operation often writes a new value to a memory-mapped register. The writing into one register is atomic and does not need additional synchronization.

If the update operation is more complex and writes multiple registers, the operation should be protected by a mutex.

Beware that mutexes can create deadlocks. The cleanest approach is to use mutexes with priority inheritance. Therefore, a task will never have to wait for a lower priority task to release a lock or a mutex.

Another trick is to set all writing tasks to the same priority. This guarantees that an update operation is not interrupted by another task requesting the same resource.

You could also implement the actuator with a tread.

This approach is often overkill. It complicates unnecessary application design.

Sensors

Sensors shall always be implemented as a thread. Otherwise, a client calling an actuator with a synchronous call and waiting for a sensor result will block the whole system.

Another trick is to use an interrupt routine to trigger the sensor reading and publish the data on a message queue. The interrupt routine takes the role of the thread. This guarantees that clients are not blocked by the sensor reading because they get the data asynchronously.

Lessons Learnt

Try to define the simplest model to implement your features. Follow the Keeep It Simple, Stupid principle.

Model the physical world. A valve, a GPIO, a CAN controller are real things. Model them. Do not try to hide things.

The introductory book Realtime C++ [1] is a rigorous introduction how to program microcontrollers in C++. You learn how to access hardware resources from C++ and what are the costs of various C++ constructs.

Use the actor pattern to implement thread communication [distributed-asynchronous-systems] [actors-in-cpp] [actors-cmsis-cpp].

References

[1] C. Kormanyos, Real-Time C++ Efficient Object-Oriented and Template Microcontroller Programming. Springer Berlin / Heidelberg, 2021 [Online]. Available: https://www.amazon.com/dp/B099J441CH