Skip to main content

Welcome to a Grasp lesson

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

Lesson 48

Volatile for Hardware Registers and Shared Memory

Recommended resources

Hello! Welcome to the next lesson in our module on C's type qualifiers.

In our previous session, we established that const is a promise you make to the compiler: you guarantee that a piece of data will not be modified. This allows the compiler to enforce safety and perform aggressive optimizations. Today, we're going to explore the conceptual opposite of const: the volatile keyword.

Our learning outcome is to use the volatile qualifier for pointers to memory-mapped hardware registers and shared memory variables. volatile serves as a directive to the compiler, telling it that a variable's value can change at any time due to external factors it cannot see. This forces the compiler to be cautious and disables certain optimizations. For your work in automotive and embedded systems, where software interacts directly with hardware and handles asynchronous events like interrupts, mastering volatile is non-negotiable for writing correct code.

What Problem Does volatile Solve?

Modern C compilers are incredibly sophisticated. To generate fast code, they make a key assumption: a variable's value, once loaded into a CPU register, does not change unless the program explicitly modifies it within the current scope of analysis. This allows the compiler to avoid redundant memory reads.

However, this assumption breaks down in two common scenarios in embedded systems:

  1. Memory-Mapped Hardware Registers: The value of a hardware status register (e.g., a timer counter, or a UART receive buffer status) can be changed by the hardware itself at any moment, completely independent of the running code.
  2. Shared Memory Variables: A global variable might be modified by an Interrupt Service Routine (ISR) or, in a multi-threaded environment, another thread of execution.

In these cases, if the compiler caches the variable's value in a register, your program will miss the external change and operate on stale data, leading to serious bugs. volatile prevents this.

How to use the volatile keyword in C?

To see a practical demonstration of this problem, let's watch a video from Jacob Sorber. He clearly shows how compiler optimizations can break a simple program that relies on a shared variable, and how volatile fixes it.

Please watch the first two sections of the video (from 00:00 to 04:26). Pay close attention to: How the program's behavior changes when compiler optimizations are enabled (-O2 or -O3). The two examples used: one with a separate thread and one with a signal handler. Both are conceptually similar to how an Interrupt Service Routine (ISR) would modify a variable in an embedded context. How adding the volatile keyword makes the optimized code behave correctly.

As the video demonstrates, volatile essentially tells the compiler: "Do not make any assumptions about this variable. Every single time my code reads from this variable, you must generate an instruction to load its value from memory. Every time my code writes to it, you must generate an instruction to store the value to memory."


Use Case 1: Memory-Mapped Hardware Registers

Your experience with microcontroller architecture means you're familiar with controlling peripherals like CAN controllers, GPIO, or timers by accessing their registers. In most modern architectures, this is done via Memory-Mapped I/O (MMIO).

MMIO Method Diagram
This diagram illustrates Memory-Mapped I/O. The CPU accesses hardware device registers as if they were regular RAM locations within a specific region of the physical memory address space.

From the C compiler's perspective, a hardware register is just a variable at a fixed memory address. Since the hardware can change the register's value at any time (e.g., a status bit flips when a CAN message is received), pointers used to access these registers must point to volatile data.

Lecture 5: Memory Mapped I/O

This next video provides an excellent, practical overview of MMIO using an ARM Cortex-M processor, which is common in the automotive industry. It shows how GPIO registers are mapped to memory and how they are accessed in C.

Please watch the following segments: 00:00 - 01:27: This introduces the concept of Memory-Mapped I/O. 03:15 - 07:28: This is the core section. It shows a concrete example of GPIO registers on an STM32 chip, demonstrates accessing them with pointers, and explains exactly why the volatile qualifier is added to the register definitions within a struct.

To solidify this, let's look at a classic example from John Regehr's expert blog post on volatile.

Nine ways to break your systems code using volatile

The article 'Nine ways to break your systems code using volatile' provides a very clear example of volatile being used to access a hardware timer.

Please read the subsection titled '1. Not enough volatile'. Focus on the first example involving the AVR hardware timer TCNT1. Notice how the compiler, when optimizing, completely eliminates the reads from the timer register, leading to an incorrect result. Then see how adding volatile to the pointer definition forces the compiler to generate the correct load instructions.


Use Case 2: Shared Variables with Asynchronous Routines

The second primary use case for volatile is for variables that are shared between your main application code and an asynchronous routine, such as an Interrupt Service Routine (ISR) or a signal handler.

Imagine a global flag is_data_ready that is set to 1 inside a CAN Rx interrupt handler. Your main loop polls this flag to know when to process new data.

// This variable can be modified by an ISR
volatile int is_data_ready = 0;

void CAN_RX_ISR(void) {
    // Hardware interrupt triggered by a new CAN message
    // ... code to read message from CAN hardware ...
    is_data_ready = 1; 
}

void main_loop(void) {
    while (1) {
        // Wait for the flag to be set by the ISR
        while (is_data_ready == 0) {
            // Spin
        }
        
        // Reset flag and process data
        is_data_ready = 0;
        process_can_message();
    }
}

Without volatile, the compiler might optimize the inner while (is_data_ready == 0) loop. It would read is_data_ready once, see it's 0, and assume it will stay 0 forever, transforming the loop into an infinite while(true). The volatile keyword forces the compiler to re-read the variable's value from memory on every iteration, ensuring it sees the change made by the ISR. The second example in the Regehr article you just read (the wait_for_done() function) illustrates exactly this scenario.


The Syntax of volatile Pointers

Just like with const, the placement of volatile in a pointer declaration is crucial. It determines whether the pointer itself is volatile or the data it points to is volatile.

Pointer to Volatile Variable and Optimization
This image visualizes the most common use case: a regular pointer (`piData`) that points to a `volatile` variable (`iValue`). Because `iValue` is volatile, the compiler cannot apply optimizations that would eliminate reads or writes to it.

Let's break down the different declarations.

VOLATILE KEYWORD IN C WITH POINTERS AND DATA

The article 'VOLATILE KEYWORD IN C WITH POINTERS AND DATA' gives a clear, structured breakdown of the three primary combinations of volatile and pointers.

Please read sections 1, 2, and 3. For each one, focus on understanding the difference in meaning and the associated use case: int *volatile ptr (Volatile Pointer to Non-Volatile Data): The pointer's address can change unexpectedly, but the data it points to is normal. volatile int *ptr (Non-Volatile Pointer to Volatile Data): The pointer's address is stable, but the data it points to can change. This is the most common and important pattern for accessing hardware registers. volatile int *volatile ptr (Volatile Pointer to Volatile Data): Both the pointer's address and the data can change unexpectedly.

Summary of Pointer Syntax:

  • volatile int *p; (or int volatile *p;)

    • Meaning: p is a pointer to a volatile int.
    • Use Case: The data at the address can change unexpectedly. This is your go-to for memory-mapped I/O. The address stored in p is considered stable.
  • int * volatile p;

    • Meaning: p is a volatile pointer to an int.
    • Use Case: The address stored in p can change unexpectedly (e.g., an ISR might retarget the pointer). The data at the address is considered normal.
  • const volatile int *p;

    • Meaning: p is a pointer to a const volatile int.
    • Use Case: The data at the address can change unexpectedly, but your program is not allowed to write to it. This is perfect for a read-only hardware status register.
Test your understanding!

You are writing a driver for an ADAS Radar sensor. The sensor's main control register is located at the memory address 0x400A8000.

  • Bit 0 of this 32-bit register is the ENABLE bit. Writing a 1 starts a radar sweep.
  • Bit 31 is the SWEEP_COMPLETE status bit. The hardware sets this bit to 1 when a sweep finishes. Your software must poll this bit to know when the sweep is done. This bit is read-only from the software's perspective.

Write a C code snippet that correctly:

  1. Declares a pointer to access this control register. The pointer must ensure that all reads and writes are not optimized away.
  2. Starts a radar sweep by setting the ENABLE bit.
  3. Polls the SWEEP_COMPLETE bit in a loop until it becomes 1.
#include <stdint.h>

// 1. Declare the pointer to the control register here.

void start_and_wait_for_radar_sweep(void) {
    // 2. Start the sweep.
    
    // 3. Poll for completion.
}
Show answer

Here is a correct implementation:

#include <stdint.h>

// 1. Declare the pointer. It points to volatile data.
volatile uint32_t* const RADAR_CTRL_REG = (uint32_t*)0x400A8000;

#define ENABLE_BIT (1U << 0)
#define SWEEP_COMPLETE_BIT (1U << 31)

void start_and_wait_for_radar_sweep(void) {
    // 2. Start the sweep using a read-modify-write operation.
    *RADAR_CTRL_REG |= ENABLE_BIT;
    
    // 3. Poll for completion. The 'volatile' is crucial here.
    while ((*RADAR_CTRL_REG & SWEEP_COMPLETE_BIT) == 0) {
        // The compiler is forced to re-read the register from memory
        // in every iteration of this loop.
    }
    
    // Sweep is complete, can proceed...
}

Explanation:

  • volatile uint32_t*: This is the key part. It declares a pointer to a volatile uint32_t. This tells the compiler that the 32-bit integer at the given address can change unexpectedly, forcing it to perform actual memory accesses for every read (*RADAR_CTRL_REG) and write.
  • const RADAR_CTRL_REG: Making the pointer itself const is a good practice here, as the address of the hardware register will never change.
  • The while loop condition (*RADAR_CTRL_REG & SWEEP_COMPLETE_BIT) == 0 will now work correctly even with full optimization, because the volatile qualifier guarantees a fresh read of the register value in each iteration.

Conclusion

Today we've demystified the volatile keyword, a critical tool for any C programmer working close to the hardware. Unlike const, which is a promise to the compiler, volatile is a command that enforces strict memory access rules.

Key Takeaways:

  • volatile prevents the compiler from making assumptions about when a variable's value can change. It disables optimizations that might cache a variable in a CPU register or reorder accesses to it.
  • It is essential for correctly accessing memory-mapped hardware registers, whose values can be altered by hardware at any time.
  • It is also necessary for variables shared between main program logic and asynchronous routines like Interrupt Service Routines (ISRs).
  • The placement of volatile in a pointer declaration is critical. For hardware access, you almost always want a pointer to volatile data: volatile type *ptr;.

Preview of the Next Lesson:
We have now covered const (for immutability) and volatile (for external changes). In the next lesson, we will examine a third, more subtle type qualifier: restrict. This keyword provides a promise to the compiler about pointer aliasing—that different pointers will not point to the same memory—allowing it to unlock another class of powerful optimizations.