Посмотрите на полный процесс преобразования исходного кода в исполняемый формат

На высоком уровне я буду смотреть на результат каждого этапа компиляции простой программы на C ++ с использованием Clang. Я также буду более внимательно следить за нашим простым кодом при выводе дизассемблера и обсуждать части файла ELF.

Когда вы полностью компилируете свою программу, она создает исполняемый двоичный файл. Например, эта простая программа…

… Производит двоичный код. Примерно так (в шестнадцатеричном редакторе):

Если вы запустите эту программу, результат будет таким, как ожидалось:

What’s Up?
20

На этом этапе весь ваш код и данные преобразуются в двоичный формат в подходящем формате, который может выполнять ваш компьютер. Для нас это не так понятно, но чтобы лучше понять, вы можете изменить статическую строковую часть этой программы, чтобы при выполнении выводить что-то другое.

Например, в этом двоичном файле я изменил код 5768 6174 2773 2055 703f 000a, представляющий строку What’s Up? на 576f 6e64 6572 6675 6c21 000a, и запуск этого измененного двоичного файла даст результат:

Wonderful!
20

Да! Замечательно, не правда ли?

Не знаю - стоит ли мне волноваться? Это просто замена кода ASCII. Изменить поведение будет намного сложнее, поэтому, вероятно, не о чем беспокоиться.

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

Преобразование исходного кода в исполняемый формат проходит в несколько этапов. Из документации Clang видно, что следующие шаги:

1. Pre-Processing
2. Parsing and Semantic Analysis
3. Code Generation and Optimization
4. Assembly
5. Linking

Давайте посмотрим на результат каждого из вышеперечисленных этапов.

Предварительная обработка

Документация Clang описывает это как:

«На этом этапе выполняется разметка входного исходного файла, расширение макроса, расширение #include и обработка других директив препроцессора».

Результат нашей программы в конце этого этапа показывает, что макросы раскрыты. Обратите внимание, что у нас есть std::cout << “What’s Up?” << “\n”;, тогда как исходный код был std::cout << MSG << “\n”;.

…..
namespace std __attribute__ ((__visibility__ (“default”)))
{
# 60 “/usr/bin/../lib/gcc/x86_64-linux-gnu/9/../../../../include/c++/9/iostream” 3
extern istream cin;
extern ostream cout;
extern ostream cerr;
extern ostream clog;
extern wistream wcin;
extern wostream wcout;
extern wostream wcerr;
extern wostream wclog;
static ios_base::Init __ioinit;
}
# 2 “pass_by_reference_example.cpp” 2
void addTen(int& num) {
num += num + 10;
}
int main(int argc, const char* argv[]) {
int a_something = 5;
std::cout << “What’s Up?” << “\n”;
addTen(a_something);
std::cout << a_something << “\n”;
return 0;
}
…

Примечание. Мне пришлось обрезать много линий верха. Это был шаблон C ++.

Парсинг и семантический анализ

Документация Clang описывает это как:

«На этом этапе анализируется входной файл, переводя токены препроцессора в дерево синтаксического анализа. Находясь в форме дерева синтаксического анализа, он применяет семантический анализ, чтобы вычислять типы для выражений, а также определять, правильно ли сформирован код. Этот этап отвечает за генерацию большинства предупреждений компилятора, а также за ошибки синтаксического анализа. Результатом этого этапа является «Абстрактное синтаксическое дерево» (AST) ».

На этом этапе был произведен AST. Вы можете увидеть, как переменная, такая как a_something, представлена ​​в иерархии. Как и остальная часть нашего кода. Опять же, я вырезал много строк, чтобы результат оставался в пределах привычного и простого.

Генерация кода и оптимизация

Документация Clang описывает это как:

«На этом этапе AST переводится в низкоуровневый промежуточный код (известный как« LLVM IR ») и, в конечном итоге, в машинный код. Этот этап отвечает за оптимизацию сгенерированного кода и обработку генерации кода для конкретной цели. Результат этого этапа обычно называется файлом «.s» или файлом «сборки».

На этом этапе в конечном итоге создается целевой ассемблерный код. Я вставляю снимок экрана с выводом, но позже в статье мы сможем пройтись по коду сборки из objdump, поскольку он чередует код со сборкой. Здесь мы проследим часть написанного кода.

сборка

Документация Clang описывает это как:

«На этом этапе запускается целевой ассемблер для преобразования вывода компилятора в целевой объектный файл. Результат этого этапа обычно называется файлом «.o» или файлом «объект» ».

Ассемблер создает объектный файл. Наша платформа - Ubuntu, поэтому тип объектного файла, создаваемого для этой платформы, - ELF. В частности, это:

ELF 64-bit LSB relocatable, x86–64, version 1 (SYSV), with debug_info, not stripped

