Skip to content
Home » Embedded Systems » Device Drivers Development – Introduction

Device Drivers Development – Introduction

Arrow diagram showing the role of device drivers as the bridge between Application and System Software on the left, Device Drivers in the middle, and Hardware on the right, illustrating how drivers abstract hardware access
Embedded Systems Learning Path
Part 98 of 129View Full Path →

KEY TAKEAWAYS

  • A device driver is a software layer that provides a standard interface between hardware peripherals and application code
  • Drivers abstract register-level details so applications can use devices through simple functions like read(), write(), and init()
  • The typical driver structure includes initialization, configuration, data transfer (read/write), interrupt handling, and shutdown
  • Well-designed drivers improve portability — swapping hardware only requires changing the driver, not the application
  • In bare-metal embedded systems, you write drivers directly against hardware registers; in Linux, drivers follow a kernel framework (character, block, or network)

Have you ever wondered why a computer sometimes fails to detect a device when you plug it in via USB? Or why deleting files from C:WindowsSystem32drivers can break your entire system? The answer is device drivers — the invisible software layer that makes hardware usable.

In embedded systems, device drivers are even more critical. There is no operating system to fall back on in many cases. You, the firmware developer, write the code that directly talks to every peripheral — the UART, the SPI flash, the ADC, the GPIO pins. Understanding how to structure this code properly is one of the most important skills in embedded development.

What is a Device Driver?

A device driver is software that directly controls and interacts with a hardware peripheral. It forms an abstraction layer between the application code and the hardware registers. The application does not need to know which specific GPIO pin an LED is connected to, or what baud rate register value corresponds to 9600 bps — the driver handles all of that.

Think of it as a translator. Your application speaks in terms of actions: “turn on the LED”, “send this byte”, “read the temperature.” The driver translates these into the specific register reads and writes that the hardware understands.

Device driver models
Fig.1. Embedded Systems Model — Device drivers sit between the application layer and hardware

Why Device Drivers Matter in Embedded Systems

In a desktop environment, drivers come pre-installed or auto-downloaded. In embedded systems, you write them yourself. Here is why structuring them properly matters:

  • Portability: If you change from an STM32 to an NXP microcontroller, only the driver layer needs to change. Your application code stays the same.
  • Testability: With a clean driver interface, you can mock the driver during unit testing without needing real hardware.
  • Readability: Application code that says uart_send("Hello") is far easier to understand than code that directly manipulates UDR0 and UCSR0B registers.
  • Reusability: A well-written SPI driver can be reused across multiple projects that use the same microcontroller family.

Types of Device Drivers

Device drivers are typically classified into two categories based on where the hardware sits:

Architecture-Specific Drivers

These manage hardware that is integrated into the processor itself. Examples include:

  • On-chip memory controllers
  • Memory Management Units (MMUs)
  • Floating-point units
  • Integrated timers and watchdog
  • On-chip ADC/DAC peripherals

These drivers are tightly coupled to the processor architecture and cannot be reused on a different processor family without significant modification.

Generic (Board-Level) Drivers

These manage external hardware located on the board but not inside the processor. Examples include:

  • External sensors (temperature, accelerometer, pressure)
  • Display controllers (LCD, OLED)
  • External flash memory (W25Q series over SPI)
  • Communication modules (WiFi, Bluetooth, LoRa)

Generic drivers typically depend on an architecture-specific driver underneath. For example, a temperature sensor driver uses the I2C driver, which in turn uses the processor’s I2C peripheral registers.

Anatomy of a Device Driver

Regardless of what hardware a driver controls, most drivers share a common set of operations. Here is the typical structure:

1. Initialization (init)

Configures the hardware upon power-on or reset. This includes setting clock sources, configuring pins, setting default operating modes, and enabling the peripheral.

