Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 30 minutes
  • Examples run with Clang 21.0.0, GCC 16.2.0 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 11 of 30 minutes free
Free: Mapping a file with one owner · What a mapping is · Reading a binary header portably · 2 more sections. With a pass: 2 in-depth parts and 3 interview answers, about 19 minutes.

What you will learn

  • Map files into memory with mmap
  • Parse binary formats portably
  • Validate untrusted lengths and offsets

Before you start

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

mapped_file.hpp and count_lines.cpp

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);
}
Counting 1,250,000 lines three ways, timed Python · count_lines_session.py
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

A file on disk is read into the kernel page cache; mmap shows those same pages in the program, filled on first touch.File on disktone.wav, 1,964 bytesPage cache in the kernelthe file's contents, in pages(16 KiB each on this Mac)The program's address spacemmap() reserves addresses for the file;a page is filled on first touch (a page fault)The program reads bytes[i]like any array, with no read() callsread from disk once, on demandthe same pages, not a copy

What mmap connects

Text description of the diagram

The diagram shows four boxes from top to bottom.

  1. A file on disk, here tone.wav of 1,964 bytes, drawn as a cylinder.
  2. 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.
  3. 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.
  4. 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_PRIVATE gives the program a copy-on-write view: its own writes, if PROT_WRITE allows them, never reach the file.
  • MAP_SHARED shares the pages with the file and with every other process that maps it; writes reach the file, and msync waits 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:

byte_reader.hpp and wav_header.cpp

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
    }
}
Writing a 10 ms WAV file, then reading its header Python · wav_header_session.py
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.

Hex Viewer & File Inspector Open a .wav file of your own and find RIFF, WAVE, fmt and data at the offsets this reader uses.

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 ByteReader does, which fix the byte order and need no alignment.
  • std::memcpy into 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.
Which standard library has std::start_lifetime_as Python · lifetime_check_session.py
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.

  1. Question 1 of 5 After mmap returns, what has happened to the file's contents?

    Choose one answer.

    Show the answer to question 1

    Answer: Nothing has been read yet; each page is loaded the first time the program touches it

    mmap only reserves addresses and records which file backs them. The first access to each page is a page fault, and the kernel fills that page from its page cache or from disk.

  2. Question 2 of 5 MappedFile::open closes the file descriptor right after mmap. What happens to the mapping?

    Choose one answer.

    Show the answer to question 2

    Answer: It stays valid until munmap is called

    A mapping keeps its own reference to the file, so the descriptor is not needed any more. The mapping ends only when munmap is called, which the destructor of MappedFile does.

  3. Question 3 of 5 The 4 bytes at offset 24 of the WAV file are 80 BB 00 00. As a little-endian 32-bit number, what value do they hold?

    Type a number.

    Show the answer to question 3

    Answer: 48000 Hz

    Little-endian means least significant byte first, so the value is 0x0000BB80, which is 48,000. ByteReader builds it with shifts, which gives the same result on every processor.

  4. Question 4 of 5 A chunk at offset at claims n bytes. Which check can never be fooled by huge values of at or n?

    Choose one answer.

    Show the answer to question 4

    Answer: at <= size && n <= size - at

    at + n can wrap around past the largest value of its type and come out small. Checking at against the size first and then n against what is left uses a subtraction that cannot wrap.

  5. Question 5 of 5 Which of these are reasons not to read a file header with reinterpret_cast to a struct?

    Choose every answer that is right.

    Show the answer to question 5

    Answer:

    • The bytes may not be aligned for the struct's members
    • The compiler may put padding where the file has none
    • The file's byte order may differ from the processor's

    Alignment, padding, byte order and the fact that no object of the struct exists at that address all make the cast wrong. Speed is not the problem: compilers turn byte-by-byte reads with shifts into single loads where the processor allows.

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.

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.

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

  1. 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.

  2. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

  4. 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.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • mmap maps 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 calls munmap.
  • Read binary formats field by field with an explicit byte order, using shifts, std::memcpy or std::bit_cast, never by casting the bytes to a struct.
  • Every length and offset in a file is untrusted: check at <= size and then n <= size - at before reading.
  • MAP_PRIVATE keeps the program’s writes to itself; MAP_SHARED shares them with the file.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.