Как же исходный код превращается в бинарный файл, который потом исполняется на компьютере? Не нашёл ни одной статьи, которая описывала бы полный процесс от начала до конца, поэтому я написал данный материал.
Как оказалось, не всё так трудно, как мне изначально казалось. Я понимаю, что в настоящих больших компиляторах всё гораздо сложнее, но это не меняет принципа по которым они строятся и работают.
Этапы
1. Lexing
На этом этапе исходный код в виде строки разделяется на отдельные части, то есть токены. Этот этап – самый простой во всём процессе компиляции.
Вход
let a = 10 + 2 if a > 8 then debug "A больше 8" else debug "А либо меньше, либо равно 8" end
Выход
[ Let, Identifier("a"), Equal, Integer(10), Plus, Integer(2), If, Identifier("a"), Greater, Integer(8), Then, Debug, String("A больше 8"), Else, Debug, String("А либо меньше, либо равно 8"), End, ]
2. Parsing
Здесь поток токенов объединяется в AST или абстрактное синтаксическое дерево. В этом дереве содержится вся информация об исходном коде в структурированном виде, удобным для обработки и анализа. Например, с его помощью можно проверять корректность типов переменных.
[ Let { identifier: "a", value: Binary(Add, Integer(10), Integer(2)), }, If { condition: Binary(Greater, Identifier("a"), Integer(8)), then: [ Debug(String("A больше 8")), ], else_: [ Debug(String("А либо меньше, либо равно 8")), ], }, ]
3. Промежуточное представление (IR)
AST преобразуется в низкоуровневые инструкции, которые не зависят от конкретной архитектуры. Это удобно, так как упрощает поддержку большого количества архитектур и процессоров.
В компиляторах Rust и Clang в качестве промежуточного представления используется LLVM IR, так как его экосистема берёт на себя многие оптимизации, и компилирование в ассемблерный код для разных платформ как X86, ARM и так далее.
Граф потока управления (CFG)
Сначала, для того, чтобы избавится от условных конструкций как if и match, мы разделяем входное дерево на отдельные блоки, которые не содержат в себе условий, и дальше связываем их, описывая переходы между ними.
Блоки, не содержащие условий
{ 0: [ Let { identifier: "a", value: Binary(Add, Integer(10), Integer(2)), }, ], 1: [ Debug(String("A больше 8")), ], 2: [ Debug(String("А либо меньше, либо равно 8")), ], 3: Empty, }
Блок
Empty
– это пустой блок, который не содержит в себе инструкций, и служит только для удобства построения CFG.И условные переходы между блоками
{ 0: Branch { # переход с условием condition: Binary(Greater, Identifier("a"), Integer(8)), true_: 1, false_: 2, }, 1: Direct(3), # прямой переход без условия 2: Direct(3), }
Трёхадресный код (3AC)
Состоит из низкоуровневых инструкций максимально приближенных к нативному ассембли-коду.
Первый блок
[ Label(0), LoadInteger { to: 0, value: 10 }, LoadInteger { to: 1, value: 2 }, Add { to: 2, left: 0, right: 1 }, Set { identifier: "a", from: 2 }, Get { to: 3, from: "a" }, LoadInteger { to: 4, value: 8 }, Greater { to: 5, left: 3, right: 4 }, JumpIf { condition: 5, label: 1 }, Jump(2),
Второй
Label(1), LoadString { to: 6, value: "A больше 8" }, Debug { value: 6 }, Get { to: 7, from: "a" }, LoadInteger { to: 8, value: 8 }, Greater { to: 9, left: 7, right: 8 }, JumpIf { condition: 9, label: 3 }, Jump(2),
Третий
Label(2), LoadString { to: 10, value: "А либо меньше, либо равно 8" }, Debug { value: 10 },
И последний, пустой блок
Label(3), ]
Или в виде псевдо-кода
@0: #0 = 10 #1 = 2 #2 = add #0 #1 $a = #2 #3 = $a #4 = 8 #5 = gt #3 #4 jump @1 if #5 jump @2 @1: #6 = "A больше 8" debug #6 #7 = $a #8 = 8 #9 = gt #7 #8 jump @3 if #9 jump @2 @2: #10 = "А либо меньше, либо равно 8" debug #10 @3:
4. Ассембли
Далее, каждая 3AC инструкция конвертируется в одну или несколько ассемблерных инструкций, которые уже напрямую выполняются на процессоре без какой-либо прослойки.
section .data str_0: db "A больше 8", 0 str_1: db "А либо меньше, либо равно 8", 0
Строки будут записаны вместе с файлом как его часть, то есть они не будут аллоцированны динамически во время выполнения.
section .bss a: resq 1
Мы будем хранить переменную в секции
.bss
, так как в нашей программе одна зона видимости. В настоящих компиляторах переменные обычно хранятся на стеке, или вовсе в регистрах в зависимости от степени оптимизации.section .text global _start _start:
Делаем
_start
глобально видимым для того, чтобы линкер смог собрать бинарный файл.L0: mov rax, 10 mov rbx, 2 mov rcx, rax add rcx, rbx mov [a], rcx
a
=rcx
=rax + rbx
=10 + 2
=12
.mov rax, [a] mov rbx, 8 cmp rax, rbx mov rcx, 0 setg cl
rcx
=rax > rbx
=a > 8
=1
то есть true.cmp rcx, 1 je L1 jmp L2
Если
rcx
= 1, то есть true, то переходим вL1
, иначе – вL2
.L1: mov rax, str_0 call debug
debug
– это какая-то функция, которая печатает строки в консоль. В целях соблюдения компактности, я не стал её включать в код. Регистрrax
– первый аргумент.mov rax, [a] mov rbx, 8 cmp rax, rbx setg rcx cmp rcx, 1 je L3 jmp L2 L2: mov rax, str_1 call debug
L2
– начало блока else.L3: mov rax, 60 mov rdi, 0 syscall
Выходим из программы, производя системный вызов (syscall). В
rax
находится номер вызова –60
, то есть выход (SYS_exit). А вrdi
лежит статус завершения программы, в данном случае0
, то есть успешное завершение.
Полезное
- Классика –Crafting Interpreters
- Исходники компилятора Rust
- BabyGo – маленький компилятор для Go
- Серия видео по разработке Porth с нуля
- Мой блог про программирование и не только
Заключение
Надеюсь вам понравилась эта статья! Она написана на основе моего хобби-компилятора, поэтому если у вас есть желание внести свою лепту в проект – отправляйте пул-реквест в репозиторий!