tl;dr: Изучение новых значений терминов front-end и back-end

Компилятор — это просто программа, которая транслирует другие программы. Традиционные компиляторы переводят исходный код в исполняемый машинный код, понятный вашему компьютеру. (Некоторые компиляторы переводят исходный код на другой язык программирования. Эти компиляторы называются трансляторами исходного кода или транспилерами.) LLVM — это широко используемый проект компилятора, состоящий из множества модульных инструментов компилятора.

Традиционный дизайн компилятора состоит из трех частей:

  • Внешний интерфейс переводит исходный код в промежуточное представление (IR)\*. clang — это интерфейс LLVM для языков семейства C.
  • Оптимизатор анализирует IR и преобразует его в более эффективную форму. opt — это инструмент оптимизатора LLVM.
  • Бэкэнд генерирует машинный код, сопоставляя IR с целевым аппаратным набором инструкций. llc — это серверный инструмент LLVM.

*LLVM IR — это язык низкого уровня, похожий на ассемблер. Однако он абстрагируется от информации, относящейся к оборудованию.

Здравствуйте, компилятор 👋

Ниже приведена простая программа на C, которая печатает «Hello, Compiler!» в стандартный вывод. Синтаксис C удобочитаем, но мой компьютер не знает, что с ним делать. Я собираюсь пройти через три этапа компиляции, чтобы сделать эту программу машиноисполняемой.

(Вы можете представить себе программу, машущую компилятору в процессе компиляции.)

// compile_me.c
// Wave to the compiler. The world can wait.

#include <stdio.h>

int main() {
printf("Hello, Compiler!\n");
return 0;
}

Внешний интерфейс

Как я упоминал выше, clang — это интерфейс LLVM для языков семейства C. Clang состоит из препроцессора C, лексера, синтаксического анализатора, семантического анализатора и генератора IR.

  • Препроцессор C изменяет исходный код перед началом трансляции в IR. Препроцессор обрабатывает включение внешних файлов, например #include ‹stdio.h› выше. Он заменит эту строку всем содержимым файла стандартной библиотеки C stdio.h, который будет включать объявление функции printf.

Просмотрите результат шага препроцессора, запустив:

clang -E compile_me.c -o preprocessed.i
  • Лексер (или сканер, или токенизатор) преобразует строку символов в строку слов. Каждое слово или токен относится к одной из пяти синтаксических категорий: пунктуация, ключевое слово, идентификатор, литерал или комментарий.

  • Синтаксический анализатор определяет, состоит ли поток слов из правильных предложений на исходном языке. После анализа грамматики потока токенов он выводит абстрактное синтаксическое дерево (AST). Узлы в Clang AST представляют объявления, операторы и типы.

AST для compile_me.c:

  • Семантический анализатор просматривает AST, определяя, имеют ли кодовые предложения допустимое значение. На этом этапе проверяются ошибки типов. Если бы основная функция в compile_me.c вернула ноль вместо 0, семантический анализатор выдал бы ошибку, поскольку ноль не имеет типа 0. >инт.
  • Генератор IR преобразует AST в IR.

Запустите интерфейс clang на compile_me.c, чтобы сгенерировать LLVM IR:

  clang -S -emit-llvm -o llvm_ir.ll compile_me.

Основная функция в llvm_ir.ll

// llvm_ir.ll

@.str = private unnamed_addr constant [18 x i8] c"Hello, Compiler!\0A\00", align 1

define i32 @main() {
%1 = alloca i32, align 4 ; <- memory allocated on the stack
store i32 0, i32* %1, align 4
%2 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([18 x i8], [18 x i8]* @.str, i32 0, i32 0))
ret i32 0
}

declare i32 @printf(i8\*, ...)

Оптимизатор

Работа оптимизатора заключается в повышении эффективности кода на основе его понимания поведения программы во время выполнения. Оптимизатор принимает IR в качестве входных данных и создает улучшенный IR в качестве выходных данных. Инструмент оптимизатора LLVM, opt, оптимизирует скорость процессора с помощью флага -O2 (заглавная буква 0, два) и размер с помощью флага -Os (заглавная о, с).

Взгляните на разницу между IR-кодом LLVM, сгенерированным нашим интерфейсом выше, и результатом работы:

opt -O2 -S llvm_ir.ll -o optimized.ll
// optimized.ll

@str = private unnamed_addr constant [17 x i8] c"Hello, Compiler!\00"

define i32 @main() {
%puts = tail call i32 @puts(i8* getelementptr inbounds ([17 x i8], [17 x i8]* @str, i64 0, i64 0))
ret i32 0
}

declare i32 @puts(i8\* nocapture readonly)

В оптимизированной версии main не выделяет память в стеке, так как не использует никакой памяти. Оптимизированный код также вызывает puts вместо printf, поскольку ни одна из функций форматирования printf не использовалась.

Конечно, оптимизатор знает больше, чем просто знает, когда использовать puts вместо printf. Оптимизатор также разворачивает циклы и встраивает результаты простых вычислений. Рассмотрим приведенную ниже программу, которая складывает два целых числа и выводит результат.

// add.c

#include <stdio.h>

int main() {
int a = 5, b = 10, c = a + b;
printf("%i + %i = %i\n", a, b, c);
}

Вот неоптимизированный LLVM IR:

@.str = private unnamed_addr constant [14 x i8] c"%i + %i = %i\0A\00", align 1

define i32 @main() {
%1 = alloca i32, align 4 ; <- allocate stack space for var a
%2 = alloca i32, align 4 ; <- allocate stack space for var b
%3 = alloca i32, align 4 ; <- allocate stack space for var c
store i32 5, i32* %1, align 4 ; <- store 5 at memory location %1
store i32 10, i32* %2, align 4 ; <- store 10 at memory location %2
%4 = load i32, i32* %1, align 4 ; <- load the value at memory address %1 into register %4
%5 = load i32, i32* %2, align 4 ; <- load the value at memory address %2 into register %5
%6 = add nsw i32 %4, %5 ; <- add the values in registers %4 and %5. put the result in register %6
store i32 %6, i32* %3, align 4 ; <- put the value of register %6 into memory address %3
%7 = load i32, i32* %1, align 4 ; <- load the value at memory address %1 into register %7
%8 = load i32, i32* %2, align 4 ; <- load the value at memory address %2 into register %8
%9 = load i32, i32* %3, align 4 ; <- load the value at memory address %3 into register %9
%10 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([14 x i8], [14 x i8]\* @.str, i32 0, i32 0), i32 %7, i32 %8, i32 %9)
ret i32 0

Вот оптимизированный LLVM IR:

@.str = private unnamed_addr constant [14 x i8] c"%i + %i = %i\0A\00", align 1

define i32 @main() {
%1 = tail call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([14 x i8], [14 x i8]\* @.str, i64 0, i64 0), i32 5, i32 10, i32 15)
ret i32 0
}

declare i32 @printf(i8\* nocapture readonly, ...)

Наша оптимизированная основная функция — это, по сути, строки 16 и 17 неоптимизированной версии со встроенными значениями переменных. opt рассчитал добавление, поскольку все переменные были постоянными. Довольно круто, да?

Бэкенд

Бэкэнд-инструмент LLVM — llc. Он генерирует машинный код из ввода LLVM IR в три этапа:

  • Выбор инструкций — это сопоставление инструкций IR с набором инструкций целевой машины. На этом шаге используется бесконечное пространство имен виртуальных регистров.
  • Распределение регистров — это сопоставление виртуальных регистров с реальными регистрами в вашей целевой архитектуре. Мой процессор имеет архитектуру x86, которая ограничена 16 регистрами. Однако компилятор будет использовать как можно меньше регистров.
  • Планирование инструкций – это переупорядочивание операций в соответствии с ограничениями производительности целевой машины.

Выполнение этой команды создаст машинный код!

llc -o compiled-assembly.s optimized.ll
_main:
 pushq %rbp
 movq %rsp, %rbp
 leaq L_str(%rip), %rdi
 callq _puts
 xorl %eax, %eax
 popq %rbp
 retq
L_str:
 .asciz "Hello, Compiler!"

Эта программа написана на языке ассемблера x86, который представляет собой удобочитаемый синтаксис языка, на котором говорит мой компьютер. Наконец-то меня кто-то понял! 🙌

Ресурсы

  1. Разработка компилятора
  2. Начало работы с базовыми библиотеками LLVM