Chapter 3: The Art of Memory-Mapped I/O (Registers)
Note: This chapter is longer than the rest because I needed to fill in several parts the AI missed.
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.
Before you can read or write a register, you need two foundations:
- How numbers are represented in binary and hexadecimal.
- How C's bitwise operators let you manipulate individual bits inside a value.
This chapter builds those foundations from scratch, then shows how they combine into the idioms of memory-mapped I/O. If you have never used &, |, ^, ~, <<, or >> before, you are in the right place. Take this chapter slowly.
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 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 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:
0x40020C00—MODER: mode (input, output, alternate function, analog)0x40020C04—OTYPER: output type (push-pull, open-drain)0x40020C08—OSPEEDR: output speed0x40020C0C—PUPDR: pull-up / pull-down0x40020C10—IDR: input data register (read-only)0x40020C14—ODR: output data register
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. Each register is typically 32 bits wide, and each bit (or small group of bits) has a meaning.
You Write the Whole Register
The CPU cannot write to an individual bit. When the CPU stores a value to a register's address, it writes all 32 bits at once, in a single bus transaction. There is no instruction that says "set bit 5 of this register." Every write is a full-width write.
So in that sense, the register is the unit of access. The CPU reads 32 bits or writes 32 bits, and that is the only interaction the bus supports.
But the Bits Inside Are What Matter
The peripheral, however, does not treat the register as one big number. It looks at the individual bits, or groups of bits, and gives each one a specific job. One bit might mean "enable this feature," another might mean "clear that flag," a pair of adjacent bits might together select one of four modes, and so on.
This is why the reference manual describes each register bit by bit. The register is just the container; the bits are the actual controls.
The Practical Consequence
Because you can only write whole registers, but only some bits in that register are relevant to what you want to do, you must not disturb the other bits. If you write a raw value like 0x00000020 to a register, you are not just setting bit 5 — you are also writing 0 to every other bit, which may inadvertently disable other features, clear other flags, or change other settings.
That is exactly why the bitwise idioms exist, which is what we are going to cover in this chapter.
To make sense of those bits, we need to start at the very bottom.
Bits and Bytes
A bit is the smallest unit of information a computer works with. It has exactly two possible values: 0 or 1. A byte is 8 bits grouped together. When we say a register is "32 bits wide," we mean it holds 32 individual bits side by side.
Within a group of bits, each position has a weight. Reading from right to left, the weights are powers of two:
Bit position:
Weight:
The rightmost bit (position 0) has weight and is called the least significant bit (LSB). The leftmost bit of a byte (position 7) has weight and is called the most significant bit (MSB). For a 32-bit value, the MSB is at position 31 with weight .
To convert a binary number to decimal, you add up the weights of every bit that is set to 1. For example:
The 0b prefix is how C lets you write binary literals directly (since C23 it is standard; many compilers have supported it as an extension for a long time). If your toolchain rejects it, you can use hexadecimal or decimal instead.
A group of bits can represent distinct values, from up to . An 8-bit byte can represent 256 values (0 to 255). A 32-bit register can represent 4,294,967,296 values (0 to 4,294,967,295).
Counting in Binary
Here are the first sixteen values, so you can see the pattern:
decimal binary
0 0000
1 0001
2 0010
3 0011
4 0100
5 0101
6 0110
7 0111
8 1000
9 1001
10 1010
11 1011
12 1100
13 1101
14 1110
15 1111
Notice that the rightmost bit flips every step, the next bit flips every 2 steps, the next every 4, and so on. This is exactly the same carry behavior you already know from decimal, just with a base of 2 instead of 10.
Hexadecimal: A Shorthand for Binary
Writing long binary strings is tedious and error-prone. Hexadecimal (base 16) solves this. One hexadecimal digit represents exactly 4 bits, so an 8-bit byte is exactly two hex digits, and a 32-bit register is exactly eight hex digits.
The hex digits are 0 through 9 for values 0 to 9, then A through F for values 10 to 15. Here is the full mapping:
0 = 0000 4 = 0100 8 = 1000 C = 1100
1 = 0001 5 = 0101 9 = 1001 D = 1101
2 = 0010 6 = 0110 A = 1010 E = 1110
3 = 0011 7 = 0111 B = 1011 F = 1111
In C, hexadecimal literals are written with the 0x prefix. For example:
uint8_t a = 0x0A; // decimal 10, binary 0000 1010
uint8_t b = 0xFF; // decimal 255, binary 1111 1111
uint32_t c = 0x40020C00; // a register address
Because hex digits line up cleanly with groups of 4 bits, hex is the natural notation for registers. When you see 0x40020C00, you are looking at 32 bits grouped into eight hex digits, four bits each.
Bitwise Operators, One at a Time
C gives you six operators for working directly on bits. We will take them one at a time, with a truth table and a worked example. Do not skip this section; every register manipulation later in the chapter is built from these.
Throughout this section, remember that a bitwise operator works on each pair of corresponding bits independently. There is no carry between positions, unlike ordinary addition.
AND: &
The AND operator produces 1 only when both operands have a 1 in that position. The rules are:
0 & 0 = 00 & 1 = 01 & 0 = 01 & 1 = 1
Worked example:
1100 1010
& 0000 1111
-----------
0000 1010
The second operand here acts as a mask: it selects which bits of the first operand survive. Wherever the mask has a 1, the result copies the original bit; wherever the mask has a 0, the result is forced to 0. This is the operator you use to clear bits or to read a specific bit.
OR: |
The OR operator produces 1 when at least one operand has a 1. The rules are:
0 | 0 = 00 | 1 = 11 | 0 = 11 | 1 = 1
Worked example:
1100 1010
| 0000 1111
-----------
1100 1111
Wherever the mask has a 1, the result is forced to 1; wherever the mask has a 0, the result copies the original bit. This is the operator you use to set bits.
XOR: ^
The XOR operator (exclusive OR) produces 1 when the two operands differ. The rules are:
0 ^ 0 = 00 ^ 1 = 11 ^ 0 = 11 ^ 1 = 0
Worked example:
1100 1010
^ 0000 1111
-----------
1100 0101
Wherever the mask has a 1, the result flips the original bit; wherever the mask has a 0, the result copies the original bit. This is the operator you use to toggle bits.
NOT: ~
The NOT operator (bitwise complement) takes one operand and flips every bit:
~ 1100 1010
-----------
0011 0101
Note that the width of the value matters here. In C, ~x operates on the full width of the type of x. If x is an int on a typical 32-bit machine, ~(1 << 5) is not 0b11011111; it is 0xFFFFFFDF, a 32-bit value with every bit flipped. When you use it with a 32-bit register, this is exactly what you want. When you use it with a smaller type, the extra bits get truncated on assignment.
Left Shift: <<
The left shift operator moves every bit to the left by the given number of positions. Bits that fall off the left end are discarded, and zeros are shifted in on the right. In numeric terms, x << n is as long as no significant bit falls off the end.
0000 0001 << 0 = 0000 0001
0000 0001 << 1 = 0000 0010
0000 0001 << 3 = 0000 1000
0000 0001 << 7 = 1000 0000
The pattern 1 << n is the standard way to build a value with only bit set. You will see it everywhere in embedded code. For example, 1 << 5 produces a value with bit 5 set and every other bit clear. That single set bit is a mask that targets position 5.
Right Shift: >>
The right shift operator moves every bit to the right. Bits that fall off the right end are discarded. For unsigned types, zeros are shifted in on the left. For signed negative values, the behavior is implementation-defined, so prefer unsigned types when shifting.
1000 0000 >> 0 = 1000 0000
1000 0000 >> 1 = 0100 0000
1000 0000 >> 7 = 0000 0001
Numerically, x >> n is for unsigned x. Right shifts are useful for extracting a multi-bit field after you have masked it (we will see this later).
Combining Operators: Building and Using Masks
A mask is a value used to select, set, clear, or flip specific bits in another value. The idiom 1 << n builds a mask with a single bit set at position . You can combine masks with OR to target multiple bits: (1 << 5) | (1 << 6) has bits 5 and 6 set.
The four operations you will use constantly are:
- Set a bit:
x |= (1 << n) - Clear a bit:
x &= ~(1 << n) - Toggle a bit:
x ^= (1 << n) - Test a bit:
if (x & (1 << n)) { ... }
The next section walks through each of these slowly, using a plain C variable. Only after that will we apply them to hardware registers.
Setting, Clearing, and Testing Bits (on a Plain Variable)
Let us forget registers for a moment and work with an ordinary byte:
uint8_t status = 0b00000000;
We will manipulate individual bits of this byte. Nothing here is specific to embedded systems; this is just C.
Setting a Bit
Suppose we want to turn bit 3 (counting from 0 on the right) into a 1, and leave every other bit exactly as it was.
The expression 1 << 3 builds a value with only bit 3 set:
1 = 0000 0001
1 << 3 = 0000 1000
This value is our mask. It marks the position we care about.
Now OR the mask with status:
status = 0000 0000
| mask = 0000 1000
--------------------
result = 0000 1000
So:
status = status | (1 << 3);
C gives us a shorthand for "assign the result of an operation back to the same variable":
status |= (1 << 3);
Why does this work? OR produces 1 whenever either operand has a 1. The mask has a 1 only at position 3, so position 3 of the result is 1 no matter what status was. Every other position of the result equals the corresponding bit of status, because OR-ing with 0 leaves a bit unchanged.
If we call this again with status already equal to 0000 1000, the result is still 0000 1000. Setting an already-set bit is harmless.
Clearing a Bit
Now suppose we want to force bit 3 back to 0, leaving the other bits alone.
We start with the same mask, 1 << 3, which has bit 3 set. We want a value that has a 0 at position 3 and 1 everywhere else, because AND-ing with 0 clears a bit and AND-ing with 1 preserves it. That value is the complement of the mask:
mask = 0000 1000
~mask = 1111 0111
Now AND it with status:
status = 0000 1000
& ~mask = 1111 0111
--------------------
result = 0000 0000
So:
status &= ~(1 << 3);
Read this as "AND with the complement of the mask," or informally, "clear the bit that the mask points to."
Remember the width caveat: ~(1 << 3) in a 32-bit int is 0xFFFFFFF7, a 32-bit value. When assigned back to a uint8_t, only the low 8 bits survive, which is exactly the 1111 0111 we want. In register code, where the register is 32 bits, the full 32-bit complement is correct.
Toggling a Bit
Toggling means flipping a bit: 0 becomes 1, 1 becomes 0. This is what XOR does when the mask has a 1 at the target position.
status = 0000 0000
^ mask = 0000 1000
--------------------
result = 0000 1000
If we toggle again:
status = 0000 1000
^ mask = 0000 1000
--------------------
result = 0000 0000
So:
status ^= (1 << 3);
This is the natural way to blink an LED: toggle the output bit each time a timer fires.
Testing a Bit
To find out whether bit 3 is currently set, we AND with the mask and check whether the result is nonzero:
if (status & (1 << 3)) {
// bit 3 is set
} else {
// bit 3 is clear
}
Here is why. If bit 3 of status is 1, then status & (1 << 3) is 0000 1000, which is nonzero, so the if is taken. If bit 3 is 0, the result is 0000 0000, which is zero, and the else branch runs. All other bits are irrelevant because the mask zeroes them out.
Extracting a Multi-Bit Field
Sometimes several adjacent bits together form a single value, called a field. For example, suppose bits 4 through 7 of status hold a 4-bit code. To extract it:
- Shift the field down to the bottom:
status >> 4. - Mask off the bits above the field:
& 0x0F.
uint8_t code = (status >> 4) & 0x0F;
After the shift, bits 4–7 have moved to positions 0–3, and the old bits 0–3 have been pushed off the right end and discarded. The & 0x0F mask (which is 0000 1111) then keeps only the four bits we want, in case the original value had higher bits set.
Replacing a Multi-Bit Field
To write a new value into a multi-bit field, you must first clear the old value, then OR in the new one. If you skip the clear step, any 1 bits already in the field will stay set and corrupt the new value.
// Set bits 4-7 to the value 0b0101
status &= ~(0x0F << 4); // clear bits 4-7
status |= (0x05 << 4); // write the new value
A common macro packages this pattern:
#define SET_FIELD(reg, mask, value) \
((reg) = ((reg) & ~(mask)) | ((value) & (mask)))
You will see this pattern in vendor HALs and register-access libraries.
Accessing Registers with Volatile Pointers
Now that you can manipulate bits on an ordinary variable, we can apply the same operations to hardware registers. The first step is to get a pointer to the register's address.
The most direct way is:
uint32_t *pMODER = (uint32_t *)0x40020C00;
*pMODER = 0x00000001; // Set GPIO D pin 0 to output mode
This compiles and often works in simple tests, 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 at any time. An interrupt can change the output data register between two reads. Without volatile, the compiler may optimize away reads or writes, cache values in CPU 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: "
volatilemakes my variables thread-safe."Reality:
volatileonly 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 later chapters).
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)
This works because the members are laid out in order, each uint32_t occupies 4 bytes, and the offsets match the hardware's register layout exactly. The volatile qualifier appears on each member, so every access through the struct is a real memory access.
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, and others) and is the de facto standard in embedded C.
Bitwise Operations on Registers
Everything you learned on plain variables now applies directly to registers. The only differences are:
- The value on the left-hand side is a hardware register, not a variable.
- Register writes take effect immediately and have real-world consequences (an LED turns on, a pin drives high, a peripheral starts transmitting).
- The compiler must be told, via
volatile, not to optimize the access away.
Here are the four fundamental operations on a register, using ODR (output data register) as an example:
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
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
Toggling Bits (XOR)
// Toggle bit 5
GPIOA->ODR ^= (1 << 5);
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;
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
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 named macros, so your code reads as configuration rather than as arithmetic. Here are some examples 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)
The suffixes U and UL on the literal constants tell the compiler the constant is unsigned, which avoids certain sign-related surprises when shifting.
With these macros, register code becomes 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 on every member. 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 the write, the interrupt's change is lost. This is a classic embedded bug, covered in later chapters.
A Complete Example: Blinking an LED
Let us 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);
}
}
Walk through what happens line by line:
RCC_AHB1ENR |= RCC_AHB1ENR_GPIODEN;sets bit 3 of the reset and clock control register. This enables the clock to the GPIO D peripheral. Without it, the GPIO D registers are dead and writes are silently ignored.GPIOD->MODER &= ~(0b11 << 24);clears bits 24 and 25 of the mode register. Each GPIO pin is controlled by 2 bits inMODER, so pin 12 uses bits 24 and 25.GPIOD->MODER |= (0b01 << 24);writes01into those bits, which selects "general purpose output mode."- The loop sets or clears bit 12 of the output data register, with a delay in between. Setting the bit drives the pin high; clearing it drives the pin low. If an LED is wired from pin 12 through a resistor to ground, it blinks.
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
The idioms we have used so far are all read-modify-write: the CPU reads the register, changes some bits in a temporary, and writes the whole value back. As noted earlier, this is not atomic. If an interrupt fires between the read and the write and modifies a different bit in the same register, the interrupt's change is lost.
Some architectures provide dedicated set and clear registers that avoid this. On STM32 GPIO ports, for example, there is a 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
Writing to BSRR is atomic: no read-modify-write, no race with interrupts. When a peripheral provides set/clear registers, prefer them for bit-level manipulation.
ARM Cortex-M also supports bit-banding, a feature that maps each bit of a peripheral or SRAM word to a unique 32-bit address. Writing a 1 to the alias address sets the bit; writing a 0 clears it. The mapping is:
Example code:
#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;
Bit-banding is available on Cortex-M3 and M4, but not on Cortex-M0, M0+, or M7. It is less common in modern code than dedicated set/clear registers, but you will encounter it.
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 (such as UART baud rate divisor formulas)
- Vendor HAL function names and parameters
Deeply understand:
- Binary and hexadecimal representation, and how the two relate
- The six bitwise operators:
&,|,^,~,<<,>> - The four everyday idioms: set, clear, toggle, test a bit
- The volatile pointer pattern and why it is essential
- 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)