Introduction

In our previous article, we built a “Hello World” program without using the C standard library. We made direct system calls, provided our own _start entry point, and understood how programs interact with the operating system at the lowest level.

But that’s not how most C programs are written. The normal “Hello World” looks like this:

#include <stdio.h>

int main(void)
{
    printf("hello world\n");
    return 0;
}

This is dramatically simpler than our no-libc version:

  • We use main() instead of _start()
  • We use printf() instead of direct system calls
  • We just return instead of calling exit()

But what’s really happening here? Where does printf come from? What runs before main()? How does the program find the library code at runtime?

In this article, we’re putting all the “magic” back in. We’ll understand:

  • What libraries are and why they exist
  • Static vs dynamic linking - two ways to use libraries
  • The linker (ld) - how it connects your code to libraries
  • The dynamic linker (ld.so) - how it loads libraries at runtime
  • Position Independent Code - how code works at any address
  • PLT and GOT - the clever mechanism for calling library functions
  • The complete startup sequence - from kernel to your main()
  • How printf actually works - from format string to screen

By the end, you’ll understand every step from typing ./hello to seeing “hello world” on your screen.

Let’s dive in.

Part 1: The Concept of Libraries

What is a Library?

Imagine if every time you wrote a program, you had to implement:

  • printf() for formatted output
  • malloc() for memory allocation
  • strlen() for string length
  • fopen() for file operations
  • And thousands of other common functions

This would be:

  • Tedious - Reinventing the wheel constantly
  • Error-prone - Each implementation might have different bugs
  • Wasteful - Everyone writing the same code
  • Inconsistent - Different programs behaving differently

The solution is libraries - collections of precompiled, reusable functions.

A library is essentially:

  • A file containing compiled code (machine code)
  • Organized collection of related functions
  • Packaged so other programs can use it

Benefits:

  1. Code reuse - Write once, use everywhere
  2. Standardization - Everyone’s printf works the same way
  3. Maintenance - Fix a bug once, all programs benefit
  4. Expertise - Library authors are experts in their domain
  5. Efficiency - Optimized implementations (often with assembly)

Types of Libraries

There are two main types of libraries on Linux:

Static Libraries (.a files)

What they are:

  • Archives of object files (.o files bundled together)
  • File extension: .a (archive)
  • Example: /usr/lib/x86_64-linux-gnu/libc.a

How they work:

  • The linker copies the needed functions into your executable
  • Your binary becomes self-contained
  • No external dependencies

Creating a static library:

gcc -c mylib.c -o mylib.o           # Compile to object file
ar rcs libmylib.a mylib.o           # Create archive
gcc main.c -L. -lmylib -o program   # Link against it

Pros:

  • ✓ No runtime dependencies
  • ✓ Portable (runs on any system with same architecture)
  • ✓ Slightly faster (no runtime overhead)
  • ✓ Predictable (exact version at compile time)

Cons:

  • ✗ Large executables (every program includes full library)
  • ✗ Wasteful (100 programs = 100 copies of library on disk and in memory)
  • ✗ No updates (must recompile to get library fixes)

Dynamic/Shared Libraries (.so files)

What they are:

  • Shared Object files
  • File extension: .so (often with version numbers like .so.6)
  • Example: /lib/x86_64-linux-gnu/libc.so.6

How they work:

  • Your executable just references the library
  • The library is loaded into memory at runtime
  • Multiple programs share the same library in memory

Creating a shared library:

gcc -fPIC -c mylib.c -o mylib.o                  # Compile position-independent
gcc -shared -o libmylib.so mylib.o               # Create shared object
gcc main.c -L. -lmylib -o program                # Link against it
LD_LIBRARY_PATH=. ./program                      # Run (if not in system path)

Pros:

  • ✓ Small executables (just references to library)
  • ✓ Shared memory (one copy in RAM serves all programs)
  • ✓ Updateable (fix library once, all programs benefit)
  • ✓ Plugins possible (load code at runtime)

Cons:

  • ✗ Runtime dependency (library must exist on target system)
  • ✗ “DLL hell” (version conflicts possible)
  • ✗ Slightly slower startup (loading and linking at runtime)
  • ✗ More complex (involves dynamic linker)

Modern Linux systems use dynamic linking by default. When you run gcc, it links against .so files by default.

What is Libc?

The C standard library (libc) is the most fundamental library on a Unix-like system. On Linux, the standard implementation is glibc (GNU C Library).

Where it lives:

ls -lh /lib/x86_64-linux-gnu/libc.so.6
# Usually a symlink to the actual library version

What libc provides:

System Call Wrappers

Instead of inline assembly:

write(1, "hello", 5);    // Clean C function
read(0, buffer, 100);
open("/tmp/file", O_RDONLY);
fork();

Libc wraps system calls and provides:

  • Clean C interface (no inline assembly needed)
  • Error handling (sets errno on failure)
  • Portability (abstracts syscall numbers)

Standard I/O (stdio.h)

printf("hello %d\n", 42);        // Formatted output
scanf("%d", &x);                  // Formatted input
FILE *f = fopen("file.txt", "r"); // File operations
fread(buffer, 1, 100, f);

These functions provide:

  • Buffering - Batch system calls for efficiency
  • Format parsing - %d, %s, %x, etc.
  • Convenience - Much easier than raw syscalls

Memory Management (stdlib.h)

void *ptr = malloc(1024);        // Allocate heap memory
ptr = realloc(ptr, 2048);        // Resize allocation
free(ptr);                        // Free memory

Libc implements a memory allocator:

  • Manages the heap using brk()/mmap() syscalls
  • Tracks allocated/free blocks
  • Handles fragmentation
  • Common implementations: ptmalloc (glibc), jemalloc, tcmalloc

String Operations (string.h)

strlen("hello");                 // String length
strcpy(dest, src);               // Copy string
strcmp(s1, s2);                  // Compare strings
memcpy(dest, src, n);            // Copy memory

Often highly optimized:

  • Assembly implementations
  • SIMD instructions (SSE, AVX)
  • Architecture-specific optimizations

Math Functions (math.h)

sin(angle);                      // Trigonometric functions
sqrt(x);                         // Square root
pow(base, exponent);             // Power

These often link to libm (math library) which may use:

  • Lookup tables
  • Polynomial approximations
  • Hardware floating-point instructions

Runtime Initialization

