Сборка в C++: частые ошибки и как их читать
Undefined reference, multiple definition, циклические зависимости заголовков — разбираем частые ошибки сборки C++ и что делать с каждой, от препроцессора до линковки.

Ошибка undefined reference to `foo()' на этапе линковки не значит, что вы неправильно написали функцию. Часто она значит, что вы её реализовали, но не сказали линкеру, где искать объектный файл. Сборка в C++ идёт в четыре этапа — препроцессор, компиляция, ассемблирование, линковка — и одна и та же на вид ошибка на разных этапах означает разные вещи. Разбираем частые случаи: как читать сообщение, где искать причину и что править. Ориентируемся на GCC 13 / Clang 17, флаги применимы и к более старым версиям.
Материал для тех, кто уже пишет на C++ и упирается не в синтаксис, а в то, что проект не собирается или собирается не тем, чем нужно. Разбор — по этапам сборки, потому что именно этап определяет, где искать проблему.
Сначала — какой этап упал
Прежде чем читать текст ошибки, посмотрите, на каком этапе она возникла. Это сужает поиск в разы.
- Препроцессор —
fatal error: X.h: No such file or directory. Проблема с путями к заголовкам или отсутствующим файлом. - Компиляция —
error: 'X' was not declared in this scope, ошибки шаблонов, несоответствие типов. Компилятор видит один translation unit* и жалуется на него. - Линковка —
undefined reference,multiple definition. Отдельные объектные файлы собраны, но не стыкуются друг с другом.
* Translation unit — один .cpp-файл вместе со всем, что в него втянул препроцессор через #include. Компилятор обрабатывает каждый такой юнит независимо, о существовании остальных не знает.
undefined reference: символ объявлен, но не найден
Самая частая ошибка линковки. Компилятор поверил объявлению в заголовке, а линкер не нашёл готовую реализацию. Причины по частоте:
1. Файл с реализацией не передали линкеру
Классика: объявили функцию в math_utils.h, реализовали в math_utils.cpp, а собираете так:
g++ main.cpp -o app # math_utils.cpp забыт
g++ main.cpp math_utils.cpp -o app # правильно
Проверка: если реализация точно есть, но ссылка не находится, первым делом посмотрите на список файлов и объектников в команде сборки или в целях CMake.
2. Библиотека не подключена или подключена не в том порядке
С внешними библиотеками порядок аргументов имеет значение. GNU-линкер разрешает символы слева направо: -l должен идти после объектных файлов, которые эти символы используют.
# не сработает: -lm стоит раньше, чем main.o его использует
g++ -lm main.cpp -o app
# сработает
g++ main.cpp -lm -o app
3. Несовпадение сигнатуры из-за манглинга имён
C++ кодирует в имя символа типы аргументов. Если объявление и определение отличаются на const, ссылку или тип параметра, линкер видит два разных символа. Отдельный случай — вызов C-функции из C++ без extern "C": имя манглится, а в C-библиотеке оно лежит без манглинга.
// заголовок C-библиотеки, включаемый в C++
extern "C" {
#include "legacy_c_api.h"
}
Посмотреть, какое имя линкер реально ищет, помогает nm: nm -C build/app.o | grep foo. Флаг -C разманглит имена в читаемый вид.
multiple definition: символ определён дважды
Обратная беда. Линкер нашёл реализацию — и не одну. Типичный текст: multiple definition of `counter'; first defined here.
Определение переменной в заголовке
Заголовок включается в несколько .cpp-файлов, и каждый получает свою копию определения.
// counters.h — так НЕ надо
int global_counter = 0; // определение попадёт в каждый TU
// counters.h — так правильно
extern int global_counter; // только объявление
// counters.cpp
int global_counter = 0; // определение ровно одно
С C++17 для констант и переменных в заголовке есть inline: inline int global_counter = 0; — компилятор сам схлопнет копии в одну. Это законный способ держать определение в заголовке.
Функция в заголовке без inline
Обычная (не шаблонная, не в классе) функция, определённая прямо в .h, при включении в два TU даст двойное определение. Пометьте её inline либо вынесите тело в .cpp.
Ошибки препроцессора и include
fatal error: json.hpp: No such file or directory — компилятор не нашёл заголовок по путям поиска. Разбирайтесь так:
- Проверьте, что файл физически существует по ожидаемому пути.
- Если заголовок в нестандартном каталоге — добавьте его флагом
-I:g++ -Iinclude main.cpp. В CMake этоtarget_include_directories(). - Различайте
#include "file.h"(сначала ищется рядом с текущим файлом) и#include <file.h>(только по системным путям и-I). Свои заголовки — в кавычках, чужие библиотеки — в угловых скобках.
Отдельная тема — двойное включение одного заголовка. Защита нужна всегда: либо #pragma once (поддерживается всеми актуальными компиляторами), либо классический include guard с #ifndef. Смешивать их в одном файле не нужно, выберите одно.
Циклические зависимости заголовков
Заголовок A включает B, B включает A. С include guard бесконечного рекурсивного включения не будет, но появятся ошибки вида error: 'ClassB' has not been declared — потому что в момент обработки одного из файлов второй ещё не дочитан.
Лечится forward declaration: если в заголовке нужен только указатель или ссылка на тип, объявите его вперёд, не включая целый заголовок.
// widget.h
class Renderer; // forward declaration вместо #include "renderer.h"
class Widget {
Renderer* renderer; // указателя достаточно объявления
void draw();
};
Полное #include "renderer.h" переносится в widget.cpp, где вызываются методы Renderer. Это заодно сокращает время пересборки: правка renderer.h не тянет за собой перекомпиляцию всех, кто включает widget.h.
Правило, которое снимает половину проблем сборки: в заголовке включайте ровно то, без чего он не компилируется сам по себе, и ни строкой больше. Всё, что нужно только реализации, — в .cpp.
ODR-нарушения: собралось, но падает
Самый коварный класс. Программа линкуется, запускается и ведёт себя странно. Причина — нарушение One Definition Rule: один и тот же класс или inline-функция скомпилированы по-разному в разных TU. Частый источник — разные флаги или разные значения макросов при сборке отдельных файлов.
Например, один .cpp собран с -DDEBUG, добавляющим поле в структуру, другой — без него. Размер объекта разъезжается, линкер выбирает одно определение, и получается порча памяти. Такие вещи стандарт объявляет UB, диагностики может не быть вовсе.
Как ловить: собирайте все TU одинаковым набором флагов, выносите флаги в один список сборочной системы, а не задавайте руками для отдельных файлов. Санитайзеры помогают частично — соберите с -fsanitize=address и погоняйте, ASan ловит часть последствий, но не сам факт разного определения.
Как отличать причины — сводка
| Текст ошибки | Этап | Первое, что проверить |
|---|---|---|
| No such file or directory | Препроцессор | Пути -I, кавычки vs угловые скобки |
| was not declared in this scope | Компиляция | Забытый #include, forward declaration |
| undefined reference | Линковка | Файл/библиотека передан линкеру, порядок -l |
| multiple definition | Линковка | Определение переменной/функции в заголовке без inline |
| Странное поведение в рантайме | После линковки | Разные флаги/макросы между TU (ODR) |
Что настроить, чтобы ошибок было меньше
Разовые правки лечат симптом. Дальше стоит убрать источники системно:
- Соберите с
-Wall -Wextra, а лучше добавьте-Werrorв CI — многие проблемы линковки начинаются с проигнорированного предупреждения компилятора. - Держите все флаги сборки в одном месте сборочной системы (CMake, Meson), а не в ручных командах. Это закрывает целый класс ODR-ошибок.
- Включите генерацию
compile_commands.json(CMAKE_EXPORT_COMPILE_COMMANDS=ON) — она нужна clangd и линтерам, чтобы видеть те же пути и флаги, что и компилятор. - Смотрите на время сборки: если оно растёт, ищите тяжёлые заголовки, которые тянутся всюду, и разрывайте зависимости через forward declaration.
Инвалидация кеша сборки и настройка ccache — отдельная тема, здесь её не разбираем. Синтаксис конкретных флагов CMake и опций линкера сверяйте с документацией вашей версии инструмента.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: GCC: Options for Linking (документация), cppreference: One Definition Rule
Частые вопросы
У меня undefined reference на функцию из шаблонного класса — почему?
А если у меня MSVC, а не GCC/Clang — советы применимы?
Что лучше — #pragma once или include guard?
Собралось на моей машине, а в CI падает с multiple definition — почему?
nm показывает символ в объектнике, но линкер всё равно его не находит
Программа падает случайным образом, санитайзеры молчат — это точно ODR?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.