Урок 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!”.
Этап линковки обладает наибольшим количеством ошибок, которые обычно связаны с разрешением внешних зависимостей.
Фух, этот урок получился очень объёмным, но информация, изложенная в нём, изучается один раз, а потом используется на протяжении всей твоей карьеры разработчика! До встречи на следующем уроке!