# Глава 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. ![Лого компиляторов](https://raw.githubusercontent.com/senjun-team/senjun-courses/refs/heads/main/illustrations/cpp/cpp_compilers.png) {.illustration} На cppreference постоянно актуализируется [список фичей](https://en.cppreference.com/w/cpp/compiler_support) версий C++ в разрезе поддержки компиляторами. Каждый из перечисленных компиляторов не существует сам по себе, а входит в состав тулчейна. **Тулчейн** — это целый инструментарий для сборки, отладки, профилирования. Он содержит утилиты, библиотеки и все необходимое для компиляции программ. ## Пайплайн компиляции По ходу компиляции из исходного кода на C++ создаётся исполняемый файл или библиотека. Процесс компиляции для них принципиально не отличается. Он выглядит как цепочка запуска трёх инструментов: компилятора, ассемблера и линкера. Тут сразу оговоримся, что под ассемблером в зависимости от контекста понимают две сущности: - язык ассемблера. Это команды процессора в виде, удобном для разработчика. - программу ассемблер. Это транслятор из текста на языке ассемблера в бинарный код. **Компилятор** (compiler) транслирует исходный код на C++ в код на ассемблере. Вы уже знакомы с важной частью компилятора — препроцессором (preprocessor). Он подготавливает исходники, чтобы их удобнее было транслировать. **Ассемблер** (assembler) в свою очередь создаёт промежуточные бинарные файлы, называемые объектными файлами. **Линкер** (linker) компонует их в результирующий исполняемый файл или библиотеку. ![Пайплайн сборки](https://raw.githubusercontent.com/senjun-team/senjun-courses/cpp-chapter-11/illustrations/cpp/cpp_build_pipeline.jpg) {.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++ модулей. ![Медленная компиляция — это проблема?](https://raw.githubusercontent.com/senjun-team/senjun-courses/introduce-cpp/illustrations/cpp/slow_compiling.jpg) {.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 для этого кода будет выглядеть так: ![Пример AST](https://raw.githubusercontent.com/senjun-team/senjun-courses/introduce-cpp/illustrations/cpp/ast_for_euclidean_algo.jpg) {.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. Он содержит два файла реализации и три хедера. Он собирается в исполняемый файл. Для этого препроцессор из файлов реализации получает единицы трансляции, компилятор транслирует их в код на ассемблере, ассемблер получает его и создаёт объектные файлы, а линкер объединяет их в исполняемый файл. ![Прохождение файлами пайплайна сборки](https://raw.githubusercontent.com/senjun-team/senjun-courses/introduce-cpp/illustrations/cpp/cpp_build_pipeline_entities.jpg) {.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. Здесь можно задавать вопросы и общаться.
Задонатить. Если вам нравится курс, вы можете поддержать развитие площадки!