Procedural Programming with C · Intermediate · Week 17

Scope, Lifetime, and Program Memory Layout

Two different questions get confused constantly: where a name can be seen, and how long the object it names exists. C answers them separately, and the answers together explain the whole memory map of a running program.

By the end of this week you can
  • Distinguish scope from storage duration and give an example where they differ.
  • Explain what a static local is and when it is the right tool.
  • Name the six regions of a running program's memory and say what lives in each.
  • Describe a stack frame and explain why locals do not survive a return.
  • Write a program that prints its own memory map.

1Scope: where a name is visible

ScopeExtentIntroduced by
BlockFrom the declaration to the closing braceAny declaration inside { }
FunctionThe whole function bodyOnly labels, for goto
FileFrom the declaration to the end of the fileDeclarations outside any function
Function prototypeWithin the parameter listParameter names in a declaration
int global = 1;              /* file scope: visible below, in this file */

void demo(void)
{
    int local = 2;           /* block scope: this function only */

    {
        int inner = 3;       /* block scope: these braces only  */
        printf("%d\n", inner);
    }
    /* inner does not exist here */
}

Declare names in the smallest scope that works. A variable visible only where it is used cannot be accidentally read or written elsewhere, and the reader does not have to search for other uses.

Shadowing

int count = 10;

void demo(void)
{
    int count = 20;          /* shadows the file-scope count */
    printf("%d\n", count);   /* 20 — the inner one wins      */
}

An inner declaration hides an outer one of the same name. This is legal and occasionally intentional, but far more often it is an accident that produces a bug the compiler will not mention unless asked. Turn it on:

gcc -Wshadow …

-Wshadow is not in -Wall or -Wextra. It is worth adding to your standard command line.

2Storage duration: how long an object lives

DurationLivesDeclared as
AutomaticFrom entry to its block until exitA plain local variable
StaticThe whole program runstatic, or any file-scope variable
AllocatedFrom malloc until freeWeek 19
ThreadThe lifetime of one thread_Thread_local — week 41

Scope and duration are independent, which is the point of separating them. A static local has block scope — only its function can name it — and static duration — it exists for the whole run and keeps its value between calls.

void counter(void)
{
    static int calls = 0;    /* initialized once, at program start */
    calls++;
    printf("call number %d\n", calls);
}
/* 1, 2, 3, … across separate calls */

Two guarantees worth knowing. A static variable's initializer must be a constant expression and runs once, before main. And any static object without an initializer is zero-initialized — unlike an automatic variable, which contains garbage.

When a static local is right

For a counter or cache private to one function, it is exactly the tool: file-scope visibility would expose it unnecessarily. The cost is that the function is no longer reentrant — two threads calling it share the variable, and so do two nested calls. strtok from week 14 is the standard library's example of this design and its consequences.

static at file scope means something else

static int helper_count;         /* not visible to other .c files */
static void helper(void) { … }   /* likewise */

Applied to a file-scope name, static restricts linkage rather than duration — the name cannot be referenced from another translation unit. That is week 28's subject, and it is the basis of information hiding in C.

3The memory map of a running program

When the operating system loads your executable, it lays out several distinct regions. Every object you have declared so far lives in one of them.

command line, environment stacklocals, parameters, return addresses heapmalloc — week 19 .bss — zero-initialized statics .data — initialized statics .text and .rodata — code, literals high addresses low addresses growsdown growsup unused gap

The classic layout. Week 52 inspects the same sections in a firmware image with objdump.

RegionHoldsWritableLifetime
.textMachine codeNoWhole run
.rodataString literals, const dataNoWhole run
.dataStatics with a non-zero initializerYesWhole run
.bssStatics initialized to zeroYesWhole run
Heapmalloc'd blocksYesUntil free
StackLocals, parameters, return addressesYesUntil the function returns

.bss deserves a note. Zero-initialized statics are not stored in the executable file at all — only their total size is recorded, and the loader zeroes that much memory at startup. A program with a ten-megabyte zeroed array has a tiny executable. Week 52 makes this visible with the size command.

This map also explains week 14's crash. A string literal lives in .rodata, which the hardware marks read-only; writing there is refused by the memory management unit, not by C.

4The call stack

Each function call pushes a stack frame containing its parameters, its local variables, and the address to return to. The frame is popped on return.

void inner(int x)
{
    int local = x * 2;      /* lives in inner's frame */
    printf("%d\n", local);
}                           /* frame popped here      */

void outer(void)
{
    int value = 21;         /* lives in outer's frame */
    inner(value);           /* inner's frame sits above outer's */
}

Three consequences, all of which you have already met.

