Сборка программ: компиляция, линковка, CMake

Как из одного файла main.cpp получается запускаемый файл program? Между исходным кодом и готовой программой стоят четыре этапа и целая система сборки. Здесь разберём каждый шаг — от препроцессора до cmake --build.

Препроцессор Компилятор Линковщик CMake

Зачем понимать, как работает сборка

В IDE всё происходит по нажатию одной кнопки — и кажется, что знать нечего. Но рано или поздно появляется ошибка undefined reference to ... или проект не собирается на другом компьютере. И тогда без понимания этапов сборки найти причину невозможно.

🔍

Найти причину ошибки

Ошибки на разных этапах выглядят совершенно по-разному. Поняв, на каком этапе они возникли, вы сразу сузите круг поиска.

🏗️

Собирать проект из нескольких файлов

Как только программа становится больше одного файла, без заголовков .h и системы сборки не обойтись.

📦

Использовать сторонние библиотеки

Любая библиотека — это набор уже собранных объектных файлов и заголовков. Нужно уметь их подключать.

Четыре этапа: от .cpp до исполняемого файла

Один файл main.cpp проходит четыре преобразования, прежде чем стать программой. Нажмите на любой этап, чтобы увидеть подробности.

1
Препроцессор
cpp
Обрабатывает директивы #include, #define, #ifdef. Разворачивает заголовки, подставляет макросы, удаляет комментарии.
.cpp → .i
2
Компилятор
g++ / clang++
Разбирает код, проверяет синтаксис и типы, строит AST и генерирует ассемблерный код. Здесь появляются ошибки вроде «ожидалась ;».
.i → .s
3
Ассемблер
as
Преобразует ассемблерный текст в машинный код. Результат — объектный файл с неразрешёнными ссылками на внешние символы.
.s → .o
4
Линковщик
ld
Собирает объектные файлы в один исполняемый. Находит все внешние символы в библиотеках. Здесь возникают ошибки «undefined reference».
.o → a.out
$ // нажмите на любой этап выше
всё одной командой vs по шагам
# Одной командой (внутри вызываются все четыре этапа)
g++ main.cpp -o program

# То же самое, но по шагам — видно промежуточные файлы
g++ -E main.cpp -o main.i    # только препроцессор
g++ -S main.i   -o main.s    # препроцессор + компиляция
g++ -c main.s   -o main.o    # + ассемблирование
g++ main.o      -o program   # + линковка

Почему проект разбивают на несколько файлов

Один файл main.cpp на 5000 строк — это кошмар для поддержки. Проект разбивают на модули, каждый в своей паре .h + .cpp.

Плохо: один большой файл

main.cpp — 3000 строк
// все объявления и определения здесь
class Product { ... };
class Order   { ... };
class Customer { ... };

void saveToFile(...) { ... }
void loadFromFile(...) { ... }

int main() { ... }

// Проблемы:
// - найти нужное место сложно
// - изменения в одном классе требуют
//   пересборки всего файла
// - два программиста не могут
//   работать параллельно

Хорошо: модульный проект

структура
project/
├── include/
│   ├── Product.h    // объявления
│   ├── Order.h
│   └── Customer.h
├── src/
│   ├── Product.cpp  // определения
│   ├── Order.cpp
│   ├── Customer.cpp
│   ├── io.cpp
│   └── main.cpp     // только точка входа
└── CMakeLists.txt
Что где лежит. .h — объявления: какие функции и классы существуют. .cpp — определения: как они работают. Компилятор должен знать объявления, чтобы использовать функцию в другом файле.

Демонстрация: что делает #include

Директива #include буквально вставляет содержимое одного файла в другой. Нажмите «Развернуть #include», чтобы увидеть, что видит компилятор после работы препроцессора.

Исходный проект

main.cpp
#include "math.h"

int main() {
    int r = square(5);
    return 0;
}
math.h
int square(int x);

После препроцессора

📄 main.cpp
#include "math.h"
int main() {
int r = square(5);
return 0;
}
Что важно понять. Компилятор не знает про файлы — он видит только один длинный текст, получившийся после препроцессора. Все #include уже развёрнуты, все #define уже подставлены.

