Mapping C++ Structs from mmap: Zero-Copy Parsing Without Undefined Behavior
The Zero-Copy Dream: Reading Files with mmap in C++
Parsing binary file formats like ELF or PE often drives developers toward zero-copy designs. After all, if the operating system can map the entire file into virtual address space using mmap, why copy megabytes of headers and symbol tables into heap buffers? In an ideal world, you'd map the file into a void*, cast it directly to a pointer to your structure, and access its members immediately.
However, from a strict C++ language-lawyer perspective, simply casting a raw byte sequence from an external source and dereferencing it triggers Undefined Behavior (UB). Let's explore why this happens and how to safely access mmap-ed structures across modern C++ standards (C++20, C++23, and earlier).
The Language-Lawyer Dilemma: Object Lifetimes and Strict Aliasing
Consider the typical setup:
void* mappedRegion = mmap(nullptr, length, PROT_READ, MAP_PRIVATE, fd, 0);
const Elf64_Ehdr* header = reinterpret_cast<const Elf64_Ehdr*>(mappedRegion);
std::cout << header->e_machine; // Is this strictly legal?In the C++ object model (starting formally in C++11 and refined through C++20):
- An object does not exist until its lifetime has begun.
mmapsimply allocates address space and backs it with file pages; it does not invoke a C++ constructor or implicitly begin an object's lifetime. - Alignment requirements: The mapped pointer must satisfy
alignof(Elf64_Ehdr). Whilemmapyields page-aligned pointers (typically 4KB), which satisfies most structures, sub-structures deep in the binary might not be suitably aligned. - Aliasing rules: Accessing an object through a pointer of a type that does not match its dynamic type violates the strict aliasing rule (
[basic.lval]).
If no Elf64_Ehdr object's lifetime has begun at that address, dereferencing header->e_machine technically executes UB.
The C++23 Way: std::start_lifetime_as
Before C++23, starting the lifetime of an object without mutating its contents or invoking constructors was a notable defect in the C++ standard. P0593R6 (Implicit creation of objects for low-level object manipulation) addressed this, culminating in std::start_lifetime_as in C++23.
If the type is an implicit-lifetime type (which includes aggregate structs and trivially copyable types like standard ELF headers), std::start_lifetime_as establishes the object's lifetime over existing bytes without modifying them:
#include <memory>
void* mappedRegion = mmap(...);
// Checks alignment and creates an Elf64_Ehdr in-place without touching the memory
const Elf64_Ehdr* header = std::start_lifetime_as<const Elf64_Ehdr>(mappedRegion);
// Well-defined, 100% legal, and strictly standard-compliant
std::cout << "Machine: " << header->e_machine << '\n';If your structure is an array (e.g., an array of section headers), use std::start_lifetime_as_array<Elf64_Shdr>(mappedRegion, count).
C++20: Implicit Object Creation
P0593R6 was adopted as a defect report affecting C++20 retroactively. The standard states that operations like std::memmove, std::memcpy, or standard allocation routines implicitly create objects of implicit-lifetime types.
While self-memmove hacks like std::memmove(mappedRegion, mappedRegion, sizeof(T)) have been discussed to trigger implicit lifetime creation, they require writable memory (which fails with PROT_READ mappings) and feel like an unnatural workaround. In C++20, you can also use placement-new with pseudo-destructors or trivial types, though writing via placement-new would overwrite the mapped bytes unless default-initialization semantics are carefully evaluated.
What Should You Do in C++17 and Earlier?
If you are stuck on C++17 or earlier and require zero copies without technical UB, here are your practical choices:
1. The Pure Standard Route: std::memcpy for Individual Fields
Compilers optimize std::memcpy into single register loads when reading primitive fields. For small reads, copying only the required fields into a local variable is guaranteed to be free of UB:
uint16_t machine;
std::memcpy(&machine, static_cast<const char*>(mappedRegion) + offsetof(Elf64_Ehdr, e_machine), sizeof(machine));Modern compilers (GCC, Clang, MSVC) turn this into a single movzx instruction, meaning no actual performance penalty exists for reading scalar values.
2. The Pragmatic System-Level Route: reinterpret_cast
Every major POSIX implementation and OS loader (like the Linux kernel and GLibc's ld.so) casts mapped buffers to ELF headers directly via reinterpret_cast. As long as:
- The struct is a TriviallyCopyable Standard Layout struct.
- The buffer pointer is properly aligned (
reinterpret_cast<uintptr_t>(ptr) % alignof(T) == 0). - You do not violate endianness constraints.
Compilers provide ABI guarantees ensuring this works as expected in real-world systems, even if C++17 language theory lagged behind standard system-programming practices.
Summary
- C++23: Use
std::start_lifetime_as<T>()orstd::start_lifetime_as_array<T>(). It solves this exact issue with zero overhead and full standard compliance. - C++20: Relies on implicit object creation rules retroactively implemented by P0593R6.
- C++17 and below: Relying on
std::memcpyguarantees standard compliance with no runtime overhead for scalar access, whilereinterpret_castremains the widely accepted pragmatic industry practice for page-aligned, trivially copyable structures.