Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Clang 21.0.0 and GCC 16.2.0
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Interactive · stage by stage From main.c and greet.c to a running process Step through the four steps. At each one, watch which addresses are known and which are still holes.
From main.c and greet.c to a running process, step 5 of 5. Showing main.c and greet.c, Compile each file, Link main.o and greet.o, Load and start, Run on the CPU. In focus: Run on the CPU.main.c and greet.cnames, no addressesCompile each filemain.o: offsets from 0Link main.o and greet.ogreet at 0x100000538Load and startdyld binds printfRun on the CPUthe MMU maps each access

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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 LOAD still 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:

One program · 14 languages One program bound at compile, load and run time one compare and one add per access

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

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

  1. Question 1 of 2 Which step gives greet its final address in the program, so that main's call to it can be filled in?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The static linker, when it links main.o and greet.o

    After the link, nm -g hello showed _greet at 0x100000538 and only _printf still undefined. Everything the program itself defines gets its address from the linker; only library names wait for the dynamic linker.

  2. Question 2 of 2 The program was bound at load time to 0x4000, so its LOAD now reads 0x4010. The kernel wants to move it to 0x9000. What must happen?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Every word in the relocation list must be patched again, so the move needs that list or a fresh load

    The relocation list named words 0, 2 and 3, and the loader added 0x4000 to each. Moving the copy means adding 0x5000 to the same three words, which needs the list; with run-time binding only the base register changes.

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:

Interactive · step by step Base and limit on two accesses of the program Step through two accesses. Watch the check against the limit, then the sum with the base, and the trap.
Base and limit on two accesses of the program, step 7 of 7. Showing The CPU (LOAD 0x0010), Below the limit? (limit 0x0014), Add the base (base 0x9000), Physical memory (the program at 0x9000), The kernel. In focus: Add the base.logical addressyesphysical addressno: trapThe CPULOAD 0x0010Below the limit?limit 0x0014Add the basebase 0x9000Physical memorythe program at 0x9000The kerneltrap handler

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. The sum. The MMU adds the base register, 0x4000, the place where the kernel loaded the program. Logical 0x0010 becomes physical 0x4010.
  4. Memory answers. Memory reads physical address 0x4010, the counter word of this program, and never sees the logical address at all.
  5. 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.
  6. 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.
  7. 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

  1. Question 1 of 2 The base register holds 0x9000 and the limit register 0x0014. Which physical address does the logical address 0x000c reach?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 0x900c

    The MMU compares first, 0x000c < 0x0014, then adds the base. The block printed the same rule for logical 0x000c at base 0x4000: physical 0x400c.

  2. Question 2 of 2 The program is 0x0014 bytes long, so the limit register holds 0x0014. Which logical address traps?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 0x0014

    A logical address passes when it is below the limit, so the valid ones run from 0x0000 to 0x0013. The block printed "trap: not below the limit" for 0x0014.

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:

Compile, link and read the symbols on macOS Python · toolchain/build_session.py
"""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:

The same main.c as an ELF object for x86-64 Linux Python · toolchain/elf_session.py
"""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:

One ELF file in two views: the linker reads sections through the section header table, the loader reads segments through program headers.Linking viewwhat the linker readsExecution viewwhat the loader readsELF headertype ET_RELELF headertype ET_EXEC or ET_DYNProgram header tableoptionalProgram header tableone entry per segmentSections .text, .rodatacode and constantsSegment: read and execute.text and .rodata insideSections .data, .bss.symtab, .rela.textSegment: read and write.data and .bss insideSection header tableone entry per sectionSection header tableoptional

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.

  1. 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.
  2. Next is the program header table. An object file may leave it out; a program has one entry in it for each segment.
  3. 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.
  4. 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.
  5. 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:

