Le compilateur

Le processus de compilation dans MicroPython comporte les étapes suivantes :

  • L’analyseur lexical (lexer) convertit le flux de texte qui compose un programme MicroPython en jetons (tokens).

  • L’analyseur syntaxique (parser) convertit ensuite les jetons en une syntaxe abstraite (arbre d’analyse).

  • Puis le bytecode ou le code natif est généré à partir de l’arbre d’analyse.

Pour les besoins de cette discussion, nous allons ajouter une fonctionnalité de langage simple add1 qui peut être utilisée en Python comme suit :

>>> add1 3
4
>>>

L’instruction add1 prend un entier en argument et lui ajoute 1.

Ajout d’une règle de grammaire

La grammaire de MicroPython est basée sur la grammaire CPython et est définie dans py/grammar.h. C’est cette grammaire qui est utilisée pour analyser les fichiers source MicroPython.

Il y a deux macros que vous devez connaître pour définir une règle de grammaire : DEF_RULE et DEF_RULE_NC. DEF_RULE vous permet de définir une règle avec une fonction de compilation associée, tandis que DEF_RULE_NC n’a pas de fonction de compilation (NC).

Une définition de grammaire simple avec une fonction de compilation pour notre nouvelle instruction add1 ressemble à ceci :

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

Le deuxième argument c(add1_stmt) est la fonction de compilation correspondante qui doit être implémentée dans py/compile.c pour transformer cette règle en code exécutable.

Le troisième argument requis peut être or ou and. Il spécifie le nombre de nœuds associés à une instruction. Par exemple, dans ce cas, notre instruction add1 est similaire à ADD1 en langage assembleur. Elle prend un argument numérique. Par conséquent, add1_stmt possède deux nœuds qui lui sont associés. Un nœud est pour l’instruction elle-même, c’est-à-dire le littéral add1 correspondant à KW_ADD1, et l’autre pour son argument, une règle testlist qui est la règle d’expression de plus haut niveau.

Note

La règle add1 ici n’est qu’un exemple et ne fait pas partie de la grammaire standard de MicroPython.

Le quatrième argument dans cet exemple est le jeton associé à la règle, KW_ADD1. Ce jeton doit être défini dans l’analyseur lexical en modifiant py/lexer.h.

La définition de la même règle sans fonction de compilation s’obtient en utilisant la macro DEF_RULE_NC et en omettant l’argument de la fonction de compilation :

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

Les arguments restants conservent la même signification. Une règle sans fonction de compilation doit être traitée explicitement par toutes les règles qui peuvent avoir cette règle comme nœud. De telles règles NC sont généralement utilisées pour exprimer des sous-parties d’une structure grammaticale complexe qui ne peuvent pas être exprimées en une seule règle.

Note

Les macros DEF_RULE et DEF_RULE_NC prennent d’autres arguments. Pour une compréhension approfondie des paramètres pris en charge, voir py/grammar.h.

Ajout d’un jeton lexical

Chaque règle définie dans la grammaire doit avoir un jeton associé qui est défini dans py/lexer.h. Ajoutez ce jeton en modifiant l’énumération _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;

Modifiez ensuite également py/lexer.c pour ajouter le nouveau texte littéral du mot-clé :

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

Notez que le mot-clé est nommé selon ce que vous souhaitez qu’il soit. Pour des raisons de cohérence, respectez la norme de nommage en conséquence.

Note

L’ordre de ces mots-clés dans py/lexer.c doit correspondre à l’ordre des jetons dans l’énumération définie dans py/lexer.h.

Analyse syntaxique

Lors de l’étape d’analyse syntaxique, l’analyseur prend les jetons produits par l’analyseur lexical et les convertit en un arbre de syntaxe abstraite (AST) ou arbre d’analyse. L’implémentation de l’analyseur est définie dans py/parse.c.

L’analyseur maintient également une table de constantes destinée à différents aspects de l’analyse, à l’instar de ce que fait une table des symboles.

