5 min read
On this page

const & Qualifiers

C has four type qualifiers: const, volatile, static, and restrict (plus extern, which is a storage-class specifier but behaves like a qualifier in practice). These keywords modify how the compiler treats variables — what can be written, what can be optimized away, where the variable lives, and how long it lasts. Getting these right is the difference between code that is merely correct and code that communicates intent to both the compiler and other programmers.

const

const means "this variable cannot be modified through this name." It is a promise to the compiler and to anyone reading your code. The compiler enforces the promise and can use it to optimize.

Basic const

#include <stdio.h>

int main(void) {
    const int max_size = 1024;
    // max_size = 2048;   // compiler error: assignment to const variable

    printf("max_size: %d\n", max_size);

    // const with arrays
    const int primes[] = {2, 3, 5, 7, 11};
    // primes[0] = 42;   // compiler error

    return 0;
}

const does not make the variable immutable in the physical sense. It makes modification through that name a compile-time error. You can still modify the underlying memory through a non-const pointer (though doing so is undefined behavior if the object was originally declared const).

const with Pointers

This is where const gets interesting. The placement of const relative to * determines what is constant.

#include <stdio.h>

int main(void) {
    int x = 10;
    int y = 20;

    // Pointer to const int: can't modify the value through this pointer
    const int *p1 = &x;
    // *p1 = 42;     // error: cannot modify value
    p1 = &y;         // OK: can change which address p1 points to

    // Const pointer to int: can't change where the pointer points
    int *const p2 = &x;
    *p2 = 42;        // OK: can modify the value
    // p2 = &y;      // error: cannot change the pointer itself

    // Const pointer to const int: can't modify either
    const int *const p3 = &x;
    // *p3 = 42;     // error
    // p3 = &y;      // error

    printf("x = %d\n", x);  // 42, modified through p2

    return 0;
}
x = 42

Read the declaration right to left:

  • const int *p — "p is a pointer to an int that is const" (pointer to const)
  • int *const p — "p is a const pointer to an int" (const pointer)
  • const int *const p — "p is a const pointer to a const int" (both const)

const in Function Parameters

const in function parameters tells callers that the function will not modify their data:

#include <stdio.h>
#include <string.h>

// Promises not to modify the string
size_t safe_strlen(const char *s) {
    size_t len = 0;
    while (s[len] != '\0') {
        len++;
    }
    return len;
}

// Promises not to modify src, may modify dst
void safe_copy(char *dst, size_t dst_size, const char *src) {
    size_t i;
    for (i = 0; i < dst_size - 1 && src[i] != '\0'; i++) {
        dst[i] = src[i];
    }
    dst[i] = '\0';
}

int main(void) {
    const char *msg = "hello, world";
    printf("length: %zu\n", safe_strlen(msg));

    char buffer[32];
    safe_copy(buffer, sizeof(buffer), msg);
    printf("copied: %s\n", buffer);

    return 0;
}
length: 12
copied: hello, world

Always use const on pointer parameters that the function does not modify. This is not just documentation — it prevents bugs and enables the compiler to optimize.

volatile

volatile tells the compiler: "Do not optimize reads or writes to this variable. The value may change in ways you cannot see."

Hardware Registers

In embedded programming, hardware registers are mapped to specific memory addresses. The hardware can change the register's value at any time, independent of your program:

#include <stdint.h>

// Memory-mapped hardware register
#define STATUS_REG (*(volatile uint32_t *)0x40021000)

void wait_for_ready(void) {
    // Without volatile, the compiler might read STATUS_REG once,
    // cache the value in a register, and loop forever
    while ((STATUS_REG & 0x01) == 0) {
        // busy wait
    }
}

Without volatile, the compiler sees that STATUS_REG is never modified inside the loop, so it "optimizes" the loop into an infinite loop or a single check. volatile forces the compiler to re-read the memory address on every iteration.

Signal Handlers

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

volatile sig_atomic_t shutdown_requested = 0;

void handle_sigint(int sig) {
    (void)sig;
    shutdown_requested = 1;
}

int main(void) {
    signal(SIGINT, handle_sigint);

    printf("Running... press Ctrl+C to stop\n");
    while (!shutdown_requested) {
        // do work
    }
    printf("Shutting down\n");

    return 0;
}

sig_atomic_t is an integer type that can be read and written atomically. volatile ensures the compiler checks the variable on each loop iteration rather than caching it in a register.

volatile Does Not Mean Thread-Safe

volatile prevents compiler optimizations but does not provide memory barriers or atomicity for multi-threaded access. For thread safety, use <stdatomic.h> (C11) or platform-specific synchronization primitives.

static

static has two distinct meanings depending on where it appears.

File-Scope static: Internal Linkage

At file scope (outside any function), static limits a variable or function to the current translation unit. Other files cannot see it:

// counter.c

// Only visible within counter.c — other files cannot access it
static int count = 0;

// Only callable from within counter.c
static void increment_internal(void) {
    count++;
}

// Visible to other files (external linkage)
void increment(void) {
    increment_internal();
}

int get_count(void) {
    return count;
}

This is C's version of encapsulation. File-scope static prevents name collisions between translation units and hides implementation details.

Function-Scope static: Persistent Local Variables

Inside a function, static makes a local variable persist across calls. The variable is initialized once (at program start) and retains its value between function calls:

#include <stdio.h>

int next_id(void) {
    static int id = 0;   // initialized once, persists across calls
    return ++id;
}

int main(void) {
    printf("id: %d\n", next_id());   // 1
    printf("id: %d\n", next_id());   // 2
    printf("id: %d\n", next_id());   // 3

    return 0;
}
id: 1
id: 2
id: 3

The static int id = 0 initialization happens once, before main runs. Each call to next_id sees the value left by the previous call.

Static locals are useful for caches, counters, and one-time initialization. They are not thread-safe by default.

extern

extern declares a variable or function without defining it. It says "this exists somewhere else — the linker will find it."

// config.h — declaration (no storage allocated)
extern int verbose_mode;

// config.c — definition (storage allocated here)
#include "config.h"
int verbose_mode = 0;

// main.c — uses the variable declared in config.h
#include "config.h"
// verbose_mode is accessible here because of the extern declaration

extern in the header declares the variable. The definition (without extern) in exactly one .c file allocates storage. Every other file that includes the header can use the variable, but there is only one copy. Functions are extern by default, which is why you do not normally write extern on function declarations in headers.

restrict

restrict is a C99 qualifier for pointers. It tells the compiler: "For the lifetime of this pointer, no other pointer will access the same memory." This enables aggressive optimization.

#include <stdio.h>
#include <string.h>

// Without restrict, the compiler must assume dst and src might overlap
// With restrict, it can use faster non-overlapping copy instructions
void fast_copy(int *restrict dst, const int *restrict src, size_t n) {
    for (size_t i = 0; i < n; i++) {
        dst[i] = src[i];
    }
}

int main(void) {
    int source[5] = {1, 2, 3, 4, 5};
    int dest[5];

    fast_copy(dest, source, 5);

    for (int i = 0; i < 5; i++) {
        printf("%d ", dest[i]);
    }
    printf("\n");

    return 0;
}
1 2 3 4 5

memcpy uses restrict internally — that is why memcpy is undefined for overlapping regions (use memmove instead). Violating the restrict contract is undefined behavior. Only use restrict when you can guarantee no aliasing.

Common Pitfalls

  • Casting away const — you can cast const int * to int *, but modifying through the non-const pointer is undefined behavior if the original object was declared const. The cast silences the compiler but does not make the modification legal.
  • Confusing const int * and int *const — read the declaration right to left. const int *p means the int is const; int *const p means the pointer is const.
  • Using volatile for thread synchronizationvolatile prevents compiler optimization of reads/writes but does not provide atomicity or memory ordering. Use _Atomic or mutexes for threads.
  • Overusing static locals — static local variables are effectively global state hidden inside a function. They make functions non-reentrant and non-thread-safe. Use them sparingly.
  • Forgetting that extern does not allocateextern int x; is a declaration, not a definition. Exactly one translation unit must define the variable without extern.

Key Takeaways

  • const prevents modification through a specific name. Use it on function parameters to document and enforce read-only access.
  • The placement of const relative to * determines whether the pointer, the pointed-to data, or both are constant.
  • volatile prevents the compiler from optimizing away reads/writes. Use it for hardware registers and signal handlers, not for thread synchronization.
  • static at file scope provides internal linkage (encapsulation). static inside a function creates a persistent local variable.
  • extern declares a variable without defining it. The definition must exist in exactly one translation unit.
  • restrict tells the compiler that pointers do not alias, enabling optimization. Violating the contract is undefined behavior.