Embedded C
Storage Classes & Scope
Every variable in C has three properties that don't show up anywhere
in its declared type: scope (where in the source it
can be named), lifetime (how long its storage
actually exists), and linkage (whether other files
can see it). The storage-class keywords — static,
extern, register, and the usually-omitted
auto — are how you control all three, and on a
memory-starved microcontroller with no OS to isolate a mistake, which
one you picked is often the difference between a working driver and a
corrupted stack.
- What it is
- The keywords and defaults that decide a variable's scope, lifetime, and cross-file visibility.
- Mental model
- Scope is who can call it by name; lifetime is how long its memory actually exists; linkage is whether other files are allowed to know about it at all.
- Key numbers
- 4 storage-class keywords total (
auto,static,extern,register) —autois the default for locals and is almost never typed out. - Reach for it when
- Deciding whether a variable should be a local, a file-scoped static, or a genuinely global — not an afterthought, a design decision made before writing the driver.
Local (automatic) variables
A variable declared inside a function with no storage-class keyword is
auto by default — nobody writes auto int x;
in practice, but that's what's happening. Scope is the enclosing
block; lifetime is exactly the function call — the storage is carved
out of the stack when the function is entered and is gone the instant
it returns.
void read_sensor(void) {
int raw = adc_read(); // lives on the stack — gone when this returns
int scaled = raw * 3 / 2;
send_over_uart(scaled);
} // raw and scaled cease to exist here
Global variables
A variable declared outside any function has static storage
duration — it exists for the entire life of the program, not
just one function call — and, by default, external
linkage, meaning another source file can see it too, by
declaring it extern. Global state is the fastest way to
let two completely unrelated pieces of code interact by accident,
which gets sharply worse the moment one of the writers is an
interrupt handler — the exact shared-state hazard covered in
Interrupts.
volatile uint32_t system_tick_ms = 0; // written by an ISR, read everywhere
void SysTick_Handler(void) {
system_tick_ms++; // fires every 1ms, forever
}
static inside a function
A local variable declared static is a genuine hybrid:
block scope (only nameable inside that function, same as a
normal local) but static lifetime (its storage exists for the
whole program, and its value persists between calls instead of being
reinitialized). It's the standard way to give one function its own
private, persistent state without polluting file or global scope.
int next_request_id(void) {
static int counter = 0; // initialized once, ever — not on every call
return ++counter;
}
static at file scope
The same keyword means something different outside a function:
static on a file-scope variable or function restricts its
linkage to internal — only this translation unit
(this .c file, after preprocessing) can see it, even
though C provides no private keyword. In a real firmware
project with dozens of driver files, marking file-local helpers
static is what prevents a linker error the moment two
unrelated files both happen to define a function called
init().
// uart_driver.c
static uint8_t rx_buffer[64]; // invisible outside this file
static void flush_rx(void) { ... } // ditto
void uart_init(void) { flush_rx(); } // the file's actual public API
extern
extern declares a variable or function without
defining it — it tells the compiler "this exists somewhere,
the linker will find it," so other files can reference a global
without each one allocating its own copy. The definition (the one
place storage is actually reserved) lives in exactly one
.c file.
// error_codes.h
extern const char *error_table[16]; // declaration — no storage here
// error_codes.c
const char *error_table[16] = { "OK", "TIMEOUT", "CRC_FAIL", ... }; // definition
register
register is a hint asking the compiler to keep a
frequently-used local variable in an actual CPU register instead of
memory, for speed — a hint modern optimizing compilers almost always
ignore in favor of their own register-allocation decisions, which are
generally better than a programmer's guess. Its one remaining
practical effect: the address-of operator is disallowed on a
register variable (&x won't compile),
since there may be no memory address to take. Mostly a historical
relic, occasionally still seen in older embedded codebases predating
good optimizers.
Where each one actually lives
Reading the diagram against the keywords above: a plain local variable
lives on the Stack. An uninitialized global or
static (static int count;) lives in
.bss — the compiler doesn't even store its zero value
in the binary, it just records "zero this many bytes at startup."
An initialized global or static (static int
count = 5;) lives in .data, and its initial
value is stored in the binary, copied into RAM by startup
code before main() runs (see
Compilation
& Linking). A const global typically lands in
.rodata, often placed in flash directly rather than
copied to RAM at all, since it's read-only.
Real-world applications
- Driver singleton state — a
staticlocal inside a driver's "get instance" function is the standard C way to implement a single, lazily-initialized instance without a global. - ISR-to-main-loop handoff — a
volatileglobal tick counter or flag, incremented in an interrupt and polled in the main loop, is the single most common piece of shared global state in bare-metal firmware. - Cross-module error tables — a shared
externarray of error strings or status codes referenced from every driver file without duplicating the definition. - File-local helpers — marking every function not part of a driver's public API
staticis standard practice in any codebase with more than a couple of source files.
Gotchas
- Returning the address of a local variable (
return &local_var;) hands the caller a pointer into stack memory that's about to be reused by the very next function call — a classic dangling-pointer bug that often "works" in a quick test and fails unpredictably later. - Forgetting
staticon a file-scope helper function is invisible until a second file happens to define a function with the same name, at which point the linker reports a duplicate symbol across two files that otherwise have nothing to do with each other. - A global written by an ISR and read in the main loop without
volatilecan be silently cached by the compiler — see Registers & volatile for exactly why. externdeclared but never actually defined anywhere links fine right up until that specific symbol is referenced, then fails with an "undefined reference" — a mismatch that's easy to introduce when a header outlives the .c file that used to define it.