Il compilatore¶
Il processo di compilazione in MicroPython prevede i seguenti passaggi:
Il lexer converte il flusso di testo che compone un programma MicroPython in token.
Il parser converte poi i token in una sintassi astratta (albero sintattico).
Successivamente viene generato il bytecode o il codice nativo a partire dall’albero sintattico.
Ai fini di questa discussione aggiungeremo una semplice funzionalità del linguaggio add1 che può essere usata in Python come:
>>> add1 3
4
>>>
L’istruzione add1 prende un intero come argomento e gli aggiunge 1.
Aggiungere una regola grammaticale¶
La grammatica di MicroPython si basa sulla grammatica di CPython ed è definita in py/grammar.h. Questa grammatica è ciò che viene usato per analizzare i file sorgente MicroPython.
Ci sono due macro che devi conoscere per definire una regola grammaticale: DEF_RULE e DEF_RULE_NC. DEF_RULE consente di definire una regola con una funzione di compilazione associata, mentre DEF_RULE_NC non ha alcuna funzione di compilazione (NC).
Una semplice definizione grammaticale con una funzione di compilazione per la nostra nuova istruzione add1 si presenta come segue:
DEF_RULE(add1_stmt, c(add1_stmt), and(2), tok(KW_ADD1), rule(testlist))
Il secondo argomento c(add1_stmt) è la corrispondente funzione di compilazione che dovrebbe essere implementata in py/compile.c per trasformare questa regola in codice eseguibile.
Il terzo argomento richiesto può essere or o and. Esso specifica il numero di nodi associati a un’istruzione. Ad esempio, in questo caso, la nostra istruzione add1 è simile a ADD1 nel linguaggio assembly. Prende un argomento numerico. Pertanto, add1_stmt ha due nodi associati. Un nodo è per l’istruzione stessa, cioè il letterale add1 corrispondente a KW_ADD1, e l’altro per il suo argomento, una regola testlist che è la regola di espressione di livello più alto.
Nota
La regola add1 qui è solo un esempio e non fa parte della grammatica standard di MicroPython.
Il quarto argomento in questo esempio è il token associato alla regola, KW_ADD1. Questo token dovrebbe essere definito nel lexer modificando py/lexer.h.
La stessa regola senza una funzione di compilazione si definisce usando la macro DEF_RULE_NC e omettendo l’argomento della funzione di compilazione:
DEF_RULE_NC(add1_stmt, and(2), tok(KW_ADD1), rule(testlist))
I restanti argomenti assumono lo stesso significato. Una regola senza funzione di compilazione deve essere gestita esplicitamente da tutte le regole che possono avere questa regola come nodo. Tali regole NC sono solitamente usate per esprimere sotto-parti di una struttura grammaticale complessa che non può essere espressa in un’unica regola.
Nota
Le macro DEF_RULE e DEF_RULE_NC accettano altri argomenti. Per una comprensione approfondita dei parametri supportati, consulta py/grammar.h.
Aggiungere un token lessicale¶
Ogni regola definita nella grammatica dovrebbe avere un token associato che sia definito in py/lexer.h. Aggiungi questo token modificando l’enum _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;
Modifica poi anche py/lexer.c per aggiungere il testo letterale della nuova parola chiave:
static const char *const tok_kw[] = {
...
"or",
"pass",
"raise",
"return",
"try",
"while",
"with",
"yield",
"add1",
...
};
Nota che la parola chiave viene nominata in base a ciò che desideri. Per coerenza, mantieni di conseguenza lo standard di denominazione.
Nota
L’ordine di queste parole chiave in py/lexer.c deve corrispondere all’ordine dei token nell’enum definito in py/lexer.h.
Analisi sintattica¶
Nella fase di analisi sintattica il parser prende i token prodotti dal lexer e li converte in un albero sintattico astratto (AST) o parse tree. L’implementazione del parser è definita in py/parse.c.
Il parser mantiene anche una tabella di costanti da usare in diversi aspetti dell’analisi, in modo simile a quanto fa una symbol table.
Durante questa fase vengono eseguite diverse ottimizzazioni come il constant folding sugli interi per la maggior parte delle operazioni, ad esempio logiche, binarie, unarie, ecc., e miglioramenti di ottimizzazione sulle parentesi attorno alle espressioni, insieme ad alcune ottimizzazioni sulle stringhe.
Vale la pena notare che le docstring vengono scartate e non sono accessibili al compilatore. Anche ottimizzazioni come lo string interning non vengono applicate alle docstring.
Passaggi del compilatore¶
Come molti compilatori, MicroPython compila tutto il codice in bytecode MicroPython o in codice nativo. La funzionalità che realizza ciò è implementata in py/compile.c. Il metodo più rilevante che dovresti conoscere è questo:
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);
}
Il compilatore compila il codice in quattro passaggi: scope, dimensione dello stack, dimensione del codice ed emit. Ogni passaggio esegue lo stesso codice C sulla stessa struttura dati AST, calcolando ogni volta cose diverse in base ai risultati del passaggio precedente.
Primo passaggio¶
Nel primo passaggio, il compilatore apprende gli identificatori noti (variabili) e il loro scope, che sia globale, locale, chiuso (closed over), ecc. Nello stesso passaggio l’emettitore (bytecode o codice nativo) calcola anche il numero di label necessarie per il codice emesso.
// 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);
}
}
}
...
}
Secondo e terzo passaggio¶
Il secondo e il terzo passaggio comportano il calcolo della dimensione dello stack Python e della dimensione del codice per il bytecode o il codice nativo. Dopo il terzo passaggio la dimensione del codice non può cambiare, altrimenti le label di salto risulterebbero errate.
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);
}
...
}
Appena prima del passaggio due c’è una selezione del tipo di codice da emettere, che può essere nativo oppure 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;
}
L’opzione bytecode è quella predefinita, ma una particolarità da notare per l’opzione del codice nativo è che esiste un’altra opzione tramite VIPER. Consulta la sezione Emettere codice nativo per maggiori dettagli sulle annotazioni viper.
Esiste anche il supporto per il codice assembly inline, in cui le istruzioni assembly vengono scritte come chiamate di funzione Python ma emesse direttamente come il corrispondente codice macchina. Questo assemblatore ha solo tre passaggi (scope, dimensione del codice, emit) e usa un’implementazione diversa, non la funzione compile_scope. Consulta il riferimento dell’assemblatore inline per maggiori dettagli.
Quarto passaggio¶
Il quarto passaggio emette il codice finale che può essere eseguito, sia esso bytecode nella macchina virtuale, oppure codice nativo direttamente dalla 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);
}
}
Emettere bytecode¶
Le istruzioni nel codice Python corrispondono di solito al bytecode emesso, ad esempio a + b genera «push a», poi «push b», poi «binary op add». Alcune istruzioni non emettono nulla ma influenzano invece altre cose come lo scope delle variabili, ad esempio global a.
L’implementazione di una funzione che emette bytecode si presenta in modo simile a questo:
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);
}
Qui usiamo come esempio le espressioni con operatore unario, ma i dettagli di implementazione sono simili per altre istruzioni/espressioni. Il metodo emit_write_bytecode_byte() è un wrapper attorno alla funzione principale emit_get_cur_to_write_bytecode() che tutte le funzioni devono chiamare per emettere bytecode.
Emettere codice nativo¶
Analogamente a come viene generato il bytecode, dovrebbe esserci una funzione corrispondente in py/emitnative.c per ogni istruzione di codice:
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]);
}
}
La differenza qui è che dobbiamo gestire la tipizzazione viper. Le annotazioni viper ci consentono di gestire più di un tipo di variabile. Per impostazione predefinita tutte le variabili sono oggetti Python, ma con viper una variabile può essere dichiarata anche come variabile tipizzata a livello macchina, come un intero o un puntatore nativo. Viper può essere considerato un superset di Python, in cui i normali oggetti Python vengono gestiti come al solito, mentre le variabili macchina native vengono gestite in modo ottimizzato usando istruzioni macchina dirette per le operazioni. La tipizzazione viper può rompere l’equivalenza con Python perché, ad esempio, gli interi diventano interi nativi e possono andare in overflow (a differenza degli interi Python che si estendono automaticamente a precisione arbitraria).