одна единица трансляции, от текста до .objone translation unit, from text to .obj

Стадии компиляцииCompilation stages

«Компиляция» — это не одна операция, а конвейер из 6 разных стадий, каждая со своим представлением кода и своим классом ошибок. Понимание того, на какой стадии что происходит, объясняет, почему одна и та же опечатка иногда даёт ошибку препроцессора, иногда — синтаксическую, а иногда компилируется, но падает на линковке. Схемы ниже показывают, как выглядит один и тот же кусок кода на входе и выходе каждой стадии; отдельный блок разбирает, как C++20-модули меняют самую первую стадию."Compilation" isn't a single operation — it's a pipeline of 6 distinct stages, each with its own representation of the code and its own class of errors. Understanding what happens at which stage explains why the exact same typo sometimes produces a preprocessor error, sometimes a syntax error, and sometimes compiles fine but fails at link time. The diagrams below show what the same piece of code looks like going into and coming out of each stage; a dedicated section covers how C++20 modules change the very first stage.

частый вопрос на собеседованииcommon interview question C++20 modules

Конвейер целикомThe pipeline in full

.cpp source text preprocessor → .i text lexer → tokens parser → AST semantic analysis → typed AST IR + codegen → .o / .obj 01 02 03 04 05+06 frontend — ошибки здесь означают «ваш код неверен» frontend — errors here mean "your code is wrong" backend — от платформо-независимого к конкретной платформе backend — target-independent → target-specific

frontend (01–04) знает про C++, backend (05–06) про C++ уже не знает — работает только с IRthe frontend (01–04) knows about C++; the backend (05–06) no longer does — it only works with the IR

01

ПрепроцессорPreprocessor

Чисто текстовая подстановка, ещё до того, как компилятор увидел хоть один токен языка C++: раскрывает #include (буквально вставляет содержимое заголовка), #define-макросы, обрабатывает #ifdef. Результат — единственный огромный текстовый файл (translation unit), который иногда можно увидеть флагом -E.Pure text substitution, before the compiler has seen a single C++ token: it expands #include (literally pastes the header's contents in), #define macros, and processes #ifdef. The result is a single, often enormous text file (the translation unit), which you can inspect directly with the -E flag.

in: main.cpp #define N 3 int x = N * 2; // comment out: text (.i) int x = 3 * 2; // comment stripped

макрос N раскрыт, комментарии вырезаны — на выходе всё ещё обычный текст, ни одного понятия C++ (ни «переменная», ни «выражение») ещё не существуетthe N macro is expanded, comments are stripped — the output is still plain text; no C++ concept ("variable," "expression") exists yet

02

Лексический анализ (лексер/токенизатор)Lexical analysis (the lexer/tokenizer)

Разбивает текстовый поток на токены — минимальные значащие единицы (ключевые слова, идентификаторы, литералы, операторы). int x=5; становится последовательностью [int] [x] [=] [5] [;]. Ошибки здесь — недопустимые символы, незакрытые строковые литералы.Splits the text stream into tokens — the smallest meaningful units (keywords, identifiers, literals, operators). int x=5; becomes the sequence [int] [x] [=] [5] [;]. Errors at this stage are things like invalid characters or unterminated string literals.

in: text int x = 3 * 2; out: token stream [KW int] [ID x] [OP =] [NUM 3] [OP *] [NUM 2] [PUNC ;]

никакой вложенности ещё нет — плоская последовательность помеченных категорией фрагментовstill no nesting — a flat sequence of category-tagged fragments

03

Синтаксический анализ (парсер) → ASTSyntax analysis (the parser) → AST

Строит из потока токенов дерево (Abstract Syntax Tree), отражающее грамматическую структуру программы — какое выражение вложено в какое, что чем является. Классические «syntax error», «expected ';'» — отсюда: парсер не смог собрать валидное дерево из токенов.Builds a tree (the Abstract Syntax Tree) from the token stream, reflecting the program's grammatical structure — what expression nests inside what, what is what. The classic "syntax error," "expected ';'" messages come from here: the parser couldn't assemble a valid tree from the tokens.