This is the hidden magic:

  • Provides _start entry point
  • Calls __libc_start_main() to initialize the C runtime
  • Sets up argc, argv, environment variables
  • Calls constructors (.init_array)
  • Calls your main(argc, argv, envp)
  • Handles the return value
  • Calls destructors and cleanup
  • Exits the process

We’ll dive deep into this later.

Part 2: Static Linking - Copying Libraries Into Your Program

What is Linking?

Linking is the final stage of creating an executable. The linker (usually ld, called by gcc) combines:

  • Your compiled code (.o object files)
  • Library code (.a static libraries or references to .so shared libraries)
  • Startup code (like _start)

Into a single executable file.

The Linker’s Job

When you compile:

gcc hello.c -o hello

This actually runs multiple stages:

  1. Preprocessing: cpp expands #include, macros
  2. Compilation: cc1 compiles C to assembly
  3. Assembly: as assembles to object file
  4. Linking: ld creates the final executable ← We’re here

The linker does:

1. Collect object files:

  • Your code: hello.o (compiled from hello.c)
  • Startup code: crt1.o, crti.o, crtn.o (C runtime initialization)
  • Libraries: libc.so.6 (or libc.a for static)

2. Find all symbols:

  • Symbols are names of functions and global variables
  • Your code: main (defined), printf (undefined, needs to be found)
  • Library: printf (defined in libc)

3. Resolve references:

  • Connect call printf in your code to the printf function in libc
  • This is called symbol resolution

4. Merge sections:

  • Combine all .text sections (code) into one
  • Combine all .data sections (initialized data) into one
  • Combine all .rodata sections (read-only data) into one
  • Allocate .bss section (uninitialized data)

5. Perform relocations:

  • Code initially has placeholder addresses
  • Linker fixes these to final addresses
  • Example: call 0x????????call 0x00401234

6. Set entry point:

  • Tell the kernel where to start executing
  • Usually _start from libc’s startup code

7. Generate the ELF binary:

  • Package everything into ELF format
  • Add headers, program headers, section headers

Symbol Resolution Explained

Symbols have different types:

Strong symbols:

  • Normal function and variable definitions
  • Example: Your main function
  • Can’t have duplicates (linker error if two files define the same function)

Weak symbols:

  • Can be overridden by strong symbols
  • Used for optional features
  • Example: __attribute__((weak)) void foo() { }

External symbols:

  • Declared but not defined
  • Example: Your code’s reference to printf
  • Must be found in another object file or library

Undefined symbols:

  • Referenced but not found anywhere
  • Linker error: “undefined reference to ‘foo’”

Static Linking with Libc

To statically link libc:

gcc -static hello.c -o hello_static

What happens:

  1. Linker opens /usr/lib/x86_64-linux-gnu/libc.a
  2. Finds printf (and everything it depends on)
  3. Copies all the needed code into your executable
  4. Resolves all symbols to absolute addresses
  5. Creates a self-contained binary

Result:

ls -lh hello_static
# Typical output: ~800KB to 900KB

Much larger than the dynamic version! Let’s verify it has no dependencies:

ldd hello_static
# Output: "not a dynamic executable"

Perfect - it’s completely standalone.

When to use static linking:

  • Embedded systems (no shared libraries available)
  • Containers/static binaries (simple deployment)
  • Ensuring specific library version
  • Maximum portability

Part 3: Dynamic Linking - The Modern Approach

The Problem with Static Linking

Imagine a system with:

  • 100 programs
  • Each statically linked with libc (~800KB each)

Disk space: 100 × 800KB = 80MB just for libc copies

Memory: If all 100 programs run simultaneously, that’s 80MB of RAM just for duplicated libc code.

Updates: Security fix in libc? Recompile all 100 programs.

This doesn’t scale.

The Dynamic Linking Solution

Idea: Keep one copy of libc on disk and in memory. Programs just reference it.

Benefits:

  • Disk: One 2MB libc.so.6 file instead of 100 × 800KB
  • Memory: One copy in RAM shared between all processes
  • Updates: Update libc once, all programs get the fix

Tradeoff: More complexity at runtime.

Creating a Dynamically Linked Executable

Normal compilation uses dynamic linking by default:

gcc hello.c -o hello

Size:

ls -lh hello
# Output: 16K hello

Much smaller! But it needs libraries.

Checking Dependencies with ldd

The ldd command shows what libraries a program needs:

ldd hello

Output:

linux-vdso.so.1 (0x00007ffd...)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...)
/lib64/ld-linux-x86-64.so.2 (0x00007f...)

Let’s understand each:

linux-vdso.so.1 (Virtual Dynamic Shared Object):

  • Not a real file on disk
  • Provided by the kernel
  • Maps into every process
  • Provides fast system calls (vsyscall/vDSO)
  • Avoids user→kernel mode switch for certain syscalls (like gettimeofday)

libc.so.6 (The C Standard Library):

  • This is what provides printf, malloc, etc.
  • Version number “6” indicates SONAME (shared object name)
  • Usually a symlink: libc.so.6libc-2.40.so (actual version)
  • Loaded at an address determined at runtime

ld-linux-x86-64.so.2 (The Dynamic Linker):

  • This is special - it’s the program that loads everything else
  • It’s not a dependency in the traditional sense
  • It’s the interpreter for dynamically linked programs
  • We’ll explore this next

The Dynamic Linker - The Star of the Show

The dynamic linker (also called dynamic loader) is the program that makes dynamic linking work.

Names:

  • ld.so - Generic name
  • ld-linux-x86-64.so.2 - Specific name on x86-64
  • Also known as: rtld (runtime linker)

Where it lives:

ls -l /lib64/ld-linux-x86-64.so.2

It’s usually a symlink to a specific version in /lib or /lib64.

Finding it in your binary:

Every dynamically linked ELF executable has an “INTERP” section that specifies the dynamic linker:

readelf -l hello | grep interpreter

Output:

[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]

This tells the kernel: “Don’t run my code first - run the dynamic linker first!”

How Dynamic Linking Works at Runtime

When you run ./hello, here’s what really happens:

Step 1: Kernel Loads the Program

./hello
  1. Shell calls execve("./hello", argv, envp)
  2. Kernel reads the ELF header
  3. Kernel sees it’s dynamically linked (has INTERP section)
  4. Kernel loads the dynamic linker instead
  5. Kernel sets RIP (instruction pointer) to dynamic linker’s entry point
  6. Control transfers to the dynamic linker

