Написание компилятора C в 500 строк Python

—

от автора

Несколько месяцев назад мне захотелось написать компилятор C на 500 строк Python. Сложно ли это? О да, даже если отказаться от многих функций. Но, в то же время, это ужасно интересно, а результат оказался на удивление функциональным и несложным для понимания!

Кода получилось слишком много, чтобы разобрать его полностью в одной статье, поэтому тут будет просто обзор решений, принятых в ходе работы, фрагментов, которые пришлось вырезать, и общей архитектуры компилятора. Это должно сделать код более доступным.

Решения, решения

Первым и наиболее важным решением стало то, что компилятор будет однопроходным. 500 строк — слишком мало для определения и преобразования абстрактного синтаксического дерева! Что это значит?

Большинство компиляторов — сплошная возня с синтаксическими деревьями

Большинство внутренних компонентов компилятора выглядит примерно так:

кодовые точки идут по дороге из желтого кирпича, преобразуются в токены, затем поклоняются крупнейшему в мире Хомскому, чтобы стать синтаксическими деревьями, а затем разрываются на куски кодогенерирующей гидрой для создания машинных инструкций

Токены лексируются, затем анализатор обрабатывает их и строит маленькие синтаксические деревья:

# hypothetical code, not from anywhere  def parse_statement(lexer) -> PrettyLittleSyntaxTree:      ...      if type := lexer.try_next(TYPE_NAME):          variable_name = lexer.next(IDENTIFIER)          if lexer.try_next("="):              initializer = parse_initializer(lexer)          else:              initializer = None          lexer.next(SEMICOLON)          return VariableDeclarationNode(              type = type,              name = variable_name,              initializer = initializer,          )      ...  # much later...  def emit_code_for(node: PrettyLittleSyntaxTree) -> DisgustingMachineCode:      ...      if isinstance(node, VariableDeclarationNode):          slot = reserve_stack_space(node.type.sizeof())          add_to_environment(node.name, slot)          if node.initializer is not None:              register = emit_code_for(node.initializer)              emit(f"mov {register}, [{slot}]")      ...

Есть два прохода: сначала синтаксический анализ выстраивает синтаксическое дерево, затем второй проход пережёвывает это дерево и превращает его в машинный код. Это действительно полезно для большинства компиляторов! Оно разделяет процесс синтаксического анализа и создание кода, поэтому каждый из них может развиваться независимо. Это также означает, что вы можете преобразовать синтаксическое дерево перед его использованием для генерации кода, например, применив к нему оптимизацию. Фактически, большинство компиляторов имеют несколько уровней «промежуточных представлений» между синтаксическим деревом и генератором кода.

Это действительно круто, бест практис и всё такое, но требует чертовски много кода! Поэтому мы так делать не можем.  

Вместо этого будем делать однопроходный компилятор: генерация кода происходит прямо в процессе синтаксического анализа. Мы чуть-чуть анализируем, выдаём немного кода, анализируем ещё немного, выдаём ещё чуть-чуть кода. Например, вот реальный код компилятора С500 для анализа префикса ~op:

# lexer.try_next() checks if the next token is ~, and if so, consumes  # and returns it (truthy)  elif lexer.try_next("~"):      # prefix() parses and generates code for the expression after the ~,      # and load_result emits code to load it, if needed      meta = load_result(prefix())      # immediately start yeeting out the negation code!      emit("i32.const 0xffffffff")      emit("i32.xor")      # webassembly only supports 32bit types, so if this is a smaller type,      # mask it down      mask_to_sizeof(meta.type)      # return type information      return meta

Обратите внимание: здесь нет ни синтаксических деревьев, ни PrefixNegateOp узлов. Видим какие-то токены и тут же выплёвываем соответствующие инструкции.

Возможно, вы заметили, что эти инструкции — WebAssembly, поэтому…

Зачем использовать WebAssembly

… поэтому было решено сделать компилятор ориентированным на WebAssembly. Честно говоря, не знаю, почему я это сделала, легче от этого не стало — наверное, мне просто было любопытно. WebAssembly — действительно странное решение, особенно для C. Помимо ряда побочных проблем (например, я надолго зависла, пока не осознала, что WebAssembly v2 сильно отличается от WebAssembly v1), сам набор инструкций тоже странный.