Проблема двойного включения и header guards

Если заголовок включён дважды, компилятор увидит определения два раза и выдаст ошибку redefinition. Решение — header guards.

Интерактивно: что происходит без защиты

$ // нажмите «Шаг», чтобы проиграть сценарий
три способа защитить заголовок
// Способ 1: #pragma once — современный, короткий
#pragma once

int square(int x);


// Способ 2: классический header guard с #ifndef
#ifndef MATH_H
#define MATH_H

int square(int x);

#endif   // MATH_H


// Способ 3: в новых проектах — обычно только #pragma once
// (поддерживается всеми популярными компиляторами)
Header guards не защищают от ошибок линковки. Они защищают только от повторного включения в одном файле. Если определение функции попало в .h и этот заголовок включён из нескольких .cpp — получите ошибку линковки «multiple definition». Определения должны быть в .cpp.

Ошибки линковки: undefined reference и multiple definition

Это самые загадочные ошибки для новичка. Компилятор их не выдаёт — они появляются на последнем этапе и выглядят совершенно иначе.

файл 1

        
файл 2

        
$ // нажмите «Собрать проект»

Статические и динамические библиотеки

Когда кода становится много и он переиспользуется в нескольких проектах, его выносят в библиотеку. Библиотеки бывают двух видов.

📦 Статическая библиотека

расширение: .a / .lib
# Создание
g++ -c math.cpp -o math.o
ar rcs libmath.a math.o

# Использование
g++ main.cpp -L. -lmath -o program

Код библиотеки копируется внутрь исполняемого файла при линковке.

  • ✅ Программа самодостаточна — можно переносить одним файлом
  • ✅ Нет задержки на загрузку библиотеки при запуске
  • ❌ Размер программы больше
  • ❌ При обновлении библиотеки нужно пересобирать программу

🔗 Динамическая библиотека

расширение: .so / .dll / .dylib
# Создание
g++ -fPIC -c math.cpp -o math.o
g++ -shared math.o -o libmath.so

# Использование
g++ main.cpp -L. -lmath -o program
LD_LIBRARY_PATH=. ./program

Программа содержит только ссылку на библиотеку, код грузится при запуске.

  • ✅ Программа меньше
  • ✅ Библиотеку можно обновить, не пересобирая программу
  • ✅ Экономия памяти при запуске нескольких программ
  • ❌ Библиотека должна быть на месте при запуске
Когда что использовать. Статические библиотеки удобны для утилит, распространяемых одним файлом. Динамические — для больших систем, где библиотека обновляется независимо от программы (например, системные библиотеки).

CMake: система сборки, независимая от платформы

Вызывать g++ вручную для каждого файла проекта — неудобно. На смену этому пришли системы сборки. CMake — самая популярная из них.

project/ ├── CMakeLists.txt ← главный файл сборки ├── include/ │ ├── math.h │ └── geometry.h ├── src/ │ ├── math.cpp │ ├── geometry.cpp │ └── main.cpp └── build/ ← создаётся здесь всё скомпилированное ├── CMakeCache.txt ├── Makefile └── program
минимальный CMakeLists.txt
cmake_minimum_required(VERSION 3.20)

project(MyApp VERSION 1.0 LANGUAGES CXX)

# Стандарт C++
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# Исполняемый файл из нескольких .cpp
add_executable(myapp
    src/main.cpp
    src/math.cpp
    src/geometry.cpp
)

# Директории с заголовками
target_include_directories(myapp PRIVATE include)
три команды — и программа готова
# 1. Конфигурация (создаёт Makefile или Ninja-файл в build/)
cmake -S . -B build

# 2. Сборка (запускает компиляцию и линковку)
cmake --build build

# 3. Запуск
./build/myapp

Интерактивно: соберите свой CMakeLists.txt

Настройте параметры проекта и посмотрите, как будет выглядеть итоговый файл сборки.

CMakeLists.txt

Лучшие практики

1. Заголовок — только объявления, реализация — в .cpp

