Chapter 4: Hardware Abstraction and Device Drivers
Raw register access works for blinking an LED, but real firmware projects have dozens of peripherals, multiple developers, and thousands of lines of code. Writing GPIOD->ODR |= (1 << 12) everywhere is unmaintainable, error-prone, and impossible to test. This chapter shows how to wrap hardware access in clean, reusable driver interfaces and explains the layered architecture used in professional embedded codebases.
The Layered Architecture of Firmware
Most embedded projects organize code into horizontal layers, each with a specific responsibility:
┌─────────────────────────────────┐
│ Application │ Business logic, product features
├─────────────────────────────────┤
│ Middleware │ RTOS, file systems, protocols
├─────────────────────────────────┤
│ Hardware Abstraction │ High-level peripheral drivers
├─────────────────────────────────┤
│ Low-Level Drivers (LL) │ Register-level access
├─────────────────────────────────┤
│ Hardware (MCU) │ Physical silicon
└─────────────────────────────────┘
Each layer depends only on the layers below it. The application layer calls uart_send_string(), not USART2->DR = 'h'. The low-level driver knows the register addresses; the hardware abstraction layer knows how to configure a UART for 115200 baud; the application layer knows what data to send.
This separation provides several benefits:
- Testability: You can mock the HAL layer to test application logic.
- Portability: Moving to a different MCU requires changing only the lower layers.
- Team collaboration: Different developers can work on different layers simultaneously.
- Code reuse: The same UART driver can be used across multiple projects.
Writing Your First Driver: A GPIO Example
Let's transform the LED-blinking code from Chapter 3 into a proper driver.
The Header File: Public Interface
// gpio.h
#ifndef GPIO_H
#define GPIO_H
#include <stdint.h>
typedef enum {
GPIO_PIN_0 = (1 << 0),
GPIO_PIN_1 = (1 << 1),
GPIO_PIN_2 = (1 << 2),
// ... up to pin 15
GPIO_PIN_12 = (1 << 12),
} gpio_pin_t;
typedef enum {
GPIO_MODE_INPUT = 0,
GPIO_MODE_OUTPUT = 1,
GPIO_MODE_ALTERNATE = 2,
GPIO_MODE_ANALOG = 3,
} gpio_mode_t;
void gpio_init(GPIO_TypeDef *port, gpio_pin_t pin, gpio_mode_t mode);
void gpio_write(GPIO_TypeDef *port, gpio_pin_t pin, uint8_t value);
uint8_t gpio_read(GPIO_TypeDef *port, gpio_pin_t pin);
void gpio_toggle(GPIO_TypeDef *port, gpio_pin_t pin);
#endif
The Source File: Implementation
// gpio.c
#include "gpio.h"
void gpio_init(GPIO_TypeDef *port, gpio_pin_t pin, gpio_mode_t mode) {
// Find the pin number (0-15) from the pin mask
uint8_t pin_num = 0;
for (uint8_t i = 0; i < 16; i++) {
if (pin & (1 << i)) {
pin_num = i;
break;
}
}
// Clear the MODER bits for this pin
port->MODER &= ~(0b11 << (pin_num * 2));
// Set the new mode
port->MODER |= (mode << (pin_num * 2));
}
void gpio_write(GPIO_TypeDef *port, gpio_pin_t pin, uint8_t value) {
if (value) {
port->BSRR = pin; // Set pin
} else {
port->BSRR = (pin << 16); // Clear pin
}
}
uint8_t gpio_read(GPIO_TypeDef *port, gpio_pin_t pin) {
return (port->IDR & pin) ? 1 : 0;
}
void gpio_toggle(GPIO_TypeDef *port, gpio_pin_t pin) {
port->ODR ^= pin;
}
The application code is now much cleaner:
// main.c
#include "gpio.h"
int main(void) {
RCC_AHB1ENR |= RCC_AHB1ENR_GPIODEN;
gpio_init(GPIOD, GPIO_PIN_12, GPIO_MODE_OUTPUT);
while (1) {
gpio_toggle(GPIOD, GPIO_PIN_12);
delay_ms(500);
}
}
Opaque Pointers and Encapsulation
The GPIO example uses a transparent struct (GPIO_TypeDef), which is appropriate for simple peripherals. For complex peripherals, you often need to maintain additional state (buffers, counters, configuration). This is where opaque pointers come in.
An opaque pointer hides the struct definition from the caller. The header only declares the type; the implementation file defines it:
// uart.h
typedef struct uart_handle uart_handle_t;
uart_handle_t *uart_create(uint32_t base_addr, uint32_t baud);
void uart_send(uart_handle_t *handle, uint8_t byte);
uint8_t uart_receive(uart_handle_t *handle);
void uart_destroy(uart_handle_t *handle);
// uart.c
struct uart_handle {
USART_TypeDef *regs;
uint32_t baud;
uint8_t rx_buffer[256];
uint8_t tx_buffer[256];
volatile uint16_t rx_head;
volatile uint16_t rx_tail;
};
uart_handle_t *uart_create(uint32_t base_addr, uint32_t baud) {
static uart_handle_t instances[3]; // Support up to 3 UARTs
static int instance_count = 0;
if (instance_count >= 3) {
return NULL;
}
uart_handle_t *handle = &instances[instance_count++];
handle->regs = (USART_TypeDef *)base_addr;
handle->baud = baud;
handle->rx_head = 0;
handle->rx_tail = 0;
// Configure hardware registers...
return handle;
}
The caller never sees the internals of uart_handle_t. They interact only through the API functions. This encapsulation prevents accidental corruption of driver state and allows the implementation to change without affecting callers.
Integrating Vendor HALs and SDKs
Most MCU vendors provide a HAL (Hardware Abstraction Layer) or SDK. The STM32 HAL, NXP MCUXpresso SDK, and ESP-IDF are examples. These provide pre-written drivers for all on-chip peripherals.
The Vendor HAL Approach
STM32 HAL code looks like this:
UART_HandleTypeDef huart2;
huart2.Instance = USART2;
huart2.Init.BaudRate = 115200;
huart2.Init.WordLength = UART_WORDLENGTH_8B;
huart2.Init.StopBits = UART_STOPBITS_1;
huart2.Init.Parity = UART_PARITY_NONE;
HAL_UART_Init(&huart2);
HAL_UART_Transmit(&huart2, (uint8_t *)"Hello\n", 6, 100);
The HAL handles the register details, error checking, and clock configuration. It works, but it comes with trade-offs:
Pros:
- Faster development: you don't write drivers from scratch
- Vendor-supported and tested across their MCU families
- Handles edge cases and errata workarounds
Cons:
- Large code size (a HAL UART driver can be 2-4 KB)
- Sometimes inefficient (polling, busy waits, unnecessary overhead)
- Less control over timing and resource usage
- Vendor lock-in: moving to a different vendor means rewriting
The Hybrid Approach
Professional projects often use a hybrid: the vendor HAL for complex peripherals (USB, Ethernet, SD cards) and custom lightweight drivers for simple ones (GPIO, UART, SPI). The key is to isolate vendor-specific code behind your own interfaces:
// board_uart.h — your interface
void board_uart_init(uint32_t baud);
void board_uart_send(uint8_t *data, uint16_t len);
// board_uart.c — STM32-specific implementation
#include "stm32f4xx_hal.h"
static UART_HandleTypeDef huart2;
void board_uart_init(uint32_t baud) {
huart2.Instance = USART2;
huart2.Init.BaudRate = baud;
HAL_UART_Init(&huart2);
}
void board_uart_send(uint8_t *data, uint16_t len) {
HAL_UART_Transmit(&huart2, data, len, 100);
}
If you later switch to an NXP MCU, only board_uart.c changes. The application code remains untouched.
Datasheet-Driven Driver Development
Writing a driver for a new peripheral follows a consistent process:
-
Read the peripheral chapter in the reference manual. Understand the registers, their functions, and the recommended initialization sequence.
-
Identify the control registers (configuration) and data registers (I/O). Control registers are written during initialization. Data registers are read/written during operation.
-
Understand the initialization sequence. Many peripherals require a specific order of operations: enable clock, configure, enable peripheral, wait for ready.
-
Understand the status flags. Most peripherals have status registers indicating data ready, errors, or completion.
-
Write the driver incrementally. Start with initialization and basic transmit. Test. Add receive. Test. Add error handling.
Here is how this process looks for a simple SPI driver:
// spi.h
typedef struct {
SPI_TypeDef *regs;
uint32_t baud_prescaler;
uint8_t mode; // 0-3: CPOL/CPHA combination
} spi_config_t;
void spi_init(const spi_config_t *config);
uint8_t spi_transfer_byte(uint8_t data);
// spi.c
void spi_init(const spi_config_t *config) {
SPI_TypeDef *spi = config->regs;
// 1. Enable clock (handled elsewhere or via RCC)
// 2. Configure control register
spi->CR1 = (config->baud_prescaler << 3) |
(config->mode << 0) |
(1 << 2) | // Master mode
(1 << 6); // SPI enabled
// 3. Configure optional features
spi->CR2 = 0;
// 4. Clear any pending flags
(void)spi->DR;
(void)spi->SR;
}
uint8_t spi_transfer_byte(uint8_t data) {
SPI_TypeDef *spi = SPI1; // Using SPI1
// Wait until transmit buffer empty
while (!(spi->SR & (1 << 1))) {
// TXE: Transmit buffer empty
}
// Send data
spi->DR = data;
// Wait until receive buffer not empty
while (!(spi->SR & (1 << 0))) {
// RXNE: Receive buffer not empty
}
// Read received data
return spi->DR;
}
Error Handling in Drivers
Desktop developers are used to exceptions and error codes. Embedded drivers typically use one of these patterns:
Return Status Codes
typedef enum {
UART_OK = 0,
UART_ERR_TIMEOUT,
UART_ERR_OVERRUN,
UART_ERR_FRAMING,
UART_ERR_PARITY,
} uart_status_t;
uart_status_t uart_send_byte(uint8_t byte, uint32_t timeout_ms);
Callback Functions
typedef void (*uart_error_callback_t)(uart_status_t error);
void uart_set_error_callback(uart_error_callback_t callback);
Assertions and Fatal Errors
void uart_send_byte(uint8_t byte) {
assert(uart_is_initialized());
if (uart_has_error()) {
// Log error, reset peripheral, or halt
hard_fault_handler();
}
// Send data...
}
The appropriate pattern depends on the project. Safety-critical systems (automotive, medical) tend to use explicit error codes and exhaustive checking. Consumer products may use assertions and fatal errors for simplicity.
What to Actually Learn vs. Look Up
Look up (as needed):
- Specific register names and bit fields for a peripheral
- Vendor HAL function signatures and configuration structures
- Recommended initialization sequences from the reference manual
Deeply understand:
- How to structure a driver: header as interface, source as implementation
- The layered architecture: application, middleware, HAL, LL
- The trade-offs of vendor HALs vs. custom drivers
- The process of reading a datasheet to extract what you need
- How to isolate vendor-specific code behind portable interfaces
Key Takeaways
- Layered architecture is the standard. Application code talks to drivers, not registers.
- The header file is the contract. It defines the API; the implementation can change freely.
- Opaque pointers provide encapsulation. Hide driver state from callers to prevent misuse.
- Vendor HALs speed development but add overhead. Isolate them behind your own interfaces.
- Datasheet-driven development follows a repeatable process. Understand control registers, status flags, and initialization sequences.
- Error handling in embedded is explicit. No exceptions; use status codes, callbacks, or assertions.
Chapter 5 will explore interrupts, the foundation of real-time embedded systems, and show how drivers integrate with interrupt-driven I/O.