The World of Linkers/ Theory/ 17 articles
34 min readPublic

[The World of Linkers—Theory 03] One Name, Several Definitions: Who Wins?

A declaration says that a definition exists somewhere. It does not say where, whether another definition competes with it, or whether the linker will ever open the file containing it. That gap is where symbol resolution begins.

In the previous chapter, main.o needed add, and add.o supplied it. Here we complicate that seemingly simple arrangement until it explains two familiar diagnostics: undefined reference and multiple definition.

The recorded experiments use native x86-64 Linux, Clang1 21.1.8, GNU2 ld 2.46, and LLD3 21.1.8. Inspection uses GNU binutils4, with llvm-objdump for LLVM5 disassembly. Commands using ld -e main produce files for inspection: setting the entry address does not provide C startup code or a caller to receive main's return. Executed examples instead use native musl-gcc6 with the appropriate runtime.

A table of promises and definitions

LOCAL symbols belong to one input file. Two files may each contain a static helper without competing for a global name. GLOBAL and WEAK symbols enter a global table, usually indexed by a hash of the name. A table entry can record a definition, an unresolved reference, or a definition available from a library but not yet loaded.

Start with an ordinary call:

// main.c
int add(int a, int b);
int main(void) { return add(1, 2); }
// add.c
int add(int a, int b) { return a + b; }
clang -O1 -c main.c add.c
ld -e main main.o -o missing-add

Reading main.o creates an unresolved add; reading add.o would resolve it. Without that second input:

ld: main.o: in function `main':
main.c:(.text+0xb): undefined reference to `add'

At -O1, the call has become a tail jump. The relocation field begins at .text+0xb; this is a byte offset, not a source line. The linker has reached a relocation that needs an address it cannot supply.

Strong definitions, weak alternatives

Two ordinary definitions compete without any rule for choosing the intended one:

$ ld -e main a.o b.o
ld: b.o:(.data+0x0): multiple definition of `counter'; a.o:(.data+0x0): first defined here
$ ld.lld -e main a.o b.o
ld.lld: error: duplicate symbol: counter
>>> defined at a.c
>>> a.o:(counter)
>>> defined at b.c
>>> b.o:(.data+0x0)

The ELF7 gABI8 prohibits multiple definitions with the same GLOBAL name. A WEAK definition supplies an explicit alternative: an ordinary GLOBAL definition takes precedence. nm9 marks weak objects V, weak functions W, and ordinary initialized objects D:

$ nm w.o a.o
w.o:
0000000000000000 V counter
a.o:
0000000000000000 D counter
0000000000000000 T main

Here w.o contains __attribute__((weak)) int counter = 3;, while a.o defines counter = 1. Putting the weak definition first does not change the winner:

$ ld -e main w.o a.o -o s2
$ nm s2 | grep counter
0000000000403004 D counter
$ llvm-objdump -s -j .data s2 | tail -1
403000 03000000 01000000 ........

Notice what remains in .data: both 3 and 1. Resolution changes what the name denotes. It does not automatically delete the losing definition's bytes or its entire section.

For two weak definitions, the gABI leaves the choice unspecified. These versions of GNU ld and LLD retain the first:

$ ld -e main m.o w.o w2.o -o s3 && llvm-objdump -s -j .data s3 | tail -1
403000 03000000 04000000 ........
$ ld -e main m.o w2.o w.o -o s4 && llvm-objdump -s -j .data s4 | tail -1
403000 04000000 03000000 ........

That makes weak definitions useful for replaceable defaults, but unsuitable for expressing a required choice between two competing implementations.

A weak reference makes another promise: absence is acceptable.

extern void hook(void) __attribute__((weak));
int main(void) { if (hook) hook(); return 0; }

An unresolved weak symbol has value zero. The generated code checks a GOT10 entry before attempting the call:

$ ld -e main u.o -o s6
$ llvm-objdump -d --no-show-raw-insn s6
0000000000401000 <main>:
401000: cmpq $0x0, 0x2fd8(%rip) # 0x403fe0
401008: je 0x401014 <main+0x14>
40100a: pushq %rax
40100b: callq 0x0
...
$ llvm-objdump -s -j .got s6
403fe0 00000000 00000000 ........

The zero-target call really exists, but the preceding condition prevents its execution. Position-independent code uses the GOT because the compiler must accommodate both a real definition and the absolute value zero. With -fno-pic, this example instead uses a direct address relocation.

Weakness is merged for the name as a whole. Let wk.o contain a weak hook reference and its relocation, and sk.o contain .globl hook without a relocation. The ordinary undefined declaration makes the merged requirement strong:

$ ld -e main wk.o sk.o
ld: wk.o: in function `main':
(.text+0x3): undefined reference to `hook'
$ ld.lld -e main wk.o sk.o
ld.lld: error: undefined symbol: hook
>>> referenced by wk.o:(.text+0x3)

Do not extend this static-link rule to every runtime lookup. The glibc11 dynamic linker normally uses the first definition in its lookup scope without searching past a weak definition for a strong one. Its older behavior is described under LD_DYNAMIC_WEAK in ld.so(8).

Common storage is a different mechanism

With -fcommon, separate files containing int x; can contribute to one common allocation. This historical extension borrowed the storage-merging idea used for FORTRAN COMMON blocks. In ELF, SHN_COMMON means that storage has not yet been assigned; st_size gives the required size and st_value the alignment.

$ nm c1.o c2.o
c1.o:
0000000000000000 T main
0000000000000004 C x
c2.o:
0000000000000004 C x
$ readelf -sW c3.o # c3.c contains long x;
Num: Value Size Type Bind Vis Ndx Name
2: 0000000000000008 8 OBJECT GLOBAL DEFAULT COM x

The first column from nm happens to match alignment here, but GNU nm displays the size of a common symbol. A larger array exposes the distinction:

$ nm cb.o
0000000000000064 C buf
$ readelf -sW cb.o | grep buf
2: 0000000000000010 100 OBJECT GLOBAL DEFAULT COM buf

The linker takes the largest size and strictest alignment, then allocates zero-initialized storage in .bss:

$ ld -e main c1.o c2.o -o a && nm -S a | grep ' x$'
0000000000403000 0000000000000004 B x
$ ld -e main c1.o c3.o -o b && nm -S b | grep ' x$'
0000000000403000 0000000000000008 B x

One file may believe x is an int and another a long; neither that merge nor successful linking establishes C type compatibility. GNU ld can flag the size disagreement:

ld: c3.o: warning: common of `x' overriding smaller common from c1.o

