TRT
Sign in

Foundations · Module 2 · Lesson 8 of 10

Storage Classes, Scope, and Linkage

Master variable lifecycles across virtual memory, differentiate internal from external linkage, and organize multi-file C architectures.

FreeAbout 19 minutes

By the end you can

  • Differentiate variable scope from storage duration and linkage semantics
  • Map static and global variables to the ELF .data and .bss binary sections
  • Control symbol visibility across translation units using static and extern
  • Prevent multiple-definition linker collisions in multi-file projects

Builds on Functions and Call Stack Frames.

01 · A question first

Why does defining an uninitialized global variable in a header file cause ld to crash with multiple definition errors, while adding static makes it compile yet duplicates state? The answer lies in how C decouples identifier scope from storage duration and symbol linkage.

Make a guess, then step through the trace below to check it.

02 · Watch it run

Lifetime and memory persistence of static versus automatic variablesstep 1 of 6
void step(void) {    static int persistent = 0;    int transient = 0;    persistent++;    transient++;}
  1. slot 0.data: persistent = 0(looked at in this step)
  2. slot 1stack frame: none
  3. slot 2transient: unallocated
  4. slot 3calls: 0

Program loaded into memory: persistent is initialized to 0 in the ELF .data segment before main() runs.

Machine work: loader maps .data segment at fixed virtual address 0x601040

03 · The idea

Start with the problem

When learning C, programmers frequently confuse three distinct properties of a variable:

  • Scope: Where in the source code the variable identifier can be referred to.
  • Storage Duration (Lifetime): When the memory for the variable is allocated and deallocated.
  • Linkage: Whether the symbol is shared across multiple translation units during the linking phase.

Failing to separate these concepts causes major architectural problems when transitioning from single-file scripts to professional multi-file projects.

Consider a development team creating a networked game engine. They create a header file named network.h to declare a shared packet counter:

c
/* network.h */
#ifndef NETWORK_H
#define NETWORK_H
 
int packet_count = 0; /* Global definition in a header */
 
void handle_packet(void);
void report_stats(void);
 
#endif

The header is included in network.c and in stats.c. Both files compile cleanly into network.o and stats.o without a single compiler warning.

However, when the developer runs gcc network.o stats.o -o engine, the build halts:

text
/usr/bin/ld: stats.o:(.data+0x0): multiple definition of 'packet_count'; 
network.o:(.data+0x0): first defined here
collect2: error: ld returned 1 exit status

To work around the error, a developer adds the static keyword inside the header:

c
/* network.h */
static int packet_count = 0;

The project now links cleanly without errors.

Yet during testing, a subtle bug appears: network.c processes 500 incoming packets and increments packet_count, but when stats.c calls report_stats(), it prints Packets processed: 0.

Why did the first version fail at the linker stage even though include guards were present? And why did adding static allow compilation while silently splitting the variable into two independent counters that never synchronized?

To build scalable C software, you must understand how storage duration dictates physical memory placement, how linkage governs symbol visibility across object files, and how to structure multi-file projects using extern.


How the idea works

Every identifier in C is governed by three independent coordinate axes:

text
          Variable Properties
         ┌─────────┴─────────┐
         ▼                   ▼
    Scope (Source)      Storage Duration (RAM)
   - Block Scope       - Automatic (Stack)
   - File Scope        - Static (.data / .bss)
   - Function Scope    - Allocated (Heap)
                       - Thread (TLS)
                             │
                             ▼
                      Linkage (Linker)
                     - None (Local)
                     - Internal (Single file)
                     - External (Whole program)

1. Scope: Where the Identifier Exists in Source Text

Scope defines the region of source code where a name is recognized by the compiler:

  • Block Scope: Any identifier declared inside a pair of braces (or function parameter list) is visible only from its declaration point to the closing brace.
  • File Scope: Any identifier declared outside of all blocks is visible from its point of declaration to the end of the translation unit.
  • Function Scope: Applies exclusively to goto labels. A label is visible throughout the entire enclosing function body regardless of block nesting.

2. Storage Duration: How Long the Memory Exists

Storage duration determines when physical memory is allocated and reclaimed:

  • Automatic Storage Duration: Objects declared inside blocks without static occupy space on the call stack. Memory is allocated when the block is entered and deallocated when execution leaves the block.
  • Static Storage Duration: Memory is allocated once when the operating system loads the binary image into virtual memory, and remains allocated until process termination. All file-scope variables, as well as block-scope variables qualified with static, have static storage duration.
  • Allocated Storage Duration: Storage requested from a heap allocator. Its lifetime persists until explicitly released; the allocation APIs are introduced in the Core dynamic-memory module.
  • Thread Storage Duration: Memory allocated per thread using the _Thread_local (or <threads.h>) qualifier.

