Memory-Mapped I/O and Peripheral Drivers
Most microcontrollers expose peripherals as memory-mapped registers. Reading or writing an address can change GPIO pins, start an ADC conversion, clear an interrupt flag, transmit a UART byte, or enable a timer. The C syntax looks like ordinary memory access, but the electrical result happens outside the C abstract machine.
Learning Objectives
By the end of this lesson, you should be able to:
- explain memory-mapped I/O;
- represent a peripheral register block in C;
- separate low-level register access from a driver interface;
- recognize status flags, control bits, and initialization order.
Memory and Registers Share an Address Map
The CPU uses ordinary load and store instructions. The address determines whether the access reaches RAM or a peripheral.
That address is assigned by the MCU vendor. Firmware must use the exact base address, register offset, bit meaning, and access rule from the reference manual.
Register Block Pattern
#include <stdint.h>
typedef struct {
volatile uint32_t CR;
volatile uint32_t SR;
volatile uint32_t DR;
volatile uint32_t BRR;
} uart_regs_t;
#define UART0_BASE 0x40004000u
#define UART0 ((uart_regs_t *)UART0_BASE)
Each field must match the datasheet offset. Reserved gaps must be represented if the hardware layout requires them.
Example with a reserved gap:
typedef struct {
volatile uint32_t CR; /* offset 0x00 */
volatile uint32_t SR; /* offset 0x04 */
uint32_t RESERVED0[2]; /* offsets 0x08, 0x0C */
volatile uint32_t DR; /* offset 0x10 */
} spi_regs_t;
Use compile-time checks when the toolchain supports them:
#include <stddef.h>
_Static_assert(offsetof(spi_regs_t, DR) == 0x10u, "SPI DR offset mismatch");
Driver Interface
void uart_init(uart_regs_t *uart, uint32_t baud_divider);
bool uart_tx_ready(const uart_regs_t *uart);
void uart_write_byte(uart_regs_t *uart, uint8_t byte);
Passing a register pointer makes the driver reusable across UART instances.
The higher-level application should not need to know the bit number of UART_SR_TX_EMPTY. It should call uart_tx_ready() or a nonblocking transmit function.
Implementation Sketch
#define UART_CR_ENABLE (1u << 0)
#define UART_CR_TX_ENABLE (1u << 1)
#define UART_SR_TX_EMPTY (1u << 7)
void uart_init(uart_regs_t *uart, uint32_t baud_divider)
{
uart->CR = 0u;
uart->BRR = baud_divider;
uart->CR = UART_CR_ENABLE | UART_CR_TX_ENABLE;
}
bool uart_tx_ready(const uart_regs_t *uart)
{
return (uart->SR & UART_SR_TX_EMPTY) != 0u;
}
void uart_write_byte(uart_regs_t *uart, uint8_t byte)
{
while (!uart_tx_ready(uart)) {
/* wait */
}
uart->DR = byte;
}
This blocking transmit function is simple and clear, but it is not always appropriate. Do not call it from a high-priority interrupt or a real-time loop unless the worst-case wait is acceptable.
Initialization Order Matters
Peripheral startup often requires:
- enable the peripheral clock;
- configure pins and alternate functions;
- reset the peripheral;
- configure baud rate or timing;
- clear pending status flags;
- enable the peripheral;
- enable interrupts only after the peripheral is ready.
Skipping steps can produce intermittent failures that look like bad hardware.
For a UART, a typical order is:
Common Register Types
| Register type | Behavior |
|---|---|
| control | software writes configuration and enable bits |
| status | hardware reports flags |
| data | transmit, receive, or conversion values |
| clear | writing 1 may clear selected flags |
| set/reset | atomic bit updates without read-modify-write |
The register type determines the code pattern. A status register with write-one-to-clear bits must not be handled like a normal read/write control register.
Polling, Timeouts, and Errors
Polling loops should usually have a timeout:
bool uart_write_byte_timeout(uart_regs_t *uart, uint8_t byte, uint32_t timeout)
{
while (!uart_tx_ready(uart)) {
if (timeout == 0u) {
return false;
}
timeout--;
}
uart->DR = byte;
return true;
}
Real drivers often use timer-based timeouts rather than loop counts, because loop counts change with clock speed and optimization.
Safety and Bring-Up Guidance
During board bring-up:
- confirm the supply voltage and clock tree before enabling peripherals;
- keep high-current outputs disabled until the pin mode is verified;
- clear old interrupt flags before enabling interrupts;
- read back configuration registers when the hardware supports it;
- use a logic analyzer or oscilloscope to confirm pin behavior.
For external loads, use proper drivers, current limiting, flyback protection, and isolation where required. A GPIO register write can damage hardware if the circuit around the pin is unsafe.
Common Mistakes
- Defining the wrong register offsets.
- Forgetting reserved fields in a struct.
- Using read-modify-write on write-one-to-clear flags.
- Enabling interrupts before clearing old flags.
- Hiding blocking waits inside a function that is called from time-critical code.
- Assuming all register bits reset to zero without checking the manual.
- Forgetting to enable the peripheral clock before writing configuration registers.
- Treating loop-count timeouts as real time across different CPU clocks.
Summary
Memory-mapped I/O lets C code control hardware through ordinary-looking loads and stores. Good drivers isolate register details, follow datasheet initialization order, use volatile, respect each register's access rules, and avoid blocking behavior where timing matters.
Further Reading
- ARM CMSIS: Device Peripheral Access
- Memfault: Practical Firmware Register Access
- SEI CERT C: Volatile and Side Effects