Chapter 3: The Art of Memory-Mapped I/O (Registers)

Updated on 2026-08-30ai-generated

In desktop programming, hardware interaction is abstracted behind layers of OS APIs and device drivers. In embedded C, you talk to hardware directly by reading and writing to specific memory addresses. A UART transmit operation is not a function call to a library; it is a store instruction to a specific address. This chapter demystifies memory-mapped I/O and teaches you the idioms that make register access readable, safe, and maintainable.

What Is Memory-Mapped I/O?

On ARM Cortex-M and most embedded architectures, peripherals are controlled through registers that are mapped into the processor's address space. A register is simply a hardware flip-flop or latch connected to the CPU's data bus. When the CPU writes to a specific address, the value is latched by the peripheral hardware, which then interprets those bits as configuration or data.

For example, on an STM32F407, the GPIO port D is controlled through a set of registers starting at address 0x40020C00:

AddressRegisterPurpose
0x40020C00MODERMode: input, output, alternate
0x40020C04OTYPEROutput type: push-pull, open-drain
0x40020C08OSPEEDROutput speed
0x40020C0CPUPDRPull-up/pull-down
0x40020C10IDRInput data register
0x40020C14ODROutput data register

What does 0x mean?
The prefix 0x indicates that the number is written in hexadecimal (base‑16). In embedded programming, addresses and register values are almost always shown in hex because each hex digit corresponds exactly to 4 bits, making it easy to see bit patterns. For example, 0x40020C00 is the decimal number 1,073,741,824, but we use hex for readability.

These addresses are documented in the reference manual. You do not memorize them; you look them up when needed. But you must understand the pattern: each peripheral is a block of addresses, and each address controls a specific aspect of that peripheral.

Accessing Registers with Volatile Pointers

The most direct way to access a register is through a pointer:

uint32_t *pMODER = (uint32_t *)0x40020C00;
*pMODER = 0x00000001;  // Set GPIO D pin 0 to output mode

This works, but it has a critical flaw: the compiler does not know that *pMODER can change independently of program flow. The hardware can modify the input data register (IDR) at any time. An interrupt can change the output data register (ODR) between two reads. Without volatile, the compiler may optimize away reads or writes, cache values in registers, or reorder operations.

volatile uint32_t *pIDR = (volatile uint32_t *)0x40020C10;

// Wait until pin 2 goes high
while ((*pIDR & (1 << 2)) == 0) {
    // Without volatile, the compiler might read *pIDR once
    // and loop forever with a stale value
}

volatile tells the compiler: "Every access to this memory location is an observable side effect. Do not optimize, cache, or reorder these accesses." This is essential for all memory-mapped registers.

Misconception: "volatile makes my variables thread-safe."

Reality: volatile only prevents compiler optimization of memory accesses. It does not provide atomicity, memory barriers, or synchronization between CPU cores. For interrupt-safe shared variables, you need additional mechanisms (covered in Chapter 5).

Defining Register Maps: Struct-Based Access

Raw address macros become unmanageable as the number of peripherals grows. The standard practice is to define a struct that mirrors the register layout of a peripheral:

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
} GPIO_TypeDef;

#define GPIOA ((GPIO_TypeDef *)0x40020000)
#define GPIOB ((GPIO_TypeDef *)0x40020400)
#define GPIOC ((GPIO_TypeDef *)0x40020800)
#define GPIOD ((GPIO_TypeDef *)0x40020C00)

Now you can access registers with clean syntax:

GPIOA->MODER = 0x00000001;  // Set GPIO A pin 0 to output
GPIOD->ODR |= (1 << 12);    // Set GPIO D pin 12 high

This is the pattern used by vendor HALs (STM32 HAL, NXP SDK, etc.) and is the de facto standard in embedded C.

Bitwise Operations: Setting, Clearing, and Toggling

Register bits are the language of hardware. You need to master bitwise operations fluently. If you come from a high‑level language, these operators may be new – they work on the individual binary bits of integer values.

Setting Bits (OR)

// Set bit 5 (make it 1) without affecting other bits
GPIOA->ODR |= (1 << 5);

// Set multiple bits
GPIOA->ODR |= (0b00000011 << 5);  // Set bits 5 and 6

a |= b is shorthand for a = a | b. The | operator performs a bitwise OR: for each bit position, the result is 1 if either operand has a 1 in that position. This is perfect for setting specific bits to 1 while leaving others unchanged.

1 << 5 is a left shift – it takes the binary value 1 (0b00000001) and shifts it left by 5 positions, resulting in 0b00100000 (which is decimal 32).

