Functions, Scope and Storage
Three keywords separate C written for computers from C written for chips: static, const and volatile. This volume explains each one, shows the machine code volatile changes, and follows the stack as calls pile up - the memory a small chip has least of.
- Where a variable can be seen, and how long it lasts
- What static does inside a function, and what it does outside one
- How extern shares one variable between files, without copies
- Why const tables can stay in flash, and what const really promises
- What volatile changes in the generated code, shown in assembly
- How much stack each call takes, and why recursion is rare in firmware
- Volume 01: functions and variables
5.1 Local and global variables
Where you declare a variable decides two things: who can see it (its scope), and how long it lasts. Firmware cares about both, because a chip has very little memory to spend.
boot_count starts at 0 (globals begin at zero)
after two calls, boot_count = 2
inside = 1, and it starts again at 0 every call
inside = 1, and it starts again at 0 every call
in the inner block, deeper = 2
outside the block, value = 1
Local: born and gone with the call
A variable declared inside a function exists only while that function runs. Call the function
again and you get a fresh one, which is why inside printed 1 both times.
Global: visible everywhere, for the whole run
A global is declared outside every function. It lives from
power-on to power-off, and it starts at zero unless you say otherwise - the start-up code
clears it before main runs, as Volume 09 will show.
Reaching for a global because it is convenient. Every global is a variable that any function, and
any interrupt, can change at any moment. When a value only matters inside one function, keep it
local. When it must be shared, share it deliberately - and look at static in the next
sub-module first.
Locals live on the stack and are recycled constantly. Globals live in their own fixed place in RAM for the whole run. A small chip has kilobytes of RAM, so every global is a permanent cost.
A function has int count = 0; count++; and prints count. What does it print on the third call?
Show the answer
Answer: B. The local is created fresh at every call and set to 0 again, so it always prints 1. Making it static would keep the value between calls, which is the next sub-module.
5.2 static and extern
static means two different things. Inside a function, it makes a local remember its value. Outside one, it hides a name from the rest of the program.
static uint32_t next_id(void)
{
static uint32_t id = 0u; /* set up once, and remembered between calls */
id++;
return id;
}
next_id gives 1
next_id gives 2
next_id gives 3
three calls in one printf came out as 6, 5, 4
private_counter = 5 (this file only)
shared_ticks = 3 (counted in the other file)
The static local lives for the whole run, like a global, but only this function can reach it by name. That is exactly what you want for a counter, a small state machine, or a cached reading.
Three calls inside one printf came out 6, 5, 4. C does not fix the order in which a
function's arguments are worked out, and this compiler went right to left. Never write code whose
answer depends on that order: make one call per statement.
static on a global: private to this file
static uint32_t private_counter = 0u; /* this file only */
uint32_t shared_ticks = 0u; /* any file that declares it extern */
A static global cannot be reached from another file, even by a file that declares the same name.
That is how a driver keeps its own state private, and it is the closest thing C has to a private
member.
extern: the same variable, seen from another file
One file defines the variable. Every other file declares it:
uint32_t shared_ticks = 0u; /* the one definition */
void tick(void)
{
shared_ticks++;
}
extern uint32_t shared_ticks; /* someone else owns this */
void tick(void);
...
printf("%u\n", shared_ticks); /* prints 3 after three ticks */
In real projects the extern line goes in a header file, and both .c files include it. That way
the declaration exists once, and the compiler checks every file against the same promise. Volume 15
covers headers properly.
Putting a definition in a header. Writing uint32_t shared_ticks = 0; in a .h and including it
from two files gives you two variables with one name - or a linker error about a duplicate symbol.
Headers declare (extern); one .c file defines.
What does static do to a variable declared inside a function?
Show the answer
Answer: C. A static local is created once, kept for the whole run, and can be reached by name only inside its own function. On a global, the same keyword does something different: it hides the name from other files.
5.3 const
const is a promise not to change something through that name. On a chip it has a second benefit: a const table can stay in flash instead of taking up RAM.
static const uint8_t gamma_table[8] = { 0u, 1u, 3u, 8u, 17u, 33u, 61u, 104u };
static const uint32_t baud_rate = 115200u;
static uint32_t sum_table(const uint8_t *table, unsigned count) /* promises not to change it */
{
uint32_t total = 0u;
for (unsigned i = 0; i < count; i++) {
total += table[i];
}
return total;
}
baud_rate = 115200
gamma_table[4] = 17
the whole table adds up to 227
limit = 100
Try to change it and the compiler stops you:
error: assignment of read-only location 'gamma_table[0]'
Why const matters more on a chip
A non-const table has to be copied from flash into RAM at start-up, because the program might
change it. A const table can be left where it is and read straight from flash. On a chip with 2 KB
of RAM and 32 KB of flash, that is the difference between fitting and not fitting.
const on a parameter is a message to the reader as much as to the compiler: this function will
not change what you hand it. Driver interfaces use it constantly - send(const uint8_t *data, uint16_t length) says the data is safe.
Going deeper: const does not always mean flash
It is the compiler and the linker that decide where a const object goes, following the linker
script. A const local inside a function still lives on the stack. A static const at file scope
is the one that normally lands in flash, and on Arm toolchains you will see it in the .rodata
section - read-only data. Volume 09 reads a map file and finds it.
Why is static const uint8_t table[256] better than static uint8_t table[256] for a lookup table that never changes?
Show the answer
Answer: A. A writable table must be copied into RAM at start-up so that it could be changed. A const table is read straight from flash, which on a small chip saves the scarcest resource there is.
5.4 volatile
volatile tells the compiler: this value can change on its own, so read it from memory every single time. Without it, an optimiser is free to read once and reuse the copy for ever.
Here are two identical-looking wait loops, one on a plain variable and one on a volatile:
static uint8_t plain_flag = 1u;
static volatile uint8_t volatile_flag = 1u;
unsigned wait_plain(void)
{
unsigned turns = 0u;
while (plain_flag != 0u && turns < 3u) { turns++; }
return turns;
}
unsigned wait_volatile(void)
{
unsigned turns = 0u;
while (volatile_flag != 0u && turns < 3u) { turns++; }
return turns;
}
Run them on a computer and both return 3. The difference is in the machine code the compiler
writes. This is the real output of gcc -O2 -S:
wait_plain:
endbr64
movl $3, %eax
ret
wait_volatile:
endbr64
movzbl volatile_flag(%rip), %eax
testb %al, %al
je .L6
movzbl volatile_flag(%rip), %eax
testb %al, %al
je .L7
movzbl volatile_flag(%rip), %eax
...
Look at what happened. Without volatile, the compiler never read the flag at all: it worked
the answer out while compiling and returned the constant 3. With volatile, there is a fresh
movzbl - a load from memory - before every test.
The compiler assumes it is the only thing changing your variables. On a chip that is untrue: an
interrupt, a timer or a pin can change memory between two lines of your code. volatile is how you
say so.
Where volatile is required
| Case | Why |
|---|---|
| A hardware register | The hardware changes it, not your code |
| A flag set by an interrupt handler | The handler runs between any two instructions |
| Memory shared with DMA | The DMA engine writes it while you are reading |
| A variable read in a wait loop | Otherwise the loop may be optimised into nothing |
Thinking volatile makes something safe to share. It does not. It stops the compiler caching the
value, and nothing more. It does not make a read-modify-write atomic, and it does not order access
between two processors. Volume 11 covers what else a shared flag needs.
An interrupt sets flag = 1, and the main loop spins on while (flag == 0) { }. The program hangs at -O2. What is missing?
Show the answer
Answer: B. Nothing in the loop changes the flag, so the optimiser may read it once and loop on the copy for ever. The volatile keyword forces a fresh read on every turn. That is exactly what the assembly above shows.
5.5 The stack and function calls
Every call takes a slice of the stack: its locals, its arguments and the address to come back to. The stack is the memory a small chip has least of, and nothing warns you when it runs out.
five nested calls moved the stack by 160 bytes
that is about 32 bytes per call, on this machine
each call also stores the return address and any saved registers
Each call adds a stack frame and each return takes one away. The frames pile up, so the deepest chain of calls in your program decides how much stack you need.
void parse(void)
{
uint8_t buffer[1024]; /* all on the stack */
...
}
On a chip with 2 KB of SRAM, that single line takes half the memory. It is also invisible in the map file, because the stack is sized once for the whole program. Big buffers belong in static storage, where the linker counts them.
Returning a pointer to a local. The frame is gone the moment the function returns, so the address points at memory that the next call will reuse. gcc catches the obvious cases:
error: storing the address of local variable 'here' in 'deepest' [-Werror=dangling-pointer=]
Volume 07 comes back to this once pointers are properly introduced.
Which of these costs stack space every time the function runs?
Show the answer
Answer: C. Only the plain local array is created on the stack at each call. A static local, a file-scope const and a global all live in their own fixed memory, counted once by the linker.
5.6 Recursion, and why firmware avoids it
Recursion is elegant, and firmware mostly avoids it. Each call takes another frame, and the depth often depends on data you cannot see from the code.
uint32_t factorial_recursive(uint32_t n)
{
if (n <= 1u) {
return 1u;
}
return n * factorial_recursive(n - 1u); /* one more frame, every time */
}
uint32_t factorial_loop(uint32_t n)
{
uint32_t result = 1u;
for (uint32_t i = 2u; i <= n; i++) {
result *= i;
}
return result; /* one frame, always */
}
recursive 10! = 3628800, and it went 10 calls deep
loop 10! = 3628800, with one stack frame
both agree for 0! to 12!
13! would overflow a uint32_t, whichever way it is written
Same answers. One of them used ten stack frames to get there; the other used one.
Why firmware says no
- The depth is often unknown. It depends on the input, and the worst case is hard to prove.
- There is no safety net. A desktop gets a crash report. A microcontroller quietly overwrites its own variables, and the symptoms appear somewhere else entirely - a stack overflow.
- It is rarely faster. A loop with no call overhead usually wins.
- Safety standards forbid it. MISRA C and most automotive and medical rules ban recursion outright. Volume 16 covers those rules.
Going deeper: when recursion is still fine
Walking a tree of known depth is the classic exception - a menu structure four levels deep, or a parser for a format with a fixed nesting limit. The test is whether you can write down the maximum depth and multiply it by the frame size. If you can, and the answer fits with room to spare, the recursion is safe and often clearer. If you cannot, write the loop.
Why is a recursive function risky on a chip with 2 KB of RAM?
Show the answer
Answer: B. Each call adds a frame. With only kilobytes of RAM and no memory protection, a deep chain of calls silently overwrites whatever sits below the stack, and the fault shows up far from the cause.
What you learned
- A local lives only while its function runs; a global lives for the whole program and starts at zero.
- static inside a function keeps the value between calls, and keeps the name private.
- static on a global hides it from other files; extern shares one definition between files.
- const is a promise not to change a value, and lets a table stay in flash instead of RAM.
- volatile forces a real read every time: without it, a wait loop can be optimised away entirely.
- Every call takes a stack frame, and a big local array takes it all at once.
- Recursion costs one frame per call, which is why firmware writes a loop instead.
Key words from this volume
Every word below has a plain-English entry in the glossary.
Practice
Choose the storage
Pick local, static local, file-static or global for each of these. A loop counter. The number of times a driver has been called. A 256-byte lookup table that never changes. A flag an interrupt sets for the main loop.
Show the solution
- Loop counter: local. It matters for three lines and should disappear afterwards.
- Call count: static local. It must survive between calls, and nothing outside the function has any business touching it.
- Lookup table:
static constat file scope. It never changes, so it belongs in flash, and nothing outside the file needs it. - Interrupt flag: global - and
volatile. Two different pieces of code must see it, and it changes on its own.
The pattern to notice: start as local as possible, and widen only when something forces you to.
Find the missing keyword
This loop never ends when built with optimisation, although the interrupt really does set the flag. What is missing, and why does it matter?
uint8_t data_ready = 0u;
void ISR_uart(void) { data_ready = 1u; }
void wait_for_data(void)
{
while (data_ready == 0u) {
/* wait */
}
}
Show the solution
data_ready needs to be volatile:
volatile uint8_t data_ready = 0u;
Nothing inside wait_for_data changes the flag, so the optimiser is entitled to read it once
before the loop and spin on that copy for ever. The assembly in this volume shows exactly that: the
non-volatile version never loaded the variable at all.
volatile fixes the caching. It does not make the flag *safe* in general - for anything wider than
a byte, or for read-modify-write, you also need the critical sections from Volume 11.
Count the stack
A function has uint8_t buf[200]; and calls another function that has uint8_t line[80];, which
calls a third with uint32_t work[16];. Roughly how much stack is in use at the deepest point, on
a machine that costs about 32 bytes per frame in overhead?
Show the solution
Add the locals and the overhead for the three frames that are alive at once:
- 200 + 32 = 232 bytes
- 80 + 32 = 112 bytes
- 16 × 4 + 32 = 96 bytes
That is about 440 bytes at the deepest point. On a chip with 2 KB of SRAM, one such chain is already a fifth of the memory. Interrupts run on the same stack too, adding their own frames on top at any moment.
This is why firmware reviews look for large locals, and why buffers are usually declared static
so the linker can count them.
Make it private
A UART driver in uart.c has a buffer and a helper function that nothing outside the file should
touch, plus one function the rest of the program calls. How do you write that?
Show the solution
static uint8_t rx_buffer[64]; /* private to this file */
static uint16_t rx_count; /* private to this file */
static void push_byte(uint8_t b) /* private helper */
{
...
}
void uart_send(const uint8_t *data, uint16_t length) /* the public one */
{
...
}
Everything private is static; only uart_send is left visible. Its declaration goes in uart.h,
which other files include. That single habit - static by default, public only on purpose - is
most of what module structure means in C.
Interview corner
What does volatile do?
"What does the volatile keyword do, and where would you use it?"
Show the solution
"It tells the compiler that a variable can change outside the program's control, so every read must actually happen and cannot be replaced by a cached copy. I use it for hardware registers, for flags written by an interrupt handler, and for buffers shared with DMA. It is easy to show why: without it, gcc at -O2 compiles a wait loop on a flag into a constant, because nothing in the loop changes the flag. The thing to be clear about is what it does not do - it is not atomic, and it is not a memory barrier between cores."
static, three ways
"Give me the three meanings of static in C."
Show the solution
"On a local variable, it moves the variable out of the stack frame so it keeps its value between calls, while the name stays private to the function. On a global variable, it limits the name to that one file, so the linker will not let another file reach it. On a function, the same thing: the function can only be called from within its own file. In firmware I use the last two by default - a driver exposes a handful of functions and keeps everything else static."
Next, Volume 06 takes on arrays and strings: how they sit in memory, how to walk them safely, and how a buffer overflow really happens.