Skip to content

Part 1 · The C Language30 min read

Variables, Scope, Lifetime, and Storage Classes

Every variable in C has three independent properties: its scope (where in the source its name can be used), its linkage (whether the same name in another file refers to the same object), and its storage duration (when it exists at run time). Together they decide whether a variable sits on the stack, in zero-filled RAM, in RAM initialized from flash, or purely in flash. On a microcontroller with 128 KB of RAM and no memory protection, getting this right is the difference between a firmware that fits and behaves, and one that randomly corrupts its own state. This lesson gives you precise control over all three properties and introduces the qualifiers const and volatile.

A declaration tells the compiler that a name exists and what type it has. A definition is a declaration that also creates the thing: it allocates storage for an object or provides the body of a function.

#include <stdint.h>
extern uint32_t g_tick_count; /* declaration only: no storage allocated */
uint32_t g_tick_count = 0; /* definition: storage allocated, initialized */
int32_t clamp(int32_t v, int32_t lo, int32_t hi); /* function declaration (prototype) */
int32_t clamp(int32_t v, int32_t lo, int32_t hi) /* function definition */
{
if (v < lo) {
return lo;
}
return (v > hi) ? hi : v;
}

You may declare a name as many times as you like, as long as the declarations agree. Each object or function with external linkage must be defined exactly once in the whole program. You saw the linker enforce this in Hello, C: the compilation pipeline: zero definitions give “undefined reference”, two give “multiple definition”.

Tentative definitions and the -fno-common change

Section titled “Tentative definitions and the -fno-common change”

A file-scope declaration with no initializer and no extern, such as uint32_t g_errors;, is a tentative definition. If the file contains no other definition of g_errors, it becomes a definition with value zero.

Historically, Unix compilers let two files both contain int g_errors; and silently merged them into one object (a “common” symbol, shown by nm as C). GCC 10 changed the default to -fno-common, so this now fails with “multiple definition”. You will hit this when building old vendor code with a modern compiler. The fix is not -fcommon; it is to define the variable in one file and declare it extern everywhere else.

C has four kinds of scope.

Block scope. A name declared inside a block (between braces) or in a function’s parameter list is visible from its declaration to the end of that block. Each for statement also forms a block, so a loop variable declared in the for exists only inside the loop.

File scope. A name declared outside every function is visible from its declaration to the end of the translation unit (the .c file after preprocessing).

Function prototype scope. Parameter names in a declaration that is not a definition are visible only until the end of that declaration. They are documentation: int32_t clamp(int32_t v, int32_t lo, int32_t hi); and int32_t clamp(int32_t, int32_t, int32_t); declare exactly the same function, and the definition may use different names. Always write the names anyway; they tell the reader which argument is which.

Function scope. Only goto labels have it: a label is visible throughout the function that contains it, even before the line where it appears. Control flow that holds up in production shows why that matters.

#include <stdint.h>
static uint32_t total_samples; /* file scope */
int32_t sum_of_squares(uint32_t n) /* n: block scope, whole body */
{
int32_t sum = 0; /* block scope, from here to the closing brace */
for (uint32_t i = 1; i <= n; i++) { /* i: only inside the for statement */
int32_t sq = (int32_t)(i * i); /* sq: only inside the loop body */
sum += sq;
}
/* i and sq are not visible here */
total_samples += n;
return sum;
}

Declare variables in the smallest scope that works, as close as possible to their first use. A narrow scope means fewer lines where the variable can be misused, and it tells the compiler the value is dead afterwards, so the register can be reused.

Scope is about the source text of one file. Linkage is about whether the same name in different scopes or different files refers to the same object or function:

  • External linkage: the name refers to the same entity across the whole program. Non-static file-scope variables and functions have it. These are the uppercase letters (T, D, B) in nm output.
  • Internal linkage: the name refers to the same entity everywhere within one translation unit, but is invisible to the linker from other files. File-scope static gives it. These are the lowercase letters.
  • No linkage: each declaration is a separate entity. Local variables and parameters have it.
