Symbols and References
Defined, undefined, global, local, weak — five categories that decide every link outcome. And how to actually read `undefined reference to 'foo'`, which has four common causes and names none of them.
What do the letters in nm output mean, and why does undefined reference appear for a function I definitely wrote?
The program as a bipartite matching problem: a set of undefined references, a set of definitions, and a rule for pairing them. Each symbol is a name plus a binding (global, local, weak), a visibility, a section and an offset. That representation exists because the compiler could not resolve names across translation units, so it deferred the whole name-resolution problem to a phase that can see all of them at once.
A reference may bind to a definition only if the definition is visible at link time — global or weak binding, not local, and not hidden by visibility — and if exactly one strong definition exists for that name across all included objects. Two strong definitions is an error; a strong plus any number of weak ones resolves to the strong; weak ones alone resolve to the first encountered. A local symbol may satisfy only references within its own object file. The rule the linker cannot enforce, and which the language nevertheless requires, is that all definitions of an entity with external linkage be identical — the one-definition rule — because the linker compares names and cannot compare bodies.
Key points
- Five categories cover every case: defined global, defined local, undefined, weak definition, weak reference.
nm's letters encode both binding and section — uppercase global, lowercase local,Uundefined,Wweak.undefined referencehas four common causes — missing definition, mangling mismatch, link order, hidden visibility — and the message distinguishes none of them.- Weak definitions are how inline functions and template instantiations end up as one copy; the linker keeps one and discards the rest without comparing them.
- The one-definition rule is a language requirement the linker cannot enforce, so violating it links cleanly and produces a program that reads objects at the wrong offsets.
- A symbol is a name, a binding and an address. It carries no type, so nothing checks that a caller and a definition agree about anything.
Five categories decide everything
nm letter codes are a GNU/LLVM binutils convention over ELF and Mach-O. Weak symbols are an ELF and Mach-O concept; PE/COFF has no direct equivalent and achieves comparable effects with COMDAT sections and explicit export tables. __attribute__((weak)) is a GCC and Clang extension, not standard C or C++.Every symbol in an object file is either defined here or referenced from here, and every definition is global, local or weak. That is the whole vocabulary, and every linking outcome follows from it.
A defined global is the ordinary case: this file provides it and anyone may use it. A defined local — static in C, an anonymous namespace in C++ — provides it only to this file, and its name may collide freely with locals in other files. An undefined symbol is a promise the linker must keep from elsewhere. A weak definition yields to any strong one and is what inline functions, template instantiations and default implementations use. And a weak reference is the unusual one: it may go unresolved, binding to zero instead, which is how optional features are detected at runtime.
nm reports all of this in a single letter per symbol, and reading those letters is the fastest way to diagnose a link problem. Uppercase means global, lowercase means local, U means undefined, W and V mean weak, and the letter itself names the section — T for text, D for data, B for bss, R for read-only.
| Category | `nm` letter | Comes from | Link behavior |
|---|---|---|---|
| Defined globaltarget | T D B R | An ordinary non-static definition | Satisfies references anywhere; two of them for one name is an error |
| Defined localtarget | t d b r | static, or an anonymous namespace | Satisfies references only within this object; never collides across files |
| Undefined | U | A declaration used but not defined here | Must be satisfied by some other object or library, or the link fails |
| Weak definitiontarget | W V | Inline functions, template instantiations, __attribute__((weak)) | Yields to any strong definition; several are permitted and one is chosen |
| Weak referencetarget | w | __attribute__((weak)) on a declaration | May go unresolved and bind to zero, which the program is expected to test for |
| Commonimplementation | C | Historically, an uninitialised C global without extern | Merged with other commons of the same name — the legacy behavior that -fno-common turned off |
Reading `undefined reference to 'foo'`
This message says one thing: no included object provided a definition for that exact string. It does not say why, and there are four common reasons that require four different fixes. Working through them in order takes about a minute and beats guessing.
Declared but never defined. The commonest and the most boring: a header promised a function and no .cpp implements it, or the implementing file was never added to the build. nm on every object will show U everywhere and T nowhere.
A mangling mismatch. A C++ file calls a C function through a header with no extern "C" guard, so the reference is to _Z3fooi and the definition is foo. The tell is in the message itself: a mangled name in the error and a plain one in nm -g output on the library, or the reverse. See [[name-mangling]].
Link order. With traditional static archives, the linker takes a member out of libfoo.a only to satisfy a reference it has *already seen*. Put -lfoo before the object that needs it and the archive is scanned when nothing is pending, contributes nothing, and the later reference has no definition. [[symbol-resolution-order]] covers the rule.
Not exported. The definition exists but is local — static, an anonymous namespace, -fvisibility=hidden without an export annotation, or excluded by a version script. nm shows a lowercase letter, or nm -D on the shared library shows nothing at all while nm shows it plainly.
- Look at the shape of the name in the message: mangled means a C++ caller, plain means a C one.
nm -gC *.o | grep fooacross every object — you are looking for aTand finding onlyUs.- For a library:
nm -DC lib.so | grep fooshows what is actually exported, which is not the same as what is present. - Check the command line order: objects first, then archives, and repeat an archive or use
--start-groupif there is a cycle. - If everything looks right and it links but misbehaves, suspect the fifth cause — an ODR violation, which does not produce this message at all.
Weak symbols, and the rule the linker cannot check
Weak definitions exist because C++ requires several translation units to be able to contain the same inline function or the same template instantiation, and to end up with one copy in the program. The compiler emits each of them as a weak definition in its own COMDAT-style section, and the linker keeps one and discards the rest. This is invisible machinery that every C++ program depends on.
The rule it implements is the one-definition rule: an entity with external linkage may be defined many times across translation units *provided every definition is identical*. The linker cannot check that. It compares names, keeps whichever copy it saw first, and discards the others without looking at them.
So when the definitions are not identical — the same class compiled with a different -D flag, a different -std, a different packing pragma, or one build with assertions on and one off — the program links cleanly and runs one of the two bodies, chosen by link order, in all callers. Objects are constructed with one layout and read with another. This is the ODR violation, and it is the reason that "it works in debug and fails in release" is sometimes not an optimizer bug at all but two translation units disagreeing about a type.
The practical defences are all about making the definitions genuinely identical: build every translation unit with the same flags, do not let macros change class layouts, and prefer tools that detect it — the ODR checker in -flto, the sanitizer's detect_odr_violation, and gold's --detect-odr-violations.
// a.cpp compiled with -DFEATURE
struct Cfg { int a; int extra; int b; };
inline int get_b(Cfg* c) { return c->b; } // weak: reads offset 8// b.cpp compiled without -DFEATURE
struct Cfg { int a; int b; };
inline int get_b(Cfg* c) { return c->b; } // weak: reads offset 4
// The linker keeps ONE of these two bodies and uses it in both files.Merging weak definitions of an inline function is legal only under the one-definition rule: every translation unit's definition must be token-for-token identical and must mean the same thing in each. Under that condition, discarding all but one copy is unobservable and is exactly what C++ requires.
Whenever a macro, a compiler flag, a different -std, a packing pragma or a differently ordered include changes the definition between translation units. The linker still merges, because it compares names and not bodies, so the program silently uses one layout's accessor on the other layout's objects. No diagnostic is produced at any stage.
What a symbol is not
A symbol carries no type. Nothing in an object file records that foo takes an int and returns a double. A C program can declare void foo(void) in one file, define double foo(int) in another, link successfully, and call it with the wrong convention. C++ gets accidental protection from [[name-mangling]] — the parameter types are in the name, so a mismatch becomes an undefined reference — but that protection stops at extern "C" and at anything the mangling does not encode, such as a struct's layout.
A symbol also carries no ownership, no lifetime and no thread-safety information. It is a name and an address. Every richer contract in the program — that this pointer must be freed by this function, that this function may only be called after that one — exists in documentation and in nothing the toolchain can check.
This is the same observation [[abi]] makes from the other side, and it explains the division of labour: the linker checks that every name has exactly one definition, and nothing anywhere checks that the definition is the one the caller had in mind.
How it works
The steps, in the order the compiler takes them.
- Each object file lists the symbols it defines, with a binding and a section and offset, and the symbols it references but does not define.
- The linker builds a global table, adding definitions as objects are included and recording references that are not yet satisfied.
- When an archive is encountered, it is scanned for members defining any currently-unsatisfied reference; those members are pulled in, which may add new unsatisfied references and require rescanning.
- A strong definition replaces any weak one for the same name; two strong definitions are a hard error; several weak ones resolve to one chosen copy and the rest are discarded.
- Local symbols are never entered into the global table and satisfy only references from their own object.
- Any reference still unsatisfied at the end is an error, unless it is a weak reference, which is resolved to zero.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A link fails with an undefined reference to a function that is plainly defined, because the archive containing it appeared before the object that needed it on the command line.
- A link fails with a mangled symbol name that appears nowhere in the source, because a C header was included from C++ without
extern "C"guards. - A link fails with
multiple definitionbecause a variable was defined rather than declaredexternin a header, so every including translation unit contributed one. - The program links and behaves differently depending on link order, because two libraries define the same symbol and the first one encountered wins silently.
- A class is compiled with different macros in two translation units, the linker merges their inline accessors, and objects are read at the wrong offsets with no diagnostic from any stage of the build.
- A symbol exists in a shared library and cannot be used, because it was compiled with hidden visibility —
nmshows it andnm -Ddoes not. - A weak reference intended as a feature check binds to zero on one platform and to a real definition on another, and the feature-detection branch takes the wrong path.
When it helps
- Every link failure. The category of the symbol on each side of the mismatch is the diagnosis, and two
nminvocations produce it. - Understanding why a C++ program with heavy template use links slowly: every instantiation is a weak definition in every translation unit that used it, and the linker discards most of them.
- Designing a library boundary: knowing that hidden visibility removes a symbol from the dynamic table is what makes
-fvisibility=hiddena deliberate choice rather than a magic flag.
When it hurts
- Using weak symbols as a design mechanism in application code. It works and it makes which implementation you get depend on link order, which is a property no source file states.
- Relying on the linker to catch interface mismatches. It matches names. Everything about types, layouts and conventions passes through it untouched.
What it costs
Every one of these is paid by something.
- Weak definitions buy the ability to have inline functions and templates in headers at all, and cost the one-definition rule becoming an unchecked obligation whose violation is silent.
- A flat global symbol namespace buys simple resolution and interposition, and costs collisions between unrelated libraries and the possibility that the first definition encountered silently wins.
- Hidden visibility by default buys a small dynamic symbol table, faster loading, more inlining within the library and no accidental interposition; it costs an explicit annotation on everything intended to be public and a class of "it is right there and I cannot link to it" confusion.
- Untyped symbols buy a linker that works for every language at once, and cost every form of interface checking, which each language must then reconstruct through its own naming scheme.
What else you could do
What a different compiler or language does instead, and when that is better.
- A two-level namespace, as Mach-O uses: every undefined reference records which library it expects, so collisions between libraries cannot silently resolve to the wrong one. It costs the interposition that
LD_PRELOADprovides. - Explicit export lists — PE's export table, an ELF version script, a Windows
.deffile — where nothing is exported unless named. Verbose and unambiguous. - Typed symbol tables, as JVM class files and .NET assemblies use: the descriptor sits beside the name, so a signature mismatch is a load-time error rather than a runtime corruption.
- Module systems that resolve by structured identity rather than by flat name, as WebAssembly component imports and Rust crate resolution do, which removes the collision problem instead of managing it.
See it for yourself
The flag, dump or tool that shows you this directly.
- What a file defines and needs:
nm -gC file.o, reading the letter before each name.nm --defined-onlyandnm -usplit the two halves. - What a shared library actually exports:
nm -DC lib.so, orreadelf --dyn-syms lib.so. This is the list that matters and it is not whatnmalone shows. - Which definition won:
-Wl,-y,foomakes the linker report every file that references or definesfoo, which settles link-order arguments immediately. - ODR violations: build with
-fltoand read the warnings, run withASAN_OPTIONS=detect_odr_violation=2, or link with gold's--detect-odr-violations. - On Apple platforms
nm -mshows the full binding and visibility information; on Windowsdumpbin /symbolsanddumpbin /exportsare the equivalents.
Plausible wrong readings
Stated the way a confident engineer states them.
- "Undefined reference means a missing library." It means no definition for that exact string was found among the included objects. A missing library is one of at least four causes.
- "
staticmakes a function faster."staticgives it internal linkage, which keeps it out of the global symbol table and enables more aggressive inlining — the speed is a consequence of the visibility, not of the keyword. - "If two definitions disagree, the linker will tell me." Only if both are strong. Weak definitions — every inline function and template instantiation — are merged silently without comparison.
- "A symbol in the library means I can call it." Only if it is in the *dynamic* symbol table. Hidden visibility leaves it visible to
nmand unusable from outside.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Each compiled file lists the names it provides and the names it needs. The linker matches them up. A name can be global (anyone may use it), local (only this file), or weak (yields to a real definition). When a name is needed and nothing provides it, you get an undefined reference — which usually means the file defining it was not in the build, or its name is spelled differently than the caller expected.
practical
For any link error, run nm -gC on the objects and nm -DC on the libraries and compare the exact strings. Check that objects come before archives on the command line. If the name in the error is mangled and the library defines a plain one, you need extern "C" guards. And if it links but behaves oddly across translation units, stop looking at the linker and start checking whether every file was compiled with the same flags — the ODR is the one rule here that fails silently.
advanced
The structural point is that the linker enforces a *cardinality* constraint on names and nothing else, and that C++ then builds a language feature on top of the deliberate weakening of that constraint. Inline functions and templates require many definitions of one entity, so the format grew weak binding and COMDAT merging, and the correctness obligation — that all those definitions be identical — moved from the toolchain to the programmer. That transfer is the origin of the ODR violation as a bug class, and it is worth recognising as a general pattern: whenever a checking mechanism is relaxed to permit a feature, the check does not disappear, it becomes an unchecked obligation. The tooling response has been to reconstruct the check elsewhere — LTO compares definitions because it has them in IR form, the sanitizers compare at load time, and modules propose to make the problem structurally impossible by not textually re-including declarations at all.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
nm letter codes and default-export visibility are ELF and Mach-O concepts. PE/COFF has no weak binding as such and uses COMDAT sections plus an explicit export table, and exports nothing by default — the opposite of ELF. A visibility strategy learned on one platform is a different strategy on the other.nm letter C) were the historical C behavior for tentative definitions and allowed a variable defined in two files to be silently merged. GCC changed the default to -fno-common in version 10, turning that into a multiple definition error, which broke a number of long-standing codebases that had been relying on the old behavior without knowing it.If you were asked this in an interview
- What are the possible causes of
undefined reference to 'foo'whenfoois defined in a file that was compiled? - Why does C++ need weak symbols, and what obligation does that transfer to the programmer?
- What is the difference between
nmandnm -Don a shared library, and when does it matter?
Connections
- Testing & Reliability Engineering — Detecting configuration divergence across a buildAn ODR violation is a build-configuration bug that no compilation catches. Catching it requires comparing artifacts across translation units — an infrastructure and testing problem as much as a compiler one, and the general technique of cross-artifact consistency checking is owned there.