Note: When you shift left, the bits on the left end "fall off" and are discarded. Meanwhile, the right side gets filled with zeros.

We didn't use GPIOA->ODR |= (0b01100000); directly because it's less readable. We will have to count the number of zeros to identify which bits we are setting. But with GPIOA->ODR |= (0b00000011 << 5);, we can instantly identify that we are setting the 5th and 6th bits.

Clearing Bits (AND with NOT)

// Clear bit 5 (make it 0) without affecting other bits
GPIOA->ODR &= ~(1 << 5);

// Clear multiple bits
GPIOA->ODR &= ~(0b00000011 << 5);  // Clear bits 5 and 6

a &= b means a = a & b. The & operator is bitwise AND: for each bit, the result is 1 only if both operands have 1. This is used with a mask that has 0s where we want to clear and 1s elsewhere.

~ is the bitwise NOT (complement) – it flips every bit: 0 becomes 1, 1 becomes 0. So ~(1 << 5) yields a mask with all bits set to 1 except bit 5, which is 0. When we AND with that mask, bit 5 is forced to 0, and all other bits keep their original values.

Toggling Bits (XOR)

// Toggle bit 5
GPIOA->ODR ^= (1 << 5);

^= is the bitwise XOR assignment. XOR (exclusive OR) gives 1 when the two bits differ. Toggling means flipping a bit: if it was 1, it becomes 0; if 0, becomes 1. XOR with 1 flips the bit, XOR with 0 leaves it unchanged.

Reading Bits

// Check if bit 5 is set
if (GPIOA->IDR & (1 << 5)) {
    // Pin is high
}

// Extract a multi-bit field (e.g., bits 4-7)
uint8_t field = (GPIOA->IDR >> 4) & 0x0F;

What & does

& is used to test a bit: (GPIOA->IDR & (1 << 5)) yields a non‑zero value only if bit 5 is set.

What >> does

>> is the right shift – it moves bits to the right. For example, (value >> 4) shifts the value right by 4 positions, bringing bits 4‑7 down to the lowest 4 bits. Then & 0x0F (which is 0b00001111) isolates those lower 4 bits, discarding the rest. This extracts a field of any width.

Modifying Multi-Bit Fields Safely

When modifying a multi-bit field, you must clear the old value first, then write the new value:

// Set the MODER field for pin 0 (bits 0-1) to "output" (value 01)
GPIOA->MODER &= ~(0b11 << 0);      // Clear bits 0-1
GPIOA->MODER |= (0b01 << 0);       // Set to output mode

Notice the pattern: first clear with & ~(...), then set with |.

A common macro encapsulates this pattern:

#define SET_FIELD(reg, mask, value) \
    ((reg) = ((reg) & ~(mask)) | ((value) & (mask)))

Register Bit-Field Macros

Vendor headers often define bit positions and masks as macros to make code more readable:

// From stm32f407xx.h
#define GPIO_MODER_MODER0_Pos     (0U)
#define GPIO_MODER_MODER0_Msk     (0x3U << GPIO_MODER_MODER0_Pos)
#define GPIO_MODER_MODER0_0       (0x1U << GPIO_MODER_MODER0_Pos)
#define GPIO_MODER_MODER0_1       (0x2U << GPIO_MODER_MODER0_Pos)

#define GPIO_ODR_OD5_Pos          (5U)
#define GPIO_ODR_OD5              (0x1U << GPIO_ODR_OD5_Pos)

These macros make code self-documenting:

GPIOA->MODER &= ~GPIO_MODER_MODER0_Msk;
GPIOA->MODER |= GPIO_MODER_MODER0_0;  // Output mode
GPIOA->ODR |= GPIO_ODR_OD5;           // Set pin 5 high

Note on C bit-fields: The C standard allows struct bit-fields, but their layout (bit ordering, packing, alignment) is implementation-defined. Most embedded coding standards prohibit using C bit-fields for register access because the compiler may not match the hardware layout. Use masks and shifts instead.

Volatile Qualification in Practice

All register accesses must be through volatile pointers. The vendor header typedefs already include volatile. When you create your own register definitions, always add volatile.

Common pitfalls:

// WRONG: compiler may optimize away the write
uint32_t *pReg = (uint32_t *)0x40021000;
*pReg = 0x01;

// CORRECT: write always happens
volatile uint32_t *pReg = (volatile uint32_t *)0x40021000;
*pReg = 0x01;

// WRONG: compiler may reorder or eliminate these
GPIOA->ODR |= (1 << 5);
GPIOA->ODR |= (1 << 6);