The kernel has loaded your program’s code into memory, but hasn’t started executing it yet!

Step 2: Dynamic Linker Does Its Magic

The dynamic linker now runs and:

A. Reads the dependency list:

  • Examines your program’s .dynamic section
  • Finds: “This program needs libc.so.6”

B. Searches for libraries:

Search order:

  1. RPATH in the binary (embedded path, checked first)
  2. LD_LIBRARY_PATH environment variable
  3. RUNPATH in the binary (embedded path, checked after LD_LIBRARY_PATH)
  4. /etc/ld.so.cache - cached list of libraries (created by ldconfig)
  5. Default paths: /lib, /usr/lib, /lib64, /usr/lib64
# View the library cache:
ldconfig -p | grep libc.so.6

C. Loads each library:

  • Uses mmap() system call
  • Maps library’s code into process address space
  • Typically loaded in the 0x7f… address range (high addresses)
  • Multiple memory regions:
    • Read-only code (executable permission)
    • Read-only data
    • Read-write data

D. Resolves symbols:

  • Finds where each function actually is
  • Example: Find printf in libc.so.6 at address 0x7f1234…
  • Updates tables (GOT - we’ll see this soon) with real addresses

E. Performs relocations:

  • Fixes up any address references
  • Handles position-independent code (PIC)

F. Calls initializers:

  • Runs library constructor functions
  • Functions marked with .init or .init_array
  • Example: C++ global constructors

G. Transfers control:

  • Finally jumps to your program’s entry point
  • Usually _start in libc’s startup code
  • Your program starts executing

Step 3: Program Executes

Now your code runs, and when it calls printf, it calls the real function that’s been loaded into memory.

Debugging Dynamic Linking

You can see all this happening with:

LD_DEBUG=all ./hello 2>&1 | less

This shows:

  • Library search paths
  • Which libraries are loaded
  • Symbol resolution
  • Relocations
  • Everything the dynamic linker does

Example output (abbreviated):

     1234:     finding libc.so.6
     1234:     search cache=/etc/ld.so.cache
     1234:     trying file=/lib/x86_64-linux-gnu/libc.so.6
     1234:
     1234:     calling init: /lib/x86_64-linux-gnu/libc.so.6
     1234:
     1234:     initialize program: ./hello
     1234:
     1234:     transferring control: ./hello

Fascinating to see it all happen!

Part 4: Position Independent Code (PIC) and PIE

The Address Problem

Challenge: Where should a shared library be loaded in memory?

For static programs:

  • Code is always at the same address (e.g., 0x400000)
  • All addresses are known at link time
  • Simple

For shared libraries:

  • Different programs load at different addresses
  • Same program might load at different addresses each run (ASLR)
  • Can’t hardcode addresses

Example problem:

int x = 42;
int get_x() {
    return x;
}

Without PIC, the assembly might be:

get_x:
    mov 0x600000, %eax    ; Absolute address of x
    ret

This only works if the library is loaded at the expected base address!

Position Independent Code (PIC) - The Solution

PIC is code that works correctly regardless of where it’s loaded in memory.

Key techniques:

1. Relative Addressing:

Instead of absolute addresses, use offsets from the current instruction:

get_x:
    lea x(%rip), %rax     ; Load x's address relative to RIP
    mov (%rax), %eax      ; Read the value
    ret

On x86-64, %rip (instruction pointer) relative addressing makes this natural.

2. Indirect Access Through Tables:

For function calls and global variables, use indirection:

  • PLT (Procedure Linkage Table) for functions
  • GOT (Global Offset Table) for variables

We’ll cover these in the next section.

Compiling with PIC:

gcc -fPIC -c mylib.c -o mylib.o          # Position-independent code
gcc -shared -o libmylib.so mylib.o       # Create shared library

All shared libraries must be compiled with -fPIC.

PIE - Position Independent Executable

Modern security best practice: Make the entire program position-independent.

PIE (Position Independent Executable) is like PIC but for the main program:

gcc -fPIE -pie hello.c -o hello

Or just gcc hello.c - modern GCC defaults to PIE.

Why PIE?

Security: ASLR (Address Space Layout Randomization)

  • Randomizes where code is loaded
  • Different address each time you run the program
  • Makes exploits harder (attacker can’t predict addresses)

Example:

# Run multiple times and check where main is loaded
for i in {1..3}; do
    echo -n "Run $i: "
    gdb -batch -ex 'b main' -ex 'run' -ex 'p $rip' ./hello 2>&1 | grep rip
done

Each run loads at a different address!

The Cost of PIC/PIE

Slight performance overhead:

  • Extra indirection for global variables
  • Extra register needed (for GOT base on some architectures)
  • Modern CPUs minimize this with good branch prediction

Modern consensus: The security benefits outweigh the tiny performance cost.

Part 5: PLT and GOT - The Clever Mechanism

This is one of the most ingenious parts of dynamic linking. Let’s understand how your code can call printf when we don’t know where printf is until runtime.

The Problem

Your code:

printf("hello\n");

Compiles to something like:

call printf

But where is printf? It’s in libc.so.6, which is loaded at runtime at an address we don’t know at compile time!

The Solution: Indirection Through Tables

Two tables work together:

  1. PLT (Procedure Linkage Table) - Small code stubs
  2. GOT (Global Offset Table) - Table of addresses

The GOT (Global Offset Table)

What it is:

  • Array of 64-bit addresses
  • One entry per external function/variable
  • Lives in the .got or .got.plt section
  • Writable (so the dynamic linker can fill it in)

Structure:

.got.plt section:
+0x00: address of .dynamic section
+0x08: address of link_map (dynamic linker internal)
+0x10: address of dynamic linker resolver function
+0x18: address of printf (filled in at runtime)
+0x20: address of malloc (filled in at runtime)
+0x28: ...

Initially: Entries contain placeholder values After dynamic linking: Entries contain real function addresses

The PLT (Procedure Linkage Table)

What it is:

  • Array of small code stubs
  • One entry per external function
  • Lives in the .plt section
  • Executable code

Structure: Each PLT entry is about 16 bytes of code.

PLT[0] (special entry - calls dynamic linker):

push QWORD PTR [rip + got.plt+8]    ; Push link_map
jmp QWORD PTR [rip + got.plt+16]    ; Jump to dynamic linker resolver

PLT[N] (for each function, e.g., printf):

