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
returninstead of callingexit()
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 outputmalloc()for memory allocationstrlen()for string lengthfopen()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:
- Code reuse - Write once, use everywhere
- Standardization - Everyone’s
printfworks the same way - Maintenance - Fix a bug once, all programs benefit
- Expertise - Library authors are experts in their domain
- 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 (
.ofiles 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
errnoon 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
_startentry 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 (
.oobject files) - Library code (
.astatic libraries or references to.soshared 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:
- Preprocessing:
cppexpands#include, macros - Compilation:
cc1compiles C to assembly - Assembly:
asassembles to object file - Linking:
ldcreates the final executable ← We’re here
The linker does:
1. Collect object files:
- Your code:
hello.o(compiled fromhello.c) - Startup code:
crt1.o,crti.o,crtn.o(C runtime initialization) - Libraries:
libc.so.6(orlibc.afor 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 printfin your code to theprintffunction in libc - This is called symbol resolution
4. Merge sections:
- Combine all
.textsections (code) into one - Combine all
.datasections (initialized data) into one - Combine all
.rodatasections (read-only data) into one - Allocate
.bsssection (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
_startfrom 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
mainfunction - 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:
- Linker opens
/usr/lib/x86_64-linux-gnu/libc.a - Finds
printf(and everything it depends on) - Copies all the needed code into your executable
- Resolves all symbols to absolute addresses
- 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.6→libc-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 nameld-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
- Shell calls
execve("./hello", argv, envp) - Kernel reads the ELF header
- Kernel sees it’s dynamically linked (has INTERP section)
- Kernel loads the dynamic linker instead
- Kernel sets RIP (instruction pointer) to dynamic linker’s entry point
- 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
.dynamicsection - Finds: “This program needs libc.so.6”
B. Searches for libraries:
Search order:
- RPATH in the binary (embedded path, checked first)
- LD_LIBRARY_PATH environment variable
- RUNPATH in the binary (embedded path, checked after LD_LIBRARY_PATH)
- /etc/ld.so.cache - cached list of libraries (created by
ldconfig) - 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
printfin 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
.initor.init_array - Example: C++ global constructors
G. Transfers control:
- Finally jumps to your program’s entry point
- Usually
_startin 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:
- PLT (Procedure Linkage Table) - Small code stubs
- 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
.gotor.got.pltsection - 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
.pltsection - 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:
- Your code:
call printf@plt - Jump to PLT entry for printf
- PLT tries to jump through GOT
- GOT still has placeholder (points back to next instruction in PLT)
- PLT pushes function index
- PLT jumps to PLT[0]
- PLT[0] calls dynamic linker’s resolver function
- Resolver finds printf’s real address
- Resolver updates GOT entry with real address
- Resolver jumps to real printf
- printf executes
Subsequent calls:
- Your code:
call printf@plt - Jump to PLT entry for printf
- PLT jumps through GOT
- GOT now has real address (from first call)
- 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:
- Loads required libraries (libc.so.6, etc.)
- Resolves symbols
- Performs relocations
- Calls initializers
- 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:
- Clears frame pointer (RBP) to mark top of call stack
- Reads argc, argv, envp from stack
- Aligns stack (x86-64 ABI requires 16-byte alignment before calls)
- Prepares arguments for
__libc_start_main - 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:
errnois 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_initcalls:- Functions in
.initsection (old-style) - Functions in
.init_arraysection (modern)
- Functions in
- 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:
mainnever really “returns” to caller - Instead, its return value is passed to
exit() - This is why
return 0;from main is the same asexit(0);
Step 6: Your main() Executes
Finally, your code runs:
int main(void)
{
printf("hello world\n");
return 0;
}
What happens:
- Stack frame is set up
printfis called (goes through PLT → GOT → libc)- Return value (0) is placed in RAX
- 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:
- Marks process as terminated
- Sets exit status
- Closes all file descriptors
- Frees memory
- Sends SIGCHLD to parent
- 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) putsis simpler (just print string + newline)putsis 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_listkeeps track of which argument is nextva_argretrieves 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:
- Checks its free lists (previously freed blocks)
- If free space available: Return it
- If not: Request more memory from OS:
- Small allocations: Use
brk()syscall (extends heap) - Large allocations: Use
mmap()syscall (map new region)
- Small allocations: Use
Free:
free(ptr);
- Marks block as free
- Possibly merges with adjacent free blocks
- Updates free lists
- 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:
- Local symbols - Defined in your binary
- LD_PRELOAD - Libraries in LD_PRELOAD environment variable
- Dependencies - Libraries your binary depends on (in order)
- 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 programbrk- Heap operationsmmap- Mapping libc.so.6write- Actual outputexit_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:
- Compiler optimizing to
puts - PLT stub being called
- First call triggers dynamic linker
- GOT being updated with real address
- Jump to libc’s puts function
- String being copied to buffer
- Newline triggering buffer flush
- write() wrapper being called
- System call happening (mode switch)
- Kernel writing to terminal
- 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
#includeactually does - Where
stdio.hcomes from - Why it’s
mainnot_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:
- glibc source
- musl libc source (simpler, easier to read)
Online resources:
- OSDev Wiki - Dynamic Linker
- Linux Foundation Referenced Specifications
- Eli Bendersky’s blog (excellent systems programming articles)
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.