C++ Module 20 – Systems programming with C++
Memory-mapped files in C++: mmap and binary formats
Map files into memory with mmap in C++, read binary formats such as WAV with explicit byte order, and check every length and offset that a file contains.
What you will learn
- Map files into memory with mmap
- Parse binary formats portably
- Validate untrusted lengths and offsets
Before you start
What you’ll learn · Before you start
What you will learn
- Map files into memory with mmap
- Parse binary formats portably
- Validate untrusted lengths and offsets
Before you start
On this page
mmap maps a file into a program’s address space. After one call, the file’s bytes can be read through a pointer
like an ordinary array, and the operating system loads each page of the file the first time the program touches it;
nothing is copied into a buffer of the program’s own. Binary formats, read this way or with read, are parsed byte by
byte with an explicit byte order, never by casting the bytes to a struct, because a struct’s alignment, padding and
byte order belong to the compiler and the processor, not to the file. And every length and offset that a file
contains is untrusted input, checked against the file’s size before it is used.
This lesson maps files with an owner that unmaps them, times mmap against streams on a 100 MB file, and reads the
header of a WAV sound file field by field, on both compilers.
Mapping a file with one owner
C++ header · mapped_file.hpp
// mapped_file.hpp: a read-only memory mapping of a whole file, with one owner that unmaps it.
#pragma once
#include "fd.hpp"
#include <cstddef>
#include <expected>
#include <span>
#include <utility>
#include <sys/mman.h>
#include <sys/stat.h>
class MappedFile {
public:
static std::expected<MappedFile, std::error_code> open(const char* path) {
auto fd = open_file(path, O_RDONLY);
if (!fd) return std::unexpected(fd.error());
struct stat st {};
if (::fstat(fd->get(), &st) != 0) return std::unexpected(last_error());
MappedFile m;
m.size_ = static_cast<std::size_t>(st.st_size);
if (m.size_ == 0) return m; // mmap of 0 bytes fails, and an empty span needs no mapping
void* p = ::mmap(nullptr, m.size_, PROT_READ, MAP_PRIVATE, fd->get(), 0);
if (p == MAP_FAILED) return std::unexpected(last_error());
m.data_ = static_cast<const std::byte*>(p);
return m; // the descriptor closes here; the mapping stays valid until munmap
}
MappedFile(MappedFile&& other) noexcept
: data_(std::exchange(other.data_, nullptr)), size_(std::exchange(other.size_, 0)) {}
MappedFile& operator=(MappedFile&& other) noexcept {
if (this != &other) {
unmap();
data_ = std::exchange(other.data_, nullptr);
size_ = std::exchange(other.size_, 0);
}
return *this;
}
~MappedFile() { unmap(); }
std::span<const std::byte> bytes() const { return {data_, size_}; }
private:
MappedFile() = default;
void unmap() {
if (data_) ::munmap(const_cast<std::byte*>(data_), size_);
data_ = nullptr;
}
const std::byte* data_ = nullptr;
std::size_t size_ = 0;
}; C++ · count_lines.cpp
// count_lines.cpp: counts the lines of a 100 MB file three ways, and times each (the fastest of three runs).
#include "mapped_file.hpp"
#include <algorithm>
#include <chrono>
#include <fstream>
#include <print>
#include <string>
#include <vector>
// Makes the compiler finish computing value here, and not assume memory unchanged across this point (an empty asm
// statement, a GCC and Clang extension), so that no work moves out of the timed span.
template <class T>
void keep(const T& value) {
asm volatile("" : : "r,m"(value) : "memory");
}
long count_mapped() {
auto file = MappedFile::open("big.txt");
return std::ranges::count(file->bytes(), std::byte{'\n'});
}
long count_getline() {
std::ifstream in("big.txt");
std::string line;
long n = 0;
while (std::getline(in, line)) ++n;
return n;
}
long count_chunks() {
std::ifstream in("big.txt", std::ios::binary);
std::vector<char> buffer(1 << 20);
long n = 0;
while (in.read(buffer.data(), static_cast<std::streamsize>(buffer.size())) || in.gcount() > 0)
n += std::count(buffer.data(), buffer.data() + in.gcount(), '\n');
return n;
}
int main() {
{
// 1,250,000 lines of 80 bytes: 100,000,000 bytes.
std::string block;
for (int i = 0; i < 1000; ++i) block += std::string(79, 'x') + '\n';
std::ofstream out("big.txt", std::ios::binary);
for (int i = 0; i < 1250; ++i) out.write(block.data(), static_cast<std::streamsize>(block.size()));
}
struct Way {
const char* name;
long (*count)();
long lines = 0;
double best_ms = 1e12;
} ways[] = {{"mmap and std::ranges::count: ", count_mapped},
{"std::getline line by line: ", count_getline},
{"ifstream::read, 1 MiB at once:", count_chunks}};
for (int run = 0; run < 3; ++run) {
for (Way& w : ways) {
auto t0 = std::chrono::steady_clock::now();
keep(t0);
w.lines = w.count();
keep(w.lines);
std::chrono::duration<double, std::milli> took = std::chrono::steady_clock::now() - t0;
w.best_ms = std::min(w.best_ms, took.count());
}
}
std::println("lines counted: mmap {}, getline {}, 1 MiB reads {}", ways[0].lines, ways[1].lines, ways[2].lines);
for (const Way& w : ways) std::println("{} {:7.1f} ms", w.name, w.best_ms);
} from session import run
# Optimised builds, no sanitizers: this one measures time. The 100 MB file is written by the program itself.
run("""
clang++ -std=c++23 -O2 -Wall -Wextra count_lines.cpp -o count_lines && ./count_lines
g++-16 -std=c++23 -O2 -Wall -Wextra count_lines.cpp -o count_lines-gcc && ./count_lines-gcc
""") Output
$ clang++ -std=c++23 -O2 -Wall -Wextra count_lines.cpp -o count_lines && ./count_lines lines counted: mmap 1250000, getline 1250000, 1 MiB reads 1250000 mmap and std::ranges::count: 56.0 ms std::getline line by line: 92.2 ms ifstream::read, 1 MiB at once: 77.0 ms $ g++-16 -std=c++23 -O2 -Wall -Wextra count_lines.cpp -o count_lines-gcc && ./count_lines-gcc lines counted: mmap 1250000, getline 1250000, 1 MiB reads 1250000 mmap and std::ranges::count: 47.8 ms std::getline line by line: 96.8 ms ifstream::read, 1 MiB at once: 50.3 ms
This output changes from run to run: the times change from run to run and from one computer to another
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 count_lines_session.py
MappedFile::open opens the file, asks fstat for its size and maps all of it with PROT_READ and MAP_PRIVATE.
The descriptor is closed at once, and the mapping stays valid until munmap, which the destructor calls exactly
once. A file of 0 bytes gets no mapping at all, because mmap refuses a length of 0. bytes() hands the contents
out as a std::span<const std::byte>.
All three counts agree. The mapped version scans the file in place, while both stream versions copy every byte from
the system into a buffer first, and std::getline also builds a std::string for each of the 1,250,000 lines. The
file was written a moment earlier, so it sits in the system’s page cache and no disk is involved; that is the case
where mapping helps most. keep matters as much as the mapping: without it, the clang build reported 100 MB counted
in about a millisecond. The first two results were overwritten before anything read them, so the optimiser removed
those counts, and the fastest run timed only open, mmap and munmap.
What a mapping is
What mmap connects
Text description of the diagram
The diagram shows four boxes from top to bottom.
- A file on disk, here tone.wav of 1,964 bytes, drawn as a cylinder.
- An arrow labelled "read from disk once, on demand" leads to the page cache in the kernel, which holds the file's contents in pages of 16 KiB on this Mac.
- An arrow labelled "the same pages, not a copy" leads to the program's address space. mmap() reserves a range of addresses for the file, and each page of that range is filled the first time the program touches it; that first touch is a page fault, handled by the kernel.
- An arrow leads to the program, which reads bytes[i] like any array, with no read() calls.
A mapping is a range of addresses whose pages the kernel fills from the file on demand. The first touch of each page is a page fault: the kernel finds the page in its cache, or reads it from disk, and maps it into the program. Pages are 16 KiB on Apple silicon and usually 4 KiB on x86-64 Linux. Two flags decide how the mapping and the file relate:
MAP_PRIVATEgives the program a copy-on-write view: its own writes, ifPROT_WRITEallows them, never reach the file.MAP_SHAREDshares the pages with the file and with every other process that maps it; writes reach the file, andmsyncwaits until they are written.
Reading a binary header portably
A WAV file is a RIFF file: the letters RIFF, a 4-byte size, WAVE, and then chunks, each a 4-character name,
a 4-byte length and that many bytes. Every number in it is little-endian, least significant byte first.
ByteReader reads such fields with shifts, which gives the same value on every processor, and refuses any field
that does not lie entirely inside the data:
C++ header · byte_reader.hpp
// byte_reader.hpp: reads fixed-size little-endian fields from bytes, and refuses to read past the end.
#pragma once
#include <cstddef>
#include <cstdint>
#include <optional>
#include <span>
#include <string_view>
class ByteReader {
public:
explicit ByteReader(std::span<const std::byte> bytes) : bytes_(bytes) {}
std::size_t size() const { return bytes_.size(); }
// True when n bytes starting at offset at lie inside the data. Written with a subtraction, so a huge at or n
// cannot wrap around and pass.
bool has(std::size_t at, std::size_t n) const { return at <= bytes_.size() && n <= bytes_.size() - at; }
// An unsigned little-endian integer of N bytes at offset at, built with shifts: the same on every processor.
template <std::size_t N>
std::optional<std::uint64_t> le(std::size_t at) const {
if (!has(at, N)) return std::nullopt;
std::uint64_t value = 0;
for (std::size_t i = N; i-- > 0;) value = value << 8 | std::to_integer<std::uint64_t>(bytes_[at + i]);
return value;
}
std::optional<std::uint16_t> u16(std::size_t at) const { return le<2>(at).transform([](auto v) { return static_cast<std::uint16_t>(v); }); }
std::optional<std::uint32_t> u32(std::size_t at) const { return le<4>(at).transform([](auto v) { return static_cast<std::uint32_t>(v); }); }
// The 4 characters of a chunk name such as "RIFF".
std::optional<std::string_view> tag(std::size_t at) const {
if (!has(at, 4)) return std::nullopt;
return std::string_view(reinterpret_cast<const char*>(bytes_.data() + at), 4);
}
private:
std::span<const std::byte> bytes_;
}; C++ · wav_header.cpp
// wav_header.cpp: writes a short WAV file, maps it, and reads its header field by field with ByteReader.
#include "byte_reader.hpp"
#include "mapped_file.hpp"
#include <cstdint>
#include <fstream>
#include <print>
#include <string>
// Appends n bytes of value, least significant byte first, as WAV files store every number.
void put(std::string& out, std::uint32_t value, int n) {
for (int i = 0; i < n; ++i) out.push_back(static_cast<char>(value >> (8 * i) & 0xff));
}
void write_wav(const char* path, std::uint32_t rate, std::uint16_t channels, std::uint32_t frames) {
std::string samples;
for (std::uint32_t i = 0; i < frames * channels; ++i) put(samples, (i / 40) % 2 ? 0x2000 : 0xe000, 2); // a square wave
std::string wav = "RIFF";
put(wav, static_cast<std::uint32_t>(36 + samples.size()), 4);
wav += "WAVEfmt ";
put(wav, 16, 4); // the size of the fmt chunk
put(wav, 1, 2); // format 1: PCM
put(wav, channels, 2);
put(wav, rate, 4);
put(wav, rate * channels * 2, 4); // bytes per second
put(wav, channels * 2, 2); // bytes per frame
put(wav, 16, 2); // bits per sample
wav += "data";
put(wav, static_cast<std::uint32_t>(samples.size()), 4);
std::ofstream(path, std::ios::binary) << wav << samples;
}
int main() {
write_wav("tone.wav", 48000, 2, 480); // 10 ms of stereo sound
auto file = MappedFile::open("tone.wav");
ByteReader r(file->bytes());
std::println("{} bytes; starts with {} and {}", r.size(), r.tag(0).value_or("?"), r.tag(8).value_or("?"));
// Chunks follow the 12-byte RIFF header: a 4-byte name, a 4-byte size, then the body.
for (std::size_t at = 12; r.has(at, 8);) {
std::string_view name = *r.tag(at);
std::uint32_t size = *r.u32(at + 4);
std::println("chunk {:?} at offset {}, {} bytes", name, at, size);
if (name == "fmt " && size >= 16) {
std::println(" format {}, {} channels, {} Hz, {} bits per sample, {} bytes per second", *r.u16(at + 8),
*r.u16(at + 10), *r.u32(at + 12), *r.u16(at + 22), *r.u32(at + 16));
}
if (!r.has(at + 8, size)) {
std::println(" the chunk claims more bytes than the file has: stop");
break;
}
at += 8 + size + (size & 1); // chunks are padded to an even length
}
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined wav_header.cpp -o wav_header && ./wav_header
g++-16 -std=c++23 -Wall -Wextra -fsanitize=address,undefined wav_header.cpp -o wav_header-gcc && ./wav_header-gcc
xxd -l 48 tone.wav
""") Output
$ clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined wav_header.cpp -o wav_header && ./wav_header 1964 bytes; starts with RIFF and WAVE chunk "fmt " at offset 12, 16 bytes format 1, 2 channels, 48000 Hz, 16 bits per sample, 192000 bytes per second chunk "data" at offset 36, 1920 bytes $ g++-16 -std=c++23 -Wall -Wextra -fsanitize=address,undefined wav_header.cpp -o wav_header-gcc && ./wav_header-gcc 1964 bytes; starts with RIFF and WAVE chunk "fmt " at offset 12, 16 bytes format 1, 2 channels, 48000 Hz, 16 bits per sample, 192000 bytes per second chunk "data" at offset 36, 1920 bytes $ xxd -l 48 tone.wav 00000000: 5249 4646 a407 0000 5741 5645 666d 7420 RIFF....WAVEfmt 00000010: 1000 0000 0100 0200 80bb 0000 00ee 0200 ................ 00000020: 0400 1000 6461 7461 8007 0000 00e0 00e0 ....data........
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 wav_header_session.py
xxd shows the bytes behind the fields: 80bb 0000 at offset 24 is 0x0000BB80, 48,000, stored least significant
byte first. has(at, n) is written as n <= size - at after checking at <= size, so a huge offset or length from
the file cannot wrap around and pass. The loop moves from chunk to chunk by the length each chunk declares, plus one
padding byte for an odd length, and stops when a chunk claims more bytes than the file has.
Why not cast the bytes to a struct
reinterpret_cast<const WavHeader*>(bytes.data()) looks shorter and breaks in four ways. The data may not be aligned
for the struct’s members; the compiler may insert padding where the file has none; no WavHeader object exists at
that address, so reading one is undefined behaviour; and the file’s byte order need not be the processor’s. The
correct ways to get a value out of bytes are:
- Shifts, as
ByteReaderdoes, which fix the byte order and need no alignment. std::memcpyinto a variable, which copies the bytes in the machine’s own byte order.std::bit_cast, which turns an array of exactly the right size into a value of another type.std::start_lifetime_as(C++23), which declares that suitably aligned bytes hold an object, without copying.
from session import run
run("""
clang++ -std=c++23 -Wall -Wextra lifetime_check.cpp -o lifetime_check && ./lifetime_check
g++-16 -std=c++23 -Wall -Wextra lifetime_check.cpp -o lifetime_check-gcc && ./lifetime_check-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra lifetime_check.cpp -o lifetime_check && ./lifetime_check std::start_lifetime_as: not in this standard library yet $ g++-16 -std=c++23 -Wall -Wextra lifetime_check.cpp -o lifetime_check-gcc && ./lifetime_check-gcc std::start_lifetime_as: available (__cpp_lib_start_lifetime_as = 202207)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lifetime_check_session.py
GCC 16’s library provides std::start_lifetime_as, and libc++ 22, the library of Apple clang 21, does not yet, which
the feature-test macro __cpp_lib_start_lifetime_as shows. Code that must build with both uses one of the first three.
Check yourself
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Lengths and offsets from a file, attacked: overreads reproduced and 4,096 damaged files Pro: opens with a Pro, Premium or Ultimate pass
A size field is an instruction from whoever made the file. This part follows a naive parser that obeys it into a silent wrong answer, a crash and an AddressSanitizer report, shows the bounds check that looks right and wraps around, and then tests a validating reader on 4,096 damaged files.
In this part:
- A size field that reads past the end, three ways
- The bounds check that wraps around
- A validating reader, tested on 4,096 damaged files
5 runnable examples (C++, C++ header, Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
mmap in practice: page faults, a file that shrinks, and a record-format exercise Pro: opens with a Pro, Premium or Ultimate pass
A mapping hides the system calls but not their costs or their failures. This part counts page faults on macOS and Linux, shows what happens when another process cuts a mapped file short, compares mapping with reading, and ends with an exercise: a validating reader for a small record format.
In this part:
- Page faults, counted
- A file cut short under a mapping
- The same programs on Linux
- When read() is the better tool
- Your turn: a validating reader for a record file
7 runnable examples (C++, C++ header, Python, linux_session.py, recorded output) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Mid · Warm-up
What does mmap give a program that read() does not, and what does it cost?
mmap makes a file's contents addressable memory: the program reads bytes through a pointer, and the kernel loads each page from its page cache or the disk the first time it is touched. Nothing is copied into a buffer of the program's own, several processes share the same cached pages, and random access needs no seek and read calls. The costs: every first touch of a page is a page fault; an I/O error or a file that shrinks while it is mapped arrives as a signal such as SIGBUS instead of an error code; the address space must hold the mapping; and the mapping must be released with munmap. For a file read once from start to end, read() with a large buffer is often as fast and easier to handle errors with.
-
Mid to senior
A C++ image decoder checks every chunk with if (offset + length > file_size) return error;, using 32-bit unsigned values, yet a crafted file makes it read far outside its buffer. Explain how, fix the check, and say how you would find such bugs before attackers do.
-
Mid to senior
A log analyser maps each log file with mmap and scans it. A few times a week it dies with SIGBUS, always around midnight, when the logs are rotated. Explain the crash and fix it. Why might the same tool on a Mac return old data instead of crashing?
-
Senior to staff
You must scan a 10 GB file for a byte pattern as fast as possible on Linux. Would you use mmap or read()? Estimate the times with numbers, design the scan, and say what you would measure before deciding.
Key takeaways
mmapmaps a file into memory; the kernel loads each page on first touch, and nothing is copied into the program. Give the mapping one owner that callsmunmap.- Read binary formats field by field with an explicit byte order, using shifts,
std::memcpyorstd::bit_cast, never by casting the bytes to a struct. - Every length and offset in a file is untrusted: check
at <= sizeand thenn <= size - atbefore reading. MAP_PRIVATEkeeps the program’s writes to itself;MAP_SHAREDshares them with the file.
References
- mmap: map pages of memory (The Open Group)
- msync: synchronize memory with physical storage (The Open Group)
- munmap: unmap pages of memory (The Open Group)
- getrusage: get information about resource utilization (The Open Group)
- posix_madvise: memory advisory information and alignment control (The Open Group)
- truncate: truncate a file to a specified length (The Open Group)
- mmap(2): map files or devices into memory (Linux man-pages) (Linux man-pages project)
- std::endian (cppreference.com)
- std::start_lifetime_as (cppreference.com)
- P2590R2: Explicit lifetime management (ISO/IEC JTC1/SC22/WG21)
- C++ Standards Support in GCC (GNU Project)
- Resource Interchange File Format (RIFF) (Microsoft)
- WAVEFORMATEX structure (mmreg.h) (Microsoft)
- libFuzzer, a library for coverage-guided fuzz testing (LLVM Project)
- PNG Specification, Third Edition: the CRC-32 algorithm (W3C)
- logrotate(8): rotates, compresses and mails system logs (copytruncate) (logrotate project)
- AddressSanitizer (LLVM Project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress