Hello! Welcome to your next lesson in Module 2: Data Representation and Low-Level Manipulation.
In our last lesson, we explored how storage class specifiers like static and extern are essentially instructions for the linker, controlling the visibility and lifetime of variables across different files. Today, we turn our attention to a different kind of instruction—one aimed squarely at the compiler.
This lesson focuses on the volatile keyword, one of the most critical and often misunderstood qualifiers in embedded C. Mastering its use is non-negotiable for writing correct and robust low-level code. This directly addresses the learning outcome: Apply volatile correctly for ISR-shared variables, memory-mapped I/O, and as a compiler optimization barrier.
In an interview for a senior role at a company like AMD or NVIDIA, you won't just be asked what volatile does, but why it's necessary, what problems it solves, and, just as importantly, what problems it doesn't solve.
We will cover:
- The core problem
volatilesolves: Aggressive compiler optimization. - The three canonical use cases for
volatile. - The critical distinction between
volatile, atomicity, and memory barriers. - Practical patterns for using
volatileefficiently.
1. The Problem: When the Compiler is Too Smart
Modern compilers are brilliant at optimization. They analyze your code and make assumptions to generate the fastest, smallest machine code possible. A key assumption is that a variable's value doesn't change unless your own code in the current scope of execution changes it.
This assumption is usually correct, but it breaks down spectacularly in embedded systems where hardware, interrupts, or other threads can change memory "behind the compiler's back."
Lecture 16. Volatile Variables
To see this problem in action, let's watch a fantastic demonstration from the "Embedded Systems and Deep Learning" channel. It shows how a perfectly logical piece of code fails when compiler optimizations are enabled.
Please watch the following segments from "Lecture 16. Volatile Variables": The Demo (0:41 - 2:50): Observe how the program works as expected with minimal optimization but fails when -O3 (maximum optimization) is enabled. The Explanation (2:50 - 5:47): Pay close attention to the assembly code comparison. This is the heart of the issue. The compiler caches the flag variable in a register and never re-reads it from memory inside the while loop, creating an infinite loop.
As the video demonstrates, the compiler optimizes the while(flag == 0) loop by reading flag into a register once. Since the loop body doesn't modify flag, the compiler assumes the register's value is always valid and never checks the actual memory location again. The interrupt service routine (ISR) changes the value in memory, but the main loop is oblivious.
This brings us to the solution: the volatile keyword.
volatile is a directive that tells the compiler that a variable may change at any time without any action being taken by the code the compiler finds nearby.
This forces the compiler to suppress certain optimizations:
- Every read of the volatile variable must be fetched directly from its memory location.
- Every write to the volatile variable must be stored directly to its memory location.
- Accesses to volatile variables will not be reordered with respect to other volatile accesses.
Now, let's apply this to the three main scenarios you'll encounter.
2. Use Case 1: Variables Shared with an ISR
This is the exact scenario from the video. When a global variable is modified by an ISR and read by your main program (or vice-versa), it must be declared volatile.
// Incorrect implementation - will likely fail with optimization
int g_event_flag = 0;
void main() {
// ... setup code ...
while (g_event_flag == 0) {
// Wait for the event
}
// ... handle the event ...
}
// Interrupt Service Routine
void GPIO_IRQHandler(void) {
// ... clear interrupt source ...
g_event_flag = 1; // This change may never be seen by main()
}
The Fix:
volatile int g_event_flag = 0; // Correct
By adding volatile, you force the compiler to generate code for the while loop that re-reads g_event_flag from memory in every single iteration, guaranteeing it will see the change made by the ISR.
3. Use Case 2: Memory-Mapped I/O (MMIO)
In embedded systems, hardware peripherals are controlled by writing to and reading from special memory addresses known as memory-mapped registers. From the CPU's perspective, these addresses look just like RAM, but they are connected to hardware logic.

Accessing these registers requires volatile for two critical reasons:
- The value can change asynchronously: A status register (e.g., "UART transmit buffer empty") can be changed by the hardware at any moment. Your code needs to see the latest state.
- Accessing the register can have side-effects: Many peripherals are designed so that simply reading or writing a register triggers an action. For example, reading a UART's data register might also clear the "data ready" interrupt flag. If the compiler optimizes away a read it deems "unnecessary," it will break your hardware interaction logic.
How Your Code Really Controls Hardware
The video "How Your Code Really Controls Hardware" provides an excellent conceptual model for MMIO. It demystifies how a simple pointer write in C translates into a physical hardware action.
Please watch these segments: What is MMIO? (1:03 - 2:24): Get a high-level understanding of mapping hardware to memory addresses. Arduino Demo (2:24 - 4:27): See a practical example of replacing library functions with direct, volatile pointer writes to control an LED. This is a classic embedded technique. How it Works (8:29 - 12:27): This is the key part. It explains how an address decoder on the memory bus routes a write operation to either RAM or a peripheral, making the distinction between a memory write and an I/O operation transparent to the CPU.
Here is a typical real-world example of defining and using a pointer to a memory-mapped register:
// In a device header file, from the microcontroller's datasheet
#define UART0_BASE_ADDR 0x4000C000
#define UART0_FR_OFFSET 0x018 // Flag Register offset
#define UART0_DR_OFFSET 0x000 // Data Register offset
// Create volatile pointers to the registers
#define UART0_FR (*(volatile uint32_t *)(UART0_BASE_ADDR + UART0_FR_OFFSET))
#define UART0_DR (*(volatile uint32_t *)(UART0_BASE_ADDR + UART0_DR_OFFSET))
// Function to send a character
void uart_putc(char c) {
// Wait until the transmit FIFO is not full.
// Without volatile, the compiler might read UART0_FR once and loop forever.
while (UART0_FR & (1 << 5)) {
// Loop
}
// Write the character to the data register
UART0_DR = c;
}
The volatile qualifier on the pointer declaration is what ensures every check of UART0_FR is a fresh read from that specific hardware address.
4. Use Case 3: Compiler Optimization Barrier
The previous two cases are specific instances of a more general principle: volatile acts as a simple barrier, preventing the compiler from making assumptions about a piece of memory. This can be used explicitly to prevent unwanted optimizations.
A classic example is creating a simple software delay loop.
// An incorrect, optimizable delay loop
void delay_ms(uint32_t ms) {
for (uint32_t i = 0; i < ms * 1000; i++) {
// Empty loop
}
}
A smart compiler will notice that this loop has no side effects—it doesn't modify any global state or produce any output. It is free to optimize the entire loop away, resulting in no delay at all.
The Fix:
// A correct, non-optimizable delay loop
void delay_ms(uint32_t ms) {
for (volatile uint32_t i = 0; i < ms * 1000; i++) {
// Empty loop
}
}
By declaring the loop counter i as volatile, we force the compiler to perform every single increment and comparison, preserving the loop's execution time. While this is a crude way to create a delay, it's a perfect illustration of volatile's role as a direct command to the compiler.
5. Advanced Topics & Interview Corner
For your level of experience and interview goals, just knowing the use cases isn't enough. You need to understand the limitations and nuances.
Articles on C's Volatile Keyword
The following resources from Barr Group and Stack Exchange provide excellent written summaries and delve into these more advanced topics. They are perfect for solidifying your knowledge for an interview setting.
First, read the following sections in the Barr Group article "How to Use C's Volatile Keyword". It clearly summarizes the three main cases we've discussed. Start at the heading "Proper Use of C's volatile Keyword". Read the subsections on "Peripheral Registers" and "Interrupt Service Routines". This will serve as a great recap.
Stack Exchange: Using volatile in embedded C development
Now, let's explore the nuances. The top answer on this Stack Exchange page offers some excellent insights, particularly on performance and atomicity.
Read these sections from the top-voted answers: "Considerate handling of volatile values" (Answer by JimmyB): This explains the 'un-volatiling' trick, a performance optimization technique where you copy a volatile value to a local temporary to work with it. This is a great practical tip. "Limitations of volatile" (Answer by JimmyB): Focus on the subsections "Non-atomic access" and "Execution order". This is critical. The code example (Answer by Lundin): Look at the adc.c example. Notice that it uses both volatile and a semaphore flag. This correctly separates the concern of preventing compiler optimization (volatile) from the concern of preventing race conditions (semaphore).
Let's distill those crucial interview points:
-
volatiledoes NOT guarantee atomicity. Accessing a 32-bitvolatilevariable on a 16-bit processor still requires two separate 16-bit memory accesses. An interrupt could fire between those two accesses, leading to a torn read (reading half of the old value and half of the new value). To ensure atomicity, you must use other mechanisms like disabling interrupts or using mutexes. -
volatileis NOT a full memory barrier. It enforces ordering among volatile accesses, but compilers and modern CPUs can still reorder non-volatile accesses around volatile ones. For strict memory ordering, especially in multi-core systems, you need explicit memory barrier/fence instructions (DMB/DSBon ARM,__sync_synchronize()compiler intrinsic, etc.). This is a key differentiator in senior-level interviews. -
The "Un-Volatiling" Pattern: If you need to perform multiple operations on a volatile variable within a function where you know interrupts are disabled or won't affect the value, you can enhance performance by copying it to a local non-volatile variable.
volatile uint32_t shared_counter; void process_counter() { // Critical section start (e.g., disable interrupts) uint32_t local_copy = shared_counter; // One volatile read // Perform multiple operations on the fast, non-volatile local copy local_copy++; local_copy *= 2; if (local_copy > 1000) { // ... } shared_counter = local_copy; // One volatile write // Critical section end (e.g., re-enable interrupts) }
Conclusion
You have now completed a deep dive into the volatile keyword, moving beyond a simple definition to understand its practical applications and critical limitations.
Key Takeaways:
volatileis a message to the compiler: It says "this memory can change unexpectedly; do not optimize accesses to it."- Three Core Use Cases:
- Memory-Mapped I/O: For all hardware register access.
- ISR-Shared Variables: For globals modified by an interrupt and used elsewhere.
- Multi-threaded applications: For globals shared between threads (though often better synchronization primitives exist).
- Interview Gold:
volatiledoes not provide atomicity or full memory ordering. Knowing this distinction separates you from less experienced candidates.
Preview of the Next Lesson:
In this lesson, we saw how volatile prevents the compiler from making incorrect assumptions about changing data. In the next lesson, we will explore the const keyword. While it seems simple, its application in pointer declarations (const int *, int * const, const int * const) provides powerful compile-time guarantees about what can and cannot be changed. We will combine this with volatile to correctly handle read-only hardware registers using const volatile, creating truly robust and self-documenting low-level code.