What You Never Knew About Symbols
“The linker is the most underappreciated tool in your entire toolchain.”
Who This Book Is For
You’re a web developer. You’ve been at it for a decade. You’ve built SPAs, wrestled with webpack configurations, optimized bundle sizes, and maybe even touched some WebAssembly. You know JavaScript intimately—its quirks, its runtime, its module systems (all seven of them).
But here’s a question: What actually happens when code becomes… code?
Not the high-level “it gets compiled” answer. The real answer. The one involving symbol tables, relocations, sections, and the three different kinds of linkers that make your programs actually work.
Why Should You Care?
As a web developer, you might think this is esoteric systems programming trivia. It’s not. Here’s why:
-
WebAssembly is eating the world. When you compile Rust, C++, or Go to WASM, you’re dealing with object files. Understanding them means understanding why your WASM module is 2MB instead of 200KB.
-
Native modules exist. Node.js addons, Electron apps, and native bindings all involve linking. When
npm installfails with “undefined symbol,” you’ll know exactly what that means. -
Bundlers are linkers. Webpack, Rollup, esbuild—they all do a form of linking. The concepts are the same: resolve symbols, eliminate dead code, produce a final artifact.
-
Debugging gets deeper. Stack traces, source maps, and debugging symbols all trace back to concepts we’ll cover here.
-
It’s fascinating. Seriously. The elegance of how a bunch of separate
.ofiles become a running program is beautiful engineering.
What We’ll Cover
This book is structured as a journey from “what is an object file” to “I can read ELF headers for fun”:
Part I: Foundations
- What symbols are and why they exist
- Deep dive into ELF (the format running on billions of devices)
- WebAssembly’s object format (yes, WASM has one)
Part II: Symbol Tables
- How symbol tables work and what’s in them
- Relocations: the placeholder system that makes linking possible
Part III: Linking
- Static linking: the simple case
- Dynamic linking: shared libraries and the runtime
- Runtime linking:
dlopen()and its friends
Part IV: Bringing It Together
- ELF vs WASM: a detailed comparison
- Practical applications: debugging, optimization, and tooling
Prerequisites
You should be comfortable with:
- Reading code in multiple languages
- Basic understanding of how compilation works (source → compiler → something → executable)
- Command-line tools
- A willingness to look at hexadecimal
You don’t need:
- Prior systems programming experience
- Assembly language knowledge (though we’ll see some)
- A Computer Science degree
A Note on Tools
Throughout this book, we’ll use various tools to inspect object files. Most examples use:
# For ELF files
readelf -a binary # Inspect ELF structure
nm binary # List symbols
objdump -d binary # Disassemble
# For WASM files
wasm-objdump -x file.wasm # Inspect WASM structure
wasm2wat file.wasm # Convert to text format
On macOS, ELF tools may need to be installed via Homebrew (binutils). For WASM tools, the WebAssembly Binary Toolkit (WABT) is your friend.
Let’s Begin
Every journey into the depths of a system starts with a single question. Ours is deceptively simple: what is a symbol?
You’ve seen symbols before, even if you didn’t call them that. Every time you write export function in JavaScript, you’re creating one. Every time webpack reports “module not found,” it’s complaining about one. Every time a native Node.js addon fails to load with “undefined symbol,” you’re confronting the consequences of one gone missing.
Symbols are the names we give to things in code—functions, variables, constants—so that separate pieces of a program can find each other. They’re the calling cards that code leaves behind, saying “I exist, and here’s how to reach me.”
In the next chapter, we’ll see exactly what symbols look like, how compilers create them, and how linkers use them to stitch separate files into working programs. We’ll start with C because it shows the machinery most clearly, but the concepts apply everywhere—including to the JavaScript bundlers you use every day.
Turn the page. The answer to “what is a symbol?” will reshape how you think about code.
What Are Symbols Anyway?
Let’s start with a simple C program:
// math.c
int add(int a, int b) {
return a + b;
}
int multiply(int a, int b) {
return a * b;
}
// main.c
extern int add(int, int);
extern int multiply(int, int);
int main() {
int result = add(2, 3);
result = multiply(result, 4);
return result;
}
Two files. Compile them separately:
gcc -c math.c -o math.o
gcc -c main.c -o main.o
Now you have two object files. Neither is executable. They’re intermediate products—compiled code with some unfinished business.
What’s the unfinished business? Let’s look at main.o:
$ nm main.o
U add
0000000000000000 T main
U multiply
Three symbols:
mainis defined here (that’s whatTmeans—it’s in the.textsection)addis undefined (U)—main.c calls it but doesn’t define itmultiplyis also undefined
Now math.o:
$ nm math.o
0000000000000000 T add
0000000000000020 T multiply
Both add and multiply are defined here. No undefined symbols.
The Core Concept
A symbol is a named reference to something in your code. Usually it’s one of:
- A function (
add,main,printf) - A global variable (
errno,stdout,my_global_config) - A static variable (file-scoped, but still needs a name internally)
Symbols are how separate compilation units talk to each other. When main.c says add(2, 3), the compiler doesn’t know where add is. It just emits a placeholder that says “call the function named add.” The linker’s job is to fill in that placeholder with an actual address.
Symbol Properties
Every symbol has several properties:
Name
The identifier. In C, what you see is (mostly) what you get. In C++, names get mangled:
// C++
int add(int a, int b); // Mangled: _Z3addii
int add(double a, double b); // Mangled: _Z3adddd
namespace math {
int add(int a, int b); // Mangled: _ZN4math3addEii
}
Mangling encodes the function signature into the name, enabling function overloading. Different compilers use different mangling schemes (a fun source of ABI incompatibility).
Binding
Who can see this symbol?
- Global (also called “external”): Visible to other object files. Default for functions and non-static globals.
- Local: Only visible within this object file.
staticfunctions and variables in C. - Weak: Like global, but can be overridden. Default implementations live here.
// Global - other files can call this
int public_function(void) { return 1; }
// Local - only this file can see it
static int private_function(void) { return 2; }
// Weak - can be overridden by a strong symbol
__attribute__((weak)) int maybe_override(void) { return 3; }
Type
What kind of thing is this symbol?
NOTYPE: Unknown/unspecifiedOBJECT: A data variableFUNC: A functionSECTION: A section nameFILE: Source file name
Section
Where in the object file does this symbol live?
.text: Executable code.data: Initialized data.bss: Uninitialized data (Block Started by Symbol—yes, really).rodata: Read-only data (string literals, constants)UNDor*UND*: Undefined—not in this file at all
Value
Usually the offset within the section. For an undefined symbol, this is meaningless until linking.
A JavaScript Analogy
If you’ve ever written:
// utils.js
export function add(a, b) {
return a + b;
}
// main.js
import { add } from './utils.js';
console.log(add(2, 3));
You’ve worked with symbols! The ES6 module system is conceptually identical:
exportmakes a symbol global (visible outside the module)importdeclares a symbol as undefined locally (defined elsewhere)- The bundler (or runtime module loader) resolves undefined symbols to their definitions
The difference? JavaScript modules are resolved at runtime (or bundle time by a bundler). Object files are designed to be resolved by a linker, which produces an executable before runtime.
Symbol Resolution: The Linker’s Job
When you run:
gcc main.o math.o -o program
The linker:
- Collects all symbols from all input files
- Resolves undefined symbols to their definitions
- Relocates code to final addresses
- Emits the final executable
If add is undefined in main.o but defined in math.o, the linker matches them up. If add were undefined everywhere? Linker error:
undefined reference to `add'
If add were defined in two files with global binding? Also an error:
multiple definition of `add'
(Unless one is weak—then the strong definition wins.)
Why Not Just Use Addresses?
You might wonder: why have named symbols at all? Why not just use addresses?
The answer is separate compilation. When main.c is compiled, the compiler has no idea where add will end up. It might be in a different object file. It might be in a shared library. It might be in a library that doesn’t exist yet.
Symbols are a level of indirection. They let us:
- Compile files independently
- Link against libraries without their source code
- Replace implementations (debugging, testing, optimization)
- Share code via dynamic libraries loaded at runtime
Visibility Beyond Binding
Modern systems add another layer: visibility. This is separate from binding and controls whether a symbol is visible outside a shared library:
// Always visible (default)
__attribute__((visibility("default")))
int public_api(void);
// Hidden - only visible within this shared library
__attribute__((visibility("hidden")))
int internal_helper(void);
This matters for shared libraries. A function can be global (callable from anywhere within the library) but hidden (not exported to programs using the library).
Symbol Versioning
Large libraries (like glibc) use symbol versioning to maintain backward compatibility:
// Old version
int old_function(void) __asm__("function@VERSION_1");
// New version with different behavior
int new_function(void) __asm__("function@@VERSION_2");
Programs linked against VERSION_1 continue getting the old behavior. New programs get VERSION_2. This is how glibc maintains ABI compatibility across decades of Linux distributions.
Try It Yourself
Create those two files (main.c and math.c) and experiment:
# Compile to object files
gcc -c main.c math.c
# Examine symbols
nm main.o
nm math.o
# See more detail
readelf -s main.o
# Link and run
gcc main.o math.o -o program
./program
echo $? # Should print 20 (add(2,3) = 5, multiply(5,4) = 20)
Try making add static in math.c. Watch the linker error. Try removing the multiply call from main—does the symbol still appear?
Key Takeaways
- Symbols are names for functions, variables, and other code elements
- Undefined symbols are promises: “this exists somewhere, trust me”
- The linker resolves undefined symbols to their definitions
- Binding controls visibility: global, local, or weak
- This is not unlike ES6 modules—the concepts transfer
Looking Ahead
We’ve established that symbols are names, and that they live in object files. But we’ve been treating object files as black boxes—we know nm can list their symbols, but we haven’t looked inside.
What is an object file, really? It’s not just a bag of symbols. It’s a structured container with headers, sections, and metadata. The symbol table is just one part of a larger architecture designed in the 1990s and still running on billions of devices today.
That architecture is called ELF—the Executable and Linkable Format. Understanding ELF means understanding how Linux, Android, and most of the world’s servers actually load and run code. It also provides the conceptual foundation for understanding WebAssembly, which made different design choices for different reasons.
In the next chapter, we’ll crack open an ELF file and examine its anatomy. You’ll see where symbols live, how code is organized into sections, and why there are two different “views” of the same file. Bring your hex editor—we’re going deep.
Object File Anatomy: ELF Deep Dive
By the early 1990s, the Unix world was fragmented. Different systems used different binary formats: a.out on older systems and early Linux, COFF on System V Release 3, Mach-O on NeXT. Porting software meant wrestling with format differences. Debugging tools had to understand multiple formats. It was a mess.
ELF—the Executable and Linkable Format—was designed to end that fragmentation. Developed at Unix System Laboratories around 1989 for System V Release 4, it spread to Solaris, then to the BSDs, and finally to Linux in 1995. It succeeded beyond anyone’s expectations. Today, ELF runs on Linux, FreeBSD, OpenBSD, NetBSD, Solaris, PlayStation, Android, and countless embedded systems. When you run a program on a Linux server, you’re running an ELF file. When your phone launches an app, ELF is involved. It’s one of the most successful binary formats ever designed.
Understanding ELF isn’t just historical curiosity. It’s practical knowledge. When npm install fails with a mysterious native module error, ELF knowledge helps you debug it. When you’re optimizing a Docker image, knowing what’s in those binaries helps you shrink them. When you’re investigating a security vulnerability, ELF structure tells you what’s exploitable.
Let’s open one up.
ELF stands for Executable and Linkable Format. It’s the standard binary format on Linux, BSD, Solaris, and many embedded systems. If you’ve ever run a program on Linux, you’ve run an ELF file.
But ELF isn’t just for executables. The same format is used for:
- Object files (
.o) — compiler output, input to linker - Shared libraries (
.so) — dynamically linked code - Executables — the final runnable program
- Core dumps — memory snapshots for debugging
Understanding ELF means understanding how all these fit together.
The ELF Header
Every ELF file starts with a header. Let’s look at one:
$ readelf -h /bin/ls
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Position-Independent Executable file)
Machine: AArch64
Version: 0x1
Entry point address: 0x65c0
Start of program headers: 64 (bytes into file)
Start of section headers: 197720 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 12
Size of section headers: 64 (bytes)
Number of section headers: 29
Section header string table index: 28
Let’s break this down:
Magic Number
7f 45 4c 46
That’s \x7fELF in ASCII. Every ELF file starts with these four bytes. It’s how programs quickly identify “this is an ELF file.”
Class (32-bit vs 64-bit)
ELF64 means this is a 64-bit binary. ELF32 would be 32-bit. The class determines pointer sizes and structure layouts throughout the file.
Data Encoding
2's complement, little endian — how numbers are stored. x86 and ARM are little-endian. Some older architectures (SPARC, older PowerPC) are big-endian.
Type
The type tells us what kind of ELF file this is:
| Type | Meaning |
|---|---|
REL | Relocatable file (object file, .o) |
EXEC | Executable (traditional fixed-address binary) |
DYN | Shared object (.so, or a PIE executable) |
CORE | Core dump |
Modern executables are often DYN (Position-Independent Executable) for security reasons (ASLR).
Entry Point
0x6ab0 — the address where execution begins. For executables, this is where the OS jumps to start your program. For object files, this is 0 (no entry point yet).
Two Views of an ELF File
Here’s the crucial insight: ELF files have two parallel structures:
- Sections — for linking (static view)
- Segments — for execution (runtime view)
ELF FILE
┌─────────────────────────────────────┐
│ ELF Header │
├─────────────────────────────────────┤
│ Program Headers │ ← Describes segments
│ (for execution/loading) │ (runtime view)
├─────────────────────────────────────┤
│ │
│ Sections │
│ .text, .data, .rodata, .bss... │
│ │
├─────────────────────────────────────┤
│ Section Headers │ ← Describes sections
│ (for linking/debugging) │ (static view)
└─────────────────────────────────────┘
Sections are the linker’s view. Each section has a name, type, and content. The linker combines sections from multiple object files.
Segments are the loader’s view. They describe how to map the file into memory. A segment might contain multiple sections.
Object files (.o) have sections but no segments—they’re not loadable yet.
Executables have both—sections for debugging/stripping, segments for loading.
Essential Sections
Let’s explore the sections you’ll encounter most often:
.text — Executable Code
Your compiled functions live here. This section is:
- Readable and executable
- Usually not writable (code shouldn’t modify itself)
- Contains machine instructions
$ objdump -d math.o
math.o: file format elf64-littleaarch64
Disassembly of section .text:
0000000000000000 <add>:
0: d10043ff sub sp, sp, #0x10
4: b9000fe0 str w0, [sp, #12]
8: b9000be1 str w1, [sp, #8]
c: b9400fe1 ldr w1, [sp, #12]
10: b9400be0 ldr w0, [sp, #8]
14: 0b000020 add w0, w1, w0
18: 910043ff add sp, sp, #0x10
1c: d65f03c0 ret
0000000000000020 <multiply>:
20: d10043ff sub sp, sp, #0x10
24: b9000fe0 str w0, [sp, #12]
28: b9000be1 str w1, [sp, #8]
2c: b9400fe1 ldr w1, [sp, #12]
30: b9400be0 ldr w0, [sp, #8]
34: 1b007c20 mul w0, w1, w0
38: 910043ff add sp, sp, #0x10
3c: d65f03c0 ret
Notice the addresses start at 0. These are relative offsets—final addresses are determined during linking.
.data — Initialized Data
Global and static variables with initial values:
int global_counter = 42;
static int file_counter = 100;
Both live in .data. The section contains the actual bytes of the initial value.
.bss — Uninitialized Data
int uninitialized_global;
static int uninitialized_static;
The .bss section is special: it doesn’t occupy space in the file. The section header just records how much memory to allocate (filled with zeros at load time).
Why separate? A program with int array[1000000]; would have a 4MB .data section. With .bss, the file just says “allocate 4MB of zeros” — much smaller.
.rodata — Read-Only Data
String literals and constants:
const char* message = "Hello, world!";
const int magic = 0xDEADBEEF;
The string "Hello, world!" lives in .rodata. The pointer message lives in .data (it’s an initialized global pointing to rodata).
.symtab and .strtab — Symbol Table
The symbol table! We covered this in Chapter 1. .symtab contains the structured symbol entries; .strtab contains the actual name strings (symbols reference them by offset).
$ readelf -s math.o
Symbol table '.symtab' contains 12 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS math.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text
3: 0000000000000000 0 SECTION LOCAL DEFAULT 2 .data
4: 0000000000000000 0 SECTION LOCAL DEFAULT 3 .bss
5: 0000000000000000 0 NOTYPE LOCAL DEFAULT 1 $x
6: 0000000000000000 0 SECTION LOCAL DEFAULT 5 .note.GNU-stack
7: 0000000000000014 0 NOTYPE LOCAL DEFAULT 6 $d
8: 0000000000000000 0 SECTION LOCAL DEFAULT 6 .eh_frame
9: 0000000000000000 0 SECTION LOCAL DEFAULT 4 .comment
10: 0000000000000000 32 FUNC GLOBAL DEFAULT 1 add
11: 0000000000000020 32 FUNC GLOBAL DEFAULT 1 multiply
.rel.text and .rela.text — Relocations
When code references something that isn’t known yet (a function in another file, a global variable), the compiler emits a relocation entry. We’ll cover these in depth in Chapter 5.
$ readelf -r main.o
Relocation section '.rela.text' at offset 0x218 contains 2 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000000010 000b0000011b R_AARCH64_CALL26 0000000000000000 add + 0
000000000020 000c0000011b R_AARCH64_CALL26 0000000000000000 multiply + 0
Relocation section '.rela.eh_frame' at offset 0x248 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
00000000001c 000200000105 R_AARCH64_PREL32 0000000000000000 .text + 0
The main.o file has two relocations: calls to add and multiply that need to be resolved.
.debug_* — Debug Information
If you compile with -g, you get DWARF debug sections:
.debug_info— type information, variable locations.debug_line— source line mappings.debug_abbrev— abbreviation tables.debug_str— debug strings
These can make binaries huge. strip removes them for release builds.
Section Flags
Each section has flags describing its properties:
$ readelf -S math.o
There are 11 section headers, starting at offset 0x2a8:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
0000000000000040 0000000000000000 AX 0 0 4
[ 2] .data PROGBITS 0000000000000000 00000080
0000000000000000 0000000000000000 WA 0 0 1
[ 3] .bss NOBITS 0000000000000000 00000080
0000000000000000 0000000000000000 WA 0 0 1
[ 4] .comment PROGBITS 0000000000000000 00000080
0000000000000013 0000000000000001 MS 0 0 1
[ 5] .note.GNU-stack PROGBITS 0000000000000000 00000093
0000000000000000 0000000000000000 0 0 1
[ 6] .eh_frame PROGBITS 0000000000000000 00000098
0000000000000048 0000000000000000 A 0 0 8
[ 7] .rela.eh_frame RELA 0000000000000000 00000220
0000000000000030 0000000000000018 I 8 6 8
[ 8] .symtab SYMTAB 0000000000000000 000000e0
0000000000000120 0000000000000018 9 10 8
[ 9] .strtab STRTAB 0000000000000000 00000200
000000000000001b 0000000000000000 0 0 1
[10] .shstrtab STRTAB 0000000000000000 00000250
0000000000000054 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
D (mbind), p (processor specific)
Flags:
A— Allocate: takes up memory at runtimeX— Executable: contains runnable codeW— Writable: can be modified at runtimeS— Strings: contains null-terminated stringsM— Merge: identical content can be merged
.text is AX (allocated + executable). .data is WA (writable + allocated).
Program Headers (Segments)
For executables and shared libraries, program headers describe memory mapping:
$ readelf -l /bin/ls
Elf file type is DYN (Position-Independent Executable file)
Entry point 0x65c0
There are 12 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000000040 0x0000000000000040
0x00000000000002a0 0x00000000000002a0 R 0x8
INTERP 0x0000000000000324 0x0000000000000324 0x0000000000000324
0x000000000000001b 0x000000000000001b R 0x1
[Requesting program interpreter: /lib/ld-linux-aarch64.so.1]
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000024f58 0x0000000000024f58 R E 0x10000
LOAD 0x000000000002ef20 0x000000000003ef20 0x000000000003ef20
0x0000000000001390 0x0000000000002698 RW 0x10000
DYNAMIC 0x000000000002f908 0x000000000003f908 0x000000000003f908
0x0000000000000230 0x0000000000000230 RW 0x8
NOTE 0x00000000000002e0 0x00000000000002e0 0x00000000000002e0
0x0000000000000020 0x0000000000000020 R 0x8
NOTE 0x0000000000000300 0x0000000000000300 0x0000000000000300
0x0000000000000024 0x0000000000000024 R 0x4
NOTE 0x0000000000024f38 0x0000000000024f38 0x0000000000024f38
0x0000000000000020 0x0000000000000020 R 0x4
GNU_PROPERTY 0x00000000000002e0 0x00000000000002e0 0x00000000000002e0
0x0000000000000020 0x0000000000000020 R 0x8
GNU_EH_FRAME 0x0000000000020c8c 0x0000000000020c8c 0x0000000000020c8c
0x00000000000009fc 0x00000000000009fc R 0x4
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
GNU_RELRO 0x000000000002ef20 0x000000000003ef20 0x000000000003ef20
0x00000000000010e0 0x00000000000010e0 R 0x1
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .note.gnu.property .note.gnu.build-id .interp .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .text .fini .rodata .eh_frame_hdr .eh_frame .note.ABI-tag
03 .init_array .fini_array .data.rel.ro .dynamic .got .data .bss
04 .dynamic
05 .note.gnu.property
06 .note.gnu.build-id
07 .note.ABI-tag
08 .note.gnu.property
09 .eh_frame_hdr
10
11 .init_array .fini_array .data.rel.ro .dynamic .got
Key segment types:
LOAD— Mapped into memory. TheRWEflags determine permissions (Read/Write/Execute).INTERP— Path to the dynamic linker (/lib64/ld-linux-x86-64.so.2)DYNAMIC— Information for dynamic linkingGNU_STACK— Stack permissions (non-executable stack for security)GNU_RELRO— Read-only after relocation (security hardening)
Notice how there are multiple LOAD segments with different permissions. One is R E (read + execute) for code. Another is RW (read + write) for data.
Section to Segment Mapping
The linker groups sections into segments:
$ readelf -l /bin/ls
Elf file type is DYN (Position-Independent Executable file)
Entry point 0x65c0
There are 12 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000000040 0x0000000000000040
0x00000000000002a0 0x00000000000002a0 R 0x8
INTERP 0x0000000000000324 0x0000000000000324 0x0000000000000324
0x000000000000001b 0x000000000000001b R 0x1
[Requesting program interpreter: /lib/ld-linux-aarch64.so.1]
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000024f58 0x0000000000024f58 R E 0x10000
LOAD 0x000000000002ef20 0x000000000003ef20 0x000000000003ef20
0x0000000000001390 0x0000000000002698 RW 0x10000
DYNAMIC 0x000000000002f908 0x000000000003f908 0x000000000003f908
0x0000000000000230 0x0000000000000230 RW 0x8
NOTE 0x00000000000002e0 0x00000000000002e0 0x00000000000002e0
0x0000000000000020 0x0000000000000020 R 0x8
NOTE 0x0000000000000300 0x0000000000000300 0x0000000000000300
0x0000000000000024 0x0000000000000024 R 0x4
NOTE 0x0000000000024f38 0x0000000000024f38 0x0000000000024f38
0x0000000000000020 0x0000000000000020 R 0x4
GNU_PROPERTY 0x00000000000002e0 0x00000000000002e0 0x00000000000002e0
0x0000000000000020 0x0000000000000020 R 0x8
GNU_EH_FRAME 0x0000000000020c8c 0x0000000000020c8c 0x0000000000020c8c
0x00000000000009fc 0x00000000000009fc R 0x4
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
GNU_RELRO 0x000000000002ef20 0x000000000003ef20 0x000000000003ef20
0x00000000000010e0 0x00000000000010e0 R 0x1
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .note.gnu.property .note.gnu.build-id .interp .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .text .fini .rodata .eh_frame_hdr .eh_frame .note.ABI-tag
03 .init_array .fini_array .data.rel.ro .dynamic .got .data .bss
04 .dynamic
05 .note.gnu.property
06 .note.gnu.build-id
07 .note.ABI-tag
08 .note.gnu.property
09 .eh_frame_hdr
10
11 .init_array .fini_array .data.rel.ro .dynamic .got
Multiple sections become one segment. All the code sections (.init, .plt, .text, .fini) map to segment 03 with R E permissions.
Inspecting ELF Files
Your essential toolkit:
# Overview
readelf -h file # ELF header
readelf -S file # Section headers
readelf -l file # Program headers (segments)
readelf -s file # Symbol table
readelf -r file # Relocations
# Alternative views
objdump -d file # Disassemble
objdump -t file # Symbol table
objdump -h file # Section headers
# Raw hex
hexdump -C file | head # See the raw bytes
xxd file | head # Another hex viewer
# Symbols specifically
nm file # Quick symbol listing
nm -C file # Demangle C++ names
A Web Developer’s Perspective
Think of an ELF file like a complex bundle:
| ELF Concept | Bundle Analogy |
|---|---|
| Sections | Separate chunks (JS, CSS, images) |
| Segments | How chunks are loaded (async vs sync) |
| Symbol table | Export/import declarations |
| Relocations | Import bindings to resolve |
.text | Your JavaScript code |
.rodata | Your string literals |
.data | Your runtime state |
The linker is like a bundler (webpack, rollup): it takes multiple inputs, resolves cross-references, and produces one output.
Try It Yourself
# Create a simple C file
echo 'int main() { return 42; }' > simple.c
# Compile to object file (not executable)
gcc -c simple.c -o simple.o
# Examine sections
readelf -S simple.o
# Compile to executable
gcc simple.c -o simple
# Compare sections
readelf -S simple
# Look at segments (only in executable)
readelf -l simple
# Check the entry point
readelf -h simple | grep Entry
Key Takeaways
- ELF has two views: sections (for linking) and segments (for loading)
- Object files have sections only; executables have both
.textis code,.datais initialized data,.bssis zero-initialized- The symbol table lives in
.symtab; relocations in.rel*sections - Segments define memory mapping: what’s readable, writable, executable
A Format Born of Its Time
ELF was designed for a world of desktop workstations and servers—machines with megabytes of RAM, spinning disks, and no security sandbox. It assumes the operating system will protect processes from each other, so the format itself doesn’t need to enforce safety. Code can jump anywhere. Pointers can point to anything. The format trusts you.
Twenty years later, the world looked different. Browsers ran code from untrusted websites. Mobile devices ran apps from unknown developers. Edge servers ran code from customers. The old assumptions—that code could be trusted, that the OS provided enough isolation—no longer held.
WebAssembly was designed for this new world. It’s a binary format, like ELF, with sections and symbols and relocations. But it makes fundamentally different choices. Memory is sandboxed. Control flow is structured. Types are mandatory. The format doesn’t trust you—and that’s the point.
In the next chapter, we’ll explore WASM’s object format. You’ll see familiar concepts—sections, imports, exports—implemented in unfamiliar ways. Understanding both formats will show you the design space of binary formats: what’s essential, what’s historical accident, and what’s deliberate trade-off.
WebAssembly Object Format
If ELF is the grizzled veteran—battle-tested, flexible, trusting—WebAssembly is the paranoid newcomer, designed by people who’d seen what happens when you trust code too much.
The web taught us hard lessons. JavaScript engines spent years hardening against malicious scripts. Browser sandboxes grew ever more complex. Even then, exploits slipped through. When the browser vendors sat down to design a binary format for the web, they asked: what if we built safety into the format itself? What if untrusted code couldn’t misbehave, not because we caught it, but because the format made misbehavior impossible to express?
The result is WebAssembly: a binary format that’s simultaneously lower-level than JavaScript (it compiles to native code) and safer (memory is sandboxed, control flow is structured, types are checked). It’s a format where the things you can’t do matter as much as the things you can.
WebAssembly wasn’t designed to replace ELF. It was designed for the web. But somewhere along the way, it grew up and became a real compilation target—complete with its own object file format, linking conventions, and toolchain.
If you’ve compiled C or Rust to WASM, you’ve produced WASM object files. Let’s understand what’s inside them.
WASM: Not Just a VM Bytecode
When WebAssembly first arrived, it looked like Java bytecode or .NET IL—a virtual machine instruction set. But WASM has a crucial difference: it’s designed to be fast to compile.
JIT compilers can turn WASM into native code in milliseconds. This changes everything. WASM isn’t interpreted; it’s compiled. And that means WASM modules need the same machinery as native object files: imports, exports, relocations, and symbols.
The WASM Binary Format
A WASM file is a module. Its structure:
┌─────────────────────────────────────┐
│ Magic Number (4 bytes) │ 0x00 0x61 0x73 0x6D ("\0asm")
├─────────────────────────────────────┤
│ Version (4 bytes) │ 0x01 0x00 0x00 0x00 (version 1)
├─────────────────────────────────────┤
│ Section 1 (Type) │
├─────────────────────────────────────┤
│ Section 2 (Import) │
├─────────────────────────────────────┤
│ Section 3 (Function) │
├─────────────────────────────────────┤
│ ... │
├─────────────────────────────────────┤
│ Section N (Custom) │
└─────────────────────────────────────┘
Each section has:
- A section ID (1 byte)
- A size (LEB128 encoded)
- Content (section-specific)
Let’s examine the key sections.
Section Types
Type Section (ID: 1)
Defines function signatures:
(type $add_type (func (param i32 i32) (result i32)))
(type $print_type (func (param i32)))
Types are defined once and referenced by index. This saves space—a module with 100 functions might only have 10 unique signatures.
Import Section (ID: 2)
External dependencies—the WASM equivalent of undefined symbols:
(import "env" "memory" (memory 1))
(import "env" "print" (func $print (type $print_type)))
(import "wasi_snapshot_preview1" "fd_write" (func $fd_write ...))
Imports have:
- Module name: where the import comes from (
"env","wasi_snapshot_preview1") - Field name: what to import (
"memory","print") - Kind: function, table, memory, or global
- Type: the signature (for functions)
This is more structured than ELF. In ELF, a symbol is just a name. In WASM, imports are organized by module and have explicit types.
Function Section (ID: 3)
Maps functions to their types. Just indices:
Function 0 → Type 0
Function 1 → Type 1
Function 2 → Type 0
The actual code comes later (in the Code section). This separation enables streaming compilation—you can start compiling functions before the whole module downloads.
Table Section (ID: 4)
Tables are for indirect calls (function pointers). Think of a table as an array of function references:
(table $t 10 funcref)
This declares a table of 10 function references. Indirect calls use table indices instead of direct addresses.
Memory Section (ID: 5)
Linear memory declaration:
(memory $m 1 10) ; 1 page minimum, 10 pages maximum
One page = 64KB. Memory is bounds-checked—no buffer overflows into arbitrary memory.
Global Section (ID: 6)
Global variables:
(global $stack_pointer (mut i32) (i32.const 1048576))
(global $heap_base i32 (i32.const 2097152))
Globals can be mutable or immutable. They have explicit types.
Export Section (ID: 7)
What the module exposes—the WASM equivalent of defined global symbols:
(export "add" (func $add))
(export "memory" (memory $m))
(export "_start" (func $_start))
Exports have names and point to internal indices. A function, table, memory, or global can be exported.
Start Section (ID: 8)
Optional. Specifies a function to call on instantiation (like a constructor).
Element Section (ID: 9)
Initializes tables with function references:
(elem (i32.const 0) $func_a $func_b $func_c)
This populates table indices 0, 1, 2 with the specified functions.
Code Section (ID: 10)
The actual function bodies—the machine code equivalent:
(func $add (param $a i32) (param $b i32) (result i32)
local.get $a
local.get $b
i32.add
)
This is where the bytes live. Each function entry has:
- Local variable declarations
- The instruction sequence
Data Section (ID: 11)
Initializes linear memory:
(data (i32.const 1024) "Hello, World!\00")
String literals, initialized arrays, and constant data go here.
Custom Sections (ID: 0)
Extension mechanism. The name section (.debug_info equivalent) is a custom section:
Custom section "name"
- Function names
- Local variable names
- Module name
Other custom sections include:
linking— relocation info for object filesreloc.*— relocation entriesproducers— toolchain metadata
WASM Object Files vs Final Modules
Here’s a key distinction: a WASM object file (.o) is different from a final WASM module (.wasm).
The difference is like ELF .o vs ELF executable:
| Property | WASM Object File | Final WASM Module |
|---|---|---|
| Relocations | Yes (in custom sections) | No |
| Undefined symbols | Yes | No (all imports resolved) |
| Multiple data segments | Yes | Merged |
| Linking metadata | Yes (linking section) | Stripped |
| Can run directly | No | Yes |
When you compile C to WASM:
clang --target=wasm32 -c file.c -o file.o
You get a WASM object file. The wasm-ld linker combines object files into a final module:
wasm-ld file.o other.o -o output.wasm
The Linking Section
WASM object files contain a custom linking section with:
Symbol Table
Symbol 0: "add" (function, defined, index 3)
Symbol 1: "printf" (function, undefined)
Symbol 2: "global_var" (data, defined, offset 1024)
Symbol 3: "external_data" (data, undefined)
Like ELF symbols: names, types, definitions vs undefined.
Segment Info
Data segments with names and alignment:
Segment 0: ".rodata.str" (alignment 1, flags STRINGS)
Segment 1: ".data" (alignment 4, flags 0)
Segment 2: ".bss" (alignment 8, flags BSS)
Init Functions
Functions to call at initialization:
Init function 0: priority 100, symbol "init_globals"
Init function 1: priority 200, symbol "init_runtime"
WASM Relocations
Object files have relocation sections (reloc.CODE, reloc.DATA):
Relocation 0: type R_WASM_FUNCTION_INDEX_LEB, offset 0x15, symbol "printf"
Relocation 1: type R_WASM_MEMORY_ADDR_LEB, offset 0x23, symbol "message"
Types of WASM relocations:
| Type | Meaning |
|---|---|
R_WASM_FUNCTION_INDEX_LEB | Reference to function index |
R_WASM_TABLE_INDEX_SLEB | Reference to table index |
R_WASM_MEMORY_ADDR_LEB | Memory address (in data section) |
R_WASM_GLOBAL_INDEX_LEB | Reference to global index |
R_WASM_TYPE_INDEX_LEB | Reference to type index |
Note the _LEB suffix. WASM uses LEB128 encoding for integers, so relocations must patch LEB128-encoded values.
Inspecting WASM Files
The WebAssembly Binary Toolkit (WABT) is essential:
# Convert binary to text format
wasm2wat module.wasm -o module.wat
# Inspect structure
wasm-objdump -x module.wasm # All sections
wasm-objdump -h module.wasm # Section headers
wasm-objdump -j Import module.wasm # Just imports
wasm-objdump -j Export module.wasm # Just exports
# For object files specifically
wasm-objdump -r module.o # Relocations
wasm-objdump -t module.o # Symbol table
Let’s see a real example:
$ wasm-objdump -x simple.wasm
simple.wasm: file format wasm 0x1
Section Details:
Type[2]:
- type[0] () -> i32
- type[1] (i32, i32) -> i32
Import[1]:
- func[0] sig=0 <env.print> <- env.print
Function[2]:
- func[1] sig=1 <add>
- func[2] sig=0 <main>
Export[2]:
- func[1] <add> -> "add"
- func[2] <main> -> "main"
Code[2]:
- func[1] size=7 <add>
- func[2] size=15 <main>
ELF vs WASM: First Impressions
| Aspect | ELF | WASM |
|---|---|---|
| Structure | Sections + Segments | Sections only |
| Symbol names | Arbitrary strings | Module.field pairs |
| Type information | Optional (debug info) | Required (type section) |
| Memory model | Flat address space | Sandboxed linear memory |
| Relocations | Machine-specific | Platform-independent |
| Entry point | Address in header | Optional start function |
WASM is safer by design:
- Type-checked at load time
- Memory bounds-checked
- No arbitrary pointers
- Capabilities-based (imports are explicit)
But also more constrained:
- No raw pointers
- No inline assembly
- Limited host interaction
A Practical Example
Let’s trace compilation from C to WASM object file to final module:
// math.c
int add(int a, int b) {
return a + b;
}
Compile to object file:
$ clang --target=wasm32 -c math.c -o math.o
Inspect the object file:
$ wasm-objdump -t math.o
math.o: file format wasm 0x1
SYMBOL TABLE:
- F d <add> func=0
One symbol: add, a defined function (d).
Now with an undefined reference:
// main.c
extern int add(int, int);
int main() {
return add(2, 3);
}
$ clang --target=wasm32 -c main.c -o main.o
$ wasm-objdump -t main.o
SYMBOL TABLE:
- F d <__main_void> func=0
- F U <add>
Two symbols: __main_void (defined) and add (undefined, U).
Link them:
$ wasm-ld math.o main.o --no-entry --export-all -o combined.wasm
$ wasm-objdump -x combined.wasm
Export[2]:
- func[0] <__main_void> -> "__main_void"
- func[1] <add> -> "add"
No more undefined symbols. The linker resolved add from math.o.
Key Takeaways
- WASM modules have sections, like ELF, but simpler—no segments for loading
- Imports and exports are explicit with module.field naming and types
- Type information is mandatory, enabling ahead-of-time validation
- Object files use custom sections for linking metadata and relocations
- WASM’s sandboxed model means no raw pointers or arbitrary memory access
The Foundation Is Set
We’ve now seen two binary formats: ELF, the veteran of native computing, and WASM, the sandboxed newcomer. Both have sections. Both have something like symbols. Both need to connect code that references things to code that defines things.
But we’ve been hand-waving about the details. When we say “symbol table,” what’s actually in it? When we say a symbol is “undefined,” where is that recorded and how? When the linker “resolves” a symbol, what data structures is it manipulating?
Part II of this book digs into those data structures. We’ll start with symbol tables—the registries that make linking possible. You’ll see the exact bytes that encode a symbol’s name, type, binding, and location. You’ll understand why C++ symbols look like _ZN4math3addEii and how to decode them. You’ll learn why ELF needs two symbol tables and what happens to each when you strip a binary.
This is where we transition from “what does it look like” to “how does it actually work.”
Symbol Tables Demystified
Every programmer has seen a symbol table error. “Undefined reference to foo.” “Multiple definition of bar.” “Symbol not found: baz.” These messages come from the linker, and they’re telling you that something went wrong in the symbol table—the data structure at the heart of linking.
But what is a symbol table? Not conceptually—we covered that in Chapter 1. What is it physically? What bytes are in the file? What does the linker actually read when it resolves your function calls?
This chapter answers those questions. We’ll look at the raw structure of symbol table entries, see how names are stored, and understand the flags that control visibility and binding. By the end, you’ll be able to read readelf -s output like a native speaker, and debug symbol problems by understanding exactly what went wrong.
We’ve mentioned symbol tables throughout this book. Now let’s crack them open and see exactly how they work.
The Symbol Table: A Database of Names
A symbol table is a structured list of symbols. Each entry contains:
- Name (or pointer to name string)
- Value (address/offset)
- Size (bytes)
- Type (function, object, etc.)
- Binding (local, global, weak)
- Section (where it lives)
- Visibility (default, hidden, protected)
Let’s look at the raw structure.
ELF Symbol Table Structure
In ELF, a symbol entry (64-bit) looks like:
typedef struct {
Elf64_Word st_name; // Offset into string table
unsigned char st_info; // Type and binding
unsigned char st_other; // Visibility
Elf64_Half st_shndx; // Section index
Elf64_Addr st_value; // Symbol value (address)
Elf64_Xword st_size; // Size of symbol
} Elf64_Sym;
That’s 24 bytes per symbol. Let’s decode each field.
st_name: The Name
This isn’t the actual string—it’s an offset into the string table (.strtab). Why?
Strings have variable length. Fixed-size symbol entries enable random access. You can jump directly to symbol N without scanning through N-1 variable-length strings.
String table (.strtab):
Offset 0: '\0' (null string)
Offset 1: 'add\0' (offset 1)
Offset 5: 'multiply\0' (offset 5)
Offset 14: 'main\0' (offset 14)
Symbol 3: st_name = 5 → "multiply"
st_info: Type and Binding Combined
One byte, split in two:
- Low 4 bits: type
- High 4 bits: binding
#define ELF64_ST_TYPE(i) ((i)&0xf)
#define ELF64_ST_BIND(i) ((i)>>4)
Type values:
| Value | Name | Meaning |
|---|---|---|
| 0 | STT_NOTYPE | Unspecified |
| 1 | STT_OBJECT | Data object (variable) |
| 2 | STT_FUNC | Function/executable code |
| 3 | STT_SECTION | Section symbol |
| 4 | STT_FILE | Source file name |
| 10 | STT_GNU_IFUNC | Indirect function (resolver) |
Binding values:
| Value | Name | Meaning |
|---|---|---|
| 0 | STB_LOCAL | Visible only in this file |
| 1 | STB_GLOBAL | Visible everywhere |
| 2 | STB_WEAK | Lower priority global |
st_other: Visibility
Controls symbol visibility beyond binding:
| Value | Name | Meaning |
|---|---|---|
| 0 | STV_DEFAULT | Normal rules apply |
| 1 | STV_INTERNAL | Hidden + cannot be interposed |
| 2 | STV_HIDDEN | Not visible outside shared object |
| 3 | STV_PROTECTED | Visible but not preemptible |
The difference between hidden and protected:
- Hidden: Other shared libraries can’t see this symbol at all
- Protected: Other libraries see it, but calls within this library always use this definition (no interposition)
st_shndx: Section Index
Which section contains this symbol?
| Value | Meaning |
|---|---|
0 (SHN_UNDEF) | Undefined (external reference) |
| 1-0xfeff | Section index |
0xfff1 (SHN_ABS) | Absolute value, not relocatable |
0xfff2 (SHN_COMMON) | COMMON symbol (allocated by linker) |
For defined symbols, this points to .text, .data, etc. For undefined symbols, it’s SHN_UNDEF.
st_value: The Address
For defined symbols: the symbol’s address (or offset in object files).
For undefined symbols: typically 0, but for COMMON symbols, it holds alignment requirements.
For section symbols: 0 (the section’s start address).
st_size: Symbol Size
How many bytes this symbol occupies. For functions, it’s the code size. For variables, it’s the variable size.
A size of 0 means unknown or not applicable.
Reading a Symbol Table
Let’s decode a real example:
$ readelf -s math.o
Symbol table '.symtab' contains 12 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS math.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text
3: 0000000000000000 0 SECTION LOCAL DEFAULT 2 .data
4: 0000000000000000 0 SECTION LOCAL DEFAULT 3 .bss
5: 0000000000000000 0 NOTYPE LOCAL DEFAULT 1 $x
6: 0000000000000000 0 SECTION LOCAL DEFAULT 5 .note.GNU-stack
7: 0000000000000014 0 NOTYPE LOCAL DEFAULT 6 $d
8: 0000000000000000 0 SECTION LOCAL DEFAULT 6 .eh_frame
9: 0000000000000000 0 SECTION LOCAL DEFAULT 4 .comment
10: 0000000000000000 32 FUNC GLOBAL DEFAULT 1 add
11: 0000000000000020 32 FUNC GLOBAL DEFAULT 1 multiply
Decoding each:
Symbol 0: Null symbol (required by ELF spec). Always present, always zero.
Symbol 1: Source file name. FILE type, ABS section (not in any section), name is the filename.
Symbol 2: Section symbol for .text. Used for relocations that reference the section itself.
Symbol 3: The add function. Offset 0 in .text, 4 bytes, global function.
Symbol 4: The multiply function. Offset 4 in .text, 6 bytes, global function.
Two Symbol Tables: .symtab vs .dynsym
ELF executables can have two symbol tables:
| Table | Purpose | Stripped? |
|---|---|---|
.symtab | All symbols (debugging, linking) | Yes, by strip |
.dynsym | Dynamic symbols only | No (needed at runtime) |
.symtab is comprehensive—every function, every variable, internal helpers. It’s used by debuggers and disassemblers.
.dynsym is minimal—only symbols needed for dynamic linking. It survives strip because the dynamic linker needs it at runtime.
$ nm /usr/bin/python3 | wc -l
0
$ nm -D /usr/bin/python3 | wc -l
2255
Symbol Hashing for Fast Lookup
Looking up a symbol by name in a linear list is O(n). For large symbol tables, ELF uses hash tables.
SysV Hash (.hash)
The original ELF hash format:
unsigned long elf_hash(const unsigned char *name) {
unsigned long h = 0, g;
while (*name) {
h = (h << 4) + *name++;
if ((g = h & 0xf0000000))
h ^= g >> 24;
h &= ~g;
}
return h;
}
The .hash section contains:
nbucket: Number of bucketsnchain: Number of symbolsbucket[nbucket]: Hash bucketschain[nchain]: Chains for collision resolution
GNU Hash (.gnu.hash)
Modern replacement, faster for typical lookups:
- Bloom filter for fast “definitely not here” checks
- Sorted symbols for binary search within buckets
- Symbols grouped: locals first (not hashed), then globals
Most modern Linux binaries use .gnu.hash.
WASM Symbol Tables
WASM’s symbol table lives in a custom linking section. The structure is different:
WASM Symbol Entry:
- kind: SYMTAB_FUNCTION, SYMTAB_DATA, SYMTAB_GLOBAL, SYMTAB_SECTION, SYMTAB_EVENT, SYMTAB_TABLE
- flags: WASM_SYM_BINDING_WEAK, WASM_SYM_BINDING_LOCAL, WASM_SYM_VISIBILITY_HIDDEN, WASM_SYM_UNDEFINED, etc.
- For functions: index into function space
- For data: segment index + offset
- name: direct string (not offset)
Key differences from ELF:
- Names are inline, not in a separate string table
- Typed by section kind: function symbols vs data symbols are distinct
- Index-based references: symbols reference indices, not addresses
- Simpler binding model: just local, global, and weak flags
$ wasm-objdump -t math.o
SYMBOL TABLE:
- F d <add> func=0
- F d <multiply> func=1
F means function, d means defined.
Symbol Resolution Rules
When the linker encounters symbols, it follows resolution rules:
Rule 1: Exactly One Strong Definition
A symbol with global binding should be defined exactly once (across all input files). Multiple definitions = error.
$ gcc -c file1.c -o file1.o # defines 'foo'
$ gcc -c file2.c -o file2.o # also defines 'foo'
$ gcc file1.o file2.o -o out
/usr/bin/ld: file2.o:(.data+0x0): multiple definition of `foo'; file1.o:(.data+0x0): first defined here
/usr/bin/ld: /lib/aarch64-linux-gnu/crt1.o: in function `__wrap_main':
(.text+0x38): undefined reference to `main'
collect2: error: ld returned 1 exit status
Rule 2: Weak vs Strong
If both weak and strong definitions exist, strong wins:
// libc provides (weak):
__attribute__((weak)) void malloc_hook(void) { }
// Your program provides (strong):
void malloc_hook(void) { custom_implementation(); }
The linker picks your strong definition.
Rule 3: Weak + Weak
If only weak definitions exist, the linker picks one (unspecified which). This is how default implementations work.
Rule 4: Undefined + Defined
Undefined symbols must be resolved to a definition. No definition = linker error:
undefined reference to 'missing_function'
Rule 5: COMMON Symbols
Uninitialized globals in C (without extern) can be COMMON:
int foo; // Might be COMMON (compiler-dependent)
COMMON symbols:
- Can appear in multiple files
- Linker allocates storage once, using the largest size
- Considered weak for resolution purposes
Modern best practice: avoid COMMON (use -fno-common).
Visibility in Practice
Visibility controls what escapes a shared library:
// Default: exported from shared library
__attribute__((visibility("default")))
void public_api(void);
// Hidden: internal to shared library
__attribute__((visibility("hidden")))
void internal_helper(void);
Or use compiler flags:
# Everything hidden by default
gcc -fvisibility=hidden -c file.c
# Then explicitly export
__attribute__((visibility("default")))
void exported_function(void);
This reduces symbol table size and enables better optimization (the compiler knows internal functions can’t be interposed).
Name Mangling
C++ (and Rust) mangle names to encode type information:
int add(int, int); // _Z3addii
double add(double, double); // _Z3adddd
The symbol table contains mangled names. Tools can demangle:
$ nm math.o | c++filt
0000000000000000 T add(int, int)
0000000000000014 T add(double, double)
Mangling schemes vary by compiler. The Itanium C++ ABI (used by GCC and Clang) is most common.
JavaScript Analogy
Think of a symbol table like a module’s exports and imports registry:
// Symbol table for this module:
// DEFINED (exported):
// - calculateTax (function, global)
// - TAX_RATE (object, global)
// UNDEFINED (imported):
// - formatCurrency (from 'utils')
// - Logger (from 'logging')
export const TAX_RATE = 0.08;
export function calculateTax(amount) {
return formatCurrency(amount * TAX_RATE);
}
The bundler (linker) resolves formatCurrency to its definition in utils.js.
Debugging Symbol Issues
Common symbol-related errors:
Undefined Reference
undefined reference to 'foo'
Meaning: foo is used but never defined. Check:
- Is the object file containing
foobeing linked? - Is
fooactually defined (not just declared)? - C++ name mangling issues? Try
extern "C".
Multiple Definition
multiple definition of 'bar'
Meaning: bar is defined more than once. Check:
- Header-only functions should be
static inline - Did you define a variable in a header (should be
extern+ one definition) - Link order issues?
Symbol Not Found (Dynamic)
symbol lookup error: undefined symbol: baz
Meaning: runtime linking failed. Check:
- Is the library providing
bazloaded? (lddto check) - Symbol visibility—is it exported?
- Library version—was it compiled with a different ABI?
Tools for Symbol Investigation
# List symbols
nm file.o
nm -D file.so # Dynamic symbols only
nm -C file.o # Demangle C++ names
# Detailed symbol info
readelf -s file.o # ELF symbol table
readelf --dyn-syms file.so # Dynamic symbols
# Find symbol in libraries
nm -A /lib/*.so | grep 'T symbol_name'
# Check if symbol is exported
objdump -T file.so | grep symbol_name
Key Takeaways
- Symbol tables map names to addresses (or to “undefined”)
- ELF has two tables:
.symtab(full, strippable) and.dynsym(minimal, required) - Binding (local/global/weak) controls resolution priority
- Visibility controls export from shared libraries
- Hash tables enable fast symbol lookup
- WASM symbol tables are simpler, type-aware, and live in custom sections
The Missing Piece
Symbol tables tell us what exists and where it is. But there’s a problem we haven’t addressed: how does code actually use that information?
When the compiler generates machine code for call add, it doesn’t know where add will be. That address is determined later, by the linker. So the compiler emits a placeholder—often just zeros—and leaves a note saying “hey, linker, please fill this in with the address of add.”
That note is called a relocation. Relocations are the instructions that tell the linker how to patch object files into working executables. They’re the glue that binds symbol tables to actual code.
In the next chapter, we’ll explore relocations in detail. You’ll see the different types (PC-relative, absolute, GOT-relative), understand the calculations involved, and learn why position-independent code needs different relocations than fixed-address code. If symbol tables are the directory, relocations are the wiring diagram.
Relocations: The Glue That Binds
Imagine you’re assembling IKEA furniture, but the instruction manual has blank spaces where measurements should be. “Insert screw at position ____.” “Align panel ____ centimeters from edge.” The actual numbers will be filled in later, once you measure your specific room.
That’s what object files are like. The compiler generates code with blank spaces—placeholders where addresses should go. The linker fills in those blanks later, once it knows where everything will actually live in memory.
Those blank spaces, and the instructions for filling them, are called relocations. They’re arguably the most important concept in linking, because they’re what makes separate compilation possible. Without relocations, every piece of code would need to know the final address of every function and variable at compile time. With relocations, code can reference things by name and trust that the addresses will be filled in later.
When the compiler generates code, it doesn’t know the final addresses of functions and data. It can’t—the final layout depends on:
- What other object files get linked
- Library load addresses (unknown until runtime for shared libs)
- Kernel decisions about memory layout (ASLR)
So the compiler leaves placeholders. Relocations are the instructions for filling those placeholders in.
The Problem
Consider this code:
extern int global_counter;
extern void helper_function(void);
void my_function(void) {
global_counter++;
helper_function();
}
The compiler generates assembly that needs to:
- Load
global_counterfrom memory - Call
helper_function
But what addresses should it use? It has no idea where these will end up.
The Placeholder Solution
The compiler emits temporary values (often 0) and creates relocation entries that say:
“At offset X in section Y, there’s a placeholder. Replace it with the address of symbol Z, using calculation method W.”
When the linker runs, it:
- Decides final addresses for everything
- Walks through all relocations
- Patches each placeholder with the correct value
ELF Relocation Structure
A relocation entry (64-bit) looks like:
typedef struct {
Elf64_Addr r_offset; // Where to patch
Elf64_Xword r_info; // Symbol index + relocation type
Elf64_Sxword r_addend; // Constant to add
} Elf64_Rela;
r_offset
The location to patch. In object files, it’s an offset within the section. In executables, it’s a virtual address.
r_info
Two pieces packed together:
- High 32 bits: symbol index (which symbol provides the address)
- Low 32 bits: relocation type (how to calculate the patch)
#define ELF64_R_SYM(i) ((i) >> 32)
#define ELF64_R_TYPE(i) ((i) & 0xffffffff)
r_addend
A constant to add to the symbol’s address. Useful for accessing struct members or array elements:
extern struct { int a; int b; } data;
int x = data.b; // Need address of data + offset to b
Relocation Types (x86-64)
There are dozens of relocation types. Here are the most common on x86-64:
R_X86_64_64 — Absolute 64-bit
S + A
Where S = symbol value, A = addend.
Used for: absolute addresses in data sections (pointers).
void (*fptr)(void) = &my_function; // Needs absolute address
R_X86_64_PC32 — PC-Relative 32-bit
S + A - P
Where P = place being patched.
Used for: relative calls and jumps.
call some_function ; Offset to function, relative to next instruction
This is position-independent—the code works regardless of where it’s loaded.
R_X86_64_PLT32 — PLT Call
L + A - P
Where L = PLT entry address.
Used for: calls to functions that might be in shared libraries.
printf("hello"); // Goes through PLT for dynamic linking
R_X86_64_GOT32 — GOT Offset
G + A
Where G = offset in Global Offset Table.
Used for: loading addresses from GOT.
R_X86_64_GOTPCREL — GOT Entry, PC-Relative
G + GOT + A - P
Used for: accessing globals through the GOT.
extern int some_global;
int x = some_global; // Load from GOT entry
Seeing Relocations
$ readelf -r main.o
Relocation section '.rela.text' at offset 0x218 contains 2 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000000010 000b0000011b R_AARCH64_CALL26 0000000000000000 add + 0
000000000020 000c0000011b R_AARCH64_CALL26 0000000000000000 multiply + 0
Relocation section '.rela.eh_frame' at offset 0x248 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
00000000001c 000200000105 R_AARCH64_PREL32 0000000000000000 .text + 0
Two relocations:
- At offset 0x11 in
.text, patch with a PLT call toadd - At offset 0x20 in
.text, patch with a PLT call tomultiply
The -4 addend accounts for the call instruction encoding on x86.
Let’s see the code before linking:
$ objdump -d main.o
main.o: file format elf64-littleaarch64
Disassembly of section .text:
0000000000000000 <main>:
0: a9be7bfd stp x29, x30, [sp, #-32]!
4: 910003fd mov x29, sp
8: 52800061 mov w1, #0x3 // #3
c: 52800040 mov w0, #0x2 // #2
10: 94000000 bl 0 <add>
14: b9001fe0 str w0, [sp, #28]
18: 52800081 mov w1, #0x4 // #4
1c: b9401fe0 ldr w0, [sp, #28]
20: 94000000 bl 0 <multiply>
24: b9001fe0 str w0, [sp, #28]
28: b9401fe0 ldr w0, [sp, #28]
2c: a8c27bfd ldp x29, x30, [sp], #32
30: d65f03c0 ret
That e8 00 00 00 00 is a call instruction with address 0. The relocation fills it in.
After linking:
$ objdump -d program
program: file format elf64-littleaarch64
Disassembly of section .init:
0000000000400458 <_init>:
400458: d503233f paciasp
40045c: a9bf7bfd stp x29, x30, [sp, #-16]!
400460: 910003fd mov x29, sp
400464: 94000039 bl 400548 <call_weak_fn>
400468: a8c17bfd ldp x29, x30, [sp], #16
40046c: d50323bf autiasp
400470: d65f03c0 ret
Disassembly of section .plt:
0000000000400480 <.plt>:
400480: a9bf7bf0 stp x16, x30, [sp, #-16]!
400484: f00000f0 adrp x16, 41f000 <__abi_tag+0x1e824>
400488: f947fe11 ldr x17, [x16, #4088]
40048c: 913fe210 add x16, x16, #0xff8
400490: d61f0220 br x17
400494: d503201f nop
400498: d503201f nop
40049c: d503201f nop
00000000004004a0 <__libc_start_main@plt>:
4004a0: 90000110 adrp x16, 420000 <__libc_start_main@GLIBC_2.34>
4004a4: f9400211 ldr x17, [x16]
4004a8: 91000210 add x16, x16, #0x0
4004ac: d61f0220 br x17
00000000004004b0 <__gmon_start__@plt>:
4004b0: 90000110 adrp x16, 420000 <__libc_start_main@GLIBC_2.34>
4004b4: f9400611 ldr x17, [x16, #8]
4004b8: 91002210 add x16, x16, #0x8
4004bc: d61f0220 br x17
00000000004004c0 <abort@plt>:
4004c0: 90000110 adrp x16, 420000 <__libc_start_main@GLIBC_2.34>
4004c4: f9400a11 ldr x17, [x16, #16]
4004c8: 91004210 add x16, x16, #0x10
4004cc: d61f0220 br x17
Disassembly of section .text:
0000000000400500 <_start>:
400500: d503245f bti c
400504: d280001d mov x29, #0x0 // #0
400508: d280001e mov x30, #0x0 // #0
40050c: aa0003e5 mov x5, x0
400510: f94003e1 ldr x1, [sp]
400514: 910023e2 add x2, sp, #0x8
400518: 910003e6 mov x6, sp
40051c: 90000000 adrp x0, 400000 <_init-0x458>
400520: 9114d000 add x0, x0, #0x534
400524: d2800003 mov x3, #0x0 // #0
400528: d2800004 mov x4, #0x0 // #0
40052c: 97ffffdd bl 4004a0 <__libc_start_main@plt>
400530: 97ffffe4 bl 4004c0 <abort@plt>
0000000000400534 <__wrap_main>:
400534: d503245f bti c
400538: 14000033 b 400604 <main>
40053c: d503201f nop
0000000000400540 <_dl_relocate_static_pie>:
400540: d503245f bti c
400544: d65f03c0 ret
0000000000400548 <call_weak_fn>:
400548: f00000e0 adrp x0, 41f000 <__abi_tag+0x1e824>
40054c: f947ec00 ldr x0, [x0, #4056]
400550: b4000040 cbz x0, 400558 <call_weak_fn+0x10>
400554: 17ffffd7 b 4004b0 <__gmon_start__@plt>
400558: d65f03c0 ret
40055c: d503201f nop
0000000000400560 <deregister_tm_clones>:
400560: 90000100 adrp x0, 420000 <__libc_start_main@GLIBC_2.34>
400564: 9100a000 add x0, x0, #0x28
400568: 90000101 adrp x1, 420000 <__libc_start_main@GLIBC_2.34>
40056c: 9100a021 add x1, x1, #0x28
400570: eb00003f cmp x1, x0
400574: 540000c0 b.eq 40058c <deregister_tm_clones+0x2c> // b.none
400578: f00000e1 adrp x1, 41f000 <__abi_tag+0x1e824>
40057c: f947e821 ldr x1, [x1, #4048]
400580: b4000061 cbz x1, 40058c <deregister_tm_clones+0x2c>
400584: aa0103f0 mov x16, x1
400588: d61f0200 br x16
40058c: d65f03c0 ret
0000000000400590 <register_tm_clones>:
400590: 90000100 adrp x0, 420000 <__libc_start_main@GLIBC_2.34>
400594: 9100a000 add x0, x0, #0x28
400598: 90000101 adrp x1, 420000 <__libc_start_main@GLIBC_2.34>
40059c: 9100a021 add x1, x1, #0x28
4005a0: cb000021 sub x1, x1, x0
4005a4: d37ffc22 lsr x2, x1, #63
4005a8: 8b810c41 add x1, x2, x1, asr #3
4005ac: 9341fc21 asr x1, x1, #1
4005b0: b40000c1 cbz x1, 4005c8 <register_tm_clones+0x38>
4005b4: f00000e2 adrp x2, 41f000 <__abi_tag+0x1e824>
4005b8: f947f042 ldr x2, [x2, #4064]
4005bc: b4000062 cbz x2, 4005c8 <register_tm_clones+0x38>
4005c0: aa0203f0 mov x16, x2
4005c4: d61f0200 br x16
4005c8: d65f03c0 ret
00000000004005cc <__do_global_dtors_aux>:
4005cc: a9be7bfd stp x29, x30, [sp, #-32]!
4005d0: 910003fd mov x29, sp
4005d4: f9000bf3 str x19, [sp, #16]
4005d8: 90000113 adrp x19, 420000 <__libc_start_main@GLIBC_2.34>
4005dc: 3940a260 ldrb w0, [x19, #40]
4005e0: 37000080 tbnz w0, #0, 4005f0 <__do_global_dtors_aux+0x24>
4005e4: 97ffffdf bl 400560 <deregister_tm_clones>
4005e8: 52800020 mov w0, #0x1 // #1
4005ec: 3900a260 strb w0, [x19, #40]
4005f0: f9400bf3 ldr x19, [sp, #16]
4005f4: a8c27bfd ldp x29, x30, [sp], #32
4005f8: d65f03c0 ret
4005fc: d503201f nop
0000000000400600 <frame_dummy>:
400600: 17ffffe4 b 400590 <register_tm_clones>
0000000000400604 <main>:
400604: a9be7bfd stp x29, x30, [sp, #-32]!
400608: 910003fd mov x29, sp
40060c: 52800061 mov w1, #0x3 // #3
400610: 52800040 mov w0, #0x2 // #2
400614: 94000009 bl 400638 <add>
400618: b9001fe0 str w0, [sp, #28]
40061c: 52800081 mov w1, #0x4 // #4
400620: b9401fe0 ldr w0, [sp, #28]
400624: 9400000d bl 400658 <multiply>
400628: b9001fe0 str w0, [sp, #28]
40062c: b9401fe0 ldr w0, [sp, #28]
400630: a8c27bfd ldp x29, x30, [sp], #32
400634: d65f03c0 ret
0000000000400638 <add>:
400638: d10043ff sub sp, sp, #0x10
40063c: b9000fe0 str w0, [sp, #12]
400640: b9000be1 str w1, [sp, #8]
400644: b9400fe1 ldr w1, [sp, #12]
400648: b9400be0 ldr w0, [sp, #8]
40064c: 0b000020 add w0, w1, w0
400650: 910043ff add sp, sp, #0x10
400654: d65f03c0 ret
0000000000400658 <multiply>:
400658: d10043ff sub sp, sp, #0x10
40065c: b9000fe0 str w0, [sp, #12]
400660: b9000be1 str w1, [sp, #8]
400664: b9400fe1 ldr w1, [sp, #12]
400668: b9400be0 ldr w0, [sp, #8]
40066c: 1b007c20 mul w0, w1, w0
400670: 910043ff add sp, sp, #0x10
400674: d65f03c0 ret
Disassembly of section .fini:
0000000000400678 <_fini>:
400678: d503233f paciasp
40067c: a9bf7bfd stp x29, x30, [sp, #-16]!
400680: 910003fd mov x29, sp
400684: a8c17bfd ldp x29, x30, [sp], #16
400688: d50323bf autiasp
40068c: d65f03c0 ret
The placeholder became a real offset to the add function.
GOT and PLT: Dynamic Linking Machinery
When linking against shared libraries, we can’t know final addresses at link time. The library might load at different addresses each run (ASLR). Two structures enable this:
Global Offset Table (GOT)
The GOT is a table of addresses, filled at runtime. Code accesses external globals through the GOT:
# Without GOT (can't work with shared libraries):
mov (0x601030), %eax # Absolute address, breaks if library moves
# With GOT (position-independent):
mov GOT(%rip), %rax # Load GOT address (PC-relative, works anywhere)
mov some_global@GOTPCREL(%rip), %rax # Load from GOT entry
The GOT entry initially contains a placeholder. The dynamic linker patches it with the real address when the library loads.
Procedure Linkage Table (PLT)
The PLT enables lazy function binding. Instead of resolving all functions at load time, each function is resolved on first call.
A PLT entry looks like:
printf@plt:
jmp *printf@GOTPLT(%rip) # Jump through GOT
push $index # If GOT not filled, push index
jmp resolver # Jump to dynamic linker
First call:
- Jump through GOT → GOT has address of “push; jmp resolver”
- Dynamic linker resolves
printf - GOT entry updated to point to real
printf
Subsequent calls:
- Jump through GOT → goes directly to
printf
This is lazy binding: symbols resolved on demand, not at load time.
Position-Independent Code (PIC)
Shared libraries must be position-independent—they work regardless of load address. This requires:
- No absolute addresses in code
- All data accessed through GOT
- All calls through PLT (or direct for internal calls)
# Compile for shared library
gcc -fPIC -shared lib.c -o lib.so
-fPIC makes the compiler generate position-independent code.
WASM Relocations
WASM relocations are simpler because WASM uses indices, not addresses:
$ wasm-objdump -r main.o
RELOCATION RECORDS FOR [CODE]:
- offset: 0x15, type: R_WASM_FUNCTION_INDEX_LEB, index: 1 <add>
- offset: 0x23, type: R_WASM_FUNCTION_INDEX_LEB, index: 2 <multiply>
Common WASM relocation types:
| Type | Meaning |
|---|---|
R_WASM_FUNCTION_INDEX_LEB | Function index (for calls) |
R_WASM_TABLE_INDEX_SLEB | Table index (for indirect calls) |
R_WASM_TABLE_INDEX_I32 | Same, but 32-bit |
R_WASM_MEMORY_ADDR_LEB | Memory address |
R_WASM_MEMORY_ADDR_SLEB | Signed memory address |
R_WASM_MEMORY_ADDR_I32 | 32-bit memory address |
R_WASM_TYPE_INDEX_LEB | Type index |
R_WASM_GLOBAL_INDEX_LEB | Global index |
The _LEB suffix means the relocation patches LEB128-encoded integers (WASM’s variable-length encoding).
WASM’s Simpler Model
WASM doesn’t need:
- GOT (no address space sharing between modules)
- PLT (imports are explicit, resolved at instantiation)
- PC-relative addressing (WASM uses structured control flow)
A WASM call instruction directly encodes the function index. The linker just needs to renumber indices when combining modules.
Relocation Sections
In ELF, relocations live in sections named .rel.X or .rela.X, where X is the section they apply to:
| Section | Relocations for |
|---|---|
.rela.text | Code |
.rela.data | Initialized data |
.rela.dyn | Dynamic relocations |
.rela.plt | PLT entries |
.rel uses implicit addends (stored in the location being patched). .rela uses explicit addends (in the relocation entry). Modern x86-64 uses .rela exclusively.
When Relocations Happen
Link-Time Relocations
The linker processes these when creating the executable:
// Object file has relocation to 'add'
// Linker resolves: add is at 0x1144
// Linker patches call instruction with offset to 0x1144
Result: executable has no relocations for these symbols.
Load-Time Relocations
The dynamic linker processes these when loading shared libraries:
// Executable has relocation for 'printf' (in libc)
// Program starts, dynamic linker loads libc
// Dynamic linker patches GOT entry with printf's address
These are .rela.dyn and .rela.plt relocations.
RELRO: Relocation Read-Only
Security hardening:
- Partial RELRO: GOT is writable (for lazy binding)
- Full RELRO: GOT patched at load time, then marked read-only
# Full RELRO (more secure, slower startup)
gcc -Wl,-z,relro,-z,now main.c -o main
# Check RELRO status
checksec --file=main
Full RELRO prevents GOT overwrite attacks.
Relocation Overflow
Relocations have limited range. R_X86_64_PC32 can only reach ±2GB from the current location. If a relocation target is too far:
relocation R_X86_64_PC32 against 'symbol' can not be used when making a PIE object; recompile with -fPIC
Solutions:
- Use
-fPIC(64-bit addressing through GOT) - Use
-mcmodel=large(all addresses 64-bit) - Place related code closer together
A Web Developer’s Analogy
Relocations are like import bindings in ES6 modules:
// Before bundling:
import { add } from './math.js';
console.log(add(2, 3));
// After bundling (conceptually):
// The bundler resolves 'add' to its actual location
// The reference is patched to point there
The bundler does the same thing a linker does:
- Collect all modules
- Find symbol definitions
- Patch references to point to definitions
Debugging Relocation Issues
Common problems:
Relocation Truncated
relocation truncated to fit: R_X86_64_PC32 against undefined symbol 'foo'
The offset doesn’t fit in 32 bits. Use -fPIC or -mcmodel=medium.
Relocation Against Non-Existent Symbol
undefined reference to 'bar'
The symbol doesn’t exist. Check your link inputs.
Text Relocations
warning: creating DT_TEXTREL in a PIE
A relocation wants to patch code (.text). This defeats code sharing and W^X security. Use -fPIC to avoid.
Examining Relocations
# Show all relocations
readelf -r file.o
# Show dynamic relocations (executables)
readelf -r executable | grep -E '\.rela\.(dyn|plt)'
# See what GOT entries exist
objdump -R executable
# Watch relocations happen (Linux)
LD_DEBUG=reloc ./executable
Key Takeaways
- Relocations are instructions for patching placeholders with addresses
- Link-time relocations are resolved by the static linker
- Load-time relocations are resolved by the dynamic linker
- GOT and PLT enable position-independent access to external symbols
- WASM relocations are simpler—indices instead of addresses
- -fPIC is essential for shared libraries
Pieces on the Board
We now have all the pieces: object files containing code and data, symbol tables mapping names to locations, and relocations describing how to patch everything together. The stage is set.
Part III is about linking—the process that takes those pieces and assembles them into something you can run. We’ll explore three variations of increasing complexity: static linking (simplest), dynamic linking (most common), and runtime linking (most powerful).
Static linking is the straightforward case. You have object files. You want an executable. The linker reads them all, resolves symbols, applies relocations, and writes out one self-contained binary. No runtime dependencies. No version conflicts. Just a file that runs.
In the next chapter, we’ll walk through static linking step by step. You’ll see exactly how the linker decides where to put each section, how it resolves symbols across multiple files, and how dead code elimination keeps your binaries lean. If you’ve ever wondered why link order matters, or why your binary is bigger than you expected, this chapter has answers.
Static Linking
There’s something satisfying about a statically linked binary. One file. No dependencies. Copy it to any machine with the right architecture, and it runs. No “library not found” errors. No version mismatches. No debugging why the production server has a different libc than your laptop.
For decades, this was the only kind of linking. You compiled your code, linked in the libraries you needed, and shipped a self-contained executable. The simplicity was the point.
Static linking fell out of favor as systems grew. When fifty programs all use the same C library, having fifty copies in memory seemed wasteful. Dynamic linking emerged to share code. But static linking never went away—it just became a choice rather than the default. Go statically links everything. Rust can. And when you need a Docker image that “just works,” static linking starts looking attractive again.
Let’s understand how it works.
Static linking is the simplest form of linking. Take some object files, combine them into one executable, resolve all symbols, apply all relocations. Done.
No runtime dependencies. No version mismatches. No “works on my machine.” Just one file that runs.
The Basic Process
# Compile to object files
gcc -c main.c -o main.o
gcc -c math.c -o math.o
gcc -c utils.c -o utils.o
# Link into executable
gcc main.o math.o utils.o -o program
Behind that gcc command, the linker (ld) does:
- Read all input object files
- Merge sections (all
.texttogether, all.datatogether, etc.) - Assign addresses to all sections
- Resolve symbols (match undefined to defined)
- Apply relocations (patch code with final addresses)
- Write the output executable
Let’s trace through each step.
Step 1: Reading Input Files
The linker reads each .o file and catalogs:
- All sections and their contents
- All symbols (with definitions and references)
- All relocations
At this point, it has a complete picture of what’s available and what’s needed.
Step 2: Section Merging
Each object file has its own .text, .data, .rodata, etc. The linker concatenates them:
main.o: math.o:
┌──────────────┐ ┌──────────────┐
│ .text (main) │ │ .text (add) │
│ 50 bytes │ │ .text (mult) │
├──────────────┤ │ 30 bytes │
│ .rodata │ └──────────────┘
│ "hello" │
└──────────────┘
Combined:
┌────────────────────┐
│ .text (merged) │
│ main: 0-50 │
│ add: 50-70 │
│ mult: 70-100 │
├────────────────────┤
│ .rodata (merged) │
│ "hello" │
└────────────────────┘
Sections of the same name merge together. Section flags must be compatible (you can’t merge writable and non-writable).
Step 3: Address Assignment
Now the linker decides where everything goes. Starting from the base address (often 0x400000 on Linux x86-64):
Virtual Address Layout:
0x400000: ELF headers
0x401000: .text (code)
0x402000: .rodata (constants)
0x403000: .data (initialized data)
0x404000: .bss (zero-initialized data)
Each symbol gets a final address:
main→ 0x401000add→ 0x401032multiply→ 0x401050message→ 0x402000
Step 4: Symbol Resolution
The linker builds a global symbol table. For each undefined symbol, it searches for a definition:
Undefined: add (in main.o)
Searching... found in math.o at offset 0
Result: add = 0x401032
Undefined: multiply (in main.o)
Searching... found in math.o at offset 32
Result: multiply = 0x401050
If a symbol is undefined everywhere: error. If a symbol is defined multiple times (and not weak): error.
Step 5: Relocation Processing
Now the linker walks through every relocation and patches the code:
Relocation in main.o:
Type: R_X86_64_PLT32
Offset: 0x15 in .text
Symbol: add
Addend: -4
Calculation:
P = place being patched = 0x401015 (main.o .text was placed at 0x401000)
S = symbol value = 0x401032 (add)
A = addend = -4
Result = S + A - P = 0x401032 + (-4) - 0x401015 = 0x19
Patch: write 0x19 at offset 0x15
The call instruction now has the correct relative offset.
Step 6: Output Generation
Finally, the linker writes the executable:
- ELF headers (magic, entry point, etc.)
- Program headers (segments for loading)
- Merged sections
- Section headers (optional, for debugging)
- Symbol table (if not stripped)
Linker Scripts
The linker script controls the layout. Default scripts work for most cases, but you can customize:
/* Custom linker script */
SECTIONS {
. = 0x10000; /* Start address */
.text : { *(.text) } /* All .text sections */
. = 0x20000;
.data : { *(.data) } /* All .data sections */
.bss : { *(.bss) } /* All .bss sections */
}
Embedded systems often need custom linker scripts for memory-mapped peripherals.
Static Libraries (.a files)
A static library is an archive of object files:
# Create library from objects
ar rcs libmath.a add.o multiply.o divide.o
# Link against library
gcc main.o -L. -lmath -o program
The linker treats .a files specially:
- Scan the archive’s table of contents
- Include only objects that define needed symbols
- Unused objects are completely excluded
This is selective linking—you don’t pay for what you don’t use.
Archive Member Selection
$ ar -t libc.a | wc -l
2021
$ nm hello | grep ' T ' | wc -l
5
The library has ~2000 object files, but only a handful of functions end up in your hello world. The rest are discarded.
Link Order Matters
With static libraries, order matters:
# This might fail:
gcc -lmath main.o -o program
# This works:
gcc main.o -lmath -o program
Why? The linker processes files left to right. When it sees -lmath first, it hasn’t seen main.o’s undefined symbols yet, so it doesn’t know it needs anything from the library.
Modern linkers have --start-group/--end-group for circular dependencies:
gcc main.o -Wl,--start-group -la -lb -Wl,--end-group -o program
This rescans the group until no new symbols are resolved.
WASM Static Linking
WASM linking works similarly but with indices instead of addresses:
# Compile WASM objects
clang --target=wasm32 -c main.c -o main.o
clang --target=wasm32 -c math.c -o math.o
# Link (using wasm-ld)
wasm-ld main.o math.o -o program.wasm --no-entry --export-all
The WASM linker:
- Merges function and data sections
- Renumbers all indices (functions, globals, tables)
- Merges type sections (deduplicating signatures)
- Combines memory segments
- Resolves symbol references
Index Renumbering
The tricky part of WASM linking. Each object file has its own function index space starting at 0:
main.o: math.o:
func 0: main func 0: add
func 1: helper func 1: multiply
Combined (after renumbering):
func 0: main (was 0 in main.o)
func 1: helper (was 1 in main.o)
func 2: add (was 0 in math.o)
func 3: multiply (was 1 in math.o)
Every call instruction referencing a renumbered function must be patched.
Dead Code Elimination
Good linkers remove unreachable code:
// utils.c
void used_function(void) { ... } // Called
void unused_function(void) { ... } // Never called
With -gc-sections:
gcc -ffunction-sections -fdata-sections main.c utils.c \
-Wl,--gc-sections -o program
Each function gets its own section. The linker builds a reachability graph from main, and anything unreachable is discarded.
This can dramatically reduce binary size. A library might define 1000 functions, but your program only uses 10—the other 990 are eliminated.
Link-Time Optimization (LTO)
With LTO, the linker sees the whole program and can optimize across compilation units:
gcc -flto main.c math.c -o program
The object files contain IR (intermediate representation) instead of machine code. The linker runs optimization passes on the combined IR before generating final code.
Benefits:
- Cross-module inlining
- Better dead code elimination
- Whole-program devirtualization
- More constant propagation
Cost: slower builds, larger compiler memory usage.
Static Linking Trade-offs
Advantages
- No runtime dependencies: The executable is self-contained
- Predictable behavior: No version conflicts
- Faster startup: No dynamic linking overhead
- Easier deployment: Copy one file
- Potential optimizations: LTO, dead code elimination
Disadvantages
- Larger binaries: Every executable includes its own copy of libraries
- No shared memory: Ten programs using libc = ten copies in memory
- No security updates: Must recompile to get library fixes
- Longer build times: More code to link
- License issues: Some licenses (LGPL) require dynamic linking
Static Linking in Practice
Go
Go statically links by default. A Go binary has no runtime dependencies (except system calls):
$ ldd hello-go
not a dynamic executable
$ file hello-go
hello-go: ELF 64-bit LSB executable, statically linked
This makes deployment trivial but binaries larger (~2MB minimum).
Rust
Rust can do either. Static is default for musl target:
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
C/C++
Usually dynamically linked to libc, but can be static:
gcc -static main.c -o main
WASM
Always statically linked (within a module). There’s no WASM dynamic linker in the traditional sense—imports are resolved at instantiation time.
The Web Developer Parallel
Static linking is like bundling everything into one file:
// Before bundling (many imports)
import { debounce } from 'lodash';
import React from 'react';
import { format } from 'date-fns';
// After bundling (one file)
// Everything needed is included in bundle.js
Webpack/Rollup/esbuild do static linking for JavaScript:
- Resolve imports
- Combine modules
- Tree-shake unused exports
- Emit one file
The trade-offs are similar: larger bundles but simpler deployment.
Key Takeaways
- Static linking combines everything into one executable
- The linker resolves all symbols and applies relocations
- Static libraries (.a) are archives with selective inclusion
- Link order matters for static libraries
- Dead code elimination removes unreachable code
- LTO enables cross-module optimization
- Trade-off: larger files but simpler deployment
The Cost of Simplicity
Static linking has elegance, but it has costs. Every program carries its own copy of every library it uses. Ten programs using OpenSSL mean ten copies of OpenSSL in memory. Security vulnerability in zlib? You need to rebuild and redeploy every affected binary.
In the 1980s and 1990s, as systems grew more complex and memory remained expensive, this became untenable. The Unix world developed an alternative: shared libraries that could be loaded once and mapped into every process that needed them. Memory saved. Updates applied once. The dream of modularity realized.
But shared libraries introduced new complexity. Code can’t use fixed addresses anymore—a library might load at different locations in different processes. Symbols need to be resolved at runtime, not just link time. New data structures (GOT, PLT) emerged to make this work. New errors appeared (“symbol not found,” “version GLIBC_2.17 not found”).
In the next chapter, we’ll explore dynamic linking—the runtime machinery that makes shared libraries possible. You’ll understand why position-independent code matters, how the dynamic linker resolves symbols, and what’s really happening when you see LD_LIBRARY_PATH in a tutorial. Static linking was the foundation; dynamic linking is how the modern world actually works.
Dynamic Linking & Shared Libraries
The year is 1988. Your Sun workstation has 4MB of RAM—a generous amount. You’re running a window manager, a text editor, a compiler, and a mail client. Each program uses the C library. With static linking, that’s four copies of libc in memory. On a 4MB machine, that’s painful.
Sun’s engineers had an idea: what if programs could share library code? Load libc once, map it into every process that needs it. One copy in physical memory, appearing in many virtual address spaces. Suddenly your 4MB machine feels roomier.
This was the birth of shared libraries. The idea spread to System V, then to Linux, then to everywhere. Today, virtually every program on your system uses them. That’s why ls is 130KB instead of 2MB—it doesn’t include libc, just a reference to it.
But sharing introduces complexity. If libc can load at different addresses in different processes, how does code find the functions it needs? If libraries can be updated independently of programs, how do you handle version mismatches? If the library isn’t loaded until runtime, when do symbol errors appear?
Dynamic linking solves these problems—elegantly, if you understand it; mysteriously, if you don’t. Let’s understand it.
Static linking has a problem: duplication. If 50 programs use libc, you have 50 copies of libc in memory. That’s wasteful.
Dynamic linking solves this. Shared libraries are loaded once and mapped into every process that needs them. Memory saved. Updates applied once. The world rejoices.
But there’s complexity. Let’s understand how it works.
Shared Libraries (.so files)
A shared library is an ELF file designed to be loaded at runtime:
# Create shared library
gcc -fPIC -shared math.c -o libmath.so
# Link against it
gcc main.c -L. -lmath -o program
# Run (library must be findable)
LD_LIBRARY_PATH=. ./program
The executable doesn’t contain libmath.so’s code. It contains a reference to the library.
What’s in a Shared Library?
A shared library is almost like an executable:
$ file libmath.so
libmath.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV),
dynamically linked, not stripped
$ readelf -h libmath.so | grep Type
Type: DYN (Shared object file)
Type DYN (not EXEC). It has:
- Code and data sections
- Symbol table (
.dynsym)—exported functions - Relocation entries—for runtime patching
- No entry point (it’s a library, not a program)
Position-Independent Code (PIC)
Shared libraries must work at any address. Why? Because multiple programs load them, and each program has different memory layouts.
Process A: Process B:
0x400000: program A 0x400000: program B
0x7f0000: libc.so 0x7f0000: libX.so
0x7f1000: libmath.so 0x7f1000: libc.so ← different address!
0x7f2000: libmath.so
The same libmath.so is at 0x7f1000 in process A but 0x7f2000 in process B. The code must work at both addresses.
Global Offset Table (GOT)
PIC code accesses global data through the GOT:
// With PIC
extern int global_var;
int read_global(void) {
return global_var; // Goes through GOT
}
Generated assembly (simplified):
read_global:
mov global_var@GOTPCREL(%rip), %rax # Load GOT entry address
mov (%rax), %eax # Load value from GOT entry
ret
The GOT entry contains global_var’s actual address, filled in by the dynamic linker.
Why Not Just Relocate Everything?
You might ask: why not just patch all addresses at load time?
The answer is code sharing. Shared libraries use a clever trick: the code is read-only and shared between processes. Only the data (including GOT) is private per-process.
Physical Memory:
┌─────────────────┐
│ libmath.so │
│ .text (shared) │ ← Read-only, same physical pages for all
│ │
│ .data (COW) │ ← Copy-on-write, per-process
│ .got (private) │ ← Each process has own GOT
└─────────────────┘
If we patched .text with process-specific addresses, we couldn’t share it.
The Dynamic Linker
When you run a dynamically linked program, the kernel doesn’t just jump to main. It:
- Loads the executable
- Reads the
INTERPsegment to find the dynamic linker - Loads the dynamic linker
- Jumps to the dynamic linker’s entry point
The dynamic linker (ld-linux.so on Linux) then:
- Reads the executable’s dynamic section
- Loads all required shared libraries
- Performs relocations
- Calls constructors
- Finally jumps to the executable’s entry point
You can see this:
$ readelf -l /bin/ls | grep INTERP
INTERP 0x0000000000000324 0x0000000000000324 0x0000000000000324
$ readelf -p .interp /bin/ls
String dump of section '.interp':
[ 0] /lib/ld-linux-aarch64.so.1
Finding Shared Libraries
How does the dynamic linker find libraries? In order:
DT_RPATH/DT_RUNPATH: Paths embedded in the executableLD_LIBRARY_PATH: Environment variable/etc/ld.so.cache: Cached library locations- Default paths:
/lib,/usr/lib, etc.
$ ldd /bin/ls
linux-vdso.so.1 (0x00007ffd4b5fc000)
libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007f4e2d800000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f4e2d400000)
...
Symbol Resolution
Dynamic linking resolves symbols at load time (or lazily at call time):
Eager Binding
By default on some systems, or with LD_BIND_NOW=1:
# All symbols resolved at load time
LD_BIND_NOW=1 ./program
Slower startup but no first-call overhead.
Lazy Binding (PLT)
Default on most Linux systems. Functions are resolved on first call:
printf@plt:
jmp *printf@GOTPLT(%rip) # Jump through GOT
push $0 # First call: push index
jmp .plt # Jump to resolver
First call:
- GOT entry points back to PLT (the push/jmp)
- Resolver is called
- Dynamic linker finds
printf - GOT entry updated to point to real
printf
Second call:
- GOT entry points to real
printf - Direct jump, no resolver involved
Symbol Interposition
One powerful feature of dynamic linking: symbol interposition. A symbol in a later library can override one in an earlier library:
# malloc_debug.so defines malloc
LD_PRELOAD=./malloc_debug.so ./program
Now every malloc call goes to your debug version. This enables:
- Memory debugging (Valgrind, AddressSanitizer)
- Performance profiling
- Mocking for tests
Symbol Visibility
If you don’t want interposition (for performance):
__attribute__((visibility("protected")))
void internal_function(void);
Protected visibility means calls within the library always use the local definition.
Version Scripts
Large libraries maintain ABI compatibility with symbol versioning:
// version.script
MYLIB_1.0 {
global: public_function;
local: *;
};
MYLIB_2.0 {
global: new_function;
} MYLIB_1.0;
gcc -shared lib.c -Wl,--version-script=version.script -o libmy.so
Programs linked against MYLIB_1.0 continue working even when MYLIB_2.0 changes internal functions.
Dynamic Section
The .dynamic section contains tags the dynamic linker needs:
$ readelf -d /bin/ls
Dynamic section at offset 0x2f908 contains 31 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [libselinux.so.1]
0x0000000000000001 (NEEDED) Shared library: [libcap.so.2]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x0000000000000001 (NEEDED) Shared library: [ld-linux-aarch64.so.1]
0x000000000000000c (INIT) 0x3970
0x000000000000000d (FINI) 0x1bb14
0x0000000000000019 (INIT_ARRAY) 0x3ef20
0x000000000000001b (INIT_ARRAYSZ) 8 (bytes)
0x000000000000001a (FINI_ARRAY) 0x3ef28
0x000000000000001c (FINI_ARRAYSZ) 8 (bytes)
0x000000006ffffef5 (GNU_HASH) 0x340
0x0000000000000005 (STRTAB) 0x10a0
0x0000000000000006 (SYMTAB) 0x380
0x000000000000000a (STRSZ) 1584 (bytes)
0x000000000000000b (SYMENT) 24 (bytes)
0x0000000000000015 (DEBUG) 0x0
0x0000000000000003 (PLTGOT) 0x3fb38
0x0000000000000002 (PLTRELSZ) 2760 (bytes)
0x0000000000000014 (PLTREL) RELA
0x0000000000000017 (JMPREL) 0x2ea8
0x0000000000000007 (RELA) 0x1888
0x0000000000000008 (RELASZ) 5664 (bytes)
0x0000000000000009 (RELAENT) 24 (bytes)
0x0000000070000001 (AARCH64_BTI_PLT)
0x000000000000001e (FLAGS) BIND_NOW
0x000000006ffffffb (FLAGS_1) Flags: NOW PIE
0x000000006ffffffe (VERNEED) 0x17e8
0x000000006fffffff (VERNEEDNUM) 3
0x000000006ffffff0 (VERSYM) 0x16d0
0x000000006ffffff9 (RELACOUNT) 218
0x0000000000000000 (NULL) 0x0
Key tags:
NEEDED: Shared libraries this file depends onINIT/FINI: Constructor/destructor functionsSYMTAB/STRTAB: Symbol and string tablesRELA/RELASZ: Relocation informationSONAME: Library’s canonical name
SONAME and Versioning
Libraries have a soname (shared object name):
$ objdump -p /lib/x86_64-linux-gnu/libc.so.6 | grep SONAME
SONAME libc.so.6
This enables version management:
Filesystem:
libfoo.so.1.2.3 (actual file)
libfoo.so.1 -> libfoo.so.1.2.3 (soname symlink)
libfoo.so -> libfoo.so.1 (development symlink)
- Programs link against
libfoo.so(resolves to current version) - At runtime, they request
libfoo.so.1(the soname) - Compatible updates (
1.2.3→1.2.4) just update the symlink
Major version bumps (incompatible changes) get a new soname (libfoo.so.2).
Constructors and Destructors
Shared libraries can have initialization code:
__attribute__((constructor))
void init(void) {
printf("Library loaded\n");
}
__attribute__((destructor))
void cleanup(void) {
printf("Library unloading\n");
}
These run automatically at load/unload time. Useful for:
- Registering with a framework
- Allocating global resources
- Setting up logging
WASM “Dynamic Linking”
WASM doesn’t have traditional dynamic linking, but it has something similar: module instantiation with imports.
// JavaScript host
const mathModule = await WebAssembly.instantiate(mathBytes, {
env: {
memory: new WebAssembly.Memory({ initial: 1 }),
log: console.log,
}
});
const mainModule = await WebAssembly.instantiate(mainBytes, {
env: {
memory: mathModule.instance.exports.memory,
add: mathModule.instance.exports.add,
}
});
Imports are resolved at instantiation time. This is more like dynamic linking than static linking, but:
- No lazy binding (all imports resolved upfront)
- No interposition (imports are explicit)
- No versioning (the host controls what’s provided)
WASM Dynamic Linking Conventions
There’s ongoing work on WASM dynamic linking:
// Shared library exporting from WASM
__attribute__((import_module("env"), import_name("add")))
extern int add(int, int);
__attribute__((export_name("public_function")))
int public_function(void);
The Emscripten toolchain has -sMAIN_MODULE and -sSIDE_MODULE for building dynamically-linked WASM:
emcc -sMAIN_MODULE=1 main.c -o main.js
emcc -sSIDE_MODULE=1 lib.c -o lib.wasm
Performance Considerations
Dynamic linking has overhead:
- Load time: Finding and loading libraries
- Relocation time: Patching GOT entries
- Call overhead: PLT indirection (first call)
- Memory overhead: GOT, PLT, dynamic sections
For performance-critical code:
- Use
-fvisibility=hiddenand export only what’s needed - Consider
-fno-plt(direct GOT calls, removes PLT) - Use
LD_BIND_NOWfor deterministic latency
Debugging Dynamic Linking
# See library search
LD_DEBUG=libs ./program
# See symbol resolution
LD_DEBUG=symbols ./program
# See relocations
LD_DEBUG=reloc ./program
# See everything
LD_DEBUG=all ./program 2>&1 | head -100
This is invaluable for debugging “symbol not found” errors.
The Trade-off Summary
| Aspect | Static | Dynamic |
|---|---|---|
| Binary size | Larger | Smaller |
| Memory usage | Higher (no sharing) | Lower (sharing) |
| Startup time | Faster | Slower |
| Updates | Requires recompile | Just update library |
| Deployment | Single file | Manage dependencies |
| Security updates | Manual | Automatic |
Key Takeaways
- Shared libraries are loaded at runtime, not linked in
- PIC enables code sharing between processes
- GOT/PLT enable position-independent access to external symbols
- The dynamic linker resolves symbols and performs relocations
- Symbol interposition allows overriding functions at runtime
- SONAME versioning enables backward-compatible updates
- WASM has import-based linking at instantiation time
Beyond Startup
Dynamic linking happens when your program starts. The dynamic linker loads libraries, resolves symbols, and by the time main() runs, everything is wired up. For most programs, this is enough.
But what if you don’t know which code you’ll need until the program is running? A text editor loading syntax highlighters based on file type. A server loading authentication modules from a config file. A game engine loading mods that didn’t exist when the game shipped.
This is runtime linking—the ability to load code while the program runs, look up symbols by name, and call functions that weren’t known at compile time. It’s the foundation of plugin architectures, hot reloading, and adaptive systems.
In the next chapter, we’ll explore the dlopen API and its WASM equivalents. You’ll learn how to build plugin systems, wrap library functions for debugging, and understand the security implications of loading arbitrary code. If dynamic linking is linking at program start, runtime linking is linking whenever you want.
Runtime Linking & Loading
Every linking mechanism we’ve seen so far has a fixed point: something is decided before your code runs. Static linking fixes everything at build time. Dynamic linking fixes library dependencies at program start. But the most interesting programs don’t know what they’ll need until they’re running.
Consider Photoshop loading a filter plugin you just downloaded. Or nginx loading a new authentication module from a config reload. Or the JVM loading classes the first time they’re referenced. These systems can’t know at build time—or even at startup—which code they’ll need. They need to load code on demand, look up symbols by name, and wire everything together at runtime.
This is the ultimate flexibility: your program as a dynamic system that can grow new capabilities while it runs. It’s also the ultimate complexity, with new error modes (what if the plugin is malicious?), new performance considerations (what’s the cost of late binding?), and new debugging challenges (where did that function come from?).
Dynamic linking happens at program startup. But what if you want to load code during execution? Maybe based on configuration, user input, or plugin architecture?
That’s runtime linking. Load a library, look up symbols, call functions—all while the program runs.
The dlopen API
On Unix-like systems, four functions handle runtime linking:
#include <dlfcn.h>
// Open a shared library
void *dlopen(const char *filename, int flags);
// Find a symbol
void *dlsym(void *handle, const char *symbol);
// Close a library
int dlclose(void *handle);
// Get error message
char *dlerror(void);
Opening a Library
void *handle = dlopen("./libplugin.so", RTLD_NOW);
if (!handle) {
fprintf(stderr, "dlopen failed: %s\n", dlerror());
exit(1);
}
Flags control behavior:
| Flag | Meaning |
|---|---|
RTLD_NOW | Resolve all symbols immediately |
RTLD_LAZY | Resolve symbols on first use |
RTLD_GLOBAL | Symbols available for subsequent loads |
RTLD_LOCAL | Symbols not available to other libraries |
RTLD_NODELETE | Don’t unload on dlclose |
RTLD_NOLOAD | Don’t load, just check if loaded |
Finding Symbols
typedef int (*add_func)(int, int);
add_func add = (add_func)dlsym(handle, "add");
if (!add) {
fprintf(stderr, "dlsym failed: %s\n", dlerror());
exit(1);
}
int result = add(2, 3); // Call the function!
dlsym returns a void pointer. You cast it to the appropriate function pointer type.
Closing
if (dlclose(handle) != 0) {
fprintf(stderr, "dlclose failed: %s\n", dlerror());
}
After dlclose, the handle is invalid. The library might be unloaded (unless other references exist).
A Complete Example
// plugin_user.c
#include <stdio.h>
#include <dlfcn.h>
int main(int argc, char **argv) {
if (argc < 2) {
fprintf(stderr, "Usage: %s <plugin.so>\n", argv[0]);
return 1;
}
// Load the plugin
void *handle = dlopen(argv[1], RTLD_NOW);
if (!handle) {
fprintf(stderr, "Error: %s\n", dlerror());
return 1;
}
// Find the entry point
typedef void (*plugin_init_func)(void);
plugin_init_func init = dlsym(handle, "plugin_init");
if (!init) {
fprintf(stderr, "Error: %s\n", dlerror());
dlclose(handle);
return 1;
}
// Call it
init();
// Clean up
dlclose(handle);
return 0;
}
// plugin.c
#include <stdio.h>
void plugin_init(void) {
printf("Plugin initialized!\n");
}
# Build
gcc -shared -fPIC plugin.c -o plugin.so
gcc plugin_user.c -ldl -o plugin_user
# Run
./plugin_user ./plugin.so
# Output: Plugin initialized!
Plugin Architectures
Many applications use dlopen for plugins:
Defined Interface
// plugin_api.h - shared between host and plugins
struct plugin_api {
const char *name;
const char *version;
int (*init)(void *host_context);
void (*shutdown)(void);
int (*process)(void *data, size_t len);
};
// Each plugin exports this symbol
extern struct plugin_api plugin;
// my_plugin.c
#include "plugin_api.h"
static int init(void *ctx) { /* ... */ return 0; }
static void shutdown(void) { /* ... */ }
static int process(void *data, size_t len) { /* ... */ return 0; }
struct plugin_api plugin = {
.name = "My Plugin",
.version = "1.0",
.init = init,
.shutdown = shutdown,
.process = process,
};
// host.c
void load_plugin(const char *path) {
void *handle = dlopen(path, RTLD_NOW);
struct plugin_api *api = dlsym(handle, "plugin");
printf("Loaded plugin: %s v%s\n", api->name, api->version);
api->init(host_context);
// Store handle and api for later use
}
Real examples:
- Apache modules (mod_ssl, mod_php)
- Nginx modules
- PostgreSQL extensions
- Node.js native addons
Symbol Lookup Variants
dlsym has variants for different lookup behaviors:
RTLD_DEFAULT and RTLD_NEXT
// Find symbol in default search order (all loaded libraries)
void *sym = dlsym(RTLD_DEFAULT, "malloc");
// Find the NEXT definition (for interposition)
void *real_malloc = dlsym(RTLD_NEXT, "malloc");
RTLD_NEXT is how you wrap library functions:
// malloc_wrapper.c
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
void *malloc(size_t size) {
static void *(*real_malloc)(size_t) = NULL;
if (!real_malloc) {
real_malloc = dlsym(RTLD_NEXT, "malloc");
}
void *ptr = real_malloc(size);
fprintf(stderr, "malloc(%zu) = %p\n", size, ptr);
return ptr;
}
gcc -shared -fPIC malloc_wrapper.c -o malloc_wrapper.so -ldl
LD_PRELOAD=./malloc_wrapper.so ls
# Every malloc is logged!
dladdr: Reverse Lookup
Given an address, find which library and symbol it belongs to:
#include <dlfcn.h>
Dl_info info;
if (dladdr(some_function_ptr, &info)) {
printf("Function is in: %s\n", info.dli_fname);
printf("Nearest symbol: %s\n", info.dli_sname);
}
Useful for crash reporters and profilers.
Windows Equivalent
Windows has similar functions with different names:
| Unix | Windows |
|---|---|
dlopen | LoadLibrary |
dlsym | GetProcAddress |
dlclose | FreeLibrary |
dlerror | GetLastError |
// Windows version
HMODULE handle = LoadLibrary("plugin.dll");
FARPROC func = GetProcAddress(handle, "plugin_init");
FreeLibrary(handle);
WASM Dynamic Loading
WebAssembly modules can be loaded dynamically in JavaScript:
async function loadPlugin(url) {
// Fetch the module
const response = await fetch(url);
const bytes = await response.arrayBuffer();
// Compile and instantiate
const module = await WebAssembly.compile(bytes);
const instance = await WebAssembly.instantiate(module, {
env: {
memory: sharedMemory,
log: console.log,
}
});
// Call exported function
instance.exports.plugin_init();
return instance;
}
// Load plugins based on configuration
for (const pluginUrl of config.plugins) {
await loadPlugin(pluginUrl);
}
The JavaScript host acts as the dynamic linker, providing imports and managing module lifecycle.
WASM Module Caching
WebAssembly.compile() is expensive. Cache compiled modules:
const moduleCache = new Map();
async function getModule(url) {
if (moduleCache.has(url)) {
return moduleCache.get(url);
}
const response = await fetch(url);
const bytes = await response.arrayBuffer();
const module = await WebAssembly.compile(bytes);
moduleCache.set(url, module);
return module;
}
Even better: use WebAssembly.compileStreaming() to compile while downloading:
const module = await WebAssembly.compileStreaming(fetch(url));
Thread Safety
dlopen and friends have thread-safety considerations:
- dlopen/dlclose: Generally thread-safe on modern systems
- dlerror: Returns thread-local error (thread-safe)
- Library constructors: Run with a lock held
- Symbol resolution during lazy binding: Thread-safe
But your plugins must be thread-safe too. A plugin’s init function might be called from multiple threads.
Hot Reloading
Runtime linking enables hot reloading—updating code without restarting:
void *current_handle = NULL;
struct plugin_api *current_api = NULL;
void reload_plugin(const char *path) {
// Load new version first (keeps old version if this fails)
void *new_handle = dlopen(path, RTLD_NOW);
if (!new_handle) {
fprintf(stderr, "Failed to load: %s\n", dlerror());
return;
}
struct plugin_api *new_api = dlsym(new_handle, "plugin");
if (!new_api) {
fprintf(stderr, "No plugin symbol: %s\n", dlerror());
dlclose(new_handle);
return;
}
// Swap (atomically if needed)
struct plugin_api *old_api = current_api;
void *old_handle = current_handle;
current_api = new_api;
current_handle = new_handle;
// Clean up old
if (old_api) old_api->shutdown();
if (old_handle) dlclose(old_handle);
// Initialize new
current_api->init(host_context);
}
Game engines often use this for rapid iteration during development.
Security Considerations
Runtime linking is powerful but risky:
Path Injection
Never construct library paths from user input without validation:
// DANGEROUS
char path[256];
snprintf(path, sizeof(path), "./plugins/%s.so", user_input);
dlopen(path, RTLD_NOW); // User could specify "../../../etc/passwd"
// SAFER
if (!is_valid_plugin_name(user_input)) {
return error;
}
// ... then load
Symbol Injection
Malicious libraries can define functions that override standard ones:
// Evil plugin
void free(void *ptr) {
// Don't actually free, cause memory exhaustion
// Or: log the pointer for later exploitation
}
Mitigations:
- Load plugins with
RTLD_LOCAL - Use namespaced symbol names
- Validate plugin signatures
Privilege Escalation
If your program runs with elevated privileges, don’t load user-specified libraries:
if (geteuid() != getuid()) {
// Running setuid - don't trust LD_LIBRARY_PATH or user paths
secure_plugin_path = "/usr/lib/myapp/plugins";
}
Performance Characteristics
| Operation | Cost |
|---|---|
dlopen (first time) | ~milliseconds (file I/O, relocation) |
dlopen (already loaded) | ~microseconds (increment refcount) |
dlsym | ~microseconds (hash lookup) |
dlclose | ~microseconds (decrement refcount) |
| Actual unload | ~milliseconds (if refcount hits zero) |
Tips:
- Cache dlsym results (function pointers)
- Use
RTLD_NOWin production (fail fast) - Use
RTLD_LAZYin development (faster startup)
Debugging Runtime Loading
# See what's being loaded
LD_DEBUG=files ./program
# See symbol lookups
LD_DEBUG=symbols ./program
# Trace library calls
ltrace ./program
# In GDB
(gdb) set stop-on-solib-events 1
(gdb) run
# Breaks when shared libraries load
Key Takeaways
- dlopen/dlsym/dlclose provide runtime library loading
- Plugin architectures use defined interfaces and dlsym
- RTLD_NEXT enables function wrapping
- Hot reloading is possible with careful lifecycle management
- Security matters—validate paths and understand symbol scope
- WASM uses JavaScript for runtime module loading
- Cache compiled modules and symbol lookups for performance
Two Worlds
We’ve now explored object files, symbol tables, relocations, and three flavors of linking—all primarily through the lens of ELF, the format that runs most of the world’s servers. But we’ve been making comparisons to WebAssembly throughout, noting where it does things differently.
It’s time to bring those comparisons together. ELF and WASM solve the same fundamental problem—representing compiled code so it can be linked and run—but they make profoundly different design choices. ELF trusts code and optimizes for performance. WASM trusts nothing and optimizes for safety. ELF has flat memory and arbitrary control flow. WASM has sandboxed memory and structured control flow.
Understanding both formats illuminates the design space. You see what’s essential to any binary format, what’s an artifact of 1990s Unix assumptions, and what’s a deliberate trade-off between power and safety.
In the next chapter, we’ll put ELF and WASM side by side—same concepts, different implementations. You’ll come away understanding not just how each format works, but why it works that way.
ELF vs WASM: A Detailed Comparison
Two binary formats. Twenty years apart. Both encode compiled code, symbols, and linking metadata. But they feel completely different to work with.
ELF grew from Unix culture: minimal constraints, maximum flexibility, trust the programmer. It’s a format that lets you shoot yourself in the foot—and assumes you know how to avoid it. You can jump to arbitrary addresses. You can read and write any memory. You can override any symbol. The power is yours; the responsibility is yours.
WASM grew from web culture: assume hostile code, verify everything, sandbox by default. It’s a format that won’t let you misbehave—not because it catches you, but because misbehavior is inexpressible. You can’t jump to arbitrary addresses because addresses don’t exist. You can’t escape your memory sandbox because the format doesn’t have escape hatches. The constraints are the feature.
Neither is “better.” They’re optimized for different threat models. ELF assumes the OS provides security; WASM assumes nothing does. ELF optimizes for native performance; WASM optimizes for portable safety.
Let’s see exactly how these philosophies manifest in their designs.
ELF was designed around 1989 for Unix System V Release 4. WebAssembly was designed in 2015 for web browsers. Different eras, different constraints, different designs.
Yet both solve the same fundamental problem: representing compiled code and the metadata needed to link and run it.
Let’s compare them systematically.
Design Philosophy
ELF: Bare Metal Legacy
ELF trusts the code. It’s designed for:
- Direct memory access
- System calls
- Shared libraries with symbol interposition
- Debugging and profiling tools
- Maximum flexibility and performance
ELF assumes the OS provides security. Code runs with whatever privileges the process has.
WASM: Sandboxed by Design
WASM trusts nothing. It’s designed for:
- Untrusted code execution (browser tabs, serverless)
- Capability-based security
- Portability across architectures
- Fast validation and compilation
- Deterministic execution
WASM enforces security at the format level. Code can only access what’s explicitly provided through imports.
Memory Model
ELF: Flat Address Space
ELF programs see the entire address space:
0x0000000000000000 ─┐
│ Unmapped (null page)
0x0000000000400000 ─┤
│ Program code (.text)
0x0000000000600000 ─┤
│ Program data (.data, .bss)
0x00007f0000000000 ─┤
│ Shared libraries
0x00007fff00000000 ─┤
│ Stack
0x00007fffffffffff ─┘
Pointers are raw addresses. Buffer overflows can overwrite arbitrary memory. Memory protection is enforced by the OS, not the format.
WASM: Linear Memory Sandbox
WASM modules have isolated linear memory:
┌─────────────────┐
│ Module A │
│ Linear Memory │ Only this module can access
│ 0 to size-1 │
└─────────────────┘
┌─────────────────┐
│ Module B │
│ Linear Memory │ Completely separate
│ 0 to size-1 │
└─────────────────┘
Memory bounds are checked on every access. You can’t read outside your linear memory—the VM traps. No pointer is “to the OS” or “to another module” unless explicitly imported.
Type System
ELF: No Type Information
ELF symbols have no type beyond “function” or “object”:
$ nm math.o
0000000000000000 T add # Function... what signature?
0000000000000010 T multiply # Also function... same?
The linker doesn’t know (or care) that add takes two ints and returns an int. It just matches names. Type mismatches are your problem.
Debug info (DWARF) has types, but it’s optional and ignored by the linker.
WASM: Mandatory Type Checking
Every WASM function has a type, and the type system is enforced:
(type $binary_int (func (param i32 i32) (result i32)))
(func $add (type $binary_int)
local.get 0
local.get 1
i32.add
)
Calling a function with wrong types? Validation fails. The module won’t even instantiate.
// This would be caught at validation, not runtime
const instance = await WebAssembly.instantiate(badModule, imports);
// ValidationError: type mismatch
Symbol Resolution
ELF: Name-Based, Late Binding
ELF symbols are strings. Resolution happens at link time or load time:
main.o: calls "printf"
libc.so: defines "printf"
↓
Linker matches strings
Features enabled by this model:
- Symbol interposition (
LD_PRELOAD) - Weak symbols and default implementations
- Symbol versioning
- Lazy binding (PLT)
Drawbacks:
- Typos in symbol names → link errors (not compile errors)
- ABI mismatches → silent corruption or crash
- Symbol lookup has overhead
WASM: Structured Imports, Eager Resolution
WASM imports are structured: module name + field name + type:
(import "env" "print" (func $print (param i32)))
(import "wasi" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32)))
Resolution happens at instantiation:
const imports = {
env: {
print: (x) => console.log(x), // Must match signature
},
wasi: {
fd_write: wasi.fd_write, // Must match signature
},
};
const instance = await WebAssembly.instantiate(module, imports);
Features:
- Type errors caught at instantiation
- No name collisions (namespaced by module)
- Host provides capabilities explicitly
Drawbacks:
- No lazy binding (all imports resolved upfront)
- No interposition (you get what you’re given)
- Less flexible
Relocations
ELF: Address Patching
ELF relocations patch actual addresses into machine code:
Before: e8 00 00 00 00 (call with placeholder)
After: e8 15 01 00 00 (call with real offset)
Relocation types are architecture-specific. x86-64 has dozens. Each encodes:
- Where to patch
- What size
- What calculation (PC-relative? GOT-relative? Absolute?)
WASM: Index Renumbering
WASM relocations patch indices:
Before: call 0 (function index 0)
After: call 5 (function index 5, after combining modules)
Relocation types are architecture-independent:
R_WASM_FUNCTION_INDEX_LEB: Function callR_WASM_MEMORY_ADDR_LEB: Memory addressR_WASM_TYPE_INDEX_LEB: Type reference
Much simpler because WASM uses structured indices, not raw addresses.
Control Flow
ELF: Unstructured (Arbitrary Jumps)
Machine code can jump anywhere:
jmp label ; Forward jump
jmp *%rax ; Indirect jump (anywhere!)
call *(%rsp) ; Return to anywhere
This enables:
- Computed gotos
- Tail calls
- JIT compilation
- Return-oriented programming (ROP) attacks 😈
WASM: Structured Control Flow
WASM only has structured control flow:
(block $outer
(block $inner
br_if $inner ; Conditional break to $inner
br $outer ; Unconditional break to $outer
)
)
(loop $repeat
;; ...
br_if $repeat ; Conditional continue
)
No arbitrary jumps. No goto. All control flow is explicitly nested.
This:
- Enables streaming compilation
- Guarantees CFI (Control Flow Integrity)
- Simplifies validation
- Prevents certain exploit classes
But limits:
- Must transform arbitrary control flow to structured form
- Some patterns require “relooper” algorithms in compilers
Dynamic Linking
ELF: Full Dynamic Linking
ELF has comprehensive dynamic linking:
Executable → Dynamic Linker → Shared Libraries
↓ ↓ ↓
NEEDED tags Loads libs GOT/PLT
Resolves Symbol
symbols tables
Features:
- Lazy binding
- Symbol interposition
- Library updates without recompiling
- Memory sharing between processes
WASM: Import-Based Linking
WASM’s “dynamic linking” is import resolution:
// Host provides imports
const instance = await WebAssembly.instantiate(module, imports);
// Imports are fixed at instantiation
// No lazy binding, no interposition
Emscripten simulates dynamic linking with:
- A shared memory between modules
- Function pointer tables
- A JavaScript “dynamic linker” shim
But it’s not the same. There’s no OS-level library sharing.
Comparison Table
| Aspect | ELF | WASM |
|---|---|---|
| Year introduced | ~1989 (SVR4) | 2015 |
| Primary platform | Unix (Linux, BSD, Solaris) | Web browsers, edge, embedded |
| Security model | OS-enforced | Format-enforced (sandbox) |
| Memory model | Flat address space | Linear memory sandbox |
| Type system | None (optional debug info) | Mandatory, validated |
| Symbol names | Arbitrary strings | Module.field pairs |
| Relocation | Address patching | Index renumbering |
| Control flow | Arbitrary jumps | Structured only |
| Dynamic linking | Full (GOT, PLT, ld.so) | Import-based only |
| Shared libraries | Yes (memory sharing) | No (instance isolation) |
| Lazy binding | Yes | No |
| Symbol interposition | Yes | No |
| Architecture | Specific (per-arch) | Portable (one format) |
| Compilation speed | N/A (native code) | Designed for fast JIT |
| Binary size | Larger (native code) | Smaller (compact encoding) |
When to Use What
Use ELF When:
- Building native Linux/Unix applications
- Need maximum performance
- Need shared library memory sharing
- Need symbol interposition for debugging/profiling
- Building kernel modules or system components
Use WASM When:
- Running untrusted code safely
- Need portability across architectures
- Running in browsers or edge environments
- Want deterministic, reproducible execution
- Building plugins for existing applications
The Convergence
Despite different designs, they’re converging:
WASM is gaining ELF-like features:
- Component Model: structured interfaces between modules
- WASI: standardized system interface
- Threads: shared memory between workers
- Exception handling: structured unwinding
ELF toolchains are adopting WASM concepts:
- CFI (Control Flow Integrity) adds WASM-like control flow guarantees
- Memory tagging adds bounds checking
- Sandboxing technologies (seccomp, Landlock) limit capabilities
A Practical Perspective
For a web developer, here’s the key insight:
ELF is what your server-side code becomes. When you deploy Node.js, Python, or Go, you’re running ELF binaries with all their flexibility and danger.
WASM is what your client-side native code becomes. When you compile Rust to run in the browser, or use Figma’s WASM-compiled engine, you’re using WASM’s safety guarantees.
And increasingly, WASM on the server (Cloudflare Workers, Fastly Compute, etc.) brings browser-style sandboxing to backend code.
Key Takeaways
- ELF optimizes for performance and flexibility at the cost of safety
- WASM optimizes for safety and portability at the cost of features
- ELF’s flat memory model enables pointer tricks; WASM’s sandbox prevents them
- ELF has no type checking; WASM enforces types at validation
- ELF’s dynamic linking is richer but more complex than WASM’s imports
- Structured control flow makes WASM safer but sometimes awkward
- Both formats are evolving toward each other’s strengths
From Theory to Practice
Nine chapters of formats, data structures, and design philosophy. You now understand object files at a level most developers never reach. But knowledge without application is just trivia.
The final chapter is about using this knowledge. What do you actually do when npm install fails with a native module error? How do you shrink a 2MB WASM bundle to something reasonable? When your Node.js app has mysterious latency spikes, how do you check if it’s library loading?
We’ll walk through real scenarios: debugging symbol errors, optimizing binary sizes, building native addons, and deploying WASM in production. The theory you’ve learned becomes the toolkit you’ll use.
Let’s make this practical.
Practical Applications for Web Developers
Here’s a confession: everything you’ve learned in this book is useless.
Not because it’s wrong. Because knowledge sitting in your head doesn’t fix bugs, shrink binaries, or unblock builds. The value comes when you reach for this knowledge at the right moment—when you recognize “this is a symbol resolution problem” instead of flailing at a mysterious error message.
This chapter is about those moments. We’ll work through real scenarios you’ll encounter as a web developer dealing with native code. Each section starts with a symptom you’ll recognize, explains what’s actually happening in terms you now understand, and shows you how to fix it.
Think of this chapter as a translation guide: from error message to root cause to solution, with all the symbol tables and relocations providing the “why.”
You’ve learned about object files, symbols, relocations, and linkers. Now let’s apply this knowledge to problems you actually face.
Debugging Native Module Failures
You’re installing an npm package and see:
$ npm install sharp
node-pre-gyp ERR! Tried to download(404): https://github.com/.../sharp-v0.32.0-linux-x64.tar.gz
gyp ERR! build error
gyp ERR! node-gyp -v v9.0.0
...
Error: Cannot find module '../build/Release/sharp-linux-x64.node'
What’s happening? Native modules like sharp (image processing) or bcrypt (crypto) include compiled code. They’re ELF shared objects (.node files are just .so files).
Understanding the Error
- Pre-built binary not available (the 404)
- Fell back to source compilation (gyp)
- Compilation failed
Check what’s actually there:
$ ls node_modules/sharp/build/Release/
ls: cannot access 'node_modules/sharp/build/Release/': No such file or directory
$ file node_modules/sharp/build/Release/sharp-linux-x64.node
# If it exists: ELF 64-bit LSB shared object...
Common Fixes
Missing build tools:
# Ubuntu/Debian
sudo apt install build-essential
# macOS
xcode-select --install
Wrong Node.js version:
$ node -p process.versions.modules
# Returns ABI version (e.g., 108 for Node 20)
# The .node file was compiled for a different ABI
# Solution: rebuild or use matching Node version
npm rebuild sharp
Missing libraries:
$ ldd node_modules/sharp/build/Release/sharp-linux-x64.node
linux-vdso.so.1 (0x00007ffc...)
libvips.so.42 => not found # ← Problem!
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
# Fix: install the missing library
sudo apt install libvips-dev
Optimizing WASM Binary Size
Your Rust-to-WASM module is 2MB. Your users are sad. Let’s fix it.
Measure First
$ wasm-objdump -h my_module.wasm
Sections:
Type start=0x0000000a end=0x00000134 (size=0x0000012a) count: 30
Import start=0x00000137 end=0x00000198 (size=0x00000061) count: 3
Function start=0x0000019b end=0x00000498 (size=0x000002fd) count: 380
...
Code start=0x00001a23 end=0x001f8271 (size=0x001f684e) count: 380
↑
This is your code: ~2MB
Most of the size is in the Code section. Let’s analyze it:
# If you have twiggy (cargo install twiggy)
twiggy top my_module.wasm
# Shows biggest functions:
Shallow Bytes │ Shallow % │ Item
───────────────┼───────────┼──────────────────────
234,567 │ 11.12% │ regex_automata::...
198,234 │ 9.40% │ serde_json::de::...
...
Reduce Dependencies
That regex library is 11% of your binary. Do you need full regex? Maybe:
- Use a simpler pattern matcher
- Process regex on the JS side
- Use a lighter regex crate
Enable LTO and Size Optimization
# Cargo.toml
[profile.release]
lto = true
opt-level = "z" # Optimize for size
codegen-units = 1
Strip Debug Info
# Using wasm-strip
wasm-strip my_module.wasm
# Or in Cargo.toml
[profile.release]
strip = true
Use wasm-opt
# Part of Binaryen toolkit
wasm-opt -Oz -o optimized.wasm my_module.wasm
# Can reduce by 10-20%
Result
Typical savings from all of the above:
| Stage | Size |
|---|---|
| Initial build | 2.1 MB |
| + Release build | 1.4 MB |
| + LTO + opt-level z | 800 KB |
| + strip | 750 KB |
| + wasm-opt | 620 KB |
| + dependency audit | 400 KB |
That’s an 80% reduction.
Understanding Bundle Splitting
Modern bundlers (webpack, Rollup, esbuild) do linking. Understanding linker concepts helps you configure them.
Dead Code Elimination = Linker GC
// utils.js
export function usedFunction() { return 1; }
export function unusedFunction() { return 2; } // Tree-shaken
// main.js
import { usedFunction } from './utils.js';
console.log(usedFunction());
This is the same as linker --gc-sections. The bundler traces from entry points, keeping only reachable code.
Gotcha: Side effects prevent tree-shaking:
// analytics.js
console.log("Analytics loaded"); // Side effect!
export function track() { }
Even if track is unused, the file runs. Mark it pure:
// package.json
{
"sideEffects": false // Or list specific files
}
Code Splitting = Shared Libraries
// Uses dynamic import
const heavyModule = await import('./heavy.js');
The bundler creates a separate chunk—like a shared library. It’s loaded on demand.
Tip: Shared code goes into common chunks:
// webpack.config.js
optimization: {
splitChunks: {
chunks: 'all',
// Code used by multiple entry points becomes shared
}
}
This is linker thinking: identify common symbols, extract to shared unit.
Building Node.js Native Addons
Want to write a native addon? You’re authoring an ELF shared object.
Using node-addon-api
// addon.cc
#include <napi.h>
Napi::Number Add(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
double a = info[0].As<Napi::Number>().DoubleValue();
double b = info[1].As<Napi::Number>().DoubleValue();
return Napi::Number::New(env, a + b);
}
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set("add", Napi::Function::New(env, Add));
return exports;
}
NODE_API_MODULE(addon, Init)
# binding.gyp
{
"targets": [{
"target_name": "addon",
"sources": ["addon.cc"],
"include_dirs": [
"<!@(node -p \"require('node-addon-api').include\")"
],
"defines": ["NAPI_DISABLE_CPP_EXCEPTIONS"]
}]
}
Build and examine:
$ npm install
$ node-gyp build
$ file build/Release/addon.node
addon.node: ELF 64-bit LSB shared object, x86-64
$ nm -D build/Release/addon.node | grep ' T '
0000000000001234 T napi_register_module_v1 # The entry point
Debugging Symbol Issues
$ node -e "require('./build/Release/addon')"
Error: /path/addon.node: undefined symbol: _ZN4Napi5Value9As...
# Decode the symbol
$ c++filt _ZN4Napi5Value9As...
Napi::Value::As<Napi::Number>()
# Missing: linked against wrong version of node-addon-api
# Fix: check include paths, rebuild with correct headers
Inspecting What Your Server Loads
Your Node.js app has latency spikes. Is it library loading?
$ LD_DEBUG=files node app.js 2>&1 | head -50
# Count loaded libraries
$ cat /proc/$(pgrep -f "node app.js")/maps | grep '\.so' | wc -l
# Check for relocations at startup
$ LD_DEBUG=statistics node app.js 2>&1 | grep runtime
If many libraries load lazily (PLT overhead on first call), consider:
# Force eager binding for consistent latency
LD_BIND_NOW=1 node app.js
WASM in Production
Loading Strategies
Streaming compilation (fastest):
const module = await WebAssembly.compileStreaming(fetch('/module.wasm'));
const instance = await WebAssembly.instantiate(module, imports);
Caching compiled modules (for repeat visits):
// Using IndexedDB to cache compiled module
const cache = await caches.open('wasm-cache');
const response = await cache.match('/module.wasm');
if (response) {
const module = await WebAssembly.compileStreaming(response);
// ...
} else {
const response = await fetch('/module.wasm');
cache.put('/module.wasm', response.clone());
const module = await WebAssembly.compileStreaming(response);
// ...
}
Memory Management
WASM uses linear memory. Watch for growth:
const memory = instance.exports.memory;
// Check current size (in pages, 64KB each)
console.log('Memory pages:', memory.buffer.byteLength / 65536);
// Grow if needed
memory.grow(10); // Add 10 pages (640KB)
Warning: Growing memory can move the buffer, invalidating views:
const memory = instance.exports.memory;
let view = new Uint8Array(memory.buffer);
memory.grow(1);
// view is now detached!
// Must recreate:
view = new Uint8Array(memory.buffer);
Debugging Tools Summary
| Task | ELF Tool | WASM Tool |
|---|---|---|
| List symbols | nm, readelf -s | wasm-objdump -t |
| Show sections | readelf -S | wasm-objdump -h |
| Disassemble | objdump -d | wasm2wat |
| Check dependencies | ldd | (inspect imports) |
| Debug loading | LD_DEBUG=all | Browser DevTools |
| Analyze size | bloaty | twiggy |
| Strip debug info | strip | wasm-strip |
| Optimize | (compiler flags) | wasm-opt |
Quick Reference: Error Messages
| Error | Meaning | Fix |
|---|---|---|
undefined symbol: X | Symbol X not found in any library | Check ldd output, install missing lib |
version 'GLIBC_2.XX' not found | Built against newer glibc | Rebuild on older system or use static linking |
ELF interpreter not found | Wrong architecture or broken binary | Check file output, ensure 64-bit matches |
LinkError: import X not found | WASM import not provided | Check your imports object in JS |
RuntimeError: unreachable | WASM trap (assertion/bounds) | Debug with source maps |
Key Takeaways
- Native module errors are often ELF linking issues—use
ldd,nm,readelf - WASM binary size can be reduced 50-80% with proper optimization
- Bundlers are linkers—same concepts of tree-shaking and code splitting
- Native addons are shared objects—debug them like any ELF file
- Streaming compilation and caching make WASM load fast
- Know your tools—the debugging commands will save hours
You now understand what happens between source code and running program. Use this knowledge to debug faster, optimize better, and build more robust systems.
Glossary
A
- ABI (Application Binary Interface)
- The low-level interface between two binary program modules. Defines calling conventions, data layout, and system call numbers. If two binaries have incompatible ABIs, they can’t work together even if the source looks compatible.
- Address Space Layout Randomization (ASLR)
- A security technique that randomizes where code and data are loaded in memory. Makes exploits harder because attackers can’t predict addresses.
- Addend
- A constant value added during relocation calculation. For example, accessing a struct member at offset 8 might use the struct’s symbol address plus addend 8.
B
- Binding
- A symbol property controlling visibility and resolution priority. LOCAL symbols are file-private, GLOBAL are visible everywhere, WEAK can be overridden by GLOBAL.
- BSS (Block Started by Symbol)
- Section for uninitialized or zero-initialized data. Doesn’t occupy file space—just records how much memory to allocate.
C
- COMMON Symbol
- A tentative definition—declared but not necessarily defined. Multiple COMMON symbols with the same name are merged by the linker. Largely obsolete; use
-fno-common. - Component Model
- A proposed WASM standard for defining structured interfaces between modules, enabling better composition than raw imports/exports.
D
- Data Section (.data)
- Contains initialized global and static variables. Takes space in the file proportional to the data size.
- Dynamic Linker (ld.so, ld-linux.so)
- The program that loads shared libraries at runtime, resolves symbols, and performs relocations. Runs before your main() function.
- Dynamic Symbol Table (.dynsym)
- The symbol table consulted at runtime for dynamic linking. Survives
stripbecause it’s needed for shared library resolution.
E
- ELF (Executable and Linkable Format)
- The standard binary format on Linux and many Unix systems. Used for executables, shared libraries, object files, and core dumps.
- Entry Point
- The address where execution begins. In ELF, specified in the header. In WASM, an optional start function.
- Export
- A symbol or function made available to other modules. In WASM, explicitly declared; in ELF, controlled by visibility attributes.
G
- Global Offset Table (GOT)
- A table of addresses filled at runtime. PIC code accesses external data through the GOT, enabling position-independence.
- GNU Hash
- A modern hash table format for fast symbol lookup in ELF files. Faster than the original SysV hash.
I
- Import
- A symbol or function required from another module. In WASM, explicitly typed and namespaced. In ELF, just an undefined symbol.
- Interposition
- The ability to override a symbol from one library with a definition from another. Enabled by LD_PRELOAD. Useful for debugging, dangerous for security.
L
- Lazy Binding
- Resolving function addresses on first call rather than at load time. Implemented via PLT. Faster startup but first-call overhead.
- LEB128 (Little Endian Base 128)
- Variable-length integer encoding used in WASM. Small numbers use fewer bytes.
- Linker (ld)
- The tool that combines object files into executables or libraries. Resolves symbols and applies relocations.
- Linker Script
- Configuration file controlling how the linker arranges sections in memory. Essential for embedded systems.
- Linear Memory
- WASM’s memory model—a contiguous, bounds-checked array of bytes. Isolated per module.
- LTO (Link-Time Optimization)
- Optimization performed by the linker across all compilation units. Enables whole-program analysis.
M
- Mangling
- Encoding function signatures into symbol names. C++ uses mangling to support overloading.
_Z3addiiis mangled;add(int, int)is demangled.
N
- Name Section
- A WASM custom section containing human-readable names for functions and variables. Like debug info, optional but helpful.
O
- Object File (.o)
- Compiler output containing code, data, symbols, and relocations. Not directly executable—must be linked.
P
- PIC (Position-Independent Code)
- Code that works at any memory address. Required for shared libraries. Uses GOT for data access and PC-relative addressing for code.
- PLT (Procedure Linkage Table)
- A jump table enabling lazy binding and position-independent function calls. Each PLT entry redirects through the GOT.
- Program Header
- ELF metadata describing memory segments—how to load the file for execution.
R
- RELRO (Relocation Read-Only)
- Security hardening that makes GOT read-only after relocations are applied. Prevents GOT overwrite attacks.
- Relocation
- An instruction to patch code or data with a final address. Contains: where to patch, which symbol, and how to calculate.
- Rodata Section (.rodata)
- Read-only data—string literals, constants. Mapped as non-writable for security.
S
- Section
- A named chunk of an object file (.text, .data, .rodata, etc.). The linker’s view of the file.
- Section Header
- ELF metadata describing sections—name, type, flags, size.
- Segment
- A loadable chunk of an executable. The loader’s view of the file. Multiple sections can be in one segment.
- Code loaded at runtime and shared between processes. Requires PIC.
- SONAME
- A shared library’s canonical name, embedded in the file. Used for versioning: libfoo.so.1 is the soname even if the file is libfoo.so.1.2.3.
- Static Library (.a)
- An archive of object files. The linker extracts only needed objects.
- String Table (.strtab)
- A section containing null-terminated strings. Symbol names are offsets into this table.
- Symbol
- A named reference to a function, variable, or other code element. Has properties: name, value (address), size, binding, type, visibility.
- Symbol Table (.symtab)
- A list of all symbols in a file. Used for linking and debugging.
T
- Text Section (.text)
- Executable code. Marked as readable and executable, but not writable.
- Tree Shaking
- Dead code elimination for JavaScript modules. Same concept as linker
--gc-sections. - Type Section
- WASM section defining function signatures. All functions reference a type by index.
V
- Visibility
- Controls symbol export from shared libraries. DEFAULT exports, HIDDEN doesn’t, PROTECTED exports but prevents interposition.
W
- WASI (WebAssembly System Interface)
- A standard API for WASM to interact with the outside world—files, network, etc. Like POSIX for WASM.
- WASM (WebAssembly)
- A portable binary format for safe, sandboxed code execution. Runs in browsers and increasingly on servers.
- Weak Symbol
- A symbol that can be overridden by a strong (GLOBAL) definition. Used for default implementations.