printf@plt:
    jmp QWORD PTR [rip + printf@got]    ; Jump through GOT
    push N                               ; Push function index
    jmp PLT[0]                           ; Jump to resolver

Lazy Binding - The Optimization

Here’s the clever part: functions are resolved only when first called.

First call to printf:

  1. Your code: call printf@plt
  2. Jump to PLT entry for printf
  3. PLT tries to jump through GOT
  4. GOT still has placeholder (points back to next instruction in PLT)
  5. PLT pushes function index
  6. PLT jumps to PLT[0]
  7. PLT[0] calls dynamic linker’s resolver function
  8. Resolver finds printf’s real address
  9. Resolver updates GOT entry with real address
  10. Resolver jumps to real printf
  11. printf executes

Subsequent calls:

  1. Your code: call printf@plt
  2. Jump to PLT entry for printf
  3. PLT jumps through GOT
  4. GOT now has real address (from first call)
  5. Direct jump to printf (no dynamic linker involved)

This is called lazy binding - only resolve symbols when needed.

Why lazy binding?

  • Faster startup (don’t resolve all symbols immediately)
  • Some functions might never be called
  • Resolve only what’s needed

Disable lazy binding (resolve everything at startup):

LD_BIND_NOW=1 ./hello

Seeing PLT/GOT in Action

Disassemble to see PLT entries:

objdump -d hello | grep -A5 "printf@plt"

Output (from our actual binary):

0000000000001030 <puts@plt>:
    1030:   ff 25 da 2f 00 00       jmp    *0x2fda(%rip)        # 4010 <puts@GLIBC_2.2.5>
    1036:   68 00 00 00 00          push   $0x0
    103b:   e9 e0 ff ff ff          jmp    1020 <_init+0x20>

Interesting! The compiler optimized printf("hello world\n") to puts("hello world") (common optimization when there are no format specifiers).

View GOT entries:

objdump -R hello

Shows relocation entries that the dynamic linker will fill in.

View during execution:

gdb ./hello
(gdb) break main
(gdb) run
(gdb) x/gx 0x4010    # Examine GOT entry for puts

Before first call: Some placeholder address After first call: Real address in libc

Part 6: The Startup Sequence - From Kernel to main()

Now let’s trace the complete journey from when you press Enter to when your main() function begins.

Comparison: Without vs With Libc

Without libc (previous article):

Kernel → _start (your code) → your program

With libc (this article):

Kernel → Dynamic Linker → _start (libc) → __libc_start_main → main (your code)

Much more complex! Let’s walk through each step.

Step 1: Shell Launches Your Program

./hello

The shell (bash/zsh) does:

pid_t pid = fork();           // Create child process
if (pid == 0) {
    // Child process
    execve("./hello", argv, envp);
    // If execve succeeds, we never return
}
// Parent waits for child
wait(&status);

Step 2: Kernel Loads and Prepares

The execve system call:

A. Read ELF header:

// Kernel code (pseudocode)
read_elf_header("./hello");
if (has_interpreter(elf)) {
    // Dynamically linked - load interpreter first
    interpreter = get_interpreter(elf);  // /lib64/ld-linux-x86-64.so.2
}

B. Set up memory:

  • Clear old process memory
  • Create new memory map:
    • Map program’s segments (text, data, etc.)
    • Allocate stack
    • Map dynamic linker’s segments
    • Map vDSO
  • Set up initial stack with:
    • Environment variables
    • Argument vector (argv)
    • Auxiliary vector (kernel info for dynamic linker)

C. Set registers:

  • RSP → top of stack
  • RIP → dynamic linker’s entry point (not your program’s!)
  • Clear other registers

D. Switch to user mode and jump:

  • CPU begins executing the dynamic linker

Step 3: Dynamic Linker Works

As we covered earlier, the dynamic linker:

  1. Loads required libraries (libc.so.6, etc.)
  2. Resolves symbols
  3. Performs relocations
  4. Calls initializers
  5. Jumps to program’s entry point: _start

Step 4: Libc’s _start Function

The entry point is not your main() - it’s libc’s _start function.

Source code location in glibc: sysdeps/x86_64/start.S

Simplified version:

.global _start
_start:
    xor %ebp, %ebp                # Clear frame pointer (marks top of stack)

    mov %rsp, %rsi                # rsi = stack pointer
                                  # Stack contains: [argc, argv[0], argv[1], ..., NULL, envp[0], ...]

    and $~15, %rsp                # Align stack to 16 bytes (ABI requirement)

    push %rax                     # Push some registers
    push %rsp

    # Prepare arguments for __libc_start_main:
    mov %rdx, %r9                 # r9 = rtld_fini (dynamic linker cleanup function)
    mov $__libc_csu_fini, %r8     # r8 = fini (our cleanup function)
    mov $__libc_csu_init, %rcx    # rcx = init (our initialization function)
    mov %rsi, %rdx                # rdx = argv
    lea (%rsi), %rax
    mov (%rax), %esi              # rsi = argc (first item on stack)
    mov $main, %rdi               # rdi = address of main function

    call __libc_start_main        # Never returns

    hlt                           # Should never get here

What _start does:

  1. Clears frame pointer (RBP) to mark top of call stack
  2. Reads argc, argv, envp from stack
  3. Aligns stack (x86-64 ABI requires 16-byte alignment before calls)
  4. Prepares arguments for __libc_start_main
  5. Calls __libc_start_main (never returns)

Step 5: __libc_start_main - The Heart of Initialization

This is where the C runtime is really set up.

Function signature (simplified):

int __libc_start_main(
    int (*main)(int, char **, char **),    // Your main function
    int argc,                              // Argument count
    char **argv,                           // Argument vector
    void (*init)(void),                    // Initialization function
    void (*fini)(void),                    // Finalization function
    void (*rtld_fini)(void),               // Dynamic linker cleanup
    void *stack_end                        // Stack end pointer
);

What it does (simplified flow):

int __libc_start_main(int (*main)(...), int argc, char **argv, ...)
{
    // 1. Set up thread-local storage (TLS)
    __pthread_initialize();

    // 2. Initialize libc internals
    __cxa_atexit(rtld_fini, NULL, NULL);   // Register dynamic linker cleanup
    __cxa_atexit(fini, NULL, NULL);        // Register our cleanup

    // 3. Call initialization functions
    if (init) {
        init();                             // Call __libc_csu_init
    }

    // 4. Call constructors from .init_array section
    call_init_array();

    // 5. Set up environment
    char **envp = argv + argc + 1;         // Environment after argv

    // 6. Call your main function
    int result = main(argc, argv, envp);

    // 7. Exit with main's return value
    exit(result);                          // Never returns

    // Should never reach here
    __builtin_unreachable();
}

