Variables, Scope, Storage Duration and Lifetime

Every variable has three separate properties that beginners often blur together:

Learning Objectives

Use the words identifier, variable, object, value and address precisely.

Distinguish declaration from definition, scope from lifetime, and linkage from storage duration.

Predict the lifetime of locals, parameters, globals, static locals, extern objects, thread-local objects and C++ temporaries.

Explain what const, volatile and register do — and what they do not do.

Tell undefined, unspecified and implementation-defined behavior apart.

Avoid dangling references, shadowing bugs and reads of uninitialised objects.

Prerequisites

Module 3 (where static, stack and heap objects live).

Plain-Language Explanation

Every variable has three separate properties that beginners often blur together:

Scope — *where in the source code* its name can be used.

Lifetime (storage duration) — *when during execution* its storage exists.

Linkage — whether the *same name in another file* refers to the same thing.

A static local variable inside a function has a tiny scope (just that function) but a lifetime as long as the whole program. A pointer to a local variable can outlive the variable itself — and then it points to nothing valid. Most "mysterious" memory bugs are a variable used outside its lifetime.

Precise Technical Explanation

Identifier, variable, object, value and address An identifier is a name in the source (count).

An object (C/C++ meaning) is a region of storage that holds a value. Objects need not have names: malloc creates unnamed objects.

A variable is a named object (C++ also counts references).

A value is the meaning of the object's bytes under its type.

An address is where the object starts; &count yields it.

Declaration versus definition A declaration introduces a name and its type; a definition also creates the object or function body. extern int count; declares; int count = 0; defines. A program may declare something many times but define it exactly once (the *one-definition rule*).

Scope: block, function, file and prototype scope in C Where the name is visible from the declaration to the closing } locals, parameters the whole function only goto labels from the declaration to the end of the translation unit globals, functions to the end of the prototype int f(int n); — n

Linkage: external, internal and no linkage External linkage — the same name in every translation unit refers to one entity (ordinary globals and functions).

Internal linkage — private to one translation unit (static at file scope).

No linkage — each declaration is its own entity (locals, parameters).

Storage duration: automatic, static, allocated and thread entering its block leaving the block before main (C) globals, static locals _Thread_local / thread_local Local variables and parameters

Automatic objects are created each time their block is entered — a recursive function has one copy per active call. Parameters are local objects initialised from the arguments (C passes everything by value; passing a pointer passes the address *by value*).

File-scope objects with static storage duration. Convenient but costly: any function may modify them, they make code non-reentrant and hard to test, and they are shared between threads.

Static local variables Scope of a local, lifetime of a global. In C they are initialised before the program starts (only with constant expressions); in C++ they are initialised the first time control passes through the declaration, and that initialisation is thread-safe since C++11.

extern declarations extern int x; says "x is defined somewhere else with external linkage". Put such declarations in headers and the single definition in one .c file.

const and read-only intent const means *this name may not be used to modify the object*. It is a promise checked by the compiler, not a guarantee the bytes never change: another non-const path, or hardware, may still modify them. Objects *defined* const may be placed in read-only memory, and modifying them through a cast is undefined behavior.

volatile and what it does and does not guarantee volatile tells the compiler that every read and write of the object is an observable side effect: it must not cache the value in a register, remove "redundant" accesses, or merge them. That is exactly what memory-mapped hardware registers need. volatile does not make operations atomic, does not order them with respect to other threads, and does not insert hardware memory barriers. It is not a thread-synchronisation tool (Module 14).

register as a historical keyword register once asked the compiler to keep a variable in a CPU register. Modern compilers allocate registers far better than people, so it is ignored for that purpose; in C it still forbids taking the variable's address, and C++17 removed the keyword's meaning entirely.

_Thread_local (C11; thread_local in C23 and C++11) gives every thread its own copy of a static-like object. Other languages: ThreadLocal<T> in Java and C#, threading.local() in Python, thread_local! in Rust.

Object initialization and default initialization by language Uninitialised global / field indeterminate — reading it is a bug compile error ("might not have been initialized") zero / null / false compile error (definite assignment) must be initialised let x; is undefined NameError until assigned

Lifetime boundaries and dangling references When a lifetime ends, every pointer or reference to the object becomes dangling. Using it is undefined behavior — it may appear to work, print garbage, or crash, and the behavior can change with optimization level.