in: tokens → out: AST Decl int x * 3 2

теперь видна структура: узел «объявление» с полем-инициализатором, который сам является деревом умноженияnow the structure is visible: a "declaration" node whose initializer field is itself a multiplication tree

04

Семантический анализSemantic analysis

Проверяет, что синтаксически корректное дерево ещё и «имеет смысл»: разрешение имён (какая переменная/функция имеется в виду), проверка типов, разрешение перегрузок и инстанциация шаблонов. Отсюда — «no matching function for call», «cannot convert», ошибки template-инстанциации.Verifies that the syntactically valid tree also "makes sense": name resolution (which variable/function is meant), type checking, overload resolution, and template instantiation. This is where "no matching function for call," "cannot convert," and template-instantiation errors come from.

in: AST Decl(int x) └─ Mul(3, 2) out: typed AST Decl(int x : int) └─ Mul(3:int, 2:int) : int

каждый узел получает тип и разрешённый оператор — то же дерево, но теперь известно, какой именно operator* вызывается и что вернёт intevery node gets a type and a resolved operator — the same tree, but now it's known exactly which operator* is called and that it returns an int

05

Генерация промежуточного представления (IR)Intermediate representation (IR) generation

AST переводится в промежуточный язык, независимый от целевой архитектуры процессора — GIMPLE у GCC, LLVM IR у Clang. IR проще, чем C++ (нет перегрузок, шаблонов, классов — только базовые операции и переходы), что и делает его удобной точкой для написания одних и тех же оптимизаций для любого исходного языка и любой целевой платформы.The AST is translated into an intermediate language independent of the target CPU architecture — GIMPLE for GCC, LLVM IR for Clang. IR is simpler than C++ (no overloads, templates, or classes — just basic operations and branches), which is exactly what makes it a convenient point to write the same optimizations once, for any source language and any target platform.

in: typed AST Decl(int x : int) └─ Mul(3,2):int out: LLVM IR (constant-folded) %x = alloca i32 store i32 6, i32* %x

классы, перегрузки и имена типов C++ исчезли — остались только регистры/память и базовые операции (тут же видно, что оптимизатор уже свернул 3*2 в 6)C++ classes, overloads, and type names are gone — only registers/memory and basic operations remain (you can already see the optimizer folded 3*2 into 6)

06

Оптимизации IR и генерация машинного кода (codegen)IR optimization and machine-code generation (codegen)

Над IR проходят десятки последовательных проходов-оптимизаций (см. отдельную страницу), после чего backend-часть компилятора превращает оптимизированный IR в машинные инструкции конкретной архитектуры (x86_64, ARM64) и упаковывает их в объектный файл нужного формата (COFF на Windows, ELF на Linux).Dozens of sequential optimization passes run over the IR (see the dedicated page), after which the compiler's backend turns the optimized IR into machine instructions for a specific architecture (x86_64, ARM64) and packages them into an object file of the target format (COFF on Windows, ELF on Linux).

in: IR store i32 6, i32* %x out: x86_64 asm → .o mov DWORD PTR [rbp-4], 6 упаковано в ELF/COFF секцию .text packaged into an ELF/COFF .text section

конкретные инструкции конкретного процессора — итог виден в theory/01, секции PE-файлаconcrete instructions for a concrete CPU — the end result is covered in theory/01, PE file sections

Наблюдать конвейер вживуюWatching the pipeline live

01

Остановиться на любой стадии флагами GCC/ClangStop at any stage with GCC/Clang flags

g++ -E main.cpp -o main.i     # только препроцессор — читаемый .i файл# preprocessor only — a readable .i file
g++ -fsyntax-only main.cpp    # лексер+парсер+семантика, без генерации кода# lexer+parser+semantics, no code generation
g++ -S main.cpp -o main.s     # до ассемблера включительно, читаемый .s# all the way to assembly, a readable .s file
g++ -c main.cpp -o main.o     # до объектного файла, ещё без линковки# all the way to the object file, not yet linked
02

