Гайд Анатомия C++ компилятора и оптимизация кода компилятором

kin4stat

mq-team · kin4@naebalovo.team
Автор темы
Всефорумный модератор
2,746
4,831
дело было вечером, делать было нечего
В этом гайде я постараюсь простым языком описать работу компилятора и ответить на вопросы "Кто такая эта ваша точка с запятой и почему компилятор не может поставить ее сам" и "Кто такой этот ваш линковщик и почему он ругается на какие-то символы"

Итак, начнем с начала. Посмотрим на самую первую программу на C++
C++:
#include <iostream>

int main() {
    std::cout << "Hello world!" << std::endl;
}

В начале большинства cpp файлов вы видите всякие команды начинающиеся на #
Все команды начинающиеся на #, это команды препроцессора. Но кто такой препроцессор?
Само слово намекает нам на то, что он обрабатывает наши файлы до процессирования файлов. Препроцессор это условно говоря примитивный текстовый обработчик, оперирующий токенами.
Когда вы пишите #include, препроцессор ищет файл указанный в кавычках, либо треугольных скобках, и просто вставляет его целиком в наш файл.
Именно из-за вставки файлов, компиляция сильно затягивается, т.к. компилятору приходится обрабатывать все подключенные файлы в каждом cpp
Например из таких файлов:
a.hpp:
int a = 5;
a.cpp:
#include "a.hpp"
int main() { return 0; }

Получается такой файл:
C++:
int a = 5;
int main() { return 0; }
Также у него есть команда для определения токенов:#define
Это токен может быть самостоятельным, а также зависеть от аргументов.
Всего команд у препроцессора немного, но с его помощью можно делать различные интересные штуки. И из-за того препроцессор работает с отдельными токенами, он не будет заменять тут:
C++:
#define A 5

int AbAbA = 10;
в названии AbAbA все A на 5.

Если вам интересно посмотреть, что происходит с вашим файлом после препроцессирования файла - добавьте в строку компиляции параметр /E(msvc) либо параметр -E(gnu)

После препроцессора в ход идет компилятор.
И у него внутри много различных уровней абстракций. Начнем с того, что сначала делает компилятор.
Сначала компилятор просто проходит по всему тексту нашей программы и разбирает его на токены, эту работу внутри компилятора выполняет лексер. Лексер просто разбирает текст на заранее определенные токены, сюда входят: кавычки, фигурные скобки, различные операторы сравнения, и т.д. После лексера получается набор лексем, условно говоря из
int a = 5; получается TOKEN: STRING "int"; TOKEN: STRING "a"; TOKEN: ASSIGNMENT; TOKEN: INT: 5; TOKEN: SEMICOLON
После этого в ход идет парсер. Парсер же разбивает токены на осмысленные выражения:
TOKEN: "int"; TOKEN: "a"; TOKEN: ASSIGNMENT; TOKEN: "5" -> TYPE: int; NAME: "a"; OP: ASSIGNMENT; VALUE: LITERAL: "5"; ENDOP;
Именно на этапе парсинга компилятор выявляет синтаксические ошибки в программе. А точку с запятой он не ставит из-за того, что это может привести не к тому результату, который ожидает пользователь. Например в питоне не получится написать что-то, что может быть воспринято как одно целое. В том же время, в C++ точка с запятой служит средством разделения команд, чтобы в итоге некорректное выражение слилось в одно целое.

Лексинг и парсинг выполняет любой компилятор, будь то компилятора C++, Assembler'а, или Python'а (Да-да, питон компилируется, но не в нативный код).

После парсинга, обычно получается полный граф(дерево) программы, с которым уже проще работать компилятору.

Большие компиляторы часто разделяются внутри на три этапа: Frontend, Middle-end и Backend.
Frontend выполняет специфические для языка преобразования и вычисление, после чего формируется программа на абстрактном языке внутреннего представления компилятора, который потом передается на Backend компилятора. Делается это все для упрощения процесса разработки компилятора под другие языки, так как внутреннее представление компилятора ему самому хорошо известно, и проще преобразовать язык в язык компилятора, нежели разрабатывать компилятор с нуля, не правда ли?

Само внутреннее представление компилятора, это по сути удобный для анализа ассемблер, обобщающий простейшие операции.
Frontend компилятора раскрывает специфичные для языка конструкции в простейшие операции. Например для C++ шаблоны раскрываются и превращаются в обычные функции, производится вычисление constepxr функции, преобразование лямбда функций в обычные функции, преобразование классов в простейшие структуры и т.д.
В конечном итоге специфичные для языка вещи превращаются в обобщенные простейшие операции.
Например clang - frontend для LLVM, msvc и gcc имеют свои представления внутри.
На примере clang, покажу во что превращается простейшая функции умножения и сложения чисел
C++:
int f(int a, int b) {
    return a + 2*b;
}
Код:
define i32 @f(i32 %a, i32 %b) {
; <label>:0
    %1 = mul i32 2, %b
    %2 = add i32 %a, %1
    ret i32 %2
}
Как вы видите, функция превратилась в ряд простейших операций.
Также frontend может выполнять некоторые оптимизации. Например
C++:
int a = 5 * 4 * 25;
Превращается в
C++:
int a = 500;
Или например
C++:
int f(int a, int b) {
    return a + 2*b;
}
int c = f(2, 5)
Превращается в
C++:
int a = 12;
Также для оптимизации компиляции, применяется такая техника как "предкомпилированные заголовки". Компилятор собирает такие заголовки в абстрактное представление, которое потом легко использовать ему самому, и не нужно проделывать кучу операций(лексинг, парсинг). И при включении таких заголовков в файл, у компилятора появляется уже подготовленный и частично оптимизированный кусок программы. Предкомпилированные заголовки являются "прародителем" модулей в C++20

После Frontend'а в дело вступает Middleend. Middle-end же выполняет общие оптимизации, которые всегда будут работать.
Например middle-end может распознавать вручную написанные циклы, и собирать их воедино. Либо наоборот, брать маленькие циклы и разворачивать их:
C++:
for (auto i = 0u; i < 5; i++) {
    std::cout << i << std::endl;
}
Может превратиться в
C++:
std::cout << 0 << std::endl;
std::cout << 1 << std::endl;
std::cout << 2 << std::endl;
std::cout << 3 << std::endl;
std::cout << 4 << std::endl;
Также на Middle end компилятора может производится оптимизация циклов. Например из цикла в 16 итераций может стать цикл на 4 итерации. Также на middle end обнаруживается "мертвый" код(недостижимый, пример - return 0; return 5;, в данном случае return 5 будет просто выкинут) и встраиваются функции на место вызова. Например
C++:
#include <iostream>

void f() {
    std::cout << "Hello world!" << std::endl;
}

int main() {
    f();
}
Преобразуется в

C++:
#include <iostream>

int main() {
    std::cout << "Hello world!" << std::endl;
}
После Middle end идет Back end, который уже преобразует внутреннее представление в код под конкретную платформу. Иногда эта стадия также включает в себя перевод абстрактного кода в текст программы на ассемблере, которая потом собирается программой ассемблером.
Тут производятся оптимизации под конкретную платформу, в т.ч. различные трюки с математикой и битовыми операциями, перестановка инструкций местами(да-да, это влияет на производительность), оптимизация последовательных операций с памятью(векторизация) и прочие. Какие-то простые примеры будет привести сложно, но вот что-то простое:
(Приведение нечетного числа в четное)
C++:
int a;
std::cin >> a;
if (a % 2) {
    ++a;
}
std::cout << a << std::endl;
Будет преобразовано в
C++:
int a;
std::cin >> a;
a |= 1;
std::cout << a << std::endl;
Операция деления и инкремента преобразовалась в одну битовую операцию. Тяжелая операция деления и последующего инкремента была заменена на почти бесплатную операцию битового ИЛИ.

После Backend уже создается выходный файл, и он называется объектным файлом.

Каждый CPP файл обрабатывается компилятором независимо и отдельно, за счет этого можно компилировать несколько файлов одновременно, но это накладывает некоторые ограничения на программиста. каждая единица компиляции CPP файла называется единицей трансляции и на английском зовется Translation Unit(Сокращенно TU). В итоге каждый TU порождает за собой объектный файл.

И на этапе компиляции практически неважно что мы собираем. Будь то библиотека или исполняемый файл.
После создания объектных файлов кто-то должен собрать их воедино. Этим занимается линковщик(или компоновщик, по английски - Linker).
Раньше Linker'ы просто собирали объектные файлы воедино, и ничего более не делали. Но со временем придумали такую вещь, как Link Time Optimization(LTO - Оптимизация времени линковки). Т.к. компилятор собирает каждый cpp файл независимо, для него не видны функции из других cpp файлов, и он просто на слово верит что такая функция есть и помечает ее как "Нужно сюда поставить реальную функцию". Но компоновщик видит все "CPP файлы" сразу, и по сути может выполнять оптимизации на основе межмодульного взаимодействия(например встраивание функции из других TU). После всех этих оптимизаций компоновщик подставляет вместо меток "нужно сюда поставить реальную функцию" реальные функции(в терминологии компоновщика это все называется символами). Но когда компоновщик видит метку "Сюда нужна реальная функция" и не находит эту самую "реальную функцию"(символ) он выдает ошибку "Ссылка на неразрешенный символ XXX" aka "Undefined reference to XXX" .потом он вставляет всякие заголовки исполняемых файлов, чтобы операционная система понимала что это за файл, как именно его нужно запускать и прочее. И вы получаете готовый файл.

И уже на этапе линковки важно что именно вы хотите получить из собранных объектных файлов. Если это статическая библиотека, то компоновщик по сути просто склеивает объектные файлы. Если это исполняемый файл, то компоновщик собирает в кучку все объектные файлы и помечает для OS его как "исполняемый", а также помечает системе, куда нужно передать управление при запуске файла. А динамические библиотеки(по крайней мере на винде) от исполняемых файлов практически не отличаются.
 
Последнее редактирование:

kin4stat

mq-team · kin4@naebalovo.team
Автор темы
Всефорумный модератор
2,746
4,831
Отдельно от поста добавлю:
Frontend также полагается на нормы языка программирования и его правила и использует их в целях оптимизации. Такой простор для оптимизаций на языке стандарта C++ называется Undefined Behaviour aka UB. На основе этих правил компилятор имеет право не учитывать крайние ситуации, и полагается на определение "Программист умный и никогда не напишет нерабочий код"