Systems Programming · C and the ABI
Linkage, Symbol Visibility, and the Weak Definition
What the linker actually does with an external declaration, how internal and external linkage differ, and the resolution order that makes weak and common symbols dangerous.
Two translation units can each declare int counter; at file scope, and the resulting program has one variable, not two, not a conflict. Two translation units can each define void helper(void) { } and the linker reports a duplicate symbol. The difference between those two outcomes is linkage, and the rules are older than any current toolchain but still govern how a program is assembled from parts.
A symbol is a name in an object file’s symbol table, with a binding and a type. The linker’s job is to take a set of object files and libraries, resolve every undefined symbol against exactly one definition, and produce an output whose addresses are fixed. Understanding that process explains why some mistakes are caught at link time, some are caught at load time, and some are never caught at all.
Three kinds of linkage, one symbol table
C defines three linkages for a name. External linkage means the name denotes the same entity in every translation unit that declares it. Internal linkage means the name is private to its translation unit. No linkage means the name denotes nothing outside its own scope, which is the case for block-scope variables and for structure members.
static at file scope gives internal linkage. A static int counter; in one file is invisible to every other file, and a static function cannot be called from elsewhere even if a declaration exists. This is the mechanism behind the pattern of a private helper with the same name in several files: each is a distinct object with its own storage, and the linker never sees a conflict because the symbols are local to their object files.
extern does not create storage; it declares that storage exists somewhere. A declaration with extern and no initialiser is a declaration only. The definition, with storage, appears exactly once in the program, or, for a tentative definition, possibly several times.
The tentative-definition rule is the source of the first surprise. In C, a file-scope declaration without extern and without an initialiser is a tentative definition. If the translation unit contains no other definition of the name, the tentative one becomes a definition with zero initialisation. If several translation units each contain a tentative definition of the same name, the standard’s common-symbol model historically permitted the linker to merge them into one object of the largest size. GCC and Clang implement this with -fcommon and, since GCC 10 and Clang 11, default to -fno-common, which makes the multiple tentative definitions a link error. The change broke real code: a header with int counter; at file scope, included by several files, linked silently for years under the old default and became a duplicate-symbol error under the new one. The fix is extern in the header and one definition in one source file.
The direction of the fix matters. A declaration belongs in a header; a definition belongs in a source file. A header that defines an object is included by every translation unit that includes it, and under -fno-common that is a link error; under -fcommon it is one object with confusing ownership.
Binding, type, and the parts of an ELF symbol
An ELF symbol entry carries a name, a value, a size, a binding, a type, and a visibility. The binding is one of STB_LOCAL, STB_GLOBAL, or STB_WEAK. The type distinguishes STT_FUNC, STT_OBJECT, STT_NOTYPE, and the TLS type STT_TLS. The section index says where the symbol lives: a defined symbol points into a section, an undefined symbol is SHN_UNDEF, and an absolute symbol is SHN_ABS.
STB_LOCAL symbols are not visible outside their object file and are not considered during global resolution. They still appear in the symbol table, which is why a static function is present in the output binary and visible to a debugger, but they cannot satisfy another object’s undefined reference.
STB_GLOBAL is the ordinary case: one definition, visible to all. STB_WEAK is the interesting one, and it comes in two forms. A weak definition is a definition that may be overridden by a strong definition elsewhere. A weak undefined reference is a reference that does not require a definition: if none exists, the symbol resolves to zero rather than causing a link error.
Weak definitions exist for optional functionality. A library can provide a default implementation of a callback as a weak symbol, and an application can supply a strong definition that replaces it without the library needing to know. The pattern is common in freestanding environments and in libraries that want a hook without a registration call.
The danger is the resolution order. When the linker processes a set of objects and archives, a strong definition anywhere overrides a weak one, and among weak definitions the first encountered wins. “First encountered” depends on link order, and link order is a property of the build system rather than of the program. A weak definition in an object file listed early can shadow a weak definition in a library listed later, and the resulting behaviour changes when the link line is reordered. The rule is deterministic, but it is not obvious from the source.
/* weak_default.c */
__attribute__((weak)) int platform_init(void)
{
return 0; /* no-op fallback */
}
The attribute is a compiler extension, not standard C, and the equivalent spelling differs: __declspec(selectany) on MSVC for data, and #pragma weak on some systems. The standard has no weak symbol at all. A portable interface provides the hook as a function pointer that the application sets, with the weak symbol as an optimisation that avoids the indirection when no override exists.
Archives and the one-pass resolution
A static archive is a collection of object files with an index of the symbols each one defines. The linker’s treatment of archives is where most “undefined reference” errors originate.
When the linker walks the link line left to right, it processes each archive by pulling in only those members that satisfy an undefined symbol that exists at that moment. A member pulled in may introduce new undefined symbols, which may pull in further members of the same archive, but only while the archive is being processed. Once the linker moves past an archive, it does not go back.
The consequence is that an archive must appear after the objects and libraries that reference it. Writing -lfoo main.o fails where main.o -lfoo succeeds, because when -lfoo was processed, no undefined symbols from main.o existed yet. Circular dependencies between archives require either repeating an archive on the line or using a group option that re-scans until no new symbols are resolved. The one-pass rule is a deliberate design choice: it keeps the linker linear in the size of the inputs and avoids pulling in members that are not needed, which is the whole point of an archive over a set of object files.
A shared library is handled differently. It is loaded as a unit, and its dynamic symbol table is consulted at load time for every undefined symbol in the program. Members are not selectively pulled in; the whole library is mapped, and its exported symbols become available. That is why a missing symbol in a shared library surfaces at load or at first call rather than at link time, and why the linker can accept a reference to a symbol that the library does not actually define when lazy binding is in effect.
Visibility: four levels and what each hides
ELF visibility controls whether a symbol is exported from a shared object. It is a separate axis from binding: a STB_GLOBAL symbol can have default visibility, meaning it is exported from the object and may be preempted by another definition loaded earlier; protected visibility, meaning it is exported but references inside the defining object always bind to that definition; or hidden or internal visibility, meaning it is not exported at all.
__attribute__((visibility("hidden"))) makes a symbol non-exported but still globally visible within the link unit, so the linker can resolve internal references and, on some targets, avoid generating an indirection through the global offset table. This is the mechanism behind -fvisibility=hidden, which makes hidden the default for all symbols and requires explicit annotation for the ones that should be exported.
The benefit is not only namespace hygiene. A symbol with default visibility in a shared library is subject to symbol interposition: a definition of the same name in the executable, or in a library earlier in the search order, will be used instead. Every access to such a symbol must go through the global offset table, because the address is not known until load time. A hidden symbol’s address is fixed at link time, so accesses can be direct. The article on position-independent code and the global offset table follows the indirection through to the instruction sequence.
Hidden visibility also makes a library’s internal symbols unavailable for interposition, which is what a library author usually wants and what a debugger or an LD_PRELOAD-based interposer usually does not. The two goals are in direct conflict, and the resolution is to export a documented interface and hide everything else.
Where the linker is not the last word
The static linker resolves symbols whose definitions it can see. The dynamic linker resolves the rest, and it searches in a defined order: the global scope first, which holds the executable and the shared objects it depends on in the order the loader maps them, and then the local scope of the object making the reference, which holds that object’s own dependencies. A symbol defined in the executable is found before any library’s definition, which is why a definition in the main program can override a library’s, and why the same program behaves differently when the symbol is moved into a library.
Two failure modes are worth naming. A definition with the wrong type can satisfy a reference without any error: an int foo; and a void foo(void) share a name, and the linker does not compare types beyond what the symbol table records. The mismatch produces a call to a data address or a read of a function’s first bytes, and the calling convention that then applies is described in function call ABI register assignment. And a reference resolved by lazy binding fails only when the call executes, so a program can start, run for a while, and then abort on a path that was never exercised. Setting the binding to immediate at load time moves the failure to startup, where it is easier to attribute. The trade is startup latency against late failure, and the choice is per-program rather than per-symbol.
References
- ELF Specification — System V ABI — System V ABI committee
- GNU ld Manual — GNU Project
- ld.so(8) — Linux man-pages — Linux man-pages project
- System V Application Binary Interface, AMD64 Architecture Processor Supplement — x86-64 psABI committee