Storage durationLifetimeWho has itWhere it lives on an MCU
AutomaticFrom entry to the enclosing block until it exitsLocal variables and parameters without staticRegisters or the stack
StaticThe entire run of the programEvery file-scope variable, and locals declared static.data, .bss (RAM) or .rodata (flash)
AllocatedFrom malloc until freeMemory you get from malloc and friendsThe heap, if one exists
ThreadThe life of a thread (_Thread_local)Rarely used in firmwareDepends on the RTOS

An automatic variable comes into existence each time its block is entered and stops existing when the block ends. The memory is then reused by the next function call. That is why returning the address of a local variable is a bug: the pointer outlives the object. Pointers II: function pointers, declarations, and bugs dissects that failure.

A static variable exists before main starts and keeps its value until the device resets.

The keyword static does two completely different jobs depending on where it appears.

Inside a function, static changes the storage duration from automatic to static. The name is still visible only inside the block, but the object lives for the whole program and keeps its value between calls.

next_id.c
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>
static uint32_t next_id(void)
{
static uint32_t id = 100; /* initialized once, before main runs */
return id++;
}
int main(void)
{
for (int i = 0; i < 3; i++) {
printf("%" PRIu32 "\n", next_id());
}
return 0;
}
100
101
102

Remove the inner static and the program prints 100 three times, because a fresh automatic id is created and initialized on every call.

Two details matter. First, the initializer = 100 is applied once, conceptually before the program starts, not each time execution passes the line. In C (unlike C++) there is no “first call” initialization, so the initializer must be a constant expression. Second, the object is stored exactly like a file-scope variable: here in .data, because its initial value is nonzero. In nm output it appears as a local symbol with a suffix, such as id.0, so that several functions can each have their own id.

Firmware uses static locals for state that belongs to one function, such as a debouncer:

#include <stdbool.h>
#include <stdint.h>
#define DEBOUNCE_SAMPLES 5u
/* Call every millisecond with the raw pin level. Returns the debounced level. */
bool debounce(bool raw)
{
static bool stable; /* zero-initialized: false */
static uint8_t count;
if (raw == stable) {
count = 0;
} else if (++count >= DEBOUNCE_SAMPLES) {
stable = raw;
count = 0;
}
return stable;
}

The convenience has costs you must understand:

  • The function is not reentrant. If it is called from both main and an interrupt handler, or for two different buttons, they share one stable and one count. For two buttons you need the state outside the function, one copy per button (a struct, as in Structs, unions, enums, and memory layout).
  • The state is hidden, which makes the function harder to unit test: there is no way to reset it between tests.

A second firmware use: moving a large buffer off the stack. uint8_t frame[2048]; inside a function puts 2 KB on the stack on every call, which may overflow a small task stack. static uint8_t frame[2048]; moves it to .bss, where its RAM cost is visible in the map file at link time, at the price of reentrancy.

At file scope every variable already has static storage duration, so static there means something else: it gives the name internal linkage. The variable or function becomes private to its .c file.

led.h
#ifndef LED_H
#define LED_H
#include <stdint.h>
void led_toggle(void);
uint32_t led_toggle_count(void);
#endif
led.c
#include <stdbool.h>
#include <stdint.h>
#include "led.h"
static bool led_on; /* private state */
static uint32_t toggle_count;
static void led_hw_write(bool on) /* private helper */
{
(void)on; /* will write GPIOD BSRR here once we have the register header */
}
void led_toggle(void)
{
led_on = !led_on;
toggle_count++;
led_hw_write(led_on);
}
uint32_t led_toggle_count(void)
{
return toggle_count;
}

Making everything static that does not need to be public is the single most valuable habit in C module design:

  • No name collisions. Ten modules can each have a private static uint32_t state; without conflict.
  • Clear ownership. Only led.c can modify toggle_count; other modules must go through led_toggle_count(). When the value is wrong, you know which file to read.
  • Better code. The compiler sees every use of a static function in this file, so it can inline it and delete the original. For a static variable that is never written after initialization, it can even replace reads with the constant.

The full module pattern (headers, opaque types, private state structs) is the subject of Organizing a real C codebase.

extern: using a variable defined elsewhere

Section titled “extern: using a variable defined elsewhere”