3. Binary Layout: .data vs .bss

Variables with static storage duration reside in dedicated ELF binary segments rather than on the temporary call stack:

  • The .data Segment: Stores global and static variables that have an explicit, non-zero initial value. These byte values are written into the compiled binary on disk.
  • The .bss Segment: Stores global and static variables that are uninitialized or explicitly initialized to zero. Crucially, .bss occupies zero bytes of disk space inside the executable file. The ELF header records how many bytes of zeroed memory the operating system loader must allocate when mapping the process.

In the trace above:

  1. Step 1 shows static int persistent = 0; placed into static memory before main() begins.
  2. In step 2, calling step() creates a transient stack frame for transient.
  3. In step 4, returning from step() tears down the stack frame, destroying transient. But persistent lives in the static memory segment, retaining its incremented value of 1.
  4. In step 5 and step 6, the second call to step() allocates a fresh transient initialized to 0, while persistent continues counting from 1 to 2.

4. Linkage: How Identifiers Cross Translation Unit Boundaries

Linkage determines whether multiple instances of an identifier in different translation units refer to the exact same object in memory:

  • No Linkage: Identifiers that represent local entities visible only within their immediate block. All automatic local variables and type definitions have no linkage.
  • Internal Linkage: An identifier visible across all functions within a single .c translation unit, but invisible to the linker. Declaring a file-scope variable or function with the static keyword grants it internal linkage. The compiler omits the symbol from the global symbol table exported in the .o file.
  • External Linkage: An identifier visible across the entire program. All file-scope variables and functions without the static keyword have external linkage by default. The compiler exports the symbol in the object file's .symtab, allowing the linker (ld) to resolve references from other object files.

5. Storage Class Specifiers

C provides four primary storage class keywords that configure these behaviors:

SpecifierStorage DurationScopeLinkageCommon Placement
autoAutomaticBlockNoneCall Stack (default for locals)
registerAutomaticBlockNoneCPU Register hint (cannot use &)
static (local)StaticBlockNone.data or .bss segment
static (global)StaticFileInternal.data or .bss (module-private)
externStaticFile/BlockExternalRefers to definition elsewhere

Why it is correct

Understanding the mechanics of linkage explains the mystery of our opening problem.

The Header File Definition Trap

When network.h contained int packet_count = 0;:

  1. The preprocessor expanded network.h into both network.c and stats.c.
  2. The compiler processed network.c and generated network.o, creating a strong global symbol definition named packet_count in its .data section.
  3. The compiler processed stats.c and generated stats.o, creating another identical strong global symbol definition named packet_count.
  4. When ld attempted to link network.o and stats.o, it detected two separate translation units exporting the same global symbol. The linker cannot guess which definition takes precedence, so it failed with multiple definition of 'packet_count'.

The static in Header Anti-Pattern

When the developer changed the header to static int packet_count = 0;:

  1. network.c included the header. The static keyword instructed the compiler to give packet_count internal linkage. The compiler created a private variable named packet_count scoped strictly to network.o.
  2. stats.c included the header. The compiler created another private variable named packet_count scoped strictly to stats.o.
  3. The linker ran. Because both symbols were internal, there was no name collision.
  4. However, at runtime, network.c and stats.c were modifying two completely different memory addresses located in different parts of the .data segment. Increments in one file were completely invisible to the other.

The Correct Architecture: Declaration vs. Definition

To share a single global variable across multiple files safely:

  1. Declare the variable as an external reference in the header using extern.
  2. Define the variable exactly once in a single .c implementation file.
c
/* network.h (Included everywhere) */
#ifndef NETWORK_H
#define NETWORK_H
 
/* DECLARATION: Informs compiler the symbol exists, but allocates NO memory */
extern int packet_count;
 
void handle_packet(void);
void report_stats(void);
 
#endif
c
/* network.c (Compiled once) */
#include "network.h"
 
/* DEFINITION: Allocates memory exactly once in the .data segment */
int packet_count = 0;
 
void handle_packet(void) {
    packet_count++;
}
c
/* stats.c (Compiled once) */
#include <stdio.h>
#include "network.h"
 
void report_stats(void) {
    /* Accesses the single global symbol defined in network.c */
    printf("Packets processed: %d\n", packet_count);
}