// CORRECT: each read-modify-write touches hardware
volatile uint32_t *pODR = &GPIOA->ODR;
*pODR = *pODR | (1 << 5);
*pODR = *pODR | (1 << 6);

Note that GPIOA->ODR |= (1 << 5) is a read-modify-write operation: the CPU reads the register, modifies the value, and writes it back. This is not atomic. If an interrupt modifies a different bit in the same register between the read and write, the ISR's change is lost. This is a classic embedded bug (covered in Chapter 5).

A Complete Example: Blinking an LED

Let's put it all together. On an STM32F407, we want to blink an LED on GPIO D pin 12.

#include <stdint.h>

// Register definitions (normally from vendor header)
typedef struct {
    volatile uint32_t MODER;
    volatile uint32_t OTYPER;
    volatile uint32_t OSPEEDR;
    volatile uint32_t PUPDR;
    volatile uint32_t IDR;
    volatile uint32_t ODR;
} GPIO_TypeDef;

#define GPIOD ((GPIO_TypeDef *)0x40020C00)
#define RCC_AHB1ENR (*(volatile uint32_t *)0x40023830)
#define RCC_AHB1ENR_GPIODEN (1 << 3)

void delay_ms(uint32_t ms) {
    // Rough busy-wait delay (assuming 16 MHz clock)
    for (uint32_t i = 0; i < ms * 4000; i++) {
        __asm volatile ("nop");
    }
}

int main(void) {
    // Enable clock for GPIO D (peripherals are disabled by default)
    RCC_AHB1ENR |= RCC_AHB1ENR_GPIODEN;

    // Configure pin 12 as output (MODER bits 24-25 = 01)
    GPIOD->MODER &= ~(0b11 << 24);
    GPIOD->MODER |= (0b01 << 24);

    while (1) {
        GPIOD->ODR |= (1 << 12);   // LED on
        delay_ms(500);
        GPIOD->ODR &= ~(1 << 12);  // LED off
        delay_ms(500);
    }
}

Notice the clock enable step. On most MCUs, peripherals are clock-gated to save power. You must enable the clock before accessing the peripheral registers. Forgetting this is a common beginner bug: the peripheral simply does not respond.

Read-Modify-Write vs. Set/Clear Registers

Some architectures (notably ARM Cortex-M with bit-banding, and some STM32 peripherals) provide dedicated set and clear registers. Instead of read-modify-write, you write only the bits you want to set or clear:

// ARM Cortex-M bit-banding: atomic bit manipulation
// Bit-band address = bit_band_base + (byte_offset * 32) + (bit_number * 4)
#define BIT_BAND_PERIPHERAL_BASE 0x40000000
#define BIT_BAND_PERIPHERAL_ALIAS 0x42000000

// Set bit 5 in address 0x40020000 atomically
uint32_t *set_reg = (uint32_t *)(BIT_BAND_PERIPHERAL_ALIAS +
                    ((0x40020000 - BIT_BAND_PERIPHERAL_BASE) * 32) + (5 * 4));
*set_reg = 1;

Many STM32 GPIO ports also have BSRR (Bit Set Reset Register):

// STM32 GPIO BSRR: write 1 to bit N to set, write 1 to bit N+16 to clear
GPIOA->BSRR = (1 << 5);        // Set pin 5
GPIOA->BSRR = (1 << (5 + 16)); // Clear pin 5

This is atomic: no read-modify-write, no race with interrupts. When available, prefer set/clear registers for bit-level manipulation.

What to Look Up vs. Deeply Understand

Look up (as needed):

  • Exact register addresses and bit field definitions for a specific MCU
  • Peripheral-specific configuration values (e.g., UART baud rate divisor formulas)
  • Vendor HAL function names and parameters

Deeply understand:

  • The volatile pointer pattern and why it is essential
  • Bitwise operations for setting, clearing, toggling, and extracting fields
  • The read-modify-write race condition and how set/clear registers avoid it
  • The overall structure of a peripheral register map (control, status, data registers)

Key Takeaways

  1. Peripherals are controlled through memory addresses. A register is hardware, not a variable.
  2. volatile is mandatory for all register accesses. Without it, the compiler may optimize away hardware interactions.
  3. Struct-based register maps are the standard. They make register access readable and maintainable.
  4. Bitwise operations are your primary tool. Setting, clearing, toggling, and field extraction are daily tasks.
  5. Read-modify-write is not atomic. Use set/clear registers when available to avoid races.
  6. Enable the peripheral clock before access. Clock-gating is a common power-saving feature.

Chapter 4 will show how to wrap register access in reusable driver functions and introduce the layered architecture used in real firmware projects.

Found an issue? Open an issue or submit a pull request on GitHub