Компиляция и линковка в C++: что происходит при сборке
От исходника к бинарнику: препроцессор, компиляция, линковка. Разбираем, почему падает undefined reference, откуда берётся multiple definition и почему один и тот же класс нельзя определять в заголовке дважды.

Вы правите одну строку в .cpp-файле, запускаете сборку — и получаете undefined reference to 'foo()' на этапе, до которого раньше даже не доходило. Или наоборот: код компилируется, а линкер жалуется на multiple definition. Оба сообщения приходят на разных стадиях сборки, и чтобы их читать, надо понимать, что именно C++ делает между вашим текстом и исполняемым файлом. Разберём конвейер на GCC/Clang, покажем, где рвётся связь между единицами трансляции, и что именно смотреть, когда линкер ругается.
Четыре стадии, а не одна
«Компиляция» в разговоре обычно означает весь путь от .cpp до a.out. На деле это четыре независимых шага, и ошибка на каждом выглядит по-своему. Возьмём тривиальный файл main.cpp и прогоним его по стадиям вручную (GCC 13, флаги те же у Clang 17).
# 1. Препроцессинг: раскрыть #include и макросы
g++ -E main.cpp -o main.i
# 2. Компиляция: C++ -> ассемблер
g++ -S main.i -o main.s
# 3. Ассемблирование: ассемблер -> объектный файл
g++ -c main.s -o main.o
# 4. Линковка: объектные файлы + библиотеки -> исполняемый файл
g++ main.o -o main
Обычный g++ main.cpp -o main просто делает всё это за раз и удаляет промежуточные файлы. Но когда что-то ломается, полезно знать, на каком шаге.
Стадия 1: препроцессор
Препроцессор — текстовая замена. Он подставляет содержимое каждого #include прямо в файл, разворачивает макросы, вырезает ветки #ifdef. Результат — один большой файл, который называют единицей трансляции1. Компилятор дальше видит именно его, а не ваш main.cpp с десятком заголовков.
Практическое следствие: если заголовок подключается дважды без защиты, его содержимое вставится дважды. Отсюда include guard или #pragma once — иначе получите переопределения ещё до компиляции.
Стадия 2 и 3: компиляция каждой единицы отдельно
Ключевой факт, вокруг которого крутятся почти все ошибки сборки: каждый .cpp компилируется независимо от остальных. Компилятор, обрабатывая main.cpp, ничего не знает про utils.cpp. Он знает только то, что было объявлено в заголовках, которые вы подключили.
Если вы вызываете функцию int add(int, int);, объявленную в заголовке, но не определённую в текущей единице — компилятор не возражает. Он верит объявлению, ставит в объектном файле пометку «здесь нужен символ add, найдёте позже» и идёт дальше. Определение он искать не обязан. Это работа линкера.
Компилятор проверяет, что символ объявлен. Линкер проверяет, что он ровно один раз определён. Перепутать эти две проверки — значит неправильно читать половину ошибок сборки.
Стадия 4: линковка сшивает единицы
Линкер (ld, под капотом у GCC/Clang) берёт все .o-файлы и библиотеки, собирает таблицу символов и разрешает ссылки. Каждый неопределённый символ он ищет среди определённых в других объектниках. Нашёл ровно один — подставил адрес. Не нашёл ни одного — undefined reference. Нашёл два — multiple definition.
Три частые ошибки и как их читать
Каждая приходит со своей стадии и лечится по-разному.
| Сообщение | Стадия | Причина |
|---|---|---|
fatal error: X.h: No such file | препроцессор | Заголовок не найден по путям поиска (-I) |
'foo' was not declared in this scope | компиляция | Нет объявления — забыли #include или опечатка |
undefined reference to 'foo()' | линковка | Объявление есть, определения нет ни в одном .o |
multiple definition of 'bar' | линковка | Символ определён больше одного раза |
undefined reference: объявили, но не собрали
Самая частая. Вы объявили функцию в utils.h, определили в utils.cpp, но забыли передать utils.cpp линкеру.
// utils.h
#pragma once
int add(int lhs, int rhs);
// utils.cpp
#include "utils.h"
int add(int lhs, int rhs) { return lhs + rhs; }
// main.cpp
#include "utils.h"
int main() { return add(2, 3); }
Это скомпилируется, но упадёт:
# Ошибка: линкер не видит определения add
g++ main.cpp -o main
# undefined reference to `add(int, int)'
# Правильно: обе единицы отдаём линкеру
g++ main.cpp utils.cpp -o main
Тот же симптом даёт забытая библиотека: -lpthread, -lssl и подобные. Символ есть, но не там, где ищет линкер. Порядок аргументов у ld тоже значим: библиотеки указывают после объектных файлов, которые их используют, иначе символы уже будут отброшены к моменту, когда линкер дойдёт до библиотеки.
multiple definition: определили дважды
Правило одного определения (ODR2) требует: у каждой функции и глобальной переменной во всей программе ровно одно определение. Классический промах — определить, а не объявить, что-то в заголовке, который подключается в нескольких .cpp.
// config.h — ОШИБКА: определение переменной в заголовке
int g_counter = 0;
Если config.h подключить в двух .cpp, линкер увидит два определения g_counter. Лечится так:
- в заголовке — только объявление:
extern int g_counter; - в одном
.cpp— определение:int g_counter = 0; - начиная с C++17 можно писать
inline int g_counter = 0;прямо в заголовке —inlineразрешает несколько идентичных определений, и линкер схлопнет их в одно.
Почему методы класса в заголовке не дают multiple definition
Потому что функции-члены, определённые прямо в теле класса, неявно получают inline. Свободная функция, определённая в заголовке без inline, ODR нарушит. Метод класса — нет. Это же правило объясняет, почему шаблоны обычно живут целиком в заголовках: компилятор инстанцирует их в каждой единице, где они используются, а inline-семантика шаблонов позволяет линкеру убрать дубликаты.
Заголовки против исходников: где что писать
Разделение обязанностей — не стиль, а следствие модели раздельной компиляции.
| Что | В заголовке (.h) | В исходнике (.cpp) |
|---|---|---|
| Объявление функции | да | нет |
| Определение обычной функции | нет (или inline) | да |
| Определение класса | да | нет |
| Тело метода | можно (станет inline) | обычно да |
| Шаблоны | да, целиком | нет |
| Глобальная переменная | extern или inline | определение |
Пара флагов, которые экономят время
Когда ошибка непонятная, помогает разложить сборку на части и посмотреть таблицы символов.
g++ -c file.cpp— остановиться на объектном файле, чтобы отделить ошибки компиляции от ошибок линковки.nm -C main.o— показать символы объектника с человекочитаемыми (-C, demangle) именами. БукваU— символ неопределён (ищется снаружи),T— определён в этом файле.c++filt _Z3addii— расшифровать искажённое (mangled) имя из ошибки линкера в нормальную сигнатуру.
Искажение имён (name mangling) — причина, по которой в ошибке линкера вы видите _Z3addii вместо add: компилятор кодирует в имя типы аргументов, чтобы работала перегрузка. Если объявление в заголовке и определение в .cpp разошлись хоть на один тип аргумента, вы получите undefined reference — с точки зрения линкера это два разных символа.
Что делать дальше
Когда ловите undefined reference — первым делом nm -C по всем объектникам и грепните имя символа: увидите, где он U, а где должен быть T. Если T нет нигде — вы не собрали нужный .cpp или не подключили библиотеку. Если несколько T — нарушен ODR.
Дальше стоит посмотреть в сторону системы сборки: собирать проект руками через g++ *.cpp перестаёт масштабироваться на десятке файлов. CMake и заголовочные зависимости, инкрементальная пересборка, precompiled headers для ускорения — отдельная тема, здесь разбирали только сам конвейер трансляции. И почитайте про C++20 modules: они меняют модель включения заголовков, но пока поддержка в компиляторах неполная — сверяйтесь с текущим состоянием в документации GCC и Clang.
Репетитор 1—11 класс и ВУЗ: Математика и физика методом наведения на мысль, помощь с ДЗ.
Спросить за 8 ₽Источники: GCC: Overall Options (стадии компиляции и флаги), cppreference: One Definition Rule (ODR)
Частые вопросы
У меня Clang вместо GCC — команды будут другими?
Скомпилировалось, а линкер падает с undefined reference — где искать?
Почему определение класса в заголовке не даёт multiple definition, а свободной функции — даёт?
Помогает ли #pragma once вместо include guard?
Порядок библиотек в команде линковки правда важен?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.