O Compilador

O processo de compilação no MicroPython envolve os seguintes passos:

  • O lexer converte o fluxo de texto que compõe um programa MicroPython em tokens.

  • O parser converte então os tokens numa sintaxe abstrata (árvore de análise sintática).

  • Em seguida, é emitido bytecode ou código nativo com base na árvore de análise sintática.

Para efeitos desta discussão, vamos adicionar uma funcionalidade de linguagem simples add1 que pode ser utilizada em Python da seguinte forma:

>>> add1 3
4
>>>

A instrução add1 recebe um inteiro como argumento e adiciona 1 ao mesmo.

Adicionar uma regra gramatical

A gramática do MicroPython é baseada na gramática do CPython e está definida em py/grammar.h. Esta gramática é a que é utilizada para analisar os ficheiros fonte do MicroPython.

Existem dois macros que precisa de conhecer para definir uma regra gramatical: 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 tem função de compilação (NC) associada.

Uma definição gramatical simples com uma função de compilação para a nossa nova instrução add1 tem o seguinte aspeto:

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 converter esta regra em código executável.

O terceiro argumento obrigatório pode ser or ou and. Este especifica o número de nós associados a uma instrução. Por exemplo, neste caso, a nossa instrução add1 é semelhante a ADD1 em linguagem assembly. Recebe um argumento numérico. Por isso, o add1_stmt tem dois nós associados. Um nó é para a própria instrução, ou seja, o literal add1 correspondente a KW_ADD1, e o outro para o 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. Este token deve ser definido no lexer editando py/lexer.h.

Definir a mesma regra sem uma função de compilação é conseguido utilizando o 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 restantes argumentos têm o mesmo significado. Uma regra sem função de compilação deve ser tratada explicitamente por todas as regras que possam ter esta regra como nó. Tais regras NC são normalmente utilizadas para expressar sub-partes de uma estrutura gramatical complexa que não pode ser expressa numa única regra.

Nota

Os macros DEF_RULE e DEF_RULE_NC aceitam outros argumentos. Para uma compreensão aprofundada dos parâmetros suportados, consulte py/grammar.h.

Adicionar um token lexical

Todas as regras definidas na gramática devem ter um token associado que esteja definido em py/lexer.h. Adicione este 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",
    ...
};

Note que a palavra-chave é nomeada em função do que se pretende que seja. Para manter a consistência, siga o padrão de nomenclatura estabelecido.

Nota

A ordem destas palavras-chave em py/lexer.c deve corresponder à ordem dos tokens no enum definido em py/lexer.h.

Análise sintática

Na fase de análise sintática, o parser recebe os tokens produzidos pelo lexer e converte-os numa árvore de sintaxe abstrata (AST) ou árvore de análise sintática. A implementação do parser está definida em py/parse.c.

O parser mantém também uma tabela de constantes para utilização em diferentes aspetos da análise sintática, de forma semelhante ao que faz uma tabela de símbolos.

Durante esta fase são realizadas várias otimizações, como o constant folding em inteiros para a maioria das operações (por exemplo, lógicas, binárias, unárias, etc.) e melhorias de otimização em parênteses em torno de expressões, bem como algumas otimizações em strings.

Vale a pena notar que as docstrings são descartadas e não estão acessíveis ao compilador. Mesmo otimizações como o string interning não são aplicadas às docstrings.

Passagens do compilador

Como muitos compiladores, o MicroPython compila todo o código para bytecode MicroPython ou código nativo. A funcionalidade que o consegue está implementada em py/compile.c. O método mais relevante que 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: âmbito, 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, calculando coisas diferentes em cada vez com base nos resultados da passagem anterior.

Primeira passagem

Na primeira passagem, o compilador aprende sobre os identificadores (variáveis) conhecidos e o seu âmbito, sendo global, local, fechado, etc. Na mesma passagem, o emissor (bytecode ou código nativo) também calcula o número de etiquetas necessárias 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 terceira passagens envolvem o cálculo do tamanho da pilha Python e do tamanho do código para bytecode ou código nativo. Após a terceira passagem, o tamanho do código não pode mudar; caso contrário, as etiquetas de salto ficarão incorretas.

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

    ...
}

Imediatamente antes da segunda passagem, há uma seleção para o tipo de código a emitir, 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 bytecode é a predefinição, mas algo único a notar para a opção de código nativo é que existe outra opção através de VIPER. Consulte a secção Emitir código nativo para mais detalhes sobre as anotações viper.

Existe também suporte para código de 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 máquina correspondente. Este assembler tem apenas três passagens (âmbito, tamanho do código, emissão) e utiliza 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, quer como bytecode na máquina virtual, quer 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);
    }
}

Emitir bytecode

As instruções no código Python geralmente correspondem ao 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 afetam outras coisas como o âmbito das variáveis, por exemplo global a.

A implementação de uma função que emite bytecode tem um aspeto semelhante a este:

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

Utilizamos as expressões de operadores unários 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.

Emitir código nativo

De forma semelhante à geração de bytecode, deve existir 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 de lidar com a tipagem viper. As anotações viper permitem-nos tratar mais de um tipo de variável. Por predefinição, todas as variáveis são objetos Python, mas com viper uma variável também pode ser declarada como uma variável de tipo nativo de máquina, como um inteiro nativo ou um ponteiro. O viper pode ser visto como um superconjunto de Python, onde os objetos Python normais são tratados da forma habitual, enquanto as variáveis nativas de máquina são tratadas de forma otimizada utilizando instruções de máquina diretas para as operações. A tipagem viper pode quebrar a equivalência com Python porque, por exemplo, os inteiros tornam-se inteiros nativos e podem causar overflow (ao contrário dos inteiros Python que se expandem automaticamente para precisão arbitrária).