Денис Злобин

Урок 13. Этапы сборки

Привет! В этом уроке ты освоишь этапы сборки программы на С++, после чего сборка проекта перестанет быть для тебя магическим чёрным ящиком.

Несмотря на то, что сборка программы на С++ выполняется одной командой, под капотом сборка программы состоит из нескольких этапов. Стандарт языка выделяет целых 9 этапов https://en.cppreference.com/w/cpp/language/translation_phases#Translation_phases ! Но для нас, разработчиков на языке С++, основными этапами являются следующие:

  • препроцессинг
  • компиляция
  • ассемблирование
  • линковка

Я буду объяснять этапы сборки на примере нашей первой программы на С++. Содержимое файла main.cpp:

#include <iostream>

int main()
{
	// Some info
    std::cout << "Hello, world!\n";
}

Команда на сборку:

g++ main.cpp

Препроцессинг На этапе препроцессинга с исходными файлами нашего проекта работает… препроцессор. Препроцессор - это отдельная утилита, которая выполняет препроцессорные директивы. Пока мы знакомы с директивой препроцессора #include - при выполнении этой директивы препроцессор просто копирует содержимое файла, указанного после директивы, в файл cpp, из которого она была вызвана. Да-да, iostream - это файл. Например, на моей ОС он лежит по пути /usr/include/c++/14/iostream

О работе препроцессора нужно помнить следующие моменты:

  • для работы препроцессора не нужны файлы с реализацией функций
  • имена заголовочных файлов не обязаны быть уникальными, главное, чтобы заголовочные файлы с одинаковыми именами лежали в разных директориях
  • препроцессор добавляет заголовочные файлы рекурсивно - в файл копируются заголовочные файлы, вложенные в другие заголовочные файлы

Запустим препроцессор для нашего исходного файла main.cpp:

cpp main.cpp > main.i

cpp - это утилита-препроцессор.

Исследуем выходной файл препроцессора main.i. Первое, на что ты обратишь внимание, main.i - просто гигантский по сравнению с исходным файлом main.cpp! Мой файл main.i содержит больше 37000 строк кода! Откуда такой размер? Напомню, что препроцессор работает рекурсивно, в итоге он добавил содержимое всех заголовочных файлов, вложенных в iostream. Следующий важный момент - препроцессор убрал все комментарии из исходного кода. Комментарии нужны разработчику, машине они ни к чему.

Обрати внимание, что имя заголовочного файла после директивы #include может быть заключено как в треугольные скобки <>, так и в двойные кавычки "". В случае, если используются треугольные скобки, файл ищется в системных директориях, о каких именно - ты узнаешь к концу урока. Если используются двойные кавычки, препроцессор ищет файл в той же директории, где находится файл cpp, из которого была вызвана директива #include.

Единственный тип ошибки, который ты можешь встретить на этапе препроцессинга в рамках нашего курса, - препроцессор не смог найти заголовочный файл. Решение: указать препроцессору директорию для поиска файла, либо добавить заголовочный файл в директорию, о которой препроцессор уже “знает”.

Важное определение: единица трансляции - это файл, получившийся из cpp-файла после завершения работы препроцессора. Т.е. это файл main.i, а не файл main.cpp.

Компиляция Компиляция - это процесс преобразования исходного кода на языке С++ в ассемблерный код. Выполним следующую команду:

g++ -fno-use-cxa-atexit -S main.i

Для того, чтобы g++ выполнил промежуточный шаг компиляции, ему необходимо передать ключ -S. Второй ключ -fno-use-cxa-atexit используется только для того, чтобы пройти все этапы сборки вручную шаг за шагом; в реальных продовых задачах ты его не встретишь. Подробнее про причины использования этого ключа ты можешь прочитать здесь https://stackoverflow.com/questions/34308720/where-is-dso-handle-defined или посмотреть, зачем он используется, прямо в исходниках GCC https://github.com/gcc-mirror/gcc/blob/9693459e030977d6e906ea7eb587ed09ee4fddbd/libgcc/config/vxcrtstuff.c#L73 В результате выполнения команды будет создан файл main.s, который содержит ассемблерный код x86-64 (AT&T синтаксис). С вероятностью 90 % ты не будешь работать с ассемблерным кодом на своём первом рабочем месте, поэтому содержимое файла main.s мы рассматривать не будем.

На этапе компиляции ты можешь столкнуться со следующими ошибками:

  • ошибки синтаксиса языка С++. Например, если вместо std::cout написать std::out, то получим ошибку error: 'out' is not a member of 'std'
  • ошибки повторного включения заголовочных файлов. Настало время познакомиться подробнее с define guard’ами, про которые я ранее упоминал.

Проблема повторного включения заголовочных файлов. Разберём проблему на следующем примере. Пусть наш демо-проект будет состоять из 2-х файлов.

Файл data.h

// Пропустили define guard
struct Data
{
	int value{0};
};

Файл main.cpp

// Включаем data.h дважды
#include "data.h"
#include "data.h"

int main()
{
	Data data;
}

При выполнении сборки командой g++ main.cpp мы получим ошибку error: redefinition of 'struct Data'

А теперь пройдёмся по шагам сборки и узнаем, что и когда пошло не так. После выполнения команды cpp main.cpp > main.i мы получим следующий файл main.i

# 0 "main2.cpp"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "<command-line>" 2
# 1 "main2.cpp"
# 1 "data.h" 1
struct Data
{
 int value{0};
};
# 2 "main2.cpp" 2
# 1 "data.h" 1
struct Data
{
 int value{0};
};
# 3 "main2.cpp" 2

int main()
{
 Data data;
}

