Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. 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.

  2. Native modules exist. Node.js addons, Electron apps, and native bindings all involve linking. When npm install fails with “undefined symbol,” you’ll know exactly what that means.

  3. 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.

  4. Debugging gets deeper. Stack traces, source maps, and debugging symbols all trace back to concepts we’ll cover here.

  5. It’s fascinating. Seriously. The elegance of how a bunch of separate .o files 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:

  • main is defined here (that’s what T means—it’s in the .text section)
  • add is undefined (U)—main.c calls it but doesn’t define it
  • multiply is 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:

  1. A function (add, main, printf)
  2. A global variable (errno, stdout, my_global_config)
  3. 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. static functions 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/unspecified
  • OBJECT: A data variable
  • FUNC: A function
  • SECTION: A section name
  • FILE: 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)
  • UND or *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:

  • export makes a symbol global (visible outside the module)
  • import declares 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:

  1. Collects all symbols from all input files
  2. Resolves undefined symbols to their definitions
  3. Relocates code to final addresses
  4. 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

  1. Symbols are names for functions, variables, and other code elements
  2. Undefined symbols are promises: “this exists somewhere, trust me”
  3. The linker resolves undefined symbols to their definitions
  4. Binding controls visibility: global, local, or weak
  5. 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:

TypeMeaning
RELRelocatable file (object file, .o)
EXECExecutable (traditional fixed-address binary)
DYNShared object (.so, or a PIE executable)
CORECore 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:

  1. Sections — for linking (static view)
  2. 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 runtime
  • X — Executable: contains runnable code
  • W — Writable: can be modified at runtime
  • S — Strings: contains null-terminated strings
  • M — 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. The RWE flags determine permissions (Read/Write/Execute).
  • INTERP — Path to the dynamic linker (/lib64/ld-linux-x86-64.so.2)
  • DYNAMIC — Information for dynamic linking
  • GNU_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 ConceptBundle Analogy
SectionsSeparate chunks (JS, CSS, images)
SegmentsHow chunks are loaded (async vs sync)
Symbol tableExport/import declarations
RelocationsImport bindings to resolve
.textYour JavaScript code
.rodataYour string literals
.dataYour 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

  1. ELF has two views: sections (for linking) and segments (for loading)
  2. Object files have sections only; executables have both
  3. .text is code, .data is initialized data, .bss is zero-initialized
  4. The symbol table lives in .symtab; relocations in .rel* sections
  5. 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 files
  • reloc.* — relocation entries
  • producers — 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:

PropertyWASM Object FileFinal WASM Module
RelocationsYes (in custom sections)No
Undefined symbolsYesNo (all imports resolved)
Multiple data segmentsYesMerged
Linking metadataYes (linking section)Stripped
Can run directlyNoYes

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:

TypeMeaning
R_WASM_FUNCTION_INDEX_LEBReference to function index
R_WASM_TABLE_INDEX_SLEBReference to table index
R_WASM_MEMORY_ADDR_LEBMemory address (in data section)
R_WASM_GLOBAL_INDEX_LEBReference to global index
R_WASM_TYPE_INDEX_LEBReference 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

AspectELFWASM
StructureSections + SegmentsSections only
Symbol namesArbitrary stringsModule.field pairs
Type informationOptional (debug info)Required (type section)
Memory modelFlat address spaceSandboxed linear memory
RelocationsMachine-specificPlatform-independent
Entry pointAddress in headerOptional 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

  1. WASM modules have sections, like ELF, but simpler—no segments for loading
  2. Imports and exports are explicit with module.field naming and types
  3. Type information is mandatory, enabling ahead-of-time validation
  4. Object files use custom sections for linking metadata and relocations
  5. 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:

  1. Name (or pointer to name string)
  2. Value (address/offset)
  3. Size (bytes)
  4. Type (function, object, etc.)
  5. Binding (local, global, weak)
  6. Section (where it lives)
  7. 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:

ValueNameMeaning
0STT_NOTYPEUnspecified
1STT_OBJECTData object (variable)
2STT_FUNCFunction/executable code
3STT_SECTIONSection symbol
4STT_FILESource file name
10STT_GNU_IFUNCIndirect function (resolver)

Binding values:

ValueNameMeaning
0STB_LOCALVisible only in this file
1STB_GLOBALVisible everywhere
2STB_WEAKLower priority global

st_other: Visibility

Controls symbol visibility beyond binding:

ValueNameMeaning
0STV_DEFAULTNormal rules apply
1STV_INTERNALHidden + cannot be interposed
2STV_HIDDENNot visible outside shared object
3STV_PROTECTEDVisible 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?

ValueMeaning
0 (SHN_UNDEF)Undefined (external reference)
1-0xfeffSection 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:

TablePurposeStripped?
.symtabAll symbols (debugging, linking)Yes, by strip
.dynsymDynamic symbols onlyNo (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 buckets
  • nchain: Number of symbols
  • bucket[nbucket]: Hash buckets
  • chain[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:

  1. Names are inline, not in a separate string table
  2. Typed by section kind: function symbols vs data symbols are distinct
  3. Index-based references: symbols reference indices, not addresses
  4. 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 foo being linked?
  • Is foo actually 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 baz loaded? (ldd to 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

  1. Symbol tables map names to addresses (or to “undefined”)
  2. ELF has two tables: .symtab (full, strippable) and .dynsym (minimal, required)
  3. Binding (local/global/weak) controls resolution priority
  4. Visibility controls export from shared libraries
  5. Hash tables enable fast symbol lookup
  6. 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:

  1. Load global_counter from memory
  2. 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:

  1. Decides final addresses for everything
  2. Walks through all relocations
  3. 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 to add
  • At offset 0x20 in .text, patch with a PLT call to multiply

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:

  1. Jump through GOT → GOT has address of “push; jmp resolver”
  2. Dynamic linker resolves printf
  3. GOT entry updated to point to real printf

Subsequent calls:

  1. 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:

  1. No absolute addresses in code
  2. All data accessed through GOT
  3. 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:

TypeMeaning
R_WASM_FUNCTION_INDEX_LEBFunction index (for calls)
R_WASM_TABLE_INDEX_SLEBTable index (for indirect calls)
R_WASM_TABLE_INDEX_I32Same, but 32-bit
R_WASM_MEMORY_ADDR_LEBMemory address
R_WASM_MEMORY_ADDR_SLEBSigned memory address
R_WASM_MEMORY_ADDR_I3232-bit memory address
R_WASM_TYPE_INDEX_LEBType index
R_WASM_GLOBAL_INDEX_LEBGlobal 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:

SectionRelocations for
.rela.textCode
.rela.dataInitialized data
.rela.dynDynamic relocations
.rela.pltPLT 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

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:

  1. Collect all modules
  2. Find symbol definitions
  3. 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

  1. Relocations are instructions for patching placeholders with addresses
  2. Link-time relocations are resolved by the static linker
  3. Load-time relocations are resolved by the dynamic linker
  4. GOT and PLT enable position-independent access to external symbols
  5. WASM relocations are simpler—indices instead of addresses
  6. -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:

  1. Read all input object files
  2. Merge sections (all .text together, all .data together, etc.)
  3. Assign addresses to all sections
  4. Resolve symbols (match undefined to defined)
  5. Apply relocations (patch code with final addresses)
  6. 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 → 0x401000
  • add → 0x401032
  • multiply → 0x401050
  • message → 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:

  1. Scan the archive’s table of contents
  2. Include only objects that define needed symbols
  3. 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.

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:

  1. Merges function and data sections
  2. Renumbers all indices (functions, globals, tables)
  3. Merges type sections (deduplicating signatures)
  4. Combines memory segments
  5. 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.

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

  1. No runtime dependencies: The executable is self-contained
  2. Predictable behavior: No version conflicts
  3. Faster startup: No dynamic linking overhead
  4. Easier deployment: Copy one file
  5. Potential optimizations: LTO, dead code elimination

Disadvantages

  1. Larger binaries: Every executable includes its own copy of libraries
  2. No shared memory: Ten programs using libc = ten copies in memory
  3. No security updates: Must recompile to get library fixes
  4. Longer build times: More code to link
  5. 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

  1. Static linking combines everything into one executable
  2. The linker resolves all symbols and applies relocations
  3. Static libraries (.a) are archives with selective inclusion
  4. Link order matters for static libraries
  5. Dead code elimination removes unreachable code
  6. LTO enables cross-module optimization
  7. 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:

  1. Loads the executable
  2. Reads the INTERP segment to find the dynamic linker
  3. Loads the dynamic linker
  4. Jumps to the dynamic linker’s entry point

The dynamic linker (ld-linux.so on Linux) then:

  1. Reads the executable’s dynamic section
  2. Loads all required shared libraries
  3. Performs relocations
  4. Calls constructors
  5. 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:

  1. DT_RPATH / DT_RUNPATH: Paths embedded in the executable
  2. LD_LIBRARY_PATH: Environment variable
  3. /etc/ld.so.cache: Cached library locations
  4. 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:

  1. GOT entry points back to PLT (the push/jmp)
  2. Resolver is called
  3. Dynamic linker finds printf
  4. GOT entry updated to point to real printf

Second call:

  1. GOT entry points to real printf
  2. 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 on
  • INIT/FINI: Constructor/destructor functions
  • SYMTAB/STRTAB: Symbol and string tables
  • RELA/RELASZ: Relocation information
  • SONAME: 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.31.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:

  1. Load time: Finding and loading libraries
  2. Relocation time: Patching GOT entries
  3. Call overhead: PLT indirection (first call)
  4. Memory overhead: GOT, PLT, dynamic sections

For performance-critical code:

  • Use -fvisibility=hidden and export only what’s needed
  • Consider -fno-plt (direct GOT calls, removes PLT)
  • Use LD_BIND_NOW for 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

AspectStaticDynamic
Binary sizeLargerSmaller
Memory usageHigher (no sharing)Lower (sharing)
Startup timeFasterSlower
UpdatesRequires recompileJust update library
DeploymentSingle fileManage dependencies
Security updatesManualAutomatic

Key Takeaways

  1. Shared libraries are loaded at runtime, not linked in
  2. PIC enables code sharing between processes
  3. GOT/PLT enable position-independent access to external symbols
  4. The dynamic linker resolves symbols and performs relocations
  5. Symbol interposition allows overriding functions at runtime
  6. SONAME versioning enables backward-compatible updates
  7. 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:

FlagMeaning
RTLD_NOWResolve all symbols immediately
RTLD_LAZYResolve symbols on first use
RTLD_GLOBALSymbols available for subsequent loads
RTLD_LOCALSymbols not available to other libraries
RTLD_NODELETEDon’t unload on dlclose
RTLD_NOLOADDon’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:

UnixWindows
dlopenLoadLibrary
dlsymGetProcAddress
dlcloseFreeLibrary
dlerrorGetLastError
// 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

OperationCost
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_NOW in production (fail fast)
  • Use RTLD_LAZY in 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

  1. dlopen/dlsym/dlclose provide runtime library loading
  2. Plugin architectures use defined interfaces and dlsym
  3. RTLD_NEXT enables function wrapping
  4. Hot reloading is possible with careful lifecycle management
  5. Security matters—validate paths and understand symbol scope
  6. WASM uses JavaScript for runtime module loading
  7. 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 call
  • R_WASM_MEMORY_ADDR_LEB: Memory address
  • R_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

AspectELFWASM
Year introduced~1989 (SVR4)2015
Primary platformUnix (Linux, BSD, Solaris)Web browsers, edge, embedded
Security modelOS-enforcedFormat-enforced (sandbox)
Memory modelFlat address spaceLinear memory sandbox
Type systemNone (optional debug info)Mandatory, validated
Symbol namesArbitrary stringsModule.field pairs
RelocationAddress patchingIndex renumbering
Control flowArbitrary jumpsStructured only
Dynamic linkingFull (GOT, PLT, ld.so)Import-based only
Shared librariesYes (memory sharing)No (instance isolation)
Lazy bindingYesNo
Symbol interpositionYesNo
ArchitectureSpecific (per-arch)Portable (one format)
Compilation speedN/A (native code)Designed for fast JIT
Binary sizeLarger (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

  1. ELF optimizes for performance and flexibility at the cost of safety
  2. WASM optimizes for safety and portability at the cost of features
  3. ELF’s flat memory model enables pointer tricks; WASM’s sandbox prevents them
  4. ELF has no type checking; WASM enforces types at validation
  5. ELF’s dynamic linking is richer but more complex than WASM’s imports
  6. Structured control flow makes WASM safer but sometimes awkward
  7. 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

  1. Pre-built binary not available (the 404)
  2. Fell back to source compilation (gyp)
  3. 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:

StageSize
Initial build2.1 MB
+ Release build1.4 MB
+ LTO + opt-level z800 KB
+ strip750 KB
+ wasm-opt620 KB
+ dependency audit400 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

TaskELF ToolWASM Tool
List symbolsnm, readelf -swasm-objdump -t
Show sectionsreadelf -Swasm-objdump -h
Disassembleobjdump -dwasm2wat
Check dependenciesldd(inspect imports)
Debug loadingLD_DEBUG=allBrowser DevTools
Analyze sizebloatytwiggy
Strip debug infostripwasm-strip
Optimize(compiler flags)wasm-opt

Quick Reference: Error Messages

ErrorMeaningFix
undefined symbol: XSymbol X not found in any libraryCheck ldd output, install missing lib
version 'GLIBC_2.XX' not foundBuilt against newer glibcRebuild on older system or use static linking
ELF interpreter not foundWrong architecture or broken binaryCheck file output, ensure 64-bit matches
LinkError: import X not foundWASM import not providedCheck your imports object in JS
RuntimeError: unreachableWASM trap (assertion/bounds)Debug with source maps

Key Takeaways

  1. Native module errors are often ELF linking issues—use ldd, nm, readelf
  2. WASM binary size can be reduced 50-80% with proper optimization
  3. Bundlers are linkers—same concepts of tree-shaking and code splitting
  4. Native addons are shared objects—debug them like any ELF file
  5. Streaming compilation and caching make WASM load fast
  6. 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 strip because 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.
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. _Z3addii is 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.
Shared Library (.so)
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.