For a long time, one line in embedded C code confused me: GPIOA->ODR |= (1 << 5);. It looked like I was just setting a bit in a variable. But there was no GPIOA variable I’d declared anywhere in my code. Eventually I learned the truth — GPIOA was a pointer to a fixed hardware address, and writing to it wasn’t manipulating memory at all in the traditional sense; it was directly flipping a physical pin on the chip. That was my introduction to memory-mapped I/O, one of the most foundational concepts in embedded systems programming.
What Is Memory-Mapped I/O?
Memory-mapped I/O (MMIO) is a technique where hardware peripherals — GPIO controllers, timers, ADCs, communication interfaces, and so on — are made accessible to the CPU by assigning them addresses within the same address space normally used for RAM and Flash memory. Instead of using special, separate instructions to talk to hardware devices, the CPU simply reads from or writes to specific memory addresses, and the underlying hardware intercepts those reads and writes to perform the actual peripheral operation.
In other words: from the CPU’s perspective, controlling a GPIO pin looks exactly like reading or writing an ordinary variable in memory. The difference is entirely in what’s physically wired to that address.
graph TD
CPU[CPU Core] -->|Read/Write to Address| BUS[System Bus]
BUS --> RAM[0x20000000 - RAM]
BUS --> FLASH[0x08000000 - Flash]
BUS --> GPIOREG[0x40020000 - GPIOA Registers]
BUS --> TIMERREG[0x40000000 - Timer Registers]
BUS --> ADCREG[0x40012000 - ADC Registers]
The Alternative: Port-Mapped I/O
To understand why MMIO matters, it helps to know the alternative approach, used historically in some architectures (notably x86): port-mapped I/O (PMIO). In PMIO, peripherals live in a completely separate address space from memory, accessed using special dedicated instructions (IN and OUT on x86) rather than ordinary load/store instructions.
graph LR
subgraph "Memory-Mapped I/O"
A1[CPU] -->|Standard LOAD/STORE instructions| B1[Unified Address Space: RAM + Peripherals]
end
subgraph "Port-Mapped I/O"
A2[CPU] -->|Standard LOAD/STORE| B2[Memory Address Space]
A2 -->|Special IN/OUT instructions| C2[Separate I/O Address Space]
end
Most embedded microcontrollers — ARM Cortex-M based chips especially — use memory-mapped I/O exclusively. There’s no separate “I/O instruction” at all; everything, from ordinary variables to hardware peripheral control, happens through the same load/store instructions.
Why Memory-Mapped I/O Makes Sense for Embedded Systems
- Simplicity: The CPU doesn’t need a separate instruction set just for I/O. Any instruction that can read or write memory can also read or write a peripheral register.
- Compiler-friendliness: High-level languages like C can express peripheral access using ordinary pointer syntax, structs, and arrays — no special compiler extensions required.
- Uniformity: The same addressing, caching, and bus-arbitration logic used for RAM applies to peripherals, simplifying the overall system architecture.
The Memory Map: Where Peripherals Live
Every microcontroller defines a memory map — a fixed layout describing which address ranges correspond to Flash, RAM, and each peripheral’s control registers. This map is documented in the chip’s reference manual and is absolutely essential reading for any embedded developer working close to the hardware.
Here’s a simplified example based loosely on an STM32F4 chip:
graph TD
A["0x00000000 - 0x0007FFFF<br/>Flash Memory (Program Code)"]
B["0x20000000 - 0x2001FFFF<br/>SRAM (Variables, Stack, Heap)"]
C["0x40000000 - 0x4000FFFF<br/>APB1 Peripherals (Timers, I2C, USART)"]
D["0x40010000 - 0x4001FFFF<br/>APB2 Peripherals (ADC, SPI1, USART1)"]
E["0x40020000 - 0x4002FFFF<br/>AHB1 Peripherals (GPIO Ports)"]
F["0xE000E000 - 0xE000EFFF<br/>Core Peripherals (NVIC, SysTick)"]
A --> B --> C --> D --> E --> F
Each peripheral occupies a specific range of addresses, and within that range, individual registers control specific behaviors — mode configuration, data input/output, status flags, and interrupt enables.
How This Looks in Practice: Registers as Structs
In embedded C, peripheral registers are typically represented as C structs mapped onto fixed memory addresses using pointers. Here’s a simplified illustration of how a GPIO peripheral might be defined:
typedef struct {
volatile uint32_t MODER; // Mode register (offset 0x00)
volatile uint32_t OTYPER; // Output type register (offset 0x04)
volatile uint32_t OSPEEDR; // Output speed register (offset 0x08)
volatile uint32_t PUPDR; // Pull-up/down register (offset 0x0C)
volatile uint32_t IDR; // Input data register (offset 0x10)
volatile uint32_t ODR; // Output data register (offset 0x14)
} GPIO_TypeDef;
#define GPIOA ((GPIO_TypeDef *) 0x40020000)
With this definition, GPIOA->ODR isn’t accessing a normal variable in RAM — it’s dereferencing a pointer set to a fixed hardware address, 0x40020000 + 0x14. When the CPU writes to that address, the write is routed by the system bus not to RAM, but directly to the GPIO peripheral’s output-data register, immediately affecting the voltage on physical pins.
GPIOA->ODR |= (1 << 5); // Sets PA5 high — turns on connected LED
The Critical Role of volatile
Notice the volatile keyword in the struct definition above. This is essential in memory-mapped I/O. Without it, the compiler might optimize away what it thinks is a “redundant” read or write to what it assumes is an ordinary variable — but peripheral registers can change unpredictably (from the CPU’s point of view) due to external hardware events, and every read or write to them has a real, physical side effect that must not be optimized away.
// Without volatile, this loop might be "optimized" into an infinite loop
// or have the read eliminated entirely, because the compiler doesn't know
// the peripheral status register can change on its own.
while (!(USART1->SR & USART_SR_TXE)); // Wait for transmit buffer empty
A Complete Example: Configuring and Using a Peripheral via MMIO
Let’s walk through a full example — configuring a GPIO pin as output and toggling it — entirely through memory-mapped registers.
#define RCC_AHB1ENR (*(volatile uint32_t *)0x40023830)
#define GPIOA_MODER (*(volatile uint32_t *)0x40020000)
#define GPIOA_ODR (*(volatile uint32_t *)0x40020014)
int main(void) {
RCC_AHB1ENR |= (1 << 0); // Enable clock for GPIOA (bit 0)
GPIOA_MODER |= (1 << (5 * 2)); // Set pin 5 to output mode (01)
while (1) {
GPIOA_ODR ^= (1 << 5); // Toggle pin 5
for (volatile int i = 0; i < 1000000; i++); // crude delay
}
}
Every line here is a direct memory access, yet every line has a real electrical effect on the chip — enabling a clock domain, configuring a pin’s mode, and toggling its voltage state.
Sequence of a Memory-Mapped Write
sequenceDiagram
participant CPU as CPU Core
participant Bus as System Bus
participant Decoder as Address Decoder
participant GPIO as GPIO Peripheral Hardware
participant Pin as Physical Pin
CPU->>Bus: STORE instruction to address 0x40020014
Bus->>Decoder: Route based on address range
Decoder->>GPIO: Forward write to GPIO peripheral
GPIO->>Pin: Update physical pin voltage
This sequence illustrates something important: a “memory write” in an embedded system isn’t necessarily touching memory at all. The address decoder on the system bus determines, based on the address, whether the write goes to actual RAM or gets routed to a peripheral’s hardware logic instead.
Memory-Mapped I/O and Hardware Abstraction Layers
Because raw register manipulation is error-prone and hard to read, most professional embedded projects use a Hardware Abstraction Layer (HAL) — vendor-provided libraries (like STM32’s HAL or Espressif’s ESP-IDF drivers) that wrap memory-mapped register access behind more readable function calls:
// Using STM32 HAL instead of raw register access
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);
Under the hood, this HAL function still performs the exact same memory-mapped register write — it’s just wrapped in a friendlier, more maintainable interface.
Frequently Asked Questions
Is memory-mapped I/O slower than direct register access? Not meaningfully in most cases — MMIO uses the same load/store instructions as regular memory access, so there’s no special performance penalty simply for being I/O. Speed is more affected by bus architecture and clock domains than by the MMIO concept itself.
Why do I need to know the exact memory addresses of peripherals? In practice, you rarely need to memorize raw addresses — chip vendors provide header files (like STM32’s CMSIS device headers) that define all the peripheral structs and addresses for you, similar to the GPIOA example above. Understanding the concept, though, helps you debug at a low level and understand what your code is actually doing.
Does memory-mapped I/O apply to microprocessors running Linux too? Yes, at the hardware level, but Linux abstracts it away through device drivers and the kernel, so application developers typically interact with peripherals through file-system interfaces (like /sys/class/gpio) or standard driver APIs rather than raw memory addresses.
Summary
Memory-mapped I/O is the technique that lets a CPU control hardware peripherals using the same load/store instructions it uses for ordinary memory, by assigning peripheral registers fixed addresses within the processor’s address space. This is why embedded C code can control a GPIO pin, configure a timer, or read an ADC value using simple pointer dereferences and struct field access — the compiler doesn’t need to know or care that it’s ultimately manipulating physical hardware rather than RAM. Understanding memory-mapped I/O is essential for anyone writing close-to-hardware embedded firmware, because it’s the bridge between abstract C code and the physical behavior of a chip.
References and Further Reading
- ARM Cortex-M Memory Map Documentation — https://developer.arm.com/documentation
- STM32F4 Reference Manual (Memory and Bus Architecture) — https://www.st.com/en/microcontrollers-microprocessors/stm32-32-bit-arm-cortex-mcus.html
- CMSIS (Cortex Microcontroller Software Interface Standard) — https://developer.arm.com/tools-and-software/embedded/cmsis
- Espressif ESP32 Technical Reference Manual — https://docs.espressif.com/projects/esp-idf/en/latest/esp32/
- Microchip AVR Register Summary Documentation — https://www.microchip.com/en-us/products/microcontrollers-and-microprocessors/8-bit-mcus/avr-mcus
