Device-Driver-Component

Device-Driver-Component

Now, after understanding what the event loop is and how to implement it in C++, I'd like to describe Device-Driver-Component stack concept before proceeding to practical examples.

The Device is a platform specific peripheral(s) control layer. Sometimes it is called HAL - Hardware Abstraction Layer. It has an access to platform specific peripheral control registers. Its job is to implement predefined interface required by upper Driver layer, handle the relevant interrupts and report them to the Driver via callbacks.

The Driver is a generic platform independent layer. Its job is to receive requests for asynchronous operation from the Component layer and forward the request to the Device. It is also responsible for receiving notifications about the interrupts from the Device via callbacks, perform minimal processing of the hardware event if necessary and schedule the execution of proper event handling callback from the Component in non interrupt context using Event Loop.

The Component is a generic or product specific layer that works fully in event loop (non-interrupt) context. It initiates asynchronous operations using Driver while providing a callback object to be called in event loop context when the asynchronous operation is complete.

There are several main operations required for any asynchronous event handling: 1. Start the operation. 1. Complete the operation. 1. Cancel the operation. 1. Suspend the operation. 1. Resume suspended operation.

All the peripherals described in Peripherals chapter will follow the same scheme for these operations with minor changes, such as having extra parameters or intermediate stages.

Starting Asynchronous Operation

Any non-interrupt context operation is initiated from some event handler executed by the Event Loop or from the main() function before the event loop started its execution. The handler being executed invokes some function in some Component, which requests the Driver to perform some asynchronous operation while providing a callback object to be executed when such operation is complete. The Driver stores the provided callback object and other parameters in its internal data structures, then forwards the request to the Device, which configures the hardware accordingly and enables all the required interrupts.

Completing Asynchronous Operation

The first entity, that is aware of asynchronous operation completion, is Device when appropriate interrupt occurs. It must report the completion to the Driver somehow. As was described earlier, the Device is a platform specific layer that resides at the bottom of the Device-Driver-Component stack and is not aware of the generic Driver layer that uses it. The Device must provide a way to set an operation completion report object. The Driver will usually assign such object during construction/initialisation stage:

When the expected interrupt occurs, the Device reports operation completion to the Driver, which in turn schedules execution of the callback object from the Component in non-interrupt context using Event Loop

Note that the operation may fail, due to some hardware faults, This is the reason to have status parameter reporting success and/or error condition in both callback invocations.

Canceling Asynchronous Operation

There must be an ability to cancel asynchronous operations in progress. For example some Component activates asynchronous operation request on some hardware peripheral together with asynchronous wait request to the timer to measure the operation timeout. If timeout callback is invoked first, then there is a need to cancel the outstanding asynchronous operation. Or the opposite, once the read is successful, the timeout measure should be canceled. However, the cancellation may be a bit tricky. One of the main requirements for asynchronous events handling is that the Component's callback MUST be called and called only ONCE. It creates a situation when cancellation may become unsuccessful. For instance, the callback of the asynchronous operation was posted for execution in Event Loop, but hasn't been executed by the latter yet. It brings us to the necessity to provide an indication whether the cancellation request was successful. Simple boolean return value is enough.

When the cancellation is successful the Component's callback object is invoked with status specifying that operation was Aborted.

One possible case of unsuccessful cancellation is when callback was posted for execution in event loop, but hasn't been executed yet when cancellation is attempted. In this case Driver is aware that there is no pending asynchronous operation and can return false immediately.

Another possible case of unsuccessful cancellation is when completion interrupt occurs in the middle of cancellation request:

In this case the Device must be able to handle such race condition appropriately, by temporarily disabling interrupts before checking whether the completion callback was executed. The Driver must also be able to handle interrupt context execution in the middle on non-interrupt one.

Suspend / Resume Asynchronous Operation

There may be a Driver, that is required to support multiple asynchronous operations at the same time, while managing internal queue of such requests and issuing them one by one to the Device. In this case there is a need to prevent "operation complete" callback being invoked in interrupt mode context, while trying to access the internal data structures in the event loop (non-interrupt) context. The Device must provide both suspendOp() and resumeOp() to suppress invocation of the callback and allow it back again respectively. Usually suspension means disabling the interrupts without stopping current operation, while resume means re-enabling them again.

Note that the suspendOp() request must also indicate whether the suspension was successful or the completion callback has been already invoked in interrupt mode, just like with the cancellation. After the operation being successfully suspended, it must be either resumed or canceled.