A Mach-O program's header, segments and libraries Python · toolchain/macho_session.py
"""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
Hex Viewer & File Inspector Open an ELF object file you built and find the four bytes 7f 45 4c 46, a 0x7f and the letters ELF, that begin every ELF file.

Checkpoint

Check: Symbols and relocation records · Sections and segments in ELF and Mach-O

  1. Question 1 of 2 After clang main.o greet.o -o hello, nm -g hello still shows _printf as U. Why?
    Show the answer: Question 1 of 2

    Answer: printf is defined in libSystem, a shared library, so the dynamic linker binds it when the program loads

    U means "undefined in this file". After a static link, every U that remains is a name a shared library must supply when the program starts.

  2. Question 2 of 2 size -m showed the __TEXT segment as 16,384 bytes, while its sections add up to 236. Why is the segment so much larger?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A segment is mapped in whole pages, and a page on this Mac is 16 KiB

    The loader reads segments and maps them into the address space in pages. __DATA_CONST, __DATA and __LINKEDIT were 16,384 bytes each for the same reason.

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:

Interactive · message by message Two calls of printf with lazy binding on Linux Step through the first call, which goes through the dynamic linker, and the second, which does not.
Two calls of printf with lazy binding on Linux, step 6 of 6. Messages so far: main's code to PLT entry and GOT slot: call printf@plt; PLT entry and GOT slot to Dynamic linker: resolve printf; Dynamic linker to PLT entry and GOT slot: write the address; main's code to PLT entry and GOT slot: call printf@plt.main's codePLT entry and GOT slotslot filled at startDynamic linkerld.socall printf@pltresolve printfwrite the addresscall printf@plt

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

Load a library by name and call a function in it Python · toolchain/plugin_session.py
"""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

  1. Question 1 of 2 A Linux program that uses lazy binding calls printf twice. Which of the two calls involves the dynamic linker?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Only the first, which finds printf and writes its address into the GOT slot

    The System V ABI lets the dynamic linker evaluate PLT entries lazily, so a function costs a lookup on its first call and a jump through its GOT slot after that.

  2. Question 2 of 2 otool -L host lists only libSystem, yet host called shout from libshout.dylib. How?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: host loaded the library itself with dlopen while it ran, and found shout by name with dlsym

    A library named in the program file is loaded at launch and listed by otool -L. A library opened with dlopen is loaded when the program asks, and the program finds its functions by name.

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 in relocations has base added 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 address base + logical when logical is below limit, and None when 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.

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.

