Today's embedded processors feature scores of easy-to-use peripherals that can usually be accessed directly through a series of memory-mapped registers, which are easily read and written through C/C++ or assembly code.
Flip a few bits here and there and all of a sudden your processor is sending data, toggling I/O lines, counting time, and making all sorts of logic dance! It's an elegant, flexible, and powerful way to control a wide variety of hardware, but it certainly doesn't help developers produce elegant, portable, or maintainable code.
To use a cliche - "with great power comes great responsibility."
What I'm referring to is the practice of separating peripheral I/O code from application code using device drivers.
And just like a teenager getting behind the wheel of Dad's car for the first time, many embedded developers take the "keys to the register set" and run directly into the proverbial garbage can before making it out of the driveway.
Chanting the mantra of code space overhead and the inefficiencies of "needless" layers of abstraction, we tell ourselves that sprinkling platform-specific I/O operations in the middle of applications is a good practice - because using functions to perform those operations would result in larger, less efficient code. And for a while it does make sense - especially on tiny microcontrollers where code space is at an absolute premium.
But as applications grow, features get added, and routines start fighting for resources, this approach falls apart. Our "streamlined" code becomes increasingly difficult to read and maintain, portability goes out the window, and reentrancy issues start to pop up making it difficult to guarantee the reliability of the system. Developers then resort to all sorts of trickery and work-arounds to plug the holes on a sinking ship.
It's at this point that programmers typically start looking at grouping related peripheral functions into modules to offer some level of abstraction. The result is a series of modules containing all of the I/O operations for the various peripherals written as functions - and this is often sufficient to get the application back into a usable shape.
While it's a good start, ad-hoc libraries (AHLs) are not device drivers.
The difference between ad-hoc libraries and a device driver is in how they are used at the application level:
While a board support package provides functions to control peripherals, each will typically have a separate API, and are generally specific to the peripheral. As a result, applications written to use AHLs are tied to a given platform, making portability difficult to achieve. Applications attempting to be portable using AHLs are typically easy to identify by the unreasonable amount of #ifdefs scattered throughout; a hallmark of unreadable, unmaintainable code.
These problems can all be avoided by abstracting AHLs through a device driver framework. While it might intuitively sound like there would be a lot of overhead involved in implementing a driver "framework", it really doesn't take much effort at all (and I'll demonstrate it in this article). In fact, most capable RTOS's include a formalized device driver framework which saves you the work.
A device driver needs to be able to perform the following operations:
- Initialize a peripheral
- Start/stop a peripheral
- Handle I/O control operations
- Perform various read/write operations
At the end of the day, that's pretty much all a device driver has to do, and all of the functionality that needs to be presented to the developer.
As a result, we need an API that contains the following functions:
- Initialize
- Start
- Stop
- Control
- Read
- Write
A basic driver framework and API can thus be implemented in six function calls - that's it! You could even reduce that further by handling the initialize, start, and stop operations inside the "control" operation.
In C, we can implement this as a data structure to abstract these event handlers, and a series of wrapper functions to call them, described below.
First, we define function pointer types for the different handlers in the framework:
typedef BOOL (*DRIVER_INIT)(void *pstThis_);
typedef BOOL (*DRIVER_START)(void *pstThis_);
typedef BOOL (*DRIVER_STOP)(void *pstThis_);
typedef USHORT (*DRIVER_CONTROL)(void *pstThis_, USHORT usID_, void *pvData_);
typedef USHORT (*DRIVER_READ)(void *pstThis_, UCHAR *pucData_, USHORT usLen_);
typedef USHORT (*DRIVER_WRITE)(void *pstThis_, UCHAR *pucData_, USHORT usLen_);
Which allows us to design a generic device driver "base" structure as follows:
typedef struct
{
// Function pointers to driver event handlers
DRIVER_INIT pfInit;
DRIVER_START pfStart;
DRIVER_STOP pfStop;
DRIVER_CONTROL pfControl;
DRIVER_READ pfRead;
DRIVER_WRITE pfWrite;
// Variables common to all drivers go below (driver name, type, state)
} DEVICE_DRIVER;
The API functions to operate on this driver structure can then be declared:
BOOL Driver_Init(DEVICE_DRIVER *pstDriver_);
BOOL Driver_Start(DEVICE_DRIVER *pstDriver_);
BOOL Driver_Stop(DEVICE_DRIVER *pstDriver_);
USHORT Driver_Control(DEVICE_DRIVER *pstDriver_, USHORT usEvent_, void *pvData_);
USHORT Driver_Read(DEVICE_DRIVER *pstDriver_, UCHAR *pucData_, USHORT usLen_);
USHORT Driver_Write(DEVICE_DRIVER *pstDriver_, UCHAR *pucData_, USHORT usLen_);These functions simply act as wrappers for the handler functions set in the DEVICE_DRIVER struct. For example, the Driver_Init function might look something like this:
BOOL Driver_Init(DEVICE_DRIVER *pstDriver_)
{
return pstDriver_->pfInit((void*)pstDriver_);
}
Now, in this example we aren't checking that pstDriver_ is valid (which you would want to do in a *real* implementation), but it does illustrate the concept of wrapper functions.
You'll notice that the handlers function types each have a void pointer as the first argument, where the wrapper API calls use DEVICE_DRIVER pointers. There's a good reason for this, and it's related to inheritance.
In this framework, each individual device driver is built on top of the the basic device driver type. Since C doesn't have built-in inheritance found in C++ or Java, we must implement this feature using the knowledge of how data is aligned in structures. Implementing a device driver based on the "base" driver type is shown in the following example:
typedef
{
DEVICE_DRIVER stDriver; //!! Must be first
// data unique to this kind of device driver goes below...
USHORT usMyData;
} MY_PERIPHERAL_STRUCT;
BOOL MyPeripheral_Init(void *pstThis_);
BOOL MyPeripheral_Start(void *pstThis_);
BOOL MyPeripheral_Stop(void *pstThis_);
USHORT MyPeripheral_Control(*DRIVER_CONTROL)(void *pstThis_, USHORT usID_, void *pvData_);
USHORT MyPeripheral_Read(void *pstThis_, UCHAR *pucData_, USHORT usLen_);
USHORT MyPeripheral_Write(void *pstThis_, UCHAR *pucData_, USHORT usLen_);
Specified handler functions for the driver are assigned to the function pointers when declaring the structure as follows:
MY_PERIPHERAL_STRUCT stMyPeripheral =
{
{ // set the handler functions for the base driver struct
MyPeripheral_Init,
MyPeripheral_Start,
MyPeripheral_Stop,
MyPeripheral_Control,
MyPeripheral_Read,
MyPeripheral_Write
},
0xBEEF // initialize driver pecific data
};
This looks simple- and it is, but something very interesting happens when we create a structure this way. In C, the first element in a struct will
always have the same address as the struct itself, so if we have a struct of type MY_PERIPHERAL_STRUCT, it is completely valid to re-cast the struct to type (DEVICE_DRIVER*). This re-casting to a parent type is the essential element of inheritance which gives us the ability to create a series of device drivers that are all accessed through a consistent interface.
As a result, any specialized device driver type can be cast back to the DEVICE_DRIVER type and used with the API functions we have already defined! The driver's handler functions are responsible for re-casting these pointers back to the appropriate type when used internally (i.e. to MY_PERIPHERAL_STRUCT).
Consider a system with drivers for I2C, SPI, and UART peripherals - under our driver framework, an application can initialize all of these peripherals and write a greeting to each using the same simple API functions for all drivers:
Driver_Init((DEVICE_DRIVER*)stMyI2C);
Driver_Init((DEVICE_DRIVER*)stMyUART);
Driver_Init((DEVICE_DRIVER*)stMySPI);
Driver_Start((DEVICE_DRIVER*)stMyI2C);
Driver_Start((DEVICE_DRIVER*)stMyUART);
Driver_Start((DEVICE_DRIVER*)stMySPI);Driver_Write((DEVICE_DRIVER*)stMyI2C, "Hello World!", 12);
Driver_Write((DEVICE_DRIVER*)stMyUART, "Hello World!", 12);
Driver_Write((DEVICE_DRIVER*)stMySPI, "Hello World!", 12);To me, the benefits of using this kind of device driver framework are obvious:
- It provides a consistent interfaces for controlling and accessing peripherals. All a developer needs is to learn a single driver API, with a few specific control events for each peripheral type.
- The internals of the device driver are completely obscured from the user - all that needs to be provided to the application is a pointer to each driver.
- Creates an automatic hardware abstraction layer, leading to better code reuse for the drivers and portability of application code.
- Reduced module coupling in application code, as the app doesn't need to call AHL functions directly.
- The wrapper functions can incorporate resource protection mechanisms to prevent deadlock in a multi-threaded system.
This approach was used in the Mini-Markade project to successfully abstract the display and joystick drivers to allow the games and frontend to compile under Borland C++ Builder on windows as well as the Atmel atmega328p platform without requiring any modifications or #defines to the application code. The overhead on the embedded side was roughly 100 bytes of code space, and in the context of a 32kB application, the benefit greatly outweighs the cost.
See the latest release of FunkOS for more information on how formal device drivers can be used in embedded systems applications.