Компиляция в C++: пошаговый разбор четырёх стадий
Что происходит между командой g++ main.cpp и готовым исполняемым файлом: препроцессор, компиляция, ассемблирование, компоновка. Разбираем каждую стадию на команде и видим её отдельный вывод.

Вы набираете g++ main.cpp -o app, получаете исполняемый файл и запускаете его. Между этими двумя моментами компилятор проходит четыре отдельных стадии, и на каждой может свалиться со своей ошибкой: fatal error: iostream: No such file or directory — это препроцессор, undefined reference to — это компоновщик. Если вы понимаете, на какой стадии умерла сборка, вы читаете сообщение об ошибке за секунды, а не гуглите его целиком. Ниже — разбор всех четырёх стадий на примере, который вы можете повторить сами, с командами, останавливающими процесс после каждого шага. Примеры даны для GCC (g++) 13.x и Clang 17.x, поведение флагов совпадает.
Что происходит и зачем это знать
Компиляция C++ — это не одно действие, а конвейер. Драйвер g++ по умолчанию скрывает промежуточные шаги и сразу отдаёт готовый бинарник, но каждый шаг можно вызвать отдельно и посмотреть его результат. Стадий четыре:
- Препроцессирование — обработка директив
#include,#define,#ifdef. На выходе — раздутый текст без единой директивы. - Компиляция — превращение этого текста в ассемблерный код целевой архитектуры.
- Ассемблирование — перевод ассемблера в машинный код, объектный файл (
.o). - Компоновка (линковка) — сшивание объектных файлов и библиотек в один исполняемый файл.
Каждая стадия читает выход предыдущей. Ошибка на первой не даст дойти до второй, поэтому диагностика идёт снизу вверх по этому списку.
Готовим пример
Создайте два файла. Первый — math_utils.h:
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
// Объявление функции: тело будет в отдельном файле
int square(int value);
#endif
Второй — math_utils.cpp:
#include "math_utils.h"
int square(int value) {
return value * value;
}
И третий — main.cpp:
#include <iostream>
#include "math_utils.h"
int main() {
int number = 7;
std::cout << "square(" << number << ") = " << square(number) << '\n';
return 0;
}
Дальше пройдём по main.cpp и math_utils.cpp стадию за стадией.
Шаг 1. Препроцессирование (флаг -E)
Флаг -E останавливает драйвер сразу после препроцессора и печатает результат в стандартный вывод. Направим его в файл:
g++ -E main.cpp -o main.i
Откройте main.i. Вы увидите, что #include <iostream> раскрылся в тысячи строк — весь текст заголовка вставлен буквально. Строка #include "math_utils.h" превратилась в объявление int square(int value);. Ни одной директивы #-типа не осталось, макросы раскрыты.
Проверка: в main.i должна встречаться строка int square(int value); и не должно быть ни одного #include.
Именно здесь падает fatal error: iostream: No such file or directory — препроцессор не нашёл заголовок по путям поиска. Это стадия 1, до компиляции дело не дошло.
Шаг 2. Компиляция в ассемблер (флаг -S)
Флаг -S проходит препроцессор и компилятор, но останавливается перед ассемблером, оставляя текстовый ассемблерный файл:
g++ -S main.cpp -o main.s
В main.s вы увидите ассемблерные мнемоники: mov, call, push. Здесь работают синтаксический и семантический анализ и оптимизации. Именно на этой стадии ловятся ошибки вроде error: 'squre' was not declared in this scope — опечатка в имени, несовпадение типов, обращение к несуществующему полю. Компилятор видит только текущую единицу трансляции: он знает, что square объявлена (из заголовка), но не знает, где её тело — это забота компоновщика.
Проверка: файл main.s создан и содержит строку с call к символу square (имя будет искажено — об этом ниже).
Шаг 3. Ассемблирование в объектный файл (флаг -c)
Флаг -c доводит до объектного файла и останавливается перед компоновкой. Скомпилируем обе единицы отдельно:
g++ -c main.cpp -o main.o
g++ -c math_utils.cpp -o math_utils.o
Теперь у вас два бинарных .o-файла. Каждый содержит машинный код своей единицы трансляции и таблицу символов: что этот файл предоставляет и что требует извне. Посмотреть символы можно утилитой nm:
nm main.o
В выводе вы увидите строку с буквой U (undefined) напротив искажённого имени square — вроде _Z6squarei. Буква U означает: этот файл использует символ, но не содержит его тела. В math_utils.o тот же символ будет помечен T (text, определён). Развернуть искажённое имя обратно в читаемое помогает c++filt:
echo '_Z6squarei' | c++filt
Вывод — square(int). Это искажение имён (name mangling), в которое компилятор кодирует типы аргументов, чтобы работала перегрузка функций.
Проверка: nm main.o показывает square как U, а nm math_utils.o — как T.
Undefined-символ в объектном файле — это не ошибка. Это нормальное состояние: единица трансляции честно сообщает, что тело функции лежит где-то ещё. Ошибкой оно становится только на стадии компоновки, если ни один из переданных файлов и библиотек этот символ не предоставил.
Шаг 4. Компоновка (линковка)
Финальная стадия сшивает объектные файлы, разрешает все undefined-символы и добавляет код стандартной библиотеки:
g++ main.o math_utils.o -o app
Компоновщик берёт U-символ square из main.o и находит его T-определение в math_utils.o. Если бы вы забыли передать math_utils.o, получили бы классику: undefined reference to 'square(int)'. Запускаем:
./app
Проверка: вывод square(7) = 49. Конвейер прошёл полностью.
Сводка по стадиям
| Стадия | Флаг остановки | Вход → выход | Типичная ошибка |
|---|---|---|---|
| Препроцессор | -E | .cpp → .i (текст) | iostream: No such file or directory |
| Компиляция | -S | .i → .s (ассемблер) | was not declared in this scope |
| Ассемблирование | -c | .s → .o (объектный) | редко видна отдельно |
| Компоновка | (по умолчанию) | .o + библиотеки → бинарник | undefined reference to |
Если не получилось
fatal error: math_utils.h: No such file or directory— препроцессор не нашёл заголовок. Проверьте, что запускаете команду из той же папки, где лежит файл, или добавьте путь флагом-I путь_к_папке.error: 'square' was not declared in this scope— стадия компиляции не увидела объявления. Вmain.cppнет#include "math_utils.h"либо в имени опечатка.undefined reference to 'square(int)'— стадия компоновки. Объявление есть (заголовок подключён), а тела нет: вы забыли передать линковщикуmath_utils.oили скомпилировали толькоmain.cpp.multiple definition of 'square(int)'— тело функции попало в два объектных файла. Частая причина — определение (не объявление) функции прямо в.hбезinline, подключённом в несколько.cpp.undefined reference to symbol ... in library— забыли слинковать внешнюю библиотеку. Добавьте её флагом-l, например-lpthread, причём после объектных файлов: порядок важен.
Что делать дальше
Когда стадии стали понятны, следующий практический шаг — не собирать проект руками. Изучите систему сборки: make для небольших проектов или CMake для всего остального — они пересобирают только изменившиеся единицы трансляции, экономя время на больших кодовых базах. Флаг -Wall -Wextra включает предупреждения компилятора и ловит проблемы на второй стадии до того, как они станут багами в рантайме. А флаг -save-temps у GCC сохраняет все промежуточные файлы (.i, .s, .o) за один прогон — удобно, когда нужно посмотреть, во что развернулся ваш шаблон или макрос.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: GCC 13 Manual — Options Controlling the Kind of Output, Clang Command Line Reference
Частые вопросы
У меня Clang, а не GCC — команды те же?
Почему в объектном файле имя функции выглядит как _Z6squarei, а не square?
Что делать, если undefined reference появляется только при подключении внешней библиотеки?
Обязательно ли компилировать каждый .cpp отдельно через -c?
На какой стадии ловятся ошибки шаблонов?
Что показывает разница между U и T в выводе nm?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.