Это перемещаемый файл, поэтому не все адреса памяти разрешены. На снимке экрана ниже представлена ​​информация заголовка файла ELF:

Как вы можете видеть выше, Entry point address это 0x0, потому что это еще не исполняемый файл и ему неизвестна точка входа в свое виртуальное адресное пространство.

Компоновщик

Документация Clang описывает это как:

«На этом этапе запускается целевой компоновщик для объединения нескольких объектных файлов в исполняемую или динамическую библиотеку. Результат этого этапа обычно называется файлом «a.out», «.dylib» или «.so» ».

Наконец, компоновщик берет объектный файл (ы) и создает исполняемый файл, разрешая разрешаемые адреса.

Двоичный файл ELF состоит из исполняемого заголовка, нуля или более заголовков программы и нуля или более заголовков разделов. Давайте кратко рассмотрим компоненты.

Исполняемый заголовок

На скриншоте ниже показан вывод ELF-заголовка нашего исполняемого файла. Он дает нам информацию о типе файла, о том, где найти другое содержимое в файле и т. Д.

Поле Magic представляет собой 16-байтовый массив с 4-байтовым магическим значением, указывающим, что это файл ELF.

Как вы теперь можете видеть из вышеприведенных выходных данных заголовков, теперь у него есть точка входа: 0x4010d0 , адрес виртуальной памяти, с которого должно начинаться выполнение. Его тип исполняемый.

Разделы

Разделы логически организуют данные и код. Они обеспечивают организованное представление для компоновщика. Давайте исследуем разделы, присутствующие в нашем двоичном файле, с помощью readelf.

Думаю, мы можем взглянуть на некоторые из наиболее известных разделов:

.text

.text содержит основной исполняемый код. Результатов много, поэтому я просто покажу скриншоты тех немногих, которые мы можем распознать. Начнем с адреса точки входа, который мы нашли в заголовке ELF. Вот разборка для этого:

Так что это не совсем наша основная функция - это какая-то _start, вероятно, настройка и / или инициализация программы.

Регистр rdi предназначен для передачи первого аргумента, и это адрес нашей основной функции, как показано на скриншоте ниже.

4010f1: 48 c7 c7 e0 11 40 00 mov rdi,0x4011e0

Здесь мы видим, что a_something создается в [rbp-0x14], и его значение установлено на 0x5.

int a_something = 5;
4011f6: c7 45 ec 05 00 00 00 mov DWORD PTR [rbp-0x14],0x5

В нашем коде мы вызываем addTen и передаем ему ссылку на a_something. Разборка для него:

addTen(a_something);
401228: 48 8d 7d ec    lea rdi,[rbp-0x14]
40122c: 48 89 45 e0    mov QWORD PTR [rbp-0x20],rax
401230: e8 8b ff ff ff call 4011c0 <_Z6addTenRi>

Не уверен, что выполняет строка 40122c, перемещая temp rax на [rbp-0x20], но мы знаем, что lea загружает эффективный адрес в [rbp-0x14], то есть адрес a_something в rdi, который используется для передачи первого аргумента и вызова метод addTen:

Здесь ведется учет указателей: rbp - это базовый указатель, а rsp - указатель стека, всегда указывающий на вершину стека.

4011c0: 55       push rbp
4011c1: 48 89 e5 mov rbp,rsp

rdi содержит адрес a_something. Этот адрес теперь копируется в [rbp-0x8], а затем копируется в регистр rax.

4011c4: 48 89 7d f8 mov QWORD PTR [rbp-0x8],rdi
4011c8: 48 8b 45 f8 mov rax,QWORD PTR [rbp-0x8]

Теперь содержимое по адресу a_something копируется в регистр ecx.

4011cc: 8b 08 mov ecx,DWORD PTR [rax]

0xa - это 10 в десятичной системе счисления, и он добавляется к содержимому ecx, которое содержит значение a_something.

4011ce: 83 c1 0a add ecx,0xa

Теперь результат помещается по адресу в регистре rax:

4011d7: 89 08 mov DWORD PTR [rax],ecx

Интересно, что в сборке из вывода Clang был раскрыт макрос msg, но на выходе objdump он остался нетронутым.

.rodata

Этот раздел содержит данные только для чтения. Итак, для нас это наша строка сообщения.

.bss

Здесь хранятся неинициализированные данные.

Сегменты

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

Типы LOAD - это сегменты, загружаемые в память. Мы видим, что сегмент, содержащий наш основной код, находится в сегменте 03, состоящем из разделов .init, .plt, .text и .fini. Он настроен на чтение (R) и выполнение (E).

Наш .rodata находится в сегменте 04 - .rodata, .eh_frame_hdr, .eh_frame - и он настроен на чтение (R).

Заключение

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

использованная литература