In Part 3, we "manipulated bits of a register." But where exactly are those registers? In embedded systems, a register is essentially a specific address in memory. For example, the STM32 GPIOC output register (ODR) is located at address 0x4001100C. Writing a value to that address moves the pins, and reading from that address gets the pin status. Treating "peripherals like memory addresses" in this way is called Memory-Mapped I/O (MMIO).
To handle addresses directly, you need a pointer, and to safely read values that hardware changes arbitrarily, you need volatile. In this article, we practice these two concepts by simulating them on a PC using gcc. Since actual addresses (like 0x40011000) cannot be accessed on a PC, we use a variable as a "fake register" to practice the same pointer patterns. The (*(volatile uint32_t *)address) pattern learned here forms the foundation for controlling STM32 registers from Part 14 onwards.
Learning Objectives
- Distinguish and use the pointer address (
&) and dereference (*). - Understand that registers are "memory at fixed addresses" and explain the MMIO concept.
- Read from and write to specific addresses using the
(*(volatile uint32_t *)ADDR)pattern. - Explain why
volatileis needed (to prevent the compiler from skipping reads due to optimization). - Understand the CMSIS-style approach that bundles peripherals with struct+pointer.
Target Environment
- Compiler: Either Keil C51 / Keil MDK-ARM (this edition is pure logic).
- Device: Pure logic — PC + gcc or μVision simulator (actual board not required).
- Verification:
gcc(main.c,pc_test.c) on PC.
Key Concepts
1) Pointer — a variable that holds an address
A pointer holds the "address where a value exists," not the "value" itself.
uint8_t x = 10;
uint8_t *p = &x; /*p is the address of x*/
*p = 20; /*dereference: write 20 to the place p points to (x) → x == 20*/
&x: the address of x.*p: the value at the location p points to (dereference).- The key is not to confuse
pwith*p.pis the address,*pis the content at that address.
2) Memory-Mapped I/O (MMIO) — Register = Address
In an MCU, peripheral registers are placed at specific addresses within the memory address space. From the CPU's perspective, writing to that address is the same as writing to RAM, but that address is connected to hardware (pins, timers, UART), not RAM.
| Example (STM32F103) | Address | Meaning |
|---|---|---|
RCC->APB2ENR |
0x40021018 |
Peripheral clock enable |
GPIOC->CRH |
0x40011004 |
Port C high pin configuration |
GPIOC->ODR |
0x4001100C |
Port C output data |
Therefore, accessing a register means "dereferencing a pointer to that address."
#define GPIOC_ODR (*(volatile uint32_t *)0x4001100CUL)
GPIOC_ODR |= (1u << 13); /*PC13 output High*/
(volatile uint32_t *)0x4001100CUL means "treat address 0x4001100C as a pointer to a 32-bit register," and then dereference it using the previous * to write directly to that register.
3) volatile — "Always read for real"
The compiler will cache values in a register (inside the CPU) and skip memory reads if it determines that the "value does not change" for optimization. However, hardware registers can be changed arbitrarily by the hardware (sensor value updates, flag setting, etc.). If you cache it, you end up seeing old values.
while (!(UART_SR & TXE)) { } /*Wait until TXE becomes 1*/
If UART_SR is not volatile, the compiler might determine, "the value does not change inside the loop," and use the once-read value to run an infinite loop. By adding volatile, the compiler reads from the actual address every iteration, reflecting hardware changes. For MMIO pointers, volatile is essentially required.
Rule: Add
volatileto variables shared by hardware registers, interrupts (ISR), and the main code.
4) Grouping Register Blocks with Structures (CMSIS Style)
Registers of a peripheral are usually laid out in consecutive addresses. Therefore, CMSIS groups them into a structure and assigns the structure pointer to the start address.
typedef struct {
volatile uint32_t MODER; /* +0x00 */
volatile uint32_t IDR; /* +0x04 */
volatile uint32_t ODR; /* +0x08 */
} gpio_t;
#define GPIOC ((gpio_t *)0x40011000UL)
GPIOC->ODR |= (1u << 13); /*0x40011000 + 0x08 = Accessing ODR*/
The member order corresponds to the offsets (+0, +4, +8…), so GPIOC->ODR points precisely to "start address + 8". From part 13 onward, notations like GPIOC->ODR, RCC->APB2ENR all follow this pattern.
5) Changing Variables Outside a Function with Pointers
When you pass a value to a function, a copy is passed. To change the caller's original, you must pass the address.
void clear_flag(uint8_t *reg) { *reg = 0; } /*Set the caller's variable to 0*/
Viewing in μVision (This time, a PC is sufficient)
For pure logic, the PC is enough. On an actual STM32, you can enter (*(volatile uint32_t*)0x4001100C) in the Memory window to see the value directly, or add GPIOC->ODR in the Watch window to observe bit changes (practical exercise in part 14).
Examples
Example 1 — main.c: Simulating MMIO with a Pointer
We use a single variable as a "fake register" and set/clear/test its bits using a volatile pointer that points to that address. On a real board, the pointer just points to a fixed address, but everything else is the same.
#include <stdio.h>
#include <stdint.h>
#define BIT(n) (1u << (n))
/*
*Lesson 4 Example 1 — Imitating the concept of memory-mapped I/O (MMIO) on a PC.
*On an actual board, registers are at 'fixed addresses',
* #define GPIO_ODR (*(volatile uint32_t *)0x4001080CUL)
*and you read and write by dereferencing pointers that point to those addresses.
*Since the PC doesn't have such addresses, you can take a variable as a 'fake register'
*and treat its address as a pointer. The operating principle is the same.
*/
int main(void)
{
volatile uint32_t fake_reg = 0; /*Actually, fixed-address hardware register*/
volatile uint32_t *reg = &fake_reg; /*pointer pointing to that address*/
*reg = 0x00000000u; /*writing to the register by dereferencing the pointer*/
printf("init = 0x%08X\n", (unsigned)*reg);
*reg |= BIT(3); /* set bit3 */
printf("set bit3 = 0x%08X\n", (unsigned)*reg);
*reg |= BIT(17); /* set bit17 */
printf("set bit17 = 0x%08X\n", (unsigned)*reg);
*reg &= ~BIT(3); /* clear bit3 */
printf("clr bit3 = 0x%08X\n", (unsigned)*reg);
/*reading the register with a pointer to check specific bits*/
printf("bit17 is %s\n", ((*reg >> 17) & 1u) ? "ON" : "OFF");
return 0;
}
Example 2 — pc_test.c: Struct + Pointer Register Block
We check the CMSIS pattern by grouping peripherals into a struct and accessing them via a pointer. We also verify that members are placed at +0/+4/+8 offsets.
#include <stdio.h>
#include <stdint.h>
#define BIT(n) (1u << (n))
/*Viewing peripherals as a 'register block' structure (the method used by CMSIS).
*In practice, this structure is 'placed' at a fixed address and accessed like GPIO->ODR.
*The member order becomes the register offset (+0, +4, +8...).*/
typedef struct {
volatile uint32_t MODER; /*+0x00 Mode*/
volatile uint32_t IDR; /*+0x04 Input*/
volatile uint32_t ODR; /*+0x08 Output*/
} gpio_t;
/*
*Lecture 04 Example 2 — Verifying the pattern of handling register blocks with structure + pointer.
*/
int main(void)
{
gpio_t storage; /*PC: regular variable. Board: fixed address like 0x4001_0800*/
gpio_t *GPIO = &storage; /*Place the structure at that address*/
GPIO->ODR = 0;
GPIO->ODR |= BIT(5); /*Assuming PA5 output High*/
printf("ODR after set PA5 = 0x%08X\n", (unsigned)GPIO->ODR);
GPIO->ODR ^= BIT(5); /* toggle */
printf("ODR after toggle = 0x%08X\n", (unsigned)GPIO->ODR);
/*Check if the structure members are located at +0, +4, +8 offsets*/
printf("offset MODER=%u IDR=%u ODR=%u\n",
(unsigned)((char *)&GPIO->MODER - (char *)GPIO),
(unsigned)((char *)&GPIO->IDR - (char *)GPIO),
(unsigned)((char *)&GPIO->ODR - (char *)GPIO));
return 0;
}
Verification on a PC
gcc main.c -o main && ./main
Expected output (stdout):
init = 0x00000000
set bit3 = 0x00000008
set bit17 = 0x00020008
clr bit3 = 0x00020000
bit17 is ON
gcc pc_test.c -o pc_test && ./pc_test
Expected output (stdout):
ODR after set PA5 = 0x00000020
ODR after toggle = 0x00000000
offset MODER=0 IDR=4 ODR=8
Common Mistakes
Q. while (!(SR & TXE)); goes into an infinite loop.
A. If SR is not volatile, the compiler optimizes by "read only once since it doesn’t change in the loop," so it won’t see bits changed by hardware. Attach the register To-pointer/macro volatile so it reads the actual address each time.
Q. I get confused between p and *p.
A. p is the address (where), *p is the value at that address (what). The target for turning on register bits is always *p (or reg->FIELD). p |= ... will corrupt the address itself.
Q. Trying to access (uint32_t *)0x40011000 from the PC crashes (segfault).
A. Of course. That address exists only in STM32 peripherals and not in PC memory. In PC practice, use the pointer to hold the variable’s address as in this example to learn the same pattern, and actual address access is done in Part 14 on-simulator/board.
Q. When writing an address as an integer, is it enough to just use 0x40011000?
A. For 32-bit address constants, it is safe to use an unsigned long suffix like 0x40011000UL (for portability and to avoid warnings). And you should always cast it to a pointer type when using it: (volatile uint32_t *)0x40011000UL.
Summary
- Embedded registers are "memory at fixed addresses," and accessing them is essentially pointer dereferencing.
- MMIO convention: read and write a specific address using
(*(volatile uint32_t *)ADDR). - Do not cache the value of
volatilethat hardware changes; read it anew each time — essential for registers. - Peripherals can be accessed cleanly like
GPIO->ODRwhen grouped as struct + pointer (CMSIS pattern). - To change a variable outside a function, pass its address (pointer), not the value.
Try it yourself
Solve the assignment below. The reference solution is shown below.