An ordinary strong definition such as int x = 5; overrides common storage. A common symbol in turn overrides a weak definition. Modern defaults make the historical case less common: GCC12 10 and Clang 11 switched to -fno-common, under which int x; becomes a real .bss definition:

$ nm n1.o n2.o # same source, compiled with -fno-common
n1.o:
0000000000000000 T main
0000000000000000 B x
n2.o:
0000000000000000 B x
$ ld -e main n1.o n2.o
ld: n2.o:(.bss+0x0): multiple definition of `x'; n1.o:(.bss+0x0): first defined here

C tentative definitions apply within a translation unit. Multiple external definitions across translation units violate the language requirements; common merging is a documented extension, not a general guarantee of valid C. Use an extern declaration in a shared header and one actual definition.

Predict these results before reading the table. Module A also contains a main that reads n.

ABWith -fcommonWith -fno-common
int n=1; void f(void){}void f(void){}Duplicate fDuplicate f
int n;int n;One common allocationDuplicate n
int n=1;int n;A, value 1Duplicate n
int n=1;weak int n=2;AA
int n;weak int n=2;A, value 0A, value 0

Reversing the last pair still makes common storage prevail:

$ ld -e main p5b.o p5a.o -o g5 && nm g5 | grep ' n$'
0000000000403004 B n
$ ld.lld -e main p5b.o p5a.o -o l5 && nm l5 | grep ' n$'
000000000020219c B n

Some textbook presentations call uninitialized common definitions “weak.” That teaching vocabulary is not ELF's STB_WEAK. In particular, it cannot predict the last row. Read the historical CS

rules with that distinction in mind; its errata also discuss changed compiler defaults.

Matching names does not check C types

Recall the opening chapter's program: one file defines int objects x and y; another declares extern double x and stores 1.0. The incompatible declarations make this UB13. The following explanation concerns the displayed build, rather than a result guaranteed by C. The reference contains no record saying “this must be a double”:

$ readelf -sW main.o | grep -E ' [xy]$'
6: 0000000000000004 4 OBJECT GLOBAL DEFAULT 2 y
7: 0000000000000000 4 OBJECT GLOBAL DEFAULT 2 x
$ readelf -sW other.o | grep ' x$'
5: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND x

Its assumption has already become an eight-byte store:

$ llvm-objdump -dr other.o
0000000000000000 <set_x>:
0: 48 8b 05 00 00 00 00 movq (%rip), %rax
0000000000000003: R_X86_64_PC32 .LC0-0x4
7: 48 89 05 00 00 00 00 movq %rax, (%rip)
000000000000000a: R_X86_64_PC32 x-0x4
e: c3 retq

Two separate decisions matter: the relocation determines where the instruction writes, while the instruction determines how many bytes it writes.

The R_X86_64_PC32 field at offset 0xa receives the displacement S + A - P, where S is the final address of x, A = -4, and P is the final address of the relocation field. The field occupies four bytes. Adding the displacement to the following RIP gives (P + 4) + (S - 4 - P) = S; the field does not contain the absolute address of x.

The definition's st_size is four, whereas the undefined reference's size is zero. These records do not encode the C types needed to compare the declarations. Ordinary symbol resolution and this relocation calculation do not recover C types from instructions. Linker relaxation may inspect and rewrite instructions, but that is not C type checking.

In the displayed .data layout, x starts at offset zero and y at offset four. If x has final address X, y is at X + 4. The IEEE 754 binary64 encoding of 1.0 is 0x3ff0000000000000, whose little-endian bytes are 00 00 00 00 00 00 f0 3f. The eight-byte store writes zero into x and 0x3ff00000, or 1072693248, into the adjacent y. This accounts for the observed result in that build. Different layouts or optimizations can change the manifestation of UB.

Even a function and an object can be confused:

// fmain.c
#include <stdio.h>
extern int answer; /* incorrectly declared as an object */
int main(void) {
printf("answer = %#x\n", answer);
return 0;
}
// fanswer.c
int answer(void) { return 42; }

Built with musl-gcc -O1 -Wall -static fmain.c fanswer.c -o fm, this prints answer = 0xfa1e0ff3 on the recorded machine. Those are the first four instruction bytes, f3 0f 1e fa, interpreted as an integer: GCC emitted CET's endbr64 before mov $42,%eax. The program reads code, not the function's return value.

Likewise, defining char msg[]="hi" while declaring extern char *msg elsewhere makes the user read the string bytes as a pointer. The recorded pointer was 0x6968; passing it to puts faulted. These are explanations of particular artifacts, not portable outcomes.

The reliable first defense is a shared header included by both users and the defining file. LTO14 can also expose cross-file type information to the compiler:

$ musl-gcc -O2 -flto -static main.lto.o other.lto.o -o prog_lto
other.c:1:15: warning: type of 'x' does not match original declaration [-Wlto-type-mismatch]
1 | extern double x;
| ^
main.c:4:5: note: type 'int' should match type 'double'

GCC's -Wlto-type-mismatch is enabled by default with LTO, but this is a warning, not a repair. In that optimized build the output changed to x = 0, y = 5: the invalid store remained, while the compiler folded y to a constant. C++'s corresponding -Wodr can diagnose some inconsistent class definitions. Neither mechanism makes ordinary ELF symbol resolution a language type checker.

Archives delay the decision

The archive is a container; its members are objects

An ordinary System V/GNU ar file begins with eight bytes, !<arch>\n, followed by repeated member headers and payloads. Each header occupies 60 bytes, with numeric fields encoded as ASCII; its decimal size field gives the following payload length. An odd-length payload is followed by one padding byte so the next header begins at an even file offset. GNU ar emits a newline for this padding. The byte is not included in size or in the payload.

Ordinary ar archive layout and the fields of every 60-byte member header

If a member header begins at file offset h, its payload occupies [h+60, h+60+size). The next header begins at h+60+size+(size%2). An ELF parser receives the payload slice, excluding archive magic, member headers, and padding. Symbol indexes and long-name tables are special members, not ordinary object files.

Names are byte strings and need not be UTF-8. GNU archives store long names in a // member and refer to them using /decimal-offset in ordinary member headers. That offset is relative to the long-name table’s payload; entries end in /\n. A short name commonly appears as name.o/ with space padding. The / index member associates symbols with member locations. It accelerates discovery without making the archive one large ELF file or selecting every member.

Thus /17 identifies a name at offset 17 within the long-name table, not archive file offset 17 or member number 17. Members may have duplicate names, so position identifies a member. Ordinary archives embed payloads; thin archives can reference external files and require a different reading contract.

Extraction follows symbol requirements

Create a fresh directory for this independent example: main calls add, which calls scale; unused_helper has no caller.

// main.c
int add(int, int);
int main(void) { return add(1, 2); }
// add.c
int scale(int);
int add(int a, int b) { return scale(a + b); }
// other.c
int unused_helper(void) { return 99; }
// scale.c
int scale(int x) { return x * 2; }
clang -O1 -fno-pic \
-fno-asynchronous-unwind-tables -c main.c add.c other.c scale.c
ar rcs libscale.a scale.o

An archive built with ar15 contains object-file members and, normally, an index of their defined symbols:

$ ar rcs libmath.a add.o other.o
$ ar t libmath.a
add.o
other.o
$ nm -s libmath.a | head -4
Archive index:
add in add.o
unused_helper in other.o

An ordinary object is admitted unconditionally. An archive member is extracted when it satisfies an unresolved requirement, after which all its definitions and references participate. Extracting one member can create a need for another. GNU ld uses the archive index; if it is missing, ranlib can generate it.

GNU ld scans the command line from left to right, repeatedly extracting from the current archive until that archive cannot satisfy another current requirement. It then moves on. An archive before its users is too early:

$ ld -e main libmath.a libscale.a main.o
ld: main.o: in function `main':
main.c:(.text+0xb): undefined reference to `add'

An archive providing a dependency can also be too early:

$ ld -e main main.o libscale.a libmath.a
ld: libmath.a(add.o): in function `add':
add.c:(.text+0x3): undefined reference to `scale'

main.o libmath.a libscale.a works. Cycles make a single left-to-right ordering insufficient. Build this one:

// cm.c
int log_value(int);
int main(void) { return log_value(42); }
// x1.c
int fmt(int);
int log_value(int x) { return fmt(x); }
// x2.c
int fmt_impl(int x) { return x; }
// y.c
int fmt_impl(int);
int fmt(int x) { return fmt_impl(x); }
clang -O1 -fno-pic \
-fno-asynchronous-unwind-tables -c cm.c x1.c x2.c y.c
ar rcs libx.a x1.o x2.o
ar rcs liby.a y.o

log_value brings in fmt, which needs an as-yet-unused member of the archive already visited:

$ ld -e main cm.o libx.a liby.a
ld: liby.a(y.o): in function `fmt':
y.c:(.text+0x1): undefined reference to `fmt_impl'

Either repeat libx.a, or group the libraries: cm.o --start-group libx.a liby.a --end-group. A group is rescanned to a fixed point; use it for actual cycles rather than hiding every library in one expensive group.

LLD remembers archive definitions as lazy symbols. A later reference can extract a member from an earlier archive, so these particular backward-reference failures disappear. This does not make arbitrary reordering harmless when multiple libraries offer competing definitions. --warn-backrefs identifies links that depend on the difference:

$ ld.lld -e main cm.o libx.a liby.a --warn-backrefs
ld.lld: warning: backward reference detected: fmt_impl in liby.a(y.o) refers to libx.a(x2.o)

Weak requirements add another boundary. Prepare these inputs:

// u.c
extern void hook(void) __attribute__((weak));
int main(void) { if (hook) hook(); return 0; }
// hook.c
void hook(void) {}
// m.c
extern int counter;
int main(void) { return counter; }
// w.c
__attribute__((weak)) int counter = 3;
// strong.c
int counter = 9;
clang -O1 -fno-pic \
-fno-asynchronous-unwind-tables -fno-builtin -c u.c hook.c m.c w.c strong.c
ar rcs libhook.a hook.o
ar rcs libcnt.a strong.o
ar rcs libweak.a w.o

An unresolved weak hook does not extract hook.o by itself. Nor does a previously supplied weak definition make the linker search for a stronger alternative in an archive:

$ ld -e main m.o w.o libcnt.a -o k1
$ llvm-objdump -s -j .data k1 | tail -1
402000 03000000 ....

The value remains 3 because the member defining 9 never enters the link. Conversely, an archive's weak definition can satisfy an unresolved strong requirement. Common symbols are a further GNU/LLD difference: GNU ld may extract an initialized definition to replace common storage; LLD requires --fortran-common for that behavior.

Extraction operates on members, not individual functions. This makes partial replacements surprising:

// app.c
#include <stddef.h>
void *malloc(size_t size) { static unsigned char buf[16]; (void)size; return buf; }
void free(void *);
int main(void) {
#ifdef CALL_FREE
free(malloc(1));
#else
(void)malloc(1);
#endif
return 0;
}
// mem.c
#include <stddef.h>
void *malloc(size_t size) { (void)size; return 0; }
void free(void *p) { (void)p; }
clang -O1 -fno-pic -fno-asynchronous-unwind-tables -fno-builtin -c mem.c
clang -O1 -fno-pic \
-fno-asynchronous-unwind-tables -fno-builtin -c app.c -DCALL_FREE -o app.o
clang -O1 -fno-pic \
-fno-asynchronous-unwind-tables -fno-builtin -c app.c -o app-only.o
ar rcs libmem.a mem.o
ld -e main app-only.o libmem.a -o malloc-only

-fno-builtin preserves the deliberately replaced allocator calls. Without free, the archive member stays out. Once free is needed, its member brings another strong malloc with it:

$ ld -e main app.o libmem.a
ld: libmem.a(mem.o): in function `malloc':
mem.c:(.text+0x0): multiple definition of `malloc'; app.o:app.c:(.text+0x0): first defined here

This particular archive requires coordinated replacement of its member's functions. A real library may use weak definitions or a different member organization. If your replacement wins, other admitted code's references to the same global name also bind to it.

Ask the linker why it extracted a member rather than guessing:

$ ld -e main main.o libmath.a libscale.a -Map=-
Archive member included to satisfy reference by file (symbol)
libmath.a(add.o) main.o (add)
libscale.a(scale.o) libmath.a(add.o) (scale)
...
$ ld.lld -e main main.o libscale.a libmath.a --why-extract=-
reference extracted symbol
libmath.a(add.o) libscale.a(scale.o) scale
main.o libmath.a(add.o) add

Shared libraries and visibility

A .so contributes definitions from its dynamic symbol table, .dynsym, without copying their code into the output. The output records a dependency through DT_NEEDED; its runtime address remains to be resolved. An ordinary object-file definition takes precedence over a shared-library definition, regardless of their relative order:

$ ld -e main main.o libadd.so add.o -o e1
$ nm e1 | grep add
0000000000401010 T add
$ readelf -dW e1 | grep NEEDED
0x0000000000000001 (NEEDED) Shared library: [libadd.so]

The unused shared dependency remains in this link unless a policy such as --as-needed removes it.

Binding alone cannot express “visible to every object forming this library, but private to the library.” Visibility supplies that boundary:

VisibilityOutside the output moduleReferences within the module
defaultExportableMay be preempted
hiddenNot exportedBind locally
protectedExportableBind to the module's own definition

internal is also defined, with processor-specific details; general tools commonly treat it like hidden. Try:

__attribute__((visibility("hidden"))) int internal_counter = 1;
__attribute__((visibility("protected"))) int api_level = 2;
int bump(void) { return ++internal_counter + api_level; }
$ readelf -sW vis.o
Num: Value Size Type Bind Vis Ndx Name
3: 0000000000000000 21 FUNC GLOBAL DEFAULT 2 bump
4: 0000000000000000 4 OBJECT GLOBAL HIDDEN 4 internal_counter
5: 0000000000000004 4 OBJECT GLOBAL PROTECTED 4 api_level

The hidden input symbol is still GLOBAL, so another object in the same link can use it. After constructing the library:

$ ld.lld -shared vis.o -o libvis.so
$ readelf --dyn-syms -W libvis.so
Num: Value Size Type Bind Vis Ndx Name
1: 00000000000012e0 21 FUNC GLOBAL DEFAULT 6 bump
2: 000000000000336c 4 OBJECT GLOBAL PROTECTED 9 api_level
$ readelf -sW libvis.so | grep internal
2: 0000000000003368 4 OBJECT LOCAL HIDDEN 9 internal_counter

Other modules cannot find that name through .dynsym:

$ ld.lld -e main useh.o libvis.so
ld.lld: error: undefined symbol: internal_counter
>>> referenced by useh.c
>>> useh.o:(main)

Visibility changes generated code as well:

# internal_counter is hidden; api_level is protected
0000000000000000 <bump>:
0: movl (%rip), %eax
0000000000000002: R_X86_64_PC32 internal_counter-0x4
...
e: addl (%rip), %eax
0000000000000010: R_X86_64_PC32 api_level-0x4
14: retq
# internal_counter is hidden; api_level is default
0000000000000000 <bump>:
0: movl (%rip), %eax
0000000000000002: R_X86_64_PC32 internal_counter-0x4
...
e: movq (%rip), %rcx
0000000000000011: R_X86_64_REX_GOTPCRELX api_level-0x4
15: addl (%rcx), %eax
17: retq

A default-visible variable may be replaced by another module's definition, so PIC16 commonly obtains its address through the GOT. Clang can use direct PC-relative access for these hidden and protected definitions. GCC 15 remains more conservative for protected data because copy relocations complicate the relationship between the executable's storage and the library's storage. Theory 07 develops that issue.

A missing table entry is not always an error

These assembly files isolate diagnostic boundaries:

# ug.s
.text
.globl main
main: ret
.globl ghost
# dbg.s
.text
.globl main
main: ret
.section .debug_info,"",@progbits
.quad dref
# hg.s
.text
.globl main
main: ret
.globl ghost
.hidden ghost
clang -c ug.s dbg.s hg.s

A GLOBAL UND entry with no relocation need not make a link fail:

$ readelf -sW ug.o | grep ghost
2: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND ghost
$ readelf -rW ug.o
There are no relocations in this file.
$ ld -e main ug.o -o g_ug; echo $?
0
$ ld.lld -e main ug.o -o l_ug; echo $?; nm l_ug
0
U ghost
0000000000201120 T main

GNU ld drops ghost; LLD retains the undefined table entry. The tools diverge further for a reference from a non-allocated debug section and for an unreferenced hidden undefined symbol:

$ ld -e main dbg.o
ld: dbg.o:(.debug_info+0x0): undefined reference to `dref'
$ ld.lld -e main dbg.o; echo $?
0
$ readelf -sW hg.o | grep ghost
2: 0000000000000000 0 NOTYPE GLOBAL HIDDEN UND ghost
$ ld -e main hg.o -o g_hg
ld: g_hg: hidden symbol `ghost' isn't defined
ld: final link failed: bad value
$ ld.lld -e main hg.o; echo $?
0

GNU ld rejects both; this LLD version accepts both. GNU's hidden-symbol check requires non-weak symbols with non-default visibility to have a local definition independently of relocation use. Resolution, relocation processing, and visibility validation are distinct checks.

COMDAT chooses a group, not a proof of equivalence

C++ inline functions and template instantiations may need an out-of-line body in several translation units:

// t1.cpp
inline int version() { return 1; }
int from_t1() { return version(); }
int main() { return from_t1(); }
$ nm t1.o
0000000000000000 T _Z7from_t1v
0000000000000000 W _Z7versionv
0000000000000010 T main
$ readelf -gW t1.o
COMDAT group section [ 4] `.group' [_Z7versionv] contains 1 sections:
[Index] Name
[ 5] .text._Z7versionv

