Kompilatorn

Kompileringsprocessen i MicroPython omfattar följande steg:

  • Lexern omvandlar textströmmen som utgör ett MicroPython-program till tokens.

  • Parsern omvandlar sedan dessa tokens till en abstrakt syntax (parsningsträd).

  • Därefter genereras bytekod eller nativ kod utifrån parsningsträdet.

I detta avsnitt kommer vi att lägga till en enkel språkfunktion add1 som kan användas i Python på följande sätt:

>>> add1 3
4
>>>

Satsen add1 tar ett heltal som argument och adderar 1 till det.

Lägga till en grammatikregel

MicroPythons grammatik är baserad på CPythons grammatik och definieras i py/grammar.h. Denna grammatik är vad som används för att parsa MicroPython-källfiler.

Det finns två makron du behöver känna till för att definiera en grammatikregel: DEF_RULE och DEF_RULE_NC. DEF_RULE låter dig definiera en regel med en tillhörande kompileringsfunktion, medan DEF_RULE_NC inte har någon kompileringsfunktion (NC) för sig.

En enkel grammatikdefinition med en kompileringsfunktion för vår nya add1-sats ser ut så här:

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

Det andra argumentet c(add1_stmt) är den motsvarande kompileringsfunktionen som bör implementeras i py/compile.c för att omvandla denna regel till körbar kod.

Det tredje obligatoriska argumentet kan vara or eller and. Detta anger antalet noder som är knutna till en sats. I detta fall liknar till exempel vår add1-sats ADD1 i assemblerspråk. Den tar ett numeriskt argument. Därför har add1_stmt två noder knutna till sig. En nod är för själva satsen, dvs. literalen add1 som motsvarar KW_ADD1, och den andra för dess argument, en testlist-regel som är uttrycksregeln på högsta nivå.

Anteckning

Regeln add1 här är bara ett exempel och ingår inte i MicroPythons standardgrammatik.

Det fjärde argumentet i detta exempel är den token som är knuten till regeln, KW_ADD1. Denna token bör definieras i lexern genom att redigera py/lexer.h.

Att definiera samma regel utan en kompileringsfunktion görs genom att använda makrot DEF_RULE_NC och utelämna argumentet för kompileringsfunktionen:

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

De återstående argumenten har samma betydelse. En regel utan kompileringsfunktion måste hanteras explicit av alla regler som kan ha denna regel som en nod. Sådana NC-regler används vanligtvis för att uttrycka deldelar av en komplicerad grammatikstruktur som inte kan uttryckas i en enda regel.

Anteckning

Makrona DEF_RULE och DEF_RULE_NC tar andra argument. För en djupare förståelse av de parametrar som stöds, se py/grammar.h.

Lägga till en lexikal token

Varje regel som definieras i grammatiken bör ha en token knuten till sig som definieras i py/lexer.h. Lägg till denna token genom att redigera enumen _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;

Redigera sedan även py/lexer.c för att lägga till den nya nyckelordslitteralen:

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

Observera att nyckelordet namnges beroende på vad du vill att det ska vara. För konsekvensens skull, håll dig till namngivningsstandarden i enlighet med detta.

Anteckning

Ordningen på dessa nyckelord i py/lexer.c måste matcha ordningen på tokens i enumen som definieras i py/lexer.h.

Parsning

I parsningssteget tar parsern de tokens som lexern producerat och omvandlar dem till ett abstrakt syntaxträd (AST) eller parsningsträd. Implementeringen av parsern definieras i py/parse.c.

Parsern upprätthåller också en tabell över konstanter för användning i olika aspekter av parsningen, liknande vad en symboltabell gör.

Flera optimeringar som konstantvikning på heltal för de flesta operationer, t.ex. logiska, binära, unära osv., samt optimerande förbättringar kring parenteser runt uttryck utförs under denna fas, tillsammans med vissa optimeringar på strängar.

Det är värt att notera att docstrings kasseras och inte är åtkomliga för kompilatorn. Inte ens optimeringar som stränginternering tillämpas på docstrings.

Kompilatorpass

Liksom många kompilatorer kompilerar MicroPython all kod till MicroPython-bytekod eller nativ kod. Funktionaliteten som åstadkommer detta är implementerad i py/compile.c. Den mest relevanta metoden du bör känna till är denna:

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

Kompilatorn kompilerar koden i fyra pass: scope, stackstorlek, kodstorlek och emit. Varje pass kör samma C-kod över samma AST-datastruktur, med olika saker som beräknas varje gång baserat på resultaten från föregående pass.

Första passet

I det första passet lär sig kompilatorn om de kända identifierarna (variablerna) och deras scope, dvs. globalt, lokalt, slutet över (closed over) osv. I samma pass beräknar emittern (bytekod eller nativ kod) även antalet etiketter som behövs för den genererade koden.

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

Andra och tredje passet

Det andra och tredje passet innebär att beräkna Python-stackens storlek och kodstorleken för bytekoden eller den nativa koden. Efter det tredje passet kan kodstorleken inte ändras, annars blir hoppetiketterna felaktiga.

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

    ...
}

Precis före pass två sker ett val av vilken typ av kod som ska genereras, vilket antingen kan vara nativ kod eller bytekod.

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

Bytekodsalternativet är standardvalet, men något unikt att notera för alternativet med nativ kod är att det finns ytterligare ett alternativ via VIPER. Se avsnittet Emitting native code för mer information om viper-annoteringar.

Det finns också stöd för inline-assemblerkod, där assemblerinstruktioner skrivs som Python-funktionsanrop men genereras direkt som motsvarande maskinkod. Denna assembler har endast tre pass (scope, kodstorlek, emit) och använder en annan implementering, inte funktionen compile_scope. Se inline assembler reference för mer information.

Fjärde passet

Det fjärde passet genererar den slutgiltiga koden som kan köras, antingen som bytekod i den virtuella maskinen eller som nativ kod direkt av CPU:n.

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

Generera bytekod

Satser i Python-kod motsvarar vanligtvis genererad bytekod, till exempel genererar a + b ”push a”, sedan ”push b”, sedan ”binary op add”. Vissa satser genererar ingenting utan påverkar i stället andra saker, som variablernas scope, till exempel global a.

Implementeringen av en funktion som genererar bytekod ser ut ungefär så här:

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

Vi använder uttryck med unära operatorer som exempel här, men implementeringsdetaljerna liknar dem för andra satser/uttryck. Metoden emit_write_bytecode_byte() är ett omslag kring huvudfunktionen emit_get_cur_to_write_bytecode() som alla funktioner måste anropa för att generera bytekod.

Generera nativ kod

Liksom hur bytekod genereras bör det finnas en motsvarande funktion i py/emitnative.c för varje kodsats:

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

Skillnaden här är att vi måste hantera viper-typning. Viper-annoteringar låter oss hantera mer än en typ av variabel. Som standard är alla variabler Python-objekt, men med viper kan en variabel även deklareras som en maskintypad variabel, som ett nativt heltal eller en pekare. Viper kan ses som en delmängd-utvidgning av Python, där vanliga Python-objekt hanteras som vanligt, medan nativa maskinvariabler hanteras på ett optimerat sätt genom att direkta maskininstruktioner används för operationerna. Viper-typning kan bryta ekvivalensen med Python eftersom till exempel heltal blir nativa heltal och kan svämma över (till skillnad från Python-heltal som automatiskt utökas till godtycklig precision).