Now, stats.o emits an unresolved relocation entry for packet_count. During the link stage, ld resolves that relocation entry directly to the address allocated by network.o. Both files share a single memory address.


What it costs

Storage class choices directly impact executable binary sizes and memory locality:

Placement ModelStorage LocationSize Impact on DiskInitialization Overhead
Initialized global (int x = 42;)Often .dataIncreases the image by the object's representation sizeLoaded according to the target executable format
Zeroed global (int x = 0;).bss section0 bytes on diskKernel zeroes memory page on initial access
Local automatic (int x = 0;)Stack frame0 bytes on diskCPU executes initialization instruction on every call
Static local (static int x = 0;).bss section0 bytes on diskZeroed by OS at load time; zero runtime init cost

The Cost of Polluting External Linkage

Every function or global variable declared without static is exported in the object file's public symbol table.

In large projects:

  • Large symbol tables increase linking times and object file sizes.
  • Global symbols cannot be aggressive targets for Link-Time Optimization (LTO) or dead-code elimination because the compiler must assume external libraries might call them.
  • Marking internal helper functions as static allows the compiler frontend to inline the entire function directly into call sites, eliminating call/return instruction overhead entirely.

Pitfalls and variations

Pitfall 1: Modifying Static Locals in Recursive or Multi-Threaded Functions

A static variable declared inside a function retains its state across all invocations. This makes the function non-reentrant and unsafe for multithreading:

c
/* THREAD-UNSAFE FUNCTION */
char *format_ip(uint32_t ip) {
    static char buffer[16]; /* Shared by all callers! */
    snprintf(buffer, sizeof(buffer), "%u.%u.%u.%u", 
             (ip >> 24) & 0xFF, (ip >> 16) & 0xFF, 
             (ip >> 8) & 0xFF, ip & 0xFF);
    return buffer;
}

If Thread A calls format_ip(), and Thread B calls format_ip() before Thread A reads the buffer, Thread B overwrites the memory.

To make this safe:

  • Use caller-allocated destination buffers passed via parameters.
  • Use thread-local storage (_Thread_local static char buffer[16];) so each thread receives its own independent static memory buffer.

Pitfall 2: Confusing extern with Initialization

Writing an initializer alongside an extern declaration overrides the extern qualifier and turns the statement into a full definition:

c
/* In a header file: */
extern int connection_limit = 100; /* DANGEROUS: Acts as a DEFINITION! */

Even though extern was written, providing = 100 forces the compiler to allocate memory and emit a strong symbol definition. If multiple .c files include this header, the linker fails with multiple definition errors. Never provide initializers on extern declarations in header files.

Pitfall 3: Header Guards Do Not Prevent Linker Clashes

Many developers assume that wrapping a header with include guards (#ifndef HEADER_H ... #endif) prevents multiple-definition linker errors:

c
/* common.h */
#ifndef COMMON_H
#define COMMON_H
 
int shared_buffer[1024]; /* DEFINITION */
 
#endif

Header guards prevent a header from being included multiple times within the same translation unit (preventing syntax errors during compilation).

However, each .c file is compiled in an independent compiler process. network.c compiles and produces shared_buffer. main.c compiles separately and produces shared_buffer. The header guard in network.c has zero knowledge of main.c. Header guards protect the compiler; extern protects the linker.


Where it is used

Precision control over storage classes and symbol visibility is central to professional software architecture:

  • Information Hiding in C Libraries: C does not have private class keywords. Instead, production libraries (such as SQLite or libcurl) implement encapsulation by marking every internal helper function and state structure as static. Only public API functions are exported without static.
  • The Linux Kernel Module Interface: Device drivers declare internal variables as static to prevent namespace collisions with thousands of other drivers. Only functions intended for consumption by other kernel subsystems are explicitly exported using the EXPORT_SYMBOL() macro.
  • Embedded Hardware Control Blocks: Embedded systems use static variables inside interrupt service routines (ISRs) to maintain ring buffer indices across hardware interrupts without allocating space on restricted stack memories.

This concludes Module 1 on Compilation and Memory Foundations. With the compilation pipeline, primitive types, evaluation rules, call stack mechanics, and storage classes mastered, you now possess the complete mental model required to manipulate raw memory addresses directly through pointers. Dynamic allocation is introduced after the pointer and array foundations.

04 · Prove it

Question 1 of 3 · answer from memory

What is the key difference between declaring a file-scope variable as 'static int counter;' versus 'int counter;'?

05 · Practice

Something wrong with this lesson? Report a problem

Not started

Sign in to save your learning progress.

Sign in to save