Let’s understand each part:

A. Thread-Local Storage (TLS):

  • Even single-threaded programs use TLS
  • Sets up per-thread data structures
  • Example: errno is thread-local

B. Register cleanup handlers:

  • atexit() registers functions to call at exit
  • Registered in reverse order (last registered = first called)
  • Both our cleanup and dynamic linker’s cleanup

C. Call init function:

  • __libc_csu_init calls:
    • Functions in .init section (old-style)
    • Functions in .init_array section (modern)
  • Used for C++ global constructors
  • Custom initialization code

D. Call your main:

  • Finally! Your code starts executing
  • Gets argc, argv, envp
  • envp is environment variables:
    for (char **e = envp; *e; e++) {
        printf("%s\n", *e);  // PATH=..., HOME=..., etc.
    }
    

E. Exit handling:

  • Key insight: main never really “returns” to caller
  • Instead, its return value is passed to exit()
  • This is why return 0; from main is the same as exit(0);

Step 6: Your main() Executes

Finally, your code runs:

int main(void)
{
    printf("hello world\n");
    return 0;
}

What happens:

  1. Stack frame is set up
  2. printf is called (goes through PLT → GOT → libc)
  3. Return value (0) is placed in RAX
  4. Function returns

Step 7: exit() Cleanup and Termination

When main returns, __libc_start_main calls exit(0).

What exit() does:

void exit(int status)
{
    // 1. Call atexit handlers (in reverse registration order)
    while (atexit_handlers) {
        call_next_atexit_handler();
    }

    // 2. Call functions in .fini_array (destructors)
    call_fini_array();

    // 3. Flush and close stdio streams
    fflush(NULL);                 // Flush all open streams
    _IO_cleanup();                // Close stdio

    // 4. Call _exit() to actually terminate
    _exit(status);                // System call - never returns
}

_exit() system call:

void _exit(int status)
{
    syscall(SYS_exit, status);    // syscall 60 on x86-64
    __builtin_unreachable();
}

This terminates the process, returning control to the kernel.

Step 8: Kernel Cleanup

The kernel:

  1. Marks process as terminated
  2. Sets exit status
  3. Closes all file descriptors
  4. Frees memory
  5. Sends SIGCHLD to parent
  6. Becomes a zombie (waiting for parent to collect status)

Step 9: Parent Reaps Child

The shell (parent process) was waiting:

wait(&status);                    // Collects child's exit status

Kernel gives the exit status (0 = success) to parent and fully removes the child process.

Complete Flow Visualization

You type: ./hello
    ↓
Shell: fork() + execve()
    ↓
Kernel: Load ELF, setup memory, jump to dynamic linker
    ↓
Dynamic Linker: Load libc.so.6, resolve symbols, jump to _start
    ↓
_start (libc): Setup stack, call __libc_start_main
    ↓
__libc_start_main: Initialize C runtime, call main()
    ↓
main(): Your code executes! printf("hello world\n")
    ↓
main() returns 0
    ↓
__libc_start_main: Calls exit(0)
    ↓
exit(): Cleanup, call _exit(0)
    ↓
_exit(): System call (SYS_exit)
    ↓
Kernel: Terminate process
    ↓
Shell: Wait completes, prompt returns

That’s the complete journey!

Part 7: How printf Actually Works

Let’s demystify printf() - one of the most commonly used functions.

From Your Code to the Screen

High-level flow:

printf("hello world\n")
    ↓ Format string parsing
Parse "hello world\n" (no format specifiers)
    ↓ Copy to buffer
Internal buffer in libc (stdout buffer)
    ↓ Newline found (line buffering)
Flush buffer
    ↓ write() wrapper
Call write(1, buffer, len)
    ↓ System call
write syscall (syscall 1)
    ↓ Kernel
Terminal driver
    ↓ Hardware
Screen displays text

Stdio Buffering

Why buffer?

System calls are expensive:

  • Context switch (user mode → kernel mode)
  • Kernel processing
  • Context switch back

Better to batch writes:

  • Collect output in a user-space buffer
  • Flush only when:
    • Buffer is full (typically 4KB or 8KB)
    • Newline encountered (for line buffering)
    • Explicit flush (fflush())
    • Program exits

Three buffering modes:

1. Unbuffered - Flush immediately:

stderr  // Always unbuffered (errors should appear immediately)

2. Line buffered - Flush on newline:

stdout  // When connected to terminal
        // Flushes when \n encountered

3. Fully buffered - Flush when buffer full:

Regular files  // Flush when buffer full or fflush()
stdout         // When redirected to file

Example:

printf("hello");        // Might stay in buffer
printf(" world\n");     // Newline causes flush

printf("no newline");   // Stays in buffer
fflush(stdout);         // Force flush

Printf Implementation (Simplified)

Real printf is thousands of lines, but here’s the concept:

int printf(const char *format, ...)
{
    // 1. Handle variable arguments
    va_list args;
    va_start(args, format);

    // 2. Format the string into a buffer
    char buffer[BUFSIZ];  // Typically 8192 bytes
    int len = vsnprintf(buffer, sizeof(buffer), format, args);

    // 3. Write to stdout
    int written = write(STDOUT_FILENO, buffer, len);

    va_end(args);
    return written;
}

