Operating Systems Module 7 – Memory management: addresses, segmentation and paging
Linking, loading and address binding
How a program gets its addresses: binding at compile, load or run time, base and limit registers, linking, ELF and Mach-O files, and dlopen.
What you will learn
- Explain when compile-time, load-time and run-time binding fix an address, and what each one allows
- Translate a logical address with base and limit registers, and name the access that traps
- Describe how static and dynamic linking resolve symbols with relocations, the PLT and the GOT
- Inspect a binary's sections, symbols and library dependencies with nm, objdump and otool
- Load a shared library while a program runs with dlopen, and call a function by its name
Before you start
What you’ll learn · Before you start
What you will learn
- Explain when compile-time, load-time and run-time binding fix an address, and what each one allows
- Translate a logical address with base and limit registers, and name the access that traps
- Describe how static and dynamic linking resolve symbols with relocations, the PLT and the GOT
- Inspect a binary's sections, symbols and library dependencies with nm, objdump and otool
- Load a shared library while a program runs with dlopen, and call a function by its name
Before you start
On this page
The compiler that turns main.c into main.o cannot know where main will sit in memory, nor where printf lives:
in the x86-64 object file built for this lesson, the call to printf is four bytes waiting for an address, with a
record beside them that says what to put there. Every such hole is filled before the call runs, and the moment it is
filled decides what the program can do, from running at one fixed address only to moving while it runs.
Here you follow two small C files from source to a running process: the three moments an address can be bound, the
base and limit registers that bind it on every access, the linker’s symbols and relocations, the ELF and Mach-O
formats with the tools that read them, and dlopen, which loads a library by name while a program runs.
Four steps take source code to a running process
Take two files. main.c calls greet, which greet.c defines, and printf, which neither file defines, and it stores
what greet returns in a global variable, visits. Four steps turn the two files into a running process, and each
step knows more about addresses than the one before it. Step through them, and watch which addresses are known at each
stage and which are still holes:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Source. The two files use names only. main calls greet and printf and writes the global visits, but nothing in the source says where any of them will be.
- Compile. The compiler and assembler turn each file into an object file. Its code and data start at offset 0, and every use of a name defined elsewhere, greet and printf in main.o, is left as a hole with a relocation record beside it.
- Link. The linker lays the objects out one after another and gives each section an address. Now greet is at 0x100000538, so the linker fills in main's call to it. printf is not in either file, so its hole stays open for the dynamic linker.
- Load. The kernel maps the program's segments into a new address space and starts the dynamic linker. dyld maps libSystem, finds printf in it and writes its address into the program's table of library addresses.
- Run. Every address the program uses is now a virtual address. On each instruction fetch, load and store, the MMU translates it to a physical one, and the kernel decides where that is.
The compiler works on one file at a time, so it can place code and data only relative to the start of that file’s own sections: its output, an object file: The output of compiling one source file: machine code and data in sections numbered from offset 0, a symbol table, and relocation records for the addresses still unknown., numbers everything from offset 0. The linker sees every object file of the program at once and can give each function its final place. The kernel and the dynamic linker decide where the program and its libraries go in memory when it starts, and the hardware translates every address while it runs.
| Step | Done by | What it fixes |
|---|---|---|
| Compile | The compiler and the assembler, once per source file | The code and data of the file, at offsets inside its sections; a hole for every name defined elsewhere |
| Link | The static linker, ld |
An address for every section and every function of the program; the holes for its own names |
| Load | The kernel and the dynamic linker | Where the program’s parts go in the address space; the addresses of library functions |
| Run | The CPU and its memory management unit | The memory location of every access, as it happens |
Those four rows answer when an address is fixed. The next section gives the three possible answers their names.
An address can be bound at compile, load or run time
address binding: Fixing which memory location an address in a program refers to. It can happen when the program is compiled, when it is loaded, or at every access while it runs. an address means deciding which memory location an address in the program
refers to. Take a program of five 4-byte words, compiled as if it started at address 0: a LOAD of the counter at
0x0010, an ADD, a STORE back to 0x0010, a JUMP to 0x0000, and the counter itself. Three of its operands are
addresses, and the kernel has room for the program only at 0x4000.
- Compile-time binding writes final addresses into the code. The program works only where the compiler assumed it
would sit: copied unchanged to 0x4000, its
LOADstill reads 0x0010, which belongs to something else. - Load-time binding keeps a list of the words that hold addresses, a relocation: Changing the addresses inside code or data to fit where they will be placed. An object file lists each place to change in a relocation record, with the symbol and the rule for the new value. list, and the loader adds the load address to each of them as it copies the program in.
- Run-time binding leaves the program exactly as it was compiled. The hardware adds the load address to every address the program uses, at the moment it uses it, so the program can be moved even while it runs.
The program below plays out all three on that image. It is written in 14 languages, their outputs were compared byte for byte, and the lines under the tabs are what every one of them printed:
Python 3.14 · main.py
IMAGE = [("LOAD", 0x10), ("ADD", 1), ("STORE", 0x10), ("JUMP", 0x00), ("DATA", 41)]
RELOCATIONS = [0, 2, 3] # the words whose operand is an address
BASE = 0x4000
def hex4(value: int) -> str:
return f"0x{value:04x}"
def show(image: list[tuple[str, int]], start: int, words: list[int]) -> None:
for i in words:
op, operand = image[i]
shown = hex4(operand) if i in RELOCATIONS else str(operand)
print(f" {hex4(start + 4 * i)} {op:<5} {shown}")
def run_time(base: int, limit: int, logicals: list[int]) -> None:
print(f"Run time, base {hex4(base)}, limit {hex4(limit)}:")
for logical in logicals:
# The MMU's check and sum, made on every access.
if logical >= limit:
print(f" logical {hex4(logical)} -> trap: not below the limit")
else:
print(f" logical {hex4(logical)} -> physical {hex4(base + logical)}")
size = 4 * len(IMAGE)
counter = IMAGE[0][1]
print(f"Compiled for 0x0000, {hex4(size)} bytes:")
show(IMAGE, 0, list(range(len(IMAGE))))
print(
f"Compile time, copied to {hex4(BASE)}: LOAD reads {hex4(counter)}, not {hex4(BASE + counter)}"
)
patched = [(op, n + BASE if i in RELOCATIONS else n) for i, (op, n) in enumerate(IMAGE)]
print(f"Load time, {len(RELOCATIONS)} operands patched for {hex4(BASE)}:")
show(patched, BASE, RELOCATIONS)
run_time(BASE, size, [0x10, 0x0C, 0x14])
run_time(0x9000, size, [0x10]) In Python: f"0x{value:04x}" pads the hexadecimal digits to four, {op:<5} pads the name to five columns, and a list comprehension builds the patched copy of the image.
Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py
JavaScript Node 24 · main.mjs
const IMAGE = [
["LOAD", 0x10],
["ADD", 1],
["STORE", 0x10],
["JUMP", 0x00],
["DATA", 41],
];
const RELOCATIONS = [0, 2, 3]; // the words whose operand is an address
const BASE = 0x4000;
const hex4 = (value) => "0x" + value.toString(16).padStart(4, "0");
function show(image, start, words) {
for (const i of words) {
const [op, operand] = image[i];
const shown = RELOCATIONS.includes(i) ? hex4(operand) : String(operand);
console.log(` ${hex4(start + 4 * i)} ${op.padEnd(5)} ${shown}`);
}
}
function runTime(base, limit, logicals) {
console.log(`Run time, base ${hex4(base)}, limit ${hex4(limit)}:`);
for (const logical of logicals) {
// The MMU's check and sum, made on every access.
if (logical >= limit) {
console.log(` logical ${hex4(logical)} -> trap: not below the limit`);
} else {
console.log(` logical ${hex4(logical)} -> physical ${hex4(base + logical)}`);
}
}
}
const size = 4 * IMAGE.length;
const counter = IMAGE[0][1];
console.log(`Compiled for 0x0000, ${hex4(size)} bytes:`);
show(IMAGE, 0, [...IMAGE.keys()]);
console.log(
`Compile time, copied to ${hex4(BASE)}: LOAD reads ${hex4(counter)}, not ${hex4(BASE + counter)}`,
);
const patched = IMAGE.map(([op, n], i) => [op, RELOCATIONS.includes(i) ? n + BASE : n]);
console.log(`Load time, ${RELOCATIONS.length} operands patched for ${hex4(BASE)}:`);
show(patched, BASE, RELOCATIONS);
runTime(BASE, size, [0x10, 0x0c, 0x14]);
runTime(0x9000, size, [0x10]); In JavaScript: toString(16) gives the hexadecimal digits and padStart pads them, since JavaScript has no printf, and map builds the patched copy of the image.
Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs
Java 25 LTS · Main.java
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.stream.IntStream;
public class Main {
record Word(String op, int operand) {}
static final List<Word> IMAGE =
List.of(
new Word("LOAD", 0x10),
new Word("ADD", 1),
new Word("STORE", 0x10),
new Word("JUMP", 0x00),
new Word("DATA", 41));
static final List<Integer> RELOCATIONS = List.of(0, 2, 3); // the words holding an address
static final int BASE = 0x4000;
static String hex4(int value) {
return String.format("0x%04x", value);
}
static void show(List<Word> image, int start, List<Integer> words) {
for (int i : words) {
Word w = image.get(i);
String shown = RELOCATIONS.contains(i) ? hex4(w.operand()) : String.valueOf(w.operand());
System.out.printf(" %s %-5s %s%n", hex4(start + 4 * i), w.op(), shown);
}
}
static void runTime(int base, int limit, int... logicals) {
System.out.printf("Run time, base %s, limit %s:%n", hex4(base), hex4(limit));
for (int logical : logicals) {
// The MMU's check and sum, made on every access.
if (logical >= limit) {
System.out.printf(" logical %s -> trap: not below the limit%n", hex4(logical));
} else {
System.out.printf(" logical %s -> physical %s%n", hex4(logical), hex4(base + logical));
}
}
}
public static void main(String[] args) {
Locale.setDefault(Locale.ROOT);
int size = 4 * IMAGE.size();
int counter = IMAGE.get(0).operand();
System.out.printf("Compiled for 0x0000, %s bytes:%n", hex4(size));
show(IMAGE, 0, IntStream.range(0, IMAGE.size()).boxed().toList());
System.out.printf(
"Compile time, copied to %s: LOAD reads %s, not %s%n",
hex4(BASE), hex4(counter), hex4(BASE + counter));
List<Word> patched = new ArrayList<>(IMAGE);
for (int i : RELOCATIONS) {
patched.set(i, new Word(IMAGE.get(i).op(), IMAGE.get(i).operand() + BASE));
}
System.out.printf("Load time, %d operands patched for %s:%n", RELOCATIONS.size(), hex4(BASE));
show(patched, BASE, RELOCATIONS);
runTime(BASE, size, 0x10, 0x0C, 0x14);
runTime(0x9000, size, 0x10);
}
} In Java: Word is a record and List.of makes the image unmodifiable, so the patched copy is an ArrayList whose relocated words are replaced by new records. runTime takes its addresses as varargs.
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java
C++23 · main.cpp
#include <algorithm>
#include <array>
#include <format>
#include <initializer_list>
#include <print>
#include <span>
#include <string>
#include <string_view>
struct Word {
std::string_view op;
unsigned operand;
};
constexpr std::array<Word, 5> IMAGE{{{"LOAD", 0x10},
{"ADD", 1},
{"STORE", 0x10},
{"JUMP", 0x00},
{"DATA", 41}}};
// the words whose operand is an address
constexpr std::array<std::size_t, 3> RELOCATIONS{0, 2, 3};
constexpr unsigned BASE = 0x4000;
std::string hex4(unsigned value) { return std::format("0x{:04x}", value); }
void show(std::span<const Word> image, unsigned start,
std::span<const std::size_t> words) {
for (const std::size_t i : words) {
const auto [op, operand] = image[i];
std::println(
" {} {:<5} {}", hex4(start + 4 * static_cast<unsigned>(i)), op,
std::ranges::contains(RELOCATIONS, i) ? hex4(operand)
: std::to_string(operand));
}
}
void run_time(unsigned base, unsigned limit,
std::initializer_list<unsigned> logicals) {
std::println("Run time, base {}, limit {}:", hex4(base), hex4(limit));
for (const unsigned logical : logicals) {
// The MMU's check and sum, made on every access.
if (logical >= limit) {
std::println(" logical {} -> trap: not below the limit", hex4(logical));
} else {
std::println(" logical {} -> physical {}", hex4(logical),
hex4(base + logical));
}
}
}
int main() {
const unsigned size = 4 * IMAGE.size();
const unsigned counter = IMAGE[0].operand;
std::println("Compiled for 0x0000, {} bytes:", hex4(size));
show(IMAGE, 0, std::array<std::size_t, 5>{0, 1, 2, 3, 4});
std::println("Compile time, copied to {}: LOAD reads {}, not {}", hex4(BASE),
hex4(counter), hex4(BASE + counter));
auto patched = IMAGE;
for (const std::size_t i : RELOCATIONS) {
patched[i].operand += BASE;
}
std::println("Load time, {} operands patched for {}:", RELOCATIONS.size(),
hex4(BASE));
show(patched, BASE, RELOCATIONS);
run_time(BASE, size, {0x10, 0x0C, 0x14});
run_time(0x9000, size, {0x10});
} In C++: auto patched = IMAGE copies the whole std::array, and std::ranges::contains, new in C++23, searches the relocation list.
Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main
C# 14 · .NET 10 · app.cs
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
(string Op, int Operand)[] image =
[("LOAD", 0x10), ("ADD", 1), ("STORE", 0x10), ("JUMP", 0x00), ("DATA", 41)];
int[] relocations = [0, 2, 3]; // the words whose operand is an address
const int Base = 0x4000;
int size = 4 * image.Length;
int counter = image[0].Operand;
Console.WriteLine($"Compiled for 0x0000, {Hex4(size)} bytes:");
Show(image, relocations, 0, Enumerable.Range(0, image.Length));
string reads = $"LOAD reads {Hex4(counter)}, not {Hex4(Base + counter)}";
Console.WriteLine($"Compile time, copied to {Hex4(Base)}: {reads}");
var patched = image
.Select((w, i) => relocations.Contains(i) ? (w.Op, w.Operand + Base) : w)
.ToArray();
Console.WriteLine($"Load time, {relocations.Length} operands patched for {Hex4(Base)}:");
Show(patched, relocations, Base, relocations);
RunTime(Base, size, 0x10, 0x0C, 0x14);
RunTime(0x9000, size, 0x10);
static string Hex4(int value) => $"0x{value:x4}";
static void Show(
(string Op, int Operand)[] image, int[] relocations, int start, IEnumerable<int> words)
{
foreach (int i in words)
{
var (op, operand) = image[i];
string shown = relocations.Contains(i) ? Hex4(operand) : operand.ToString();
Console.WriteLine($" {Hex4(start + 4 * i)} {op,-5} {shown}");
}
}
static void RunTime(int baseAddress, int limit, params int[] logicals)
{
Console.WriteLine($"Run time, base {Hex4(baseAddress)}, limit {Hex4(limit)}:");
foreach (int logical in logicals)
{
// The MMU's check and sum, made on every access.
Console.WriteLine(logical >= limit
? $" logical {Hex4(logical)} -> trap: not below the limit"
: $" logical {Hex4(logical)} -> physical {Hex4(baseAddress + logical)}");
}
} In C#: The image is an array of tuples and Select builds the patched copy. The parameter is named baseAddress, because base is a C# keyword.
Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs
C23 · main.c
#include <stddef.h>
#include <stdio.h>
typedef struct {
const char *op;
unsigned operand;
} Word;
enum { WORDS = 5, RELOCATED = 3, BASE = 0x4000 };
static const Word IMAGE[WORDS] = {
{"LOAD", 0x10}, {"ADD", 1}, {"STORE", 0x10}, {"JUMP", 0x00}, {"DATA", 41}};
// the words whose operand is an address
static const size_t RELOCATIONS[RELOCATED] = {0, 2, 3};
static bool relocated(size_t i) {
for (size_t k = 0; k < RELOCATED; k++) {
if (RELOCATIONS[k] == i) {
return true;
}
}
return false;
}
static void show(const Word image[], unsigned start, const size_t words[],
size_t count) {
for (size_t k = 0; k < count; k++) {
const size_t i = words[k];
printf(" 0x%04zx %-5s ", start + 4 * i, image[i].op);
printf(relocated(i) ? "0x%04x\n" : "%u\n", image[i].operand);
}
}
static void run_time(unsigned base, unsigned limit, const unsigned logicals[],
size_t count) {
printf("Run time, base 0x%04x, limit 0x%04x:\n", base, limit);
for (size_t k = 0; k < count; k++) {
// The MMU's check and sum, made on every access.
if (logicals[k] >= limit) {
printf(" logical 0x%04x -> trap: not below the limit\n", logicals[k]);
} else {
printf(" logical 0x%04x -> physical 0x%04x\n", logicals[k],
base + logicals[k]);
}
}
}
int main(void) {
const unsigned size = 4 * WORDS;
const unsigned counter = IMAGE[0].operand;
printf("Compiled for 0x0000, 0x%04x bytes:\n", size);
const size_t all[WORDS] = {0, 1, 2, 3, 4};
show(IMAGE, 0, all, WORDS);
printf("Compile time, copied to 0x%04x: LOAD reads 0x%04x, not 0x%04x\n",
BASE, counter, BASE + counter);
Word patched[WORDS];
for (size_t i = 0; i < WORDS; i++) {
patched[i] = IMAGE[i];
patched[i].operand += relocated(i) ? BASE : 0;
}
printf("Load time, %d operands patched for 0x%04x:\n", RELOCATED, BASE);
show(patched, BASE, RELOCATIONS, RELOCATED);
run_time(BASE, size, (const unsigned[]){0x10, 0x0C, 0x14}, 3);
run_time(0x9000, size, (const unsigned[]){0x10}, 1);
return 0;
} In C: The patched copy is a second array filled word by word, and the run-time addresses are passed as compound literals with their count.
Recorded with Apple clang 21.0.0 (C23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.c -o main && ./main
PHP 8.5 · main.php
<?php
declare(strict_types=1);
const IMAGE = [['LOAD', 0x10], ['ADD', 1], ['STORE', 0x10], ['JUMP', 0x00], ['DATA', 41]];
const RELOCATIONS = [0, 2, 3]; // the words whose operand is an address
const BASE = 0x4000;
function hex4(int $value): string
{
return sprintf('0x%04x', $value);
}
/**
* @param list<array{string, int}> $image
* @param list<int> $words
*/
function show(array $image, int $start, array $words): void
{
foreach ($words as $i) {
[$op, $operand] = $image[$i];
$shown = in_array($i, RELOCATIONS, true) ? hex4($operand) : (string) $operand;
printf(" %s %-5s %s\n", hex4($start + 4 * $i), $op, $shown);
}
}
function runTime(int $base, int $limit, int ...$logicals): void
{
printf("Run time, base %s, limit %s:\n", hex4($base), hex4($limit));
foreach ($logicals as $logical) {
// The MMU's check and sum, made on every access.
if ($logical >= $limit) {
printf(" logical %s -> trap: not below the limit\n", hex4($logical));
} else {
printf(" logical %s -> physical %s\n", hex4($logical), hex4($base + $logical));
}
}
}
$size = 4 * count(IMAGE);
$counter = IMAGE[0][1];
printf("Compiled for 0x0000, %s bytes:\n", hex4($size));
show(IMAGE, 0, array_keys(IMAGE));
$reads = sprintf('LOAD reads %s, not %s', hex4($counter), hex4(BASE + $counter));
printf("Compile time, copied to %s: %s\n", hex4(BASE), $reads);
$patched = IMAGE;
foreach (RELOCATIONS as $i) {
$patched[$i][1] += BASE;
}
printf("Load time, %d operands patched for %s:\n", count(RELOCATIONS), hex4(BASE));
show($patched, BASE, RELOCATIONS);
runTime(BASE, $size, 0x10, 0x0C, 0x14);
runTime(0x9000, $size, 0x10); In PHP: PHP arrays are values, so $patched = IMAGE is a copy that can be patched, and runTime takes its addresses as a variadic parameter.
Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php
Rust 1.99 · main.rs
const IMAGE: [(&str, u32); 5] = [
("LOAD", 0x10),
("ADD", 1),
("STORE", 0x10),
("JUMP", 0x00),
("DATA", 41),
];
const RELOCATIONS: [usize; 3] = [0, 2, 3]; // the words whose operand is an address
const BASE: u32 = 0x4000;
fn hex4(value: u32) -> String {
format!("{value:#06x}")
}
fn show(image: &[(&str, u32)], start: u32, words: &[usize]) {
for &i in words {
let (op, operand) = image[i];
let shown = if RELOCATIONS.contains(&i) {
hex4(operand)
} else {
operand.to_string()
};
println!(" {} {op:<5} {shown}", hex4(start + 4 * i as u32));
}
}
fn run_time(base: u32, limit: u32, logicals: &[u32]) {
println!("Run time, base {}, limit {}:", hex4(base), hex4(limit));
for &logical in logicals {
// The MMU's check and sum, made on every access.
if logical >= limit {
println!(" logical {} -> trap: not below the limit", hex4(logical));
} else {
println!(
" logical {} -> physical {}",
hex4(logical),
hex4(base + logical)
);
}
}
}
fn main() {
let size = 4 * IMAGE.len() as u32;
let counter = IMAGE[0].1;
println!("Compiled for 0x0000, {} bytes:", hex4(size));
show(&IMAGE, 0, &[0, 1, 2, 3, 4]);
println!(
"Compile time, copied to {}: LOAD reads {}, not {}",
hex4(BASE),
hex4(counter),
hex4(BASE + counter)
);
let mut patched = IMAGE;
for i in RELOCATIONS {
patched[i].1 += BASE;
}
println!(
"Load time, {} operands patched for {}:",
RELOCATIONS.len(),
hex4(BASE)
);
show(&patched, BASE, &RELOCATIONS);
run_time(BASE, size, &[0x10, 0x0C, 0x14]);
run_time(0x9000, size, &[0x10]);
} In Rust: let mut patched = IMAGE copies the array of tuples, and patched[i].1 changes the second field of a word. In {value:#06x} the width of six counts the 0x.
Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main
TypeScript 6.0 · main.ts
type Word = readonly [op: string, operand: number];
const IMAGE: readonly Word[] = [
["LOAD", 0x10],
["ADD", 1],
["STORE", 0x10],
["JUMP", 0x00],
["DATA", 41],
];
const RELOCATIONS: readonly number[] = [0, 2, 3]; // the words whose operand is an address
const BASE = 0x4000;
const hex4 = (value: number): string => "0x" + value.toString(16).padStart(4, "0");
function show(image: readonly Word[], start: number, words: readonly number[]): void {
for (const i of words) {
const [op, operand] = image[i];
const shown = RELOCATIONS.includes(i) ? hex4(operand) : String(operand);
console.log(` ${hex4(start + 4 * i)} ${op.padEnd(5)} ${shown}`);
}
}
function runTime(base: number, limit: number, logicals: readonly number[]): void {
console.log(`Run time, base ${hex4(base)}, limit ${hex4(limit)}:`);
for (const logical of logicals) {
// The MMU's check and sum, made on every access.
if (logical >= limit) {
console.log(` logical ${hex4(logical)} -> trap: not below the limit`);
} else {
console.log(` logical ${hex4(logical)} -> physical ${hex4(base + logical)}`);
}
}
}
const size = 4 * IMAGE.length;
const counter = IMAGE[0][1];
console.log(`Compiled for 0x0000, ${hex4(size)} bytes:`);
show(IMAGE, 0, [...IMAGE.keys()]);
console.log(
`Compile time, copied to ${hex4(BASE)}: LOAD reads ${hex4(counter)}, not ${hex4(BASE + counter)}`,
);
const patched = IMAGE.map(([op, n], i): Word => [op, RELOCATIONS.includes(i) ? n + BASE : n]);
console.log(`Load time, ${RELOCATIONS.length} operands patched for ${hex4(BASE)}:`);
show(patched, BASE, RELOCATIONS);
runTime(BASE, size, [0x10, 0x0c, 0x14]);
runTime(0x9000, size, [0x10]); In TypeScript: Word is a labelled readonly tuple, so the compiler refuses any write into the image, and map returns new tuples for the patched copy.
Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts
Go 1.27 · main.go
package main
import (
"fmt"
"slices"
)
type word struct {
op string
operand int
}
var image = []word{{"LOAD", 0x10}, {"ADD", 1}, {"STORE", 0x10}, {"JUMP", 0x00}, {"DATA", 41}}
// the words whose operand is an address
var relocations = []int{0, 2, 3}
const loadBase = 0x4000
func hex4(value int) string {
return fmt.Sprintf("0x%04x", value)
}
func show(img []word, start int, words []int) {
for _, i := range words {
shown := fmt.Sprint(img[i].operand)
if slices.Contains(relocations, i) {
shown = hex4(img[i].operand)
}
fmt.Printf(" %s %-5s %s\n", hex4(start+4*i), img[i].op, shown)
}
}
func runTime(base, limit int, logicals ...int) {
fmt.Printf("Run time, base %s, limit %s:\n", hex4(base), hex4(limit))
for _, logical := range logicals {
// The MMU's check and sum, made on every access.
if logical >= limit {
fmt.Printf(" logical %s -> trap: not below the limit\n", hex4(logical))
} else {
fmt.Printf(" logical %s -> physical %s\n", hex4(logical), hex4(base+logical))
}
}
}
func main() {
size := 4 * len(image)
counter := image[0].operand
fmt.Printf("Compiled for 0x0000, %s bytes:\n", hex4(size))
show(image, 0, []int{0, 1, 2, 3, 4})
fmt.Printf("Compile time, copied to %s: LOAD reads %s, not %s\n",
hex4(loadBase), hex4(counter), hex4(loadBase+counter))
patched := slices.Clone(image)
for _, i := range relocations {
patched[i].operand += loadBase
}
fmt.Printf("Load time, %d operands patched for %s:\n", len(relocations), hex4(loadBase))
show(patched, loadBase, relocations)
runTime(loadBase, size, 0x10, 0x0C, 0x14)
runTime(0x9000, size, 0x10)
} In Go: slices.Clone copies the image before the loop patches the copy, and runTime takes its addresses as a variadic parameter.
Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go
Swift 6.4 · main.swift
let image = [("LOAD", 0x10), ("ADD", 1), ("STORE", 0x10), ("JUMP", 0x00), ("DATA", 41)]
let relocations = [0, 2, 3] // the words whose operand is an address
let loadBase = 0x4000
func hex4(_ value: Int) -> String {
let digits = String(value, radix: 16)
return "0x" + String(repeating: "0", count: max(0, 4 - digits.count)) + digits
}
func show(_ image: [(String, Int)], start: Int, words: [Int]) {
for i in words {
let (op, operand) = image[i]
let shown = relocations.contains(i) ? hex4(operand) : String(operand)
let padded = op + String(repeating: " ", count: 5 - op.count)
print(" \(hex4(start + 4 * i)) \(padded) \(shown)")
}
}
func runTime(base: Int, limit: Int, logicals: [Int]) {
print("Run time, base \(hex4(base)), limit \(hex4(limit)):")
for logical in logicals {
// The MMU's check and sum, made on every access.
guard logical < limit else {
print(" logical \(hex4(logical)) -> trap: not below the limit")
continue
}
print(" logical \(hex4(logical)) -> physical \(hex4(base + logical))")
}
}
let size = 4 * image.count
let counter = image[0].1
print("Compiled for 0x0000, \(hex4(size)) bytes:")
show(image, start: 0, words: Array(image.indices))
let reads = "LOAD reads \(hex4(counter)), not \(hex4(loadBase + counter))"
print("Compile time, copied to \(hex4(loadBase)): \(reads)")
var patched = image
for i in relocations {
patched[i].1 += loadBase
}
print("Load time, \(relocations.count) operands patched for \(hex4(loadBase)):")
show(patched, start: loadBase, words: relocations)
runTime(base: loadBase, limit: size, logicals: [0x10, 0x0C, 0x14])
runTime(base: 0x9000, limit: size, logicals: [0x10]) In Swift: The image is an array of tuples, which are values: var patched = image copies it, so patching leaves image as it was compiled.
Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main
Ruby 4.0 · main.rb
# frozen_string_literal: true
IMAGE = [['LOAD', 0x10], ['ADD', 1], ['STORE', 0x10], ['JUMP', 0x00], ['DATA', 41]].freeze
RELOCATIONS = [0, 2, 3].freeze # the words whose operand is an address
BASE = 0x4000
def hex4(value)
format('0x%04x', value)
end
def show(image, start, words)
words.each do |i|
op, operand = image[i]
shown = RELOCATIONS.include?(i) ? hex4(operand) : operand.to_s
puts " #{hex4(start + (4 * i))} #{op.ljust(5)} #{shown}"
end
end
def run_time(base, limit, logicals)
puts "Run time, base #{hex4(base)}, limit #{hex4(limit)}:"
logicals.each do |logical|
# The MMU's check and sum, made on every access.
if logical >= limit
puts " logical #{hex4(logical)} -> trap: not below the limit"
else
puts " logical #{hex4(logical)} -> physical #{hex4(base + logical)}"
end
end
end
size = 4 * IMAGE.size
counter = IMAGE[0][1]
puts "Compiled for 0x0000, #{hex4(size)} bytes:"
show(IMAGE, 0, (0...IMAGE.size).to_a)
reads = "LOAD reads #{hex4(counter)}, not #{hex4(BASE + counter)}"
puts "Compile time, copied to #{hex4(BASE)}: #{reads}"
patched = IMAGE.each_with_index.map { |(op, n), i| [op, RELOCATIONS.include?(i) ? n + BASE : n] }
puts "Load time, #{RELOCATIONS.size} operands patched for #{hex4(BASE)}:"
show(patched, BASE, RELOCATIONS)
run_time(BASE, size, [0x10, 0x0C, 0x14])
run_time(0x9000, size, [0x10]) In Ruby: format('0x%04x', value) is Ruby's sprintf, and each_with_index.map hands every word to the block together with its index.
Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb
Kotlin 2.4 · Main.kt
val image = listOf("LOAD" to 0x10, "ADD" to 1, "STORE" to 0x10, "JUMP" to 0x00, "DATA" to 41)
val relocations = intArrayOf(0, 2, 3) // the words whose operand is an address
const val BASE = 0x4000
fun hex4(value: Int) = "0x" + value.toString(16).padStart(4, '0')
fun show(
image: List<Pair<String, Int>>,
start: Int,
words: List<Int>,
) {
for (i in words) {
val (op, operand) = image[i]
val shown = if (i in relocations) hex4(operand) else operand.toString()
println(" ${hex4(start + 4 * i)} ${op.padEnd(5)} $shown")
}
}
fun runTime(
base: Int,
limit: Int,
vararg logicals: Int,
) {
println("Run time, base ${hex4(base)}, limit ${hex4(limit)}:")
for (logical in logicals) {
// The MMU's check and sum, made on every access.
if (logical >= limit) {
println(" logical ${hex4(logical)} -> trap: not below the limit")
} else {
println(" logical ${hex4(logical)} -> physical ${hex4(base + logical)}")
}
}
}
fun main() {
val size = 4 * image.size
val counter = image[0].second
println("Compiled for 0x0000, ${hex4(size)} bytes:")
show(image, 0, image.indices.toList())
val reads = "LOAD reads ${hex4(counter)}, not ${hex4(BASE + counter)}"
println("Compile time, copied to ${hex4(BASE)}: $reads")
val patched = image.mapIndexed { i, (op, n) -> op to if (i in relocations) n + BASE else n }
println("Load time, ${relocations.size} operands patched for ${hex4(BASE)}:")
show(patched, BASE, relocations.toList())
runTime(BASE, size, 0x10, 0x0C, 0x14)
runTime(0x9000, size, 0x10)
} In Kotlin: The words are pairs, mapIndexed builds the patched copy with if as an expression, and runTime takes its addresses as vararg.
Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar
Dart 3.13 · main.dart
const image = [
('LOAD', 0x10),
('ADD', 1),
('STORE', 0x10),
('JUMP', 0x00),
('DATA', 41),
];
const relocations = [0, 2, 3]; // the words whose operand is an address
const loadBase = 0x4000;
String hex4(int value) => '0x${value.toRadixString(16).padLeft(4, '0')}';
void show(List<(String, int)> image, int start, List<int> words) {
for (final i in words) {
final (op, operand) = image[i];
final shown = relocations.contains(i) ? hex4(operand) : '$operand';
print(' ${hex4(start + 4 * i)} ${op.padRight(5)} $shown');
}
}
void runTime(int base, int limit, List<int> logicals) {
print('Run time, base ${hex4(base)}, limit ${hex4(limit)}:');
for (final logical in logicals) {
// The MMU's check and sum, made on every access.
if (logical >= limit) {
print(' logical ${hex4(logical)} -> trap: not below the limit');
} else {
print(' logical ${hex4(logical)} -> physical ${hex4(base + logical)}');
}
}
}
void main() {
final size = 4 * image.length;
final counter = image[0].$2;
print('Compiled for 0x0000, ${hex4(size)} bytes:');
show(image, 0, [for (var i = 0; i < image.length; i++) i]);
print(
'Compile time, copied to ${hex4(loadBase)}: '
'LOAD reads ${hex4(counter)}, not ${hex4(loadBase + counter)}',
);
final patched = [
for (final (i, (op, n)) in image.indexed)
(op, relocations.contains(i) ? n + loadBase : n),
];
print(
'Load time, ${relocations.length} operands patched for ${hex4(loadBase)}:',
);
show(patched, loadBase, relocations);
runTime(loadBase, size, [0x10, 0x0C, 0x14]);
runTime(0x9000, size, [0x10]);
} In Dart: The words are records, read with the pattern (op, operand), and a collection for over image.indexed builds the patched copy.
Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart
What every language printed
Compiled for 0x0000, 0x0014 bytes: 0x0000 LOAD 0x0010 0x0004 ADD 1 0x0008 STORE 0x0010 0x000c JUMP 0x0000 0x0010 DATA 41 Compile time, copied to 0x4000: LOAD reads 0x0010, not 0x4010 Load time, 3 operands patched for 0x4000: 0x4000 LOAD 0x4010 0x4008 STORE 0x4010 0x400c JUMP 0x4000 Run time, base 0x4000, limit 0x0014: logical 0x0010 -> physical 0x4010 logical 0x000c -> physical 0x400c logical 0x0014 -> trap: not below the limit Run time, base 0x9000, limit 0x0014: logical 0x0010 -> physical 0x9010
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. Runs on this device: a JavaScript build of this program (about 67 KB), made when the page was built. The first run also downloads the JavaScript runner (about 0.6 MB), which is kept for the next runs. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 152 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device: a .NET build of this program (about 4 KB), made when the page was built. The first run also downloads .NET (about 3.6 MB), which is kept for the next runs. Edit runs your changes in the browser: the first edited run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 8 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads PHP (about 4.3 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 21 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads the JavaScript runner (about 0.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 719 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 1.7 MB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads Ruby (about 10.2 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 183 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 11 KB), made when the page was built. To change the code, copy it and run it with the command below.Your run, in this browser
Compile-time binding leaves LOAD reading 0x0010 while the counter sits at 0x4010. Load-time binding fixes that by
patching three operands, but the patched copy is now tied to 0x4000: moving it would mean patching it again, and the
loader must know every word that holds an address. Run-time binding needed no patching at all, and moving the program
to 0x9000 changed one register. Its price is an addition on every memory access, so it needs hardware that adds at
full speed.
Key idea
The later an address is bound, the more freedom the system keeps: compile time fixes one place, load time allows any place chosen once, and run time lets the kernel move a program whenever it needs to, at the cost of hardware that translates every access.
Checkpoint
Check: From source to process in four steps · Compile-time, load-time and run-time binding
The MMU checks every address against a limit and adds a base
Run-time binding puts hardware between the CPU and memory. The addresses a program uses are logical address: An address as a program uses it, before translation. The MMU turns it into a physical address on every access; with paging it is usually called a virtual address., the addresses memory receives are physical address: The address of a byte in the computer's main memory, as the memory receives it after the MMU has translated the address the program used., and the memory management unit (MMU): The part of each core that translates every virtual address a program uses into a physical address, using page tables the kernel wrote, and refuses accesses those tables do not allow. More in “The hardware an operating system manages” turns one into the other on every instruction fetch, load and store. The simplest MMU has two registers, the base and limit registers: The two registers of the simplest MMU. An address below the limit is added to the base to give the physical address; any other address traps into the kernel.: an address below the limit is added to the base, and any other address traps into the kernel. Step through two accesses of the program above, loaded at 0x4000:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- A logical address. The program runs LOAD 0x0010 as it was compiled, unpatched. The CPU hands the logical address 0x0010 to the MMU before anything reaches memory.
- The limit check. The program is 0x0014 bytes long, so the limit register holds 0x0014. 0x0010 is below it, so the access is inside the program.
- The sum. The MMU adds the base register, 0x4000, the place where the kernel loaded the program. Logical 0x0010 becomes physical 0x4010.
- Memory answers. Memory reads physical address 0x4010, the counter word of this program, and never sees the logical address at all.
- An address past the end. The next access asks for logical 0x0014, the first byte after the program. The check compares it with the limit before any sum is made.
- The trap. 0x0014 is not below the limit, so the MMU refuses the access and traps into the kernel. No memory outside the program is read, whatever its address would have been.
- Moving the program. To move the program to 0x9000, the kernel copies its bytes and loads 0x9000 into the base register. The image needs no patching, and logical 0x0010 now reaches 0x9010.
The check comes before the sum, so a program cannot reach a byte outside its own area, whatever address it computes. The limit protects every other program and the kernel; the base relocates. Only the kernel may load the two registers, when it switches from one program to another, because a program that could set its own base could reach any memory at all. Protection and relocation come from the same two registers.
Base and limit treat the whole program as one block of memory. Today’s processors translate in small fixed-size pages instead, each with its own entry in a page table, and the Linux kernel’s documentation describes the same division of labour: when “an MMU is available”, the CPU “can translate a virtual address to a physical address”. Logical addresses are then usually called virtual addresses. The lessons that follow build from this one-block version up to paging.
Reading the limit as the last valid address
The limit holds the size of the program, 0x0014 here, so the last address that passes is 0x0013, and 0x0014 itself
traps. A check written as logical <= limit lets one byte past the end through.
Checkpoint
Check: Base and limit registers
Linking resolves symbols and patches the places that use them
Back to main.c and greet.c. The session below compiles each file on its own, lists the symbols each object file
defines or needs with nm -g, links the two files, and lists the program’s symbols:
"""Compiles main.c and greet.c on their own, reads their symbols, links them and reads the program's symbols."""
from session import run, version
version("clang --version")
run("clang -O1 -c main.c greet.c")
run("nm -g main.o")
run("nm -g greet.o")
run("clang main.o greet.o -o hello")
run("nm -g hello")
run("./hello") Output
# Apple clang version 21.0.0 (clang-2100.3.34.2)
$ clang -O1 -c main.c greet.c
$ nm -g main.o
U _greet
0000000000000000 T _main
U _printf
0000000000000078 S _visits
$ nm -g greet.o
0000000000000000 T _greet
U _printf
$ clang main.o greet.o -o hello
$ nm -g hello
0000000100000000 T __mh_execute_header
0000000100000538 T _greet
00000001000004f8 T _main
U _printf
0000000100008000 S _visits
$ ./hello
Hello, Asha
4 letters
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 build_session.py
nm marks a symbol that a file defines with the kind of section it is in, T for code and S for another section, here
that of the global visits, and a symbol that the file only uses with U, which LLVM’s documentation defines as
“undefined in this file”. In main.o, _main sits at offset 0 of
the file’s code, while _greet and _printf are U: the compiler left holes for them. (Mach-O puts an underscore in
front of every C name.) greet.o defines _greet and needs _printf too. After linking, _greet has an address,
0x100000538, and main’s call to it has been filled in. _printf is still U, because neither file defines it.
Each hole is a relocation record: a place, a symbol, and a rule for the value to write there. The same main.c,
compiled into an ELF object file for x86-64 Linux, shows its records:
"""Compiles main.c into an ELF object for x86-64 Linux, then lists its sections and its relocation records."""
from session import run, version
version("clang --version")
run("clang --target=x86_64-linux-gnu -O1 -c main.c -o main-elf.o")
run("objdump -h main-elf.o")
run("objdump -r main-elf.o") Output
# Apple clang version 21.0.0 (clang-2100.3.34.2) $ clang --target=x86_64-linux-gnu -O1 -c main.c -o main-elf.o $ objdump -h main-elf.o main-elf.o: file format elf64-x86-64 Sections: Idx Name Size VMA Type 0 00000000 0000000000000000 1 .strtab 00000096 0000000000000000 2 .text 00000027 0000000000000000 TEXT 3 .rela.text 00000078 0000000000000000 4 .bss 00000004 0000000000000000 BSS 5 .rodata.str1.1 00000011 0000000000000000 DATA 6 .comment 00000030 0000000000000000 7 .note.GNU-stack 00000000 0000000000000000 8 .eh_frame 00000030 0000000000000000 DATA 9 .rela.eh_frame 00000018 0000000000000000 10 .llvm_addrsig 00000000 0000000000000000 11 .symtab 000000d8 0000000000000000 $ objdump -r main-elf.o main-elf.o: file format elf64-x86-64 RELOCATION RECORDS FOR [.text]: OFFSET TYPE VALUE 0000000000000004 R_X86_64_PC32 .L.str-0x4 0000000000000009 R_X86_64_PLT32 greet-0x4 000000000000000f R_X86_64_PC32 visits-0x4 0000000000000016 R_X86_64_PC32 .L.str.1-0x4 000000000000001f R_X86_64_PLT32 printf-0x4 RELOCATION RECORDS FOR [.eh_frame]: OFFSET TYPE VALUE 0000000000000020 R_X86_64_PC32 .text
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 elf_session.py
Five records cover .text, the code. The two at offsets 0x9 and 0x1f are the calls: R_X86_64_PLT32 greet-0x4 asks
for the distance from that place in the code to greet, minus 4, which is how an x86-64 call encodes its target. The
other three reach the two string literals and visits, and one more record covers .eh_frame, the table that error
handling uses to unwind calls. The linker computes each value once it has placed everything,
and hands the records for names that only a library defines on to the dynamic linker.
That hand-over is the difference between static and dynamic linking. A static linker copies the library code a program uses into the program: in the words of Apple’s documentation, the code “is copied to the generated executable file”. A shared library: A library that is not copied into the programs that use it but loaded into their address spaces when they start or while they run, so one copy of its code serves many processes. is not copied. The program records its name, and the dynamic linker: The program that loads a program's shared libraries and resolves their names when it starts, or later on request: ld-linux.so on Linux, dyld on macOS. loads it when the program starts, or later when the program asks.
ELF and Mach-O: sections for the linker, segments for the loader
Linux keeps object files, programs and shared libraries in one format, the Executable and Linkable Format (ELF), and macOS in another, Mach-O. Both answer the same two needs. The System V ABI names the two jobs that object files serve, program linking, which builds a program, and program execution, which runs it, so the format offers parallel views of the same bytes:
The same ELF format seen by the linker, in sections, and by the loader, in segments
Text description of the diagram
The figure has two columns of boxes. The left column is the linking view of an object file, the right column the execution view of a program.
- Both columns start with the ELF header, at offset zero of the file. An object file's header gives the type ET_REL, a relocatable file; a program's gives ET_EXEC, an executable, or ET_DYN, a shared object.
- Next is the program header table. An object file may leave it out; a program has one entry in it for each segment.
- In the linking view the file is made of sections, such as .text for the code and .rodata for constants, .data and .bss for variables, .symtab for the symbols and .rela.text for the relocations of the code.
- In the execution view the same bytes are grouped into segments by their permissions: a read-and-execute segment holding .text and .rodata, and a read-and-write segment holding .data and .bss.
- Both columns end with the section header table. The linker needs it, with one entry per section; a program may leave it out, because the loader reads only the program headers.
The linker reads sections, listed in the section header table: .text, .rodata, .data, .bss, the symbol table
and the relocation records you saw above. The loader reads segments, listed in the program header table, which the
ABI says “tells the system how to create a process image”. A segment groups sections that need the same permissions,
such as code with constants, and data with zero-filled data. objdump -h listed eleven sections in the ELF object
file, after an empty entry 0, and the file has no segments at all: the ABI lets a relocatable file leave the program
header table out, because nothing loads it. The Mac’s linked program shows the other view:
"""Builds the program, then reads its Mach-O header, its segments, the libraries it needs and what dyld must bind."""
from session import run, version
version("clang --version")
run("clang -O1 main.c greet.c -o hello")
run("otool -hv hello")
run("size -m hello")
run("otool -l hello | head -12")
run("otool -L hello")
run("dyld_info -fixups hello") Output
# Apple clang version 21.0.0 (clang-2100.3.34.2)
$ clang -O1 main.c greet.c -o hello
$ otool -hv hello
hello:
Mach header
magic cputype cpusubtype caps filetype ncmds sizeofcmds flags
MH_MAGIC_64 ARM64 ALL 0x00 EXECUTE 18 1208 NOUNDEFS DYLDLINK TWOLEVEL PIE
$ size -m hello
Segment __PAGEZERO: 4294967296 (zero fill)
Segment __TEXT: 16384
Section __text: 108
Section __stubs: 12
Section __cstring: 28
Section __unwind_info: 88
total 236
Segment __DATA_CONST: 16384
Section __got: 8
total 8
Segment __DATA: 16384
Section __common: 4 (zerofill)
total 4
Segment __LINKEDIT: 16384
total 4295032832
$ otool -l hello | head -12
hello:
Load command 0
cmd LC_SEGMENT_64
cmdsize 72
segname __PAGEZERO
vmaddr 0x0000000000000000
vmsize 0x0000000100000000
fileoff 0
filesize 0
maxprot 0x00000000
initprot 0x00000000
nsects 0
$ otool -L hello
hello:
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1359.0.0)
$ dyld_info -fixups hello
hello [arm64]:
-fixups:
segment section address type target
__DATA_CONST __got 0x100004000 bind libSystem/_printf
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 macho_session.py
otool -hv reads the Mach-O header: an arm64 executable, with the PIE flag that the next section explains. size -m
lists its segments with the sections inside them, which Apple’s Mach-O overview describes as “organized into segments.
Each segment contains one or more sections”. __TEXT holds the code and the string literals, and __DATA the
variable visits. Each segment is 16,384 bytes, although __TEXT’s sections add up to only 236, because a segment is
mapped in whole pages, and a page on this Mac is 16 KiB. __PAGEZERO covers the lowest 4 GiB with nothing from the
file, and its load command, the first one otool -l prints, gives it the protections 0: no reading, writing or
executing, so a null pointer, or any small number used as an address, traps. otool -L lists the one library the
program needs, libSystem, and dyld_info -fixups shows the one address the dynamic linker must write when it starts.
| To see | On Linux (ELF) | On macOS (Mach-O) |
|---|---|---|
| The file’s header and type | readelf -h |
otool -hv |
| Sections | readelf -S, objdump -h |
otool -l, size -m |
| Segments, for the loader | readelf -l |
otool -l, size -m |
| Symbols defined and needed | nm, readelf -s |
nm |
| Relocation records | readelf -r, objdump -r |
objdump -r, dyld_info -fixups |
| Shared libraries needed | ldd, readelf -d |
otool -L |
Checkpoint
Check: Symbols and relocation records · Sections and segments in ELF and Mach-O
Shared libraries are bound at launch or at the first call
When an ELF program starts, the kernel reads the path of a program interpreter from the program header, PT_INTERP,
and runs it. That interpreter is the dynamic linker, ld-linux.so on Linux, which finds and loads “the shared objects
(shared libraries) needed by a program”, prepares the program and runs it. The libraries it loads are named in the
program’s DT_NEEDED entries; on macOS, dyld reads the same list from the load commands that otool -L printed.
A program calls a library function through two tables. The call goes to the function’s entry in the
procedure linkage table (PLT): A table of small code entries inside an ELF program or library, one for each library function it calls. Each entry jumps through that function's slot in the global offset table., inside the program itself, and that entry jumps to the address in the
function’s slot of the global offset table (GOT): A table of addresses inside a program or library that the dynamic linker fills in, so that code can reach functions and data whose addresses are known only when it is loaded., which the dynamic linker fills in. When it fills the
slots is up to the dynamic linker: the System V ABI says it is “permitted to evaluate procedure linkage table entries
lazily”, so that a function that is never called is never looked up. That is
lazy binding: Looking up a library function's address the first time it is called rather than when the program starts, so that functions that are never called cost nothing.. Step through two calls of printf on Linux:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The first call. main calls printf. The linker made that call go to printf's entry in the procedure linkage table, a few instructions inside the program itself.
- The slot is not filled yet. The PLT entry jumps to the address in printf's slot of the global offset table. With lazy binding that slot still leads back into the PLT, to code that calls the dynamic linker.
- The lookup. The dynamic linker searches the loaded libraries for printf and finds it in the C library, which it mapped when the program started.
- The slot is patched. It writes printf's address into the GOT slot and jumps to printf. printf prints and returns to main as from any call.
- The second call. The next call of printf reaches the same PLT entry, which now jumps straight through the patched slot to printf. The dynamic linker is not involved any more.
- Binding now instead. With LD_BIND_NOW set, or a program linked to bind at once, the dynamic linker fills every slot before main starts, and the first call is as fast as the second.
The first call costs a lookup, every later one a jump through the slot. Setting LD_BIND_NOW tells Linux’s dynamic
linker to resolve every symbol when the program starts rather than at its first use, as its manual page explains, which
trades a slower start for no lookups during the run. The Mac’s program above was built without lazy binding:
size -m lists no __la_symbol_ptr section, the “lazy” symbol pointers of Apple’s Mach-O overview, and
dyld_info -fixups lists _printf as a bind into __got, a pointer that dyld writes when it loads the program.
The tables exist because a shared library has no fixed address. Its code is
position-independent code (PIC): Machine code that runs correctly at any address, because it reaches its own code and data relative to where it runs and other addresses through the global offset table., which GCC’s documentation describes as
code that “accesses all constant addresses through a global offset table (GOT)”, so the same pages of code can be
mapped at any address in every process that uses them. A program built as a position-independent executable, with
-fPIE and -pie on Linux and by default on macOS, where otool -hv showed the PIE flag, can be placed anywhere
too, and that is what address space layout randomisation needs: Linux’s randomize_va_space setting randomises the
base of the mappings, and with it every shared library.
Loading a library by name while the program runs
Everything so far was decided by the time main started. dlopen loads a library later, when the program asks for
it: in the words of the Linux manual page it “loads the dynamic shared object (shared library)” named by its argument
and returns a handle, and dlsym looks a symbol up in it by name. A program can then call a function
whose name it learns while it runs, which is how plug-ins work. The session builds shout.c as a shared library and
host.c as a program that loads it by name, then asks it for two functions:
"""Builds shout.c as a shared library and host.c as a program that loads it by name while it runs."""
from session import run, version
version("clang --version")
run("clang -std=c23 -O2 -Wall -Wextra -Werror -shared -fPIC shout.c -o libshout.dylib")
run("clang -std=c23 -O2 -Wall -Wextra -Werror host.c -o host")
run("./host ./libshout.dylib shout")
run("./host ./libshout.dylib whisper", 1)
run("nm -gU libshout.dylib")
run("otool -L host") Output
# Apple clang version 21.0.0 (clang-2100.3.34.2) $ clang -std=c23 -O2 -Wall -Wextra -Werror -shared -fPIC shout.c -o libshout.dylib $ clang -std=c23 -O2 -Wall -Wextra -Werror host.c -o host $ ./host ./libshout.dylib shout shout(...) returned 24: PLUGINS LOAD AT RUN TIME $ ./host ./libshout.dylib whisper ./libshout.dylib has no function named whisper [exit status 1] $ nm -gU libshout.dylib 00000000000003b8 T _shout $ otool -L host host: /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1359.0.0)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 plugin_session.py
host called shout without being linked against libshout.dylib: otool -L host lists only libSystem, so the
dynamic linker knew nothing of the library until dlopen asked for it. The second call shows the failure to plan
for: dlsym found no symbol named whisper and returned NULL, and host checked before calling, which is why it
ended with status 1 instead of crashing. RTLD_NOW asked for every undefined symbol of the library, each name it
takes from elsewhere, to be resolved before dlopen returned, and RTLD_LOCAL kept its symbols out of the search for
libraries loaded later.
Calling what dlsym returned without checking it
dlsym returns NULL when the library has no such symbol, and calling a null function pointer crashes the program.
Check the pointer first, as host.c does, and report the name that was missing.
Checkpoint
Check: Shared libraries, the PLT, the GOT and lazy binding · Loading a library by name with dlopen
Practise: relocate a program and translate its addresses
Write the two halves of the lesson’s program: what a loader does at load time, and what the MMU does at run time. The sample tests run in your browser, and the last one checks the address just past the end of the program:
Exercise · Easy · Python
Relocate a program image and translate its addresses
Write the two halves of the lesson's program in binding.py: what a loader does at load time, and what the MMU does at run time.
relocate(image, relocations, base)takes a program image, a list of(op, operand)pairs, and returns a new list in which every word whose index is inrelocationshasbaseadded to its operand. The other words are copied as they are, and the image passed in must not change.translate(logical, base, limit)returns the physical addressbase + logicalwhenlogicalis belowlimit, andNonewhen the access would trap.
With the lesson's image, relocations [0, 2, 3] and base 0x4000, the first word becomes ("LOAD", 0x4010), and translate(0x10, 0x4000, 0x14) returns 0x4010.
Starter code · binding.py
def relocate(image, relocations, base):
"""A copy of image with base added to the operand of each word listed in relocations."""
# Replace this line with your code.
return image
def translate(logical, base, limit):
"""The physical address of a logical one under base and limit, or None when the access traps."""
# Replace this line with your code.
return logical The sample tests · test_binding.py
from binding import relocate, translate
IMAGE = [("LOAD", 0x10), ("ADD", 1), ("STORE", 0x10), ("JUMP", 0x00), ("DATA", 41)]
def test_relocate_patches_listed_words():
"""adds the base to words 0, 2 and 3 and copies the others"""
assert relocate(IMAGE, [0, 2, 3], 0x4000) == [
("LOAD", 0x4010),
("ADD", 1),
("STORE", 0x4010),
("JUMP", 0x4000),
("DATA", 41),
]
def test_relocate_keeps_the_image():
"""returns a new list and leaves the image as compiled"""
before = list(IMAGE)
relocate(IMAGE, [0, 2, 3], 0x9000)
assert IMAGE == before
def test_translate_inside():
"""adds the base to an address below the limit"""
assert translate(0x10, 0x4000, 0x14) == 0x4010
def test_translate_last_byte():
"""still translates the last byte of the program"""
assert translate(0x13, 0x9000, 0x14) == 0x9013
def test_translate_traps_at_the_limit():
"""returns None for the first address past the program"""
assert translate(0x14, 0x4000, 0x14) is None A hint
enumerate(image) gives every word with its index, so a list comprehension can add base only when the index is in relocations. The limit is the size of the program, so the last address that passes is limit - 1.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then check your grasp of all seven ideas, from the steps of a build to dlopen. Questions on your weakest idea come first, and any you miss return once more before the end.
Practice that adapts to you
Practise this lesson
17 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
The first question comes with its full answer. The two after it ask how you would find why a program does not start, and whether you would ship a tool as one static binary; their model answers are in the module’s question bank.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid · Warm-up
What is the difference between a logical address and a physical address, and what translates one into the other?
A logical address is the address a program uses: the operand of a load, the target of a jump, the value in a pointer. A physical address is where the byte really is in main memory. The memory management unit, the MMU, translates one into the other on every instruction fetch, load and store, using registers or tables that only the kernel may set. The simplest MMU has a base and a limit register: an address below the limit is added to the base, and any other address traps. Processors today translate page by page through page tables, and logical addresses are then called virtual addresses. Because a program can name only logical addresses, the kernel can place it anywhere, move it and keep it out of other programs' memory. In the lesson's program, logical 0x0010 became physical 0x4010 under base 0x4000.
-
Mid to senior
A program that runs on your laptop fails to start on a production Linux server, which reports that a shared library cannot be found. How does the dynamic linker look for libraries, and how would you find and fix the cause?
-
Mid to staff
Your team ships a command-line tool to thousands of Linux machines that you do not control. Would you link it statically or dynamically? Argue from start-up time, file size, security fixes and what can break on the machines.
Key takeaways
- A program’s addresses are filled in by four steps: the compiler places code and data inside each file, the linker places the whole program, the loader and dynamic linker place it and its libraries in memory, and the MMU translates every access.
- Compile-time binding ties a program to one address, load-time binding patches the words in its relocation list once, and run-time binding adds a base on every access, so the program can move without any patching.
- Base and limit registers check each logical address against the program’s size and add the load address; an address at or past the limit traps, and only the kernel may change the two registers.
- The linker resolves each undefined symbol and applies its relocation records; names that only a shared library defines are left to the dynamic linker, which fills the GOT at launch or, with lazy binding, at the first call.
- The linker reads an ELF or Mach-O file’s sections and the loader its segments;
nm,objdump,readelf,otoolanddyld_infoshow the symbols, relocations, segments and libraries. dlopenloads a library by name while the program runs anddlsymfinds a function in it, returningNULLwhen the name is missing.
Terms in this lesson
- address binding
- Fixing which memory location an address in a program refers to. It can happen when the program is compiled, when it is loaded, or at every access while it runs.
- base and limit registers
- The two registers of the simplest MMU. An address below the limit is added to the base to give the physical address; any other address traps into the kernel.
- dynamic linker
- The program that loads a program's shared libraries and resolves their names when it starts, or later on request: ld-linux.so on Linux, dyld on macOS.
- global offset table (GOT)
- A table of addresses inside a program or library that the dynamic linker fills in, so that code can reach functions and data whose addresses are known only when it is loaded.
- lazy binding
- Looking up a library function's address the first time it is called rather than when the program starts, so that functions that are never called cost nothing.
- logical address
- An address as a program uses it, before translation. The MMU turns it into a physical address on every access; with paging it is usually called a virtual address.
- object file
- The output of compiling one source file: machine code and data in sections numbered from offset 0, a symbol table, and relocation records for the addresses still unknown.
- physical address
- The address of a byte in the computer's main memory, as the memory receives it after the MMU has translated the address the program used.
- position-independent code (PIC)
- Machine code that runs correctly at any address, because it reaches its own code and data relative to where it runs and other addresses through the global offset table.
- procedure linkage table (PLT)
- A table of small code entries inside an ELF program or library, one for each library function it calls. Each entry jumps through that function's slot in the global offset table.
- relocation
- Changing the addresses inside code or data to fit where they will be placed. An object file lists each place to change in a relocation record, with the symbol and the rule for the new value.
- shared library
- A library that is not copied into the programs that use it but loaded into their address spaces when they start or while they run, so one copy of its code serves many processes.
References
- System V Application Binary Interface: Object Files, Introduction (linking and execution views) (The Linux Foundation (Linux Standard Base reference specifications))
- System V Application Binary Interface: Dynamic Linking (program interpreter, DT_NEEDED, lazy binding) (The Linux Foundation (Linux Standard Base reference specifications))
- elf(5): format of Executable and Linking Format (ELF) files (Linux man-pages project (man7.org))
- ld.so(8): dynamic linker/loader (Linux man-pages project (man7.org))
- dlopen(3): open and close a shared object (Linux man-pages project (man7.org))
- POSIX.1-2024: dlopen, open a symbol table handle (The Open Group and IEEE)
- Options for code generation conventions: -fpic, -fPIC, -fpie and -fPIE (GNU Project (GCC))
- llvm-nm: list LLVM bitcode and object file symbol table (symbol types) (LLVM Project)
- Overview of the Mach-O Executable Format (Apple Developer Documentation Archive)
- Overview of Dynamic Libraries (Dynamic Library Programming Topics) (Apple Developer Documentation Archive)
- Concepts overview: virtual memory primer (The Linux Kernel Organization)
- Documentation for /proc/sys/kernel/: randomize_va_space (The Linux Kernel Organization)
- Operating Systems: Three Easy Pieces, chapter 15: Mechanism: Address Translation (University of Wisconsin-Madison (Arpaci-Dusseau))
- GATE 2027 syllabus for Computer Science and Information Technology (CS) (Indian Institute of Technology Madras (GATE 2027))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress