Компилятор

Процесс компиляции в 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, которые автоматически расширяются до произвольной точности).