Главная / Курсы / C++ по спирали / Сборка программы
# Глава 11. Сборка программы В этой главе вы научитесь компилировать программы на 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), [strace](https://man7.org/linux/man-pages/man1/strace.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++ в код на ассемблере компилятор может сам создавать объектные файлы. В таком случае вызова ассемблера как самостоятельной программы не произойдет. Скомпилируем проект, состоящий из единственного файла `main.cpp`: ```c++ // 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`: - `-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/` — путь к хедерам в [стандартной системной директории.](/courses/cpp/chapters/cpp_chapter_0100/#block-system) - `-nostdinc++` — запрет на поиск C++ хедеров по стандартным путям. - `-o main` — имя результирующего бинарного файла. Если вызов компилятор завершился успехом, результат его работы будет сохранен в бинарный файл `main`. Вызовем его: ```bash ./main ``` ``` Hello compiler ``` А теперь рассмотрим подробнее каждый из этапов компиляции. ## Препроцессинг Препроцессор поштучно обрабатывает `cpp`-файлы. Он обращается к хедерам, только если они подключены в `cpp`-файлы. Препроцессор формирует единицы трансляции. Иногда их называют единицами компиляции. **Единица трансляции** (translation unit) — это `cpp`-файл, в который добавлено содержимое всех подключаемых в него хедеров. Задача препроцессора — обнаружить и обработать [директивы препроцессора.](/courses/cpp/chapters/cpp_chapter_0100/#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_0050/#block-overloading) - Классы с одинаковым именем, но в разных пространствах имен. Чтобы однозначно идентифицировать именованные сущности в коде, компилятор добавляет к имени информацию о пространстве имен, принимаемых функцией параметрах и их типах. Это называется [искажением имен](https://en.wikipedia.org/wiki/Name_mangling) (name mangling). Иногда это называется декорированием имен (name decoration). Набор правил для искажения имен зависит в том числе от компилятора и его версии, переданных ему флагов, от версии C++. Иногда искаженные имена встречаются в тексте ошибок на этапе линковки. Чтобы вы не растерялись и поняли, что перед вами искаженное имя, приведем короткий пример. Допустим, у нас есть две перегрузки функции `format()`: ```c++ std::string format(double val); std::string format(const std::string & val); ``` Их искаженные имена могут выглядеть так: ``` _Z9formatB5cxx11d _Z9formatRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE ``` Искаженные имена компилятор сохраняет в таблицу символов. **Символ** — это уникальное имя, обозначающее переменную, функцию, класс и другие сущности в коде. Таблица символов — это структура данных, хранящая такие атрибуты символов как адрес, область видимости, тип. Компилятор генерирует код для [шаблонных](/courses/cpp/chapters/cpp_chapter_0050/#block-templates) функций и классов: он инстанцирует шаблоны, то есть порождает специализации шаблонов для конкретных параметров. Исполнение кода на этапе компиляции (compile-time) достигается при использовании по отдельности или в комбинации: - шаблонов, - ключевых слов `constinit`, `consteval` и `constexpr` для вычисления выражений и вызова функций на этапе компиляции, а не в рантайме. Итак, компилятор умеет модифицировать, генерировать и исполнять код. А результат транслирует в ассемблер. Как выглядит весь процесс? Компилятор проводит три вида анализа кода: - Лексический. Препроцессинг — это часть лексического анализа. - Синтаксический (грамматический). - Семантический (смысловой). Компилятор сообщает о нарушении [ODR.](/courses/cpp/chapters/cpp_chapter_0100/#block-odr) Так как он обрабатывает каждую единицу трансляции отдельно, то находит повторные определения только внутри одной единицы трансляции. Нарушения ODR *между* единицами трансляции определяет линкер. По ходу синтаксического анализа компилятор строит из кода дерево разбора (parse tree). Это ориентированное дерево, в котором внутренние вершины — операторы, а листья — соответствующие им операнды, то есть переменные и константы. Иными словами, структура программы отображается в виде дерева из объявлений, инструкций и выражений. Затем дерево разбора урезается до AST (abstract syntax tree, абстрактное синтаксическое дерево). AST отличается от дерева разбора тем, что в нем отсутствуют не влияющие на семантику программы узлы. Например, группирующие скобки. Потери важной информации при этом не происходит, ведь группировка операндов и так задается древовидной структурой. Рассмотрим фрагмент кода из проекта gcd для нахождения наибольшего общего делителя целых чисел `a` и `b`: ```c++ 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` применяет максимальный набор оптимизаций. В главе «Что такое C++» рассматривалось два примера кода для изменения элементов вектора. В [первом примере](/courses/cpp/chapters/cpp_chapter_0010/#block-naive) это реализовано через наивный цикл. Во [втором](/courses/cpp/chapters/cpp_chapter_0010/#block-for-each) использован алгоритм `std::for_each()`. Откройте оба примера в Playground, чтобы увидеть их полный код. Поочередно сохраните его в `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_0010/#block-efficiency) То есть вызов алгоритма работает медленнее цикла, если оптимизации не включены, и быстрее — если включены. После проведения оптимизаций компилятор из промежуточного представления для каждой единицы трансляции создает файл с кодом на ассемблере. Верните файл `main.cpp` в изначальное состояние: ```c++ // main.cpp #include <print> int main() { std::println("Hello compiler"); } ``` Скомпилируйте `main.cpp` с опцией `-S`, которая останавливает сборку после этапа компиляции. ```c++ clang \ -S \ -std=c++23 \ -isystem /usr/lib/llvm-20/include/c++/v1/ \ -nostdinc++ \ main.cpp ``` Командой [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_0100/#block-declarations) с их [определениями.](/courses/cpp/chapters/cpp_chapter_0100/#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_0100/#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 ``` ### Видимость символа для линкера У термина «линковка» два значения. Первое вы уже знаете: это завершающий этап сборки программы, на котором из объектных файлов создается исполняемый файл или библиотека. Но есть и второе значение. Линковка (linkage) — это _свойство_ символа, определяющее его видимость для линкера. Символ может быть вовсе не доступен для линкера, то есть отсутствовать в таблице символов. В таком случае говорят, что у него **нет линковки** (no linkage). Например, ее нет у локальных переменных, полей классов, параметров функций. Символ может быть доступен линкеру только внутри своей единицы трансляции. Это значит, что у него **внутренняя линковка** (internal linkage). Например, внутренней линковкой [обладают](https://timsong-cpp.github.io/cppwp/n4950/basic#link-3) константные глобальные переменные. ```c++ #include <print> const std::size_t buffer_size = 2048; // внутренняя линковка int main() { std::println("{}", buffer_size); } ``` А у не константных глобальных переменных **внешняя линковка** (external linkage). Символ со внешней линковкой доступен из любой единицы трансляции. Во всей программе у него должно быть только одно определение, иначе нарушится ODR. По умолчанию внешняя линковка есть у функций, перечислений и классов. И, наконец, с появлением в C++20 модулей появилась [модульная линковка](https://en.cppreference.com/w/cpp/language/storage_duration.html#Module_linkage) (module linkage). Символы с модульной линковкой доступны из любой единицы трансляции, принадлежащей модулю. Подробнее мы рассмотрим этот вид линковки в главе про модули. В некоторых случаях может потребоваться заменить внутреннюю линковку на внешнюю. Например, если в проекте есть глобальная константа, определенная в `cpp`-файле одной единице трансляции и использующаяся в других единицах трансляции. Сделать ее доступной, просто подключив хедер, не получится, ведь она определена в `cpp`-файле. Чтобы задать такой переменной внешнюю линковку, при ее определении используется ключевое слово [extern](https://en.cppreference.com/w/c/language/storage_class_specifiers.html): ```c++ // net.cpp #include <print> extern const std::size_t buffer_size = 2048; // внешняя линковка // ... ``` В `cpp`-файле, внутри которого планируется использовать эту переменную, она объявляется также с участием спецификатора `extern`. Это означает, что переменная определена в другой единице трансляции. Если линкер не найдет определения такой переменной, сборка проекта завершится с ошибкой: ```c++ // main.cpp #include <print> // Только объявление. Определение в другом файле: extern const std::size_t buffer_size; int main() { std::println("{}", buffer_size); } ``` ``` 2048 ``` Итак, ключевое слово `extern` используется, чтобы заменить внутреннюю линковку на внешнюю. Порой возникает обратная задача: замена внешней линковки на внутреннюю. У не константных глобальных переменных внешняя линковка. Чтобы это изменить, глобальная переменная объявляется [статической:](/courses/cpp/chapters/cpp_chapter_0090/#block-static) ```c++ static std::size_t bits = 16; ``` Как видите, спецификатор `static` для локальных и глобальных переменных имеет совершенно [разный смысл.](/courses/cpp/chapters/cpp_chapter_0090/#block-static) Есть и второй способ замены внешней линковки на внутреннюю: размещение сущности внутри анонимного пространства имен (unnamed namespace). У такого пространства имен нет имени, и для доступа к объявленным внутри него сущностям не требуется использовать оператор разрешения области видимости `::`. ```c++ import std; namespace // анонимное пространство имен { void f() // внутренняя линковка вместо внешней { std::println("Function in unnamed namespace"); } } int main() { f(); } ``` ``` Function in unnamed namespace ``` ### Статическая и динамическая линковка В зависимости от вида подключаемых библиотек линковаться с ними можно статически или динамически. Статическая линковка означает полное включение библиотеки в результирующий бинарный файл. При статической линковке на момент сборки есть вся необходимая информация, чтобы разрешить межфайловые зависимости. Динамическая линковка подразумевает подгрузку используемой библиотеки каждый раз при запуске программы. Поэтому даже если сборка программы прошла успешно, необходимо, чтобы на целевой машине была установлена нужная библиотека. Все библиотеки проверяются на совместимость по ABI. ## C++ ABI [ABI](https://ru.wikipedia.org/wiki/%D0%94%D0%B2%D0%BE%D0%B8%D1%87%D0%BD%D1%8B%D0%B9_%D0%B8%D0%BD%D1%82%D0%B5%D1%80%D1%84%D0%B5%D0%B9%D1%81_%D0%BF%D1%80%D0%B8%D0%BB%D0%BE%D0%B6%D0%B5%D0%BD%D0%B8%D0%B9) (application binary interface) — интерфейс между бинарными компонентами. Например, между библиотекой и подключающей ее программой. В мире C и C++ ABI фиксирует подробности реализации языка. Например, стандарт C++ описывает синтаксис функций, но не указывает, как в функцию передаются параметры — в регистрах процессора, по стеку или комбинированно. Этим заведует ABI. Стандарт C++ определяет, что такое классы и как их использовать. ABI определяет, как представлены поля класса в памяти компьютера: их расположение, порядок, [выравнивание.](https://ru.wikipedia.org/wiki/%D0%92%D1%8B%D1%80%D0%B0%D0%B2%D0%BD%D0%B8%D0%B2%D0%B0%D0%BD%D0%B8%D0%B5_%D0%B4%D0%B0%D0%BD%D0%BD%D1%8B%D1%85) Стандарт языка разрабатывается комитетом по стандартизации, а ABI — вендорами компиляторов. Совместимость по ABI важна и для статических, и для динамических библиотек. Если два компилятора на одной и той же платформе будут следовать разным ABI, то собранный этими компиляторами код не удастся слинковать. Кстати, между GCC и Clang нет полной совместимости по ABI. Код, собранный разными версиями одного и того же компилятора, тоже может быть не совместим по ABI. ## Библиотеки Библиотеки C++ бывают трех видов: - Статические (archive, архив). - Динамические (shared library, разделяемая библиотека). - Header-only (состоящие только из заголовочных файлов). У любой библиотеки есть хедеры. Они содержат интерфейс библиотеки — объявления функций и других сущностей. По месту их использования требуется подключать соответствующие им хедеры. **Статические** библиотеки имеют расширение `.a` под *nix и `.lib` под Windows. На этапе линковки они становятся частью бинаря, который их использует. Это увеличивает размер программы, но не создает внешних зависимостей. Такие библиотеки иногда называют архивами, потому что они представляют из себя несколько объектных файлов, скомпонованных вместе. **Динамические** библиотеки имеют расширение `.so` (shared object) под *nix и `.dll` (dynamic link library) под Windows. Они не становятся частью программы, а подгружаются во время исполнения. Несколько исполняемых файлов совместно могут использовать один и тот же файл библиотеки. Поэтому динамические библиотеки и называют разделяемыми. Такой подход экономит место, но требует установки на целевую систему библиотеки нужной версии. **Header-only** библиотеки состоят _только_ из заголовочных файлов. Их удобно подключать к проекту, ведь дополнительной линковки с такой библиотекой не требуется. Код header-only библиотеки копируется препроцессором в файлы реализации проекта по месту директивы `#include`. ## Рантайм C++ В языках семейства C есть **рантайм** — набор библиотек, реализующих часть описанных в стандарте языка возможностей, его [модель исполнения](https://en.wikipedia.org/wiki/Execution_model) и функции для корректного запуска программы. Рантайм C++ помимо прочего реализует механизм обработки исключений, операторы `new` и `delete` для выделения и освобождения памяти, а также нешаблонный код стандартной библиотеки. Не путайте концепцию рантайма в C и C++ с рантаймом в [управляемых языках,](https://ru.wikipedia.org/wiki/%D0%91%D0%B8%D0%B1%D0%BB%D0%B8%D0%BE%D1%82%D0%B5%D0%BA%D0%B0_%D1%81%D1%80%D0%B5%D0%B4%D1%8B_%D0%B2%D1%8B%D0%BF%D0%BE%D0%BB%D0%BD%D0%B5%D0%BD%D0%B8%D1%8F) таких как Java, C# и python. В них рантайм — это полноценная среда выполнения программы, укомплектованная сборщиком мусора и виртуальной машиной. Программы на C++ линкуются не только с рантаймом C++, но и с рантаймом C. Зачем? Дело в том, что рантайм C++ опирается на рантайм C. Например, операторы `new` и `delete` в C++ зачастую реализованы через сишные функции `malloc` и `free`. С рантаймом можно линковаться динамически и статически. По умолчанию линковка динамическая. С рантаймом можно не линковаться вовсе. Тогда в программе будет доступно минимальное подмножество языка. Это имеет смысл при разработке [встраиваемых систем](https://ru.wikipedia.org/wiki/%D0%92%D1%81%D1%82%D1%80%D0%B0%D0%B8%D0%B2%D0%B0%D0%B5%D0%BC%D0%B0%D1%8F_%D1%81%D0%B8%D1%81%D1%82%D0%B5%D0%BC%D0%B0) (embedded systems), драйверов и низкоуровневых программ, запускаемых на устройствах без ОС. Библиотека с рантаймом C++ от GCC называется [libstdc++](https://gcc.gnu.org/onlinedocs/libstdc++/index.html), от Clang — [libc++](https://libcxx.llvm.org/), а от Microsoft — [STL](https://github.com/microsoft/STL). Также эти библиотеки содержат реализацию стандартной библиотеки. Библиотека glibc (GNU C Library) реализует рантайм C. При линковке рантайма, как и при линковке любой другой библиотеки, встает необходимость совместимости по ABI. Если в программе статически линкуются две библиотеки, использующие несовместимые между собой версии рантайма, то компиляция завершится с ошибкой. Помимо рантайма языка есть еще **рантайм компилятора.** Он содержит определения функций, неявно используемые компилятором для поддержки операций, которых нет на целевой машине. Например, операции над 64-битными числами на 32-битных архитектурах. Реализация рантайма зависит от компилятора, его версии, от целевой системы и архитектуры процессора. Допустим, программа собрана компилятором GCC с опциями по умолчанию. Можно ли ее запустить на такой же платформе, но с рантаймом от Clang? Нет, У рантайма GCC и Clang нет полной совместимости по ABI. Посмотрите, какие библиотеки подгружает бинарь `main`. Запустите его из-под [strace](https://man7.org/linux/man-pages/man1/strace.1.html) — команды, отслеживающей, какие системные вызовы делает программа. Опция `-e trace=openat` означает, что интересуют только вызовы [openat](https://linux.die.net/man/2/openat) для открытия файловых дескрипторов. ```shell strace -e trace=openat ./main ``` ``` openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc++.so.1", O_RDONLY|O_CLOEXEC) = 3 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc++abi.so.1", O_RDONLY|O_CLOEXEC) = 3 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libunwind.so.1", O_RDONLY|O_CLOEXEC) = 3 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libm.so.6", O_RDONLY|O_CLOEXEC) = 3 ... ``` Простой бинарь, выводящий в консоль "Hello compiler", зависит от нескольких динамических библиотек! Причем их количество, названия и пути к ним будут отличаться на разных системах. В данном случае нас больше всего интересуют библиотеки: - [ld.so](https://www.opennet.ru/man.shtml?topic=ld.so&category=8&russian=0) — часть рантайма C. Ищет и подгружает используемые в программе динамические библиотеки, подготавливает программу к запуску. Также здесь содержится код, инициализирующий глобальные переменные и вызывающий функцию `main()` — точку входа в программу. - [libm.so](https://packages.debian.org/search?searchon=contents&keywords=libm.so&mode=path&suite=stable&arch=any) — еще одна часть рантайма C, которая отвечает за реализацию математических функций из хедера `math.h`. Она была вынесена в отдельный файл по историческим причинам. - [libc.so](https://www.man7.org/linux/man-pages/man7/libc.7.html) — реализация стандартной библиотеки C. - [libc++](https://packages.debian.org/ru/sid/libstdc++6) — Реализация рантайма и стандартной библиотеки C++ в Clang. ## Сборка проекта с модулями Артефакты сборки модулей зависят от компилятора. Но в целом работа с модулями выглядит следующим образом: 1. Для модуля компилируется его BMI (Built Module Interface) — бинарный файл с [интерфейсом модуля.](/courses/cpp/chapters/cpp_chapter_0100/#block-interface) Он содержит экспортируемые объявления. Одно из распространенных расширений для BMI-файлов — это `.pcm` (Precompiled Module). 2. При сборке проекта повсюду, где импортируется модуль, компилятор задействует этот BMI. Это гораздо эффективнее, чем подключение хедеров препроцессором. ### Модуль стандартной библиотеки Чтобы импортировать модуль `std` в своих проектах, для начала нужно получить его BMI. Делается это единожды. Так выглядит компиляция интерфейса модуля `std.cppm` с сохранением результата в файл с BMI `std.pcm`. Обратите внимание на флаг `--precompile`: ```bash clang \ -std=c++23 \ -O3 \ -nostdinc++ \ -isystem /usr/lib/llvm-20/include/c++/v1/ \ --precompile \ -o /usr/local/lib/std.pcm \ /usr/lib/llvm-20/share/libc++/v1/std.cppm ``` Теперь в файле нашего проекта `main.cpp` мы можем заменить директиву препроцессора `#include <print>` на выражение `import std;`: ```c++ // main.cpp import std; int main() { std::println("Hello compiler"); } ``` Чтобы собрать файл, импортирующий модуль, нужно передать компилятору опцию `-fmodule-file`. Она указывает путь к BMI и имеет формат `-fmodule-file=<module-name>=<BMI-path>`. После первого знака `=` указывается имя модуля, после второго — путь: ```bash clang \ -std=c++23 \ -O3 \ -fmodule-file=std=/usr/local/lib/std.pcm \ -lc++ \ -isystem /usr/lib/llvm-20/include/c++/v1/ \ -nostdinc++ \ -o main \ main.cpp ``` ### Пользовательские модули Создайте модуль `hello_compiler.cppm`, функция из которого вызывается в `main.cpp`. Содержимое файлов [приведено](/courses/cpp/chapters/cpp_chapter_0100/#block-project-modules) в прошлой главе. ``` ├── hello_compiler.cppm └── main.cpp ``` При сборке такого проекта вначале нужно получить BMI модуля `hello_compiler`. Так как внутри него присутствует импорт модуля `std`, нужно указать путь к `std.pcm`: ```bash clang \ -std=c++23 \ -O3 \ -fmodule-file=std=/usr/local/lib/std.pcm \ --precompile \ -o hello_compiler.pcm \ hello_compiler.cppm ``` Теперь соберем бинарь `main` с указанием пути до двух BMI: стандартной библиотеки и нашего модуля. ```bash clang \ -std=c++23 \ -O3 \ -fmodule-file=std=/usr/local/lib/std.pcm \ -fmodule-file=hello_compiler=hello_compiler.pcm \ -lc++ \ -o main \ main.cpp hello_compiler.pcm ``` ## Автоматизация сборки Если вы дочитали до этого места, то уже прочувствовали, насколько неудобно собирать проекты C++ непосредственным вызовом драйвера компилятора. К счастью, в мире C++ есть инструменты для автоматизации сборки. И о них вы узнаете уже в следующей главе. ----- ## Резюме - Драйвер компилятора — это фасад для вызова компилятора, ассемблера и линкера. Почти всегда драйвер компилятора называют просто компилятором. - Единица трансляции — это файл реализации со включенными в него хедерами. - Компилятор из единиц трансляции генерирует код на ассемблере. - Ассемблер создает объектные файлы, по одному на единицу трансляции. - Объектный файл — бинарный файл, полученный в результате обработки компилятором и ассемблером единицы трансляции. - Линкер объединяет объектные файлы друг с другом, с используемыми библиотеками и рантаймом. Он создает исполняемый файл или библиотеку. - ABI (Application Binary Interface) — интерфейс между бинарными компонентами, фиксирующий детали реализации языка. - В мире C и C++ рантайм — это набор библиотек, реализующих часть описанных в стандарте языка возможностей, его модель исполнения и функции для запуска программы. - BMI (Built Module Interface) — бинарный файл с интерфейсом модуля, содержащий все экспортируемые объявления.

Следующие главы находятся в разработке

Отправка...
Наша группа в telegram. Здесь можно задавать вопросы и общаться.
Задонатить. Если вам нравится курс, вы можете поддержать развитие площадки!