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 *toint *, 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 *andint *const— read the declaration right to left.const int *pmeans the int is const;int *const pmeans the pointer is const. - Using volatile for thread synchronization —
volatileprevents compiler optimization of reads/writes but does not provide atomicity or memory ordering. Use_Atomicor 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 allocate —
extern int x;is a declaration, not a definition. Exactly one translation unit must define the variable withoutextern.
Key Takeaways
constprevents modification through a specific name. Use it on function parameters to document and enforce read-only access.- The placement of
constrelative to*determines whether the pointer, the pointed-to data, or both are constant. volatileprevents the compiler from optimizing away reads/writes. Use it for hardware registers and signal handlers, not for thread synchronization.staticat file scope provides internal linkage (encapsulation).staticinside a function creates a persistent local variable.externdeclares a variable without defining it. The definition must exist in exactly one translation unit.restricttells the compiler that pointers do not alias, enabling optimization. Violating the contract is undefined behavior.