extern declares a name without defining it. The standard pattern for a genuinely shared variable puts the declaration in a header and the single definition in one source file:

config.h
#ifndef CONFIG_H
#define CONFIG_H
#include <stdint.h>
extern uint32_t g_console_baud; /* declaration for every user */
#endif
config.c
#include "config.h" /* lets the compiler check the definition matches */
uint32_t g_console_baud = 115200; /* the one definition */

Any file that includes config.h can read and write g_console_baud. Including the header in config.c itself is important: if someone later changes the definition to uint16_t, the compiler reports a conflict with the declaration. Without that, a file declaring extern uint16_t g_console_baud; by hand would read only half the variable, and neither the compiler nor the linker would notice, because the linker matches names, not types.

extern is also implied for function declarations: void led_toggle(void); and extern void led_toggle(void); mean the same thing. Most code omits it for functions.

The rules differ sharply between static and automatic storage, and the difference explains a whole category of bugs.

Objects with static storage duration (file scope, or static locals):

  • With no initializer, they are zero-initialized: integers 0, floats 0.0, pointers null.
  • Their initializers must be constant expressions, computable at compile time. You cannot write static uint32_t start = get_time();.
  • The values are baked into the image. On a microcontroller, initialized variables go to .data, whose initial values are stored in flash and copied into RAM by the startup code; zero-initialized ones go to .bss, which startup code fills with zeros. That happens before main runs. (On a PC the operating system loader does the equivalent.) If the startup code is broken, “zero-initialized” variables are not zero, which is one of the first things to check on a new board.

Objects with automatic storage duration (ordinary locals):

  • With no initializer, the value is indeterminate. In practice it is whatever bits the previous function left in that register or stack slot. Using it is undefined behavior.
  • Initializers can be any expression, and they run every time the declaration is reached.
#include <stdint.h>
uint32_t count_ones(uint32_t v)
{
uint32_t count; /* bug: never initialized */
while (v != 0u) {
count += v & 1u;
v >>= 1;
}
return count;
}
warning: 'count' may be used uninitialized [-Wmaybe-uninitialized]

-Wall catches this case, reported as either “is used uninitialized” or “may be used uninitialized” depending on the GCC version and optimization level. The detection works best with optimization enabled, because it relies on the compiler’s data-flow analysis. It does not catch every case, for example when the variable is initialized on only some paths through a complicated function or passed by address to another function. On a microcontroller this bug is nasty because the stack contents are often the same on every boot, so the code works on the bench, then breaks when an unrelated change alters what ran before. Initialize every local at its declaration unless you have a reason not to.

Aggregates (arrays and structs) follow one more rule: if you provide an initializer for some elements, all the remaining elements are zero-initialized. So uint8_t buf[64] = { 0 }; zeroes the whole array, and uint16_t pins[8] = { 12, 13 }; sets the first two and zeroes the other six. For an automatic array this initialization costs time on every call (the compiler emits a memset or a copy), so do not initialize large local buffers you are about to overwrite anyway.

const qualifies an object as read-only: the compiler rejects any attempt to modify it through that name.

#include <stdint.h>
const uint32_t sysclk_hz = 16000000u; /* HSI after reset */
void f(void)
{
sysclk_hz = 168000000u; /* error: assignment of read-only variable 'sysclk_hz' */
}

On a microcontroller, const at file scope (or static const in a function) has a major practical benefit: the object goes to .rodata, which the linker places in flash. A 1 KB lookup table declared const costs 1 KB of flash and zero RAM. The same table without const costs 1 KB of flash for its initial values and 1 KB of RAM to hold the copy. Arrays, strings, and tables exploits this heavily.

A const local without static is still an automatic variable: it lives on the stack or in a register and is initialized each time. const only promises you will not change it.

This surprises everyone coming from C++. In C, a const variable is a read-only variable, not a compile-time constant. The places where C demands a constant expression reject it:

#include <stdint.h>
const uint32_t BUF_LEN = 64u;
uint8_t rx_buf[BUF_LEN]; /* rejected at file scope: not a constant expression */
static uint32_t copy = BUF_LEN; /* rejected: static initializer must be constant */
void handle(uint32_t code)
{
uint8_t scratch[BUF_LEN]; /* accepted, but it is a variable-length array */
switch (code) {
case BUF_LEN: /* rejected: case label must be constant */
break;
default:
break;
}
(void)scratch;
}

