Memory-mapped I/O in C is how embedded software controls hardware. Instead of using special CPU instructions to access devices, the processor maps hardware control registers to ordinary memory addresses. A C pointer dereferenced at that address reads or writes the hardware directly — toggling an LED, configuring a timer, or sending a byte over UART reduces to a pointer assignment.
How Memory-Mapped I/O Works
Modern microcontrollers (STM32, nRF52, RP2040, AVR with MMIO) place peripheral registers at fixed addresses in the address space:
Address space (example — ARM Cortex-M): 0x00000000 – 0x1FFFFFFF → Flash (code) 0x20000000 – 0x3FFFFFFF → SRAM 0x40000000 – 0x5FFFFFFF → Peripheral registers ← MMIO lives here 0xE0000000 – 0xFFFFFFFF → System / debug
Reading or writing a peripheral register is identical to reading or writing a variable — because it is a variable, just one that controls hardware instead of storing software state.
Accessing a Register via a Pointer
/* Read the value at address 0x40020000 */
unsigned int val = *(volatile unsigned int *)0x40020000;
/* Write 0x05 to address 0x40020014 */
*(volatile unsigned int *)0x40020014 = 0x05;
The cast constructs a pointer of the right type. The dereference (*) performs the actual access. The volatile qualifier is non-negotiable — without it, the compiler may eliminate the read (seeing no software use of the result) or cache the write (seeing no software variable changed).
Register Access Macros
Raw casts are hard to read. The standard embedded idiom is to define a macro that wraps the cast:
/* Macro to define a memory-mapped register */
#define MMIO32(addr) (*(volatile unsigned int *)(addr))
/* GPIO port A (STM32F4-style addresses — illustrative) */
#define GPIOA_BASE 0x40020000UL
#define GPIOA_MODER MMIO32(GPIOA_BASE + 0x00) /* mode register */
#define GPIOA_OTYPER MMIO32(GPIOA_BASE + 0x04) /* output type */
#define GPIOA_ODR MMIO32(GPIOA_BASE + 0x14) /* output data reg */
#define GPIOA_IDR MMIO32(GPIOA_BASE + 0x10) /* input data reg */
/* RCC (Reset and Clock Control) */
#define RCC_BASE 0x40023800UL
#define RCC_AHB1ENR MMIO32(RCC_BASE + 0x30) /* peripheral clock */
/* Bit positions */
#define RCC_GPIOAEN (1u << 0)
#define GPIO_PIN_5 (1u << 5)
LED Blink — Complete MMIO Example
The following code is structured for an STM32-class Cortex-M MCU. It compiles as standard C; the actual hardware interaction happens only on device (running it on a desktop will do nothing harmful — the pointer writes go to unmapped addresses which the OS will fault). This illustrates the exact pattern used in real firmware:
#include <stdint.h>
/* ── Register definitions ─────────────────────────────────── */
#define MMIO32(addr) (*(volatile uint32_t *)(addr))
#define RCC_AHB1ENR MMIO32(0x40023830UL)
#define GPIOA_MODER MMIO32(0x40020000UL)
#define GPIOA_ODR MMIO32(0x40020014UL)
#define GPIOA_EN (1u << 0)
#define PA5_OUT_MODE (1u << 10) /* MODER[11:10] = 01 for output */
#define PA5_OUT_MASK (3u << 10) /* 2-bit field mask */
#define PA5 (1u << 5)
/* ── Software delay (not precise — hardware timer is better) ── */
static void delay(volatile uint32_t count)
{
while (count--)
;
}
/* ── Main ─────────────────────────────────────────────────── */
int main(void)
{
/* 1. Enable clock for GPIOA peripheral */
RCC_AHB1ENR |= GPIOA_EN;
/* 2. Configure PA5 as output (clear 2-bit field, then set 01) */
GPIOA_MODER &= ~PA5_OUT_MASK;
GPIOA_MODER |= PA5_OUT_MODE;
/* 3. Toggle LED forever */
while (1) {
GPIOA_ODR ^= PA5; /* toggle bit 5 */
delay(500000);
}
return 0;
}
How to Compile (for host — no hardware needed for building)
gcc -ansi -Wall -Wextra -o mmio_demo mmio_demo.c
# To cross-compile for Cortex-M (ARM GCC toolchain):
# arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -O2 -o mmio_demo.elf mmio_demo.c
Why Every MMIO Access Must Be volatile
Without volatile — what the compiler might generate at -O2:
GPIOA_ODR ^= PA5; ; compiler sees this write has no software effect
; it optimises it away entirely
delay(500000); ; compiler also removes (no visible side effects)
→ infinite loop with no hardware access
With volatile — what the compiler must generate:
ldr r0, =0x40020014 ; load GPIOA_ODR address
ldr r1, [r0] ; read current value
eor r1, r1, #0x20 ; XOR bit 5
str r1, [r0] ; write back — hardware sees it
bl delay ; called because argument is volatile
Struct-Based Register Maps
Real SDKs (STM32 HAL, CMSIS) define peripheral registers as structs, mapping each field to its offset:
typedef struct {
volatile uint32_t MODER; /* offset 0x00 */
volatile uint32_t OTYPER; /* offset 0x04 */
volatile uint32_t OSPEEDR; /* offset 0x08 */
volatile uint32_t PUPDR; /* offset 0x0C */
volatile uint32_t IDR; /* offset 0x10 */
volatile uint32_t ODR; /* offset 0x14 */
volatile uint32_t BSRR; /* offset 0x18 */
} GPIO_TypeDef;
#define GPIOA ((GPIO_TypeDef *)0x40020000UL)
/* Usage */
GPIOA->MODER &= ~PA5_OUT_MASK;
GPIOA->MODER |= PA5_OUT_MODE;
GPIOA->ODR ^= PA5;
Each field in the struct must be volatile. The struct must be packed or verified against the datasheet — unintended padding between fields will misalign register addresses and cause silent hardware bugs.
Bit Fields in Register Structs
typedef struct {
volatile unsigned int PE : 1; /* bit 0: parity enable */
volatile unsigned int PS : 1; /* bit 1: parity select */
volatile unsigned int STOP : 2; /* bits 3:2: stop bits */
volatile unsigned int M : 1; /* bit 4: word length */
volatile unsigned int : 27; /* reserved */
} UART_CR1_Bits;
Caution: C bit fields are portable in structure but not in bit ordering. Bit numbering within a word (MSB vs LSB first) is implementation-defined. Most embedded codebases prefer explicit masks and shifts over bit fields for register access, relying on bit fields only for compact non-hardware structs.
What This Teaches
- Pointers are addresses — MMIO makes this concrete: the hardware doesn’t distinguish a pointer dereference from a variable access; everything is a memory read or write at a physical address
- volatile is mandatory for MMIO: the compiler’s optimisation model assumes memory has no external observers; volatile is the mechanism to break that assumption for hardware-backed addresses
- Bit manipulation is the language of registers: every peripheral operation reduces to setting, clearing, or testing bits — the same four patterns from the bit manipulation page
- Abstraction layers: the progression from raw casts → macros → structs → HAL libraries all solve the same problem — wrapping the MMIO access in a readable, maintainable interface
Related Topics
- volatile Keyword in C – Full Explanation
- Bit Manipulation in C – Set, Clear, Toggle, Test
- Function Pointers in C – Callbacks and Dispatch Tables
- C Aptitude Questions and Answers
As an Amazon Associate we earn from qualifying purchases.
Recommended Book
The C Programming Language by K&R covers pointers and type casts thoroughly — the mental model behind MMIO. For hardware-specific embedded C, the ARM® Cortex®-M reference manuals from the silicon vendor are the definitive source. K&R also on Amazon.com.