Device Driver Development: The Ultimate Guide For Embedded System Developers

Posted by Magnus Unemyr on Oct 21, 2016 4:29:12 PM

One of many difficulties in embedded systems development is hardware dependencies. A software developer targeting PC/web/mobile platforms generally doesn’t need to understand the hardware, at least not in any detail. To an embedded systems developer, this is critical.

In this blog post, I will explain how embedded software interacts with the hardware to get access to, and to control various hardware resources called peripheral modules. These can be timers, A/D or D/A converters, digital I/O, LCD display controllers, and much more. To use hardware resources like these, you will have to write device drivers, also known as a HAL (hardware abstraction layer).

 driver complexities.jpg

Peripheral Modules

A microcontroller often includes hardware functionalities (peripheral modules) like:

  • Serial communication (common examples are USB, UART, I2C, etc.)
  • Timers (watchdogs, to drive RTOS scheduling, PWM signals for stepper motors, etc.)
  • Digital I/O (for example connected to LED’s, push buttons etc.)
  • Analog I/O (A/D converters for measurement, or D/A converters for control)
  • Super-fast data transfers (using direct memory access, DMA)
  • GUI (LCD and touch controllers)
  • Networking (Ethernet for office equipment, CAN for automotive etc.)
  • And much more

To use these functionalities from your software application, you will need to interact with them. That is done using device driver software, which acts as a hardware abstraction layer. A device driver library is an interface between the hardware and the application software.

It is a very bad idea to inject hardware dependencies into the application software – make sure to isolate this in a device driver abstraction layer – the device driver library.

The Anatomy of a Device Driver

A device driver library is generally modularized around the peripheral modules – i.e. there is typically one device driver module for each peripheral module. For each peripheral module, the device driver contains 4 types of API functions:

  • Initialization (for example, enable a UART channel and initialize data structures)
  • Configuration (for example, to set the baud rate)
  • Runtime control (for example, to send characters or retrieve received characters)
  • Shutdown

The initialization phase is typically done by one device driver API function, for example:

  • UART_Initialize()

Sometimes, peripheral modules have parallel instances of hardware functionalities, for example, several serial channels (UART’s). In such case, the device driver can be designed to initialize one channel at a time:

  • UART_Initialize_Channel0()
  • UART_Initialize_Channel1()

Another alternative is to use the channel number as a parameter:

  • UART_Initialize( 0 )
  • UART_Initialize( 1 )

It is up to the developer of the device driver library to decide which architectural model to use.

The configuration phase often setup the peripheral module in a particular way, according to the needs of the particular application and product being developed. For a UART, the communications speed and handshaking protocol might have to be configured using a device driver API. Something like this:

  • UART_Configure( 9600, 8, ‘N’, 1 )

Alternatively, using a per-parameter model, like this:

  • UART_SetBaudrate( 9600 )
  • UART_SetDatabits( 8 )
  • UART_SetParity( ‘N’ )
  • UART_SetStopbits( 1 )

Runtime control functions handle the actual behavior as the system runs – for example sending or receiving characters on a UART cable, writing pixels to an LCD display, or starting and stopping timers. Examples of device driver API runtime control functions are:

  • UART_SendChar( ‘a’ )
  • UART_SendString( “Hello world!” )
  • UART_RetrieveChar()

The shutdown phase is often not needed and is usually omitted in such case. But it might sometimes be required, for example, to disconnect from a network in a well-behaved manner. Perhaps, something like this:

  • ETHERNET_Disconnect()

Many peripheral modules also generate interrupts/exceptions, that are notifications the hardware gives to the software indicating a particular hardware event has happened – for example, a character has been received on a UART channel, a timer has expired, or a DMA block memory transfer is completed.

To respond to a hardware event, the device driver developer has to write an interrupt handler, that is a C function that is never called by the application program. Instead, the hardware starts the interrupt handler C function automatically, whenever a hardware event occurs.

  • UART_CharacterReceived_InterruptHandler()

I will go into more detail on the mechanics of interrupt handlers further down in this blog post.

Accessing The Hardware – Special Function Registers

Device driver API functions (for initialization, configuration, runtime control, or shutdown) and interrupt handler functions need to talk to the hardware in a bi-directional fashion. This is done using Special Function Registers, more commonly called SFR’s or SFR registers.

A special function register is just a memory location with a special meaning and behavior. A special function register might, for example, be 8 bits wide (16- and 32-bit registers are common too) and hardcoded by the chip designer to live on the memory address 0x00F40020.


The SFR register is often divided into several bit groups that are used for different purposes. In an 8-bit register, the 3 leftmost bits (MSB, most significant bits) might be used to configure the baud rate of a UART channel, the next 2 bits might be used to configure the parity, the next bit might be used to configure the number of stop bits, and the final 2 rightmost (LSB, least significant bits) might be unused.

