The volatile keyword tells the compiler: “do not optimize reads and writes to this variable — every access must go to actual memory.” It sounds simple, but it is one of the most misunderstood qualifiers in C. People use it as a poor man’s thread synchronization, which it is not. Here is what it actually does and where it actually belongs.

What volatile Does

Without volatile, the compiler can:

  • Cache a variable in a register and never reload it from memory
  • Reorder reads and writes to the same variable
  • Eliminate reads whose results it can prove haven’t changed

With volatile, the compiler must:

  • Re-read the variable from memory every time it is accessed
  • Write to memory every time it is assigned
  • Not reorder accesses relative to other volatile accesses

Memory-Mapped Hardware Registers

#include <stdint.h>

/* Hardware register at a fixed address */
#define GPIO_STATUS ((volatile uint32_t*)0x40020010)

void wait_for_pin_high(void) {
    /* Without volatile, the compiler may read the register ONCE
       and cache the result — the loop becomes infinite or never executes */
    while ((*GPIO_STATUS & 0x01) == 0) {
        /* spin until bit 0 is set by hardware */
    }
}

This is the canonical use of volatile. The hardware changes GPIO_STATUS independently of the CPU — the compiler has no way to know this. Without volatile, it reads the register into a register once and loops on that cached value forever.

Signal Handlers

#include <signal.h>
#include <stdio.h>

/* sig_atomic_t is guaranteed to be read/written atomically */
volatile sig_atomic_t stop_flag = 0;

void handler(int signum) {
    stop_flag = 1;   /* set by the signal handler */
}

int main() {
    signal(SIGINT, handler);

    while (!stop_flag) {
        /* main loop — compiler must re-read stop_flag each iteration */
        /* without volatile, compiler may cache 0 and loop forever */
    }

    printf("Stopped by signaln");
    return 0;
}

Signal handlers run asynchronously. Without volatile sig_atomic_t, the compiler may cache stop_flag in a register and never see the update made by the signal handler. sig_atomic_t is an integer type that can be read and written atomically with respect to signals.

What volatile Does NOT Do — The Threading Mistake

#include <stdio.h>

/* WRONG — volatile does not make this thread-safe */
volatile int shared_counter = 0;

void thread_function(void) {
    for (int i = 0; i < 1000000; i++) {
        shared_counter++;   /* not atomic: read-modify-write is 3 operations */
    }
}

volatile prevents caching, but it does not make compound operations atomic. shared_counter++ compiles to three instructions: load, increment, store. Another thread can read between the load and store, causing lost updates. This is a race condition, and volatile does not prevent it.

For multithreaded code, use:

  • _Atomic int (C11 atomics) for simple counters and flags
  • POSIX mutexes (pthread_mutex_t) for protecting compound operations

setjmp / longjmp

#include <setjmp.h>
#include <stdio.h>

jmp_buf env;

void risky_operation(void) {
    longjmp(env, 1);   /* jump back to setjmp */
}

int main() {
    volatile int result = 0;   /* volatile: value preserved across longjmp */

    if (setjmp(env) == 0) {
        result = 42;
        risky_operation();
    } else {
        /* longjmp lands here */
        printf("result = %dn", result);   /* 42, not undefined */
    }
    return 0;
}

Local variables that are modified between setjmp and longjmp have undefined values after the jump — unless they are declared volatile. This is the one case in non-embedded code where volatile is required for correctness.

volatile with Pointers — The Two Forms

volatile int *p;   /* pointer to volatile int — the int is volatile */
int * volatile p;  /* volatile pointer to int — the pointer itself is volatile */
volatile int * volatile p;  /* both volatile */

The distinction matters for hardware registers: you typically want a non-volatile pointer to a volatile memory location — the pointer itself doesn’t change, but the memory it points to does.

The Optimizer in Action

int non_volatile = 0;
volatile int is_volatile = 0;

/* Loop 1: optimizer removes entirely (result unused, no side effects) */
for (int i = 0; i < 1000; i++) non_volatile++;

/* Loop 2: optimizer keeps all 1000 iterations (volatile writes are observable) */
for (int i = 0; i < 1000; i++) is_volatile++;

Without volatile, the compiler eliminates the first loop entirely. With volatile, every write is kept because the compiler must assume external hardware or code observes the memory. This is useful in our c code compiler for debugging — if a loop is being optimized away and you need to see its effect, volatile prevents the optimizer from removing it. But that’s a debugging technique, not a production pattern.

TL;DR

  • volatile prevents the compiler from caching a variable in a register — every access touches memory
  • Use it for memory-mapped hardware registers, signal handler flags, and variables modified between setjmp/longjmp
  • volatile does NOT make operations atomic — it does not replace mutexes or C11 atomics for thread safety
  • volatile sig_atomic_t is the correct type for signal handler flags
  • volatile int *p = pointer to volatile int; int * volatile p = volatile pointer to int
  • If you are reaching for volatile to fix a threading bug, reach for _Atomic or a mutex instead
  • Avoid undefined behavior with volatile by ensuring the accesses themselves are well-defined