Skip to content

Binary Formats

When the linker completes its work, it produces a binary file. This file is not merely a linear Sequence of machine code; it is a structured database containing instructions, static data, symbol Tables, relocation entries, and metadata required by the Operating System’s Loader.

Understanding the structure of this binary is essential for investigating code size bloat, debugging Startup crashes, and understanding how C++ language constructs map to physical memory.

The format of the binary dictates how the OS parses and maps the file into virtual memory.

  • Operating Systems: Linux, Android, BSD, Solaris, Game Consoles (PlayStation).
  • Architecture:
  • Section Header Table: Used by the Linker. Describes the logical organization of data (e.g., “this is code”, “this is debug info”).
  • Program Header Table: Used by the OS Loader. Describes the physical mapping of file segments to memory segments (e.g., “Load this 4KB chunk into address 0x400000 with Read/Execute permissions”).
  • Characteristics: Highly flexible and supports arbitrary numbers of sections.
  • Operating Systems: Windows, EFI (UEFI Bootloaders).
  • Architecture:
  • MS-DOS Stub: A legacy header ensuring the program prints “This program cannot be run in DOS mode” if executed on 16-bit DOS.
  • PE Header: Contains the “Optional Header” (which is actually mandatory) defining the entry point, stack size, and subsystem (Console vs. GUI).
  • Data Directories: Pointers to tables like Imports, Exports, and Resources.
  • Operating Systems: macOS, iOS, watchOS.
  • Architecture:
  • Load Commands: A sequence of headers describing the file layout (e.g., LC_SEGMENT_64 LC_MAIN).
  • FAT Binaries (Universal): A wrapper allowing multiple architectures (e.g., x86_64 and arm64) to exist in a single file structure, enabling “Universal binaries” for Apple Silicon migration.
AspectELFPE/COFFMach-O
Primary PlatformLinux, BSD, AndroidWindows, UEFImacOS, iOS
Logical ViewSection Header TableSection TableLoad Commands
Loader ViewProgram Header TableOptional HeaderLC_SEGMENT_64
Debug InfoDWARF (inline)PDB (external)DWARF (inline)
Code SigningOptionalAuthenticodeMandatory
Max SectionsUnlimited96 (PE32+)Unlimited
Dynamic Linking.got + .pltIATStub islands + GOT
TLS.tbss / .tdata.tls sectionLC_THREAD_LOCAL

Despite the differences in file headers, all three formats organize C++ content into similar logical Sections. Each section has specific memory protection permissions enforced by the hardware (MMU).

  • Content: The compiled machine code instructions.
  • Permissions: RX (Read + Execute).
  • C++ Mapping: Functions, methods, and lambdas.
  • Security: The OS prevents writing to this section to thwart Self-Modifying Code exploits.
  • Content: Global and static variables that have a non-zero initial value.

  • Permissions: RW (Read + Write).

  • C++ Mapping:

    int global_counter = 42; // .data
    static int internal_id = 1; // .data
  • Cost: Increases the size of the binary on disk because the values must be stored in the file.

  • Content: Global and static variables initialized to zero (or uninitialized).

  • Permissions: RW (Read + Write).

  • C++ Mapping:

    int zero_counter; // .bss (Implicitly 0)
    static int buffer[1024]; // .bss
  • Cost: Zero on disk. The binary header states “Reserve 1024 bytes here.” The OS Loader allocates zeroed pages in RAM at runtime. Moving large arrays from .data to .bss is a common optimization to reduce binary size.

  • Content: Immutable constants.

  • Permissions: R (Read Only).

  • Name Variations: .rdata (PE), __TEXT,__const (Mach-O).

  • C++ Mapping:

    const int config_val = 100; // .rodata
    const char* str = "Hello"; // The string literal "Hello" is .rodata
  • Content: Pointers to functions that must run before main().

  • C++ Mapping: Constructors of global/static objects.

    struct System { System() { /* ... */ } };
    System sys; // The constructor call is registered here

To analyze how your C++ code is being distributed across these sections, utilize platform-specific Binary analysis tools.

Tool: readelf or size

  1. View Section Headers:

    Terminal window
    readelf -S ./app

Look for Flags: AX (Alloc/Execute), WA (Write/Alloc).

  1. View Segment Size Summary:

    Terminal window
    size ./app

Output:

text data bss dec hex filename
2048 512 1024 3584 e00 ./app
  1. View Program Headers (Loader View):

    Terminal window
    readelf -l ./app

This shows LOAD segments, which map sections to memory pages.

Modern operating systems enforce a security policy where memory can be Writable or Executable, but Never both simultaneously.

  • .text is Executable but not Writable.
  • .data and .bss are Writable but not Executable.
  • Implication: You cannot generate code at runtime (JIT) in standard C++ without utilizing specific OS APIs (mmap``VirtualAlloc) to explicitly request RWX pages, which is flagged by security software.

Code in .init_array executes before main. The order in which object files populate this array is Generally undefined across translation units [N4950 S6.6.3.2].

  • Risk: If Global A depends on Global B, but A’s constructor runs before B’s, the program crashes.
  • Solution: Avoid global objects with non-trivial constructors. Use constinit (C++20) or the “Construct on First Use” idiom (static local variables).

If a binary is unexpectedly large, check the .data section. A large global array initialized to Non-zero values will bloat the file on disk.

  • Optimization: Initialize large arrays to zero (moves them to .bss).
  • Optimization: Use constexpr to verify calculations happen at compile time, potentially removing the need for data storage if the value is immediate.

ELF is the dominant binary format on Linux and forms the basis for understanding how the OS Interacts with compiled C++ code. Every ELF file begins with an ELF header that identifies the file Class (32-bit or 64-bit), endianness, and file type (relocatable, executable, shared object, or core Dump).

Terminal window
readelf -h ./app

Key fields in the ELF header:

FieldDescription
e_typeET_EXEC (executable), ET_DYN (shared/PIE), ET_REL (object file)
e_machineTarget ISA: x86-64``AArch64``RISC-VEtc.
e_phoffByte offset to the Program Header Table (loader view)
e_shoffByte offset to the Section Header Table (linker view)
e_phentsizeSize of each Program Header entry
e_phnumNumber of Program Header entries
Modern Linux distributions compile executables as Position-Independent Executables (PIE), which
Means e_type is ET_DYN (the same type as shared libraries). This enables Address Space Layout
Randomization (ASLR) for executables.

Section Header Table vs Program Header Table

Section titled “Section Header Table vs Program Header Table”

These are two orthogonal views of the same binary data:

  • Section Headers are used by the linker and debugger. They describe logical groupings of data (code, read-only data, debug info, symbol tables). Section headers are not loaded into memory at runtime and can be stripped without affecting execution.
  • Program Headers are used by the OS loader. They describe contiguous memory segments (PT_LOAD) that must be mapped into the process address space. Each PT_LOAD entry specifies the virtual address, file offset, size in the file, and size in memory (for .bss).
Terminal window
# Show the mapping between sections and segments
readelf -lS ./app

A single PT_LOAD segment contains multiple sections. For example, the read-only Executable segment might contain .text``.rodataAnd .eh_frame sections.

File Offset: 0x0000 0x1000 0x3000
+--------------------------+--------------------------+
| ELF Header (64 bytes) | |
+--------------------------+ .text section |
| Program Header Table | (machine code) |
+--------------------------+ |
| +--------------------------+
| (padding) | .rodata section |
| | (read-only data) |
+--------------------------+--------------------------+
| .data section | .bss (zeroed, not on |
| (initialized data) | disk, but in memory)|
+--------------------------+--------------------------+
| Section Header Table | Symbol tables (.symtab) |
| (can be stripped) | String tables (.strtab) |
+--------------------------+--------------------------+
Memory View (after OS loader maps PT_LOAD segments):
Virtual Addr: 0x400000 (RX) 0x600000 (RW)
+--------------------------+--------------------------+
| .text | .data |
| .rodata | .bss |
+--------------------------+--------------------------+
| .got | .got (PLT entries) |
+--------------------------+--------------------------+

The key insight is that sections (linker view) are mapped into segments (loader view). The .text And .rodata sections may share a single PT_LOAD segment mapped as Read+Execute, while .data And .bss share a PT_LOAD segment mapped as Read+Write.

