Device-Driver-Component
Last updated
Last updated
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.
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.
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.
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.
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.
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:
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:
Then, almost every member function defined by Device class has to specify extra tag parameter indicating context:
The Driver class will invoke the Device functions using relevant temporary context object passed as the last parameter:
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:
If there is no need to differentiate between the contexts the function is invoked in, then it is quite easy to unify them:
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:
There is also a wrapper class around the embxx::error::ErrorCode
, called embxx::error::ErrorStatus
(defined in embxx/error/ErrorStatus.h):
It allows implicit conversion from embxx::error::ErrorCode
to embxx::error::ErrorStatus
and convenient evaluation whether error has occurred in if
sentences:
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:
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:
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:
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.
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:
For projects that allow dynamic memory allocation std::function<...>
can be passed, for others embxx::util::StaticFunction<...>
or similar must be used.