Standard C rejects the file-scope array, the static initializer and the case label. GCC accepts some of these as an extension when it can fold the value, and -Wpedantic then warns; other compilers refuse outright, so do not rely on it. The local array is the sneakiest: it compiles silently as a variable-length array (VLA), an array whose size is computed at run time and carved out of the stack. VLAs are banned in most firmware coding standards because their stack cost is not known at compile time. -Wvla warns about every one.

C offers three ways to name a constant:

#define BUF_LEN 64uenum { BUF_LEN = 64 };static const uint32_t BUF_LEN = 64u;
Usable as array size, case label, static initializeryesyesno (in standard C)
Has a C typethe literal’s typeintexactly what you wrote
Obeys block and file scopeno, it is text replacement until #undefyesyes
Usable in #ifyesnono
Visible in the debuggerusually notyesyes
Value rangeanything (64-bit, float, strings)must fit in intanything
Takes storagenonopossibly, if its address is taken

The rules most firmware teams follow:

  • Use enum for sets of related integer constants (states, command codes, error codes) and for array sizes that fit in an int.
  • Use #define for things that must be visible to the preprocessor (#if), for register addresses and bit masks (always with a u suffix: #define LED_PIN_MASK (1u << 12)), and for values that are not int-sized.
  • Use static const for typed values whose address you need and for tables.

C23 adds constexpr objects, which are true constant expressions with a type. Until your toolchain and coding standard allow C23, the table above is the state of the art. Macro pitfalls are covered in The preprocessor, used well.

Normally the compiler assumes that a variable changes only when your code changes it. That assumption is what lets it keep values in registers and remove redundant loads. It is false in two situations that are everyday firmware:

  1. Memory-mapped hardware registers. The value of GPIOA->IDR (input data register, address 0x40020010 on the STM32F407) changes when someone presses the button on PA0, with no code running.
  2. Variables shared with interrupt handlers. An interrupt can fire between any two instructions of main and modify a global.

Here is the classic bug:

#include <stdbool.h>
static bool tick_flag; /* bug: should be volatile */
void SysTick_Handler(void) /* called by hardware every millisecond */
{
tick_flag = true;
}
void wait_for_tick(void)
{
while (!tick_flag) {
/* spin */
}
tick_flag = false;
}

Nothing inside the loop modifies tick_flag and the loop calls no function, so at -O2 the compiler may load the flag once, before the loop, and spin forever on the register copy. On the Cortex-M4 the loop often compiles to a test followed by a branch to itself. The interrupt does fire and does set the variable in RAM, but the loop never looks at RAM again. It works at -O0 and hangs at -O2.

Declaring it static volatile bool tick_flag; tells the compiler that every read and write of this object is an observable event that must actually be performed, in program order relative to other volatile accesses. The loop then re-reads the flag from memory on every iteration. The register case uses the same qualifier through a pointer:

#include <stdbool.h>
#include <stdint.h>
#define GPIOA_IDR (*(volatile uint32_t *)0x40020010u) /* GPIOA base + 0x10 */
bool button_pressed(void)
{
return (GPIOA_IDR & 1u) != 0u; /* PA0, user button, active high */
}

The cast and the dereference are explained in Memory-mapped registers in C. For now, notice where volatile sits: on the object being pointed to, so every read of GPIOA_IDR becomes a real load instruction.

volatile is necessary but far from sufficient. It does not make an access atomic, it does not stop the CPU or compiler from reordering non-volatile accesses around it, and it does not protect a read-modify-write such as counter++ from being interrupted halfway. The precise rules are in Undefined behavior, volatile, and what the optimizer assumes, and safe data sharing with interrupts is in Interrupts done right.

Two more storage-class keywords exist, and you will see them in old code:

  • auto explicitly requests automatic storage duration, which is already the default for locals, so it is never needed in C11. In C23 auto gains a new meaning (type inference, as in C++), another reason not to use it in its old sense.
  • register asks the compiler to keep a variable in a register. Modern compilers ignore the hint for allocation decisions; they allocate registers far better than people do. Its only remaining effect is that taking the address of a register variable is an error. Do not use it. (GCC’s extension register uint32_t sp_val __asm__("sp"); binds a variable to a specific register; that is a different, non-portable feature you may meet in startup code.)

A declaration in an inner scope shadows a declaration of the same name in an outer scope: inside the inner scope, the name refers to the inner object only. It is legal, and it is a reliable source of bugs:

#include <stdint.h>
static uint32_t total; /* module-wide running total */
uint32_t accumulate(uint32_t sample)
{
uint32_t total = 0; /* pasted in from another function: shadows the module total */
total += sample;
return total; /* returns sample; the module total never changes */
}

This compiles silently with -Wall -Wextra. Add -Wshadow:

warning: declaration of 'total' shadows a global declaration [-Wshadow]
note: shadowed declaration is here

Enable -Wshadow in every firmware build. It also catches a nested loop that reuses i, and a parameter named the same as a global. The usual fix is renaming, and the conventions that prevent it in the first place (module prefixes for file-scope names, g_ for the rare true global) appear in Organizing a real C codebase.

Firmware uses more global state than application code: there is no heap to speak of, hardware is global by nature, and interrupt handlers take no arguments, so the only way to communicate with them is through variables with static storage. That makes discipline essential:

  1. Default to static. A file-scope variable should be static unless another module truly must touch it. Expose it through functions instead.
  2. One definition, one header declaration. If a variable must be global, define it in one .c file, declare it extern in that module’s header, and include the header in the defining file.
  3. One writer. Document which module (or which interrupt handler) writes each shared variable. Many readers with one writer is manageable; many writers is a design bug.
  4. Mark ISR-shared variables volatile, and remember that volatile does not make multi-step updates safe.
  5. Initialize in code, not by assumption. Static initializers must be constants, so anything computed (a calibration value, a clock frequency) needs an explicit module_init() called from main. Do not read a global from an interrupt that might fire before that init has run.
  6. Count the RAM. Every static object occupies RAM for the entire run, whether or not it is in use. The map file tells you exactly how much each one costs; you will learn to read it in Linker scripts.

Pulling it together, here is a file that touches every kind of storage, annotated with where each object ends up on the STM32F407:

placement.c
#include <stdint.h>
uint32_t boot_count = 1; /* .data RAM, initial value copied from flash */
uint32_t error_count; /* .bss RAM, zeroed by startup code */
static uint8_t rx_buffer[256]; /* .bss RAM, private to this file */
const uint16_t crc_table[4] = { 0x0000, 0x1021, 0x2042, 0x3063 }; /* .rodata flash */
uint32_t process(uint32_t input) /* .text flash */
{
static uint32_t calls; /* .bss RAM, despite block scope */
const uint32_t scale = 3u; /* register or stack: automatic, not flash */
uint32_t result = input * scale; /* register or stack */
calls++;
rx_buffer[calls % sizeof rx_buffer] = (uint8_t)result;
return result + crc_table[input & 3u] + calls;
}
FLASH (0x08000000) SRAM (0x20000000, 128 KB)
+--------------------------------+ +--------------------------------+
| vector table | | .data boot_count | <- copied from flash
| .text process, ... | | .bss error_count, rx_buffer, | <- zeroed
| .rodata crc_table, strings | | calls |
| .data initial values (1) -----+----> | heap (if any) |
+--------------------------------+ | ... |
| stack: result, scale, ... | <- grows down from
+--------------------------------+ 0x20020000
DeclarationStorage durationLinkageSectionMemory
file-scope, initialized nonzerostaticexternal.dataRAM (plus its initial value in flash)
file-scope, uninitialized or zerostaticexternal.bssRAM
file-scope staticstaticinternal.data or .bssRAM
file-scope conststaticexternal.rodataflash
local staticstaticnone.data or .bssRAM
local (automatic)automaticnonenoneregisters or stack
functionn/aexternal or internal.textflash
string literalstaticnone.rodataflash
malloc resultallocatednonenoneheap

