C++ Компиляция. Основы.
patriosЯ давно хотел написать список заметок про сборку в C++. Основной целью цикла я ставлю – структурирование информации в моей голове и наличие заметки всегда под рукой. В цикле я постараюсь разобрать и продемонстрировать все этапы трансформации исходного кода написанного на C++ в исполняемый файл. В основном я буду пользоваться набором инструментов LLVM (соответственно Clang компилятор и lld линковщик). Однако большинство концепций будут применимы так же и к другим наборам инструментов (MSVC/GCC).
Конкретно в данной заметке я в общих чертах опишу процесс сборки исходного кода и опишу разницу между компиляцией и линковкой (linking).
Как вы знаете C++ проекты в основном состоят из набора файлов с исходным кодом. Данные файлы чаще всего имеют расширение .cc или .cpp. На этапе компиляции эти файлы часто называются единицами трансляции (translation unit).
Давайте рассмотрим общую схему сборки:
Можно заметить, что я игнорирую заголовочные файлы (headers, .h). Дело в том что непосредственно в процессе сборки данные файлы не фигурируют. Цель заголовочных файлов – продекларировать функции и классы реализованные в единице трансляции, чтобы облегчить их аннотацию в других модулях. Для каждого .cc/.cpp модуля все включенные заголовочные файлы объединяются вместе и помещаются в сам модуль. Данный процесс происходит на этапе препроцессора, который мы рассмотрим подробнее в одной из следующих заметок.
Из схемы также видно, что процесс компиляции каждого модуля трансляции не зависит от остальных модулей. Другими словами компиляция всех модулей в проекте может происходить параллельно. Именно поэтому большинство сборочных систем (таких как cmake/make) поддерживают параллельный запуск нескольких процессов компилятора (к примеру clang). С другой стороны, линковка практически монолитный процесс, который крайне сложно распараллелить. Впрочем существуют линковщики поддерживающие сложные схемы параллельной линковки (такие как gold), о них мы поговорим в другой заметке.
Стоит упомянуть, что компиляция в разы более дорогостоящая операций нежели линковка. Во время компиляции происходит практически полный цикл превращения исходного C++ кода в машинный байт-код. По сути объектные файлы .o почти ничем не отличаются от непосредственно исполнимых бинарных файлов или динамических библиотек. Единственное что остается сделать линковщику -- это поместить все объектные файлы в один результирующий файл и исправить адреса вызовов кросс-модульных методов и объектов. Мы рассмотрим подробнее этот процесс на примере.
Так как объектные файлы не отличаются от исполняемых файлов изначально дорогостоящий процесс оптимизации кода также происходил исключительно на этапе компиляции. В современном мире большинство наборов инструментов также поддерживает оптимизацию на этапе компоновки, мы рассмотрим этот процесс в последующих заметках.
Для понимания масштабов -- полная компиляция таких проектов как Unreal Engine Shooter Game Sample на современном ПК займет 5-10 минут, при этом линковка без оптимизации займет несколько секунд.
Также важно понимать, что насколько бы линковка не была простой операцией – ее длительность тем не менее важна при инкрементальной сборке проекта. При такой сборке компиляция будет запущена для относительно небольшого числа измененных файлов с исходным кодом, линковка же будет запущена заново для всего проекта.
Давайте теперь рассмотрим простейший пример состоящий всего из двух единиц трансляции:
foo.cc:
int foo() {
return 4;
}
И main.cc:
int foo();
int main() {
return foo();
}
Для начала мы соберем эти исходники в объектные файлы следующими командами:
clang++ -O0 -g -c foo.cc clang++ -O0 -g -c main.cc
Результатом этих команд будут два объектных файла foo.o и main.o. Давайте посмотрим на результате дизассемблинга содержимого этих модулей. Для этого воспользуемся командой objdump -d.
objdump -d foo.o:
0: 55 push %rbp 1: 48 89 e5 mov %rsp,%rbp 4: b8 04 00 00 00 mov $0x4,%eax 9: 5d pop %rbp a: c3 retq
В данном коде мы не видим ничего удивительного, значение 4 сохраняется в регистр и возвращается в качестве результата функции.
objdump -d main.o:
0: 55 push %rbp 1: 48 89 e5 mov %rsp,%rbp 4: 48 83 ec 10 sub $0x10,%rsp 8: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp) f: e8 00 00 00 00 callq 14 <main+0x14> 14: 48 83 c4 10 add $0x10,%rsp 18: 5d pop %rbp 19: c3 retq
А вот тут мы уже видим более интересный код. Стоит обратить внимание на строку:
f: e8 00 00 00 00 callq 14 <main+0x14>
В этой строке происходит вызов метода из другой единицы трансляции. E8 это числовое представление команды вызова метода, при этом мы видим что в качестве параметров данной команде передается вектор нулей. Единственный намек на реальное расположение функции это смещение 14. Собственно задача линковщика исправить этот самый адрес функции foo.
Давайте теперь соберем исполняемый файл следующей командой:
clang++ main.o foo.o
Несмотря на то, что мы используем тут утилиту clang, под капотом будет запущен исключительно процесс компоновки. Мы рассмотрим разницу между утилитами из тулчейнов и как они взаимосвязаны друг с другом в последующих заметках.
Результатом команды выше будет исполняемый файл a.out. Дизассемблер этого файла содержит очень много нерелевантных нам секций (таких как __start) которые мы также рассмотрим в других заметках, давайте сейчас посмотрим на секцию с кодом метода main.
objdump -d a.out:
401110: 55 push %rbp 401111: 48 89 e5 mov %rsp,%rbp 401114: 48 83 ec 10 sub $0x10,%rsp 401118: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp) 40111f: e8 0c 00 00 00 callq 401130 <_Z3foov> 401124: 48 83 c4 10 add $0x10,%rsp 401128: 5d pop %rbp 401129: c3 retq 40112a: 66 0f 1f 44 00 00 nopw 0x0(%rax,%rax,1)
Как мы видим теперь в метод call передается правильный адрес метода в памяти исполняемого модуля:
40111f: e8 0c 00 00 00 callq 401130 <_Z3foov>
Как я и писал сначала, цель данный заметки лишь ознакомить с общими концепциями компиляции и линковки, детали я постараюсь раскрыть в последующих заметках.