Hello! Welcome to the next lesson in our journey into embedded systems.
In our last session, we demystified the embedded build process. You learned how a cross-compiler, linker, linker script, and startup file work in concert to transform your C/C++ source code into a binary image, precisely placing code and data into the microcontroller's Flash and RAM. We established that the linker script is a blueprint, defining memory regions like FLASH and RAM with specific start and end addresses.
This lesson addresses a crucial follow-up question: why are those specific memory addresses so important? You'll discover that not all memory addresses lead to memory. This is the key that unlocks the connection between your abstract code and the physical world. We will explore memory-mapped I/O (MMIO), the fundamental mechanism that allows your C/C++ code to control hardware peripherals through the simple, yet powerful, act of pointer manipulation.
By the end of this lesson, you will be able to explain how memory-mapped I/O works, from the hardware level up to the specific C code patterns used to interact with it.
The Big Idea: Memory as a Control Panel
In a typical program running on your PC, a memory address points to a location in RAM or is an invalid address that will cause a crash. Microcontrollers, however, use a clever architectural trick. They reserve large blocks of their address space not for memory, but as a direct interface to hardware peripherals like GPIOs, timers, and communication interfaces.
This concept is called memory-mapped I/O. From the CPU's perspective, it's just reading from or writing to a memory address. But behind the scenes, an operation on an address in the "peripheral" region doesn't go to a RAM chip; it gets routed to a specific hardware module, effectively flipping a switch or reading a sensor value.
The Artful Bytes channel offers a fantastic high-level introduction to this idea. The video uses the analogy of a chest of drawers, where most drawers hold ordinary things (data), but some are wired to control external hardware.
How Your Code Really Controls Hardware
Please watch the first part of the video "How Your Code Really Controls Hardware". It introduces the concept of MMIO and demonstrates it by replacing high-level Arduino functions with direct memory writes.
Focus on the introduction and the first demo, from the explanation of MMIO to the conclusion of the Arduino blinking LED example. Pay attention to how a simple pointer write in C can achieve the same result as a library function call.
As the video demonstrates, the CPU uses a unified address space. This means that both real memory (RAM, Flash) and peripheral registers share the same set of addresses. There are no special instructions needed for I/O; the same load and store instructions the CPU uses for memory access are used to control hardware.
The Memory Map: A Tale of Two Regions
Let's make this concrete by looking at the memory map of a typical ARM Cortex-M microcontroller, which you might recall from our discussions on linker scripts.

Notice the large region labeled "Peripherals" starting at address 0x40000000. When your C code tries to access an address in this range, the hardware behaves differently than if you were accessing the "SRAM" region.
But how does this work? How does the system know whether to talk to a RAM chip or a GPIO controller? The answer lies in a piece of hardware called an address decoder.
The second half of the Artful Bytes video provides an excellent conceptual animation of this process.
How Your Code Really Controls Hardware
Now, let's watch the part of the video that explains the internal mechanism.
Watch the section that details how MMIO works internally. Focus on the role of the address decoder. It acts like a traffic cop, examining the address on the bus and directing the read/write operation to either the RAM block or the GPIO hardware block.
To summarize the key takeaway:
- The CPU places an address on the address bus and data (for a write) on the data bus.
- The address decoder hardware inspects the address.
- If the address is in the RAM range (e.g.,
0x20000000), it enables the RAM controller to store or retrieve the data. - If the address is in the peripheral range (e.g.,
0x40020000), it enables the corresponding peripheral's hardware, which then uses the data to configure itself.
The CPU remains blissfully unaware of this distinction, which simplifies the processor's design and makes our C/C++ code for controlling hardware surprisingly elegant.
From Address to Action: Pointer Magic in C/C++
Now we arrive at the heart of the matter: how do we write C or C++ code to perform these hardware interactions? The answer combines your knowledge of computer architecture, data structures, and pointer manipulation. We write a value to a specific, hard-coded memory address.
Let's dissect the standard C pattern for doing this. A peripheral like a UART or GPIO controller isn't a single entity; it's a collection of registers. Each register is a small, dedicated piece of memory (usually 32 bits on an ARM Cortex-M) that controls one specific aspect of the peripheral's function.

