User and System Header Units (Donskoy)
A header unit lets you import an ordinary header — one that was never written with modules in mind — instead of #include-ing it. The compiler treats the header as if it were a module: parsed once, turned into a Binary Module Interface, and reused rather than reprocessed textually for every translation unit. This chapter imports a single standard header, <print>, this way. Importing the Standard Library covers the much larger version of the same idea — the entire standard library as one module — and reuses several of the mechanics introduced here.
Module Interface Unit: Donskoy.cppm
1 module;
2
3 export module Donskoy;
4
5 export auto meow() -> void;
Nothing about the interface unit itself mentions <print> — the header unit import lives in the implementation unit, where it’s actually used.
Module Implementation Unit: donskoy.cpp
1 module;
2 import <print>;
3
4 module Donskoy;
5
6 using std::println, std::print;
7
8 auto meow() -> void {
9 std::println("Донской кот говорит «мяу»");
10 }
import <print>; (line 2) is the header unit import — angle brackets, exactly as you’d write #include <print>, but with import in front. It appears inside the global module fragment, the same section that would otherwise hold a #include.
User Code: main.cpp
1 import Donskoy;
2
3 auto main() -> int {
4 meow();
5
6 return 0;
7 }
Building With Command Lines
With GCC
1 g++-15 -std=c++23 -c -fmodules-ts -xc++-system-header print
2
3 # build the module unit interface - produces a .gcm in gcm.cache
4 g++-15 -std=c++23 -c -fmodules-ts -x c++ Donskoy.cppm -o bin/Donskoy.o
5
6 # build the module unit implementation and link with the MUI
7 g++-15 -std=c++23 -c -fmodules-ts donskoy.cpp -o bin/Donskoy.All.o
8
9 # build main and link with the module
10 g++-15 -std=c++23 -fmodules-ts main.cpp bin/Donskoy.All.o -o bin/main
The first line builds <print>’s BMI directly from the system header, using -xc++-system-header instead of a source file — GCC produces gcm.cache/usr/include/c++/15/print.gcm from this, with no -o needed; it resolves the output path itself from the header’s own location. Everything after that is an ordinary GCC module build.
With Clang
1 # create a module from system header <print>
2 clang++-22 -std=c++23 -xc++-system-header print -o bin/print.pcm
3
4 # build the module unit interface - produces a .pcm in bin
5 clang++-22 -std=c++23 -fmodule-file=bin/print.pcm \
6 -c Donskoy.cppm -fmodule-output=bin/Donskoy.pcm -o bin/Donskoy.o
7
8 # build the module unit implementation and link with the MUI
9 clang++-22 -std=c++23 -fmodule-file=Donskoy=bin/Donskoy.pcm -fmodule-file=bin/print.pcm \
10 donskoy.cpp -o bin/donskoy.o -r bin/Donskoy.o -o bin/Donskoy.All.o
11
12 # build main and link with the module
13 clang++-22 -std=c++23 -fmodule-file=Donskoy=bin/Donskoy.pcm \
14 -c main.cpp -o bin/main.o
15
16 clang++-22 -std=c++23 bin/Donskoy.All.o bin/main.o -o bin/main
Clang’s -xc++-system-header (line 2) works the same way GCC’s does, but Clang has no implicit search path for the result — every subsequent step needs an explicit -fmodule-file=bin/print.pcm to find it.
Building With CMake
This is where header units get genuinely difficult, and it’s worth understanding why rather than just copying the workaround.
The Problem
CMake has no first-class model for header units — no FILE_SET type for them, no property that says “this header should be scanned and built as a header unit.” CMAKE_CXX_SCAN_FOR_MODULES handles named modules and their partitions; a bare import <print>; isn’t one of those.
GCC compounds this: it needs a header unit’s BMI to already exist before it can even scan a file that imports it — unlike a named module, where CMake’s dependency scanner can discover the need for a BMI and schedule it to be built first, GCC’s preprocessor has to resolve the header import just to finish preprocessing the importing file at all. And once CMake’s own generated module-mapper file is in play for a target, GCC stops falling back to its implicit gcm.cache/ search entirely; only imports CMake explicitly listed in the mapper resolve. A hand-built <print> BMI sitting in the exactly right directory is invisible to GCC’s build step, because CMake never told it about it.
The Workaround
The fix is a small, shared CMake fragment that pre-builds the header unit’s BMI at configure time, before any target’s build graph exists at all — early enough that GCC’s scan step finds it already sitting where GCC expects.
1 # Detect the compiler. Both branches pin to CMAKE_CXX_COMPILER (whichever
2 # sandboxed compiler the active CMake preset selected) rather than a
3 # hardcoded binary -- a version mismatch between the BMI and the compiler
4 # that consumes it produces cryptic "older format" / "no such file" errors.
5 if (CMAKE_CXX_COMPILER_ID STREQUAL "Clang")
6 set(ExtBMIDir ${CMAKE_BINARY_DIR}/pcm_cache)
7 file(MAKE_DIRECTORY ${ExtBMIDir})
8
9 set(BMI_COMMAND ${CMAKE_CXX_COMPILER} -std=c++23 -xc++-system-header print -o ${ExtBMIDir}/print.pcm)
10
11 set(PrintBMI ${ExtBMIDir}/print.pcm)
12
13 elseif (CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
14
15 # No -o: GCC's default header-unit layout (gcm.cache/<resolved-path>.gcm,
16 # relative to the CWD it's invoked from) is what the later scan/compile
17 # steps look up implicitly. Ninja always invokes from CMAKE_BINARY_DIR,
18 # so that's where this has to run too.
19 set(BMI_COMMAND ${CMAKE_CXX_COMPILER} -std=c++23 -fmodules-ts -xc++-system-header print)
20
21 else()
22 message(FATAL_ERROR "Unsupported compiler: ${CMAKE_CXX_COMPILER_ID}")
23 endif()
24
25
26 # Execute the command during configuration, from the top-level build
27 # directory -- see the GNU branch comment above for why.
28 execute_process(
29 COMMAND ${BMI_COMMAND}
30 WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
31 RESULT_VARIABLE BMI_BUILD_RESULT
32 ERROR_VARIABLE BMI_BUILD_ERROR
33 )
34
35 if(NOT BMI_BUILD_RESULT EQUAL 0)
36 message(FATAL_ERROR "Failed to generate print BMI ${BMI_BUILD_ERROR}")
37 endif()
Two details matter enough to have broken earlier drafts of this fragment:
The compiler is tracked via CMAKE_CXX_COMPILER, never hardcoded (line 9, line 19). An earlier version of this file hardcoded specific compiler binaries; building the BMI with one compiler version and then consuming it with another produces exactly the kind of cryptic mismatch error you’d expect — “uses an older format that is no longer supported,” or the header simply failing to load.
The working directory is ${CMAKE_BINARY_DIR}, not the source tree (line 30). GCC’s implicit header-unit layout, gcm.cache/<resolved-header-path>.gcm, is resolved relative to whatever directory the compiler is actually invoked from — and Ninja always invokes commands from the top-level build directory, never a subdirectory. Building the BMI anywhere else means the later compile steps look for it in the wrong place and never find it.
Donskoy’s own CMakeLists.txt includes this fragment and, for Clang, wires the resulting .pcm into both the library and executable targets via -fmodule-file=:
1 include(../CMakeBuildPrint.txt)
2
3 # ...
4
5 if (CMAKE_CXX_COMPILER_ID STREQUAL "Clang")
6 target_compile_options(${module_name} PRIVATE -fmodule-file=${PrintBMI})
7 target_compile_options(${PROJECT_NAME} PRIVATE -fmodule-file=${PrintBMI})
8 endif ()
GCC needs no equivalent target_compile_options() — the BMI it built lands exactly where GCC’s own implicit search already looks, so nothing further has to point at it.
![]() |
Even with the BMI correctly pre-built and correctly located, |