Compile placement.c with arm-none-eabi-gcc -c and run arm-none-eabi-nm on it to see the letters D, B, b, R and T line up with this table. The complete memory map, how startup code fills .data and .bss, and how to measure stack and heap usage are the subject of Memory: where everything lives.

  • Defining a variable in a header: every includer gets a definition and the link fails with “multiple definition”. Put extern in the header and the definition in one .c file.
  • Two files with the same uninitialized global: links with old compilers, fails with GCC 10 and later. Define it once and declare it extern elsewhere.
  • Reading an uninitialized local: the value is whatever was on the stack. Initialize at the declaration and keep -Wall on.
  • Using a const variable as an array size: at file scope it is rejected; in a function it silently creates a VLA. Use an enum or #define.
  • Forgetting volatile on a variable written by an interrupt: the main loop spins forever at -O2. Mark it volatile, then handle atomicity separately.
  • Expecting a static local to be reinitialized each call: it is initialized once. Use an ordinary local if you need a fresh value.
  • Calling a function with static local state from both main and an ISR: they share one copy of the state. Move the state out and pass it in, or protect it.
  • Shadowing a global with a local of the same name: writes go to the local. Enable -Wshadow.
  • Large arrays as automatic locals: they consume stack on every call and can overflow it. Make them static (accepting non-reentrancy) or file-scope, and check the map file.
  1. Build next_id.c, then remove the static from id and predict the new output before running it. Then move id to file scope as static uint32_t id = 100; and confirm the output returns to 100, 101, 102. Explain what changed in scope and what did not change in storage duration.
  2. Create a.c containing int counter; and int get_a(void) { return counter; }, and b.c containing int counter; and a main that prints get_a(). Link with your compiler’s default, then with -fcommon, then with -fno-common. Explain each result, then fix the code properly with extern.
  3. Compile placement.c with arm-none-eabi-gcc -std=c11 -O2 -mcpu=cortex-m4 -mthumb -c, run arm-none-eabi-nm placement.o and arm-none-eabi-size placement.o. Match every symbol to its row in the table above. Then remove the const from crc_table and explain the change in the size columns: which grew, which shrank, and what it would cost on the chip.
  4. Compile wait_for_tick from the volatile section with arm-none-eabi-gcc -O2 -mcpu=cortex-m4 -mthumb -S, once without and once with volatile. In the non-volatile version, find the loop and show that it contains no load from memory. In the volatile version, find the ldrb inside the loop.
  5. Write uint32_t moving_sum(uint32_t sample) that returns the sum of the last four samples it was given, keeping its history in static locals. Test it on the host. Then explain in two sentences why this design fails if two sensors call it, and rewrite the function so the caller passes in the history storage instead.

Question 1:What does this print? void f(void) { static int n = 0; n++; printf("%d ", n); } called three times.

Question 2:Which of these is a declaration but not a definition?

Question 3:What does static do to a function defined at file scope?

Question 4:Which can legally be used as the size of a file-scope array in standard C?

Question 5:A SysTick handler sets bool done = true; (not volatile) and main runs while (!done) {}. At -O2 the program hangs. Why?

Question 6:Inside a function you write static uint8_t frame[2048];. Where does it live on the STM32F407?

  • Scope (where a name is visible), linkage (whether names in different files match) and storage duration (when the object exists) are separate properties. Learn to name all three for any declaration.
  • Every externally linked object or function is defined exactly once; headers contain extern declarations, .c files contain definitions.
  • static in a function means “persist between calls”; static at file scope means “private to this file”. Make everything static that does not need to be public.
  • Static-duration objects are zero-initialized if not explicitly initialized and must have constant initializers; automatic objects start with garbage.
  • const means read-only, not compile-time constant. File-scope const data lives in flash and saves RAM. Use enum or #define for constant expressions.
  • volatile forces every access to happen, which hardware registers and ISR-shared flags require. It does not provide atomicity or ordering of other accesses.
  • Initialized statics go to .data (RAM plus a flash copy), zeroed statics to .bss (RAM), const data to .rodata (flash), locals to registers or the stack.

Next: Operators, expressions, and implicit conversions, where you learn what C really does when it evaluates a + b, and why -1 < 1u is false.