Компилятор¶
Процесс компиляции в MicroPython включает следующие шаги:
Лексер преобразует поток текста, из которого состоит программа на MicroPython, в токены.
Затем парсер преобразует токены в абстрактный синтаксис (дерево разбора).
Затем на основе дерева разбора генерируется байт-код или машинный код.
Для целей данного обсуждения мы добавим простую языковую конструкцию add1, которую можно использовать в Python следующим образом:
>>> add1 3
4
>>>
Инструкция add1 принимает целое число в качестве аргумента и прибавляет к нему 1.
Добавление грамматического правила¶
Грамматика MicroPython основана на грамматике CPython и определена в py/grammar.h. Эта грамматика используется для разбора исходных файлов MicroPython.
Существует два макроса, которые нужно знать для определения грамматического правила: DEF_RULE и DEF_RULE_NC. DEF_RULE позволяет определить правило со связанной функцией компиляции, тогда как DEF_RULE_NC не имеет функции компиляции (NC).
Простое определение грамматики с функцией компиляции для нашей новой инструкции add1 выглядит следующим образом:
DEF_RULE(add1_stmt, c(add1_stmt), and(2), tok(KW_ADD1), rule(testlist))
Второй аргумент c(add1_stmt) — это соответствующая функция компиляции, которая должна быть реализована в py/compile.c, чтобы превратить это правило в исполняемый код.
Третий обязательный аргумент может быть or или and. Он задаёт количество узлов, связанных с инструкцией. Например, в данном случае наша инструкция add1 похожа на ADD1 в языке ассемблера. Она принимает один числовой аргумент. Поэтому с add1_stmt связаны два узла. Один узел — для самой инструкции, то есть литерала add1, соответствующего KW_ADD1, а другой — для её аргумента, правила testlist, которое является правилом выражения верхнего уровня.
Примечание
Правило add1 здесь является лишь примером и не входит в стандартную грамматику MicroPython.
Четвёртый аргумент в этом примере — это токен, связанный с правилом, KW_ADD1. Этот токен должен быть определён в лексере путём редактирования py/lexer.h.
Определение того же правила без функции компиляции достигается с помощью макроса DEF_RULE_NC и опускания аргумента функции компиляции:
DEF_RULE_NC(add1_stmt, and(2), tok(KW_ADD1), rule(testlist))
Остальные аргументы имеют то же значение. Правило без функции компиляции должно обрабатываться явно всеми правилами, которые могут иметь это правило в качестве узла. Такие NC-правила обычно используются для выражения подчастей сложной грамматической структуры, которые невозможно выразить в одном правиле.
Примечание
Макросы DEF_RULE и DEF_RULE_NC принимают и другие аргументы. Для глубокого понимания поддерживаемых параметров смотрите py/grammar.h.
Добавление лексического токена¶
Каждому правилу, определённому в грамматике, должен соответствовать токен, определённый в py/lexer.h. Добавьте этот токен, отредактировав перечисление _mp_token_kind_t:
typedef enum _mp_token_kind_t {
...
MP_TOKEN_KW_OR,
MP_TOKEN_KW_PASS,
MP_TOKEN_KW_RAISE,
MP_TOKEN_KW_RETURN,
MP_TOKEN_KW_TRY,
MP_TOKEN_KW_WHILE,
MP_TOKEN_KW_WITH,
MP_TOKEN_KW_YIELD,
MP_TOKEN_KW_ADD1,
...
} mp_token_kind_t;
Затем также отредактируйте py/lexer.c, чтобы добавить текст нового ключевого слова-литерала:
static const char *const tok_kw[] = {
...
"or",
"pass",
"raise",
"return",
"try",
"while",
"with",
"yield",
"add1",
...
};
Обратите внимание, что ключевое слово называется в зависимости от того, каким вы хотите его видеть. Для согласованности соблюдайте соответствующий стандарт именования.
Примечание
Порядок этих ключевых слов в py/lexer.c должен соответствовать порядку токенов в перечислении, определённом в py/lexer.h.
Разбор¶
На этапе разбора парсер берёт токены, произведённые лексером, и преобразует их в абстрактное синтаксическое дерево (AST), или дерево разбора. Реализация парсера определена в py/parse.c.
Парсер также ведёт таблицу констант для использования в различных аспектах разбора, аналогично тому, как это делает таблица символов.
На этом этапе выполняется несколько оптимизаций, таких как свёртка констант для целых чисел в большинстве операций, например логических, двоичных, унарных и т. д., а также оптимизирующие улучшения, связанные со скобками вокруг выражений, наряду с некоторыми оптимизациями строк.
Стоит отметить, что строки документации отбрасываются и недоступны компилятору. Даже такие оптимизации, как интернирование строк, не применяются к строкам документации.
Проходы компилятора¶
Как и многие компиляторы, MicroPython компилирует весь код в байт-код MicroPython или машинный код. Функциональность, реализующая это, находится в py/compile.c. Наиболее значимый метод, о котором следует знать, такой:
mp_obj_t mp_compile(mp_parse_tree_t *parse_tree, qstr source_file, bool is_repl) {
// Create a context for this module, and set its globals dict.
mp_module_context_t *context = m_new_obj(mp_module_context_t);
context->module.globals = mp_globals_get();
// Compile the input parse_tree to a raw-code structure.
mp_compiled_module_t cm;
cm.context = context;
mp_compile_to_raw_code(parse_tree, source_file, is_repl, &cm);
// Create and return a function object that executes the outer module.
return mp_make_function_from_proto_fun(cm.rc, cm.context, NULL);
}
Компилятор компилирует код за четыре прохода: область видимости, размер стека, размер кода и генерация. Каждый проход выполняет один и тот же код на C над одной и той же структурой данных AST, при этом каждый раз вычисляются разные вещи на основе результатов предыдущего прохода.
Первый проход¶
На первом проходе компилятор узнаёт об известных идентификаторах (переменных) и их области видимости, будь то глобальная, локальная, захваченная по замыканию и т. д. На том же проходе генератор (байт-кода или машинного кода) также вычисляет количество меток, необходимых для генерируемого кода.
// Compile pass 1.
comp->emit = emit_bc;
comp->emit_method_table = &emit_bc_method_table;
uint max_num_labels = 0;
for (scope_t *s = comp->scope_head; s != NULL && comp->compile_error == MP_OBJ_NULL; s = s->next) {
if (s->emit_options == MP_EMIT_OPT_ASM) {
compile_scope_inline_asm(comp, s, MP_PASS_SCOPE);
} else {
compile_scope(comp, s, MP_PASS_SCOPE);
// Check if any implicitly declared variables should be closed over.
for (size_t i = 0; i < s->id_info_len; ++i) {
id_info_t *id = &s->id_info[i];
if (id->kind == ID_INFO_KIND_GLOBAL_IMPLICIT) {
scope_check_to_close_over(s, id);
}
}
}
...
}
Второй и третий проходы¶
Второй и третий проходы включают вычисление размера стека Python и размера кода для байт-кода или машинного кода. После третьего прохода размер кода не может измениться, иначе метки переходов окажутся неверными.
for (scope_t *s = comp->scope_head; s != NULL && comp->compile_error == MP_OBJ_NULL; s = s->next) {
...
// Pass 2: Compute the Python stack size.
compile_scope(comp, s, MP_PASS_STACK_SIZE);
// Pass 3: Compute the code size.
if (comp->compile_error == MP_OBJ_NULL) {
compile_scope(comp, s, MP_PASS_CODE_SIZE);
}
...
}
Непосредственно перед вторым проходом происходит выбор типа генерируемого кода, который может быть либо машинным, либо байт-кодом.
// Choose the emitter type.
switch (s->emit_options) {
case MP_EMIT_OPT_NATIVE_PYTHON:
case MP_EMIT_OPT_VIPER:
if (emit_native == NULL) {
emit_native = NATIVE_EMITTER(new)(&comp->compile_error, &comp->next_label, max_num_labels);
}
comp->emit_method_table = NATIVE_EMITTER_TABLE;
comp->emit = emit_native;
break;
default:
comp->emit = emit_bc;
comp->emit_method_table = &emit_bc_method_table;
break;
}
Вариант с байт-кодом используется по умолчанию, но в случае с машинным кодом стоит отметить уникальную особенность — наличие ещё одного варианта через VIPER. Подробнее об аннотациях viper смотрите в разделе Генерация машинного кода.
Также имеется поддержка встроенного ассемблерного кода, где ассемблерные инструкции записываются как вызовы функций Python, но генерируются напрямую в виде соответствующего машинного кода. У этого ассемблера всего три прохода (область видимости, размер кода, генерация), и он использует другую реализацию, а не функцию compile_scope. Подробнее смотрите в справочнике по встроенному ассемблеру.
Четвёртый проход¶
Четвёртый проход генерирует окончательный код, который может быть выполнен, — либо байт-код в виртуальной машине, либо машинный код напрямую процессором.
for (scope_t *s = comp->scope_head; s != NULL && comp->compile_error == MP_OBJ_NULL; s = s->next) {
...
// Pass 4: Emit the compiled bytecode or native code.
if (comp->compile_error == MP_OBJ_NULL) {
compile_scope(comp, s, MP_PASS_EMIT);
}
}
Генерация байт-кода¶
Инструкции в коде Python обычно соответствуют генерируемому байт-коду, например a + b порождает «push a», затем «push b», затем «binary op add». Некоторые инструкции ничего не генерируют, а вместо этого влияют на другие вещи, такие как область видимости переменных, например global a.
Реализация функции, генерирующей байт-код, выглядит примерно так:
void mp_emit_bc_unary_op(emit_t *emit, mp_unary_op_t op) {
emit_write_bytecode_byte(emit, 0, MP_BC_UNARY_OP_MULTI + op);
}
В качестве примера здесь мы используем выражения с унарным оператором, но детали реализации аналогичны для других инструкций/выражений. Метод emit_write_bytecode_byte() является обёрткой над основной функцией emit_get_cur_to_write_bytecode(), которую должны вызывать все функции для генерации байт-кода.
Генерация машинного кода¶
Аналогично тому, как генерируется байт-код, для каждой инструкции кода должна существовать соответствующая функция в py/emitnative.c:
static void emit_native_unary_op(emit_t *emit, mp_unary_op_t op) {
vtype_kind_t vtype;
emit_pre_pop_reg(emit, &vtype, REG_ARG_2);
if (vtype == VTYPE_PYOBJ) {
emit_call_with_imm_arg(emit, MP_F_UNARY_OP, op, REG_ARG_1);
emit_post_push_reg(emit, VTYPE_PYOBJ, REG_RET);
} else {
adjust_stack(emit, 1);
EMIT_NATIVE_VIPER_TYPE_ERROR(emit,
MP_ERROR_TEXT("unary op %q not implemented"), mp_unary_op_method_name[op]);
}
}
Разница здесь в том, что нам приходится обрабатывать типизацию viper. Аннотации viper позволяют нам обрабатывать более одного типа переменной. По умолчанию все переменные являются объектами Python, но с viper переменная также может быть объявлена как переменная машинного типа, например как нативное целое число или указатель. Viper можно рассматривать как надмножество Python, где обычные объекты Python обрабатываются как обычно, в то время как нативные машинные переменные обрабатываются оптимизированным способом — с использованием прямых машинных инструкций для операций. Типизация viper может нарушить эквивалентность с Python, поскольку, например, целые числа становятся нативными целыми числами и могут переполняться (в отличие от целых чисел Python, которые автоматически расширяются до произвольной точности).