Locals do not survive the return — week 11's rule and week 13's dangling pointer. The memory is not erased; it simply belongs to the next call, which is why a use-after-return often appears to work.

Recursion consumes stack. Each call is another frame. Week 18 measures how many fit.

The stack is small and fixed. Typically 8 MB on Linux, 1 MB on Windows. A large local array can exhaust it on its own:

void reckless(void)
{
    int huge[4000000];      /* 16 MB on the stack — crashes */
    huge[0] = 1;
}

There is no error message for this, only a segmentation fault. Anything large belongs on the heap, which is week 19. Check your limit with ulimit -s.

Stack overflow is not reported gracefully. The stack grows downward into an unmapped guard page and the program receives a fatal signal. There is no NULL to check and no error code. Sanitizers detect it — -fsanitize=address reports stack-overflow — but the language itself gives you nothing.

5The cost of global state

A file-scope variable is visible to every function below it, which sounds convenient and is the reason it is overused.

int total = 0;                    /* everyone can read and write this */

void add(int n)   { total += n; }
void reset(void)  { total = 0;  }
int  report(void) { return total; }

What it costs:

  • Reasoning. To know what report returns you must know every call to add and reset that has happened. A function taking parameters and returning a value can be understood alone.
  • Testing. Tests must reset the state between cases, and they cannot run in parallel. Week 35 hits this immediately.
  • Reuse. There is exactly one total. Handling two independent sums requires rewriting all three functions.
  • Threads. Two threads calling add concurrently corrupt the value. Week 41.

The alternative is to pass state explicitly — a parameter now, a pointer to a structure from week 21 onward:

typedef struct { int total; } Accumulator;

void add(Accumulator *a, int n)   { a->total += n; }
void reset(Accumulator *a)        { a->total = 0;  }
int  report(const Accumulator *a) { return a->total; }

Longer to write and better in every other respect: independent instances, testable in isolation, thread-safe by construction, and the const says which operations are read-only. Week 33 makes this an opaque handle.

Where a genuinely global resource exists — a log file, a configuration loaded once — make it static at file scope so at least the name does not leak into other translation units.

6Worked example: a program that prints its own memory map

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

/* .data — initialized, writable, whole-run lifetime */
int    global_initialized = 42;
double global_double      = 3.14;

/* .bss — zero-initialized; costs no space in the executable */
int    global_zero;
int    big_zero_array[100000];

/* .rodata — read-only */
const char *const message = "stored in read-only memory";
const int  lookup[4] = { 1, 2, 3, 4 };

/* file scope but not exported: week 28 */
static int file_private = 7;

/* A static local: block scope, static duration. */
static int call_count(void)
{
    static int calls = 0;       /* initialized once, before main */
    return ++calls;
}

/* Demonstrates that each frame is distinct and reused. */
static void frame_depth(int depth)
{
    int marker = depth;
    printf("    depth %d: &marker = %p\n", depth, (void *)&marker);
    if (depth < 3) {
        frame_depth(depth + 1);
    }
}

static void shadowing_demo(void)
{
    int file_private = 999;     /* shadows the file-scope one */
    printf("  inside the function, file_private = %d\n", file_private);
    printf("  (the file-scope one is still 7, but unreachable by name here)\n");
}

int main(void)
{
    int    local          = 1;
    char   local_array[64] = "on the stack";
    void  *heap_block      = malloc(1024);

    if (heap_block == NULL) {
        fprintf(stderr, "allocation failed\n");
        return EXIT_FAILURE;
    }

    puts("== where things live (addresses vary between runs) ==\n");
    printf("  %-26s %p\n", ".text  (code: main)",      (void *)(void *)main);
    printf("  %-26s %p\n", ".rodata (string literal)", (void *)message);
    printf("  %-26s %p\n", ".rodata (const array)",    (void *)lookup);
    printf("  %-26s %p\n", ".data  (global = 42)",     (void *)&global_initialized);
    printf("  %-26s %p\n", ".data  (static file var)", (void *)&file_private);
    printf("  %-26s %p\n", ".bss   (global zero)",     (void *)&global_zero);
    printf("  %-26s %p\n", ".bss   (big zero array)",  (void *)big_zero_array);
    printf("  %-26s %p\n", "heap   (malloc 1024)",     heap_block);
    printf("  %-26s %p\n", "stack  (local int)",       (void *)&local);
    printf("  %-26s %p\n", "stack  (local array)",     (void *)local_array);

    puts("\n  read the addresses: code and constants lowest, then the");
    puts("  writable statics, then the heap, with the stack far above");

    puts("\n== automatic versus static duration ==");
    printf("  call_count() -> %d\n", call_count());
    printf("  call_count() -> %d\n", call_count());
    printf("  call_count() -> %d   (the value persisted between calls)\n",
           call_count());

    puts("\n== zero-initialization ==");
    printf("  global_zero (static, no initializer) = %d  -- guaranteed 0\n",
           global_zero);
    puts("  an uninitialized LOCAL would hold garbage instead");

    puts("\n== stack frames ==");
    printf("  main's local is at      %p\n", (void *)&local);
    frame_depth(1);
    puts("  each frame sits at a lower address; they are popped on return");

    puts("\n== scope and shadowing ==");
    printf("  at file scope, file_private = %d\n", file_private);
    shadowing_demo();

    puts("\n== read-only memory ==");
    printf("  message = \"%s\"\n", message);
    puts("  writing through it would be refused by the hardware");

    free(heap_block);
    return EXIT_SUCCESS;
}
gcc -std=c17 -Wall -Wextra -Wshadow -g -o layout layout.c
./layout