Смотреть на IR напрямуюInspecting the IR directly

clang -S -emit-llvm main.cpp -o main.ll   # LLVM IR — читаемый текст# LLVM IR — readable text
gcc -fdump-tree-gimple main.cpp           # GIMPLE-дамп GCC# GCC's GIMPLE dump

Godbolt Compiler Explorer делает то же самое интерактивно и является стандартным инструментом для быстрой проверки «что на самом деле сгенерировал компилятор» на любой из стадий 5-6.Godbolt Compiler Explorer does the same thing interactively and is the standard tool for quickly checking "what did the compiler actually generate" at either of stages 5-6.

C++20 модули: как они меняют препроцессорную стадиюC++20 modules: how they change the preprocessor stage

Модули (import/export module) — не синтаксический сахар над #include, а замена стадии 01 принципиально другим механизмом: вместо текстовой вставки заголовка компилятор подключает уже готовое, предварительно обработанное двоичное представление интерфейса модуля.Modules (import/export module) aren't syntactic sugar over #include — they replace stage 01 with a fundamentally different mechanism: instead of textually pasting in a header, the compiler attaches an already-built, precompiled binary representation of the module's interface.

#include <math.hpp> math.hpp plain text a.cpp TU #1 pasted text re-parsed b.cpp TU #2 math.hpp снова вставлен и разобранpasted & parsed again

#include: каждая единица трансляции сама заново парсит один и тот же текст заголовка#include: every translation unit re-parses the same header text on its own

import math; math.cppm исходник модуляmodule source math.pcm / .ifc собран один разbuilt once a.cpp b.cpp

import: интерфейс модуля разбирается компилятором один раз, обе единицы трансляции подключают готовый двоичный артефактimport: the module interface is parsed by the compiler once, and both translation units attach the already-built binary artifact

01

Изоляция препроцессора — макросы больше не «протекают»Preprocessor isolation — macros no longer "leak"

При #include любой макрос, определённый до подключения заголовка, может незаметно изменить его содержимое (классический пример — макрос min/max из windows.h, ломающий std::min в подключённом позже заголовке). Границы модуля непроницаемы для макропроцессора: макросы, определённые внутри math.cppm, не видны в a.cpp, и наоборот — препроцессор фактически исключён из взаимодействия между модулями.With #include, any macro defined before the header is pulled in can silently alter its contents (the classic example is the min/max macro from windows.h breaking std::min in a header included afterward). Module boundaries are impermeable to the macro processor: macros defined inside math.cppm aren't visible in a.cpp, and vice versa — the preprocessor is effectively removed from the interaction between modules.

02

Синтаксис: export module, export, importSyntax: export module, export, import

// math.cppm — интерфейс модуля — the module interface
export module math;   // объявляет имя модуля// declares the module's name

export int square(int x) { return x * x; } // видно снаружи// visible outside
int helper(int x) { return x + 1; }        // НЕ экспортирован — скрыт от импортёров// NOT exported — hidden from importers

// a.cpp
import math;
int main() { return square(5); }  // helper() отсюда вызвать нельзя// helper() cannot be called from here

Ключевое отличие от заголовка: без ключевого слова export сущность остаётся приватной для модуля — явный контроль видимости, которого #include в принципе не может дать (заголовок либо весь текст вставляется, либо не вставляется совсем).The key difference from a header: without the export keyword, an entity stays private to the module — explicit visibility control that #include simply cannot offer (a header's text is either pasted in whole or not at all).

03

Скорость сборки: O(N²) → O(N)Build speed: O(N²) → O(N)

При #include каждая единица трансляции, включающая тяжёлый заголовок (типичный пример — STL-заголовки с шаблонами), заново проходит для него весь фронтенд-конвейер (стадии 01-04) — на N файлов это N полных разборов одного и того же текста. Скомпилированный интерфейс модуля (.pcm у Clang, .ifc у MSVC, .gcm у GCC) строится один раз и лишь загружается остальными единицами трансляции — отсюда практическое ускорение полной пересборки больших проектов, где один и тот же тяжёлый заголовок подключён в сотнях файлов.With #include, every translation unit that pulls in a heavy header (the typical case being template-laden STL headers) re-runs the entire frontend pipeline (stages 01-04) for it — across N files, that's N full parses of the exact same text. A precompiled module interface (.pcm for Clang, .ifc for MSVC, .gcm for GCC) is built once and merely loaded by the remaining translation units — hence the practical speedup on full rebuilds of large projects where the same heavy header is included in hundreds of files.

Поддержка сборочными системами долго отставала от компиляторовBuild-system support long lagged behind the compilers

Модули требуют, чтобы сборочная система знала граф зависимостей между модулями до компиляции (нужно собрать интерфейс модуля прежде, чем компилировать любой файл, который его импортирует) — задача, для которой классический Makefile не был спроектирован. Полноценная поддержка в CMake появилась значительно позже самого стандарта C++20, и ещё дольше стабилизировалась кроссплатформенно между GCC/Clang/MSVC.Modules require the build system to know the dependency graph between modules before compilation (a module's interface must be built before compiling any file that imports it) — a task the classic Makefile was never designed for. Full support in CMake arrived significantly later than the C++20 standard itself, and took even longer to stabilize across GCC/Clang/MSVC.

Модули и заголовки можно смешивать — это не всё-или-ничегоModules and headers can be mixed — it isn't all-or-nothing

Header units (import <vector>; вместо #include <vector>) — промежуточный шаг: существующий заголовок компилируется в двоичный формат модуля без переписывания его исходного кода, что даёт часть выгоды по скорости сборки без немедленной миграции всей кодовой базы на настоящие export module.Header units (import <vector>; instead of #include <vector>) are an intermediate step: an existing header gets compiled into the module binary format without rewriting its source, giving part of the build-speed benefit without an immediate migration of the whole codebase to real export module declarations.

ГраблиGotchas

Ошибка линковки — это не «ошибка компиляции»A linker error is not a "compilation error"

«Undefined reference to `foo()`» происходит уже после всех 6 стадий, на отдельном шаге линковки нескольких объектных файлов — компилятор был полностью доволен кодом (семантически всё корректно, объявление функции есть), просто её тело не нашлось ни в одном объектном файле или библиотеке. Частая путаница на собеседованиях — «а на какой стадии эта ошибка» с ответом «ни на одной из стадий компиляции, это отдельный шаг»."Undefined reference to `foo()`" happens after all 6 stages, at the separate step of linking several object files together — the compiler was perfectly happy with the code (semantically everything checks out, a function declaration exists), the body just wasn't found in any object file or library. A common interview mix-up: "which stage is this error at," with the answer being "none of the compilation stages — it's a separate step."

Шаблоны инстанцируются на семантической стадии, а не при парсингеTemplates are instantiated during semantic analysis, not parsing

Тело шаблонной функции синтаксически разбирается один раз, но реально проверяется на корректность (типы, вызовы методов) только при инстанциации с конкретным типом — отсюда классические «страшные» простыни ошибок шаблонов: они всплывают на семантической стадии, в момент подстановки конкретного типа, а не в момент объявления шаблона.A template function's body is parsed syntactically once, but is actually checked for correctness (types, method calls) only at instantiation with a concrete type — hence the classically "terrifying" walls of template error text: they surface during semantic analysis, at the moment of substituting a concrete type, not at the point where the template is declared.

IR — не одна и та же вещь у разных компиляторовIR isn't the same thing across different compilers

GIMPLE (GCC) и LLVM IR (Clang) — разные, несовместимые представления с разным набором доступных оптимизационных проходов. Это одна из причин, почему один и тот же исходный код может давать заметно разный машинный код и разную производительность на GCC и Clang даже с одинаковыми флагами оптимизации.GIMPLE (GCC) and LLVM IR (Clang) are different, incompatible representations with different sets of available optimization passes. This is one of the reasons the exact same source code can produce noticeably different machine code and performance on GCC versus Clang, even with identical optimization flags.