Plusieurs optimisations comme le repliement des constantes sur les entiers pour la plupart des opérations, par exemple logiques, binaires, unaires, etc., ainsi que des améliorations d’optimisation sur les parenthèses autour des expressions, sont effectuées durant cette phase, de même que certaines optimisations sur les chaînes de caractères.

Il convient de noter que les docstrings sont supprimées et ne sont pas accessibles au compilateur. Même les optimisations comme l”internement des chaînes ne sont pas appliquées aux docstrings.

Passes du compilateur

Comme de nombreux compilateurs, MicroPython compile tout le code en bytecode MicroPython ou en code natif. La fonctionnalité qui réalise cela est implémentée dans py/compile.c. La méthode la plus pertinente que vous devez connaître est celle-ci :

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

Le compilateur compile le code en quatre passes : portée (scope), taille de la pile, taille du code et émission. Chaque passe exécute le même code C sur la même structure de données AST, en calculant des éléments différents à chaque fois en fonction des résultats de la passe précédente.

Première passe

Lors de la première passe, le compilateur prend connaissance des identifiants connus (variables) et de leur portée, qu’elle soit globale, locale, capturée par fermeture, etc. Dans la même passe, l’émetteur (de bytecode ou de code natif) calcule également le nombre d’étiquettes nécessaires pour le code émis.

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

Deuxième et troisième passes

Les deuxième et troisième passes consistent à calculer la taille de la pile Python et la taille du code pour le bytecode ou le code natif. Après la troisième passe, la taille du code ne peut plus changer, sinon les étiquettes de saut seront incorrectes.

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

    ...
}

Juste avant la passe deux, il y a une sélection du type de code à émettre, qui peut être soit natif soit 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’option bytecode est l’option par défaut, mais un point particulier à noter pour l’option code natif est qu’il existe une autre option via VIPER. Voir la section Émission de code natif pour plus de détails sur les annotations viper.

Il existe également une prise en charge de l”assembleur en ligne, où les instructions d’assemblage sont écrites sous forme d’appels de fonctions Python mais sont émises directement sous forme du code machine correspondant. Cet assembleur n’a que trois passes (portée, taille du code, émission) et utilise une implémentation différente, et non la fonction compile_scope. Voir la référence de l’assembleur en ligne pour plus de détails.

Quatrième passe

La quatrième passe émet le code final qui peut être exécuté, soit du bytecode dans la machine virtuelle, soit du code natif directement par le 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);
    }
}

Émission du bytecode

Les instructions dans le code Python correspondent généralement à du bytecode émis ; par exemple a + b génère « push a » puis « push b » puis « binary op add ». Certaines instructions n’émettent rien mais affectent plutôt d’autres choses comme la portée des variables, par exemple global a.

L’implémentation d’une fonction qui émet du bytecode ressemble à ceci :

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

Nous utilisons ici les expressions d’opérateur unaire comme exemple, mais les détails d’implémentation sont similaires pour les autres instructions/expressions. La méthode emit_write_bytecode_byte() est une enveloppe autour de la fonction principale emit_get_cur_to_write_bytecode() que toutes les fonctions doivent appeler pour émettre du bytecode.

Émission de code natif

De la même manière que le bytecode est généré, il devrait y avoir une fonction correspondante dans py/emitnative.c pour chaque instruction de code :

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 différence ici est que nous devons gérer le typage viper. Les annotations viper nous permettent de gérer plus d’un type de variable. Par défaut, toutes les variables sont des objets Python, mais avec viper une variable peut également être déclarée comme une variable à type machine, comme un entier natif ou un pointeur. Viper peut être considéré comme un sur-ensemble de Python, où les objets Python normaux sont traités comme d’habitude, tandis que les variables machine natives sont traitées de manière optimisée en utilisant directement des instructions machine pour les opérations. Le typage viper peut rompre l’équivalence avec Python car, par exemple, les entiers deviennent des entiers natifs et peuvent déborder (contrairement aux entiers Python qui s’étendent automatiquement à une précision arbitraire).