What the compiler adds

Because -Wshadow is on:

layout.c:52:9: warning: declaration of 'file_private' shadows
              a global declaration [-Wshadow]

That warning is off by default and finds a real class of bug. Add it permanently.

Confirm the sections from outside the program

size layout
   text	   data	    bss	    dec	    hex	filename
   3521	    664	 400064	 404249	  62b99	layout

The bss figure is roughly 400 KB — the 100 000-element zeroed array — yet the executable on disk is only a few kilobytes. Zeroed statics are recorded as a size, not as content. Delete big_zero_array, rebuild, and watch bss collapse.

nm layout | grep -E ' [BDRT] ' | sort

The letter in nm's output is the section: T for text, D for data, B for bss, R for read-only. You can see each variable land exactly where the table in section 3 predicted.

Exhaust the stack on purpose

static void recurse(int depth)
{
    char padding[1024];
    padding[0] = (char)depth;
    printf("\rdepth %d", depth);
    recurse(depth + 1);
}
ulimit -s            # your stack limit in kilobytes
./layout             # add a call to recurse(1)

It prints an increasing depth and then dies with a segmentation fault — no message, no chance to recover. Divide your stack limit by 1024 and the depth at which it fails should be close. Week 18 does this arithmetic deliberately.

7Common mistakes

MistakeWhat happensFix
Returning a pointer to a localDangling pointer once the frame popsCaller-supplied buffer, static, or heap.
Expecting an uninitialized local to be 0Garbage; statics are zeroed, locals are notInitialize every local at declaration.
A large array as a localStack overflow, no diagnosticUse the heap — week 19.
Shadowing a variable accidentallyThe wrong object is modifiedCompile with -Wshadow.
Globals instead of parametersUntestable, unreusable, thread-unsafePass state explicitly; group it in a struct.
A static local in a function called from threadsShared state, data raceMake the state a parameter — week 41.
Writing through a pointer to a literalCrash: .rodata is read-onlyDeclare it const char *.
Assuming a static initializer runs at first callIt runs once, before mainUse a flag if you need lazy initialization.

8Check yourself

Give an example where scope and storage duration differ.

A static local variable. Its scope is the block it is declared in — no other function can name it — but its storage duration is the entire program run, so it retains its value between calls. Scope is about visibility of the name; duration is about lifetime of the object.

Why is a static variable guaranteed to be zero when an uninitialized local is not?

Statics are allocated once at program start, and the loader zeroes the .bss region before main runs. A local is created by adjusting the stack pointer on each call, which does not clear the memory; whatever the previous call left there remains.

Why does a ten-megabyte zeroed global array barely increase the executable's size?

Because it goes in .bss, which records only a size. There is nothing to store — every byte is zero — so the loader simply reserves and clears that much memory at startup. Give the same array a non-zero initializer and it moves to .data, where the contents must actually be in the file.

What happens when the stack runs out, and how would you notice?

The stack grows into an unmapped guard page and the program receives a fatal signal — a segmentation fault with no message and no opportunity to recover. There is no return code to check. The usual causes are unbounded recursion and very large local arrays; both are fixed by moving the data to the heap or bounding the depth.

Name two concrete costs of using a file-scope variable instead of a parameter.

Any two of: you cannot understand a function without tracing every other function that touches the variable; tests must reset shared state and cannot run in parallel; only one instance can ever exist, so the code cannot be reused for two independent sets of data; and concurrent access from threads corrupts it. Passing state explicitly removes all four.

9Where this leads

You can now picture where every object lives. Week 18 uses the stack half of that picture directly: recursion is the construct that makes frames visible, and you will watch them accumulate and unwind in a debugger. Week 19 then uses the heap half, where lifetime stops being automatic and becomes your responsibility.