Embedded C volatile const static and extern keyword workflow

In embedded C, four small keywords can change how a program interacts with hardware, memory, modules, and the compiler: volatile, const, static, and extern. They are often taught as vocabulary, but that is not enough. The useful question is: what promise does each keyword make, and what does it not promise?

Misunderstanding these keywords can produce bugs that only appear with compiler optimization enabled, after code is split into multiple files, or when an interrupt and the main loop share state.

volatile: the value can change outside normal program flow

volatile tells the compiler that accesses to an object are observable and must not be optimized away as if the value could only change through ordinary code visible to the compiler. Typical embedded uses include memory-mapped peripheral registers and variables that can be changed by an interrupt handler.

volatile uint32_t * const GPIO_STATUS =
    (volatile uint32_t *)0x40020010u;

while ((*GPIO_STATUS & READY_BIT) == 0u) {
    /* wait for hardware */
}

Without the volatile qualification on the hardware register, an optimizing compiler could treat repeated reads as unnecessary because it cannot see ordinary C code changing that address.

What volatile does not guarantee

volatile is not a synchronization primitive. It does not automatically make an access atomic, does not create a mutex, and does not act as a general memory barrier for unrelated non-volatile data. GCC’s own documentation explicitly warns that volatile access does not order unrelated non-volatile memory operations.

That matters when data is shared between execution contexts. For a simple flag written in an ISR and read by the main loop, volatile may be part of the solution, but correctness still depends on the object size, target architecture, interrupt behavior, and whether multiple related values must be observed consistently.

static volatile bool sample_ready = false;

void ADC_IRQHandler(void)
{
    sample_ready = true;
}

int main(void)
{
    for (;;) {
        if (sample_ready) {
            sample_ready = false;
            process_sample();
        }
    }
}

If the ISR and main code exchange a multi-field data structure or require ordering guarantees, use the synchronization mechanisms appropriate to the MCU/RTOS/compiler rather than assuming volatile solves concurrency.

const: this access path must not modify the object

const expresses a write restriction through a particular declaration or pointer. It is valuable because it lets the compiler diagnose accidental modification and makes function interfaces easier to reason about.

uint16_t average_samples(const uint16_t *samples, size_t count);

Here, the function promises not to modify the samples through that pointer. That is different from promising that the underlying memory can never change by any mechanism.

Read the pointer declaration from the variable name outward

Declaration Meaning
const uint8_t *p Pointer can change; data cannot be modified through p.
uint8_t * const p Pointer cannot change; pointed-to data can be modified.
const uint8_t * const p Neither the pointer nor the pointed-to data can be modified through this declaration.

Embedded code often combines const and volatile. A read-only hardware status register can be modeled as data the software must not write, but whose value may change because of hardware:

volatile const uint32_t *status_reg;

A write-only control register may require a different abstraction. Always follow the MCU vendor’s register definitions when they are available instead of inventing qualifiers around raw addresses.

static has two important meanings

static is confusing because its effect depends on where it appears.

1. static inside a function: persistent storage

uint32_t next_sequence(void)
{
    static uint32_t sequence = 0u;
    return ++sequence;
}

sequence is initialized once and retains its value between calls. Its name is visible only inside the function, but its lifetime lasts for the program’s execution.

This is useful for persistent local state, but it also creates hidden state. A function with static locals can be harder to test, reset, make reentrant, or call safely from multiple contexts.

2. static at file scope: internal linkage

static uint32_t fault_count;
static void reset_peripheral(void);

At file scope, static keeps the name private to that translation unit. This is one of the most useful encapsulation tools in C. If a helper function or module variable does not need to be referenced by another .c file, make it static.

This reduces namespace collisions and makes the module’s public interface explicit.

extern: declare an object or function defined elsewhere

extern is normally used to declare a global object whose storage is defined in another translation unit.

config.h

extern uint32_t system_clock_hz;

config.c

#include "config.h"

uint32_t system_clock_hz = 48000000u;

The header provides a declaration. Exactly one source file provides the definition that allocates storage. Putting a non-static variable definition directly in a header can cause multiple-definition linker errors when several source files include that header.

Functions have external linkage by default, so an ordinary function prototype in a header generally does not need the extern keyword explicitly.

The combinations that matter in firmware

Pattern Typical intent
static const Private read-only table/configuration owned by one source file.
static volatile Private state in one source file that can change asynchronously, such as an ISR flag.
extern const Read-only interface to an object defined in another source file.
extern volatile Shared declaration of asynchronously changing state defined elsewhere; use carefully because global shared state increases coupling.
volatile const Software read-only object that can change externally, such as some hardware status registers.

A common interrupt bug

Consider a flag set by an ISR:

bool done = false;

void TIMER_IRQHandler(void)
{
    done = true;
}

void wait_for_timer(void)
{
    while (!done) {
    }
}

At low optimization this may appear to work. Under optimization, the compiler may determine that ordinary code inside wait_for_timer() never changes done and reuse a previously loaded value. Qualifying the flag as volatile communicates that an asynchronous agent can change it:

static volatile bool done = false;

But again, this only addresses the visibility of accesses. If the shared data is larger, requires atomic read-modify-write operations, or crosses RTOS tasks/cores, use appropriate atomic or RTOS synchronization mechanisms.

Use static to design clean modules

A robust embedded module usually exposes the smallest practical interface in a header and keeps implementation details private in the source file.

sensor.h

void sensor_init(void);
bool sensor_read(int16_t *value);

sensor.c

#include "sensor.h"

static uint8_t retry_count;

static bool bus_ready(void)
{
    /* private helper */
}

void sensor_init(void)
{
    retry_count = 0u;
}

bool sensor_read(int16_t *value)
{
    /* public implementation */
}

The two public functions form the module’s contract. The variable and helper function cannot be referenced by name from another translation unit. That makes future refactoring safer.

Five rules worth remembering

  1. Use volatile when an object’s value can change outside the compiler-visible flow, but do not treat it as a lock, atomic operation, or universal memory barrier.
  2. Use const to express and enforce which interfaces should not modify data.
  3. Use file-scope static aggressively for implementation details that should remain private to one .c file.
  4. Use extern declarations in headers only when a global object genuinely needs cross-module visibility, and provide one definition in one source file.
  5. When qualifiers are attached to hardware registers, prefer the MCU vendor’s CMSIS/device headers or official register definitions.

Debugging checklist

If a bug changes when optimization is enabled, inspect every object shared with hardware, interrupts, DMA, another task, or another core. If a linker reports multiple definitions, look for global variable definitions in headers. If module state is being modified from everywhere, replace unnecessary global extern variables with a narrow function-based API.

You can continue with C bugs that become hardware failures on microcontrollers and pointers vs arrays vs structs in embedded C.

Primary reference