Each functional group of bits in the SFR register is a bit field. In the example above, the first bit field is used for baud rate configuration, the next bit field is used for parity configuration, etc.

Individual bits or bit fields may be read-only, read/write, write only or unused. To make matters even more confusing, some SFR register bits might auto-set or auto-clear themselves if you read or write to them.

So how can we access these hardware functionalities from software? Assume we want to write code that configures the baud rate to 2400 baud and leave all other bits untouched. First, we need to be able to access the register in a general manner.

Our aim is to be able to read from and write to the register in the same way we access any other variable. To do that, we must create a “variable” that is hardcoded to live at the same memory address as the SFR register, which in our example is 0x00F40020. We also need to ensure that our “SFR variable” is the same size as the SFR register – in this case, 8 bits wide.

To create an 8-bit “SFR variable” that is located on 0x00F40020, we can create a preprocessor symbol that perform some pointer tricks:

/* Define the UART channel 0 configuration register */

#define UART0_CR *((volatile unsigned char *)0x00F40020)

We can now read and write to the UART0_CR register in the same way we do with any normal variable:

UART0_CR = 0x00;

If( UART0_CR != 0 )

              ;     /* Do something*/

By the way, if you are puzzled by the “volatile” keyword in the SFR register definition, read this blog post. Your code will not work without it.

To set the baud rate to 2400 (and leave everything else untouched, we need to write 001 into the 3 leftmost bits). We can do this using with code like this:

UART0_CR = (UART0_CR & 0x1F) | (0x01 << 5 );


Let me explain this line, piece by piece.

(UART0_CR & 0x1F): Read the current value of the SFR register and clear the baud rate bit field (the 3 leftmost bits). We do this by performing a bitwise AND operation with the bit pattern 00011111 (which is 0x1F in hexadecimal).

(0x01 << 5 ): Then we want to set the baud rate to 2400. We do that by writing the bit pattern 001 into the 3 leftmost bit positions. The easiest way to do that is to create the bit pattern 00100000, which we get by shifting one bit with the value of one 5 steps to the left.

And finally we put this together with this assignment:

UART0_CR = (register_value_with_baudrate_field_cleared) | (new_baudrate_value_bit_pattern);

In other words, the UART0_CR register is assigned the same value it already has, but with the baud rate bit field cleared, and a new baud rate bit pattern OR’ed-in in the correct bit positions.

Not so complicated, right?                                             

But you will need to study bit manipulation in the C language, and learn how to perform bitwise AND, OR, XOR and NOT– as well as setting, clearing and shifting bit values.

Behave: There Might Be a Protocol to Follow

When you arrive at a dinner party, there is a protocol to follow. For example, you say hello to the host and the other guests before grabbing the finger food served in the back garden. And you don’t start to eat before all other guests are seated by the table. In short, you must behave well if you and the other guests are going to have a great evening together.

The same goes for device driver development and SFR register access. While you can read and write SFR register bits and bit fields any way you like, it must often be done is a specific way to get the result you want. In effect, a device driver function often is the C implementation of a flowchart.

For example, it is common you sometimes have to wait for the hardware becoming ready before you continue to do something. So you might have to write code like:

/*The UART cannot send a new character before the status register is zero */

while ( UART0_SR != 0 )

              ;   /* Wait for the status register to become zero */


You might also have to recover from error situations, or otherwise behave differently dependent on some feedback you get from the hardware:

/* There is a parity error if bit 2 is set (00000100) */

If ( (UART0_SR & 0x04) != 0 )


              /* Recover from the problem */



The above code also highlights the difficulty of understanding and maintaining device driver code. Who remembers what the bitmask 0x04 in the UART channel 0 status register means? Or indeed, if that is the correct bit position considering what we want to do?

Good practice is thus not only to create symbolic names for the SFR registers themselves (like UART0_CR and UART0_SR), but also for the bit fields and bit values.

For example, we defined the UART0_CR register like this:

#define UART0_CR *((volatile unsigned char *)0x00F40020)

Good practice is to compliment the SFR register with other symbolic defines that relate to the internals of the register. Something like this:

#define UART0_BF_BAUDRATE                 0xE0      /* Bit field mask is 11100000 */

#define UART0_BV_BAUDRATE_1200     0x00      /* Bit value mask is 000 */

#define UART0_BV_BAUDRATE_2400     0x01      /* Bit value mask is 001 */

#define UART0_BV_BAUDRATE_4800     0x02      /* Bit value mask is 010 */

#define UART0_BV_BAUDRATE_9600     0x03      /* Bit value mask is 011 */

#define UART0_BV_BAUDRATE_19200   0x04      /* Bit value mask is 100 */

Now, we can write code like this:



              /* Recover from the problem */



While still being fairly advanced to understand and maintain (as you need to understand the hardware to assess if this code is correct or not), it does at least raise the abstraction level above binary bit mask numbers to a symbolic level.

