Developing Reusable Device
Drivers for MCUs
Jacob Beningo
jacob@beningo.com
Social Links:
Developing Reusable Device Drivers for MCUs
Table of Contents
Contents
Introduction ................................................................................................................................................... 2
Driver Code Organization ............................................................................................................................... 2
Application Programming Interface (API) ...................................................................................................... 3
Pointer Arrays ................................................................................................................................................ 4
Configuration Tables ...................................................................................................................................... 6
Digital Input/Output Driver Design ................................................................................................................ 7
Serial Peripheral Interface (SPI) Driver Design.............................................................................................12
Conclusion ....................................................................................................................................................20
Introduction
The rate at which society expects products to be released and refreshed has steadily increased over the
last two decades. The result has left development teams scrambling to implement the most essential
product features before the launch date. Designing a new product from scratch takes time, effort, and
money that is often unavailable.
Embedded software developers often look to chip manufacturers to provide example code and processor
drivers to help accelerate the design cycle. Unfortunately, the provided code often lacks a layered
architecture that would allow the code to be easily reused. In addition, the code is often sparingly
documented, making fully understanding what is being done difficult. The result is poorly crafted code
that is difficult to read and comprehend and offers no possibility of reuse with the following product.
Time and effort are forced to focus on developing low-level drivers rather than implementing the
product features.
This paper will explore methods and techniques that can be used to develop reusable abstracted device
drivers that will result in a sped-up development cycle. A method for driver abstraction is examined in
addition to a brief look at crucial C language features. A layered approach to software design will be
explored with common driver design patterns for Timers, I/O, and SPI. This can then be expanded upon
to develop drivers for additional peripherals across a wide range of processor platforms.
Driver Code Organization
There are many different ways in which software can be organized. Nearly every engineer has their own
opinion on how things should be done. In this paper, the software will be broken up into driver and
application layers to create drivers and reusable design patterns. The primary focus will be on the driver
layer with the intent that the same basic principles can be applied to higher layers.
2 © 2023 Beningo Embedded Group, All Rights Reserved
Developing Reusable Device Drivers for MCUs
As expected, the driver layer will consist of peripheral interface code; however, the drivers will attempt
to remain generic to the peripheral. This will allow them to be used and configured for various
applications. The driver layer can be compiled into a separate library that can be dropped into any
project. The configuration for each driver would be contained within configuration modules that would
be part of its layer. Each application can uniquely configure the driver and layers to match the
requirements. Figure 1 shows how the configuration and driver code would be organized.
Figure 1 – Layered Organization
Application Programming Interface (API)
One of the most critical steps in developing a reusable driver framework is to define the Application
Programming Interface (API). Properly defining the APIs allows for a standard interface to be used to
access hardware across multiple platforms and projects. This is something that high-level operating
systems have done relatively well over the years.
These APIs can be defined in many possible ways and are often dictated by programmer preferences.
For this reason, the developed APIs should become part of the development teams’ software coding
standard. The end goal is to define the APIs in a way that meets the system's general requirements but
allows each peripheral's power to be fully utilized.
There are software APIs available that can provide a starting point. Adopting formats used by the Linux
kernel, Arduino libraries, AUTOSAR, or a custom driver API that is a mix is possible. It doesn’t matter,
provided the format is well-documented and used across all platforms and projects.
Defining the APIs for common and useful features for each peripheral is helpful. Each peripheral will
require an initialization function and functions that allow the peripheral to perform its functions. For
example, Listing 1 shows a possible Digital Input/Output driver interface. It consists of initialization,
read, write, and toggle functions.
Listing 1: Digital Input/Output API
© 2023 Beningo Embedded Group, All Rights Reserved 3
Developing Reusable Device Drivers for MCUs
The Serial Peripheral Interface (SPI) and EEPROM APIs are below in Listing 2 and Listing 3. These are the
example interfaces that will be used in this paper.
Listing 2: Serial Peripheral Interface API
Listing 3: EEPROM API
In these examples, the coding standard typically uses a three-letter designation to indicate the peripheral
or board support interface followed by a single underscore. The underscore precedes the interface
function. Each word is capitalized to ease the readability of the code.
It should be noted that uint8, uint16, and uint32 are, respectively uint8_t, uint16_t, and uint32_t. The
author has found that it is fairly obvious what these types are, and continually writing “_t” after every
type doesn’t have any added value. This is open to personal interpretation but is the convention that
will be used throughout the rest of this paper.
Pointer Arrays
One of the fundamental issues in driver design is deciding how to map to the peripheral registers. Over
the years, many different methods have been used, such as setting up structures to define bit maps or
simply writing the desired value to the register; however, my all-time favorite method is to create an
array of pointers that map to the peripheral registers. This method offers an elegant way to group
peripheral registers into logical channels and provides a simple method to initialize the peripheral and
access its data.
The pointer array method is easily ported and can be used to create standard APIs and application code
that can work across different hardware platforms, allowing for application code to be shared. If properly
written, it also produces code that is far easier to read and understand, making software maintenance
easier.
The concept of pointer arrays is a relatively straightforward method for mapping to a peripheral. The
idea is to create an array where each index of an array is a pointer to a peripheral register of a particular
type. For example, for a microcontroller with multiple GPIO ports, a pointer array would be set to access
the direction registers of each available port (Listing 4). Another pointer array would be set up to access
the input and output registers. Each register type would be associated with its own pointer array.
4 © 2023 Beningo Embedded Group, All Rights Reserved
Developing Reusable Device Drivers for MCUs
Listing 4: Pointer Array for GPIO
It is essential to take note of how the pointer array is declared. The pointer array portsddr is a constant
pointer to a volatile uint16. Notice that the declaration is defined from right to left. The pointer to the
register is a continual pointer but declaring it as a volatile uint16 notifies the compiler that the value
being pointed to may change on its own without interaction from the software.
There are many advantages to using this approach to memory mapping. First, it allows registers of the
same function to be logically grouped. This allows the software engineer to view each peripheral as a
separate channel of the MCU. For example, timer 1 and timer 2 could be looked at as being two different
timer channels.
To set up the period register of each timer would only require a simple write to the proper channel index
of the period pointer array. The index of the pointer array then becomes a channel access index. For
instance, pointer array index 0 would be associated with Timer 1; pointer array index 1 would be
associated with Timer 2.
Next, when the peripherals start to look like channels, creating an abstract method of initializing and
accessing each peripheral data becomes easy. This allows a simple loop to initialize each peripheral
(Listing 5). It will enable the data of the peripheral to be accessed by simply using the correct channel
index. This results in a driver framework that is not only easy to understand and reuse but also a
framework that abstracts the device registers.
Listing 5: Timer Initialization Loop
Finally, it allows the developer to create configuration tables for each peripheral. Instead of always
writing custom initialization code, the developer can create a reusable driver that takes the configuration
table as a parameter. The initialization function then loops through the table one channel at a time and
initializes the peripheral registers through the pointer array. This allows the driver to become a library
module that is repeatedly tested, resulting in proven code that can accelerate the next project.
© 2023 Beningo Embedded Group, All Rights Reserved 5
Developing Reusable Device Drivers for MCUs
Configuration Tables
Memory mapping microcontroller peripherals using pointer arrays allows the peripheral to be viewed as
a collection of channels that can be configured through an index in a loop. By taking this generic
approach to memory mapping, a technique is needed to control precisely what is put into the registers.
Configuration tables serve as a valuable tool for this exact purpose.
A configuration table is precisely what it sounds like - a collection of channels and values configuring a
peripheral. The most helpful way to define a configuration table is to create a typedef structure
containing all the fields needed to set up each channel. Start by examining the peripheral registers of
interest. For example, reading the timer peripheral may determine that the configuration table should
include channel, period, and control fields. The table elements can then be defined by the structure
shown in Listing 6.
Listing 6: Configuration Table Definition
The Tmr_ConfigType defines all the data required to set up a single-timer peripheral. Since most
microcontrollers contain more than a single timer, an array of Tmr_ConfigType would be created with
each array index representing a channel (a single-timer module). Before a configuration table can be
defined, it is helpful first to define channel types for the table. The channel will access indices in an array
that belongs to that channel, allowing the application code to manipulate that particular timer.
Listing 7: Timer Channel Definitions
In Listing 7, a typedef enumeration creates the channel names. Since enumerations start at 0 (in C
anyway), TIMER1 can access index 0 of an array containing information about TIMER1. NUM_TIMERS
then holds the value for the number of available timers. This can be used in the driver initialization to
loop through and configure each channel up to NUM_TIMERS.
6 © 2023 Beningo Embedded Group, All Rights Reserved
Developing Reusable Device Drivers for MCUs
Once the channel type has been defined, filling in the configuration table with the values used to
configure the timers is possible. Listing 8 shows an example configuration table based on the
Tmr_ConfigType structure. The configuration table is defined as a const since the configuration data will
not change during run-time. This will allow the configuration tables to remain in Flash and not take up
valuable space in RAM. Each channel is listed along with a period and a control register value. If a clock
module were developed, it would be possible to use a time in microseconds instead of a period. The
timer module would then use the clock module to correct the period register.
Listing 8: Configuration Table Example for 2 timers
If Listing 8 were being used within an actual project, the period values would correspond to the number
of ticks of the timer required before an interrupt or some other helpful system event would occur. The
control register attributes would be representative of different registers that would require setup. It
would be possible to include enabling and disabling interrupts for each timer and controlling the
interrupt priority. Items included in the configuration table may vary from peripheral to peripheral based
on what the manufacturer supports features. Each table's process, design pattern, and look would be
similar and familiar, leaving little guesswork regarding configuring the module.
Digital Input/Output Driver Design
General Purpose Input / Output or Digital Input / Output is one of the most fundamental peripherals on
every microcontroller. However, figuring out how the devices’ pins are configured in most applications
can be a nightmare. They are usually configured as shown in Listing 9, except that instead of only
displaying four registers, there are hundreds of them! This definition was acceptable when devices only
had 8-bit ports and only one or two per device. However, today, microcontrollers can have 100’s of pins
which need to be configured. This is why we will examine an approach to pin mapping using arrays of
pointers. At the end of this section, you will find that this method proves far more manageable to
determine the configuration of a pin once the work has been put in front.
Listing 9: Example I/O Configuration
© 2023 Beningo Embedded Group, All Rights Reserved 7
Developing Reusable Device Drivers for MCUs
The first step that should be performed when developing the digital input/output driver is that the
device registers should be examined in the datasheet. While there are standard features across
manufacturers and chip families, features do vary.
Next, write down a list of all the features that should be implemented in the driver. Some example
features for a digital input/output driver are pin direction, initial state, and the function the pin will
serve, such as GPIO, SPI, PWM, etc. Once this list has been compiled, it can be put into a configuration
structure, as shown in Listing 10.
Listing 10: Digital I/O Configuration Structure
With the list of configuration parameters developed, the channel definitions are the only pieces missing
before the table can be filled in. These definitions can start as a generic list such as PORTA_0, PORTA_1,
etc. However, once in an application, it is far more convenient to label the channels with valuable
designations. For example, LED_RED and LED_BLUE would replace the generic label so the developer
knows exactly what output is being manipulated. An example channel definition in Listing 11 is a typedef
enumeration.
Listing 11: Digital I/O Channel Types
Once the channels have been defined, it is straightforward to generate the configuration table. Create a
const array of type Dio_ConfigType and start populating how each channel (pin) should be configured.
8 © 2023 Beningo Embedded Group, All Rights Reserved
Developing Reusable Device Drivers for MCUs
For instance, for the LED_RED channel, the pin should be configured as a digital pin, with the direction of
OUTPUT and an initial state of HIGH. The pin function would, of course, be set to GPIO. A complete
example of the configuration table can be seen in Listing 12.
Listing 12: Digital I/O Configuration Table example
With the configuration table and channels defined, the next step in developing a digital input/output
driver is to memory map the peripheral registers to a pointer array. Once this is done, the initialization
function can be created. As a simple example, the code in Listing 13 assumes that the device is a single-
port device. The digital input register, digital direction register, and output state register are all mapped.
The final code creates an array allowing the driver to access an individual bit within a register based on
the pin number. For example, pin 3 would be accessed by bit 2 in a register, which is a 1 shifted to the
left by 2. The initialization function can simplify the code if these bit shifts are stored in an array.
© 2023 Beningo Embedded Group, All Rights Reserved 9
Developing Reusable Device Drivers for MCUs
Listing 13: Pointer Array Memory Maps for Digital I/O
After much preparation, the initialization function is finally ready to be written. It is relatively simple. A
pointer to the configuration table is passed to the function. A simple loop is used to set up each of the
pins. Each configuration value is read during each pass, and based on the value, a register is configured.
Listing 14 shows how each configuration value is recorded in the registers. As you can see, this code is
straightforward and easily re-used. The only change is that the pointer array must be updated for the
correct records. Minor changes to how the analog pins are configured may be necessary, but as long as
the API is followed, application code can be reused from one processor to the next.
10 © 2023 Beningo Embedded Group, All Rights Reserved
Developing Reusable Device Drivers for MCUs
Listing 14: Example Digital I/O Initialization Function
© 2023 Beningo Embedded Group, All Rights Reserved 11
Developing Reusable Device Drivers for MCUs
A quick example of how to write an additional function would be helpful. In many applications, it is often
valuable to toggle an LED to see that the system is functioning. Listing 15 demonstrates how to access
the pointer array to toggle a channel.
Listing 15: Digital I/O Driver Definition
The usage for this function is very straightforward. Simply pass one of the DioChannelType channels,
such as LED_RED. The function could be called at a rate of 500 ms.
Listing 16 demonstrates how other functions can be used along with the Dio_ToggleChannel.
Listing 16: Digital I/O Functions
Serial Peripheral Interface (SPI) Driver Design
The serial peripheral interface (SPI) is commonly used. It consists of three communication lines in
addition to a chip select line. It is often used to communicate with EEPROM, SD cards, and other
peripheral devices. Most SPI interfaces can reach speeds over 4 Mbps.
Like the Digital I/O driver, the first step to developing an SPI driver will be establishing the configuration
table. An example configuration structure can be found in Listing 16.
12 © 2023 Beningo Embedded Group, All Rights Reserved
Developing Reusable Device Drivers for MCUs
Listing 16: SPI Configuration Table Definitions
Depending on the part being used, there may be more than a single SPI channel per chip. In Listing 17, a
Spi_ChannelType enumeration defines the possible SPI channels. These channels can access the pointer
arrays and control the application's behavior.
Listing 17: SPI Channel Definitions
Several features are standard to SPI peripherals configured by the configuration table. SPI allows the
processor to behave as a Controller, which controls the communication with a target device. It also
allows the processor to be configured as the target device. If there is more than a single SPI channel,
each channel's baud rate can be individually configured, and the width of each communication data
chunk.
Listing 18 shows how a two-channel SPI processor could be configured. In this example, the first SPI
peripheral is enabled during start-up as a controller device with a baud rate of four Mbps. Each
communication with a target device occurs in byte communication. The second channel is disabled at
start-up, but it would act as a target device if it were enabled during operation. A target device requires a
chip select to clock in data. The target channel would be configured to expect a baud rate of 400 kbps
and receive the data in 2-byte data chunks.
© 2023 Beningo Embedded Group, All Rights Reserved 13
Developing Reusable Device Drivers for MCUs
Listing 18: SPI Configuration Table Example
There are only a couple of functions that are necessary to get an SPI driver up and running. The first is
the initialization function. The second is a transfer function that sends out and receives data. The
Spi_Init function would accept a pointer to the configuration table. The Spi_Transfer function would also
get a pointer to a configuration table. Listing 19 shows the prototypes for these functions.
Listing 19: SPI Function Prototypes
There is a significant difference between the configuration tables each function takes for parameters.
The Spi_Init configuration initializes the peripheral from a general standpoint, for example, the
peripheral baud rate. The Spi_Transfer configuration describes how a particular device will communicate
over SPI. For example, two different SPI target devices may be set up to communicate differently. One
may be an active low chip select with a particular phase and clock polarity, while another device may be
the opposite. In this case, Spi_Transfer allows each device to be set up with the same SPI channel and
each data transfer configured as required. Listing 20 shows some examples of what might be found in
the configuration structure.
The Spi_Init function would be written in the same manner as the digital input/output initialization.
Pointer arrays would be declared, and the initialization would loop through each channel, setting up the
registers per the configuration table. The Spi_Transfer function is far more interesting to take a look at. It
consists of several steps to send data properly.
The first step of the Spi_Transfer function is to configure the SPI peripheral for communication. This is
usually done by first resetting the peripheral. This aims to clear out any old transfer data and prepare the
peripheral for new configuration data. Next, the clock phase and polarity are configured. The transfer
mode (Controller or Target) is set up before enabling the SPI peripheral. This can be seen in Listing 21.
14 © 2023 Beningo Embedded Group, All Rights Reserved
Developing Reusable Device Drivers for MCUs
Listing 20: SPI Transfer Configuration
At this point, the peripheral is configured and ready to send data. In this example, the SPI is configured
as a controller. This means that the processor controls the communication on the bus. To talk to a target
device, the chip selection must be toggled to tell it to prepare to receive data. Chip selects can be either
active high or active low. The configuration data determines which is correct to communicate with this
target device, and the chip select is active. Listing 22 shows an example function that can be used to set
a target device into active mode. Listing 23 shows the opposite function used to put the target in an
inactive state.
© 2023 Beningo Embedded Group, All Rights Reserved 15
Developing Reusable Device Drivers for MCUs
Listing 21: SPI Transfer Peripheral Setup Function
16 © 2023 Beningo Embedded Group, All Rights Reserved
Developing Reusable Device Drivers for MCUs
Listing 22: SPI Target Chip Select Active Function
© 2023 Beningo Embedded Group, All Rights Reserved 17
Developing Reusable Device Drivers for MCUs
Listing 23: SPI Target Chip Select Inactive Function
The data is then transferred one chunk at a time to the target device; however, before data is
transferred, the order of the bytes and bits must be set. Some devices expect data LSB to MSB while
others MSB to LSB. This is part of the configuration. If required, the Spi_Transfer function reorders the
bytes and transmits them. A new chunk of data is read at the end of each piece of data. Once all the data
has been sent, the chip select is cleared, and the data transfer is complete. The final Spi_Transfer
function can be found in Listing 24.
18 © 2023 Beningo Embedded Group, All Rights Reserved
Developing Reusable Device Drivers for MCUs
Listing 24: SPI Transfer Function Example
© 2023 Beningo Embedded Group, All Rights Reserved 19
Developing Reusable Device Drivers for MCUs
Conclusion
Many methods can be used to develop device drivers. Using pointer arrays with configuration tables
opens up the possibility of developing reusable drivers that follow a design pattern that can be used
across not only families of processors but across platforms. Following these simple design patterns will
drastically speed up the driver design cycle, leaving more time for focusing on the application challenges
rather than low-level chip functions.
Keeping to standard driver APIs allows higher-level application code to be easily ported from one project
to the next. This continues to speed up the design cycle while increasing the components' quality.
20 © 2023 Beningo Embedded Group, All Rights Reserved