Compilatorul

Procesul de compilare în MicroPython implică următorii pași:

  • Analizorul lexical (lexer) convertește fluxul de text care alcătuiește un program MicroPython în token-uri.

  • Analizorul sintactic (parser) convertește apoi token-urile într-o sintaxă abstractă (arbore de analiză).

  • Apoi se emite bytecode sau cod nativ pe baza arborelui de analiză.

În scopul acestei discuții vom adăuga o caracteristică simplă de limbaj add1 care poate fi utilizată în Python astfel:

>>> add1 3
4
>>>

Instrucțiunea add1 primește un întreg ca argument și îi adaugă 1.

Adăugarea unei reguli gramaticale

Gramatica MicroPython se bazează pe gramatica CPython și este definită în py/grammar.h. Această gramatică este cea utilizată pentru a analiza fișierele sursă MicroPython.

Există două macrocomenzi pe care trebuie să le cunoști pentru a defini o regulă gramaticală: DEF_RULE și DEF_RULE_NC. DEF_RULE îți permite să definești o regulă cu o funcție de compilare asociată, în timp ce DEF_RULE_NC nu are nicio funcție de compilare (NC) pentru ea.

O definiție gramaticală simplă cu o funcție de compilare pentru noua noastră instrucțiune add1 arată astfel:

DEF_RULE(add1_stmt, c(add1_stmt), and(2), tok(KW_ADD1), rule(testlist))

Al doilea argument c(add1_stmt) este funcția de compilare corespunzătoare care ar trebui implementată în py/compile.c pentru a transforma această regulă în cod executabil.

Al treilea argument obligatoriu poate fi or sau and. Acesta specifică numărul de noduri asociate cu o instrucțiune. De exemplu, în acest caz, instrucțiunea noastră add1 este similară cu ADD1 din limbajul de asamblare. Primește un singur argument numeric. Prin urmare, add1_stmt are două noduri asociate. Un nod este pentru instrucțiunea în sine, adică literalul add1 corespunzător lui KW_ADD1, iar celălalt pentru argumentul său, o regulă testlist care este regula de expresie de nivel superior.

Notă

Regula add1 de aici este doar un exemplu și nu face parte din gramatica standard MicroPython.

Al patrulea argument din acest exemplu este token-ul asociat regulii, KW_ADD1. Acest token ar trebui definit în lexer prin editarea fișierului py/lexer.h.

Definirea aceleiași reguli fără o funcție de compilare se realizează utilizând macrocomanda DEF_RULE_NC și omițând argumentul funcției de compilare:

DEF_RULE_NC(add1_stmt, and(2), tok(KW_ADD1), rule(testlist))

Argumentele rămase au aceeași semnificație. O regulă fără funcție de compilare trebuie tratată explicit de toate regulile care pot avea această regulă ca nod. Astfel de reguli NC sunt de obicei utilizate pentru a exprima sub-părți ale unei structuri gramaticale complicate care nu pot fi exprimate într-o singură regulă.

Notă

Macrocomenzile DEF_RULE și DEF_RULE_NC primesc și alte argumente. Pentru o înțelegere aprofundată a parametrilor acceptați, consultă py/grammar.h.

Adăugarea unui token lexical

Fiecare regulă definită în gramatică ar trebui să aibă un token asociat care este definit în py/lexer.h. Adaugă acest token prin editarea enumerării _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;

Apoi editează și py/lexer.c pentru a adăuga noul text literal al cuvântului-cheie:

static const char *const tok_kw[] = {
    ...
    "or",
    "pass",
    "raise",
    "return",
    "try",
    "while",
    "with",
    "yield",
    "add1",
    ...
};

Observă că cuvântul-cheie este denumit în funcție de ceea ce dorești să fie. Pentru consecvență, menține standardul de denumire în mod corespunzător.

Notă

Ordinea acestor cuvinte-cheie din py/lexer.c trebuie să corespundă ordinii token-urilor din enumerarea definită în py/lexer.h.

Analiza sintactică

În etapa de analiză sintactică, parser-ul preia token-urile produse de lexer și le convertește într-un arbore de sintaxă abstractă (AST) sau arbore de analiză. Implementarea pentru parser este definită în py/parse.c.

Parser-ul menține de asemenea un tabel de constante pentru utilizare în diferite aspecte ale analizei, similar cu ceea ce face un tabel de simboluri.