Interrupt handling

A key concept in device driver development is something called interrupt handlers – C or assembler functions that are started automatically by the hardware (at any point in time, completely asynchronously from the application software execution) whenever a hardware event is signaled.

It works like this:

  1. Some hardware event is detected (for example, a new character may have been received on a UART channel).
  2. The peripheral module signals this to the processor by raising an interrupt/exception event. Dependent on what hardware event occurred (such as a UART character received, or a timer expired, etc.) a different interrupt source (interrupt number) is flagged.
  3. The processor determines which interrupt handler function to execute by looking up the address of the appropriate interrupt handler, using an interrupt vector table. For each interrupt source/interrupt number in the processor, the interrupt vector table contains an entry with the address of the appropriate interrupt handler function.
  4. The raised interrupt signal causes the processor to halt the current execution thread, and start to execute the code that is pointed to by the interrupt vector table
  5. Once the interrupt handler code has taken care of the situation that was raised by the hardware interrupt, the processor exit the interrupt handler function and resumes execution of the normal application software execution thread.

To set this up, we need a few things in place:

  1. An interrupt handler function that takes care of the processing that should be done once the hardware signals a hardware interrupt event. If this code is written in C, it should return nothing (void) and take no parameters (void).
  2. An interrupt vector table, where each entry point to the correct interrupt handler function for each interrupt number/interrupt source – as defined by the microcontroller hardware manual.
  3. The interrupt vector table itself must be located in the correct memory area – this is also defined by the microcontroller hardware manual. In most compilers, you will have to update the linker configuration script to locate the interrupt vector table on the correct memory address.

It looks something like this:


For the GNU compiler on ARM Cortex-M, the interrupt vector table looks something like this:

void (* const InterruptVector[])() __attribute__ ((section(".isr_vector"))) = {

              /* Processor exceptions */

              (void(*)(void)) &_estack,


              NMI_Handler,                 /* NMI Handler                  */

              HardFault_Handler,           /* Hard Fault Handler           */

              MemManage_Handler,           /* MPU Fault Handler            */

              BusFault_Handler,            /* Bus Fault Handler            */

              UsageFault_Handler,          /* Usage Fault Handler          */

              0,                           /* Reserved                     */

              0,                           /* Reserved                     */

              0,                           /* Reserved                     */

              0,                           /* Reserved                     */

              SVC_Handler,                 /* SVCall Handler               */

              DebugMonitor_Handler,        /* Debug Monitor Handler        */

              0,                           /* Reserved                     */

              PendSV_Handler,              /* PendSV Handler               */

              SysTick_Handler,             /* SysTick Handler              */


              /* External Interrupts */

              DMA0_IRQHandler,          /* DMA channel 0 transfer complete interrupt */

              DMA1_IRQHandler,          /* DMA channel 1 transfer complete interrupt */

              DMA2_IRQHandler,          /* DMA channel 2 transfer complete interrupt */


To locate the interrupt vector table on the correct memory address (as defined by the processor manual), the linker configuration script has to look something like this:



  FLASH (rx)  : ORIGIN = 0x00000000, LENGTH = 32K     

  RAM (rwx)   : ORIGIN = 0x1FFFF000, LENGTH = 8K



/* Define output sections */



  /* The startup code goes first into FLASH */

  .isr_vector :


    . = ALIGN(4);

    KEEP(*(.isr_vector)) /* Startup code */

    . = ALIGN(4);

  } >FLASH

This illustration explains what happens. The normal application software executes until a hardware interrupt event occurs (in this case, the UART channel 0 is now ready to send one more character). The UART peripheral module raises an interrupt flag, that causes the processor to halt the execution.

The processor looks up the address of the interrupt handler function for the UART0_Tx_Interrupt (interrupt source number 25 in this processor, according to the example). It then starts to execute the interrupt handler function and returns to the interrupted application software execution thread once completed. It can be noted that in the example provided in this illustration, the processor defines the interrupt vector table to start on the address 0x00F40020.



To wrap this up, we can conclude that device driver libraries act as an interface between the hardware functionalities and the application software. The device driver library should isolate hardware dependent code from the application software as much as possible, and act as a hardware abstraction layer (HAL).

The device driver library contains API functions that enable application software developers to access hardware functionalities. Device driver functions are typically grouped into initialization functions, configuration functions, runtime control functions, and sometimes also shutdown functions.

Device drivers are heavily device dependent and read and write bits and bitmasks into bit fields of Special Function Registers (SFR’s).

Additionally, a device driver library must sometimes respond to hardware events. This is done using interrupt handler functions, that are never called by the application software. Instead, these are started automatically by the processor.

The processor uses an interrupt vector table to determine what code should be executed when different types of hardware events fire.

Topics: Embedded Software Development