编译器

MicroPython 中的编译过程包含以下步骤:

  • 词法分析器(lexer)将构成 MicroPython 程序的文本流转换为词法单元(token)。

  • 随后,语法分析器(parser)将词法单元转换为抽象语法(语法分析树)。

  • 然后根据语法分析树生成字节码或原生代码。

为了便于讨论,我们将添加一个简单的语言特性 add1,它在 Python 中可以这样使用:

>>> add1 3
4
>>>

add1 语句接受一个整数作为参数,并将其加 1

添加语法规则

MicroPython 的语法基于 CPython 语法,并在 py/grammar.h 中定义。该语法用于解析 MicroPython 源文件。

定义语法规则时需要了解两个宏:DEF_RULEDEF_RULE_NCDEF_RULE 允许你定义一个带有关联编译函数的规则,而 DEF_RULE_NC 则没有编译函数(NC 即 no compile)。

为我们新的 add1 语句定义一个带编译函数的简单语法,如下所示:

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

第二个参数 c(add1_stmt) 是对应的编译函数,它应在 py/compile.c 中实现,用于将该规则转换为可执行代码。

第三个必需参数可以是 orand。它指定与语句关联的节点数量。例如,在本例中,我们的 add1 语句类似于汇编语言中的 ADD1。它接受一个数字参数。因此,add1_stmt 关联有两个节点。一个节点对应语句本身,即对应于 KW_ADD1 的字面量 add1,另一个节点对应其参数,即作为顶层表达式规则的 testlist 规则。

备注

这里的 add1 规则只是一个示例,并非标准 MicroPython 语法的一部分。

本例中的第四个参数是与该规则关联的词法单元 KW_ADD1。该词法单元应通过编辑 py/lexer.h 在词法分析器中定义。

定义不带编译函数的相同规则可以通过使用 DEF_RULE_NC 宏并省略编译函数参数来实现:

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

其余参数含义相同。不带编译函数的规则必须由所有可能将该规则作为节点的规则显式处理。这类 NC 规则通常用于表达无法在单条规则中表达的复杂语法结构的子部分。

备注

DEF_RULEDEF_RULE_NC 还接受其他参数。要深入了解所支持的参数,请参阅 py/grammar.h

添加词法单元

在语法中定义的每条规则都应有一个与之关联的词法单元,并在 py/lexer.h 中定义。通过编辑 _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;

然后还需编辑 py/lexer.c 以添加新的关键字字面量文本:

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

请注意,关键字的命名取决于你希望它叫什么。为保持一致性,请相应地遵循命名规范。

备注

py/lexer.c 中这些关键字的顺序必须与 py/lexer.h 中定义的枚举中词法单元的顺序一致。

解析

在解析阶段,语法分析器接收词法分析器生成的词法单元,并将它们转换为抽象语法树(AST)或语法分析树。语法分析器的实现定义在 py/parse.c 中。

语法分析器还维护一个常量表,用于解析的各个方面,类似于符号表 的作用。

在此阶段会执行多项优化,例如对大多数运算(如逻辑、二元、一元等)的整数进行常量折叠,以及对表达式括号进行优化增强,同时还会对字符串进行一些优化。

值得注意的是,文档字符串(docstring)会被丢弃,编译器无法访问它们。即使是字符串驻留(string interning) 这样的优化也不会应用于文档字符串

编译器的多遍处理

与许多编译器一样,MicroPython 将所有代码编译为 MicroPython 字节码或原生代码。实现此功能的代码位于 py/compile.c 中。你应当了解的最相关的方法是:

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

编译器分四遍编译代码:作用域、栈大小、代码大小和生成。每一遍都对相同的 AST 数据结构运行相同的 C 代码,每次根据上一遍的结果计算不同的内容。

第一遍

在第一遍中,编译器了解已知的标识符(变量)及其作用域,包括全局、局部、闭包捕获等。在同一遍中,生成器(字节码或原生代码)还会计算所生成代码所需的标签数量。

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

第二遍和第三遍

第二遍和第三遍涉及计算字节码或原生代码的 Python 栈大小和代码大小。在第三遍之后,代码大小不能再改变,否则跳转标签将不正确。

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

    ...
}

在第二遍之前,会对要生成的代码类型进行一次选择,可以是原生代码或字节码。

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

字节码选项是默认选项,但对于原生代码选项有一点独特之处需要注意,即还可以通过 VIPER 选用另一种选项。有关 viper 注解的更多详情,请参阅 生成原生代码 一节。

此外还支持内联汇编代码,其中汇编指令以 Python 函数调用的形式编写,但会直接生成为对应的机器代码。该汇编器只有三遍(作用域、代码大小、生成),并使用不同的实现,而非 compile_scope 函数。更多详情请参阅 内联汇编器参考

第四遍

第四遍生成可执行的最终代码,可以是在虚拟机中执行的字节码,也可以是由 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);
    }
}

生成字节码

Python 代码中的语句通常对应于所生成的字节码,例如 a + b 会生成“压入 a”、然后“压入 b”、然后“二元运算加法”。某些语句不生成任何代码,而是影响其他事物,例如变量的作用域,比如 global a

生成字节码的函数的实现类似于这样:

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

这里我们以一元运算符表达式为例,但其他语句/表达式的实现细节是类似的。方法 emit_write_bytecode_byte() 是对主函数 emit_get_cur_to_write_bytecode() 的封装,所有函数都必须调用后者来生成字节码。

生成原生代码

与字节码的生成方式类似,py/emitnative.c 中应为每个代码语句提供一个对应的函数:

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

这里的区别在于我们必须处理viper 类型(viper typing)。Viper 注解允许我们处理不止一种类型的变量。默认情况下,所有变量都是 Python 对象,但借助 viper,变量也可以声明为机器类型变量,例如原生整数或指针。可以将 viper 视为 Python 的一个超集,其中普通的 Python 对象照常处理,而原生机器变量则通过对运算使用直接的机器指令以优化的方式处理。Viper 类型可能会破坏与 Python 的等价性,因为例如整数会变成原生整数并可能溢出(不同于会自动扩展到任意精度的 Python 整数)。