În această fază se efectuează mai multe optimizări precum plierea constantelor (constant folding) asupra întregilor pentru majoritatea operațiilor, de exemplu logice, binare, unare etc., și îmbunătățiri de optimizare asupra parantezelor din jurul expresiilor, împreună cu unele optimizări asupra șirurilor de caractere.

Merită menționat că docstring-urile sunt eliminate și nu sunt accesibile compilatorului. Chiar și optimizări precum internarea șirurilor de caractere (string interning) nu sunt aplicate docstring-urilor.

Trecerile compilatorului

Ca multe compilatoare, MicroPython compilează tot codul în bytecode MicroPython sau cod nativ. Funcționalitatea care realizează acest lucru este implementată în py/compile.c. Cea mai relevantă metodă pe care ar trebui să o cunoști este aceasta:

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);
}

Compilatorul compilează codul în patru treceri: domeniu de vizibilitate (scope), dimensiunea stivei, dimensiunea codului și emitere. Fiecare trecere rulează același cod C peste aceeași structură de date AST, calculându-se de fiecare dată lucruri diferite pe baza rezultatelor trecerii anterioare.

Prima trecere

În prima trecere, compilatorul află despre identificatorii cunoscuți (variabile) și domeniul lor de vizibilitate, fie el global, local, capturat (closed over) etc. În aceeași trecere, emițătorul (de bytecode sau cod nativ) calculează de asemenea numărul de etichete necesare pentru codul emis.

// 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);
            }
        }
    }
    ...
}

A doua și a treia trecere

A doua și a treia trecere implică calcularea dimensiunii stivei Python și a dimensiunii codului pentru bytecode sau codul nativ. După a treia trecere, dimensiunea codului nu se mai poate schimba, altfel etichetele de salt vor fi incorecte.

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);
    }

    ...
}

Chiar înainte de a doua trecere are loc o selecție a tipului de cod care urmează să fie emis, care poate fi fie nativ, fie bytecode.

// 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;
}

Opțiunea bytecode este cea implicită, dar ceva unic de remarcat pentru opțiunea de cod nativ este că există o altă opțiune prin VIPER. Consultă secțiunea Emiterea de cod nativ pentru mai multe detalii despre adnotările viper.

Există de asemenea suport pentru cod de asamblare inline, unde instrucțiunile de asamblare sunt scrise ca apeluri de funcții Python, dar sunt emise direct ca codul mașină corespunzător. Acest asamblor are doar trei treceri (scope, dimensiunea codului, emitere) și utilizează o implementare diferită, nu funcția compile_scope. Consultă referința pentru asamblorul inline pentru mai multe detalii.

A patra trecere

A patra trecere emite codul final care poate fi executat, fie bytecode în mașina virtuală, fie cod nativ direct de către CPU.

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);
    }
}

Emiterea de bytecode

Instrucțiunile din codul Python corespund de obicei unui bytecode emis, de exemplu a + b generează „push a”, apoi „push b”, apoi „binary op add”. Unele instrucțiuni nu emit nimic, ci în schimb afectează alte lucruri precum domeniul de vizibilitate al variabilelor, de exemplu global a.

Implementarea unei funcții care emite bytecode arată similar cu aceasta:

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);
}

Folosim aici expresiile cu operatori unari ca exemplu, dar detaliile de implementare sunt similare pentru alte instrucțiuni/expresii. Metoda emit_write_bytecode_byte() este un înveliș (wrapper) în jurul funcției principale emit_get_cur_to_write_bytecode() pe care toate funcțiile trebuie să o apeleze pentru a emite bytecode.

Emiterea de cod nativ

Similar cu modul în care se generează bytecode-ul, ar trebui să existe o funcție corespunzătoare în py/emitnative.c pentru fiecare instrucțiune de cod:

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]);
     }
}

Diferența aici este că trebuie să gestionăm tiparea viper. Adnotările viper ne permit să gestionăm mai multe tipuri de variabile. În mod implicit, toate variabilele sunt obiecte Python, dar cu viper o variabilă poate fi declarată și ca variabilă de tip mașină, precum un întreg nativ sau un pointer. Viper poate fi considerat un superset al Python, unde obiectele Python normale sunt gestionate ca de obicei, în timp ce variabilele native de tip mașină sunt gestionate într-un mod optimizat prin utilizarea instrucțiunilor mașină directe pentru operații. Tiparea viper poate rupe echivalența cu Python deoarece, de exemplu, întregii devin întregi nativi și pot depăși capacitatea (spre deosebire de întregii Python care se extind automat la precizie arbitrară).