This section contains exception handling frame information — the data required for C++ stack Unwinding during exception propagation and for debuggers to walk the call stack. It is present even In -fno-exceptions builds because it is used by std::terminate and signal handlers.

  • Format: DWARF Call Frame Information (CFI).
  • Size impact: Can be 5-15% of binary size. The .eh_frame_hdr section provides a binary search index for faster unwinding.
  • Stripping: strip removes .symtab and .debug_* sections but preserves .eh_frame because the runtime needs it.

These sections implement dynamic linking at runtime:

  • GOT (Global Offset Table): An array of pointers, one per external symbol. Initially points to the PLT stub. After resolution, points to the actual function/data.
  • PLT (Procedure Linkage Table): Code stubs that jump through the GOT. On first call, the stub invokes the dynamic loader to resolve the symbol and patch the GOT entry.
Terminal window
# Inspect PLT entries
objdump -d -j .plt ./app
# Inspect GOT entries
objdump -R ./app
readelf -r ./app

These are arrays of function pointers that the dynamic loader calls before main and after main Returns respectively:

  • .init_array (ELF) / .ctors (legacy): Constructors for global/static C++ objects, plus __libc_csu_init.
  • .fini_array (ELF) / .dtors (legacy): Destructors for global/static C++ objects, plus __libc_csu_fini. The order of entries within .init_array is determined by the linker’s input order of object files, Which is not guaranteed to be deterministic across builds or platforms. This is the root cause of The Static Initialization Order Fiasco.
// BAD: relies on initialization order across TUs
// TU a.cpp
extern int config_value;
int derived_value = config_value * 2; // UB if a.cpp is linked before config's TU
// GOOD: use constinit (C++20) for constant initialization (no TU ordering issue)
// header.h
inline constinit int config_value = 42; // constant initialization, no .init_array entry
// GOOD: use "construct on first use" idiom
// header.h
int& get_config() {
static int config_value = 42; // thread-safe since C++11, no ordering issue
return config_value;
}

ELF binaries contain two symbol tables with distinct purposes:

TablePurposeLoaded at RuntimeStrip-safe
.symtabFull symbol table (linker + debugger)NoCan be stripped
.dynsymDynamic symbols (runtime linking only)YesCannot be stripped
  • .symtab contains every symbol: local functions, static variables, debug symbols, and exported symbols. It is used by the linker (during linking) and debuggers (during debugging). strip removes it to reduce binary size.
  • .dynsym contains only symbols that participate in dynamic linking: functions and variables imported from or exported to shared libraries. The OS loader reads this table at process startup to resolve GOT entries.
Terminal window
# Full symbol table (includes local/static symbols)
readelf -s ./app
# Dynamic symbols only (what the loader needs)
readelf --dyn-syms ./app
# Count exported symbols (potential binary bloat source)
readelf --dyn-syms ./app | grep -c "GLOBAL.*DEFAULT"

Relocation entries (.rela.text``.rela.dyn``.rela.plt) tell the linker where to patch addresses In the binary. There are two types:

  1. .rela.text (section relocations): Applied at static link time by the linker. Used to resolve references between sections within the same binary.
  2. .rela.dyn / .rela.plt (dynamic relocations): Applied at load time by the dynamic linker. Used to resolve references to shared library symbols.
Terminal window
# View all relocations
readelf -r ./app
# View only dynamic relocations (GOT entries)
readelf -r ./app | grep JUMP_SLOT

The Windows equivalent of the GOT+PLT mechanism is the IAT. When a Windows executable calls a Function from a DLL, the call goes through the IAT — an array of function pointers that the loader Fills in when the DLL is loaded. Unlike ELF’s lazy binding, Windows defaults to load-time binding (all IAT entries are resolved when The process starts). You can enable delay-load imports for DLLs that may not be needed:

# Delay-load a DLL
target_link_options(app PRIVATE /DELAYLOAD:myplugin.dll)

