编译器

在 MicroPython 中,编译过程包括以下几个步骤:

  • 词法分析器将一个 MicroPython 程序的文本流转换为标记。

  • 语法分析器将标记转换为抽象语法(解析树)。

  • 基于解析树生成字节码或者本地代码。

为了验证,我们将添加一个简单的语言特性 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 有两个关联的节点。第一个节点是语句本身,即 add1 对应 KW_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 中定义的枚举中的标记顺序一致。

解析中

在解析阶段,解析器接受词法分析器产生的标记,并将其转换为一个抽象语法树( abstract syntax tree 简称 AST )或者 解析树。解析器的实现在 py/parse.c 中。

解析器还维护一个用于在解析中的不同方面使用的常量表,与 符号表 symbol table 所承载的能力类似。

在解析阶段会进行一些优化,比如对大多数操作(例如逻辑、二进制、单目运算)的整数进行 常量折叠 ,并且会对表达式周围的括号进行优化增强,同时也会对字符串进行一些优化。

值得注意的是,文档字符串 docstrings 会被丢弃,并且编译器无法访问。即使对像 字符串驻留 这样的特性也不会对文档字符串生效。

编译器步骤

像大多数编译器一样,MicroPython 编译所有代码到 MicroPython 字节码或者原生代码。这一功能在 py/compile.c 中实现。你应该了解的最直接相关的函数是:

mp_obj_t mp_compile(mp_parse_tree_t *parse_tree, qstr source_file, bool is_repl) {
    // Compile the input parse_tree to a raw-code structure.
    mp_raw_code_t *rc = mp_compile_to_raw_code(parse_tree, source_file, is_repl);
    // Create and return a function object that executes the outer module.
    return mp_make_function_from_raw_code(rc, MP_OBJ_NULL, MP_OBJ_NULL);
}

编译器会进行四步操作:作用域、栈大小、代码大小和发射。每次编译都会使用相同的 C 代码,并且使用相同的 AST 数据结构,每次编译都会使用上一次编译的结果来计算新的结果。

第一步

在第一次编译中,编译器会学习到已知的标识符(变量)的作用域,是全局的、局部的、闭包的等等。在同一次编译中,编译器(字节码或机器码 )也会计算出提交的代码所需的标签数量。

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

第二和第三步

第二和第三步都会计算出字节码或机器码的栈大小和代码大小。第三步之后,代码大小不能变,否则标签的位置会出错。

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 来设置。请参见 发射原生代码 小节的详细信息。

内联汇编代码 同样也是支持的,汇编指令是作为函数调用,但是会直接发射成对应的机器指令。这个汇编器只有三步(作用域、代码大小、发射),并且使用了一个不同的实现,而不是 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 会生成 push apush b 然后 binary op add。某些语句不会发射任何代码,但是会影响到变量的作用域,比如 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() 是一个包装函数,且所有的函数都必须调用它来发射字节码。

发射原生代码

与字节码一样,原生代码在 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 注解允许我们处理不止一种类型的变量。默认情况下,所有的变量都是 Python 对象,但是在 viper 中,变量也可以声明为一个机器类型的变量,比如一个原生的整数或者指针。viper 可以被认为是 Python 的超集,其中正常的 Python 对象像往常一样处理,而在处理机器类型的变量时,我们使用直接的机器指令以优化过的方式处理。但 viper 类型可能会破坏 Python 的等价性,比如整数会变成原生的整数,而且可能会溢出(和 Python 整数不同,它们不会自动扩展到任意精度)。