После написания предыдущей статьи прошло чуть больше года, и пора написать вторую часть, так как здесь есть, о чём рассказать. Главное изменение в языке: в нём теперь есть intermediate representation (IR) и сразу два codegen-а: снова в x86_64 assembly, и в C. В самом же языке почти ничего не изменилось.
Компилятор и главный репозиторий: GitHub
Здесь я напишу о своём личном проекте — компиляторе к C-подобному языку. Я не являюсь профессиональным разработчиком, изучал эту тему почти самостоятельно и не читал никакие книги по написанию компиляторов (но читал по операционным системам).
Документацию к языку и к его стандартной библиотеке Altlib можно найти в репозитории. Реализация (надеюсь) полностью рабочая (по крайней мере, есть немаленький набор тестов).
Есть вспомогательный репозиторий для упрощения установки компилятора и IDE к нему: GitHub
В процессе чтения статьи вы увидите, что я сильно вдохновлялся языком Zig.
Немного теории об assembler-е/машинном коде
Есть регистры: “ячейки” памяти для наиболее быстрого доступа. Их несколько десятков.
Есть стек - доступный для записи отрезок памяти, обычно длинной в мегабайты+. Один из регистров указывает на важную точку в стеке.
Есть инструкции. Они могут в качестве аргументов принимать:
Регистры
Константы
“Effective addresses” - адреса в специфическом формате, например,
[reg + reg * const]
Память процесса состоит из сегментов. У сегментов могут быть права на запись и/или исполнение.
Инструкции, которые выполняет процессор, записаны в каком-то сегменте с правами на исполнение.
Глобальные переменные записаны в каком-то сегменте с правами на запись.
Как генерировался код раньше
Более подробно об этом было написано в предыдущей части. По сути, у меня генерируется “стековая машина”. На каждую вершину синтаксического дерева должен сгенерироваться код, при выполнении которого результат выполнения вершины будет лежать на вершине стека.
Например, генерация сложения у меня выглядит примерно так:
func generate_addition(Node *left, Node *right) { generate_node(left); output_asm("add rsp, -8"); // Чтобы результат вычисления первого аргумента не затёрся generate_node(right); output_asm("add rsp, +8"); output_asm("mov rax, [rsp - 8]"); // Здесь был сохранён первый аргумент output_asm("add rax, [rsp - 16]"); // Здесь был сохранён второй аргумент, так как стек был сдвинут output_asm("mov [rsp - 8], rax"); // Здесь мы должны сохранить результат }
Особенно интересно выглядит генерация агрегатов, которые были представлены только инстансами (объектами структуры): сначала положить каждое поле на стек с постоянным выравниванием на машинное слово, а затем “спрессовать” их.
Ниже мы узнаем, какое примерно время показывает такая реализация.
Теперь, с появлением нового “нормального” backend-а, этот backend называется x86_64_asm_legacy.
IR - intermediate representation
Базовая информация
Отличия от языка assembler-а следующие:
Регистров нет. Вместо них - бесконечное множество значений (values).
Большинство инструкций не записывают что-либо куда-либо: вместо этого они “возвращают” значение.
Значения не являются переменными (то есть, изменить их нельзя).
Например, там выглядит функция, суммирующая аргументы:
function foo(i32 %0, i32 %1) i32 { .0: %2 := %0 + %1 return %2 }
Значения обозначаются знаком процента %, а блоки точкой ..
Здесь я буду показывать куски IR-кода. Однако я их пишу в llvm-стиле, так как у меня нет вывода IR-а вообще (хотя, результат компиляции в C весьма читаем, и обычно я читал IR с него).
Из базовых инструкций у нас будут следующие:
+,-,*,/и другие арифметические и логические инструкции.Терминаторы - инструкции, завершающие блок. Они не возвращают значения.
br .a- безусловный переход на блок.a.condbr %a, .b, .c- условный переход: если условие%aне ноль, то на блок.b, иначе на блок.c.return %a- возврат из функции со значением%a.

Структура у IR-а следующая:
Есть функции. Они хранят список блоков.
Есть блоки. Они хранят список значений.
Есть значения. Они хранят аргументы, которые являются указателями на другие значения или другие блоки.
Важно: аргументы-блоки я буду рисовать оранжевыми стрелками, а аргументы-блоки у phi-nodes не буду рисовать, чтобы не загромождать рисунок.
Если сравнить IR с AST, то первый является “сплющенной” и обратно упорядоченной версией второго (то есть, в AST сложение является родителем операндов, а в IR-е сложение идёт после вычисления операндов в списке).
Вообще, если использовать goto без ограничений, то даже определение того, точно ли переменная будет объявлена до использования, не является очевидной задачей. (Я потыкал некоторые компиляторы, и они если я объявлял переменную “ниже” в коде, но использовал goto так, что точка объявления точно проходила, то получал ошибку на фронтенде. Интересно, насколько много свободы они дают при использовании goto.) В моём случае goto отсутствует, и потому возможные формы IR-а весьма ограниченны. И эта форма будет использована для, по сути, единственного псевдо-pass-а, о котором будет написано ниже.
Почему в IR-ах значения не являются переменными (такие IR-ы называются single static assignment (SSA) и такими они стали не сразу)? Для облегчения выполнения преобразований. Менять код, в котором значения меняются, очень сложно, так как есть очень много инвариантов, которые можно случайно сломать. В случае же неизменных значений, значение почти (так как есть, например, терминаторы) изоморфно инструкции, и в такой ситуации сломать почти нечего.
Пока у меня полноценных оптимизационных обходов (pass-ов) нет.
Как имитировать переменные
Для имитации работы с переменными есть две “структуры”:
Инструкции
alloca/load/store:alloca- аллоцирует место на стеке и возвращает указатель на него.load- разыменовывает указатель и возвращает его содержимое.store- сохраняет по первому аргументу указателю значение второго аргумента. Не возвращает значение.
phi-nodes - о них ниже.
Пусть мы хотим написать функцию, которая, в зависимости от значения первого аргумента, записывает по адресу второго аргумента разную константу.
void foo(int a, int *b) { if (a) { *b = a; } else { *b = 1; } }
alloca/load/store
Так это можно сделать первым способом. По сути, мы имитируем переменные ячейками, выделяемыми инструкцией alloca.
function foo(i32 %0, i32* %1) void { .0: %2 := alloca, i32 // "Аллоцировали" ячейку на стеке condbr %0, .1, .2 .1 store %2, %0 // Загрузили в ячейку число br .3 .2 store %2, 1 br .3 .3 %3 := load %2 // Достали из ячейки число store %1, %3 return }
Часто в IR-ах alloca не лежат в списках инструкций в блоках, а лежат в отдельных списках в функциях.
phi-nodes
Так это можно сделать вторым способом.
function foo(i32 %0, i32* %1) void { .0: condbr %0, .1, .2 .1 br .3 .2 br .3 .3 %2 := phi [%0, .1], [1, .2] // Если пришли из блока .1, то получаем значение %0. Если пришли из блока .2, то получаем значение 1. store %1, %2 return }
В отличии от всех других инструкций, phi-инструкция не генерирует код в месте, где она написана. Она генерирует код в самом конце блоков-источников перед прыжками: в данном случае, перед обеими инструкциями br .3. Более того, эта инструкция, как бы, не включена в последовательность инструкций в блоке (так как в этом нет смысла), она находится в отдельном списке в контексте блока. (А в некоторых IR-ах такие инструкции вообще лежат на рёбрах.)
Какой способ лучше
Работать с результатами alloca, как с регистрами, сложно, так как значение меняется, а это, как было сказано ранее, усложняет все дальнейшие алгоритмы. (В случае взятия адреса переменной &, необходимо разместить её на стек, и эти рассуждения не имеют смысла.) В случае же с phi-nodes, нет проблем всё держать в регистрах.
Поэтому alloca пытаются переписывать в phi-nodes. Для этого используются преобразования, которые называются mem-to-reg (mem2reg).
В то же время писать код через phi-nodes вручную весьма сложно (если мы генерируем IR вручную, как, например, при JIT-компиляции), и гораздо легче видеть в коде обычные “переменные”.
Как генерировать IR
Главная проблема в генерации IR-а - генерация phi-nodes для переменных. Я их генерирую одним проходом за O(количество переменных * количество блоков) (поэтому, лучше иметь много мелких функций, чем одну большую), и “алгоритм” непростой.
Начнём с генерации control flow-структур. Генерация идёт линейно. В каждый момент времени есть “текущий” блок и список инструкций в нём.
Block
Важно: не путать блок в контексте синтаксического дерева и блок в контексте IR-а.
Итак, есть текущий блок с некоторым множеством инструкций. В данном языке блок возвращает значение, и точек возврата может быть много. Значит, на конце блока должна быть phi-node, и каждый return (да, это ключевое слово делает возврат из блока, а не функции) должен класть свой аргумент и текущий IR-блок в список аргументов этой phi-node.
Логика генерации примерно такая:
void generate_block(IRBuilder *builder, ASTBlock *block) { IRBlock *block = create_block(builder); // Новый блок, в который будут вести все return. IRPhi *phi = create_phi(builder, block); // Создаём phi-node в новом блоке. Но пока этот блок не выбираем. push_phi(builder, block, phi, block->label); // Сохраняем phi-node и её блок в некий стек, по которому находящиеся в блоке return-ы будут её находить. for (ASTStatement *stmt : block) { generate_node(builder, stmt); } pop_phi(builder); // Если блок возвращает void, то здесь нужно сгенерировать безусловный прыжок, а phi-node не нужен вообще. Но этот отдельный случай показывать не будем. set_block(builder, block); // И теперь выбираем новый блок, в который будут генерироваться следующие инструкции. }
void generate_return(IRBuilder *builder, ASTReturn *return) { IRBlock *block, IRPhi *phi = find_phi(builder, return->label); // Находим соответствующую phi-node. vector_push(phi->blocks, builder->current_block); // Кладём в её аргументы текущий блок. vector_push(phi->values, generate(builder, return->value)); // И аргумент return-а. IRBr *br = create_br(builder, builder->current_block, block); // Генерируем безусловный прыжок на блок, в котором лежит phi-node. }
Поясняющая картинка.

Сгенерировали
phi-node в отдельном блоке.Вызвали генерацию вершин-детей в AST-блоке. Там нашлась вершина
return, которая сгенерировала инструкциюbr. Добавились взаимосвязи:brсодержит аргументом блок сphi, аphiсодержит аргументами блок сbr, и возвращённое значение.Продолжили генерацию вершин-детей в AST-блоке. Сгенерировался ещё один
return.
Пример (увы, так как мы пока не видели ветвления, очень глупый).
func foo(a #I) -> #V { def b := { def c := a + 2 return c } ... }
function foo(i32 %0) void { .0 %1 := %0 + 2 br .1 .1 %2 := phi [%1, .0] ... }
If/else
Снова в этом языке if возвращает значение, а значит на его конце должна быть phi-node. Логика здесь такая же, как и в случае с блоком, поэтому ограничимся картинкой.

Вызвали генерацию вершины-условия в AST.
Сгенерировали инструкцию
condbr. Также, по аналогии с блоком в предыдущем пункте, создалиphi.Сгенерировали вершину
thenв AST. Положили последнюю инструкцию и текущий блок в аргументы кphi-node.Сгенерировали вершину
elseв AST. Положили последнюю инструкцию и текущий блок в аргументы кphi-node.
Пример.
func foo(a #I) -> #V { def b := if (a - 3) { def c := a + 2 return c } else 7 ... }
function foo(i32 %0) void { .0 %1 := %0 - 3 condbr %1, .1, .3 .1 %2 := %0 + 2 br .2 .2 %3 := phi [%2, .1] // Это phi и блок сгенерировала функция generate_block. br .4 .3 br .4 .4 %3 := phi [%3, .2], [7, .3] // В качестве значений могут быть и константы. Они не привязаны к структуре и "висят в воздухе". ... }
Переменные
С циклами повременим, так как там самое страшное. Для начала нужно разобраться с переменными и научиться генерировать для них phi-nodes.
Делаем это так. Для каждого блока для каждой переменной храним соответствующее ей значение. При присваивании значение у переменной в блоке меняется, но не меняется в параллельном блоке.
func foo(a #I) -> #V { b := 0 // a :: Arg(0), b :: Const(0) eval if (a) { b := 1 // a :: Arg(0), b :: Const(1) } else { b := a + 1 // a :: Arg(0), b :: Addition(Arg(0), Const(1)) } // ??? ... }
В этом коде в каждом блоке я написал сопоставление переменная :: значение (надо ещё не забыть о том, что переменные могут иметь одинаковое имя). Когда мы генерируем if, мы сначала генерируем его ветви, а затем переходим в новый блок, где появляется возможная phi-node от if-а (но в данном примере он void) и где появляются phi-nodes от переменных.
Смотрим на два предшествующих блока и видим, что переменной a соответствует одно значение Arg(0). А вот переменной b - два разных значения. А значит, нужно для неё сделать phi-node. Поэтому вместо ??? у нас появляется следующее.
%x := phi [Const(1), ...], [Addition(...), ...] ... // a :: Arg(0), b :: %x
Обозначения здесь очень схематические и перемешанные. Но, думаю, дублировать всё IR-ом было бы не лучше.
Нужно отметить, что присваивания не заканчивают блок, а значит по мере генерации блока сопоставление переменная :: значение меняется. Здесь я писал его таким, каким оно является в конце блока, после генерации всех присваиваний.
While
Итак, мы не знаем наперёд, какие переменные изменятся и на что. Поэтому построим по phi-node для каждой переменной, предварительно объявив новый блок. Первым источником этих phi-nodes будет последний блок и значения переменных в нём. Вторым источником у phi-nodes будет блок, полученный после генерации тела цикла, и значения переменных в нём.
Пример.
func foo(a #I) -> #V { def b := 0 eval while (b < a) { b := b + a } ... }
function foo(i32 %0) void { .0 br .1 .1 %1 := phi [%0, .0], [%1, .2] %2 := phi [0, .0], [%4, .2] %3 := %2 < %0 condbr %3, .2, .3 .2 %4 := %2 + %1 br .1 .3 ... }
Здесь мы видим, что для переменной, которая не поменялась, появилась бессмысленная phi-node.
Решение проблемы довольно очевидно: удалить phi-node (а точнее, сделать её no-op), если в результате значение переменной не изменилось. Вместе с этим нужно также изменить места, где она использовалась как аргумент, записав там адрес старого значения (то есть, заменить %4 := %2 + %1 на %4 := %2 + %0), что несколько больно.
Условие удаления также может транзитивно передаться из вложенных блоков: если phi-node была удалена из внутреннего цикла и заменена, то в конец блока внешнего цикла она также дойдёт в оригинальном виде, поэтому и там будет удалена. (Что, конечно, не было бы верным, если бы были goto.)
Небольшой вопрос, ответ на который будет позже: если мы представляем lifetime значение лишь отрезком, то какой он будет у переменной, которая как-либо пересекалась с циклом?
Немного для понимания: так выглядит IR абстрактной большой функции. Но для простоты картинки я нарисовал не цикл while, а цикл do while (в первом присутствует слишком длинная стрелка направо).

Генерация C
Первой была написана генерация кода на C. Здесь проблем особо нет, и, как и было написано выше, это практически просто вывод IR-а.
Чтобы не было проблем с порядком объявления переменных, я их просто объявляю в самом начале функции. Никаких if/while не генерируется - вместо них goto (кажется, я в первый раз писал этот keyword (хотя нет, я лишь написал код, который его генерирует: моя совесть по-прежнему чиста)). При генерации alloca создаётся массив, а самому значению присваивается указатель на него. (Кстати, тот факт, что массив в C неявно преобразуется в указатель, немного попутало меня в процессе. У себя я сделал типизацию у массива как в Zig-е).
Единственной проблемой была типизация, так как компилятор меня заставлял указывать типы явно, поэтому пришлось их корректно генерировать. Но, с другой стороны, это дало читаемость.
Генерация x86_64
Как и ранее, я генерирую язык ассемблера, а не объектный файл, но здесь есть *, о которой позже.
Основная проблема - это вариативность расположения значений. Они могут быть на стеке, как и в старом backend-е, а могут быть в регистре.
Regalloc
Сейчас алгоритм regalloc-а (аллокации регистров) глупый и работает следующим образом.
Все значения делятся на две группы: те, которые живут в регистре (для простоты используются те регистры, которые не используются в инструкциях), и те, которые живут на стеке.
У каждого значения есть свой lifetime (время жизни). Так как мой IR “сплющенный”, можно представлять lifetime, как отрезок. В каждой точке каждый регистр соответствует только одному значению, и это распределение идёт жадно: в первую очередь регистры получают значения с максимальным количеством инструкций, которые их содержат как аргументы.
Псевдокод этого алгоритма такой.
void regalloc(IRBuilder *builder) { int cnt_blocks = vector_size(builder->blocks); Vector *registers_used = vector_create(cnt_blocks); // Создаёт вектор заданной длины из нулей. VectorPairs *instructions = vector_pairs_create(0); // Создаёт вектор пар. for (IRBlock *block : builder->blocks) { for (IRValue *value : block->values) { vector_pairs_push(-vector_size(value->uses), value); // value->uses - вектор значений, которые содержат value, как аргумент. } } vector_pairs_sort(instructions); for (IRValue *value : vector_pairs_second(instructions)) { int left = value->lifetime_left; // Индекс блока. int right = value->lifetime_right; int mask_free = ~0; // Изначально все регистры свободны. for (int i = left; i <= right; i++) { mask_free &= ~registers_used[i]; // Выключаем все использованные регистры на отрезке lifetime. } if (mask_free) { // Есть свободный регистр на всём отрезке - возьмём его. int idx = __builtin_ctz(mask_free); assign_reg(value, idx); for (int i = left; i <= right; i++) { registers_uses[i] |= (1 << idx); } } } }
Для работы же со значениями, живущими на стеке, есть набор функций (которые для значений, живущих в регистре, ничего не делают):
Занять регистр - значению будет сопоставлен регистр.
Переместить в регистр - то же самое, дополнительно будет сгенерирована инструкция, перемещающая значение со стека в регистр.
Освободить регистр - значение будет отвязано от регистра.
Убрать из регистра - то же самое, дополнительно будет сгенерирована инструкция, перемещающая значение из регистра в стек.
Например, генерация значение-сложения и значения-равенства будет выглядеть примерно так.
void codegen_addition(IRAddition *node) { // У инструкций mov и add хотя бы один аргумент должен быть регистром (в общем случае). Пусть это будет выходное значение. occupy_reg(node->reg); output_asm("mov ", node->reg, ", ", position(node->left)); // Пусть position проверяет положение значения и возвращает её регистр или effective address. output_asm("add ", node->reg, ", ", position(node->right)); from_reg(node->reg); // Теперь регистр можно снова использовать. При этом его значение сохранено на стек. }
void codegen_equal(IREqual *node) { output_asm("xor rax, rax"); to_reg(node->left); // На этот раз уберём (а не просто займём) в регистр один из аргументов, так как инструкция cmp тоже требует хотя бы один из аргументов в регистре. output_asm("cmp ", position(node->left), ", ", position(node->right)); free_reg(node->left); // Только освободили регистр. Никаких перемещений не нужно. output_asm("sete rax, 1"); output_asm("mov ", node, ", rax"); }
Как вычислять lifetime
Lifetime - это отрезок инструкций или блоков, в течении которых значение может быть использовано, а значит, регистр, в котором оно живёт, не должен быть использован.
На первый взгляд, чтобы вычислить lifetime, нужно взять самое левое объявление/использование значения и самое правое объявление/использование значения. Но здесь есть исключения.
Если значение является phi-node, то начало его использования это не его объявление, а конец блока-источника, так как там и будет впервые загрузка в регистр этой phi-node.
function foo(i32 %0) { .0 %1 := %0 + 1 condbr %1, .1, .2 .1 %2 := %1 * 2 br .3 /--- %3 lifetime begin .2 | br .3 | .3 | %3 := phi [%2, .1], [5, .2] | %4 := %3 - 7 \--- %3 lifetime end return %4 }
Если значение находится в цикле, то оно должно занимать свой регистр на протяжении всего цикла, чтобы не потерять его на суффиксе цикла после последнего его использования в нём.
function foo(i32 %0) { .0 %1 := %0 + 1 %2 := %0 * 2 /--- %2 lifetime begin br .1 | .1 | %3 := phi [.0, %1], [.2, %5] | condbr %1, .2, .3 | .2 | %4 := %2 + %3 | %5 := %3 - 1 | br .1 \--- %2 lifetime end .3 return %1 }
Performance testing
Для тестирования времени используются следующие шесть способов компиляции. gcc и clang запускаются с -O2.

stdlib- это маленькая стандартная библиотека, написанная на C. Скомпилирована системнымgccс-O0.altlib- это ещё более маленькая стандартная библиотека, написанная на alias-е. Скомпилированаcalias-ом.
Функции, требующие языка ассемблера (например, memcpy) у них общие.
Результаты замеров на моём компьютере сейчас следующие (все числа в секундах).
# | dfs | fft | floyd | merge_sort |
|---|---|---|---|---|
1 | 0.29 | 1.81 | 1.40 | 0.64 |
2 | 0.25 | 1.23 | 0.49 | 0.32 |
3 | 0.21 | 0.58 | 0.09 | 0.18 |
4 | 0.30 | 0.18 | 0.08 | 0.19 |
5 | 0.20 | 0.52 | 0.06 | 0.20 |
6 | 0.28 | 0.18 | 0.06 | 0.20 |
Выводы
Выводы здесь будут краткие. Обычно я изучаю выходной код и смотрю, что компилятор сделал плохо, но здесь я ограничусь лишь одним примером без комментариев.
Тест
fftпоказывает, что мой mem2reg скорее всего плохой, так как портит результаты “нормальных” компиляторов на нём в ~3 раза.В тесте
dfsиз-за линейности алгоритма по большей части замеряется генерация графа. Кроме того, так как в коде на alias-е используется линейный аллокатор, а в коде на C сложный, с возможностью деаллокации, то второй аллокатор скорее медленный, чем сложный.Тест
merge_sortпо большей части показывает более плохой regalloc, так как других недостатков в его выходном коде я не увидел.Тест
floydпросто требует много не очень сложных оптимизаций. Очень часто он просто повторно вычисляет одинаковые значения, значительно увеличивая количество IR-значений, что в добавок ухудшает regalloc.
Так выглядит код функции floyd, сгенерированный gcc.
void floyd(long n, long **a) { for (long k = 0; k < n; k++) { for (long i = 0; i < n; i++) { for (long j = 0; j < n; j++) { long x = a[i][k] + a[k][j]; if (a[i][j] > x) { a[i][j] = x; } } } } }
Подсказка: увидеть переменные i, j, k можно, посмотрев на инструкции cmp.
00000000000011d0 <floyd>: 11d0: f3 0f 1e fa endbr64 11d4: 48 85 ff test %rdi,%rdi 11d7: 7e 66 jle 123f <floyd+0x6f> 11d9: 55 push %rbp 11da: 48 8d 2c fd 00 00 00 lea 0x0(,%rdi,8),%rbp 11e1: 00 11e2: 49 89 f8 mov %rdi,%r8 11e5: 45 31 d2 xor %r10d,%r10d 11e8: 53 push %rbx 11e9: 4c 8d 5c 35 00 lea 0x0(%rbp,%rsi,1),%r11 11ee: 48 89 f3 mov %rsi,%rbx 11f1: 0f 1f 80 00 00 00 00 nopl 0x0(%rax) 11f8: 4a 8b 3c 13 mov (%rbx,%r10,1),%rdi 11fc: 49 89 d9 mov %rbx,%r9 11ff: 90 nop 1200: 49 8b 09 mov (%r9),%rcx 1203: 31 c0 xor %eax,%eax 1205: 4a 8d 34 11 lea (%rcx,%r10,1),%rsi 1209: 0f 1f 80 00 00 00 00 nopl 0x0(%rax) 1210: 48 8b 14 c7 mov (%rdi,%rax,8),%rdx 1214: 48 03 16 add (%rsi),%rdx 1217: 48 39 14 c1 cmp %rdx,(%rcx,%rax,8) 121b: 7e 04 jle 1221 <floyd+0x51> 121d: 48 89 14 c1 mov %rdx,(%rcx,%rax,8) 1221: 48 83 c0 01 add $0x1,%rax 1225: 49 39 c0 cmp %rax,%r8 1228: 75 e6 jne 1210 <floyd+0x40> 122a: 49 83 c1 08 add $0x8,%r9 122e: 4d 39 cb cmp %r9,%r11 1231: 75 cd jne 1200 <floyd+0x30> 1233: 49 83 c2 08 add $0x8,%r10 1237: 4c 39 d5 cmp %r10,%rbp 123a: 75 bc jne 11f8 <floyd+0x28> 123c: 5b pop %rbx 123d: 5d pop %rbp 123e: c3 ret 123f: c3 ret
Генерация AST
Так можно сгенерировать AST (можно генерировать и IR, но там нужен IRBuilder и всё значительно больнее) в отдельной программе на C, которое затем можно скомпилировать в объектный файл.
int main() { struct Node *tree = create_module(vector(1, create_function_definition( NULL, "foo", create_function_signature( vector(2, "a", "b" ), vector(2, create_type_int(0), create_type_int(0) ), create_type_int(0) ), create_binary_operator( NodeAddition, create_identifier("a", false), create_identifier("b", false) ), true, false ) )); struct Settings *settings = _malloc(sizeof(struct Settings)); settings->testing = false; settings->filename_compile_output = "a.out"; compile_process(tree, settings); return 0; }
Здесь интересно то, что генерация такого дерева и сам mem2reg довольно дёшевы, и можно делать JIT-компиляцию, генерируя не IR со значениями-константами и phi-nodes, а AST с нормальными переменными.
JIT-компиляция
Как выполнить JIT-компиляцию?
Попросить у ОС сегмент для записи с помощью системного вызова
mmap.Вывести в этот сегмент код. Он должен быть position independent (PIC) - все инструкции, использующие глобальные значения, должны использовать их по относительным адресам.
Сделать сегмент исполняемым с помощью системного вызова
mprotect.
Чтобы выполнить сгенерированный код, его необходимо ассемблировать (обычные ассемблеры не очень пригодны для этого, так как слишком медленные и обычно генерируют машинный код в файлы), и я попробовал это сделать с помощью fadec-а. Я пока не решил, добавлю ли я его в зависимости, или нет, поэтому этого кода в публичном репозитории нет.
Смысл такой компиляции - расположение “констант относительно генерируемого кода” не как значений, а как immediate arguments в инструкциях.
Например, пусть есть функция.
foo: mov rax, rdi add rax, [boo] ret
Если известно, что переменная boo в процессе вызовов функции foo не будет менятся, можно скомпилировать такой код.
foo: lea rax, [rdi + 0x43215] ; boo = 0x43215 ret
Более глобальный пример, это интерпретатор, где для программы для интерпретатора можно сгенерировать код, избавившись от диспетчеризации (switch-case) инструкций.
Что дальше?
Чуть позже я обозначу текущее состояние компилятора как версию 0.2.
Скорее всего, следующей целью у меня будут:
Управляемые и как можно более лёгкие оптимизационные обходы.
Усложнённые типы (можно заметить, сгенерировав code coverage, что в файле
type.cв принципе исполняется не весь код, и текущее состояние типов плохое).comptime.