Temporaries in C++ and managed languages C++ creates temporary objects for intermediate results (std::string("a") + "b"). A temporary lives until the end of the *full-expression* that created it, unless it is bound directly to a const& or && local reference, which extends its lifetime to the reference's. Managed languages also create temporaries, but the garbage collector keeps them alive as long as they are reachable, so they cannot dangle.

An inner declaration with the same name hides the outer one. It is legal and often a bug. -Wshadow warns about it.

Undefined, unspecified and implementation-defined behavior the implementation chooses and documents the behavior

sizeof(long), whether char is signed, right shift of a negative number several outcomes allowed, no documentation required evaluation order of function arguments Undefined (UB) no requirements at all reading an uninitialised local, signed overflow, use after lifetime, out-of-bounds access

UB is not "whatever the hardware does": optimizers assume UB never happens and remove code on that basis (Module 9).

Zero initialization versus uninitialized automatic objects Objects with static or thread storage duration are zero-initialised before any other initialisation. Automatic and malloced objects are not: their bytes are whatever was in that memory before. calloc and = {0} give zeroed memory explicitly.

Lifetime and scope exercises See the three exercises at the end of this module, and Lab 3 in Module 20.

Why It Matters in Real Systems

Returning a pointer to a local, caching a pointer into a vector that later reallocates, using an object after free — these lifetime errors are among the most common causes of crashes and security vulnerabilities in C and C++ code. Thread-local storage and static locals underpin errno, allocators and loggers.

Key Terms

Region of source text where a name is visible Lifetime / storage duration

Period of execution during which storage exists Whether a name refers to the same entity across translation units

Exactly one definition of each object/function in the program

A pointer to an object whose lifetime has ended The unspecified contents of an uninitialised automatic object One instance per thread

An expression that is not part of a larger expression; temporaries die at its end

Memory Layout and Execution Flow