Both ELF and PE have dedicated sections for thread-local variables declared with thread_local or __declspec(thread):

  • ELF: .tbss (uninitialized TLS), .tdata (initialized TLS). Access via the fs or gs segment register on x86_64.
  • PE: .tls section with a TLS directory in the data directories. The loader allocates a TLS block per thread. The TLS model affects performance: | TLS Model | Description | Overhead | | :----------------- | :------------------------------------------------------ | :------------------------------------ | | Global Dynamic | General case, works for shared libraries and dlopen | Two memory accesses (GOT + TLS block) | | Initial Exec | Faster, but the TLS block address is known at load time | One memory access | | Local Exec | Fastest, TLS block address is a compile-time constant | Direct offset from fs/gs |
# Optimize TLS for executables (no shared library TLS access)
target_compile_options(app PRIVATE -ftls-model=initial-exec)

Use initial-exec for performance-critical thread_local variables in executables. It breaks if The variable is accessed from a dynamically loaded shared library.

Section titled “Use initial-exec for performance-critical thread_local variables in executables. It breaks if The variable is accessed from a dynamically loaded shared library.”

Mach-O uses Load Commands rather than section/program header tables. Key load commands:

Load CommandDescription
LC_SEGMENT_64Maps a contiguous range of file data to virtual memory
LC_MAINSpecifies the entry point offset (replaces LC_UNIXTHREAD)
LC_LOAD_DYLIBRecords a dependency on a dynamic library (dylib)
LC_LOAD_DYLINKERSpecifies the path to the dynamic linker (dyld)
LC_CODE_SIGNATUREOffset and size of the code signature (mandatory on Apple platforms)
LC_DYLD_INFOLocations of rebase and bind information for the loader
LC_FUNCTION_STARTSTable of function entry points for unwinding

Unlike Linux, macOS and iOS require all executables and shared libraries to be cryptographically Signed. The LC_CODE_SIGNATURE load command points to a signature that covers specific ranges of The binary. Modifying any signed byte (including patching instructions) invalidates the signature And causes the OS to refuse to load the binary. This has implications for:

  • JIT compilation: Must use MAP_JIT with pthread_jit_write_protect_np on ARM64 macOS.
  • Self-modifying code: Requires re-signing or disabling SIP (System Integrity Protection).

MacOS optimizes startup by pre-linking all system libraries into a single shared cache (/System/Library/dyld/dyld_shared_cache). This eliminates per-library symbol resolution overhead And reduces page faults. The cache is mapped read-only and shared across all processes.

Section titled “MacOS optimizes startup by pre-linking all system libraries into a single shared cache (/System/Library/dyld/dyld_shared_cache). This eliminates per-library symbol resolution overhead And reduces page faults. The cache is mapped read-only and shared across all processes.”

Debug information is stored in special sections that can be removed without affecting program Execution.

SectionContent
.debug_infoCore debug info (type descriptions, variable locations)
.debug_abbrevAbbreviation tables for .debug_info
.debug_lineSource file/line number mappings
.debug_strString literals (file paths, variable names)
.debug_frameCall frame information for unwinding (.eh_frame is the runtime subset)
Terminal window
# Strip debug symbols (produces a smaller binary but no debugging)
strip ./app
# Extract debug info into a separate file (for distribution)
objcopy --only-keep-debug ./app ./app.debug
strip --strip-debug --strip-unneeded ./app
objcopy --add-gnu-debuglink=./app.debug ./app
# GDB automatically loads the debug file if it's in the same directory
gdb ./app

On Windows, debug information is stored in a separate .pdb (Program Database) file, not in the Executable itself. MSVC always generates a .pdb in debug builds and optionally in release builds (/Zi flag).