Во-первых, нет оператора goto. Вместо этого у вас есть блоки — структурированная сборка, только представьте себе! — и инструкции «разрыва», которые переходят либо в начало, либо в конец определённого уровня вложенности блока. Для if и while это было несущественно, а в процессе реализации for я прокляла всё на свете, но об этом позже.

Кроме того, в WebAssembly нет регистров, есть стек и стековая машина. Поначалу вы можете сказать: да это же круто! Для языка C нужен стек! Мы можем просто использовать стек WebAssembly в качестве стека C! А вот и нет. Вы не можете брать ссылки на стек WebAssembly. Поэтому нам в любом случае придётся поддерживать собственный стек в памяти, а затем включать и выключать shuffle из стека параметров WASM.

В итоге получилось немного больше кода, чем было бы при более нормальной ISA, такой как x86 или ARM. Но это было интересно! И теоретически вы можете запустить код, скомпилированный c500 с помощью браузера, хотя я не пробовала (я использую wasmerCLI).

Обработка ошибок

Такого нет в принципе. Есть функция die, которая вызывается, когда происходит что-то странное. Она выводит трассировку стека компилятора, и если вам повезет, вы получите номер строки и расплывчатое сообщение об ошибке.

------------------------------    File "...compiler.py", line 835, in <module>      compile("".join(fi))  # todo: make this line-at-a-time?    File "...compiler.py", line 823, in compile      global_declaration(global_frame, lexer)    <snip>    File "...compiler.py", line 417, in value      var, offset = frame.get_var_and_offset(varname)    File "...compiler.py", line 334, in get_var_and_offset      return self.parent.get_var_and_offset(name)    File "...compiler.py", line 336, in get_var_and_offset      die(f"unknown variable {n}", None if isinstance(name, str) else name.line)    File "...compiler.py", line 14, in die      traceback.print_stack()  ------------------------------  error on line 9: unknown variable c

Это вам не компилятор Rust 🙂

От чего отказаться?

Наконец, мне пришлось решить, от каких функций отказаться, поскольку уместить весь C в 500 строк было просто невозможно (уж извините!). Я решила, что мне нужна действительно хорошая выборка функций, которая позволит проверить возможности моего подхода к реализации в целом — например, если бы я отказалась от типа данных «указатели», я могла бы просто обойтись без стека параметров WASM и значительно всё упростить, но это было бы читерством.

В итоге я реализовала следующие функции:

  • арифметические операции и бинарные операторы с надлежащим приоритетом

  • типы int, short и char

  • строковые константы (с escapes)

  • указатели (любого количества уровней), включая правильную арифметику указателей (приращение на int*4)

  • массивы (только одноуровневые, не int[][])

  • функции

  • Определения типов typedefs (и lexer hack!)

Примечательно, что он не поддерживает:

  • структуры 🙁 Это было бы возможно, если бы было больше кода. У меня даже были основы, но я просто не смогла их втиснуть

  • enums (перечисления)/unions (объединения)

  • директивы препроцессора (это само по себе уже может занять 500 строк…)

  • плавающая запятая. Тоже можно реализовать, wasm_type материал есть, но, опять же, просто не смог его втиснуть

  • 8-байтовые типы ( long/long long или double)

  • некоторые другие мелочи, такие как операторы pre/post increments, инициализация на месте и т. д., которые просто не совсем подходили

  • любая стандартная библиотека или ввод-вывод, который не возвращает целое число из main()

  • выражения приведения

Компилятор проходит 34 из 220 тестов в c-testsuite. Что еще важнее, он может успешно скомпилировать и запустить следующую программу:

int swap(int* a, int* b) {    int t;    t = *a; *a = *b; *b = t;    return t;  }  int fib(int n) {    int a, b;    for (a = b = 1; n > 2; n = n - 1) {      swap(&a, &b);      b = b + a;    }    return b;  }  int main() {    return fib(10); // 55  }

Ну всё, хватит о решениях, давайте перейдем к коду!

Типы помощников

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

Emitter (compiler.py:21)

Это одноэлементный помощник для создания красиво отформатированного WebAssembly кода.

WebAssembly, по крайней мере текстовый формат, форматируется как s-выражения, но отдельные инструкции не нужно заключать в круглые скобки:

(module    ;; <snip...>    (func $swap      (param $a i32)      (param $b i32)      (result i32)      global.get $__stack_pointer ;; prelude -- adjust stack pointer      i32.const 12      i32.sub      ;; <snip...>    )  )

Emitter просто помогает создавать код с красивыми отступами, чтобы его было легче читать. 

StringPool ( compiler.py:53 )

StringPool хранит все строковые константы, чтобы их можно было расположить в непрерывной области памяти, и передаёт в нее адреса для использования генератором кода. Когда вы пишете char *s = «abc» в c500, на самом деле происходит следующее:

  1. StringPool добавляет нуль-терминатор

  2. StringPool проверяет, хранит ли он уже «abc», и если да, просто возвращает этот адрес

  3. В противном случае StringPool добавляет его в словарь вместе с базовым адресом + общей длиной сохраненных на данный момент байтов (адрес этой новой строки в пуле).

  4. StringPool возвращает этот адрес

  5. Когда весь код завершен, мы создаем раздел rodata с гигантской объединенной строкой, созданной StringPool, хранящейся по базовому адресу пула строк (задним числом делая все переданные адреса StringPool действительными).

Lexer ( compiler.py:98 )

Класс Lexer сложен, потому что и лексирование C сложное ( (\\([\\abfnrtv’»?]|[0-7]{1,3}|x[A-Fa-f0-9]{1,2})) это настоящее регулярное выражение в этом коде для escape-символов), но концептуально всё просто: лексер идентифицирует токен в текущей позиции. Вызыватель (the caller) может просмотреть этот токен или использовать next, чтобы лексер двигался дальше, «потребляя» этот токен. Он также может использовать try_next для условного продвижения, только если следующий токен определенного типа — по сути, try_next это сокращение для выражения if self.peek().kind == token: return self.next().

Есть некоторая дополнительная сложность из-за так называемого «Lexer hack». По сути, при анализе C вы хотите знать, является ли что-то именем типа или именем переменной (потому что контекст имеет значение для компиляции определенных выражений), но между ними нет синтаксического различия: int int_t = 0; для C абсолютно корректен, как и typedef int int_t; int_t x = 0;.

Чтобы узнать, является ли произвольный токен int_t именем типа или именем переменной, нам нужно передать информацию о типе со стадии синтаксического анализа/генерации кода обратно в лексер. Это огромная проблема для обычных компиляторов, которые хотят сохранить свои модули лексера, парсера и кодогенератора чистыми и отделёнными, но на самом деле для нас это не очень сложно! Я объясню это подробнее, когда дойдём до раздела typedef, но в основном мы просто сохраняем types: set[str], Lexer и при лексировании проверяем, есть ли токен в этом наборе, прежде чем присваивать ему тип токена:

if m := re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*", self.src[self.loc :]):      tok = m.group(0)      ...      # lexer hack      return Token(TOK_TYPE if tok in self.types else TOK_NAME, tok, self.line)

CType ( compiler.py:201 )

Это просто класс данных для представления информации о типе C, как если бы вы писали int **t или short t[5] или char **t[17], за вычетом t.

Там содержится:

  • имя типа (с разрешенными определениями типов), например int или short

  • уровень указателя ( 0= не указатель, 1= int *t, 2= int **t и т. д.)

  • размер массива ( None= не массив, 0= int t[0], 1= int t[1] и т. д.)

Примечательно, что этот тип поддерживают только одноуровневые массивы, а не вложенные массивы, такие как int t[5][6].

FrameVar и StackFrame ( compiler.py:314 )

Эти классы обрабатывают наши фреймы стека C. Поскольку вы не можете использовать ссылки на стек WASM, приходится вручную обрабатывать стек C.

Чтобы настроить стек C, в, __main__, устанавливается глобальная переменная __stack_pointer, а затем каждый вызов функции уменьшает её на столько, сколько функции требуется для своих параметров и локальных переменных. Это рассчитывает экземпляр StackFrame данной функции.

Я более подробно расскажу о том, как работает это вычисление, когда мы перейдём к анализу функций, но, по сути, каждый параметр и локальная переменная получают слот в этом пространстве стека и увеличиваются (и, следовательно, происходит смещение StackFrame.frame_size следующей переменной) в зависимости от размера. Смещение, информация о типе и другие данные для каждого параметра и локальной переменной хранятся в экземпляре FrameVar в StackFrame.variables в порядке объявления.

ExprMeta ( compiler.py:344 )

Этот последний класс данных используется для отслеживания того, является ли результат выражения значением или местом. Нам необходимо учитывать это различие, чтобы по-разному обрабатывать определённые выражения в зависимости от того, как они используются.

Например, если у вас есть переменная x типа int, её можно использовать двумя способами:

  1. Чтобы продолжить вычисление, выражению x + 1 требуется значение x, например, 1

  2. А выражению &x требуется адрес x , скажем 0xcafedead

Когда мы анализируем выражение x, мы можем легко получить адрес из фрейма стека:

# look the variable up in the `StackFrame`  var, offset = frame.get_var_and_offset(varname)  # put the base address of the C stack on top of the WASM stack  emit(f"global.get $__stack_pointer")  # add the offset (in the C stack)  emit(f"i32.const {offset}")  emit("i32.add")  # the address of the variable is now on top of the WASM stack

Но что теперь? Если мы загрузим этот адрес по i32., чтобы получить значение, то у &x не будет возможности получить адрес. Но если мы его не загрузим, x + 1 попытается прибавить к адресу единицу, в результате вместо 2 получится 0xcafedeae!

Вот тут-то и выходит на сцену ExprMeta: мы оставляем адрес в стеке и возвращаем ExprMeta указывающее, что это — то самое место :

return ExprMeta(True, var.type)

Затем, для операций типа +, которые всегда хотят работать со значениями, а не с местами, есть функция load_result, которая превращает любые места в значения:

def load_result(em: ExprMeta) -> ExprMeta:      """Load a place `ExprMeta`, turning it into a value      `ExprMeta` of the same type"""      if em.is_place:          # emit i32.load, i32.load16_s, etc., based on the type          emit(em.type.load_ins())      return ExprMeta(False, em.type)  ...  # in the code for parsing `+`  lhs_meta = load_result(parse_lhs())  ...

Между тем, операция типа & просто не загружает результат, а вместо этого оставляет адрес в стеке: таким образом, & — это пустая операция в нашем компиляторе, поскольку она не генерирует никакого кода!

if lexer.try_next("&"):      meta = prefix()      if not meta.is_place:          die("cannot take reference to value", lexer.line)      # type of &x is int* when x is int, hence more_ptr      return ExprMeta(False, meta.type.more_ptr())

Также обратите внимание: несмотря на то, что это адрес, результат & не является местом! (Код возвращает ExprMeta со значением is_place=False) Результат & следует рассматривать как значение, поскольку к адресу &x + 1 следует добавить 1 ( или даже sizeof(x)). Вот почему нам нужно различать места и значения, поскольку просто «быть адресом» недостаточно, чтобы знать, следует ли загружать результат выражения.

Ладно, хватит о вспомогательных классах. Перейдем к сути кодегена!

Парсинг и генерация кода

Общий поток управления компилятором выглядит следующим образом:

Синие прямоугольники — основные функции компилятора __main__— , compile(), global_declaration(), statement()и expression(). 

Длинная цепочка квадратов внизу показывает приоритет оператора — однако большинство этих функций автоматически генерируются функцией более высокого порядка!

Я по очереди пройдусь по синим квадратам и про каждый расскажу что-нибудь интересное.

__main__ ( compiler.py:827)

Он довольно короткий и скучный. Вот он полностью:

if __name__ == "__main__":     import fileinput      with fileinput.input(encoding="utf-8") as fi:         compile("".join(fi))  # todo: make this line-at-a-time?

Очевидно, я так и не закончила это TODO! Единственная действительно интересная штука тут — это модуль fileinput, о котором вы, возможно, не слышали. Из документации модуля:

Типичное использование:

import fileinput  for line in fileinput.input(encoding="utf-8"):      process(line)

При этом происходит перебор строк всех файлов, перечисленных в sys.argv[1:], если список пуст, по умолчанию используется sys.stdin. Если имя файла равно «-», оно также заменяется на sys.stdin, а необязательные аргументы mode и openhook игнорируются. Чтобы указать альтернативный список имён файлов, передайте его в качестве аргумента функции input(). Также допускается одно имя файла.

Технически это означает, что поддерживается несколько файлов! (Если вы не против, что они все объединены и имеют перепутанные номера строк 🙂 fileinput на самом деле довольно сложен и работает по методу filelineno(), я просто не использовала его из соображений экономии места.)

compile() (compiler.py:805)

compile() — это первая интересная функция из описанных тут, и она достаточно короткая, чтобы я могла вставить её целиком:

def compile(src: str) -> None:      # compile an entire file      with emit.block("(module", ")"):          emit("(memory 3)")          emit(f"(global $__stack_pointer (mut i32) (i32.const {PAGE_SIZE * 3}))")          emit("(func $__dup_i32 (param i32) (result i32 i32)")          emit("  (local.get 0) (local.get 0))")          emit("(func $__swap_i32 (param i32) (param i32) (result i32 i32)")          emit("  (local.get 1) (local.get 0))")          global_frame = StackFrame()          lexer = Lexer(src, set(["int", "char", "short", "long", "float", "double"]))          while lexer.peek().kind != TOK_EOF:              global_declaration(global_frame, lexer)          emit('(export "main" (func $main))')          # emit str_pool data section          emit(f'(data $.rodata (i32.const {str_pool.base}) "{str_pool.pooled()}")')

Эта функция обрабатывает создание прелюдии уровня модуля.

Сначала мы выдаём прагму, позволяющую виртуальной машине WASM зарезервировать 3 страницы памяти ( (memory 3)), и устанавливаем указатель стека так, чтобы он начинался в конце этой зарезервированной области (он будет расти вниз).

Затем мы определяем двух помощников, которые будут взаимодействовать со стеком __dup_i32 и __swap_i32. Если вы когда-нибудь использовали Forth, то уже знакомы с ними: dup дублирует элемент на вершине стека WASM ( a — a a), а  swap меняет положение двух верхних элементов стека WASM ( a b — b a) .

Затем мы инициализируем фрейм стека для хранения глобальных переменных, инициализируем лексер со встроенными именами типов для Lexer Hack и обрабатываем глобальные объявления, пока они не закончатся!

Наконец, мы экспортируем main и сбрасываем пул строк.

global_declaration() (compiler.py:743)

Эта функция слишком длинная, чтобы показать её целиком, но сигнатура выглядит так:

def global_declaration(global_frame: StackFrame, lexer: Lexer) -> None:      # parse a global declaration -- typedef, global variable, or function.      ...

Она обрабатывает определения типов, глобальные переменные и функции.

Typedefs — это круто, поскольку именно здесь происходит Lexer hack!

if lexer.try_next("typedef"):      # да, `typedef int x[24];` корректно (но безумно) c      type, name = parse_type_and_name(lexer)      # lexer hack!      lexer.types.add(name.content)      typedefs[name.content] = type      lexer.next(";")      return

Мы повторно используем общий инструмент парсинга имён типов, поскольку определения типов (typedefs) наследует странное правило C “declaration reflects usage” (объявление диктует использование), что удобно для нас. (особенно для озадаченного новичка!) Затем мы сообщаем лексеру, что обнаружили новое имя типа, так что в будущем этот токен будет лексироваться как имя типа, а не имя переменной.

Наконец, для определений типов мы сохраняем тип в глобальном реестре определений типов, используем завершающую точку с запятой и возвращаемся к следующему глобальному объявлению compile(). Важно отметить, что тип, который мы храним, является целым разобранным типом, поскольку вы прописали typedef int* int_p;, а затем int_p *x, x должен получить результирующий тип int**— уровень указателя является аддитивным! Это означает, что мы не можем просто хранить базовое имя типа C, вместо этого нужно хранить целый CType.

Если объявление не было typedef, мы парсим тип и имя переменной. Если мы находим токен ;, мы знаем, что это объявление глобальной переменной (поскольку мы не поддерживаем глобальные инициализаторы). В этом случае мы добавляем глобальную переменную во фрейм глобального стека и таким образом фиксируем ее.

if lexer.try_next(";"):      global_frame.add_var(name.content, decl_type, False)      return

Однако если точки с запятой нет, мы определённо имеем дело с функцией. Чтобы сгенерировать код функции, нам необходимо:

  1. Создать новую функцию StackFrame с именем frame

  2. Проанализировать все параметры и сохранить их во фрейме с помощью frame.add_var(varname.content, type, is_parameter=True)

  3. Проанализировать все объявления переменных с помощью variable_declaration (lexer, frame), который добавит их во frame

  4. Теперь мы знаем, насколько большим должен быть фрейм стека функции ( frame.frame_size), поэтому можем начать генерировать прелюдию!

  5. Во-первых, для всех параметров во фрейме стека (добавленных с помощью is_parameter=True) мы генерируем param объявления WASM, чтобы функцию можно было вызывать с соглашением о вызовах WASM (передавая параметры в стек WASM):

for v in frame.variables.values():      if v.is_parameter:          emit(f"(param ${v.name} {v.type.wasmtype})")
  1. Затем мы можем создать аннотацию result для возвращаемого типа и настроить указатель стека C, чтобы освободить место для параметров и переменных функции:

for v in reversed(frame.variables.values()):     if v.is_parameter:         emit("global.get $__stack_pointer")         emit(f"i32.const {frame.get_var_and_offset(v.name)[1]}")         emit("i32.add")         # fetch the variable from the WASM stack         emit(f"local.get ${v.name}")         # and store it at the calculated address in the C stack         emit(v.type.store_ins())
  1. Для каждого параметра (в обратном порядке, потому что они в стеке) скопируйте его из стека WASM в наш стек:

while not lexer.try_next("}"):     statement(lexer, frame)
  1. Наконец, мы можем вызвать цикл statement(lexer, frame) для кодирования всех операторов функции, пока не нажмём закрывающую скобку:

while not lexer.try_next("}"):      statement(lexer, frame)
  1. Бонусный шаг: мы предполагаем, что функция всегда будет иметь return, поэтому анализатор WASM emit(«unreachable») не сходит с ума.

Ух! Этот пункт получился огромным. Но зато мы закончили с функциями и с global_declaration(), поэтому давайте перейдём к statement().

statement() (compiler.py:565)

В statement() много кода. Однако большая часть повторяется, поэтому я просто объясню про while и for.

Помните, что в WASM нет переходов, а есть структурированный поток управления? Сейчас это пригодится.

Для начала давайте посмотрим, как это работает с while, где особых проблем не возникло. Цикл while в WASM выглядит следующим образом:

block    loop      ;; <test>      i32.eqz      br_if 1      ;; <loop body>      br 0    end  end

Как видите, есть два типа блоков — block и loop (есть ещё if тип блока, который я не использовала). Каждый включает в себя некоторое количество операторов и заканчивается на end. Внутри блока вы можете делать разбивки с помощью br, или через условие на основе вершины стека WASM с помощью br_if (также есть br_table, который я не использовала).

Семейство br принимает параметр labelidx, в нашем случае либо 1, либо 0, указывающий, к какому уровню блока применяется операция. Таким образом, в нашем цикле while, br_if 1 применяется к внешнему блоку — index 1, в то время как br 0 применяется к внутреннему блоку — index 0. (Индексы всегда относятся к рассматриваемой инструкции — 0 — это самый внутренний блок этой инструкции .)

Наконец, последнее правило, которое нужно знать: br в блоке переходит вперёд к концу блока, тогда как br в цикле уходит назад к началу цикла.

Так что, надеюсь, код цикла while теперь имеет смысл! Посмотрим на него снова,

block    loop      ;; <test>      i32.eqz      ;; if test == 0, jump forwards (1 = labelidx of the `block`),      ;; out of the loop      br_if 1      ;; <loop body>      ;; unconditionally jump backwards (0 = labelidx of the `loop`).      ;; to the beginning of the loop      br 0    end  end

В более обычной сборке это будет:

.loop_start    ;; <test>    jz .block_end    ;; <loop body>    jmp .loop_start  .block_end

Но с помощью переходов вы можете выразить то, что вы не сможете (по крайней мере без геморроя) сделать в WASM — например, вы можете перескочить сразу в середину блока.

(В основном это проблема компиляции C goto, которую я даже не пробовала — существует алгоритм, который может преобразовать любой используемый код goto в эквивалентную программу, используя структурированный поток управления, но он сложен, и я не думаю, что он будет работать с нашим однопроходным методом.)

Но для циклов while это не так уж и плохо. Все, что нам нужно сделать, это:

# `emit.block` is a context manager to emit the first parameter ("block" here),  # and then the second ("end") on exit  with emit.block("block", "end"):      with emit.block("loop", "end"):          # emit code for the test, ending with `i32.eqz`          parenthesized_test()          # emit code to exit the loop if the `i32.eqz` was true          emit("br_if 1")          # emit code for the body          bracketed_block_or_single_statement(lexer, frame)          # emit code to jump back to the beginning          emit("br 0")

Однако с циклами for это уже не очень хорошо работает. Рассмотрим такой цикл for:

for (i = 0; i < 5; i = i + 1) {      j = j * 2 + i;  }

Порядок, в котором части цикла for будут видны лексеру/генератору кода:

  1. i = 0

  2. i < 5

  3. i = i + 1

  4. j = j * 2 + i

Но порядок их размещения в коде для работы со структурированным потоком управления WASM следующий:

block    ;; < code for `i = 0` (1) >    loop      ;; < code for `i < 5` (2) >      br_if 1      ;; < code for `j = j * 2 + i` (4!) >      ;; < code for `i = i + 1` (3!) >      br 0    end  end

Обратите внимание, что 3 и 4 в сгенерированном коде инвертированы, образуя порядок 1, 2, 4, 3. Это проблема однопроходного компилятора! В отличие от обычного компилятора, мы не можем сохранить оператор продвижения на будущее. Или… так можем или нет?

В итоге я справилась с этим, сделав лексер клонируемым и повторно сделав парсинг оператора after после парсинга тела кода. По сути, код выглядит так:

elif lexer.try_next("for"):      lexer.next("(")      with emit.block("block", "end"):          # parse initializer (i = 0)          # (outside of loop since it only happens once)          if lexer.peek().kind != ";":              expression(lexer, frame)              emit("drop") # discard result of initializer          lexer.next(";")          with emit.block("loop", "end"):              # parse test (i < 5), if present              if lexer.peek().kind != ";":                  load_result(expression(lexer, frame))                  emit("i32.eqz ;; for test")                  emit("br_if 1 ;; exit loop")              lexer.next(";")              # handle first pass of advancement statement, if present              saved_lexer = None              if lexer.peek().kind != ")":                  saved_lexer = lexer.clone()                  # emit.no_emit() disables code output inside of it,                  # so we can skip over the advancement statement for now                  # to get to the for loop body                  with emit.no_emit():                      expression(lexer, frame)              lexer.next(")")              # parse body              bracketed_block_or_single_statement(lexer, frame)              # now that we parsed the body, go back and re-parse              # the advancement statement using the saved lexer              if saved_lexer != None:                  expression(saved_lexer, frame)              # jump back to beginning of loop              emit("br 0")

Как видите, хитрость заключается в том, чтобы сохранить лексер, а затем использовать его для возврата и обработки оператора продвижения after, вместо сохранения синтаксического дерева, как это делает обычный компилятор. Не очень элегантно вышло — именно компиляция циклов for — и это, пожалуй, самый корявый код в компиляторе, — но работает достаточно хорошо!

Остальные части компилятора statement() во многом схожи, поэтому я пропущу их, чтобы перейти к последней основной части компилятора — expression().

expression() (compiler.py:375)

expression() — это последний большой метод в компиляторе, который, как и следовало ожидать, обрабатывает выражения синтаксического анализа. Он содержит множество внутренних методов, по одному для каждого уровня приоритета, каждый из которых возвращает структуру ExprMeta, описанную ранее (которая обрабатывает различие «место и значение» и может быть преобразована в значение с помощью load_result).

Нижняя часть стека приоритетов имеет value() (несколько запутанное название, поскольку оно может возвращать ExprMeta(is_place=True, …)). Он обрабатывает константы, выражения в скобках, вызовы функций и имена переменных.

Помимо этого, базовым шаблоном уровня приоритета является такая функция:

def muldiv() -> ExprMeta:      # lhs is the higher precedence operation (prefix operators, in this case)      lhs_meta = prefix()      # check if we can parse an operation      if lexer.peek().kind in ("*", "/", "%"):          # if so, load in the left hand side          lhs_meta = load_result(lhs_meta)          # grab the specific operator          op_token = lexer.next()          # the right hand side should use this function, for e.g. `x * y * z`          load_result(muldiv())          # emit an opcode to do the operation          if op_token == "*":              emit(f"i32.mul")          elif op_token == "/":              emit(f"i32.div_s")          else: # %              emit(f"i32.rem_s")          # mask down the result if this is a less-than-32bit type          mask_to_sizeof(lhs_meta.type)          # we produced a value (is_place=False)          return ExprMeta(False, lhs_meta.type)      # if we didn't find a token, just return the left hand side unchanged      return lhs_meta

Этот шаблон настолько последователен, что большинство операций, включая muldiv, не записываются, а определяются функцией более высокого порядка makeop:

# function for generating simple operator precedence levels from declarative  # dictionaries of { token: instruction_to_emit }  def makeop(      higher: Callable[[], ExprMeta], ops: dict[str, str], rtype: CType | None = None  ) -> Callable[[], ExprMeta]:      def op() -> ExprMeta:          lhs_meta = higher()          if lexer.peek().kind in ops.keys():              lhs_meta = load_result(lhs_meta)              op_token = lexer.next()              load_result(op())              # TODO: type checking?              emit(f"{ops[op_token.kind]}")              mask_to_sizeof(rtype or lhs_meta.type)              return ExprMeta(False, lhs_meta.type)          return lhs_meta      return op  muldiv = makeop(prefix, {"*": "i32.mul", "/": "i32.div_s", "%": "i32.rem_s"})  ...  shlr = makeop(plusminus, {"<<": "i32.shl", ">>": "i32.shr_s"})  cmplg = makeop(      shlr,      {"<": "i32.lt_s", ">": "i32.gt_s", "<=": "i32.le_s", ">=": "i32.ge_s"},      CType("int"),  )  cmpe = makeop(cmplg, {"==": "i32.eq", "!=": "i32.ne"}, CType("int"))  bitand = makeop(cmpe, {"&": "i32.and"})  bitor = makeop(bitand, {"|": "i32.or"})  xor = makeop(bitor, {"^": "i32.xor"})  ...

Необходимо явно определить только несколько операций со специальным поведением, например, plusminus,  которые должны обрабатывать нюансы математики указателей C.

Вот и все! Это была последняя основная часть компилятора.

Подводим итоги…

Это был наш тур по компилятору C в 500 строках Python ! Компиляторы имеют репутацию чего-то сложного и громоздкого — GCC и Clang огромны, и даже TCC, компилятор Tiny C, состоит из десятков тысяч строк кода. Но если вы готовы пожертвовать качеством кода и сделать всё за один проход, компиляторы могут быть удивительно компактными!


Что ещё интересного есть в блоге Cloud4Y

→ Спортивные часы Garmin: изучаем GarminOS и её ВМ MonkeyC

→ NAS за шапку сухарей

→ Взлом Hyundai Tucson, часть 1, часть 2

→ Взламываем «умную» зубную щётку

→ 50 самых интересных клавиатур из частной коллекции


ссылка на оригинал статьи https://habr.com/ru/articles/760400/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *