Volatile in C and Compiler optimization

Volatile is a keyword in C language that explicitly tells the compiler that this variable can be modified from outside of program scope and thus prevents the compiler from optimizing that variable.

The first question is who can modify it — the answer is: by the operating system, by another thread of execution such as an interrupt routine or signal handler, or by hardware — anyone.

Now another question: what kind of optimization?

For example, whenever a variable is used frequently this leads to frequent read/write operations at that memory location, resulting in lower performance due to time cost. In such cases the compiler caches its value in a register to avoid memory access and improve performance.

This is a good approach for higher performance, but the problem is when another program/OS wants to access the fresh value from that memory location, they will never get it because the compiler has optimized that variable and placed it in a register.

Therefore declaring a variable as volatile tells the compiler that a specific type of behavior is intended, and that such code must not be optimized in such a way that it removes the intended functionality.

Let's see the hidden picture:

//Nonvolatile version of buffer loop
int buffer_full;
int read_stream(void)
{
    int count = 0;
    while (!buffer_full)
    {
        count++;
    }
    return count;
}
//Volatile version of buffer loop
volatile int buffer_full;
int read_stream(void)
{
    int count = 0;
    while (!buffer_full)
    {
        count++;
    }
    return count;
}

The use of the volatile keyword is illustrated in the two sample routines above. Both of these routines loop reading a buffer until a status flag buffer_full is true.

Their machine code is produced by the compiler for each of the samples, where the C code for each has been compiled using Optimization level two (O2).

//Nonvolatile version — ARM assembly
read_stream PROC
    LDR      r1, |L1.28|
    MOV      r0, #0
    LDR      r1, [r1, #0]
|L1.12|
    CMP      r1, #0
    ADDEQ    r0, r0, #1
    BEQ      |L1.12|      ; infinite loop
    BX       lr
    ENDP
|L1.28|
    DCD      ||.data||
    AREA ||.data||, DATA, ALIGN=2
buffer_full
    DCD      0x00000000

//Volatile version — ARM assembly
read_stream PROC
    LDR      r1, |L1.28|
    MOV      r0, #0
|L1.8|
    LDR      r2, [r1, #0]  ; buffer_full — reloaded each iteration
    CMP      r2, #0
    ADDEQ    r0, r0, #1
    BEQ      |L1.8|
    BX       lr
    ENDP
|L1.28|
    DCD      ||.data||
    AREA ||.data||, DATA, ALIGN=2
buffer_full
    DCD      0x00000000

In the disassembly of the nonvolatile form, LDR r0, [r0, #0] loads the value of buffer_full into register r0 outside the loop labeled |L1.12|. Because buffer_full is not declared as volatile, the compiler assumes that its value cannot be modified outside the program. Having already read the value of buffer_full into r0, the compiler omits to reload the variable when optimizations are enabled. The result is an infinite loop.

In the volatile form, the compiler assumes the value of buffer_full can change outside the program and performs no optimizations — the value of buffer_full is loaded into the register r0 inside the loop labeled |L1.8| each time.

In coding practice, we must declare a variable as volatile in the following cases:

The compiler will never optimize the variables you have declared as volatile.

What Happens Without volatile

Without volatile, the compiler is free to cache a variable's value in a register and never re-read it from memory. This is normally a useful optimisation — but it breaks code that depends on hardware or another thread changing memory behind the compiler's back.

/* status_reg is a hardware register at a fixed address */
uint32_t *status = (uint32_t *)0x40011000;

/* WITHOUT volatile — compiler may optimise the loop away entirely */
while (*status == 0) { }  /* compiler sees: register never changes → infinite loop OR skip */

/* WITH volatile — compiler re-reads from address on every iteration */
volatile uint32_t *status = (volatile uint32_t *)0x40011000;
while (*status == 0) { }  /* safe — hardware can change status */

Enable -O2 or higher and inspect the assembly — without volatile, the compiler often emits a single load and compares the cached value in a tight loop or eliminates the loop entirely.

The Three Legitimate Uses of volatile

1. Memory-mapped hardware registers

Any peripheral register that can change independently of program flow must be volatile. This includes status registers, data registers, and interrupt flag registers.

#include <stdint.h>

/* STM32-style UART register struct */
typedef struct {
    volatile uint32_t SR;    /* status register — hardware sets bits */
    volatile uint32_t DR;    /* data register   — read clears RX flag */
    volatile uint32_t BRR;   /* baud rate       — rarely changes */
    volatile uint32_t CR1;   /* control 1 */
} USART_TypeDef;

#define USART1 ((USART_TypeDef *)0x40011000)

void uart_send_byte(uint8_t b) {
    while (!(USART1->SR & (1 << 7))) { }  /* wait TXE bit */
    USART1->DR = b;
}

2. Variables modified by an ISR (Interrupt Service Routine)

An ISR runs asynchronously — the main loop has no idea when it fires. Any variable shared between an ISR and the rest of the program must be volatile so the compiler always reads the latest value from memory.

#include <stdint.h>
#include <stdbool.h>

volatile bool     uart_rx_ready = false;
volatile uint8_t  uart_rx_byte  = 0;

/* ISR — fires when UART receives a byte (called by hardware) */
void USART1_IRQHandler(void) {
    uart_rx_byte  = USART1->DR;   /* read clears the flag */
    uart_rx_ready = true;
}

/* Main loop */
int main(void) {
    while (1) {
        if (uart_rx_ready) {           /* re-read from memory every iteration */
            process(uart_rx_byte);
            uart_rx_ready = false;
        }
    }
}

3. setjmp / longjmp variables

Variables that must survive a longjmp across a setjmp boundary must be volatile. Without it, the compiler may keep them in a register that longjmp restores to a stale value.

#include <setjmp.h>

jmp_buf env;

void error_path(void) { longjmp(env, 1); }

int main(void) {
    volatile int counter = 0;   /* survives longjmp */
    if (setjmp(env) == 0) {
        counter = 42;
        error_path();
    }
    /* counter == 42 here because it is volatile */
    return 0;
}

volatile const — Both Together

volatile and const are orthogonal and can be combined. The combination means: "I cannot write to it, but hardware can change it." Typical for read-only status registers.

/* Read-only hardware register — I can only read it, hardware can change it */
volatile const uint32_t *RO_REG = (volatile const uint32_t *)0x40012000;

uint32_t val = *RO_REG;   /* OK — reading allowed */
// *RO_REG = 0;            /* ERROR — const prevents writing */

What volatile Does NOT Do

MisconceptionReality
Makes multi-threaded code safe No. volatile does not provide atomicity or memory ordering. Use stdatomic.h (_Atomic) or OS mutexes for thread safety.
Prevents all compiler optimisations No. Only optimisations involving reads/writes to that specific variable are inhibited. Other code around it is still optimised normally.
Guarantees cache coherency on SMP No. On multi-core systems you still need memory barriers (__DMB(), atomic_thread_fence()) to enforce ordering across cores.
Makes ISR variables automatically atomic No. A 32-bit read on an 8-bit MCU may not be atomic. Use _Atomic or disable interrupts during access for true atomicity.

volatile tells the compiler "do not optimise away reads and writes to this variable." It says nothing about ordering, atomicity, or visibility across CPU cores.

Quick Reference

ScenarioUse volatile?Also need
Memory-mapped hardware register ✅ Yes Nothing extra (single-core)
Variable shared with ISR ✅ Yes Disable interrupts for multi-byte reads
Variable shared between threads ❌ Not sufficient _Atomic or mutex
setjmp/longjmp surviving variable ✅ Yes Nothing extra
Read-only hardware register ✅ Yes const as well
Spin-delay loop ✅ Yes Nothing extra (prevents loop removal)
Normal local variable ❌ No

📬 Get new articles in your inbox

Deep dives on SystemC, C++, and embedded systems — no spam, unsubscribe any time.

No spam, unsubscribe any time. Privacy Policy

Aditya Gaurav

Aditya Gaurav

Embedded systems engineer specializing in SystemC, ARM architecture, and C/C++ internals. Writing deep technical dives for VLSI and embedded engineers.