Т.е. объявление структуры Data было включено два раза в единицу трансляции, а это является нарушением правила One Definition Rule (ODR https://en.cppreference.com/w/cpp/language/definition.html). ODR требует: в каждой единице трансляции может быть только одно определение переменной, функции или структуры.

Чтобы избежать подобных ошибок, нужно исключить возможность повторного включения заголовочного файла в одну и ту же единицу трансляции. В этом нам помогает define guard. Существует два варианта защиты:

  • современный - с помощью #pragma once. Он поддерживается всеми современными компиляторами, но, увы, до сих пор не включён в стандарт языка
#pragma once

struct Data
{
	int value{0};
};
  • устаревший, не очень удобный, но гарантированно поддерживаемый любой версией компилятора - с помощью условных директив препроцессора
#ifndef FILE_H
#define FILE_H

struct Data
{
    int value{0};
};

#endif // FILE_H

Что здесь происходит. При подключении заголовочного файла выполняется проверка на определение макроконстанты FILE_H (#ifndef FILE_H): если константа не определена, то она определяется (#define FILE_H). При попытке повторного включения заголовочного файла условие #ifndef не будет выполнено и содержимое заголовочного файла не будет добавлено.

Какой define guard выбрать? Выбор зависит от код-стайла твоего проекта. Если требований нет - используй #pragma once. Он намного читабельнее и его проще писать.

Ассемблирование На этапе ассемблирования команды на языке ассемблера преобразуются в машинный код:

as -o main.o main.s

В результате выполнения команды получится объектный файл main.o. Существует несколько программ для просмотра содержимого объектных файлов, например, radare2 или objdump. В рамках этого урока я познакомлю тебя с nm как наиболее простой программой данного класса. Ты можешь изучить инструкцию к nm с помощью команды:

man nm

Посмотрим содержимое файла main.o, а точнее таблицу символов:

nm --demangle main.o

Опция --demangle преобразует низкоуровневые имена символов в человекочитаемый вид.

Вывод команды:

0000000000000000 T main
000000000000000f r std::__detail::__integer_to_chars_is_unsigned<unsigned int>
0000000000000010 r std::__detail::__integer_to_chars_is_unsigned<unsigned long>
0000000000000011 r std::__detail::__integer_to_chars_is_unsigned<unsigned long long>
                 U std::ios_base_library_init()
                 U std::cout
                 U std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)

В рамках этого курса мы обсудим только строчку

                 U std::cout

Обрати внимание на символ U - он говорит о том, что объект std::cout объявлен в этом объектном файле, но определён в другом месте, т.е. std::cout является внешней зависимостью, которая будет разрешена на следующем этапе сборки - линковке.

Кстати, на этапе ассемблирования ты не встретишься с ошибками.

Линковка На этапе линковки все файлы нашего проекта объединяются в один бинарный файл (исполняемый модуль), в бинарный файл добавляется информация о внешних зависимостях - где и как их взять.

Важно: не пытайся повторять процесс линковки руками для продовых продуктов. Дальнейшая информация приводится исключительно в учебно-развлекательных целях. Для сборки реальных проектов используй команду типа g++ main.cpp. Компилятор сам разберётся со всеми необходимыми зависимостями.

Итак, команда для линковки нашего простого примера для моей ОС выглядит следующим образом:

ld -o main main.o /usr/lib/gcc/x86_64-linux-gnu/14/../../../x86_64-linux-gnu/Scrt1.o /usr/lib/gcc/x86_64-linux-gnu/14/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/14/crtbeginS.o -L/usr/lib/gcc/x86_64-linux-gnu/14 -L/usr/lib/gcc/x86_64-linux-gnu/14/../../../x86_64-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/14/../../../../lib -L/lib/x86_64-linux-gnu -L/lib/../lib -L/usr/lib/x86_64-linux-gnu -L/usr/lib/../lib -L/usr/lib/gcc/x86_64-linux-gnu/14/../../.. -lstdc++ -lm -lgcc_s -lgcc -lc -lgcc_s -lgcc /usr/lib/gcc/x86_64-linux-gnu/14/crtendS.o /usr/lib/gcc/x86_64-linux-gnu/14/../../../x86_64-linux-gnu/crtn.o

ld - линковщик, т.е. утилита, которая добавляет в написанный нами код внешние зависимости. Всё, что идёт после части строки ld -o main main.o - это внешние зависимости. Да, их очень много.

Чтобы посмотреть, какую работу выполняет под капотом команда g++, нужно выполнить сборку с ключом расширенной печати -v:

g++ -v main.cpp

В выводе будет много информации. Из вывода можно извлечь информацию, чем отличается использование кавычек в #include "..." от использования треугольных скобок в #include <...>:

#include "..." search starts here:
#include <...> search starts here:
 /usr/include/c++/14
 /usr/include/x86_64-linux-gnu/c++/14
 /usr/include/c++/14/backward
 /usr/lib/gcc/x86_64-linux-gnu/14/include
 /usr/local/include
 /usr/include/x86_64-linux-gnu
 /usr/include

#include "..." ищет заголовочные файлы в директории, откуда он был вызван, #include <...> - в заранее определённых системных директориях.

По окончании этого этапа сборки мы наконец-то получили исполняемый модуль, который выведет в консоль заветное “Hello, world!”.

Этап линковки обладает наибольшим количеством ошибок, которые обычно связаны с разрешением внешних зависимостей.

Фух, этот урок получился очень объёмным, но информация, изложенная в нём, изучается один раз, а потом используется на протяжении всей твоей карьеры разработчика! До встречи на следующем уроке!