void uart_init(uint32_t baud_rate) {
    // Enable clock for UART peripheral
    RCC->APB2ENR |= RCC_APB2ENR_USART1EN;

    // Configure GPIO pins for TX (PA9) and RX (PA10)
    GPIOA->CRH &= ~(0xFF <CRH |= (0x0B <CRH |= (0x04 <BRR = SystemCoreClock / baud_rate;

    // Enable UART: TX, RX, and USART
    USART1->CR1 = USART_CR1_TE | USART_CR1_RE | USART_CR1_UE;
}

2. Read and Write

The core data transfer functions. These are what the application calls most frequently.

void uart_send_byte(uint8_t data) {
    while (!(USART1->SR & USART_SR_TXE));  // Wait until TX buffer is empty
    USART1->DR = data;
}

uint8_t uart_read_byte(void) {
    while (!(USART1->SR & USART_SR_RXNE));  // Wait until data is received
    return (uint8_t)USART1->DR;
}

void uart_send_string(const char *str) {
    while (*str) {
        uart_send_byte(*str++);
    }
}

3. Interrupt Handling

Polling (waiting in a loop) works for simple cases, but real-world drivers use interrupts for efficiency. The interrupt service routine (ISR) responds to hardware events without blocking the main application.

#define RX_BUFFER_SIZE 64
static volatile uint8_t rx_buffer[RX_BUFFER_SIZE];
static volatile uint8_t rx_head = 0;

void USART1_IRQHandler(void) {
    if (USART1->SR & USART_SR_RXNE) {
        rx_buffer[rx_head] = (uint8_t)USART1->DR;
        rx_head = (rx_head + 1) % RX_BUFFER_SIZE;
    }
}

4. Configuration and Control

Functions that allow the application to modify driver behavior at runtime — changing baud rate, enabling/disabling the peripheral, or switching operating modes.

void uart_set_baud(uint32_t baud_rate) {
    USART1->CR1 &= ~USART_CR1_UE;         // Disable UART before changing
    USART1->BRR = SystemCoreClock / baud_rate;
    USART1->CR1 |= USART_CR1_UE;          // Re-enable
}

void uart_enable_rx_interrupt(void) {
    USART1->CR1 |= USART_CR1_RXNEIE;
    NVIC_EnableIRQ(USART1_IRQn);
}

5. Shutdown

Safely disabling the peripheral, typically used before entering low-power modes or when the peripheral is no longer needed.

void uart_deinit(void) {
    USART1->CR1 = 0;                      // Disable UART
    RCC->APB2ENR &= ~RCC_APB2ENR_USART1EN; // Disable clock to save power
}

Structuring a Driver: Header and Source Files

A clean driver separates the interface (what the application sees) from the implementation (how it talks to hardware). This is done using header and source files.

uart_driver.h — The public interface:

#ifndef UART_DRIVER_H
#define UART_DRIVER_H

#include <stdint.h>

void    uart_init(uint32_t baud_rate);
void    uart_deinit(void);
void    uart_send_byte(uint8_t data);
void    uart_send_string(const char *str);
uint8_t uart_read_byte(void);
void    uart_enable_rx_interrupt(void);

#endif

The application only includes the header file. It never needs to know about USART1->SR, GPIOA->CRH, or any register. If you move to a different microcontroller, you rewrite uart_driver.c but uart_driver.h stays the same — and so does every file that includes it.

Bare-Metal vs Linux Device Drivers

The approach to writing drivers differs significantly depending on whether you are working bare-metal or on a Linux-based embedded system:

AspectBare-MetalLinux
Register accessDirect memory-mapped I/OThrough kernel APIs (ioremap, readl/writel)
StructureFree-form, your choiceMust follow kernel framework (char/block/net)
Interrupt handlingDirect ISR in vector tablerequest_irq() with top/bottom half
User interactionFunction calls from application/dev file nodes, ioctl, read/write syscalls
DebuggingJTAG, printf over UARTprintk, dmesg, ftrace

Common Mistakes When Writing Device Drivers

Having written and reviewed many drivers, here are the most frequent mistakes developers make:

  • Skipping the volatile keyword: Hardware registers can change at any time (by the hardware itself or an interrupt). Without volatile, the compiler may optimize away your register reads, causing the driver to miss status changes.
  • Blocking in interrupts: An ISR should be fast. Never put delay(), printf(), or long loops inside an interrupt handler. Set a flag or write to a buffer, and let the main loop handle the rest.
  • Hardcoding pin assignments: If your driver has GPIOB->ODR |= (1 << 5) scattered throughout, changing the pin becomes a painful search-and-replace. Define pins in one place using macros or a configuration structure.
  • No error handling: What happens if a sensor does not acknowledge an I2C read? If the driver returns garbage data silently, the application has no way to know something went wrong. Always return status codes.
  • Forgetting to enable clocks: On ARM microcontrollers, peripherals are clock-gated by default. If you skip enabling the peripheral clock in your init function, every subsequent register write is ignored silently — one of the most frustrating bugs to track down.

A Practical Example: GPIO Driver

Let us look at a minimal but complete GPIO driver to see all these concepts in practice. This driver provides a clean interface to configure and use GPIO pins:

/* gpio_driver.h */
#ifndef GPIO_DRIVER_H
#define GPIO_DRIVER_H

#include <stdint.h>

typedef enum {
    GPIO_MODE_INPUT,
    GPIO_MODE_OUTPUT
} gpio_mode_t;

typedef struct {
    volatile uint32_t *port_base;  // Base address of GPIO port
    uint8_t pin;                    // Pin number (0-15)
} gpio_pin_t;

void gpio_init(gpio_pin_t *pin, gpio_mode_t mode);
void gpio_write(gpio_pin_t *pin, uint8_t value);
uint8_t gpio_read(gpio_pin_t *pin);
void gpio_toggle(gpio_pin_t *pin);

#endif

The application uses it like this:

gpio_pin_t led = { .port_base = GPIOB_BASE, .pin = 5 };
gpio_pin_t button = { .port_base = GPIOA_BASE, .pin = 0 };

gpio_init(&led, GPIO_MODE_OUTPUT);
gpio_init(&button, GPIO_MODE_INPUT);

while (1) {
    if (gpio_read(&button)) {
        gpio_toggle(&led);
    }
}

Notice how the application code says nothing about registers, clock enables, or bit manipulation. All of that complexity lives inside gpio_driver.c. If you swap from an STM32 to an AVR, the application code above does not change at all.

What Comes Next

Writing device drivers is where you truly learn how embedded hardware works. Every new peripheral you bring up — a UART, an SPI flash chip, a motor controller — teaches you how to read datasheets, understand timing requirements, and translate hardware behavior into clean, reliable code.

The best way to get started is to pick a peripheral on your development board and write a driver from scratch, without using the vendor’s HAL library. Start with GPIO (the simplest), then move to UART, then SPI or I2C. Each one builds on the skills from the previous one.

Leave a Reply

Your email address will not be published. Required fields are marked *