Name mangling produces _Z7versionv: the Itanium C++ ABI17 encodes the name and parameter information into a string. The function's section belongs to an ELF COMDAT18 group. Its SHT_GROUP payload contains a flag followed by member section indices; sh_info identifies the symbol supplying the group signature.

GNU ld and LLD retain the first group with a given signature and discard later groups in their entirety. Code, constants, relocation sections, and exception data may have to survive or disappear together. A weak function symbol complements this mechanism but is not a substitute for group selection.

If two supposedly interchangeable groups have different contents, selection can lose a required definition:

$ ld -e main gm.o g2.o g1.o
ld: gm.o: in function `main':
(.text+0x6): undefined reference to `foo_extra'
$ ld.lld -e main gm.o g2.o g1.o
ld.lld: error: relocation refers to a symbol in a discarded section: foo_extra
>>> defined in g1.o
>>> section group signature: foo
>>> prevailing definition is in g2.o
...

Here the discarded foo group alone contained foo_extra. LLD identifies the discarded section and prevailing group; GNU ld presents the resulting unresolved reference.

The language's ODR19 requires the appropriate source-level consistency, including token sequences and name lookup, for permitted repeated definitions. It does not require identical machine bytes across optimization levels. Nor does COMDAT selection prove ODR compliance. Make the second version() return 2:

$ ld -e main t1.o t2.o -o o12
0000000000401030 <_Z7versionv>:
401034: movl $0x1, %eax
$ ld -e main t2.o t1.o -o o21
0000000000401010 <_Z7versionv>:
401014: movl $0x2, %eax

At -O0, input order selects the observed answer. Compile the second translation unit at -O1, and its caller may inline 2 while an out-of-line call elsewhere still returns 1. Successful linking has hidden the source inconsistency, not resolved it.

C inline rules differ. In C11, an inline definition without extern need not supply the required external definition:

$ nm ci.o
0000000000000000 T main
U twice
$ ld -e main ci.o
ld: ci.o: in function `main':
ci.c:(.text+0x15): undefined reference to `twice'

At -O0, the unresolved call survives. At -O1, inlining can make it disappear. GNU89's historical inline rules differ again. A small header-local C helper usually uses static inline, giving each translation unit its own LOCAL definition.

Definitions supplied by the linker

Some addresses can only be known after layout. Conventional linker-defined symbols expose them:

// syn.c
#include <stdio.h>
#include <elf.h>
extern char etext[], edata[], end[];
extern const Elf64_Ehdr __ehdr_start;
int counter = 1; /* .data */
int zeros[100]; /* .bss */
int main(void) {
printf("__ehdr_start = %p magic = %.3s\n",
(void *)&__ehdr_start, (const char *)__ehdr_start.e_ident + 1);
printf("etext = %p\n", (void *)etext);
printf("edata = %p\n", (void *)edata);
printf("end = %p\n", (void *)end);
return 0;
}
$ ./syn
__ehdr_start = 0x400000 magic = ELF
etext = 0x405947
edata = 0x408110
end = 0x408958
$ readelf -SW syn | grep -E "fini|data|bss"
[ 3] .fini PROGBITS 0000000000405944 005944 000003 00 AX 0 0 1
[ 4] .rodata PROGBITS 0000000000406000 006000 000cba 00 A 0 0 32
[ 7] .fini_array FINI_ARRAY 0000000000407fc8 006fc8 000008 08 WA 0 0 8
[ 8] .data.rel.ro PROGBITS 0000000000407fd0 006fd0 000010 00 WA 0 0 8
[11] .data PROGBITS 0000000000408000 007000 000110 00 WA 0 0 32
[12] .bss NOBITS 0000000000408120 007110 000838 00 WA 0 0 32
$ readelf -lW syn | grep LOAD
LOAD 0x000000 0x0000000000400000 0x0000000000400000 0x000190 0x000190 R 0x1000
LOAD 0x001000 0x0000000000401000 0x0000000000401000 0x004947 0x004947 R E 0x1000
LOAD 0x006000 0x0000000000406000 0x0000000000406000 0x000cf4 0x000cf4 R 0x1000
LOAD 0x006fc0 0x0000000000407fc0 0x0000000000407fc0 0x000150 0x000998 RW 0x1000

