57 Linking and the ABI — promises at the binary level
What to know first
Looking back
The previous chapter said “the linker joins them”. When does that happen — at build time, or at run time?
A. Both, and that is where this chapter starts. Merging object files and binding symbols to addresses happens when you build; finding shared libraries and joining the symbols inside them happens when the program runs. The single word “linking” straddles two moments, and unless they are named apart the error messages cannot be read.
The need for this chapter, and its context
long has. Every case of “the library was swapped, it still compiles, and it falls over at run time” comes from breaking one of these promises.By the end of this chapter
The questions this chapter answers
- So a link error can suddenly appear while the program is running?
- How much of this does my own library need?
57.1 Linking is not one step — three names#
So far we have said loosely that “the linker joins things up”. It is in fact three stages, each with a name.1
| Stage | What it does | When |
|---|---|---|
| link-editing | merges object files and binds symbols to addresses | at build time — ld |
| loading | brings the executable’s segments into the address space | at run time — the kernel |
| runtime linking | finds and brings in shared libraries, joins symbols | at run time — the dynamic linker |
Table 57.1 — The stages of a split build
A statically linked program ends after the first two. A dynamically linked one goes through all three. That is why the one word “linking” appears at both build time and run time, and confuses people.
★ One practical fact attaches here. The runtime linker brings shared objects into the address space before main is called, but it does not join function symbols until they areactually called. This is lazy binding. So linking against a library you never call costs almost nothing at start-up.
On Linux, LD_BIND_NOW=1 turns the deferral off and joins everything at start. Use it when the delay just after launch matters, or when you want to learn about a link failure now rather than later.
Q. So a link error can suddenly appear while the program is running?
A. It can, and that is the price of lazy binding. If a code path that calls a missing symbol is first taken after the program has been running for a while, the dynamic linker fails at that moment — the message symbol lookup error.
★ So there is a habit of running once with LD_BIND_NOW=1 before shipping, or of building with -Wl,-z,now for immediate binding outright. Security people prefer immediate binding too, because once binding is finished the table can be made read-only (used together with -Wl,-z,relro).
57.2 The real purpose of dynamic linking — not size but the ABI#
The benefit of dynamic linking is commonly given as “the executable gets smaller”. Measured, that is true enough.
| Build | Size | What it holds |
|---|---|---|
gcc -O2 -o hello hello.c | about 16 KB | only the call to puts. the implementation is outside |
gcc -O2 -static -o hello hello.c | about 758 KB | it carries the needed part of libc bodily |
Table 57.2 — Output size and content, by build
★ But the size is a by-product. The real purpose lies elsewhere. Dynamic linking detaches the program from a particular version of a library. The system promises “these services in this shape”, and the program relies only on that promise. That is why a library can be fixed without rebuilding the program.
This promise is called the ABI (Application Binary Interface). If an API is a promise at the source level, an ABI is one at the binary level — not only what the function is called, but which register the arguments ride in, how a struct is laid out, where the return value goes.
| What the ABI fixes | Example |
|---|---|
| calling convention | which register each argument goes in, and where the overflow sits on the stack |
| data layout | alignment and padding of struct members (chapter 46) |
| sizes of basic types | whether long is 4 or 8 bytes — it can differ on one CPU |
| naming rules | whether symbols get a leading underscore |
| library versions | soname and symbol versioning |
Table 57.3 — What an ABI settles
soname is the part met most often in practice. The number in a name like libfoo.so.2 declares “binary compatibility is kept up to this version”; a change that breaks compatibility raises the number. Old programs still look for .so.2 while new ones look for .so.3, so both can live on one system.
In practice. Static linking did not die
Unix literature of the 1990s treated static linking as effectively obsolete, the ABI’s advantages being so great. ★ Yet static linking has come back, for reasons different from then.
Containers and distribution. A program you deploy by copying one file is overwhelmingly simpler to ship. There is no question of whether that machine has that library at that version.
Reproducible builds. Getting the same bytes from the same source means pinning dependencies, and dynamic linking leaves the result to the run-time environment.
Small libc implementations. Ones like musl are designed with static linking in mind and produce results far smaller than the 758 KB above.
So today’s judgement is not “which is right” but “what are you buying” — if you want the system libraries’ security updates automatically, dynamic linking is better; if simplicity of distribution and reproducibility matter more, static is. ★ The security-update point is the big one: a statically linked program has to be rebuilt and redeployed whenever a hole appears in libc.
57.3 What an ABI promises — item by item#
Table 57.3 was the list; here we see what each item actually turns into. None of this is written in the source, yet all of it is plainly there in the machine code.
First, the calling convention. Where do the arguments ride? Under the System V ABI for x86-64, integer and pointer arguments go in rdi, rsi, rdx, rcx, r8, r9, in that order, and the seventh onwards go on the stack. Read what the compiler emitted and there is nothing left to doubt.
f: /* long f(long a, long b, long c, …) */
add rdi, rsi
add rdi, rdx
add rdi, rcx
add rdi, r8★ The same CPU with a different platform has a different convention. Windows x64 passes the first four arguments in rcx, rdx, r8, r9 and has the caller reserve 32 bytes of shadow space. The same instructions, a different promise. That is why an object file built for Linux cannot be linked into a Windows program.
Second, where the return value goes. This too depends on size.
| what is returned | how | what it looks like in the machine code |
|---|---|---|
| a small struct (16 bytes or less) | in registers | two ints packed into one rax |
| a large struct | through a hidden pointer | the caller reserves room and passes it in rdi |
Table 57.4 — Two ways to return a struct (x86-64 System V)
A function returning a large struct really does have one more argument: the address to write the result into. That argument, which appears nowhere in the source, is part of the ABI.
Third, the sizes of the basic types. This is where people get burned most often.
| model | int | long | pointer |
|---|---|---|---|
| LP64 — Linux, macOS, BSD | 4 | 8 | 8 |
| LLP64 — Windows | 4 | 4 | 8 |
Table 57.5 — The same 64-bit machine, a different promise
The classic accident — code that stores a pointer in a long, runs happily on Linux and falls apart on Windows — comes out of one row of that table. When the width matters, use a type that says its width in its name, such as int64_t from <stdint.h> (chapter 28).
Fourth, the naming rules. C barely decorates its symbols: greet is simply greet, and only some older platforms prefixed an underscore. C++ does the opposite and melts the types into the name (name mangling), which is why calling a C function from C++ requires extern "C" — “do not decorate this name”. That is what the familiar incantation in headers is for.
57.4 What breaks an ABI#
Hold on to one principle and the rest follows.
A header is copied into the caller. So the shape of a struct, the value of a macro, and the body of an inline function written in a header are frozen into the caller’s machine code, not the library’s.
Rebuild only the library, leave the caller alone, and the frozen old shape meets the new library. The compiler sees one translation unit at a time, and the linker matches names, never layouts. That is what makes this accident so quiet.
examples-en/ch55/abi_break/main.c
/* When the header and the library disagree, the linker says nothing.
Two translation units know a struct of the same name in two different shapes.
The caller is still built from the old header; only the library was rebuilt.
Nothing crosses the boundary here --- each side just prints what it believes. */
#include <stddef.h>
#include <stdio.h>
#include "conf_v1.h"
void lib_report(void);
int main(void)
{
puts("the same name, two different shapes:");
printf(" caller (not rebuilt, v1.0) believes:\n");
printf(" sizeof(struct conf) = %zu\n", sizeof(struct conf));
printf(" offsetof(struct conf, width) = %zu\n", offsetof(struct conf, width));
printf(" offsetof(struct conf, height)= %zu\n", offsetof(struct conf, height));
lib_report();
puts("\nnothing in the build complained:");
puts(" the compiler saw one translation unit at a time,");
puts(" and the linker matches names, not layouts.");
printf("\nhad a struct crossed the boundary, the caller would write height at"
" offset %zu\n", offsetof(struct conf, height));
puts(" and the library would read it from offset 8 --- a silent wrong answer.");
return 0;
}
Output
the same name, two different shapes:
caller (not rebuilt, v1.0) believes:
sizeof(struct conf) = 8
offsetof(struct conf, width) = 0
offsetof(struct conf, height)= 4
library (rebuilt, v1.1) believes:
sizeof(struct conf) = 12
offsetof(struct conf, width) = 0
offsetof(struct conf, height)= 8
nothing in the build complained:
the compiler saw one translation unit at a time,
and the linker matches names, not layouts.
had a struct crossed the boundary, the caller would write height at offset 4
and the library would read it from offset 8 --- a silent wrong answer.
Two translation units know a struct of the same name in two different shapes, and nothing in the build said a word. Had that struct crossed the boundary, the caller would write height at offset 4 and the library would read it from offset 8 — nobody crashes, the answer is simply wrong.
| the change | in source | in binary |
|---|---|---|
| adding a member in the middle | compiles as before | every later member shifts |
| reordering members | as before | the positions swap |
widening a member (int→long) | as before | size and alignment change |
| changing an enumeration constant | as before | the old number is frozen in old code |
| editing an inline function in a header | as before | the old body is frozen in the caller |
| changing the value of a macro | as before | the old value is frozen in |
| removing a function | the build breaks | the symbol is missing at run time |
Table 57.6 — Changes that are source compatible but not binary compatible
A common misconception. The library was rebuilt, so the new behaviour must be in effect
57.5 How to change it without breaking it#
Is a library then unfixable forever? No. Libraries that live a long time are designed from the start with room to change.
- An opaque pointer. Do not put the shape in the header; announce only the name (
typedef struct conf conf;). The caller knows neither size nor layout, so nothing can freeze. Everything — creating, reading, modifying — is offered as functions instead. The layout becomes free to change; the price is one call. - Pass the size along. Put a
size_t sizefirst in the struct and have the caller fill in the size it knows. The library reads that number and knows “this caller is an older edition”. Operating-system APIs are fond of this. - Leave room in advance. Keep something like
void *reserved[4]and spend it later. The size never changes, so neither does the layout. - Do not change it — add. Instead of altering
render(), shiprender_ex(). Crude, but certain. - Symbol versioning. Keep the old and the new version of a name in one library and bind old programs to the old one. glibc has held compatibility this way for decades. When even that will not do, raise the
soname:libfoo.so.2andlibfoo.so.3are different libraries and coexist on one system.
Q. How much of this does my own library need?
A. Decide by who is able to rebuild. For code inside your own project, where you rebuild everything anyway, these devices are mostly baggage — edit the header and rebuild. It changes the moment something built by someone else links against your library. ★ The day you cross that line is the day you declare that layout has become a contract.
57.6 Position-independent code (PIC) — why libraries must be built this way#
A shared library has a problem to solve. The same library lands at a different address in each process. Yet addresses inside machine code have to be somewhere.
The naive answer is “fix up the addresses in the code when loading”. That answer has a fatal cost. ★ The moment you patch a code page, that page can no longer be shared. If eight processes use one library, the code should exist once in physical memory; patch it differently in each and there are eight copies. The very reason shared libraries exist collapses.
Hence position-independent code (PIC). The idea is simple — never patch the code; gather every address that needs fixing into a “table”. A table is data, so having one per process costs little.
| What | Table | Shape of the indirection |
|---|---|---|
| global data access | GOT (Global Offset Table) | read the address from the table → go there |
| function call | PLT (Procedure Linkage Table) | go by way of a small relay |
Table 57.7 — The tables linking uses, and the shape of indirection
It is plain when seen for real. Here is int counter; int bump(void){ return ++counter; } built with -fPIC -shared and taken apart with objdump (x86-64).
bump:
mov 0x2ec9(%rip),%rdx <- read counter's address out of the GOT
mov (%rdx),%eax <- read the value at that address
add $0x1,%eax
mov %eax,(%rdx)★ Two reads. The very structure that split arrays from pointers in chapter 41 — “read the address first, then read the value” — returns here. The cost of PIC is, in the end, that one step.
The function side goes through the PLT.
bump@plt:
jmp *0x2fc2(%rip) <- jump to the GOT entry
push $0x1 <- if not yet joined, fall through to here
jmp <dynamic linker> <- the linker fills the address in and jumps againThose two lines are the machinery of lazy binding. On the first call the dynamic linker is summoned to fill the GOT entry; after that a single first jmp goes straight there.
Platform note. Today’s circumstances — what changed since the 1990s
Older literature wrote “PIC is a little slower, so do not use it for executables”. Several things have changed.
① On x86-64 the cost fell sharply. RIP-relative addressing arrived, removing the 32-bit era’s separate work of finding out where one is. The 0x2ec9(%rip) above is exactly that.
② It is no longer optional. Try to make a shared library on x86-64 without -fPIC and the linker refuses — literally: relocation R_X86_64_PC32 against symbol 'counter' can not be used when making a shared object; recompile with -fPIC
③ Executables are built position-independent too. Most distributions today build executables as PIEs (position-independent executables). The reason is not performance but security — the load address of the executable itself must be randomised for ASLR (address space layout randomisation) to be worth much.
④ The old option names can be forgotten. The Sun compiler’s -K pic, and -G for making a shared object, are -fPIC and -shared in today’s GCC and Clang.
A few knobs are worth knowing. -Bsymbolic and -fno-semantic-interposition keep a library’s internal calls from being intercepted from outside, reducing the indirect cost; --as-needed records no dependency that is not actually used.
57.7 When names collide they get replaced — interpositioning#
The previous section showed that external linkage is the default. Meet that with dynamic linking and something surprising happens. A function I wrote can quietly stand in for a library function.
This is interpositioning. The dynamic linker searches for a symbol in a fixed order and uses the first one found. So if my program has a global function named malloc, even the malloc calls made inside the library come to mine.
★ Two things must coincide for this to become an accident. One is that functions are global by default; the other is that the compiler does not treat the redefinition as an error. The latter is C’s old attitude — it assumes the programmer meant it.
Counter-example. Using a common name for a global function
/* in my file, with no static */
char *mktemp(char *tpl) { ... } /* a name the standard library also uses */
int index(int i) { ... } /* an old Unix function name */Such names may already be in use by the standard or a system library. When they collide, my function intercepts even the library’s internal calls. The symptom usually appears somewhere else entirely — a function I never called misbehaves.
The cure is the discipline of the previous section. If it is not going out as an interface, mark it static. And give what does go out a prefix (chapter 59). Avoiding the shapes of name the standard reserves belongs here too.
In practice. Where interpositioning becomes a tool
There are places where this property is used on purpose. Linux’s LD_PRELOAD environment variable says “search this library before anything else”, so a particular function can be swapped without touching the program.
Several uses are legitimate — tools that wrap malloc/free to track leaks, test harnesses that fake system calls, pinning the time functions to make tests reproducible.
★ But the same property is dangerous: it means another program’s behaviour can be changed from outside, so the variable is ignored for setuid programs. Judgement about such “powerful and dangerous knobs” follows chapter 13′s ladder — write down what you buy and what you pay, then use it.
We have learned the promises made at the binary level. But #include and #ifndef appeared again in the previous chapter — the layer chapter 17 passed over with “the preprocessor is a text tool that does not know C”. Soon we open that layer head on, and also see the formal stages by which source code becomes a program.
Notes
- These three names and the distinction follow the standard account of linking and loading. Particularly helpful for clarifying them were John R. Levine, Linkers and Loaders (Morgan Kaufmann, 1999), the ELF (executable and linkable format) sections of the System V ABI, and
ld.so(8). For a 1994 presentation of the distinction aimed at C programmers, see Peter van der Linden, Expert C Programming, in the chapter on linking. ↩