Все статьи Стековые и регистровые машины
Машинный код (байткод) всегда предназначен для исполнения каким-либо процессором, физическим либо виртуальным. Процессоры (машины) могут быть стековыми либо регистровыми:
- Стековая машина для вычислений использует только стек, постоянно добавляя на него новые значения либо снимая старые, что приводит к непрерывному изменению вершины стека.
- Регистровая может использовать стек, но кроме него использует также независимые регистры, распределяя и переиспользуя регистры для последовательных вычисления.
В стековом байткоде виртуальной машины инструкции короче, а сам подход позволяет написать компилятор значительно легче. Регистровый позволяет проводить операции с памятью намного эффективнее, особенно с учётом современных процессоров, которые практически всегда являются регистровыми с программным стеком в оперативной памяти (более медленной, чем регистры).
Дерево выражений
В большинстве языков символ бинарного оператора ставится между его операндами, например: x + y * z + u
. Порядок выполнения операций определяется неявным приоритетом операторов и явно расставленными скобками. Расставим все скобки явно: ((x + (y * z)) + u)
(мы пренебрегли правилом ассоциативности, по которому (a + b) + c = a + (b + c)
).
После явной расстановки скобок можно нарисовать эквивалентное дерево, при обходе которого слева направо вычисления будут выполнены корректно.
В BNF нотации простейшая грамматика выражений выглядит так:
expression ::= add_sub_expr
add_sub_expr ::= mul_div_expr '+' add_sub_expr
| mul_div_expr '-' add_sub_expr
| mul_div_expr
mul_div_expr ::= atom_expr '*' mul_div_expr
| atom_expr '/' mul_div_expr
| atom_expr '%' mul_div_expr
| atom_expr
atom_expr ::= [0-9]+
Трансляция дерева в инструкции стековой машины
Если у вас есть построенное дерево выражения, вы можете выполнить трансляцию с помощью обхода дерева слева направо в глубину:
- каждый узел со значением транслируется в команду push
- каждый узел с операцией транслируется в команду этой операции, которая возьмёт операнды со стека
push x # Занести на стек x
push y # Занести на стек y
push z # Занести на стек z
multiply # Снять 2 последних значения со стека и перемножить, результат положить в вершину
add # Снять 2 последних значения со стека и сложить, результат положить в вершину
push u # Занести на стек u
add # Снять 2 последних значения со стека и сложить, результат положить в вершину
Если теперь выполнить инструкции, то на стеке останется одно число — результат арифметической операции
Трансляция дерева в инструкции регистровой машины
Мы покажем способ на примере ассемблера виртуальной регистровой машины LLVM-IR, в котором можно использовать сколько угодно регистров — для этого нужно просто связывать каждое значение с новым имененем, а в остальном использовать те же самые принципы, что и для стековой (более того, в реализации кодогенератора вам наверняка пригодится структура данных “стек” либо рекурсия):
# Исходник на компилируемом языке
function sqr(x Number) Number
return x * x
end
# Результат в виртуальном ассемблере LLVM-IR
define double @sqr(double %x) {
entry:
%x1 = alloca double # выделяем место для копирования параметра
store double %x, double* %x1 # копируем параметр в переменную
%x2 = load double, double* %x1 # вместо push x
%x3 = load double, double* %x1 # вместо push x
%multmp = fmul double %x2, %x3 # вместо mul
ret double %multmp # возвращаем результат
}
Если число регистров в процессоре ограничено, то придётся распределять свободные регистры по выражениям (хотя проще воспользоваться стековым методом). Хороший алгоритм распределения регистров выходит за рамки этой статьи, но в грубом виде он мог бы выглядеть так: у вас есть набор реальных регистров, имена которых служат ключом в ассоциативном массиве (например, в
std::unordered_map
), а значение — это указатель на объект Value, временно занимающий регистр (например,shared_ptr<Value>
). Этот ассоциативный массив используется для учёта занятых регистров. Если регистры кончились, вы можете выдавить несколько значений на стек (добавив в промежуточный код соответствующие команды копирования), а освободившиеся регистры использовать.
Упражнения
- Напишите программу, которая выполняет разбор арифметического выражения в структуру данных “дерево”, а затем обходит дерево и генерирует линейный список простейших строковых команд работы со стеком:
push
,add
(добавление),sub
(вычитание),mul
(умножение),div
(деление),mod
(деление по остатку). Вы можете взять за основу готовый пример калькулятора на рекурсивном спуске. - Добавьте к предыдущей программе функцию, которая получает на вход линейный список строковых команд работы со стеком, и выполняет их интерпретацию, используя массив (или
std::vector
) в качестве стека вычислений. После выполнения вычислений программа должна выдать распечатку оставшихся на стеке значений. Должны обрабатываться ситуации stack overflow и stack underflow.