Number Base Converter Turn 0x4010 or any other address of the lesson into decimal and binary, to check a sum by hand.

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.

  1. Question 1 of 17 objdump -h lists the .text section of main-elf.o at address 0. Why 0?

    Choose one answer.

    Show the answer: Question 1 of 17

    Answer: An object file numbers each section from 0, because the compiler does not know where it will be placed

    Each compiled file starts its sections at 0 and records relocations for every address that will change once the linker places it.

  2. Question 2 of 17 Which part decides where a program's segments go in a new process's address space when it starts?

    Choose one answer.

    Show the answer: Question 2 of 17

    Answer: The kernel, with the dynamic linker placing the shared libraries

    Load is the third of the four steps: the kernel creates the address space and maps the segments, then the dynamic linker maps the libraries and binds their names.

  3. Question 3 of 17 Which kind of binding lets the kernel move a running program by changing a single register?

    Choose one answer.

    Show the answer: Question 3 of 17

    Answer: Run-time binding

    In the block, moving the program from 0x4000 to 0x9000 changed only the base, and logical 0x0010 reached 0x9010.

  4. Question 4 of 17 A small computer has no MMU at all. It must run two programs whose addresses are chosen only when each one starts. Which binding can it use?

    Choose one answer.

    Show the answer: Question 4 of 17

    Answer: Load-time binding, with a relocation list in each program for the loader to apply

    Each binding needs something: compile time needs a known address, load time a relocation list, run time an MMU. Here only the relocation list is possible.

  5. Question 5 of 17 The base register holds 16,384 (0x4000) and the limit register 20 (0x0014). Which physical address, in decimal, does the logical address 16 reach?

    Type a number.

    A hint

    Compare with the limit first, then add the base.

    Show the answer: Question 5 of 17

    Answer: 16400 in decimal

    16 is below 20, so the access passes, and 16,384 + 16 = 16,400, which is 0x4010, the counter's place in the block's output.

  6. Question 6 of 17 Why may only the kernel load the base and limit registers?

    Choose one answer.

    Show the answer: Question 6 of 17

    Answer: A program that could set its own base and limit could reach any memory, so protection would be gone

    Relocation and protection come from the same two registers, so writing them is a privileged instruction that the kernel executes when it switches programs.

  7. Question 7 of 17 In nm -g main.o, _greet is marked U. What does U say?

    Choose one answer.

    Show the answer: Question 7 of 17

    Answer: main.o uses _greet but does not define it

    main.o defines _main, T, and needs _greet and _printf, U. greet.o then defines _greet, so the link resolves it.

  8. Question 8 of 17 main-elf.o has the record 0x9 R_X86_64_PLT32 greet-0x4 for a call. What does the linker write at offset 0x9?

    Choose one answer.

    Show the answer: Question 8 of 17

    Answer: The distance from that place in the code to greet, minus 4

    A relocation record names a place, a symbol and a rule. For the two calls the rule gives a distance, which also works when the code is moved as a whole.

  9. Question 9 of 17 What does a static linker do with the code a program uses from a static library?

    Choose one answer.

    Show the answer: Question 9 of 17

    Answer: It copies that code into the program file

    Static linking makes the program carry its library code; dynamic linking makes it carry a name and look the code up when it starts.

  10. Question 10 of 17 Which table of an ELF file tells the system how to create a process image?

    Choose one answer.

    Show the answer: Question 10 of 17

    Answer: The program header table

    Sections are the linking view and segments, described by the program headers, are the execution view of the same file.

  11. Question 11 of 17 objdump -h listed eleven sections in main-elf.o, after an empty entry 0, but the file has no segments. Why not?

    Choose one answer.

    Show the answer: Question 11 of 17

    Answer: It is an object file for the linker, and only files that are loaded need program headers

    An object file is for the linker, which reads sections; the program the linker writes gets the program headers that the loader reads.

  12. Question 12 of 17 A Mac program built like hello reads through a pointer that holds 0x10. What happens?

    Choose one answer.

    Show the answer: Question 12 of 17

    Answer: It traps, because 0x10 lies in __PAGEZERO, whose protections are 0

    otool -l printed __PAGEZERO with vmsize 0x100000000 and maxprot and initprot 0, so any access below 4 GiB traps, which catches null and small pointers.

  13. Question 13 of 17 With lazy binding, when does the dynamic linker look up the address of printf?

    Choose one answer.

    Show the answer: Question 13 of 17

    Answer: When printf is called for the first time

    Lazy binding defers each lookup to the first call, so functions that are never called cost nothing.

  14. Question 14 of 17 Why are shared libraries compiled as position-independent code?

    Choose one answer.

    Show the answer: Question 14 of 17

    Answer: Each process may map the library at another address, and the same code pages must work at all of them

    GCC's documentation says such code accesses all constant addresses through the GOT, which the dynamic loader fills in, so the code itself needs no patching.

  15. Question 15 of 17 A server that must answer its first requests quickly is started with LD_BIND_NOW=1. What changes?

    Choose one answer.

    Show the answer: Question 15 of 17

    Answer: Every symbol is resolved at startup, so no request pays for a first-call lookup

    Eager binding trades a slower start for calls that never stop for a lookup, which suits a server whose start is rare and whose requests are many.

  16. Question 16 of 17 What does dlsym return when the library has no symbol of that name?

    Choose one answer.

    Show the answer: Question 16 of 17

    Answer: NULL

    A missing name is an ordinary result of dlsym, NULL, and calling through a null pointer would crash, so the caller checks first.

  17. Question 17 of 17 A program loads plug-ins by name with dlopen. One plug-in, written for an older version, lacks a function the program needs. What should the program do?

    Choose one answer.

    Show the answer: Question 17 of 17

    Answer: Check what dlsym returns and refuse that plug-in with a message naming the missing function

    Loading by name moves link errors to run time, so the program must handle them as ordinary results, as host.c did for whisper.

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.

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

  2. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

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

    Model answer, Pro. First step and follow-ups

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, otool and dyld_info show the symbols, relocations, segments and libraries.
  • dlopen loads a library by name while the program runs and dlsym finds a function in it, returning NULL when 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.