編譯器¶
MicroPython 的編譯過程包含以下步驟:
詞法分析器(lexer)會將構成 MicroPython 程式的文字串流轉換成詞元(tokens)。
接著語法分析器(parser)會將這些詞元轉換成抽象語法(剖析樹)。
然後再根據剖析樹產生位元組碼(bytecode)或原生碼(native code)。
為了便於本節討論,我們將新增一個簡單的語言功能 add1,它在 Python 中的用法如下:
>>> add1 3
4
>>>
add1 陳述式接受一個整數作為引數,並對它加上 1。
新增語法規則¶
MicroPython 的語法是以 CPython 語法 為基礎,並定義於 py/grammar.h 中。此語法用於剖析 MicroPython 原始檔。
要定義語法規則,你需要認識兩個巨集:DEF_RULE 與 DEF_RULE_NC。DEF_RULE 可讓你定義一條附帶對應編譯函式的規則,而 DEF_RULE_NC 則沒有對應的編譯(NC)函式。
我們新的 add1 陳述式所對應、附帶編譯函式的簡單語法定義如下:
DEF_RULE(add1_stmt, c(add1_stmt), and(2), tok(KW_ADD1), rule(testlist))
第二個引數 c(add1_stmt) 是對應的編譯函式,應在 py/compile.c 中實作,用以將此規則轉換為可執行的程式碼。
第三個必要引數可以是 or 或 and,用來指定與某個陳述式相關聯的節點數量。舉例來說,在這個例子中,我們的 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_RULE 與 DEF_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。
語法分析器還會維護一個常數表,供剖析的各個面向使用,其作用類似於符號表。
在此階段會進行多項最佳化,例如對整數在大多數運算(如邏輯、二元、一元等)上進行常數摺疊,以及對運算式外圍括號的最佳化增強,同時也會對字串進行一些最佳化。
值得注意的是,docstrings(說明字串)會被捨棄,編譯器無法存取。即使是字串駐留這類最佳化,也不會套用於 docstrings。
編譯器的多趟掃描¶
如同許多編譯器,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);
}
編譯器分四趟(passes)來編譯程式碼:作用域(scope)、堆疊大小(stack size)、程式碼大小(code size)與發射(emit)。每一趟都會對同一份 AST 資料結構執行相同的 C 程式碼,並根據前一趟的結果,在每次執行時計算不同的內容。
第一趟¶
在第一趟中,編譯器會了解已知的識別字(變數)及其作用域,例如全域、區域、閉包擷取(closed over)等。在同一趟中,發射器(位元組碼或原生碼)也會計算所發射程式碼所需的標籤數量。
// 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 註記的更多細節,請參閱 發射原生碼 一節。
此外也支援行內組合語言碼(inline assembly code),其中組合語言指令是以 Python 函式呼叫的形式撰寫,但會直接發射為對應的機器碼。這個組譯器只有三趟(scope、code size、emit),並使用不同的實作,而非 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 a」、再「push 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() 是主函式 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 註記讓我們能處理一種以上的變數型別。預設情況下所有變數都是 Python 物件,但有了 viper,變數也可以宣告為機器型別變數,例如原生整數或指標。Viper 可視為 Python 的超集合:一般 Python 物件照常處理,而原生機器變數則透過直接使用機器指令來執行運算,以最佳化的方式處理。Viper 型別標註可能會破壞與 Python 的等價性,因為舉例來說,整數會變成原生整數而可能溢位(不像 Python 整數會自動延伸為任意精度)。