O Compilador¶
O processo de compilação no MicroPython envolve as seguintes etapas:
O lexer converte o fluxo de texto que compõe um programa MicroPython em tokens.
O parser então converte os tokens em uma sintaxe abstrata (árvore de análise).
Em seguida, o bytecode ou o código nativo é emitido com base na árvore de análise.
Para os fins desta discussão, vamos adicionar um recurso de linguagem simples, add1, que pode ser usado em Python da seguinte forma:
>>> add1 3
4
>>>
A instrução add1 recebe um inteiro como argumento e adiciona 1 a ele.
Adicionando uma regra de gramática¶
A gramática do MicroPython é baseada na gramática do CPython e é definida em py/grammar.h. Essa gramática é o que é usado para analisar os arquivos-fonte do MicroPython.
Há duas macros que você precisa conhecer para definir uma regra de gramática: DEF_RULE e DEF_RULE_NC. DEF_RULE permite definir uma regra com uma função de compilação associada, enquanto DEF_RULE_NC não possui função de compilação (NC) para ela.
Uma definição de gramática simples com uma função de compilação para a nossa nova instrução add1 se parece com a seguinte:
DEF_RULE(add1_stmt, c(add1_stmt), and(2), tok(KW_ADD1), rule(testlist))
O segundo argumento c(add1_stmt) é a função de compilação correspondente que deve ser implementada em py/compile.c para transformar essa regra em código executável.
O terceiro argumento obrigatório pode ser or ou and. Isso especifica o número de nós associados a uma instrução. Por exemplo, neste caso, nossa instrução add1 é semelhante ao ADD1 em linguagem assembly. Ela recebe um argumento numérico. Portanto, a add1_stmt tem dois nós associados a ela. Um nó é para a própria instrução, ou seja, o literal add1 correspondente a KW_ADD1, e o outro para seu argumento, uma regra testlist, que é a regra de expressão de nível superior.
Nota
A regra add1 aqui é apenas um exemplo e não faz parte da gramática padrão do MicroPython.
O quarto argumento neste exemplo é o token associado à regra, KW_ADD1. Esse token deve ser definido no lexer editando py/lexer.h.
Definir a mesma regra sem uma função de compilação é feito usando a macro DEF_RULE_NC e omitindo o argumento da função de compilação:
DEF_RULE_NC(add1_stmt, and(2), tok(KW_ADD1), rule(testlist))
Os demais argumentos mantêm o mesmo significado. Uma regra sem função de compilação deve ser tratada explicitamente por todas as regras que possam ter essa regra como um nó. Essas regras NC são geralmente usadas para expressar subpartes de uma estrutura gramatical complicada que não podem ser expressas em uma única regra.
Nota
As macros DEF_RULE e DEF_RULE_NC recebem outros argumentos. Para uma compreensão aprofundada dos parâmetros suportados, consulte py/grammar.h.
Adicionando um token léxico¶
Toda regra definida na gramática deve ter um token associado a ela que seja definido em py/lexer.h. Adicione esse token editando o 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;
Em seguida, edite também py/lexer.c para adicionar o novo texto literal da palavra-chave:
static const char *const tok_kw[] = {
...
"or",
"pass",
"raise",
"return",
"try",
"while",
"with",
"yield",
"add1",
...
};
Observe que a palavra-chave é nomeada conforme o que você deseja que ela seja. Para manter a consistência, siga o padrão de nomenclatura adequadamente.
Nota
A ordem dessas palavras-chave em py/lexer.c deve corresponder à ordem dos tokens no enum definido em py/lexer.h.
Análise (Parsing)¶
Na etapa de análise, o parser pega os tokens produzidos pelo lexer e os converte em uma árvore de sintaxe abstrata (AST) ou árvore de análise. A implementação do parser é definida em py/parse.c.
O parser também mantém uma tabela de constantes para uso em diferentes aspectos da análise, semelhante ao que uma tabela de símbolos faz.
Diversas otimizações, como dobramento de constantes em inteiros para a maioria das operações, por exemplo, lógicas, binárias, unárias, etc., e aprimoramentos de otimização em parênteses ao redor de expressões são realizadas durante esta fase, juntamente com algumas otimizações em strings.
Vale notar que as docstrings são descartadas e não ficam acessíveis ao compilador. Mesmo otimizações como internação de strings não são aplicadas às docstrings.
Passagens do compilador¶
Como muitos compiladores, o MicroPython compila todo o código para bytecode do MicroPython ou para código nativo. A funcionalidade que realiza isso é implementada em py/compile.c. O método mais relevante que você deve conhecer é este:
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);
}
O compilador compila o código em quatro passagens: escopo, tamanho da pilha, tamanho do código e emissão. Cada passagem executa o mesmo código C sobre a mesma estrutura de dados AST, com coisas diferentes sendo calculadas a cada vez com base nos resultados da passagem anterior.
Primeira passagem¶
Na primeira passagem, o compilador descobre os identificadores conhecidos (variáveis) e seu escopo, seja global, local, fechado sobre (closed over), etc. Na mesma passagem, o emissor (de bytecode ou código nativo) também calcula o número de rótulos necessários para o código emitido.
// 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);
}
}
}
...
}
Segunda e terceira passagens¶
A segunda e a terceira passagens envolvem o cálculo do tamanho da pilha Python e do tamanho do código para o bytecode ou código nativo. Após a terceira passagem, o tamanho do código não pode mudar, caso contrário os rótulos de salto ficarão incorretos.
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);
}
...
}
Logo antes da segunda passagem há uma seleção do tipo de código a ser emitido, que pode ser nativo ou 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;
}
A opção de bytecode é o padrão, mas algo singular a se notar quanto à opção de código nativo é que há outra opção via VIPER. Consulte a seção Emitindo código nativo para mais detalhes sobre as anotações viper.
Há também suporte para código assembly inline, onde as instruções de assembly são escritas como chamadas de função Python, mas são emitidas diretamente como o código de máquina correspondente. Esse assembler tem apenas três passagens (escopo, tamanho do código, emissão) e usa uma implementação diferente, não a função compile_scope. Consulte a referência do assembler inline para mais detalhes.
Quarta passagem¶
A quarta passagem emite o código final que pode ser executado, seja como bytecode na máquina virtual, ou como código nativo diretamente pela 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);
}
}
Emitindo bytecode¶
Instruções no código Python geralmente correspondem a bytecode emitido, por exemplo, a + b gera “push a”, depois “push b” e depois “binary op add”. Algumas instruções não emitem nada, mas em vez disso afetam outras coisas, como o escopo das variáveis, por exemplo, global a.
A implementação de uma função que emite bytecode se parece com isto:
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);
}
Usamos as expressões de operador unário como exemplo aqui, mas os detalhes de implementação são semelhantes para outras instruções/expressões. O método emit_write_bytecode_byte() é um wrapper em torno da função principal emit_get_cur_to_write_bytecode() que todas as funções devem chamar para emitir bytecode.
Emitindo código nativo¶
De forma semelhante a como o bytecode é gerado, deve haver uma função correspondente em py/emitnative.c para cada instrução de código:
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]);
}
}
A diferença aqui é que temos que lidar com a tipagem viper. As anotações viper nos permitem lidar com mais de um tipo de variável. Por padrão, todas as variáveis são objetos Python, mas com o viper uma variável também pode ser declarada como uma variável de tipo de máquina, como um inteiro nativo ou um ponteiro. O viper pode ser entendido como um superconjunto do Python, em que os objetos Python normais são tratados como de costume, enquanto as variáveis nativas de máquina são tratadas de forma otimizada usando instruções de máquina diretas para as operações. A tipagem viper pode quebrar a equivalência com o Python porque, por exemplo, os inteiros se tornam inteiros nativos e podem sofrer overflow (diferentemente dos inteiros Python, que se estendem automaticamente para precisão arbitrária).