# Глава 11.1. Этапы сборки
В этой главе вы научитесь компилировать программы на C++. Вместо задач в online IDE вам потребуется выполнять консольные команды в собственном окружении. Подготовьте для этого терминал Linux или контейнер Docker, в котором есть:
- Компилятор C++ одной из последних версий. В этом курсе мы используем Clang, и все примеры команд завязаны на него.
- Текстовый редактор — [vim](https://www.vim.org/), [neovim](https://neovim.io/), [emacs](https://www.gnu.org/software/emacs/), [nano](https://www.nano-editor.org/) или любой другой.
- Утилиты [xxd](https://www.opennet.ru/man.shtml?topic=xxd&category=1), [ldd](https://man7.org/linux/man-pages/man1/ldd.1.html).
Рекомендуем воспользоваться нашим [Docker-образом,](https://hub.docker.com/repository/docker/microvenator/senjun_cpp/general) в котором _все это уже установлено._ Вам останется только скачать его с Docker Hub: {#block-docker-image}
```bash
docker pull microvenator/senjun_cpp:2.0
```
А затем запустить его и войти в консоль:
```bash
docker run -it --entrypoint bash microvenator/senjun_cpp:2.0
```
## Компиляция проекта
Чтобы запустить программу на C++, её нужно **скомпилировать** — из файлов с исходным кодом получить исполняемый файл для целевой платформы. Этот файл содержит бинарный код — машинные команды для конкретной архитектуры процессора. Бинарный файл — это артефакт сборки исполняемого файла или библиотеки. Файлы с бинарным кодом для краткости называют **бинарниками.**
Бинарники не переносимы между разными системами. Нельзя собрать исполняемый файл под процессор ARM и запустить на Intel x86. Библиотека, собранная под Linux, не может быть переиспользована в Windows. Конечно, сам по себе C++ — это кроссплатформенный язык: скомпилировать программу на нем можно практически под любую платформу. Если целевая платформа отличается от той, на которой происходит сборка, то такой процесс называется кросс-компиляцией. Однако тот факт, что язык кросплатформенный, не даёт гарантии, что конкретная программа на нем тоже кросплатформенная. Напротив, для разработки действительно кросс-платформенных проектов нужно прилагать усилия.
Сборка программы на C++ состоит из нескольких стадий, за которые отвечают три инструмента: компилятор, ассемблер и линкер. Цепочка их вызова скрыта от разработчика фасадом — **драйвером компилятора** (compiler driver). Чтобы собрать проект, нужно вызвать драйвер компилятора, и он позаботится об остальном.
Для краткости драйвер компилятора практически всегда называют просто компилятором. Поэтому в зависимости от контекста под **компилятором** подразумевается как вся система сборки целиком, так и отдельный её компонент.
## Компиляторы C++
Подавляющее [большинство](https://www.jetbrains.com/lp/devecosystem-2023/cpp/#cpp_compilers) проектов на C++ собирается одним из компиляторов:
- [Clang.](https://clang.llvm.org/) Развивается в рамках проекта [LLVM](https://www.llvm.org/). Известен своей модульной структурой и удобством в использовании.
- [Apple Clang.](https://opensource.apple.com/projects/llvm-clang/) Дистрибутив Clang от Apple. Используется для сборки ядер macOS и iOS.
- [GCC](https://gcc.gnu.org/) ([GNU](https://www.gnu.org/) Compiler Collection). Входит в состав большинства дистрибутивов Linux и используется для сборки ядра Linux.
- [MSVC.](https://visualstudio.microsoft.com/vs/features/cplusplus/) Де-факто стандарт от Microsoft для сборки проектов под Windows.
 {.illustration}
На cppreference постоянно актуализируется [список фичей](https://en.cppreference.com/w/cpp/compiler_support) версий C++ в разрезе поддержки компиляторами.
Каждый из перечисленных компиляторов не существует сам по себе, а входит в состав тулчейна. **Тулчейн** — это целый инструментарий для сборки, отладки, профилирования. Он содержит утилиты, библиотеки и все необходимое для компиляции программ.
## Пайплайн компиляции
По ходу компиляции из исходного кода на C++ создаётся исполняемый файл или библиотека. Процесс компиляции для них принципиально не отличается. Он выглядит как цепочка запуска трёх инструментов: компилятора, ассемблера и линкера.
Тут сразу оговоримся, что под ассемблером в зависимости от контекста понимают две сущности:
- язык ассемблера. Это команды процессора в виде, удобном для разработчика.
- программу ассемблер. Это транслятор из текста на языке ассемблера в бинарный код.
**Компилятор** (compiler) транслирует исходный код на C++ в код на ассемблере. Вы уже знакомы с важной частью компилятора — препроцессором (preprocessor). Он подготавливает исходники, чтобы их удобнее было транслировать.
**Ассемблер** (assembler) в свою очередь создаёт промежуточные бинарные файлы, называемые объектными файлами.
**Линкер** (linker) компонует их в результирующий исполняемый файл или библиотеку.
 {.illustration}
Это классический пайплайн сборки. У него возможны вариации. Например, вместо трансляции кода C++ в код на ассемблере компилятор может сам создавать объектные файлы. В таком случае вызова ассемблера как самостоятельной программы не произойдёт.
В простейшем случае для компиляции единственного исходника достаточно вызвать компилятор и указать путь к этому файлу:
```bash
clang main.cpp
```
Компилятор сгенерирует бинарный файл с именем `a` и расширением, зависящим от системы. Например, `a.out` — исторически сложившееся сокращение от «assembler output». Расширения может и не быть вовсе.
Чтобы задать другое имя, нужно передать опцию `-o`:
```bash
clang -o run main.cpp
```
В этом случае будет создан исполняемый файл с именем `run`.
Зачастую требуется передать множество дополнительных опций. Скомпилируем проект, состоящий из единственного файла `main.cpp`:
```cpp
// main.cpp
#include <print>
int main()
{
std::println("Hello compiler");
}
```
Для этого выполним команду:
```bash
clang \
-std=c++23 \
-lc++ \
-isystem /usr/lib/llvm-20/include/c++/v1/ \
-nostdinc++ \
-o main \
main.cpp
```
Разберем опции, с которыми был вызван `clang`: {#block-opts}
- `-std=c++23` — стандарт C++23.
- `-lc++` — подключение линкером реализации стандартной библиотеки [libc++](https://libcxx.llvm.org/) от LLVM вместо [GNU Standard C++ Library,](https://gcc.gnu.org/onlinedocs/libstdc++/) по умолчанию используемой `clang`.
- `-isystem /usr/lib/llvm-20/include/c++/v1/` — путь, по которому нужно искать хедеры.
- `-nostdinc++` — запрет на поиск хедеров по [стандартным путям.](/courses/cpp/chapters/cpp_chapter_0102/#block-system)
- `-o main` — имя результирующего бинарного файла.
Если вызов компилятор завершился успехом, результат его работы будет сохранён в бинарный файл `main`. Вызовем его:
```bash
./main
```
```
Hello compiler
```
А теперь рассмотрим подробнее каждый из этапов компиляции.
## Препроцессинг
Препроцессор поштучно обрабатывает `cpp`-файлы. Он обращается к хедерам, только если они подключены в `cpp`-файлы. Препроцессор формирует единицы трансляции. Иногда их называют единицами компиляции.
**Единица трансляции** (translation unit) — это `cpp`-файл, в который добавлено содержимое всех подключаемых в него хедеров.
Задача препроцессора — обнаружить и обработать [директивы препроцессора.](/courses/cpp/chapters/cpp_chapter_0102/#block-preprocessor) Он буквально *переписывает код,* например:
- Вместо директивы `#include file` рекурсивно подставляет содержимое файла.
- Выполняет макроподстановки: вместо макроса, определённого директивой `#define`, по месту использования макроса вставляет его тело.
- Размечает код для следующих этапов компиляции. Расставляет маркёры, подсказывающие, из какого файла какая строка была подставлена. Эта информация используется при выводе ошибок.
Многократная обработка препроцессором одних и тех же хедеров — _настоящая проблема._ Она приводит к разрастанию объёма единиц трансляции и, следовательно, к медленной компиляции. Ведь после фазы препроцессинга компилятор _весь этот код_ оптимизирует.
Вызовите компилятор с опцией `E`, которая указывает ему остановиться после этапа препроцессинга:
```bash
clang \
-E \
-std=c++23 \
-isystem /usr/lib/llvm-20/include/c++/v1/ \
-nostdinc++ \
-o main.i \
main.cpp
```
А затем воспользуйтесь командой [wc](https://linux.die.net/man/1/wc) (word count) для подсчёта количества строк (`-l`, lines) в файле, который обработал препроцессор:
```bash
wc -l main.i
```
```
47030 main.i
```
Из нескольких строк кода мы получили десятки тысяч! И это для маленького файла, подключающего всего один хедер. А все благодаря директиве `#include`, вместо которой препроцессор рекурсивно скопировал содержимое файла `print`. А если в проекте сотни файлов, и каждый из них подключает десятки хедеров? Неудивительно, что компиляция крупных проектов может длиться часами! Решение этой проблемы — одна из мотиваций для появления в C++ модулей.
 {.illustration}
Если описывать работу препроцессора верхнеуровнево, то он:
- получает на вход содержимое файлов реализации,
- на выходе формирует единицы трансляции с проведёнными подстановками.
## Компиляция
Компиляция — ключевая фаза сборки проекта. Ее цель — транслировать код из C++ в ассемблер. В процессе компилятор применяет множество оптимизаций, призванных сделать код более эффективным.
Кроме того, компилятор умеет _модифицировать_ код, _генерировать_ новый код, а некоторый код даже _выполнять!_
Компилятор модифицирует код. Что это значит? Имена переменных, полей классов, функций и других сущностей в коде заменяются по определённому набору правил. Цель — присвоить им уникальные идентификаторы, чтобы линкер мог различать разные сущности с одинаковым именем. Примеры:
- Поле с одним и тем же именем, заведённое в разных классах.
- Функция с несколькими [перегрузками.](/courses/cpp/chapters/cpp_chapter_0051/)
- Классы с одинаковым именем, но в разных пространствах имён.
Чтобы однозначно идентифицировать именованные сущности в коде, компилятор добавляет к имени информацию о пространстве имён, принимаемых функцией параметрах и их типах. Это называется [искажением имён](https://en.wikipedia.org/wiki/Name_mangling) (name mangling). Иногда это называется декорированием имён (name decoration).
Набор правил для искажения имён зависит в том числе от компилятора и его версии, переданных ему флагов, от версии C++. Иногда искажённые имена встречаются в тексте ошибок на этапе линковки. Чтобы вы не растерялись и поняли, что перед вами искажённое имя, приведём короткий пример. Допустим, у нас есть две перегрузки функции `format()`:
```cpp
std::string format(double val);
std::string format(const std::string & val);
```
Их искажённые имена могут выглядеть так:
```
_Z9formatB5cxx11d
_Z9formatRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE
```
Искаженные имена компилятор сохраняет в таблицу символов. **Символ** — это уникальное имя, обозначающее переменную, функцию, класс и другие сущности в коде. Таблица символов — это структура данных, хранящая такие атрибуты символов как адрес, область видимости, тип.
Компилятор генерирует код для [шаблонных](/courses/cpp/chapters/cpp_chapter_0056/) функций и классов: он инстанцирует шаблоны, то есть порождает реализации шаблонов для конкретных параметров.
Исполнение кода на этапе компиляции (compile-time) достигается при использовании по отдельности или в комбинации:
- шаблонов,
- ключевых слов `constinit`, `consteval` и `constexpr` для вычисления выражений и вызова функций на этапе компиляции, а не в рантайме.
Итак, компилятор умеет модифицировать, генерировать и исполнять код. А результат транслирует в ассемблер. Как выглядит весь процесс? Компилятор проводит три вида анализа кода:
- Лексический. Препроцессинг — это часть лексического анализа.
- Синтаксический (грамматический).
- Семантический (смысловой).
Компилятор сообщает о нарушении [ODR.](/courses/cpp/chapters/cpp_chapter_0101/#block-odr) Так как он обрабатывает каждую единицу трансляции отдельно, то находит повторные определения только внутри одной единицы трансляции. Нарушения ODR *между* единицами трансляции определяет линкер.
По ходу синтаксического анализа компилятор строит из кода дерево разбора (parse tree). Это ориентированное дерево, в котором внутренние вершины — операторы, а листья — соответствующие им операнды, то есть переменные и константы. Иными словами, структура программы отображается в виде дерева из объявлений, инструкций и выражений.
Затем дерево разбора урезается до AST (abstract syntax tree, абстрактное синтаксическое дерево). AST отличается от дерева разбора тем, что в нем отсутствуют не влияющие на семантику программы узлы. Например, группирующие скобки. Потери важной информации при этом не происходит, ведь группировка операндов и так задаётся древовидной структурой.
Рассмотрим фрагмент кода из проекта gcd для нахождения наибольшего общего делителя целых чисел `a` и `b`:
```cpp
while (b != 0)
{
if (a > b)
a = a - b;
else
b = b - a;
}
return a;
```
В упрощённом виде AST для этого кода будет выглядеть так:
 {.illustration}
AST обходится при [семантическом анализе](https://ru.wikipedia.org/wiki/%D0%A1%D0%B5%D0%BC%D0%B0%D0%BD%D1%82%D0%B8%D1%87%D0%B5%D1%81%D0%BA%D0%B8%D0%B9_%D0%B0%D0%BD%D0%B0%D0%BB%D0%B8%D0%B7) кода. На этом этапе компилятор сообщает о некорректных программных конструкциях. Например, о вызове функции с неправильным количеством аргументов.
После семантического анализа стартует этап кодогенерации. Компилятор проводит ряд оптимизаций. Так как единица трансляции — это файл, то все оптимизации выполняются в рамках одного файла. Вариантов оптимизаций насчитываются сотни. Например, встраивание функций (inlining) — подстановка тела функции по месту её вызова.
То, насколько активно компилятор будет оптимизировать код, задаётся [опциями.](https://clang.llvm.org/docs/CommandGuide/clang.html#code-generation-options) Например, `-O0` означает отсутствие оптимизаций для дебаг-сборки и используется по умолчанию, а `-O3` применяет максимальный набор оптимизаций. {#block-optimizations}
В главе «Что такое C++» рассматривалось два примера кода для изменения элементов вектора. В [первом примере](/courses/cpp/chapters/cpp_chapter_0011/#block-naive) это реализовано через наивный цикл. Во [втором](/courses/cpp/chapters/cpp_chapter_0011/#block-for-each) использован алгоритм `std::for_each()`. Откройте оба примера в песочнице, чтобы увидеть их полный код. Поочередно сохраните его в `main.cpp`. Соберите его в двух вариантах: с оптимизациями `-O3` и без них.
```bash
clang \
-O3 \
-std=c++23 \
-lc++ \
-isystem /usr/lib/llvm-20/include/c++/v1/ \
-nostdinc++ \
-o main \
main.cpp
```
Запустите получившийся бинарник, чтобы замерить время выполнения кода:
```bash
./main
```
```
Duration: 101 ms
```
Всего должно получиться 4 замера: с оптимизациями и без них для двух примеров кода. Убедитесь, что результаты замеров соответствуют принципу [абстракций с нулевой стоимостью.](/courses/cpp/chapters/cpp_chapter_0011/#block-efficiency) То есть вызов алгоритма работает медленнее цикла, если оптимизации не включены, и быстрее — если включены.
После проведения оптимизаций компилятор из промежуточного представления для каждой единицы трансляции создаёт файл с кодом на ассемблере.
Верните файл `main.cpp` в изначальное состояние:
```cpp
// main.cpp
#include <print>
int main()
{
std::println("Hello compiler");
}
```
Скомпилируйте `main.cpp` с опцией `-S`, которая останавливает сборку после этапа компиляции.
```cpp
clang \
-S \
-std=c++23 \
-isystem /usr/lib/llvm-20/include/c++/v1/ \
-nostdinc++ \
main.cpp
```
Чтобы посмотреть, как выглядят полученные ассемблерные команды, поизучайте файл с ассемблером `main.s`. Например, командой [head](https://linux.die.net/man/1/head) можно вывести его начало в консоль.
```bash
head main.s
```
```
.file "main.cpp"
.text
.globl main # -- Begin function main
.p2align 4
.type main,@function
main: # @main
...
```
## Ассемблерирование
После компилятора запускается ассемблер (assembler): он поштучно транслирует файлы на ассемблере в машинный код — платформозависимый бинарный код, содержащий команды для конкретной архитектуры процессора. Ассемблер сохраняет машинный код в **объектные файлы** (object files). Каждой единице трансляции после этого соответствует один объектный файл.
В каждом объектном файле есть секция, содержащая таблицу символов.
Скомпилируйте `main.cpp` с опцией `-c`, которая останавливает сборку до этапа линковки. То есть после препроцессинга, компиляции и ассемблирования.
```bash
clang \
-c \
-std=c++23 \
-isystem /usr/lib/llvm-20/include/c++/v1/ \
-nostdinc++ \
-o main.o \
main.cpp
```
С помощью команды [xxd](https://linux.die.net/man/1/xxd) посмотрите содержимое получившегося объектного файла в шестнадцатеричном представлении (hexdump):
```bash
xxd main.o
```
```
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............
00000010: 0100 3e00 0100 0000 0000 0000 0000 0000 ..>.............
00000020: 0000 0000 0000 0000 5000 0600 0000 0000 ........P.......
00000030: 0000 0000 4000 0000 0000 4000 5209 0100 ....@.....@.R...
00000040: 5548 89e5 4883 ec10 488d 0500 0000 0048 UH..H...H......H
00000050: 8945 f048 c745 f80e 0000 0048 8b7d f048 .E.H.E.....H.}.H
...
```
В первых байтах зашит формат файла. В мире *nix для объектных файлов, исполняемых файлов и библиотек распространён двоичный формат под названием [ELF](https://ru.wikipedia.org/wiki/Executable_and_Linkable_Format) (Executable and Linking Format, формат исполнимых и компонуемых файлов). В мире Windows используются форматы [COFF](https://ru.wikipedia.org/wiki/COFF) (Common Object File Format) для объектных файлов и [PE](https://ru.wikipedia.org/wiki/Portable_Executable) (Portable Executable) для исполняемых.
## Линковка
Линковка (компоновка) — это финальный этап сборки программы. До него каждый файл реализации проходит сборку обособленно от других файлов. На шаге линковки объектные файлы объединяются в исполняемый файл или библиотеку. При этом они компонуются:
- друг с другом,
- с используемыми в них библиотеками,
- с рантаймом C и C++ — набором библиотек, реализующих значительную часть языковых возможностей.
Линкер (linker, линковщик) объединяет объектные файлы, библиотеки и рантайм в единый исполняемый файл или библиотеку. Линкер использует таблицы символов в объектных файлах, чтобы сопоставить все [объявления](/courses/cpp/chapters/cpp_chapter_0101/#block-declarations) с их [определениями.](/courses/cpp/chapters/cpp_chapter_0101/#block-definitions) Этот процесс называется разрешением символов. Если какой-либо символ разрешить не получается, сборка программы завершается с ошибкой. Зачастую это связано с:
- отсутствием определения объявленного символа,
- множественным определением одного и того же символа (нарушением ODR),
- наличием циклических зависимостей.
С помощью команды [nm](https://www.opennet.ru/man.shtml?topic=nm&category=1&russian=1) посмотрите, какие символы экспортирует объектный файл `main.o`.
```bash
nm main.o
```
```
...
0000000000000000 W _ZNSt3__118__formatter_stringIcEC2Ev
...
```
Линкер заменяет вызовы функций по имени из других объектных файлов и библиотек на вызовы по адресу. Если целью сборки является исполняемый файл, то линкер подключает код, выполняющийся с момента запуска программы и до входа в функцию `main()`.
На этапе линковки возможны [оптимизации](https://johnnysswlab.com/link-time-optimizations-new-way-to-do-compiler-optimizations/) (LTO, link time optimizations), в том числе встраивание функций (inlining), объявление которых расположено в другом объектном файле.
Итак, на входе этапа линковки — набор объектных файлов, а на выходе — исполняемый файл или библиотека.
Допустим, мы работаем с проектом поискового движка search_engine. Он содержит два файла реализации и три хедера. Он собирается в исполняемый файл. Для этого препроцессор из файлов реализации получает единицы трансляции, компилятор транслирует их в код на ассемблере, ассемблер получает его и создаёт объектные файлы, а линкер объединяет их в исполняемый файл.
 {.illustration}
Вернемся к проекту `hello_compiler`. Усложним его: пусть он состоит из 3-х файлов, содержимое которых [приводилось](/courses/cpp/chapters/cpp_chapter_0102/#block-hello-compiler) в прошлой главе:
```
├── hello_compiler.h
├── hello_compiler.cpp
└── main.cpp
```
Файл `hello_compiler.cpp` реализует функцию, объявленную в `hello_compiler.h`. А `main.cpp` использует эту функцию. Сборка такого проекта состоит из трёх шагов: сначала создаются объектные файлы для единиц трансляции, а затем они объединяются линкером в исполняемый файл.
Получение объектного файла `hello_compiler.o`:
```bash
clang \
-c \
-std=c++23 \
-isystem /usr/lib/llvm-20/include/c++/v1/ \
-nostdinc++ \
-o hello_compiler.o \
hello_compiler.cpp
```
Получение объектного файла `main.o`:
```bash
clang \
-c \
-std=c++23 \
-isystem /usr/lib/llvm-20/include/c++/v1/ \
-nostdinc++ \
-o main.o \
main.cpp
```
Линковка `hello_compiler.o` и `main.o` для получения бинарника `main`:
```bash
clang \
-lc++ \
-o main \
main.o hello_compiler.o
```
-----
## Резюме
- Драйвер компилятора — это фасад для вызова компилятора, ассемблера и линкера. Почти всегда драйвер компилятора называют просто компилятором.
- Единица трансляции — это файл реализации со включёнными в него хедерами.
- Компилятор из единиц трансляции генерирует код на ассемблере.
- Ассемблер создаёт объектные файлы, по одному на единицу трансляции.
- Объектный файл — бинарный файл, полученный в результате обработки компилятором и ассемблером единицы трансляции.
- Линкер объединяет объектные файлы друг с другом, с используемыми библиотеками и рантаймом. Он создаёт исполняемый файл или библиотеку.
Наша группа в telegram. Здесь можно задавать вопросы и общаться.
Задонатить. Если вам нравится курс, вы можете поддержать развитие площадки!