Подключение библиотек в C++: разбор на живом проекте
Проект собирается локально, а на CI падает с undefined reference. Разбираемся, где заканчивается заголовочный файл и начинается линковка, и почему CMake здесь надёжнее ручных флагов.

Типичная картина: вы добавили в проект библиотеку — скажем, fmt для форматирования строк. Локально g++ main.cpp собирается, потому что вы вручную дописали -lfmt. Через неделю коллега клонирует репозиторий, запускает сборку, и получает undefined reference to fmt::v10::vformat. Проблема не в коде, а в том, что подключение библиотеки в C++ — это не одно действие, а три разных: препроцессор ищет заголовки, компилятор проверяет объявления, линковщик подставляет реализацию. Разберём это на реальном дереве проекта с CMake 3.28 и покажем, где именно ломается сборка и почему.
Три этапа, которые все путают
Слово «подключить» скрывает три независимые операции. Пока вы не разведёте их в голове, ошибки линковки будут выглядеть случайными.
- Препроцессор обрабатывает
#include: физически вставляет содержимое заголовка в ваш.cpp. Ему нужно знать, где искать заголовки — это флаг-I. - Компилятор видит объявления функций и классов из заголовка. Ему достаточно сигнатуры
int add(int, int);, тела он не требует. На этом этапе код с внешней библиотекой компилируется без единого её байта реализации. - Линковщик собирает объектные файлы вместе и ищет тела функций. Вот здесь нужен сам
.aили.so— флаги-L(где искать) и-l(что искать).
Ошибка вида «unknown type» или «was not declared» — это компилятор, ему не хватило заголовка. Ошибка «undefined reference» — это линковщик, ему не хватило реализации. Половина вечеров в отладке сборки уходит на то, что человек лечит не тот этап.
Живой проект
Возьмём структуру, к которой сводится большинство небольших приложений:
myapp/
├── CMakeLists.txt
├── src/
│ ├── main.cpp
│ └── report.cpp
└── include/
└── report.hpp
В report.cpp мы форматируем строку через fmt, а report.hpp объявляет функцию, которую вызывает main.cpp. Это две наши единицы трансляции плюс одна внешняя библиотека — минимальный набор, где проявляются все три этапа сразу.
Ручные флаги: почему они работают и почему это ловушка
Собрать вручную можно так (g++ 13, Linux):
g++ -std=c++20 -Iinclude src/main.cpp src/report.cpp -lfmt -o myapp
Разберём флаги по этапам:
-Iinclude— препроцессор найдётreport.hpp;- оба
.cppв списке — обе единицы трансляции попадут линковщику; -lfmt— линковщик подставит реализацию fmt.
Команда рабочая. Но у неё есть свойство, из-за которого её не стоит хранить в README как «инструкцию по сборке»:
- Порядок
-lимеет значение. Линковщик GNU разрешает символы слева направо. Если написать-lfmt src/report.cpp, символы fmt ещё не запрошены на момент обработки библиотеки, и вы получите undefined reference при формально верных флагах. - Путь к библиотеке зашит в систему. На машине коллеги fmt может лежать в
/usr/local/lib, которого нет в путях линковщика по умолчанию — понадобится-L/usr/local/lib, о котором в команде ничего нет. - Заголовки и библиотека могут разъехаться по версиям. Заголовок от fmt 10, а
.soв системе от fmt 8 — компиляция пройдёт, линковка упадёт на несуществующем символе внутреннего namespacefmt::v10.
Ручная команда — это снимок вашей конкретной машины. Как только сборка уезжает на CI или к другому человеку, снимок перестаёт совпадать с реальностью.
CMake: описываем зависимость, а не команду
Идея CMake в том, что вы описываете что от чего зависит, а конкретные флаги -I, -L, -l и их порядок он выводит сам. Минимальный CMakeLists.txt для нашего проекта:
cmake_minimum_required(VERSION 3.28)
project(myapp LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Ищем установленную в системе библиотеку fmt.
# find_package читает конфиг, который fmt поставляет вместе с собой.
find_package(fmt REQUIRED)
add_executable(myapp
src/main.cpp
src/report.cpp
)
# Наши собственные заголовки из include/
target_include_directories(myapp PRIVATE include)
# Одна строка вместо -I, -L и -l для fmt.
# fmt::fmt — это цель, которая тянет за собой свои заголовки и линковку.
target_link_libraries(myapp PRIVATE fmt::fmt)
Сборка:
cmake -S . -B build
cmake --build build
После этого в build/ появится исполняемый файл myapp. Ключевая строка — target_link_libraries(myapp PRIVATE fmt::fmt). Цель fmt::fmt несёт в себе не только сам файл библиотеки, но и путь к её заголовкам, и нужные флаги компиляции. Вам не нужно знать, где именно лежит fmt на этой машине.
PRIVATE, PUBLIC, INTERFACE — не косметика
Это самый частый источник загадочных ошибок в проектах из нескольких целей. Ключевое слово определяет, распространяется ли зависимость на тех, кто использует вашу цель:
- PRIVATE — fmt нужен только для сборки
myapp. Если кто-то будет линковаться кmyapp, fmt ему не навяжется. - PUBLIC — fmt нужен и для сборки, и всем, кто использует эту цель. Так делают, когда тип из fmt торчит в вашем публичном заголовке.
- INTERFACE — сама цель fmt не использует, но передаёт зависимость дальше. Типично для header-only обёрток.
Ошибка выбора видна не сразу: маленький проект соберётся при любом варианте. Проблема всплывает, когда на вашу библиотеку начинает опираться вторая цель и внезапно не видит транзитивную зависимость.
Три способа получить библиотеку — что выбрать
Отдельный вопрос — откуда вообще берётся find_package(fmt). Библиотека должна быть в системе или в проекте. Вариантов три, и у каждого своя цена.
| Способ | Как выглядит | Плюс | Минус |
|---|---|---|---|
| Системный пакет | apt install libfmt-dev, затем find_package | Быстро, ничего не качается при сборке | Версия зависит от дистрибутива, на CI и локально разная |
| FetchContent | CMake сам клонирует репозиторий при конфигурации | Версия зафиксирована в CMakeLists, воспроизводимо | Дольше первая сборка, тянет исходники |
| Пакетный менеджер (vcpkg, Conan) | Отдельный манифест, интеграция через toolchain | Единая версия у всех, кеш сборок | Ещё один инструмент в цепочке, порог входа |
Для FetchContent строка выглядит так (CMake 3.28):
include(FetchContent)
FetchContent_Declare(
fmt
GIT_REPOSITORY https://github.com/fmtlib/fmt.git
GIT_TAG 10.2.1 # фиксируем конкретный тег, не master
)
FetchContent_MakeAvailable(fmt)
# find_package больше не нужен: цель fmt::fmt уже доступна
target_link_libraries(myapp PRIVATE fmt::fmt)
Правило простое: для личного эксперимента хватит системного пакета; для проекта, который собирают несколько человек и CI, — FetchContent с зафиксированным тегом или пакетный менеджер. Плавающая версия из дистрибутива — главная причина «у меня собирается, а у него нет».
Где это ломается на практике
undefined reference to .... Компиляция прошла, упала линковка. Библиотека не подключена (target_link_librariesзабыт), либо версия.soне совпадает с заголовками, либо при ручной сборке перепутан порядок-l.fatal error: fmt/format.h: No such file or directory. Препроцессор не нашёл заголовок. Не отработалfind_package, либо забытtarget_include_directoriesдля своих заголовков.Could not find a package configuration file provided by "fmt". CMake не нашёл конфиг библиотеки. Пакет не установлен, либо стоит без-devчасти (только рантайм, без заголовков и cmake-конфигов).- Собирается локально, падает на CI. Почти всегда — разные версии библиотеки. Лечится фиксацией версии через FetchContent или манифест пакетного менеджера, а не правкой флагов.
- Смешение ABI. Библиотека собрана другим компилятором или с другим стандартом (
-std=c++17противc++20) — символы могут не совпасть. Собирайте зависимости тем же тулчейном.
Что делать дальше
Если вы сейчас собираете руками — перенесите проект на CMake, даже если файлов всего три: именно на трёх файлах ошибки видимости целей ещё дёшево исправить. Зафиксируйте версию каждой внешней библиотеки: тег в FetchContent или строка в манифесте, а не «то, что стоит в системе». Проверьте свои target_link_libraries на PRIVATE/PUBLIC заранее, до того как появится вторая цель. И держите под рукой различие: «не объявлено» лечат заголовком, «undefined reference» — линковкой. Дальше стоит посмотреть в сторону find_package в режиме CONFIG и того, как ваша собственная библиотека экспортирует цели через install(EXPORT ...) — это следующий уровень, когда проект начинают подключать извне.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: CMake Documentation: target_link_libraries, CMake Documentation: FetchContent
Частые вопросы
У меня CMake старее 3.28 — примеры сработают?
В чём разница между .a и .so при подключении?
Собралось локально, а на CI undefined reference — с чего начать?
Обязательно ли писать include/ в target_include_directories, если заголовки лежат рядом с .cpp?
Header-only библиотеку тоже нужно линковать?
PRIVATE или PUBLIC — как понять, что выбрать?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.