Real implementation is much more complex:

  • Multiple buffer sizes
  • Sophisticated format parsing (%d, %s, %x, %p, etc.)
  • Width and precision handling (%10d, %.2f)
  • Flags (%-10s, %+d, %#x)
  • Type conversion
  • Locale support (thousands separators, decimal point)
  • Wide character support (Unicode)
  • Security checks (format string attacks)

Printf Optimization

Our example:

printf("hello world\n");

Compiled to:

call   puts@plt

The compiler optimized printf to puts!

Why?

  • No format specifiers (no % in the string)
  • puts is simpler (just print string + newline)
  • puts is faster (less overhead)

This is a common GCC optimization. You can disable it:

gcc -fno-builtin-printf hello.c

Variadic Arguments

How does printf accept any number of arguments?

Variadic functions in C:

int printf(const char *format, ...)
{
    va_list args;              // List of arguments
    va_start(args, format);    // Initialize list (after last fixed arg)

    // Access arguments:
    int x = va_arg(args, int);
    char *s = va_arg(args, char *);

    va_end(args);              // Cleanup
}

How it works (x86-64):

  • Arguments passed in registers (RDI, RSI, RDX, RCX, R8, R9)
  • Additional arguments on stack
  • va_list keeps track of which argument is next
  • va_arg retrieves and advances

Format string parsing:

void parse_format(const char *format, va_list args)
{
    while (*format) {
        if (*format == '%') {
            format++;
            switch (*format) {
                case 'd':  // Integer
                    int x = va_arg(args, int);
                    print_int(x);
                    break;
                case 's':  // String
                    char *s = va_arg(args, char *);
                    print_string(s);
                    break;
                // ... many more
            }
        } else {
            putchar(*format);
        }
        format++;
    }
}

Relationship to write()

Eventually, printf calls:

write(STDOUT_FILENO, buffer, len);

Which libc implements as:

ssize_t write(int fd, const void *buf, size_t count)
{
    long result;
    __asm__ volatile (
        "syscall"
        : "=a"(result)
        : "a"(1), "D"(fd), "S"(buf), "d"(count)  // SYS_write = 1
        : "rcx", "r11", "memory"
    );

    if (result < 0) {
        errno = -result;
        return -1;
    }
    return result;
}

And we’re back to system calls - the same mechanism we used in the no-libc version!

Part 8: Memory Layout with Libc

Updated Memory Map

With libc and shared libraries, the memory layout is more complex:

High addresses
0xFFFFFFFFFFFFFFFF  ┌─────────────────────┐
                    │   Kernel Space      │ Ring 0 only
0xFFFF800000000000  ├─────────────────────┤
                    │                     │
0x00007FFFFFFFFFFF  ├─────────────────────┤
                    │   Stack             │ Grows downward
                    │   (8MB default)     │ ↓
0x00007FFFFF000000  ├─────────────────────┤
                    │   (gap)             │ Space for growth
                    ├─────────────────────┤
                    │   vDSO              │ Kernel-provided virtual library
                    ├─────────────────────┤
                    │   Shared Libraries  │ libc.so.6, ld-linux.so, etc.
                    │   (around 0x7f...)  │ mmap'd at runtime
0x00007F0000000000  ├─────────────────────┤
                    │   (gap)             │
                    ├─────────────────────┤
                    │   Heap              │ Grows upward
                    │   (malloc/free)     │ ↑
                    ├─────────────────────┤
                    │   BSS               │ Uninitialized globals
                    ├─────────────────────┤
                    │   Data              │ Initialized globals
                    ├─────────────────────┤
                    │   Text (Code)       │ Your program code
Low addresses       │   (.text section)   │ Read-only, executable
0x0000000000400000  └─────────────────────┘
(PIE randomizes this)

Shared Library Mapping

How libraries are mapped:

cat /proc/self/maps  # View memory map of current shell

Example output:

400000-401000 r-xp  /usr/bin/bash      # Executable code
600000-601000 rw-p  /usr/bin/bash      # Data
7f1234-7f5678 r-xp  /lib/libc.so.6     # Libc code (shared)
7f5678-7f9abc rw-p  /lib/libc.so.6     # Libc data (private)
7ffd...   rw-p  [stack]                # Stack

Key points:

  • Shared text: Multiple processes share read-only code pages
  • Private data: Each process gets its own copy of writable pages (COW)
  • Random addresses: ASLR randomizes library locations

Copy-on-Write (COW)

Shared libraries’ writable pages use COW:

Initial state: All processes share the same physical page When one writes: Kernel copies the page, process gets private copy

This allows:

  • Sharing most of the library (read-only code)
  • Each process having its own writable data

Heap Management

When you call malloc():

void *ptr = malloc(1024);

Libc’s malloc:

  1. Checks its free lists (previously freed blocks)
  2. If free space available: Return it
  3. If not: Request more memory from OS:
    • Small allocations: Use brk() syscall (extends heap)
    • Large allocations: Use mmap() syscall (map new region)

Free:

free(ptr);
  1. Marks block as free
  2. Possibly merges with adjacent free blocks
  3. Updates free lists
  4. Large blocks might be unmapped (munmap())

Part 9: Symbol Resolution and Versioning

Symbol Lookup Order

When you call a function, which implementation gets used?

Search order:

  1. Local symbols - Defined in your binary
  2. LD_PRELOAD - Libraries in LD_PRELOAD environment variable
  3. Dependencies - Libraries your binary depends on (in order)
  4. Global scope - All loaded libraries

Example:

LD_PRELOAD=./myprintf.so ./hello

If myprintf.so defines printf, it will be used instead of libc’s!

Symbol Visibility

Symbols have different visibility levels:

global (default):

  • Exported from library
  • Available to other modules
  • Can be overridden by LD_PRELOAD

local:

  • Not exported
  • Only visible within this library
  • Can’t be called from outside

weak:

  • Can be overridden by strong symbol
  • Useful for providing default implementations

hidden:

  • Not exported, even if global
  • Good for library-internal functions

Controlling visibility:

__attribute__((visibility("hidden")))
void internal_function() { }

__attribute__((visibility("default")))
void public_function() { }

Symbol Versioning

Problem: Library updates might break programs

Example:

  • Program built with libc 2.2.5
  • System updated to libc 2.40
  • Function behavior changed

Solution: Symbol versioning

How it works:

nm -D /lib/x86_64-linux-gnu/libc.so.6 | grep " printf"

Shows:

printf@@GLIBC_2.2.5

The @@GLIBC_2.2.5 is the version tag.

Multiple versions coexist:

old_func@GLIBC_2.2.5   # Old version
old_func@@GLIBC_2.28   # New default version

Programs link to the version they were built with, ensuring compatibility.

Part 10: Practical Analysis - Dissecting Hello World

Let’s examine a real Hello World binary in detail.

The Program

#include <stdio.h>

int main(void)
{
    printf("hello world\n");
    return 0;
}

Compilation

gcc hello.c -o hello

Size Comparison

ls -lh hello
# Output: 16K

Compare to:

  • No-libc version: ~8-16KB
  • Static version: ~800KB

Dependencies

ldd hello

Output:

linux-vdso.so.1 (0x00007f5db66a7000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f5db6400000)
/lib64/ld-linux-x86-64.so.2 (0x00007f5db66a9000)

Three dependencies, all system-provided.

Finding the Interpreter

readelf -l hello | grep interpreter

Output:

[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]

Viewing All Sections

readelf -S hello

Important sections:

.interp         Dynamic linker path
.text           Code
.rodata         Read-only data ("hello world\n")
.data           Initialized variables
.bss            Uninitialized variables
.plt            Procedure Linkage Table
.plt.got        GOT-based PLT entries
.got            Global Offset Table
.got.plt        PLT-specific GOT entries
.dynamic        Dynamic linking info
.symtab         Symbol table
.strtab         String table

Disassembling main

objdump -d hello | grep -A15 "<main>:"

Output:

0000000000001139 <main>:
    1139:   push   %rbp
    113a:   mov    %rsp,%rbp
    113d:   lea    0xec0(%rip),%rax        # Load address of string
    1144:   mov    %rax,%rdi               # First argument to puts
    1147:   call   1030 <puts@plt>         # Call puts (optimized from printf)
    114c:   mov    $0x0,%eax               # Return 0
    1151:   pop    %rbp
    1152:   ret

Observations:

  • Standard function prologue/epilogue
  • String address loaded with RIP-relative addressing (PIC)
  • Call goes to puts@plt, not directly to puts
  • Return value (0) in EAX

Examining PLT Entry

objdump -d hello | grep -A5 "<puts@plt>"

Output:

0000000000001030 <puts@plt>:
    1030:   jmp    *0x2fda(%rip)        # 4010 <puts@GLIBC_2.2.5>
    1036:   push   $0x0
    103b:   jmp    1020 <_init+0x20>

This is the PLT stub! It jumps through the GOT at address 0x4010.

Viewing Relocations

readelf -r hello

Shows entries like:

Offset          Type            Sym.Name + Addend
000000004010    R_X86_64_JUMP   puts@GLIBC_2.2.5 + 0

This tells the dynamic linker: “Fill address 0x4010 with the address of puts”

Runtime Tracing

System calls:

strace ./hello

See:

  • execve - Starting the program
  • brk - Heap operations
  • mmap - Mapping libc.so.6
  • write - Actual output
  • exit_group - Terminating

Library calls:

ltrace ./hello

See:

  • puts("hello world") - The actual library call

Dynamic linking details:

LD_DEBUG=libs ./hello

See which libraries are loaded and where.

Memory Map at Runtime

gdb ./hello
(gdb) break main
(gdb) run
(gdb) info proc mappings

Shows all memory regions, including where libc is mapped.

Part 11: Comparison - With vs Without Libc

Let’s put everything in perspective.

Side-by-Side Code

Without libc:

static inline long syscall3(long n, long a1, long a2, long a3) {
    long ret;
    __asm__ volatile (
        "syscall"
        : "=a"(ret)
        : "a"(n), "D"(a1), "S"(a2), "d"(a3)
        : "rcx", "r11", "memory"
    );
    return ret;
}

static inline long syscall1(long n, long a1) {
    long ret;
    __asm__ volatile (
        "syscall"
        : "=a"(ret)
        : "a"(n), "D"(a1)
        : "rcx", "r11", "memory"
    );
    return ret;
}

__attribute__((noreturn))
void _start(void) {
    const char msg[] = "hello world\n";
    syscall3(1, 1, (long)msg, 12);      // write
    syscall1(60, 0);                     // exit
    __builtin_unreachable();
}

Compile: gcc -nostdlib -static hello.c

With libc:

#include <stdio.h>

int main(void) {
    printf("hello world\n");
    return 0;
}

Compile: gcc hello.c

Feature Comparison

Aspect Without Libc With Libc (Dynamic) With Libc (Static)
Entry point _start (we provide) _start (libc provides) _start (libc provides)
Initialization None Full C runtime setup Full C runtime setup
Output function Direct write syscall printf / puts printf / puts
Buffering None Line buffering Line buffering
Exit handling Direct exit syscall exit() with cleanup exit() with cleanup
Binary size ~8-16 KB ~16 KB ~800 KB
Dependencies None libc.so.6, ld-linux.so.2 None
Portability Low (Linux-specific syscalls) High (C standard) High (C standard)
Convenience Low High High
Startup time Fastest Fast Slightly slower
Memory usage Minimal Shared with other processes ~800 KB private
Updates Recompile needed Automatic Recompile needed

When to Use Each Approach

Use dynamic linking (default) for:

  • Normal application development (99.9% of cases)
  • When you want updates without recompiling
  • Desktop/server applications
  • Anything where convenience matters

Use static linking for:

  • Containers (simpler deployment)
  • Embedded systems without shared library support
  • When you need to guarantee exact library version
  • Creating self-contained binaries

Skip libc for:

  • Educational purposes (understanding the fundamentals)
  • Bootloaders, OS kernels
  • Extreme size optimization
  • Code golf / minimalism challenges
  • Special-purpose tools

Part 12: Advanced Topics

Custom Dynamic Linker

You can specify a different interpreter:

gcc -Wl,--dynamic-linker=/my/custom/ld.so hello.c

Used for:

  • Testing custom dynamic linkers
  • Running programs with specific glibc versions
  • Containerization

LD_PRELOAD Tricks

Override library functions without modifying the program:

Example - Logging malloc calls:

// malloc_logger.c
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>

void *malloc(size_t size) {
    static void *(*real_malloc)(size_t) = NULL;
    if (!real_malloc) {
        real_malloc = dlsym(RTLD_NEXT, "malloc");
    }
    void *ptr = real_malloc(size);
    fprintf(stderr, "malloc(%zu) = %p\n", size, ptr);
    return ptr;
}
gcc -shared -fPIC malloc_logger.c -o malloc_logger.so -ldl
LD_PRELOAD=./malloc_logger.so ./any_program

Every malloc call will be logged!

RPATH and RUNPATH

Embed library search paths in the binary:

RPATH (searched before LD_LIBRARY_PATH):

gcc -Wl,-rpath,/custom/lib hello.c

RUNPATH (searched after LD_LIBRARY_PATH):

gcc -Wl,--enable-new-dtags,-rpath,/custom/lib hello.c

View with:

readelf -d hello | grep RPATH
readelf -d hello | grep RUNPATH

Direct Syscalls with Libc

You can still make syscalls directly:

#include <unistd.h>
#include <sys/syscall.h>

int main() {
    const char msg[] = "hello\n";
    syscall(SYS_write, 1, msg, 6);
    syscall(SYS_exit, 0);
}

Useful when libc doesn’t provide a wrapper for a syscall.

Weak Symbols

Define default implementations that can be overridden:

// library.c
__attribute__((weak))
void custom_handler(void) {
    printf("Default handler\n");
}

void do_work(void) {
    custom_handler();
}
// main.c
void custom_handler(void) {  // Strong symbol, overrides weak
    printf("Custom handler\n");
}

int main() {
    do_work();  // Calls our version
}

Constructor and Destructor Functions

Run code before main() and after exit():

__attribute__((constructor))
void before_main(void) {
    printf("This runs before main!\n");
}

__attribute__((destructor))
void after_main(void) {
    printf("This runs after main!\n");
}

int main() {
    printf("Inside main\n");
    return 0;
}

Output:

This runs before main!
Inside main
This runs after main!

Part 13: Tools Summary

Essential tools for understanding dynamic linking:

Tool Purpose Example
gcc Compiler/linker driver gcc hello.c -o hello
ld Static linker (usually called by gcc) Usually indirect
ldd List dynamic dependencies ldd hello
readelf Read ELF file structure readelf -h hello
objdump Disassemble, view sections objdump -d hello
nm List symbols nm hello
strace Trace system calls strace ./hello
ltrace Trace library calls ltrace ./hello
LD_DEBUG Debug dynamic linking LD_DEBUG=all ./hello
patchelf Modify ELF binaries patchelf --set-rpath /path hello
ldconfig Configure dynamic linker cache sudo ldconfig
gdb Debugger gdb ./hello

Quick reference commands:

# View dependencies
ldd ./program

# View ELF header
readelf -h ./program

# View program headers (segments)
readelf -l ./program

# View section headers
readelf -S ./program

# View dynamic section
readelf -d ./program

# View symbol table
readelf -s ./program

# View relocations
readelf -r ./program

# Disassemble
objdump -d ./program

# View all sections with content
objdump -s ./program

# Dynamic symbols
objdump -T ./program

# List symbols
nm ./program
nm -D ./program  # Dynamic symbols only

# Trace syscalls
strace ./program

# Trace library calls
ltrace ./program

# Debug dynamic linking
LD_DEBUG=libs ./program
LD_DEBUG=symbols ./program
LD_DEBUG=all ./program 2>&1 | less

Part 14: Conclusion

We’ve completed our journey through the world of libraries and dynamic linking. Let’s reflect on what we’ve learned.

The Layers of Abstraction

Starting from the previous article, we now understand the complete stack:

Hardware level:

  • CPU fetching and executing instructions
  • Registers storing data
  • Memory hierarchy (cache, RAM)

Kernel level:

  • System calls bridging user and kernel space
  • Process management
  • Memory management

Library level (this article):

  • Libraries providing reusable code
  • Static linking (copying code into executable)
  • Dynamic linking (loading code at runtime)
  • The dynamic linker’s crucial role

Language level:

  • C standard library providing convenient functions
  • Runtime initialization before main()
  • Cleanup and finalization after main()

Application level:

  • Your code, blissfully unaware of all this complexity
  • printf("hello world\n"); just works

The Hidden Complexity

That simple line:

printf("hello world\n");

Involves:

  1. Compiler optimizing to puts
  2. PLT stub being called
  3. First call triggers dynamic linker
  4. GOT being updated with real address
  5. Jump to libc’s puts function
  6. String being copied to buffer
  7. Newline triggering buffer flush
  8. write() wrapper being called
  9. System call happening (mode switch)
  10. Kernel writing to terminal
  11. Display showing the text

Dozens of steps, thousands of lines of code, decades of engineering - all hidden behind one simple function call.

Appreciation for the Tools

The tools we use every day:

  • GCC - Sophisticated compiler with incredible optimizations
  • glibc - Decades of refinement, optimization, bug fixes
  • Dynamic linker - Clever engineering enabling code sharing
  • ELF format - Flexible format supporting complex loading scenarios

These are the result of countless hours of work by expert engineers.

Practical Takeaways

For application developers:

  • Use dynamic linking (the default) for normal programs
  • Understand that startup isn’t instant (dynamic linking has cost)
  • Be aware of dependencies (what libraries you need)
  • Know that updating libraries affects your program

For system programmers:

  • Understand the full picture from syscalls to libc
  • Know how to debug with strace, ltrace, ldd
  • Understand symbol resolution for debugging conflicts
  • Know when static linking makes sense

For curious minds:

  • Every abstraction has a cost (complexity, performance)
  • Simple interfaces hide enormous complexity
  • Understanding the layers makes you a better programmer
  • There’s always another level of depth to explore

The Journey Continues

We’ve covered a lot, but there’s still more:

  • Thread-local storage - Per-thread variables
  • Dynamic loading - dlopen(), plugins at runtime
  • Link-time optimization - Cross-file optimization
  • Profile-guided optimization - Using runtime data to optimize
  • Sanitizers - AddressSanitizer, ThreadSanitizer, etc.
  • Alternative libc implementations - musl, uclibc, bionic

But you now have the foundation to understand all of these.

Final Thoughts

The next time you write:

#include <stdio.h>

int main(void) {
    printf("hello world\n");
    return 0;
}

You’ll know:

  • What #include actually does
  • Where stdio.h comes from
  • Why it’s main not _start
  • What happens before main executes
  • How printf finds its way to libc
  • What PLT and GOT do
  • How the dynamic linker loads libraries
  • Why system calls are involved
  • How everything cleans up at the end

You’ve gone from the surface to the depths. From the simple abstraction to the complex reality underneath.

Welcome to the deeper understanding of how C programs really work.

Resources for Further Learning

Official documentation:

Books:

  • “Linkers and Loaders” by John R. Levine
  • “Computer Systems: A Programmer’s Perspective” by Bryant & O’Hallaron
  • “The Linux Programming Interface” by Michael Kerrisk
  • “Advanced C and C++ Compiling” by Milan Stevanovic

Source code:

Online resources:

Practice:

  • Read glibc source code
  • Write your own dynamic linker (educational)
  • Create shared libraries
  • Use LD_PRELOAD to hook functions
  • Build programs with different linking strategies

Understanding is built layer by layer, experiment by experiment. Keep digging deeper.