Device Function Invocation Context

Let's think about the case when Driver supports multiple asynchronous operations at the same time and queuing them internally while issueing start requests to the Device one by one.

The reader may notice that the startOp() member function of the Device was invoked in event loop (non-interrupt) context while the second time it was in interrupt context right after the completion of the first operation was reported. There may be a need for the Device's implementation to differentiate between these calls.

One of the ways to do so is to have different names and make the Driver use them depending on the current execution context:

class MyDevice
{
public:
    void startOp();
    void startOpInterruptCtx();
}

Another way is to use a tag dispatching idiom, which I decided to use in embxx library.

It defines two extra tag structs in embxx/device/context.h:

namespace embxx
{

namespace device
{

namespace context
{

// Event loop context tag class.
struct EventLoop {};

// Interrupt context tag class.
struct Interrupt {};

} // namespace context

} // namespace device

} // namespace embxx

Then, almost every member function defined by Device class has to specify extra tag parameter indicating context:

class MyDevice
{
public:
    typedef embxx::device::context::EventLoop EventLoopCtx;
    typedef embxx::device::context::Interrupt InterruptCtx;

    void startOp(EventLoopCtx context)
    {
        static_cast<void>(context); // unused parameter
        ... // Perform operation when called in event loop context
    }

    void startOp(InterruptCtx context)
    {
        static_cast<void>(context); // unused parameter
        ... // Perform operation when called in interrupt context
    }
};

The Driver class will invoke the Device functions using relevant temporary context object passed as the last parameter:

class MyDriver
{
public:
    typedef embxx::device::context::EventLoop EventLoopCtx;
    typedef embxx::device::context::Interrupt InterruptCtx;

    // Invoked by some Component object in Event Loop Context
    void asyncOp(...)
    {
        ...
        device_.startOp(EventLoopCtx());
        ...
    }

private:

   // Some registered event callback handler, 
   // invoked in interrupt context
   void interruptCallbackHandler()
   {
       ...
       device_.startOp(InterruptCtx());
   }
};

If some function needs to be called only in, say EventLoop context, and not supported in Interrupt context, then it is enough to implement only supported variant. If Driver layer tries to invoke the function with unsupported context tag parameter, the compilation will fail:

class MyDevice
{
public:
    typedef embxx::device::context::EventLoop EventLoopCtx;

    void cancelOp(EventLoopCtx context)
    {
        static_cast<void>(context); // unused parameter
        ... // Cancel recent operation
    }
};

If there is no need to differentiate between the contexts the function is invoked in, then it is quite easy to unify them:

class SomeDevice
{
public:

    template <typename TContext>
    void startOp(TContext context)
    {
        static_cast<void>(context); // unused parameter
        startOpInternal();
    }

private:
    void startOpInternal()
    {
        ...
    }
};

Reporting Errors

When issuing asynchronous operation request to the Driver and/or Component, there must be a way to report success / failure status of the operation, and if it failed provide some extra information about the reason of the failure. Providing such information as first parameter to the callback functor object is a widely used convention among the developers.

In most cases, the numeric value of error code is good enough.

The embxx library provides a short list of such values in enumeration class defined in embxx/error/ErrorCode.h:

namespace embxx
{

namespace error
{

enum class ErrorCode
{
    Success, ///< Successful completion of operation.
    Aborted, ///< The operation was cancelled/aborted.
    BufferOverflow, /// The buffer is full with read termination condition being false
    HwProtocolError, ///< Hardware peripheral reported protocol error.
    Timeout, ///< The operation takes too much time.
    NumOfStatuses ///< Number of available statuses. Must be last
};

}  // namespace error

}  // namespace embxx

There is also a wrapper class around the embxx::error::ErrorCode, called embxx::error::ErrorStatus (defined in embxx/error/ErrorStatus.h):

namespace embxx
{

namespace error
{

template <typename TErrorCode = ErrorCode>
class ErrorStatusT
{
public:
    /// @brief Error code enum type
    typedef TErrorCode ErrorCodeType;

    /// @brief Default constructor.
    /// @details The code value is 0, which is "success".
    ErrorStatusT();

    /// @brief Constructor
    /// @details This constructor may be used for implicit 
    ///          construction of error status object out 
    ///          of error code value.
    /// @param code Numeric error code value.
    ErrorStatusT(ErrorCodeType code);

    /// @brief Copy constructor is default
    ErrorStatusT(const ErrorStatusT&) = default;

    /// @brief Destructor is default
    ~ErrorStatusT() = default;

    /// @brief Copy assignment is default
    ErrorStatusT& operator=(const ErrorStatusT&) = default;

    /// @brief Retrieve error code value.
    const ErrorCodeType code() const;

    /// @brief boolean conversion operator.
    /// @details Returns true if error code is not equal 0, 
    ///          i.e. any error will return true, success 
    ///          value will return false.
    operator bool() const;

    /// @brief Same as !(static_cast<bool>(*this)).
    bool operator!() const;

private:
    ErrorCodeType code_;
};

typedef ErrorStatusT<ErrorCode> ErrorStatus;

}  // namespace error

}  // namespace embxx

It allows implicit conversion from embxx::error::ErrorCode to embxx::error::ErrorStatus and convenient evaluation whether error has occurred in if sentences:

embxx::error::ErrorStatus es;
GASSERT(!es); // No error
...
if (/* some condition */) {
    es = embxx::error::ErrorCode::BufferOverflow;
}
...
if (es) {
    ... // Error occurred, access the arror code by calling es.code()
}

By convention every callback function provided with any asynchronous request to any Driver and/or Component implemented in embxx library will receive const embxx::error::ErrorStatus& as its first argument:

void callback(const embxx::error::ErrorStatus& es, ... /* some other parameters */)
{
    if (es == embxx::error::ErrorCode::Aborted) {
        return; // Nothing to do
    }

    if (es) {
        ... // Error occurred
        return;
    }
    ... // Success
}

Cooperation

As it is seen in the charts above, the Driver must have an access to the Device as well as Event Loop objects. However, the former is not aware of the exact type of the latter. In order to write fully generic code, the Device and Event Loop types must be provided as template arguments:

template <typename TDevice, typename TEventLoop>
class MyDriver
{
public:
    // During the construction store references to Device
    // and Event Loop objects.
    MyDriver(TDevice& device, TEventLoop& el)
      : device_(device),
        el_(el)
    {
    }

    ...

private:

    TDevice& device_;
    TEventLoop& el_;
};

The Component needs an access only to the Device and maybe Event Loop. The reference to the latter may be retrieved from the Device object itself:

template <typename TDevice, typename TEventLoop>
class MyDriver
{
public:
    TEventLoop& getEventLoop()
    {
        return el_;
    }

private:
    TEventLoop& el_;
};

template <typename TDriver>
class MyComponent
{
public:
    MyComponent(TDriver& driver)
      : driver_(driver)
    {
    }

    void someFunc()
    {
        auto& el = driver_.getEventLoop();
        el.post(...);
    }

private:
    TDriver& driver_;
};

Storing Callback Object

The Driver needs to provide a callback object to the Device to be called when appropriate interrupt occurs. The Component also provides a callback object to be invoked in non-interrupt context when the asynchronous operation is complete, aborted or terminated due to some error condition. These callback objects need to be stored somewhere. The best way to do so in conventional C++ is using std::function.

template <typename TDevice, typename TEventLoop>
class MyDriver
{
public:
    template <typename TFunc>
    void asyncOp(TFunc&& callbackObj)
    {
        callback_ = std::forward<TFunc>(callbackObj);
        ... // Start the operation
    }

private:
    typedef std::function<void embxx::error::ErrorStatus&> CallbackType;

    void opCompleteInterruptCallback(void embxx::error::ErrorStatus& es)
    {
        ... // Complete the operation
        el_.postInterruptCtx(std::bind(std::move(callback_), es));
    }

    EventLoop& el_;
    CallbackType callback_;
};

There are two problems with using std::function: exceptions and dynamic memory allocation. It is possible to suppress the usage of exceptions by making sure that function object is never invoked without proper object being assigned to it, and by overriding appropriate __throw_* function(s) to remove exception handling code from binary image (described in Exceptions chapter). However, it is impossible to get rid of dynamic memory allocation in this case, which reduces number of bare metal products the Driver code can be reused in, i.e. it makes the Driver class not fully generic.

The problem is resolved by defining the callback storage type as a template parameter to the Driver:

template <typename TDevice, 
          typename TEventLoop, 
          typename TCallbackType>
class MyDriver
{
private:
    ...
    TCallbackType callback_;
};

For projects that allow dynamic memory allocation std::function<...> can be passed, for others embxx::util::StaticFunction<...> or similar must be used.

Last updated