Linkers and Loaders: ELF, PE, and Mach-O Deep Dive
Master linkers and loaders: understand ELF, PE, and Mach-O formats, symbol resolution, relocation, and debugging production issues.
20+ years shipping production systems from the metal up. Lessons pulled from things that broke in production.
- ✓Basic understanding of C/C++ compilation
- ✓Familiarity with command-line tools
- ✓Knowledge of memory management concepts
- Linkers combine object files into executables, resolving symbols and performing relocation.
- ELF (Linux), PE (Windows), and Mach-O (macOS) are the three major executable formats.
- Loaders map executables into memory, resolve dynamic libraries, and start execution.
- Common issues include undefined symbols, relocation overflow, and dynamic linking errors.
- Debugging involves tools like objdump, readelf, dumpbin, and otool.
Think of a linker as a librarian who takes several chapters (object files) written by different authors, resolves cross-references (symbols), and binds them into a single book (executable). The loader is like a reader who opens the book, finds the page numbers for each chapter, and starts reading from the beginning.
When you compile a C++ program, the compiler produces object files (.o on Linux, .obj on Windows). These contain machine code but with placeholders for symbols defined in other files. The linker's job is to resolve these symbols, assign final addresses, and produce an executable. The loader then loads that executable into memory and starts it.
Understanding linkers and loaders is crucial for debugging cryptic errors like 'undefined reference', 'segmentation fault', or 'library not loaded'. This article dives into the three major executable formats: ELF (Linux), PE (Windows), and Mach-O (macOS). We'll explore their structures, symbol resolution, relocation, and dynamic linking. You'll learn how to inspect binaries, diagnose linking issues, and apply best practices in production.
Real-world incidents often stem from subtle linking problems: mismatched calling conventions, symbol visibility, or library versioning. By the end, you'll be equipped to handle these challenges with confidence.
Executable Formats Overview
Executable formats define how code and data are organized in a binary file. The three major formats are ELF (Executable and Linkable Format) used on Linux and Unix-like systems, PE (Portable Executable) used on Windows, and Mach-O (Mach Object) used on macOS and iOS. Each format has its own header structures, section organization, and dynamic linking mechanisms.
ELF is the most flexible, supporting multiple architectures and advanced features like thread-local storage. PE is derived from COFF and includes a DOS stub for backward compatibility. Mach-O is designed for the Mach kernel and uses a two-level namespace for symbols.
Understanding these formats helps when debugging issues like 'file not recognized' or 'bad executable' errors. Tools like 'file' can identify the format: 'file myprogram' might output 'ELF 64-bit LSB executable, x86-64'.
ELF File Structure
An ELF file begins with an ELF header that contains magic number (\x7fELF), class (32/64-bit), endianness, OS/ABI, and entry point. Following the header are program headers (for loading) and section headers (for linking).
- .text: executable code
- .data: initialized data
- .bss: uninitialized data
- .rodata: read-only data
- .symtab: symbol table
- .strtab: string table
- .rela.text: relocation entries for .text
Dynamic linking uses .dynamic, .dynsym, .dynstr, .got, and .plt sections. The Global Offset Table (GOT) and Procedure Linkage Table (PLT) enable position-independent code.
Example: Inspecting ELF sections with readelf.
PE File Structure
PE files start with a DOS header (MZ) and a DOS stub, followed by the PE signature and COFF header. The optional header contains image base, entry point, and data directories. Sections include .text, .data, .rdata, .idata (import), .edata (export), and .reloc (base relocations).
PE uses Import Address Table (IAT) and Export Address Table (EAT) for dynamic linking. The loader resolves imports by walking the IAT and patching addresses.
Example: Dumping PE headers with dumpbin.
Mach-O File Structure
Mach-O files have a header with magic (MH_MAGIC_64), followed by load commands that describe segments and sections. Segments include __TEXT (code), __DATA (data), __LINKEDIT (symbols, strings).
Mach-O uses two-level namespace for symbols: each symbol is associated with its defining library. This avoids symbol clashes but can cause 'symbol not found' errors if libraries are missing.
Example: Using otool to inspect Mach-O.
Symbol Resolution and Relocation
Symbol resolution is the process of matching symbol references to definitions across object files. Relocation adjusts addresses in code and data to reflect the final memory layout.
There are two types of relocation: static (at link time) and dynamic (at load time). Static relocation modifies the binary directly; dynamic relocation uses GOT/PLT.
Example: A simple C++ program with external symbol.
Dynamic Linking and Loading
Dynamic linking allows executables to use shared libraries at runtime. The dynamic linker/loader (ld.so on Linux, dyld on macOS, Ldr on Windows) resolves symbols and loads libraries.
On Linux, the dynamic linker uses LD_LIBRARY_PATH, rpath, and ldconfig cache. On macOS, dyld uses DYLD_LIBRARY_PATH and install names. On Windows, the loader searches the application directory, system directories, and PATH.
Example: Setting rpath during linking.
Best Practices and Debugging Tools
- Always link libraries in the correct order (dependent libraries first).
- Use '--as-needed' to avoid unnecessary dependencies.
- Use version scripts to control symbol visibility.
- For C++, use 'extern "C"' to prevent name mangling when interfacing with C.
- Use 'nm', 'objdump', 'readelf', 'dumpbin', 'otool' to inspect binaries.
Example: Using version script to hide internal symbols.
The Case of the Missing VTable: A C++ ABI Disaster
- Always recompile all dependencies when upgrading the compiler.
- Use version scripts or visibility attributes to control symbol exports.
- Test with a clean build from source in CI/CD pipelines.
- Be aware of C++ ABI compatibility across compiler versions.
- Use tools like 'objdump -T' to inspect dynamic symbols.
nm -u myprogram.oobjdump -T mylib.so | grep symbol| File | Command / Code | Purpose |
|---|---|---|
| check_format.sh | file myprogram | Executable Formats Overview |
| elf_sections.cpp | int global_var = 42; | ELF File Structure |
| pe_headers.bat | dumpbin /headers myprogram.exe | PE File Structure |
| macho_otool.sh | otool -L myprogram | Mach-O File Structure |
| symbol_resolution.cpp | int foo(); | Symbol Resolution and Relocation |
| rpath_example.sh | g++ -o myprogram main.cpp -L. -lmylib -Wl,-rpath,'$ORIGIN/lib' | Dynamic Linking and Loading |
| version_script.map | { | Best Practices and Debugging Tools |
Key takeaways
Common mistakes to avoid
3 patternsLinking libraries in wrong order
Forgetting to export symbols in shared libraries
Mixing 32-bit and 64-bit object files
Interview Questions on This Topic
Explain the difference between static and dynamic linking.
Frequently Asked Questions
20+ years shipping production systems from the metal up. Lessons pulled from things that broke in production.
That's Compiler Design. Mark it forged?
3 min read · try the examples if you haven't