To interact with a register, we need to:
- Know its exact memory address.
- Tell the C compiler to treat that address as a pointer.
- Tell the compiler that this "memory" is special and can change at any time.
- Read from or write to the location using the pointer.
The Low Byte Productions video gives a masterclass in this by digging into the libopencm3 library code to reveal the raw MMIO happening underneath.
Interrupts and Memory Mapped I/O :: Bare Metal Programming Series 2
This video will connect all the dots, from the human-readable names in code to the raw memory addresses and the C syntax used to access them.
First, watch the segment that explains how macros define peripheral addresses. This will show you how a name like GPIOA is just a text substitution that ultimately resolves to a raw hexadecimal address found in the datasheet's memory map. Next, watch the most critical part, which explains how C code accesses these addresses. This section breaks down the pointer casting and the all-important volatile keyword.
Let's consolidate what you just saw into the core programming pattern. To write the value 0x01 to a control register located at address 0x40020C14, the C code looks like this:
// Step 1: Define the address
#define MY_PERIPHERAL_CONTROL_REG 0x40020C14
// Step 2: Write to the address using the MMIO pattern
*( (volatile uint32_t *) MY_PERIPHERAL_CONTROL_REG ) = 0x01;
Let's break down that second line from the inside out, as it's the most important idiom in bare-metal C programming:
MY_PERIPHERAL_CONTROL_REG: This is just the raw integer0x40020C14.(uint32_t *): This is a type cast. It tells the compiler, "Treat this integer as a pointer to an unsigned 32-bit integer." At this point, we have a pointer, but we haven't accessed the data it points to yet.volatile: This is a crucial type qualifier. It is a command to the compiler that says: "The value at this address can change at any time due to external factors (like hardware). Therefore, you must never optimize away reads from or writes to this address." Withoutvolatile, the compiler might decide a write is redundant and remove it, breaking your code in a subtle and hard-to-debug way.
Memory Mapped I/O in C - Mattia Maldini
The article "Memory Mapped I/O in C" by Mattia Maldini provides another excellent explanation for the role of volatile.
Please read the section that begins just after the use case introduction. Focus on the paragraph explaining the volatile keyword. This reinforces why it's non-negotiable for MMIO.
*: This is the dereference operator. It means "go to the address held by the pointer and access the value there."
When we put it all together, *( (volatile uint32_t *) 0x40020C14 ) gives us a direct line to the 32-bit hardware register at that exact memory location, allowing us to read or write to it as if it were a simple variable.
Don't Step on Toes: Read-Modify-Write
There's one final, critical piece of the puzzle. A 32-bit register often contains multiple smaller configuration fields, or "bit-fields." For example, bits 0-3 might control one feature, while bits 4-7 control another.
If you want to change just one field, you cannot simply write a new value to the entire register. Doing so would overwrite (or "clobber") the settings for all the other fields in that register.
The correct procedure is a three-step read-modify-write operation, which uses the bitwise skills you learned in a previous lesson:
- Read: Read the current 32-bit value of the register into a temporary variable.
- Modify: Use bitwise
AND(&) to clear the bits you want to change, and bitwiseOR(|) to set them to their new value. - Write: Write the modified 32-bit value from your temporary variable back to the register.
// Example: Set bits 8 and 9 of a control register to '10' (binary)
// without affecting any other bits.
volatile uint32_t* control_reg = (volatile uint32_t*) 0x40020C14;
// 1. Read
uint32_t temp = *control_reg;
// 2. Modify
// Clear bits 8 and 9
temp = temp & ~(0b11 << 8);
// Set bits to '10' (i.e., set bit 9)
temp = temp | (0b10 << 8);
// 3. Write
*control_reg = temp;
This pattern ensures that you only change the specific bits you intend to, leaving the rest of the peripheral's configuration intact. Many microcontroller datasheets provide special "atomic" registers (like the _set and _clr registers mentioned in the Maldini article) to make this even easier, but understanding the fundamental read-modify-write process is essential.
Conclusion
You have now connected the dots from the software build process all the way to hardware control. You understand that the memory map defined by a linker script isn't just for code and variables; it's a complete map of the system, including the hardware peripherals that give a microcontroller its power.
Key Takeaways:
- Memory-Mapped I/O (MMIO) is an architecture where hardware peripheral registers are mapped into the CPU's standard memory address space.
- The CPU uses normal
loadandstoreinstructions, and an address decoder determines whether to access RAM or a peripheral. - In C/C++, we control peripherals by creating a pointer to a specific memory address, casting it to the correct type (e.g.,
uint32_t *), and marking it asvolatile. - The
volatilekeyword is essential to prevent the compiler from making optimizations that would break the intended hardware interaction. - The read-modify-write pattern using bitwise operations is the standard, safe way to change specific bit-fields within a register without disturbing other settings.
In our previous lessons, we've defined memory layouts and learned to manipulate bits. Now, you know how to use those skills to talk to the hardware itself. In our next lesson, we will close the loop on the build process by learning how to inspect a linker map file. This will allow you to verify that the compiler and linker have correctly placed every piece of your program—from functions to volatile pointer definitions—exactly where you expect them to be in the microcontroller's memory.