Scope and linkage
Scope in C is the region of the program in which a name is visible. The language defines four kinds of scope, each with distinct rules. Linkage governs whether the same name in different translation units denotes the same entity. Together with storage duration — the lifetime of an object — scope and linkage form the system that the C standard uses to specify identifier visibility, object lifetime, and the One Definition Rule. Understanding the interactions among these three concepts is necessary to read non-trivial C code; many of the language’s idioms — header guards, static at file scope, extern declarations of globals — are direct consequences of the rules.
The four scopes
The standard names four kinds of scope in §6.2.1:
- Block scope — the curly-brace-delimited region in which an identifier is declared. Each compound statement and function body introduces a new block scope.
- File scope — the scope of identifiers declared at the top level of a translation unit, outside any function or block. File scope extends from the point of declaration to the end of the translation unit.
- Function scope — the scope of
gotolabels. A label is visible throughout the function in which it appears, regardless of where in the function the label is declared. - Function prototype scope — the scope of parameter names in a function declaration that is not a definition. Such names are visible only within the prototype itself, and they may be omitted entirely.
/* file scope */
int counter;
void f(int x); /* x has function prototype scope */
void g(int n) { /* n has block scope (the function body) */
int total = 0; /* total has block scope */
for (int i = 0; i < n; ++i) { /* i has block scope (the for-init) */
int square = i * i; /* square has block scope (the loop body) */
total += square;
}
/* square and i are no longer visible here */
return;
cleanup: /* cleanup has function scope */
do_cleanup();
}
A name declared in an inner block shadows any name with the same identifier in an outer scope; the inner declaration prevails until the block closes.
Storage duration
Distinct from scope is storage duration: the lifetime of the object the name designates. The standard recognises four:
- Automatic — the object exists for the duration of the block in which it is declared. The default for variables declared at block scope.
- Static — the object exists from program startup to program termination. The default for variables declared at file scope, and for any variable declared with the
statickeyword. - Thread — one instance of the object exists per thread, with the same start-to-end-of-thread lifetime. The
_Thread_local(C11) /thread_local(C23) specifier introduces this duration. - Allocated — the object exists from a successful call to
malloc,calloc,aligned_alloc, orreallocuntil the correspondingfree. Storage duration is independent of any block.
Scope and storage duration are independent. A static variable inside a function has block scope (the variable is visible only inside the function) but static storage duration (its value persists across calls):
int next_id(void) {
static int counter = 0; /* block scope, static duration */
return ++counter;
}
The variable counter is initialised to 0 once, before the program starts, and retains its value from one call to the next. The same identifier counter declared without static would have automatic duration: the variable would be re-created and re-initialised on every call.
Linkage
Linkage applies only to identifiers declared at file scope and to identifiers declared with extern at any scope. It governs whether the same identifier in different translation units, or in different declarations within the same translation unit, refers to the same entity.
The three linkage classes are:
- External linkage — every reference to the identifier across all translation units linked together refers to the same object or function. Functions defined at file scope and variables defined at file scope without
statichave external linkage. - Internal linkage — every reference to the identifier within a single translation unit refers to the same entity, but the identifier is invisible from outside the translation unit. Functions and variables declared
staticat file scope have internal linkage. - No linkage — the identifier denotes a unique entity per declaration. All identifiers declared at block scope (without
extern), all identifiers other than objects and functions, and all members of structures, unions, and enumerations have no linkage.
/* file_a.c */
int shared = 0; /* external linkage; defined here */
static int local = 0; /* internal linkage; private to this file */
/* file_b.c */
extern int shared; /* external linkage; refers to the definition in file_a.c */
extern int local; /* compile error or link error: no external 'local' */
The interaction of storage class, scope, and linkage
The interaction among the three is exhaustively summarised by a few rules:
| Declaration | Scope | Storage duration | Linkage |
|---|---|---|---|
int x; at file scope | file | static | external |
static int x; at file scope | file | static | internal |
extern int x; at file scope | file | static | external (referring to elsewhere) |
int x; at block scope | block | automatic | none |
static int x; at block scope | block | static | none |
extern int x; at block scope | block | static | external |
Two cases warrant emphasis:
staticat file scope means not exported;staticat block scope means persistent across calls. The two uses share the keyword for historical reasons but are conceptually distinct.externat block scope is rare; it allows a function to reference a global without making the global visible to the rest of the file. The pattern is mostly used when the global is conditionally defined and most callers should not see it.
Tentative definitions
A tentative definition is a declaration at file scope of an object that has external or internal linkage and no initialiser:
int counter; /* tentative definition */
If, by the end of the translation unit, no other declaration of the identifier with an initialiser appears, the tentative definition becomes a true definition with the initialiser 0 (or, for aggregates, the equivalent of {0}). If multiple tentative definitions appear, they are taken to be the same object.
Tentative definitions are a quirk of C’s design: they exist principally because Fortran-style declarations were familiar in 1972, and the rule has remained for compatibility. The principal practical consequence is that two object files defined as
/* a.c */ int counter;
/* b.c */ int counter;
will link successfully under most implementations even though both files appear to define counter. The standard says the behaviour is permitted but the strict rule is that one definition must dominate; some linkers diagnose, others silently coalesce. For portability and clarity, define globals exactly once with an explicit = initialiser, and reference them elsewhere with extern.
The One Definition Rule
The standard requires that any object or function with external linkage have at most one definition across the whole program. Multiple declarations are permitted; multiple definitions are not. The diagnostic from the linker — multiple definition of 'foo' — is the typical evidence that the rule has been violated.
The conventional discipline:
- Each
.cfile (translation unit) defines the objects and functions it owns. - Each
.hfile declares what other files may need, withexternfor objects. - Each
.cfile#includes the headers of the modules it uses. - Linker errors of the multiple-definition variety usually indicate that a definition was placed in a header rather than a source file.
/* counter.h */
#ifndef COUNTER_H
#define COUNTER_H
extern int counter; /* declaration */
void counter_increment(void); /* declaration */
#endif
/* counter.c */
#include "counter.h"
int counter = 0; /* definition */
void counter_increment(void) { /* definition */
++counter;
}
/* main.c */
#include "counter.h"
int main(void) {
counter_increment();
return counter;
}
The header guard around counter.h ensures that even if a translation unit #includes it transitively through several other headers, the contents are processed only once. Header guards are necessary because #include is textual substitution and the preprocessor has no other notion of include-once.
Object lifetime and scope
The lifetime of an object is the period during which storage is reserved for it. For automatic objects, lifetime begins when execution enters the enclosing block and ends when execution leaves. For static objects, lifetime is the entire program execution. For allocated objects, lifetime begins on the successful allocation call and ends on the matching free.
A pointer to an automatic object becomes invalid when the object’s lifetime ends, even if the pointer itself remains in scope:
int *make(void) {
int x = 42;
return &x; /* undefined: the lifetime of x ends with the function */
}
The compiler is not required to diagnose this; some do, with -Wreturn-local-addr. The discipline is to allocate dynamically (and arrange for the caller to free), to use static storage (sharing across all calls), or to take a pointer to a caller-provided buffer.
Common patterns
Module-private state via static at file scope
/* logger.c */
static FILE *log_file = NULL; /* internal linkage */
void log_open(const char *path) { log_file = fopen(path, "w"); }
void log_close(void) { if (log_file) fclose(log_file); }
void log_write(const char *msg) { if (log_file) fputs(msg, log_file); }
Variables declared static at file scope are invisible outside the file; they form a clean encapsulation boundary.
Globals declared in a header, defined in one source file
/* config.h */
extern int config_max_workers;
extern char config_log_path[];
/* config.c */
int config_max_workers = 4;
char config_log_path[] = "/var/log/server.log";
The pattern — extern declaration in the header, definition in exactly one source file — is the One Definition Rule applied at the source level.
Function-static accumulators
double running_average(double x) {
static double sum = 0.0;
static int count = 0;
sum += x;
return sum / ++count;
}
Function-static variables are not visible to other functions and persist across calls. They are a reasonable mechanism for memoisation and counters in single-threaded code; in multithreaded code they are a hazard, since they introduce shared mutable state without synchronisation.