flowchart LR
  P[Program start] --> G[static objects exist: globals, static locals]
  G --> M[main called]
  M --> F[f called: f's locals created]
  F --> R[f returns: f's locals destroyed]
  R --> E[exit: static objects destroyed]

Syntax

extern int shared_count;          /* declaration, external linkage */
int shared_count = 0;             /* definition (one file only) */
static int file_only = 0;         /* internal linkage, static duration */
_Thread_local int per_thread;     /* thread storage duration (C11) */

void f(int param)                 /* param: block scope, automatic */
{
    static int calls = 0;         /* function-local name, static duration */
    const int limit = 10;         /* may not be modified through this name */
    volatile int *status = 0;     /* every access through status is observable */
    int local;                    /* indeterminate until assigned */
}

Complete Runnable Example

The specification's static-local counter, extended to show every storage duration. GCC 11.2, x86-64 Linux.

Scope, linkage and the one-definition rule across two files — and what the linker says when a name has internal linkage:

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

int global_calls;                    /* static duration, external linkage, zero-initialised */

static int *make_heap_int(int v)     /* internal linkage */
{
    int *p = malloc(sizeof *p);      /* allocated duration: lives until free */
    if (p != NULL) {
        *p = v;
    }
    return p;
}

void counter(void)
{
    static int count = 0;            /* initialised once, before main */
    int automatic = 0;               /* re-created on every call */
    ++count;
    ++automatic;
    ++global_calls;
    printf("count=%d automatic=%d global_calls=%d\n", count, automatic, global_calls);
}

int main(void)
{
    counter();
    counter();
    counter();

    int *kept = make_heap_int(42);   /* the object outlives make_heap_int's frame */
    if (kept == NULL) {
        return 1;
    }
    printf("heap object still alive: %d\n", *kept);
    free(kept);                      /* its lifetime ends here */
    kept = NULL;
    return 0;
}
/* m04_lib.c */
int shared_total = 100;              /* definition with external linkage */
static int hidden_helper(int x)      /* internal linkage: invisible to other files */
{
    return x * 2;
}
int public_api(int x)
{
    return hidden_helper(x) + shared_total;
}
/* m04_main.c */
#include <stdio.h>
extern int shared_total;             /* declaration: defined in m04_lib.c */
int public_api(int x);
int hidden_helper(int x);            /* tries to use the static function */

int main(int argc, char *argv[])
{
    (void)argv;
    printf("public_api(1) = %d\n", public_api(1));
    if (argc > 5) {
        printf("%d\n", hidden_helper(1));   /* referenced, so the linker must find it */
    }
    return 0;
}
gcc -c -Wall m04_lib.c -o m04_lib.o
echo "--- nm: T = external, t = internal (static), D = data ---"; nm m04_lib.o
gcc -c -Wall m04_main.c -o m04_main.o
echo "--- link ---"
gcc m04_main.o m04_lib.o -o m04_two 2>&1 | grep -o "undefined reference to .hidden_helper." || true
echo "--- after removing the call to hidden_helper ---"
sed '/hidden_helper/d' m04_main.c > m04_main_fixed.c
gcc -Wall m04_main_fixed.c m04_lib.c -o m04_two && ./m04_two

Line-by-Line Explanation

global_calls has no initialiser, so it is zero before main starts.

count is initialised once; its value survives between calls. automatic starts at 0 on every call.

make_heap_int returns a pointer to an *allocated* object — valid after the function returns, unlike a pointer to one of its locals.

free(kept) ends the heap object's lifetime; kept = NULL prevents accidental reuse of *this* pointer (other copies would still dangle).

In the two-file example nm shows T public_api (external), t hidden_helper (internal) and D shared_total (initialised data).

The linker cannot see hidden_helper from the other file, so a reference fails with "undefined reference".

Expected Output

count=1 automatic=1 global_calls=1
count=2 automatic=1 global_calls=2
count=3 automatic=1 global_calls=3
heap object still alive: 42
--- nm: T = external, t = internal (static), D = data ---
0000000000000000 t hidden_helper
0000000000000012 T public_api
0000000000000000 D shared_total
--- link ---
undefined reference to `hidden_helper'
--- after removing the call to hidden_helper ---
public_api(1) = 102

Step-by-Step Execution Trace

1st counter() 1 (then destroyed) 2nd counter() 3rd counter()

Interactive Visualization

The lifetime visualizer tracks creation, use, aliasing, ownership transfer and destruction. Step through the safe example, then the dangling-pointer example, and watch the alias turn red when its object's lifetime ends.

Common Mistakes and Failure Modes

GCC catches this particular case, and also reads of uninitialised locals and shadowing — but only when asked, and the uninitialised check needs optimization to see the data flow:

Other mistakes: assuming volatile makes a counter thread-safe; defining (not just declaring) a variable in a header; relying on the unspecified order of argument evaluation, as in printf("%d %d", next(), next()).

int *broken(void)
{
    int local = 5;
    return &local;      /* dangling: local dies when broken() returns */
}
/* m04_mistakes.c */
#include <stdio.h>
int *broken(void)
{
    int local = 5;
    return &local;
}
int sum(int n)
{
    int total;                       /* never initialised */
    for (int i = 0; i < n; ++i) {
        int total = i;               /* shadows the outer total */
        (void)total;
    }
    return total;
}
int main(void)
{
    printf("%d %p\n", sum(3), (void *)broken());
    return 0;
}
gcc -std=c17 -O2 -Wall -Wextra -Wshadow -c m04_mistakes.c -o /dev/null 2>&1 | grep -E 'warning' | sed 's/^m04_mistakes.c://'

Corrected / Safe Pattern

/* let the caller own the storage ... */
void fill(int *out) { *out = 5; }

/* ... or return by value ... */
int make(void) { return 5; }

/* ... or return allocated storage and document who frees it */
int *make_heap(void);   /* caller must free() the result */

int sum(int n)
{
    int total = 0;      /* always initialise */
    for (int i = 0; i < n; ++i) total += i;
    return total;
}

Debugging and Diagnostic Techniques

Warnings: -Wall -Wextra -Wshadow -Wuninitialized; -O2 improves uninitialised-use detection.

AddressSanitizer detects stack-use-after-return with ASAN_OPTIONS=detect_stack_use_after_return=1 and heap use-after-free (Module 9).

MemorySanitizer (Clang) and Valgrind detect reads of uninitialised memory.

A debugger's "locals" view shows automatic variables per frame; "value optimized out" means the optimizer removed the storage.

Performance Implications

Locals are the cheapest storage: usually registers or a stack slot already in cache. Globals and static locals need address calculations and may be shared between threads, which inhibits some optimizations. volatile forces a memory access every time and can make loops much slower — use it only for real hardware or signal-handler flags. Function-local statics in C++ add a guard check on every call.

Security and Safety Implications

Uninitialised memory can leak secrets (old passwords, keys) into output. Dangling pointers are exploitable when the memory is reused for attacker-controlled data. Mutable globals are a common source of data races. Prefer const, initialise everything, and minimise global state.

Platform and Language Caveats

C allows static-storage initialisers only with constant expressions; C++ allows arbitrary code and runs it before main or on first use.

Since C++20 the result of a common mistake — std::string_view sv = std::string("temp"); — still dangles: lifetime extension applies only to references bound directly to the temporary.

Java, C#, Python and JavaScript have no dangling references to *objects*: the garbage collector keeps reachable objects alive. They do have lifetime issues with *resources* (files, sockets) — Module 15.

Thread-local storage has a per-thread memory cost and may be slow on some platforms when accessed from shared libraries.

Real-World Application

errno is thread-local in every modern C library. Memory allocators keep per-thread caches in thread-local storage. Embedded drivers declare status registers volatile. Logging frameworks use function-local statics for lazily-created singletons.

Best Practices Checklist

[ ] Initialise every variable at its definition.

[ ] Give each name the smallest scope and the shortest lifetime that works.

[ ] Use static for file-private functions and objects.

[ ] Declare in headers with extern; define once.

[ ] Never return or store pointers to automatic objects beyond their block.

[ ] Use volatile only for hardware registers and signal flags; use atomics for threads.

[ ] Build with -Wall -Wextra -Wshadow.

Exercises

E1 · Beginner. For each variable state its scope, storage duration and linkage: int a; at file scope; static int b; at file scope; static int c; inside a function; a parameter d.

Answer: a: file scope, static duration, external linkage. b: file scope, static, internal linkage. c: block scope, static, no linkage. d: block scope, automatic, no linkage.

E2 · Intermediate. Why can counter be called from two threads at once only with care, and how would _Thread_local change its behavior?

Answer: count is one shared object, so concurrent ++count is a data race (undefined behavior). With static _Thread_local int count, each thread gets its own counter — no race, but counts are per thread, not global.

E3 · Advanced challenge. In C++, explain the output of this code and fix it:

Answer: The temporary std::string("temp") is bound to the *parameter* a, not directly to s, so lifetime extension does not apply; it is destroyed at the end of the full-expression and s dangles — using it is undefined behavior. Fix: return by value (std::string pick(...)) or keep a named std::string alive in the caller.

const std::string& pick(const std::string& a) { return a; }
const std::string& s = pick(std::string("temp"));
std::cout << s;

Quiz

? A static local variable has...
- block scope and automatic duration :: Automatic would mean it is re-created on each call.
+ block scope and static duration :: Its name is local; its storage lives for the whole program.
- file scope and static duration :: The name is not visible outside the function.
- file scope and automatic duration :: That combination does not exist.

? What does volatile guarantee in C?
- The access is atomic. :: volatile does not provide atomicity.
- Other threads see the write immediately. :: It gives no inter-thread ordering or visibility guarantees.
+ The compiler performs every access to the object as written. :: Reads and writes are side effects and are not removed or cached.
- The object is placed in read-only memory. :: That is const (for defined-const objects), not volatile.

? Which are undefined behavior in C? (select all that apply)
+ Reading an uninitialised automatic int :: Its value is indeterminate; using it is UB in the cases that matter.
- The order in which f() and g() are evaluated in h(f(), g()) :: That is unspecified, not undefined.
+ Dereferencing a pointer to a local after the function returned :: The object's lifetime has ended.
- si
…

Summary

Scope is about names in source code; lifetime is about storage at run time; linkage is about names across files. C has four storage durations — automatic, static, allocated and thread. Using an object outside its lifetime, or reading it before it is initialised, is undefined behavior. const restricts modification through a name, volatile forces every access to happen, and neither provides thread safety.

References and Further Reading

ISO/IEC 9899:2018 (C17) §6.2.1 (scopes), §6.2.2 (linkages), §6.2.4 (storage durations), §3.4 (behavior categories).

cppreference.com: "Storage-class specifiers", "Lifetime", "Reference initialization".

John Regehr, "A Guide to Undefined Behavior in C and C++" (blog series).