В .h пишите только то, что нужно для использования: объявления функций, классов, константы. Определения функций — в .cpp. Единственные исключения — шаблоны, inline-функции и constexpr.

2. #pragma once в каждом заголовке

Одна строка сверху файла — и вы защищены от двойного включения. Это дешевле, чем разбираться с ошибками redefinition.

3. Собирайте в отдельную папку build/

Никогда не запускайте cmake в корне проекта — все объектные файлы и Makefile'ы попадут в исходники. Собирайте в build/ и добавляйте её в .gitignore.

.gitignore
build/
*.o
*.a
*.so
*.dylib
*.exe
CMakeCache.txt
CMakeFiles/

4. Не пишите относительные пути к заголовкам вручную

Вместо #include "../include/math.h" используйте target_include_directories в CMake. Тогда из любого файла можно писать #include "math.h", и всё работает одинаково.

5. Включите предупреждения — это ловит большинство ошибок

CMakeLists.txt
target_compile_options(myapp PRIVATE
    -Wall -Wextra -Wpedantic
)

Антипаттерн: определения функций в .h

плохой код
// math.h
int square(int x) {   // ← определение в заголовке
    return x * x;
}

// main.cpp
#include "math.h"     // square определена здесь
int main() { return square(5); }

// other.cpp
#include "math.h"     // square определена И ЗДЕСЬ

// Линковщик: multiple definition of 'square(int)'

Правильно: объявление в .h, определение в .cpp.

Антипаттерн: #include "big_library.h" в каждом файле

Раздувает время компиляции. Каждый #include — это буквальная вставка десятков тысяч строк. Если заголовок нужен только в одном .cpp, включайте его там, а не в общем .h. По возможности используйте forward declarations вместо включения.

Антипаттерн: ручные g++-команды для всего проекта

Работает, пока файлов пять. С ростом проекта становится невозможно поддерживать: забытые зависимости, несогласованные флаги, разные настройки для разных файлов. Используйте CMake с самого начала.

Шпаргалка

ЗадачаКоманда
Скомпилировать один файлg++ main.cpp -o program
Скомпилировать без линковкиg++ -c file.cpp -o file.o
Посмотреть код после препроцессораg++ -E file.cpp
Собрать из нескольких .cppg++ a.cpp b.cpp main.cpp -o program
Подключить папку с заголовкамиg++ -Iinclude main.cpp
Подключить библиотекуg++ main.cpp -L. -lmylib
Строгие предупреждения-Wall -Wextra -Wpedantic
Оптимизация-O2
Отладочные символы-g
Собрать через CMakecmake -S . -B build && cmake --build build
Пересобрать с нуляrm -rf build && cmake -S . -B build
Запустить тесты CTestcd build && ctest

Задание

  1. Скомпилируйте программу из одного файла main.cpp командой g++ main.cpp -o program. Запустите её.
  2. Сделайте то же самое в четыре команды: сначала -E, потом -S, потом -c, потом линковка. Посмотрите размеры промежуточных файлов.
  3. Создайте три файла: math.h (объявление int square(int);), math.cpp (определение) и main.cpp (использование). Соберите проект командой g++ main.cpp math.cpp -o program.
  4. Уберите файл math.cpp из команды сборки. Какую ошибку выдаст линковщик? Объясните её смысл.
  5. Добавьте определение функции прямо в math.h и включите её из двух .cpp-файлов. Какую ошибку выдаст линковщик?
  6. Добавьте #pragma once в math.h. Проверьте, что теперь её можно включать несколько раз без ошибок.
  7. Напишите CMakeLists.txt для проекта из трёх файлов. Соберите его командой cmake -S . -B build && cmake --build build.
  8. Добавьте в CMakeLists.txt флаги -Wall -Wextra -Wpedantic. Что изменится в выводе компилятора?
  9. Соберите статическую библиотеку из math.cpp: g++ -c math.cpp -o math.o && ar rcs libmath.a math.o. Затем слинкуйте с ней свою программу.
  10. Соберите ту же библиотеку как динамическую (-fPIC -shared). Сравните размеры итоговых файлов и способ запуска.