Trình biên dịch

Quá trình biên dịch trong MicroPython bao gồm các bước sau:

  • Bộ phân tích từ vựng (lexer) chuyển đổi luồng văn bản cấu thành một chương trình MicroPython thành các token.

  • Bộ phân tích cú pháp (parser) sau đó chuyển đổi các token thành cây cú pháp trừu tượng (cây phân tích cú pháp).

  • Sau đó, mã bytecode hoặc mã native được phát ra dựa trên cây phân tích cú pháp.

Để minh họa, chúng ta sẽ thêm một tính năng ngôn ngữ đơn giản add1 có thể dùng trong Python như sau:

>>> add1 3
4
>>>

Câu lệnh add1 nhận một số nguyên làm đối số và cộng 1 vào đó.

Thêm quy tắc ngữ pháp

Ngữ pháp của MicroPython dựa trên ngữ pháp CPython và được định nghĩa trong py/grammar.h. Ngữ pháp này được dùng để phân tích cú pháp các tệp nguồn MicroPython.

Có hai macro bạn cần biết để định nghĩa một quy tắc ngữ pháp: DEF_RULEDEF_RULE_NC. DEF_RULE cho phép bạn định nghĩa một quy tắc có hàm biên dịch đi kèm, trong khi DEF_RULE_NC không có hàm biên dịch (NC) cho nó.

Định nghĩa ngữ pháp đơn giản có hàm biên dịch cho câu lệnh add1 mới của chúng ta trông như sau:

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

Đối số thứ hai c(add1_stmt) là hàm biên dịch tương ứng cần được cài đặt trong py/compile.c để chuyển quy tắc này thành mã thực thi.

Đối số thứ ba bắt buộc có thể là or hoặc and. Tham số này xác định số lượng nút liên kết với một câu lệnh. Ví dụ, trong trường hợp này, câu lệnh add1 của chúng ta tương tự như ADD1 trong hợp ngữ. Nó nhận một đối số số. Do đó, add1_stmt có hai nút liên kết với nó. Một nút dành cho bản thân câu lệnh, tức là ký tự add1 tương ứng với KW_ADD1, và nút kia dành cho đối số của nó, một quy tắc testlist là quy tắc biểu thức cấp cao nhất.

Ghi chú

Quy tắc add1 ở đây chỉ là ví dụ và không phải là một phần của ngữ pháp MicroPython chuẩn.

Đối số thứ tư trong ví dụ này là token liên kết với quy tắc, KW_ADD1. Token này cần được định nghĩa trong bộ phân tích từ vựng bằng cách chỉnh sửa py/lexer.h.

Để định nghĩa quy tắc tương tự mà không có hàm biên dịch, dùng macro DEF_RULE_NC và bỏ qua đối số hàm biên dịch:

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

Các đối số còn lại mang ý nghĩa tương tự. Một quy tắc không có hàm biên dịch phải được xử lý tường minh bởi tất cả các quy tắc có thể có quy tắc này làm nút con. Các quy tắc NC như vậy thường được dùng để biểu diễn các phần con của một cấu trúc ngữ pháp phức tạp mà không thể diễn đạt trong một quy tắc duy nhất.

Ghi chú

Các macro DEF_RULEDEF_RULE_NC nhận các đối số khác. Để hiểu sâu hơn về các tham số được hỗ trợ, xem py/grammar.h.

Thêm token từ vựng

Mỗi quy tắc được định nghĩa trong ngữ pháp phải có một token liên kết được định nghĩa trong py/lexer.h. Thêm token này bằng cách chỉnh sửa enum _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;

Sau đó cũng chỉnh sửa py/lexer.c để thêm văn bản ký tự từ khóa mới:

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

Lưu ý rằng từ khóa được đặt tên tùy theo mục đích sử dụng. Để đảm bảo tính nhất quán, hãy duy trì quy ước đặt tên phù hợp.

Ghi chú

Thứ tự của các từ khóa trong py/lexer.c phải khớp với thứ tự của các token trong enum được định nghĩa trong py/lexer.h.

Phân tích cú pháp

Trong giai đoạn phân tích cú pháp, bộ phân tích cú pháp nhận các token do bộ phân tích từ vựng tạo ra và chuyển đổi chúng thành cây cú pháp trừu tượng (AST) hay còn gọi là cây phân tích cú pháp. Cài đặt cho bộ phân tích cú pháp được định nghĩa trong py/parse.c.

Bộ phân tích cú pháp cũng duy trì một bảng hằng số để sử dụng trong các khía cạnh khác nhau của quá trình phân tích, tương tự như chức năng của bảng ký hiệu.

Một số tối ưu hóa như constant folding trên số nguyên cho hầu hết các phép toán như logic, nhị phân, đơn ngôi, v.v., và các cải tiến tối ưu hóa trên dấu ngoặc đơn quanh biểu thức được thực hiện trong giai đoạn này, cùng với một số tối ưu hóa trên chuỗi.

Đáng lưu ý rằng docstring bị loại bỏ và không thể truy cập được bởi trình biên dịch. Ngay cả các tối ưu hóa như string interning cũng không được áp dụng cho docstring.

Các lần duyệt của trình biên dịch

Giống như nhiều trình biên dịch khác, MicroPython biên dịch tất cả mã thành bytecode MicroPython hoặc mã native. Chức năng thực hiện điều này được cài đặt trong py/compile.c. Phương thức quan trọng nhất bạn cần biết là:

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

Trình biên dịch biên dịch mã qua bốn lần duyệt: phạm vi, kích thước stack, kích thước mã và phát mã. Mỗi lần duyệt chạy cùng một mã C trên cùng một cấu trúc dữ liệu AST, với các tính toán khác nhau mỗi lần dựa trên kết quả của lần duyệt trước.

Lần duyệt thứ nhất

Trong lần duyệt đầu tiên, trình biên dịch tìm hiểu về các định danh (biến) đã biết và phạm vi của chúng, bao gồm phạm vi toàn cục, cục bộ, đóng gói, v.v. Trong cùng lần duyệt đó, bộ phát mã (bytecode hoặc mã native) cũng tính toán số lượng nhãn cần thiết cho mã được phát ra.

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

Lần duyệt thứ hai và thứ ba

Lần duyệt thứ hai và thứ ba liên quan đến việc tính toán kích thước stack Python và kích thước mã cho bytecode hoặc mã native. Sau lần duyệt thứ ba, kích thước mã không thể thay đổi, nếu không các nhãn nhảy sẽ bị sai.

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

    ...
}

Ngay trước lần duyệt thứ hai, có một bước lựa chọn loại mã cần phát ra, có thể là mã native hoặc 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;
}

Tùy chọn bytecode là mặc định, nhưng điều đặc biệt cần lưu ý đối với tùy chọn mã native là có một tùy chọn khác thông qua VIPER. Xem phần Phát mã native để biết thêm chi tiết về chú thích viper.

Cũng có hỗ trợ cho mã hợp ngữ nội tuyến, trong đó các lệnh hợp ngữ được viết dưới dạng lời gọi hàm Python nhưng được phát trực tiếp thành mã máy tương ứng. Bộ hợp ngữ này chỉ có ba lần duyệt (phạm vi, kích thước mã, phát mã) và sử dụng cài đặt khác, không phải hàm compile_scope. Xem tài liệu tham chiếu bộ hợp ngữ nội tuyến để biết thêm chi tiết.

Lần duyệt thứ tư

Lần duyệt thứ tư phát ra mã cuối cùng có thể thực thi, hoặc là bytecode trong máy ảo, hoặc là mã native trực tiếp bởi 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);
    }
}

Phát bytecode

Các câu lệnh trong mã Python thường tương ứng với bytecode được phát ra, ví dụ a + b tạo ra "push a" rồi "push b" rồi "binary op add". Một số câu lệnh không phát ra gì mà thay vào đó ảnh hưởng đến các yếu tố khác như phạm vi của biến, ví dụ global a.

Cài đặt của một hàm phát bytecode trông tương tự như sau:

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

Chúng ta dùng biểu thức toán tử đơn ngôi làm ví dụ ở đây, nhưng chi tiết cài đặt tương tự cho các câu lệnh/biểu thức khác. Phương thức emit_write_bytecode_byte() là một wrapper xung quanh hàm chính emit_get_cur_to_write_bytecode() mà tất cả các hàm phải gọi để phát bytecode.

Phát mã native

Tương tự như cách tạo bytecode, cần có một hàm tương ứng trong py/emitnative.c cho mỗi câu lệnh mã:

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

Điểm khác biệt ở đây là chúng ta phải xử lý kiểu dữ liệu viper. Các chú thích viper cho phép chúng ta xử lý nhiều hơn một loại biến. Theo mặc định, tất cả các biến đều là đối tượng Python, nhưng với viper, một biến cũng có thể được khai báo là biến kiểu máy như số nguyên native hoặc con trỏ. Có thể coi viper như một tập cha của Python, trong đó các đối tượng Python thông thường được xử lý như bình thường, còn các biến máy native được xử lý theo cách tối ưu bằng cách sử dụng trực tiếp các lệnh máy cho các phép toán. Kiểu dữ liệu viper có thể phá vỡ sự tương đương với Python vì, ví dụ, số nguyên trở thành số nguyên native và có thể bị tràn (khác với số nguyên Python tự động mở rộng đến độ chính xác tùy ý).