Section titled “On Windows, debug information is stored in a separate .pdb (Program Database) file, not in the Executable itself. MSVC always generates a .pdb in debug builds and optionally in release builds (/Zi flag).”
  • Assuming .bss is “free.” While .bss costs nothing on disk, it consumes virtual memory at runtime. A 1GB .bss array will cause the OS to commit 1GB of zeroed pages (physical memory or swap). This is especially problematic on memory-constrained embedded systems.
  • Forgetting that const globals go to .rodataNot .data. A const global initialized to a non-zero value does not increase .data size — it increases .rodata size. However, if you take a pointer to a const global and cast away constness, modifying it through that pointer will trigger a segfault (.rodata is mapped read-only).
  • Not stripping debug symbols for release builds. An unstripped release binary can be 2-10x larger than a stripped one, purely due to DWARF debug sections. Always strip before distribution, but keep the debug files for crash analysis.
  • Relying on .init_array ordering across translation units. The order is linker-dependent and not specified by the C++ standard [N4950 S6.6.3.2]. Use constinit (C++20) or the construct-on-first-use idiom to avoid the Static Initialization Order Fiasco entirely.
  • Modifying code sections at runtime on macOS. Apple’s code signing prevents this. Use MAP_JIT and pthread_jit_write_protect_np for legitimate JIT use cases.
  • Mixing -fPIC and non-PIC code in shared libraries. On x86_64, non-PIC code uses absolute addressing that requires relocations in .textPreventing the OS from sharing the code segment across processes. Always compile shared library code with -fPIC (or -fPIE for executables).
  • Forgetting that .symtab is separate from .dynsym. Stripping removes .symtab but not .dynsym. If your binary still has many dynamic symbols, it may be because you are not using -fvisibility=hidden to limit exports.
  • Assuming sections map 1:1 to segments. Multiple sections are merged into a single PT_LOAD segment. Understanding this mapping is critical when analyzing memory permissions.

The PE format organizes sections differently from ELF. Each section has a name (up to 8 characters), Virtual size, virtual address, file offset, and raw data size:

PE SectionELF EquivalentContent
.text.textCode
.rdata.rodataRead-only data
.data.dataInitialized writable data
.bss.bssUninitialized data (virtual size > 0, raw size = 0)
.pdata.eh_frameException handling data
.rsrc(none)Windows resources (icons, dialogs)
.reloc.rela.dynBase relocations (for ASLR)
.CRT$XCA-XCZ.init_array / .fini_arrayC++ initializers and terminators

The PE Optional Header contains an array of data directories, each pointing to a specific table:

  • Export Table (IMAGE_DIRECTORY_ENTRY_EXPORT): Lists symbols exported by the DLL. Consumed by the loader to resolve import references from other binaries.
  • Import Table (IMAGE_DIRECTORY_ENTRY_IMPORT): Lists symbols imported from other DLLs. The loader fills in the IAT at load time.
  • Base Relocation Table: Contains relocations that must be applied if the binary is loaded at an address different from its preferred base address (ASLR).
  • TLS Directory: Points to the .tls section and callback functions for thread-local storage.
  • IAT: The Import Address Table, filled in by the loader.
Terminal window
dumpbin /HEADERS app.exe
# Look for "IMAGE_DIRECTORY_ENTRY_EXPORT" and "IMAGE_DIRECTORY_ENTRY_IMPORT"

Mach-O organizes sections into segments, where each segment has uniform memory protections. Key Segments:

Mach-O SegmentSectionsPermissionsContent
__TEXT__text``__stubs``__cstring``__constR-XCode, read-only data
__DATA__data``__bss``__la_symbol_ptr``__gotRW-Writable data, GOT
__LINKEDIT(linker metadata)R—Symbol table, string table, relocations
__PAGEZERO(none)---Null page (catches null pointer dereferences)

MacOS binaries include a __PAGEZERO segment mapped at address 0x0 with no permissions. Any Access to a null pointer (or near-null address) triggers a segmentation fault. This is the Mach-O Equivalent of the kernel’s mmap of the zero page on Linux.

Mach-O uses two types of dynamic bindings:

  • Non-lazy bindings: Resolved by dyld at load time (like PE’s IAT).
  • Lazy bindings: Resolved on first call via stub islands in __TEXT,__stubs (like ELF’s PLT). By default, macOS uses lazy bindings for performance. Bindings can be made non-lazy via the -bind_at_load linker flag.
Terminal window
clang++ -bind_at_load main.o -o app

When a binary is larger than expected, a systematic analysis approach helps identify the source of Bloat:

Terminal window
# Section-level size breakdown (ELF)
size -A ./app
# Per-symbol size (requires debug info)
nm -S --size-sort ./app
# Largest sections
readelf -S ./app | awk '{print $6, $2}' | sort -rn | head -20
# Identify the largest functions (requires debug info)
bloaty --sorted_by=vmss ./app

Common sources of binary bloat:

  1. Unstripped debug info: DWARF sections (.debug_*) can be 5-10x the size of the code. Always strip for distribution.
  2. Large .rodata sections: String literals, jump tables, and vtables accumulate here. Consider string interning or constexpr evaluation.
  3. Template instantiation bloat: Each unique template instantiation produces code. Use extern template to deduplicate across TUs.
  4. Excessive dynamic symbols: Each exported symbol adds an entry to .dynsym and .dynstr. Use -fvisibility=hidden to reduce exports.

When building for multiple platforms, the binary format differences affect the build configuration:

# Platform-specific link flags
if(APPLE)
target_link_options(app PRIVATE -Wl,-dead_strip) # Remove unused code (Mach-O)
elseif(UNIX)
target_link_options(app PRIVATE -Wl,--gc-sections) # Remove unused sections (ELF)
target_link_options(app PRIVATE -Wl,-z,relro,-z,now) # Security hardening
elseif(WIN32)
target_link_options(app PRIVATE /OPT:REF /OPT:ICF) # Remove unused refs, enable ICF
endif()

Each platform has different flags for equivalent operations. CMake’s CMAKE_SHARED_LINKER_FLAGS and CMAKE_EXE_LINKER_FLAGS can be used to set platform-appropriate defaults.

Debug information storage differs significantly across the three binary formats:

AspectELF (DWARF)PE/COFF (PDB)Mach-O (DWARF)
LocationInline in binarySeparate .pdb fileInline in binary (or .dSYM bundle)
FormatDWARF 4/5CodeViewDWARF 4/5
StrippableYes (strip)Always externalYes (strip) or .dSYM
Size overhead5-10x binary size0 (separate file)5-10x binary size
Toolinggdb``lldb``readelfVisual Studio``WinDbglldb``dsymutil

DWARF 5 (the latest standardized version) offers significant improvements over DWARF 4:

  • Smaller debug info through a more compact abbreviation table.
  • Better support for C++ templates and namespaces.
  • Faster lookup via a hash-based debug names index.
  • Improved debug info for optimized code (location lists, call frame information).
# Request DWARF 5 explicitly (GCC 11+, Clang 12+)
target_compile_options(app PRIVATE -gdwarf-5)

MacOS recommends separating debug info into .dSYM bundles for distribution:

Terminal window
# Generate dSYM after building
dsymutil ./app
# The dSYM is placed in app.dSYM/
# Symbolicate crash reports:
atos -o ./app.dSYM/Contents/Resources/DWARF/app -l 0x... < crash.log

The dSYM bundle is the macOS equivalent of a PDB file: it contains the DWARF debug information Extracted from the binary. The binary is stripped of debug info, and the dSYM is archived Separately.

Beyond macOS code signing, other platforms have integrity mechanisms that affect binary formats:

Linux does not mandate binary signing, but several mechanisms exist:

  • ELF NOTE: The .note.gnu.build-id section contains a hash of the binary’s contents, used by tools like coredumpctl and systemd-coredump to match core dumps with binaries.
  • IMA/EVM: Linux Integrity Measurement Architecture can sign ELF binaries for tamper-detection on secure boot systems.
  • fs-verity: A filesystem-level integrity mechanism that can verify file contents without reading the entire file.

Windows binaries can be signed using Authenticode, which embeds a digital signature in the PE file’s Certificate table (pointed to by the IMAGE_DIRECTORY_ENTRY_SECURITY data directory).

Terminal window
signtool sign /fd sha256 /t http://timestamp.digicert.com app.exe

Authenticode signing does not prevent modification (the signature can be stripped), but it allows Verifying the publisher’s identity and detecting modifications.

This topic covers the core concepts of binary formats, including underlying theory, practical implementation, and key applications. Key concepts include:

  • core concepts and terminology
  • algorithms and computational thinking
  • practical implementation
  • security and ethical considerations
  • applications in the real world Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.

Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.