De compiler¶
Het compilatieproces in MicroPython bestaat uit de volgende stappen:
De lexer zet de tekststroom waaruit een MicroPython-programma bestaat om in tokens.
De parser zet de tokens vervolgens om in een abstracte syntaxis (parse tree).
Daarna wordt op basis van de parse tree bytecode of native code gegenereerd.
Voor deze bespreking voegen we een eenvoudige taalfunctie add1 toe die in Python als volgt gebruikt kan worden:
>>> add1 3
4
>>>
De add1-instructie neemt een geheel getal als argument en telt er 1 bij op.
Een grammaticaregel toevoegen¶
De grammatica van MicroPython is gebaseerd op de CPython-grammatica en is gedefinieerd in py/grammar.h. Deze grammatica wordt gebruikt om MicroPython-bronbestanden te parsen.
Er zijn twee macro’s die je moet kennen om een grammaticaregel te definiëren: DEF_RULE en DEF_RULE_NC. Met DEF_RULE kun je een regel definiëren met een bijbehorende compileerfunctie, terwijl DEF_RULE_NC daar geen compileerfunctie (NC) voor heeft.
Een eenvoudige grammaticadefinitie met een compileerfunctie voor onze nieuwe add1-instructie ziet er als volgt uit:
DEF_RULE(add1_stmt, c(add1_stmt), and(2), tok(KW_ADD1), rule(testlist))
Het tweede argument c(add1_stmt) is de bijbehorende compileerfunctie die in py/compile.c geïmplementeerd moet worden om deze regel om te zetten in uitvoerbare code.
Het derde verplichte argument kan or of and zijn. Dit geeft het aantal nodes aan dat bij een instructie hoort. In dit geval is onze add1-instructie bijvoorbeeld vergelijkbaar met ADD1 in assemblytaal. Deze neemt één numeriek argument. Daarom heeft add1_stmt twee bijbehorende nodes. Eén node is voor de instructie zelf, dat wil zeggen de letterlijke add1 die overeenkomt met KW_ADD1, en de andere voor het argument ervan, een testlist-regel die de expressieregel op het hoogste niveau is.
Notitie
De add1-regel hier is slechts een voorbeeld en geen onderdeel van de standaard MicroPython-grammatica.
Het vierde argument in dit voorbeeld is het token dat bij de regel hoort, KW_ADD1. Dit token moet in de lexer gedefinieerd worden door py/lexer.h te bewerken.
Dezelfde regel zonder compileerfunctie definieer je met de DEF_RULE_NC-macro, waarbij je het argument voor de compileerfunctie weglaat:
DEF_RULE_NC(add1_stmt, and(2), tok(KW_ADD1), rule(testlist))
De overige argumenten hebben dezelfde betekenis. Een regel zonder compileerfunctie moet expliciet afgehandeld worden door alle regels die deze regel als node kunnen hebben. Dergelijke NC-regels worden meestal gebruikt om subonderdelen van een ingewikkelde grammaticastructuur uit te drukken die niet in één enkele regel uit te drukken zijn.
Notitie
De macro’s DEF_RULE en DEF_RULE_NC nemen nog andere argumenten. Voor een diepgaand begrip van de ondersteunde parameters, zie py/grammar.h.
Een lexicaal token toevoegen¶
Elke regel die in de grammatica gedefinieerd is, moet een bijbehorend token hebben dat in py/lexer.h gedefinieerd is. Voeg dit token toe door de _mp_token_kind_t-enum te bewerken:
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;
Bewerk vervolgens ook py/lexer.c om de letterlijke tekst van het nieuwe sleutelwoord toe te voegen:
static const char *const tok_kw[] = {
...
"or",
"pass",
"raise",
"return",
"try",
"while",
"with",
"yield",
"add1",
...
};
Merk op dat het sleutelwoord een naam krijgt afhankelijk van wat je wilt dat het is. Houd voor consistentie de naamgevingsstandaard dienovereenkomstig aan.
Notitie
De volgorde van deze sleutelwoorden in py/lexer.c moet overeenkomen met de volgorde van de tokens in de enum die in py/lexer.h gedefinieerd is.
Parsen¶
In de parseerfase neemt de parser de tokens die door de lexer geproduceerd zijn en zet deze om in een abstracte syntaxisboom (AST) of parse tree. De implementatie van de parser is gedefinieerd in py/parse.c.
De parser onderhoudt ook een tabel met constanten voor gebruik in verschillende aspecten van het parsen, vergelijkbaar met wat een symboltabel doet.
Tijdens deze fase worden verschillende optimalisaties uitgevoerd, zoals constant folding op gehele getallen voor de meeste bewerkingen, bijvoorbeeld logische, binaire, unaire enzovoort, en optimaliserende verbeteringen rond haakjes om expressies, samen met enkele optimalisaties op strings.
Het is vermeldenswaard dat docstrings worden weggegooid en niet toegankelijk zijn voor de compiler. Zelfs optimalisaties als string interning worden niet toegepast op docstrings.
Compilerpassages¶
Zoals veel compilers compileert MicroPython alle code tot MicroPython-bytecode of native code. De functionaliteit die dit bewerkstelligt, is geïmplementeerd in py/compile.c. De meest relevante methode die je moet kennen, is deze:
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);
}
De compiler compileert de code in vier passages: scope, stackgrootte, codegrootte en emit. Elke passage voert dezelfde C-code uit over dezelfde AST-datastructuur, waarbij elke keer andere zaken worden berekend op basis van de resultaten van de vorige passage.
Eerste passage¶
In de eerste passage leert de compiler de bekende identifiers (variabelen) en hun scope kennen, of die nu globaal, lokaal, closed-over enzovoort zijn. In dezelfde passage berekent de emitter (bytecode of native code) ook het aantal labels dat nodig is voor de gegenereerde code.
// 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);
}
}
}
...
}
Tweede en derde passage¶
Bij de tweede en derde passage worden de Python-stackgrootte en de codegrootte voor de bytecode of native code berekend. Na de derde passage kan de codegrootte niet meer veranderen, anders zouden de jump-labels onjuist zijn.
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);
}
...
}
Net vóór passage twee wordt het type code geselecteerd dat gegenereerd moet worden, wat native of bytecode kan zijn.
// 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;
}
De bytecode-optie is de standaard, maar iets unieks om op te merken bij de native-code-optie is dat er nog een andere optie is via VIPER. Zie het gedeelte Native code genereren voor meer details over viper-annotaties.
Er is ook ondersteuning voor inline assembly-code, waarbij assembly-instructies als Python-functieaanroepen geschreven worden maar direct als de bijbehorende machinecode gegenereerd worden. Deze assembler heeft slechts drie passages (scope, codegrootte, emit) en gebruikt een andere implementatie, niet de compile_scope-functie. Zie de referentie voor de inline assembler voor meer details.
Vierde passage¶
De vierde passage genereert de definitieve code die uitgevoerd kan worden, hetzij als bytecode in de virtuele machine, hetzij als native code direct door de 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);
}
}
Bytecode genereren¶
Instructies in Python-code komen meestal overeen met gegenereerde bytecode, bijvoorbeeld a + b genereert “push a”, dan “push b” en dan “binary op add”. Sommige instructies genereren niets maar beïnvloeden in plaats daarvan andere zaken, zoals de scope van variabelen, bijvoorbeeld global a.
De implementatie van een functie die bytecode genereert, ziet er ongeveer zo uit:
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);
}
We gebruiken hier de unaire-operatorexpressies als voorbeeld, maar de implementatiedetails zijn vergelijkbaar voor andere instructies/expressies. De methode emit_write_bytecode_byte() is een wrapper rond de hoofdfunctie emit_get_cur_to_write_bytecode() die alle functies moeten aanroepen om bytecode te genereren.
Native code genereren¶
Net zoals bytecode gegenereerd wordt, zou er voor elke code-instructie een bijbehorende functie in py/emitnative.c moeten zijn:
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]);
}
}
Het verschil hier is dat we viper-typering moeten afhandelen. Viper-annotaties stellen ons in staat meer dan één type variabele af te handelen. Standaard zijn alle variabelen Python-objecten, maar met viper kan een variabele ook gedeclareerd worden als een machinegetypeerde variabele, zoals een native geheel getal of pointer. Viper kan beschouwd worden als een superset van Python, waarbij normale Python-objecten op de gebruikelijke manier worden afgehandeld, terwijl native machinevariabelen op een geoptimaliseerde manier worden afgehandeld door directe machine-instructies te gebruiken voor de bewerkingen. Viper-typering kan de Python-equivalentie verbreken omdat bijvoorbeeld gehele getallen native gehele getallen worden en kunnen overlopen (in tegenstelling tot Python-gehele getallen, die automatisch uitbreiden tot willekeurige precisie).