Embedded C track

Lesson 04 · Part 1: Foundations (shared — verified with PC gcc)

Pointers, volatile & Memory-Mapped I/O

Registers as addresses and the role of `volatile`

Part 1: Foundations (shared — verified with PC gcc)8 min readFree lesson

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 volatile is 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 p with *p. p is the address, *p is 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 volatile to 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 volatile that hardware changes; read it anew each time — essential for registers.
  • Peripherals can be accessed cleanly like GPIO->ODR when 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.

Example files

The runnable files used in this lesson. Copy them into your own project and experiment.

src/pointers_mmio/main.cC
#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;
}
src/pointers_mmio/pc_test.cC
#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;
}

Assignment

This assignment is all pure logic (PC verifiable). You can solve and grade it using only gcc without a board or μVision.

gcc main.c -o prog && ./prog

Problem 1 — Manipulating a Fake Register with Pointers

  • File name: homework_01/main.c
  • Key concepts: Pointer dereferencing (*reg), bit manipulation
  • Platform: Common

Requirements

  • Start 16-bit fake_reg at 0x0000 and manipulate it only with volatile uint16_t *reg pointing to that address.
  • Apply the following in order and after each step print *reg to 0x%04X.
    1. set bit 0
    2. set bit 12
    3. toggle bit 0
    4. set bit 4
  • Output format step N: 0xXXXX must match the "expected output" character by character.

Expected output

step 1: 0x0001
step 2: 0x1001
step 3: 0x1000
step 4: 0x1010

Hint

  • The target of manipulation is the *reg (value), not reg (address).
  • BIT(12) = 1u << 12 = 0x1000.
  • Pad %04X with zeros to 4 digits.

Problem 2 — Swapping Two Values with Pointers

  • File name: homework_02/main.c
  • Key concepts: Passing addresses, dereferencing
  • Platform: Common

Requirements

  • Swap uint8_t a = 0xAB; uint8_t b = 0xCD; with a single function call.
  • Print swapped Before/After. Output format must match the "expected output" character by character.

Expected output

before: a = 0xAB, b = 0xCD
after : a = 0xCD, b = 0xAB

Hint

  • Passing by value like swap(a, b) will not change the original. Pass the address with swap(&a, &b).
  • Inside the function, use a temporary variable tmp to swap *pa and *pb.

Check your answers

After solving it yourself, compare it with the reference solution below. The solution files include comments on key points and common mistakes.

Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

homework_01/main.cC
#include <stdio.h>
#include <stdint.h>

/*
 *Answer 1 — Manipulating fake registers with pointers
 *
 *Key Points:
 *- Treat the register as a pointer: *reg is the value at that address.
 *- Bit manipulation idioms (set/clear/toggle) are applied directly to *reg.
 *
 *Common mistake:
 *- Confusing reg (address) with *reg (value). The target to turn bits on is *reg.
 *- A 16-bit register, but forgetting 0-padding in %04X causes the digit alignment to shift.
 */

#define BIT(n) (1u << (n))

int main(void)
{
    uint16_t fake_reg = 0x0000;        /*16-bit fake register*/
    volatile uint16_t *reg = &fake_reg;
    int step = 0;

    *reg |= BIT(0);                    /* set bit0  */
    printf("step %d: 0x%04X\n", ++step, (unsigned)*reg);

    *reg |= BIT(12);                   /* set bit12 */
    printf("step %d: 0x%04X\n", ++step, (unsigned)*reg);

    *reg ^= BIT(0);                    /* toggle bit0 */
    printf("step %d: 0x%04X\n", ++step, (unsigned)*reg);

    *reg |= BIT(4);                    /* set bit4  */
    printf("step %d: 0x%04X\n", ++step, (unsigned)*reg);

    return 0;
}
homework_02/main.cC
#include <stdio.h>
#include <stdint.h>

/*
 *Answer 2 — Swap two values using pointers
 *
 *Key Points:
 *- To have a function change the caller's variables, you need to receive the 'address' (pass by pointer).
 *- If passed by value, only the copy changes, and the original remains the same.
 *
 *Common mistake:
 *- In cases like swap(a, b) where passing by value does not change the original. By passing swap(&a, &b),
 *you pass the addresses and exchange using *pa, *pb inside the function.
 */

static void swap_u8(uint8_t *pa, uint8_t *pb)
{
    uint8_t tmp = *pa;
    *pa = *pb;
    *pb = tmp;
}

int main(void)
{
    uint8_t a = 0xAB;
    uint8_t b = 0xCD;

    printf("before: a = 0x%02X, b = 0x%02X\n", a, b);
    swap_u8(&a, &b);
    printf("after : a = 0x%02X, b = 0x%02X\n", a, b);

    return 0;
}

Learn this lesson with a mentor

Work through the Embedded C track on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now