El compilador¶
El proceso de compilación en MicroPython consta de los siguientes pasos:
El analizador léxico (lexer) convierte el flujo de texto que compone un programa de MicroPython en tokens.
A continuación, el analizador sintáctico (parser) convierte los tokens en una sintaxis abstracta (árbol de análisis).
Después se emite el bytecode o el código nativo a partir del árbol de análisis.
Para los fines de esta explicación vamos a añadir una característica de lenguaje sencilla, add1, que puede usarse en Python de la siguiente manera:
>>> add1 3
4
>>>
La sentencia add1 toma un entero como argumento y le suma 1.
Añadir una regla gramatical¶
La gramática de MicroPython se basa en la gramática de CPython y está definida en py/grammar.h. Esta gramática es la que se usa para analizar los archivos fuente de MicroPython.
Hay dos macros que necesitas conocer para definir una regla gramatical: DEF_RULE y DEF_RULE_NC. DEF_RULE te permite definir una regla con una función de compilación asociada, mientras que DEF_RULE_NC no tiene función de compilación (NC) asociada.
Una definición gramatical sencilla con una función de compilación para nuestra nueva sentencia add1 tiene el siguiente aspecto:
DEF_RULE(add1_stmt, c(add1_stmt), and(2), tok(KW_ADD1), rule(testlist))
El segundo argumento, c(add1_stmt), es la función de compilación correspondiente que debe implementarse en py/compile.c para convertir esta regla en código ejecutable.
El tercer argumento obligatorio puede ser or o and. Esto especifica el número de nodos asociados a una sentencia. Por ejemplo, en este caso, nuestra sentencia add1 es similar a ADD1 en lenguaje ensamblador. Toma un argumento numérico. Por lo tanto, la regla add1_stmt tiene dos nodos asociados. Un nodo corresponde a la sentencia en sí, es decir, el literal add1 que se corresponde con KW_ADD1, y el otro a su argumento, una regla testlist que es la regla de expresión de nivel superior.
Nota
La regla add1 aquí es solo un ejemplo y no forma parte de la gramática estándar de MicroPython.
El cuarto argumento de este ejemplo es el token asociado a la regla, KW_ADD1. Este token debe definirse en el analizador léxico editando py/lexer.h.
Para definir la misma regla sin una función de compilación se usa la macro DEF_RULE_NC y se omite el argumento de la función de compilación:
DEF_RULE_NC(add1_stmt, and(2), tok(KW_ADD1), rule(testlist))
Los argumentos restantes tienen el mismo significado. Una regla sin función de compilación debe gestionarse explícitamente por todas las reglas que puedan tener esta regla como nodo. Estas reglas NC se usan normalmente para expresar subpartes de una estructura gramatical complicada que no puede expresarse en una sola regla.
Nota
Las macros DEF_RULE y DEF_RULE_NC aceptan otros argumentos. Para comprender en profundidad los parámetros admitidos, consulta py/grammar.h.
Añadir un token léxico¶
Toda regla definida en la gramática debe tener un token asociado que esté definido en py/lexer.h. Añade este token editando la enumeración _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;
Después edita también py/lexer.c para añadir el texto literal de la nueva palabra clave:
static const char *const tok_kw[] = {
...
"or",
"pass",
"raise",
"return",
"try",
"while",
"with",
"yield",
"add1",
...
};
Observa que la palabra clave se nombra según lo que quieras que sea. Por coherencia, mantén el estándar de nomenclatura en consecuencia.
Nota
El orden de estas palabras clave en py/lexer.c debe coincidir con el orden de los tokens en la enumeración definida en py/lexer.h.
Análisis sintáctico¶
En la etapa de análisis sintáctico, el parser toma los tokens producidos por el lexer y los convierte en un árbol de sintaxis abstracta (AST) o árbol de análisis. La implementación del parser está definida en py/parse.c.
El parser también mantiene una tabla de constantes para usar en distintos aspectos del análisis, de forma similar a lo que hace una tabla de símbolos.
Durante esta fase se realizan varias optimizaciones, como el plegado de constantes sobre enteros para la mayoría de las operaciones (lógicas, binarias, unarias, etc.), así como mejoras de optimización en torno a los paréntesis de las expresiones, junto con algunas optimizaciones sobre las cadenas.
Cabe destacar que las docstrings se descartan y no son accesibles para el compilador. Ni siquiera optimizaciones como el internado de cadenas se aplican a las docstrings.
Pasadas del compilador¶
Como muchos compiladores, MicroPython compila todo el código a bytecode de MicroPython o a código nativo. La funcionalidad que logra esto está implementada en py/compile.c. El método más relevante que debes conocer es 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);
}
El compilador compila el código en cuatro pasadas: ámbito (scope), tamaño de la pila, tamaño del código y emisión. Cada pasada ejecuta el mismo código C sobre la misma estructura de datos del AST, calculando cosas diferentes cada vez en función de los resultados de la pasada anterior.
Primera pasada¶
En la primera pasada, el compilador averigua los identificadores conocidos (variables) y su ámbito, ya sea global, local, capturado (closed over), etc. En la misma pasada, el emisor (de bytecode o código nativo) también calcula el número de etiquetas necesarias para el 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 y tercera pasadas¶
La segunda y la tercera pasada implican calcular el tamaño de la pila de Python y el tamaño del código para el bytecode o el código nativo. Después de la tercera pasada, el tamaño del código no puede cambiar, de lo contrario las etiquetas de salto serían incorrectas.
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);
}
...
}
Justo antes de la segunda pasada se selecciona el tipo de código que se va a emitir, que puede ser nativo o 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;
}
La opción de bytecode es la predeterminada, pero algo único a destacar de la opción de código nativo es que existe otra opción a través de VIPER. Consulta la sección Emitir código nativo para más detalles sobre las anotaciones de viper.
También hay soporte para código ensamblador en línea, donde las instrucciones de ensamblador se escriben como llamadas a funciones de Python pero se emiten directamente como el código máquina correspondiente. Este ensamblador tiene solo tres pasadas (ámbito, tamaño del código, emisión) y usa una implementación diferente, no la función compile_scope. Consulta la referencia del ensamblador en línea para más detalles.
Cuarta pasada¶
La cuarta pasada emite el código final que puede ejecutarse, ya sea bytecode en la máquina virtual o código nativo directamente por la 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¶
Las sentencias del código Python normalmente se corresponden con el bytecode emitido; por ejemplo, a + b genera «push a», luego «push b» y después «binary op add». Algunas sentencias no emiten nada, sino que afectan a otras cosas como el ámbito de las variables; por ejemplo, global a.
La implementación de una función que emite bytecode tiene un aspecto similar 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);
}
Aquí usamos las expresiones de operador unario como ejemplo, pero los detalles de implementación son similares para otras sentencias o expresiones. El método emit_write_bytecode_byte() es un envoltorio en torno a la función principal emit_get_cur_to_write_bytecode() que todas las funciones deben llamar para emitir bytecode.
Emitir código nativo¶
De forma similar a como se genera el bytecode, debería haber una función correspondiente en py/emitnative.c para cada sentencia 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]);
}
}
La diferencia aquí es que tenemos que gestionar el tipado viper. Las anotaciones viper nos permiten manejar más de un tipo de variable. De forma predeterminada todas las variables son objetos de Python, pero con viper una variable también puede declararse como una variable de tipo máquina, como un entero nativo o un puntero. Viper puede considerarse un superconjunto de Python, donde los objetos normales de Python se manejan como de costumbre, mientras que las variables máquina nativas se manejan de forma optimizada usando instrucciones máquina directas para las operaciones. El tipado viper puede romper la equivalencia con Python porque, por ejemplo, los enteros se convierten en enteros nativos y pueden desbordarse (a diferencia de los enteros de Python, que se extienden automáticamente a precisión arbitraria).