Компілятор

Процес компіляції в 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 — No Compile).

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