In this recorded image, __ehdr_start is 0x400000, the mapped ELF header. etext is the executable segment's end, 0x401000 + 0x4947. edata is .data's end, 0x408000 + 0x110. end includes .bss, reaching 0x407fc0 + 0x998.

GNU ld's default script expresses the convention with the location counter .:

PROVIDE (etext = .);
_edata = .;
PROVIDE (edata = .);
_end = .;
PROVIDE (end = .);

PROVIDE only supplies a referenced name that has no input definition. Defining your own int end=3 overrides that fallback; an unconditional script assignment such as _end=. behaves differently. LLD implements corresponding conventional definitions in code rather than a default script.

Related names include _GLOBAL_OFFSET_TABLE_, __start_NAME/__stop_NAME for suitably named sections, and _binary_NAME_start, _end, _size when importing raw binary data. An address-only symbol is commonly declared extern char end[]: array decay gives the address without reading a fictitious stored pointer. xv620 uses precisely this boundary to begin freeing physical pages after its kernel image.

Resolution as a state machine

LLD's symbol states—Undefined, Lazy, Shared, Common, Defined—turn these cases into merge operations. Strong undefined plus lazy triggers extraction; weak undefined does not. A real definition blocks unnecessary lazy extraction. Strong replaces weak; common replaces weak; ordinary strong replaces common or shared. Two commons combine size and alignment. Visibility constraints merge as well.

Compatibility introduces exceptions. This LLD 21.1.8 check accepts repeated absolute definitions with equal nonzero values:

// Allow absolute symbols with the same value for GNU ld compatibility.
if (!d->section && !errSec && errOffset && d->value == errOffset)
return;

The extra errOffset test explains a small difference from GNU ld:

$ ld -e main r.o abs_a5.o abs_b5.o; echo $? # both define lim = 5
0
$ ld.lld -e main r.o abs_a5.o abs_b5.o; echo $?
0
$ ld -e main r.o abs_a0.o abs_b0.o; echo $? # both define lim = 0
0
$ ld.lld -e main r.o abs_a0.o abs_b0.o
ld.lld: error: duplicate symbol: lim
>>> defined in abs_a0.o
>>> defined in abs_b0.o
$ ld -e main r.o abs_a5.o abs_c6.o # lim = 5 versus lim = 6
ld: abs_c6.o: in function `lim':
(*ABS*+0x6): multiple definition of `lim'

Both accept duplicate absolute value 5; only GNU accepts the tested duplicate zero. Both reject differing values. Such version-specific observations belong beside the rule, not inside a claim that every linker must behave identically.

Reading the diagnostics

For an undefined name, check omitted objects, archive order or cycles, visibility, discarded COMDAT groups, and the actual symbol spelling. C and C++ source names may look identical while their symbol names differ:

$ nm ver.o verc.o
ver.o:
0000000000000000 T _Z7versionv
verc.o:
0000000000000000 T version
$ ld -e main cmain.o ver.o
ld: cmain.o: in function `main':
cmain.c:(.text+0x10): undefined reference to `version'

An extern "C" declaration on the C++ interface supplies the matching linkage. For duplicates, look for definitions in headers, old common assumptions, or archive members bringing extra definitions.

One error can hide another:

$ ld -e main da.o db.o
ld: db.o:(.data+0x0): multiple definition of `counter'; da.o:(.data+0x0): first defined here
ld: da.o: in function `main':
da.c:(.text+0x2): undefined reference to `missing'
$ ld.lld -e main da.o db.o
ld.lld: error: duplicate symbol: counter
>>> defined at da.c
>>> da.o:(counter)
>>> defined at db.c
>>> db.o:(.data+0x0)

GNU ld reports both problems here. LLD stops after name-resolution errors, before its relocation scan would diagnose missing. Fixing the duplicate merely exposes the already-existing undefined reference. --allow-multiple-definition chooses the first definition; it suppresses the diagnostic without establishing that this is the intended program.

Use nm -A to inspect all candidates, then trace the decision:

$ ld -e main main.o libmath.a libscale.a -y scale
ld: libmath.a(add.o): reference to scale
ld: libscale.a(scale.o): definition of scale

When invoking a compiler driver, pass that option as -Wl,-y,scale. Trace output records events, not necessarily every ignored definition:

$ ld -e main mref.o s.o w.o -y counter
ld: mref.o: reference to counter
ld: s.o: definition of counter
$ ld.lld -e main mref.o s.o w.o -y counter
mref.o: reference to counter
s.o: definition of counter
$ ld.lld -e main mref.o cm.o -y counter
mref.o: reference to counter
cm.o: common definition of counter
cm.o: definition of counter

A losing weak definition may produce no trace line. LLD prints common storage once when encountered and again when converted into an allocated definition. GNU ld has its own differences for ignored common/weak entries. Combine traces with -Map or --why-extract; absence from a trace does not prove absence from a file.

--defsym=limit=real_limit can explicitly create an alias. --wrap=malloc redirects undefined malloc references to __wrap_malloc and undefined __real_malloc references to the original:

$ ld -e main wm.o wr.o --wrap=malloc -o wrp
0000000000401000 <main>:
401001: movl $0x10, %edi
401006: callq 0x401020 <__wrap_malloc>

The qualifier undefined matters. A call whose definition is already in the same object is not rewritten merely because its machine code retains a relocation.

From a definition to an address

Symbol resolution identifies the definition associated with a reference and determines how an absent definition may be treated. For an ordinary definition in an object file, that identity includes the input file, the section, and an offset within that section. A common symbol still describes storage to allocate, with a merged size and alignment. An absolute symbol already has a fixed value. A permitted unresolved weak reference is handled according to the applicable relocation rules; it does not designate a definition in an input section.

A selected definition need not have a final address. Consider an ordinary symbol in a static link whose offset within an input .text section is 8. If layout places that input section at output virtual address 0x401020, the symbol's address is 0x401028. The value 8 is relative to an input section, while 0x401020 belongs to the output address space. A byte offset within the file is a separate coordinate.

Definition categoryWhat resolution establishesWhen its value becomes known
Ordinary section definitionAn input section and a section-relative offset, such as 8Layout assigns that input section an output address; adding the offset gives the symbol address
Absolute symbol, SHN_ABSA fixed value, such as 0x42No section address is added; the value remains 0x42
Common symbol, SHN_COMMONMerged storage size and alignmentStorage allocation and layout determine the address of the allocated storage
Definition supplied by a shared objectA symbol available at link time and its dynamic-linking constraintsVisibility, binding rules, and relocation form determine whether runtime lookup is required

Resolution may also retain references that remain unsatisfied. Whether they require an error depends on whether their referring sections survive, the output kind, and the diagnostic rules discussed above. Garbage collection and other later processing still determine which input sections enter the output. Resolution supplies reference relationships, layout places the surviving contents, and relocation writes the required values at the reference sites.

Thus, “add is defined at offset 0 in add.o's .text” identifies a definition without yet determining the bytes of a jump instruction. Once output positions are known, the relocation type, addend, and patch location determine the value to write. The next chapter develops that calculation.

Exercises

The native comparison also records the expected failures.

  1. Inspect the opening chapter's main.o, other.o, and prog with nm -A, then relink with -Wl,--trace-symbol=x. Which file defines x, which references it, and does any diagnostic mention double?
  2. Statically link a program including the proper headers and returning puts(strdup(argv[0])) < 0. Trace strdup and malloc. Which musl21 archive member supplies malloc, and is its definition strong?
  3. Inspect syn. Which nm letters describe etext, edata, end, and __ehdr_start?
  4. Use REF(name.module) → DEF(name.module) to predict these -O0 links: (a) buf[4] and buf[8] in separate files, with/without -fcommon; (b) strong level=3 and weak level=1, each with a reader; (c) weak functions returning 1 and 2, with the second file first; (d) a hidden secret=7, once linked directly and once supplied through a .so; (e) inconsistent inline ver() functions returning 1 and 2, second file first; (f) a LOCAL static n=1 and an ordinary global n=2.
  5. Find the shortest GNU ld library sequence for this graph, a grouped alternative, the error from one p,q,r pass, and LLD's result:
main.o -> p1
libp.a: p1.o (p1 -> q1) p2.o (p2 -> q2)
libq.a: q1.o (q1 -> r1) q2.o (q2 has no further calls)
libr.a: r1.o (r1 -> p2)
  1. Identify the failing stage with GCC 15 via musl-gcc: (a) missing config.h; (b) undeclared twice(21); (c) inline assembly movq %eax,%rbx; (d) declared but undefined twice; (e) a linked libfoo.so absent from the runtime search path; (f) declaring the function answer as an integer and writing to it.

Answers

  1. main.o defines x and y as D; other.o shows U x; the recorded executable places x at 0x408008. The trace names a definition in main.o and a reference in other.o. No C double type appears.
  2. The trace is:
ld: dup.o: reference to strdup
ld: .../libc.a(strdup.lo): definition of strdup
ld: .../libc.a(strdup.lo): reference to malloc
ld: .../libc.a(lite_malloc.lo): definition of malloc

lite_malloc.lo supplies a weak W malloc, so an application strong definition can replace it without the duplicate from the artificial libmem.a example. This program does not call free; adding free can select a different musl allocator implementation.

  1. The letters are T, D, B, and lowercase t. These symbols have NOTYPE and zero size; the letters reflect section association and local/global status, not allocated objects of those types.
  2. (a) Common merges to 32 bytes; -fno-common fails. (b) Both references select module 1 and read 3. (c) Module 2 wins and main returns 2. (d) Direct objects resolve successfully; the hidden definition is unavailable through the shared library. (e) The first COMDAT group wins, returning 2 in this -O0 experiment; the source violates ODR. (f) Each reference selects its own distinct definition; LOCAL n never competes in the global table.
  3. The shortest sequence is main.o libp.a libq.a libr.a libp.a libq.a, because requirements arise in the order p1, q1, r1, p2, q2:
libp.a(p1.o) main.o (p1)
libq.a(q1.o) libp.a(p1.o) (q1)
libr.a(r1.o) libq.a(q1.o) (r1)
libp.a(p2.o) libr.a(r1.o) (p2)
libq.a(q2.o) libp.a(p2.o) (q2)

Group the three libraries to permit rescanning. One ungrouped p,q,r pass leaves p2 undefined; omit only the final q from the five-library sequence and q2 remains undefined. LLD resolves this graph with each archive once in any order because their definitions do not compete.

  1. (a) Preprocessing: missing include. (b) Compilation: GCC 15 diagnoses the implicit function declaration as an error; older GCC versions might continue to a link failure. (c) Assembly: operand type mismatch for movq; gcc -S itself succeeds. (d) Linking: undefined reference, followed by collect2's22 link-failure message. (e) Loading: musl reports the missing shared library and exits 127; -L. is a link-time search path, while LD_LIBRARY_PATH=. ./b5 supplies the runtime path and returns 42. (f) Execution: writing to the read/execute code segment causes SIGSEGV; the shell reports 139 in the recorded run.

References and terminology

Appendix: terms and tools

  1. Clang provides C-family language frontends and a compiler driver within the LLVM project. It commonly uses an integrated assembler; linker selection still depends on the target and configuration. Clang toolchain documentation. ↩

  2. GNU is the recursive acronym “GNU's Not Unix,” the name of the free-software operating-system project. GCC, binutils, and glibc are distinct GNU projects with different responsibilities. GNU's introduction. ↩

  3. LLD is LLVM's linker. ELF tools commonly invoke it as ld.lld; lld-link provides a Windows-compatible interface. LLD. ↩

  4. GNU binutils includes the assembler as, linker ld, and inspection or archive utilities such as readelf, nm, objdump, and ar. Documentation. ↩

  5. LLVM names a collection of compiler and toolchain projects, including optimization and code-generation infrastructure. Clang, LLD, and LLVM IR are related but have different roles. LLVM. ↩

  6. musl-gcc wraps GCC with musl-specific header and linking settings. It is not inherently a cross-compiler; options such as -static determine the requested linkage. Getting started with musl. ↩

  7. ELF, the Executable and Linkable Format, specifies object files, executables, and shared objects. The gABI supplies generic rules; a processor-specific ABI supplies architecture-dependent rules such as relocation encodings. ELF specification. ↩

  8. gABI is the generic System V Application Binary Interface. It specifies architecture-independent ELF rules, supplemented by processor-specific ABIs. Specification. ↩

  9. nm lists symbols. Its letter codes summarize attributes such as section and binding; inspect the ELF symbol fields when the precise semantics matter. Manual. ↩

  10. GOT, the Global Offset Table, stores addresses or related offsets used through indirection. It lets some address-dependent updates happen in data rather than instruction bytes; relocation types and the ABI define each entry's role. Dynamic linking. ↩

  11. glibc, the GNU C Library, is the default C library in many Linux distributions. Startup files, shared libraries, and the dynamic linker all participate in building and running programs. Project. ↩

  12. GCC, the GNU Compiler Collection, provides compilers for several languages. The gcc command is a driver that coordinates compilation, assembly, and linking; it need not perform all those operations in one process. Overall options. ↩

  13. UB, undefined behavior, means the language standard imposes no requirements on the execution in question. A binary's observed output may be explained without becoming a portable promise. An undefined reference linker error is a different concept. C11 draft. ↩

  14. LTO, link-time optimization, coordinates compiler optimization during linking using retained intermediate representation. It supports cross-file analysis beyond ordinary native-object linking. GCC LTO. ↩

  15. ar builds and inspects archives; its symbol index helps a linker find object members on demand. GNU documentation. ↩

  16. PIC, position-independent code, uses addressing suited to placement at varying load addresses. It is common in shared libraries; the exact use of PC-relative access or indirection depends on the architecture and symbol binding. GCC code-generation options. ↩

  17. ABI, Application Binary Interface, specifies how compiled components cooperate, including calling conventions, data layout, and object-format rules. It governs the machine-level boundary rather than only the source API. System V ABI. ↩

  18. COMDAT identifies duplicate definition groups from which the linker may retain one copy. ELF expresses this with section groups and signatures; related group members must be selected consistently. ELF section groups. ↩

  19. ODR, the One Definition Rule, constrains C++ definitions and their consistency across translation units. Selecting one COMDAT copy does not prove those source requirements. C++ draft. ↩

  20. xv6 is MIT's small Unix-style teaching operating system. The series uses its RISC-V version to examine the handoff from ELF files to processes. Source. ↩

  21. musl is a C-library implementation for Linux, providing standard functions and runtime support. We use it when inspecting or linking a compact static runtime; an ordinary Linux server need not have it installed. Project. ↩

  22. collect2 is a GCC helper that may sit between the driver and linker. Seeing it in a diagnostic identifies part of the invocation chain, not a separate object format. GCC internals. ↩