Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 62

Volatile Pointers for Memory-Mapped Registers

Recommended resources

Hello! Welcome back to our course on C pointers.

In our last lesson, we explored how pointer and data alignment are critical for correctness and performance, especially on strict embedded architectures. We saw that satisfying hardware alignment rules is a non-negotiable part of low-level programming.

Today, we'll continue our deep dive into the hardware-software interface. We'll focus on one of the most direct ways software communicates with hardware. This lesson is designed to help you master the learning outcome: Access memory-mapped hardware registers using volatile pointers.

This is a fundamental skill for anyone in your line of work, whether you're configuring a CAN controller, reading a sensor value from an ADC, or developing low-level drivers within an AUTOSAR stack. We'll explore how to use pointers to "talk" to these hardware registers and uncover the critical role of the volatile keyword in ensuring your code works correctly and isn't broken by an optimizing compiler.

1. Understanding Memory-Mapped I/O (MMIO)

In most modern microcontrollers, like the ones you'd find in an automotive ECU, peripherals (GPIO, timers, UART, CAN controllers, etc.) are controlled via a set of registers. With Memory-Mapped I/O (MMIO), these hardware registers are assigned specific addresses within the processor's main memory map.

This means the CPU doesn't need special I/O instructions. It can read from and write to hardware registers using the same standard load and store instructions it uses to access RAM. For a C programmer, this is incredibly convenient: a hardware register just looks like a variable at a fixed memory address.

Memory-Mapped I/O in System Memory Map
This diagram shows a typical system memory map. Notice how the I/O registers are assigned a specific range of addresses, making them accessible just like any other part of memory.

To get a clear overview of this concept, let's watch a short video.

Lecture 5: Memory Mapped I/O

This video from the Embedded Systems and Deep Learning channel provides a concise introduction to Memory-Mapped I/O and illustrates where peripherals live in the memory map of an ARM Cortex-M processor.

Please watch from the beginning to 03:15. Focus on how MMIO allows the use of standard instructions and how the memory space is partitioned to include a dedicated region for peripherals.


2. The Problem: The Over-Eager Compiler

Given that a hardware register is just a location in memory, the most direct way to access it is to create a pointer to its address, which you'd find in the microcontroller's datasheet.

For example, to set bit 14 of a GPIO Output Data Register (ODR) on an STM32 chip, you might write:

#define GPIOA_ODR_ADDRESS 0x48000014

// 1. Create a pointer to the 32-bit register address
uint32_t *p_gpioa_odr = (uint32_t *)GPIOA_ODR_ADDRESS;

// 2. Dereference the pointer to write to the register
*p_gpioa_odr |= (1 << 14); // Set the 14th bit to turn on an LED

This seems straightforward, but it hides a critical flaw. Let's consider a common scenario: waiting for a device to become ready by polling a status bit.

// Assume this points to a UART status register
uint32_t *p_uart_status = (uint32_t *)0x40011000; 
#define TX_EMPTY (1 << 7) // Transmission buffer is empty bit

// Wait until the transmit buffer is empty
while ( (*p_uart_status & TX_EMPTY) == 0 ) {
    // do nothing, just wait
}

// Now, write data to the transmit register...

A modern compiler, with optimizations enabled (which is standard for release builds), will analyze this code. It sees a loop that reads from the memory location 0x40011000 but never modifies it inside the loop. The compiler assumes that if the program doesn't change the value, nothing can.

It might reason:

  1. Read the value of *p_uart_status once.
  2. If the TX_EMPTY bit is not set, the loop condition is true and will always be true.
  3. Therefore, this is an infinite loop. I can "optimize" it to for(;;);.

The compiler effectively removes the repeated polling of the hardware register, your program gets stuck in an infinite loop, and the device driver fails.

This video provides an excellent and detailed walkthrough of exactly how a compiler's optimizations can lead to incorrect behavior in a device driver.

What Volatile Means (and Doesn’t Mean) in C++ Programming - Ben Saks - CppCon 2024

This segment from a CppCon talk by Ben Saks demonstrates step-by-step how an optimizer can break a simple driver that waits on a status register, ultimately doing 'the wrong thing, but much more efficiently'.

Please watch from 05:51 to 11:03. Pay close attention to the logical steps the compiler takes to transform the correct, waiting code into faulty, optimized code.


3. The Solution: The volatile Keyword

To prevent these dangerous optimizations, we must use the volatile type qualifier.

volatile is a directive to the compiler. It tells the compiler: "The value of this object can change at any time from sources outside this program's control."

When an object is declared volatile, the compiler is forced to be conservative. It will:

  • Strictly perform every read: Each time your code reads the volatile variable, the compiler will generate code to actually fetch the value from memory. It won't use a previously cached copy from a CPU register.
  • Strictly perform every write: Each time your code writes to the variable, the value will be written back to memory.
  • Preserve the order: The compiler will not reorder accesses to volatile variables relative to other volatile accesses.

Here is the corrected, safe version of our polling loop:

// Note the addition of 'volatile'
volatile uint32_t *p_uart_status = (volatile uint32_t *)0x40011000; 
#define TX_EMPTY (1 << 7)

// Wait until the transmit buffer is empty
while ( (*p_uart_status & TX_EMPTY) == 0 ) {
    // The compiler will now generate a memory read in every loop iteration.
}

For a more detailed look at the role of volatile and various C techniques for managing registers, please read the following article.

Memory Mapped I/O in C

The article 'Memory Mapped I/O in C' by Mattia Maldini is an excellent practical guide. It explains why volatile is necessary and then walks through three common methods for representing and accessing hardware registers in C.

Please read the article starting from the text 'One should also note that all register operations...' through to the 'Conclusion'. Focus on understanding the pros and cons of the three main approaches presented: Variables, Macros, and Structures.


4. Structuring Register Access

As the article you just read explains, while using individual macros or pointers for each register works, it can become unwieldy for complex peripherals. The most robust, scalable, and common method used in professional SDKs (like those provided by NXP, STMicroelectronics, etc.) is the struct overlay method.

  1. Define a struct that exactly matches the memory layout of the peripheral's registers, as specified in the datasheet.
  2. Define a single volatile pointer of this struct type, initialized to the base address of the peripheral.
  3. Access registers as members of the struct.

This gives you clean, readable, and namespaced access to all of a peripheral's registers.

// From the microcontroller's datasheet for a hypothetical UART
typedef struct {
    uint32_t  CTRL;       // Offset 0x00: Control Register
    uint32_t  STATUS;     // Offset 0x04: Status Register
    uint32_t  BAUD;       // Offset 0x08: Baud Rate Register
    uint32_t  TX_DATA;    // Offset 0x0C: Transmit Data Register
    uint32_t  RX_DATA;    // Offset 0x10: Receive Data Register
} UartRegs;

// Define a pointer to the UART peripheral at its base address
#define UART0_BASE_ADDR ((UartRegs *)0x40011000)

// Make the access volatile!
volatile UartRegs * const p_uart0 = UART0_BASE_ADDR;

void uart_init() {
    // Access registers as struct members
    p_uart0->CTRL = 0; // Disable UART
    p_uart0->BAUD = 115200;
    p_uart0->CTRL |= (1 << 0) | (1 << 5); // Enable UART and RX
}

void uart_send_char(char c) {
    // Poll the status register
    while ((p_uart0->STATUS & (1 << 7)) == 0);
    
    // Write to the data register
    p_uart0->TX_DATA = c;
}

This is the approach you'll see in most hardware abstraction layers (HALs) and AUTOSAR MCAL modules.

Test your understanding!

The volatile keyword, like const, can be placed in different positions in a pointer declaration. For memory-mapped I/O, which of the following is correct, and what do the others mean?

  1. uint32_t volatile * p_reg;
  2. uint32_t * volatile p_reg;
  3. uint32_t volatile * volatile p_reg;
Show answer
  • 1. uint32_t volatile * p_reg; (or volatile uint32_t * p_reg;) is correct for MMIO. It declares "a non-volatile pointer to a volatile uint32_t". The data being pointed to can change unexpectedly, but the pointer itself (p_reg) can be changed to point to a different address.

  • 2. uint32_t * volatile p_reg; is incorrect for MMIO. It declares "a volatile pointer to a non-volatile uint32_t". This means the pointer p_reg might change its value unexpectedly, but the data it points to is assumed to be normal memory. This is rarely used.

  • 3. uint32_t volatile * volatile p_reg; is overkill for most MMIO. It declares "a volatile pointer to a volatile uint32_t". Both the pointer and the data it points to are treated as volatile.

The key is that the volatile keyword applies to the item on its left. If there's nothing on its left, it applies to the item on its right. Reading declarations from right to left can also help: * volatile p_reg means p_reg is a volatile pointer. volatile * p_reg means p_reg is a pointer to a volatile uint32_t.


5. What volatile Does NOT Guarantee

Understanding the limits of volatile is just as important as knowing when to use it. This is a common source of bugs in embedded systems.

  • volatile does not guarantee atomicity. A read-modify-write operation like *p_reg |= 0x01; is not atomic. It involves three steps: a read, a modification in a CPU register, and a write. If an interrupt occurs between the read and the write and the ISR also modifies *p_reg, the change made in the ISR will be lost.
  • volatile is not a memory barrier for the CPU. volatile prevents the compiler from reordering accesses. It does not prevent the CPU hardware from reordering memory operations for performance. On multi-core systems or when interacting with DMA, you need explicit memory barrier instructions (e.g., DMB on ARM) to ensure strict ordering.
  • volatile is not for multi-threading. Do not use volatile to synchronize data between threads. Use proper synchronization primitives like mutexes, semaphores, or atomic types from <stdatomic.h>.

The following video segment clearly explains these crucial limitations.

What Volatile Means (and Doesn’t Mean) in C++ Programming - Ben Saks - CppCon 2024

The same CppCon talk also provides a clear and authoritative explanation of what volatile does not do. This is essential for writing robust, advanced embedded code.

Please watch from 20:17 to 28:07. The video clearly explains why volatile is not a tool for inter-thread communication and does not make accesses atomic.


Conclusion

Today we've demystified one of the most fundamental aspects of embedded programming. By using volatile pointers, you can directly and safely communicate with hardware peripherals, forming the bedrock of any device driver or hardware abstraction layer.

Key Takeaways:

  • Memory-Mapped I/O (MMIO) makes hardware registers appear as memory locations, accessible via standard C pointers.
  • The volatile qualifier is mandatory when accessing MMIO registers to prevent the compiler from making unsafe optimizations that would break your driver.
  • The correct declaration for an MMIO register pointer is volatile type * p_reg;, which declares a pointer to volatile data.
  • Using struct overlays is the standard, most scalable, and readable method for managing access to a peripheral's registers.
  • volatile is not a silver bullet: it does not guarantee atomicity or provide CPU-level memory barriers, and it is not a substitute for proper multi-threading synchronization primitives.

Preview of the Next Lesson

We have now seen how pointers are used to interact directly with hardware. In the next lesson, we will shift our focus to higher-level software architecture, exploring how pointers are essential for communication between different processes. We will cover the learning outcome: Describe the role of pointers in accessing OS-managed shared memory segments for Inter-Process Communication (IPC). This is a key technique in complex systems, such as those running Linux or QNX, which are increasingly prevalent in advanced automotive platforms.