คอมไพเลอร์

กระบวนการคอมไพล์ใน MicroPython มีขั้นตอนดังต่อไปนี้:

  • Lexer จะแปลงสตรีมข้อความที่ประกอบเป็นโปรแกรม MicroPython ให้เป็นโทเค็น

  • จากนั้น parser จะแปลงโทเค็นให้เป็น abstract syntax (parse tree)

  • จากนั้นจะสร้าง bytecode หรือ native code ตาม parse tree

เพื่อประกอบการอธิบาย เราจะเพิ่มลักษณะเด่นทางภาษาอย่างง่ายชื่อ add1 ซึ่งสามารถใช้ใน Python ได้ดังนี้:

>>> add1 3
4
>>>

คำสั่ง add1 รับค่าจำนวนเต็มเป็นอาร์กิวเมนต์และบวก 1 เข้าไป

การเพิ่มกฎ grammar

grammar ของ MicroPython อิงตาม CPython grammar และกำหนดไว้ใน py/grammar.h grammar นี้ใช้ในการ parse ไฟล์ซอร์สโค้ด MicroPython

มีแมโครสองตัวที่จำเป็นต้องรู้เพื่อกำหนดกฎ grammar ได้แก่ DEF_RULE และ DEF_RULE_NC แมโคร DEF_RULE ช่วยให้กำหนดกฎพร้อมฟังก์ชัน compile ที่เชื่อมโยงได้ ในขณะที่ DEF_RULE_NC ไม่มีฟังก์ชัน compile (NC)

การกำหนด grammar อย่างง่ายพร้อมฟังก์ชัน compile สำหรับคำสั่ง add1 ใหม่มีลักษณะดังนี้:

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

อาร์กิวเมนต์ที่สอง c(add1_stmt) คือฟังก์ชัน compile ที่สอดคล้องกัน ซึ่งควรนำไปใช้งานใน py/compile.c เพื่อแปลงกฎนี้ให้เป็นโค้ดที่สามารถรันได้

อาร์กิวเมนต์ที่สามที่จำเป็นสามารถเป็น or หรือ and ซึ่งกำหนดจำนวนโหนดที่เชื่อมโยงกับคำสั่ง ตัวอย่างเช่น ในกรณีนี้คำสั่ง add1 มีความคล้ายคลึงกับ ADD1 ในภาษา assembly โดยรับอาร์กิวเมนต์ตัวเลขหนึ่งตัว ดังนั้น add1_stmt จึงมีสองโหนดที่เชื่อมโยงด้วย โหนดหนึ่งสำหรับตัวคำสั่งเอง คือ literal add1 ที่ตรงกับ KW_ADD1 และอีกโหนดสำหรับอาร์กิวเมนต์ ซึ่งเป็นกฎ testlist อันเป็นกฎนิพจน์ระดับสูงสุด

Note

กฎ add1 ในที่นี้เป็นเพียงตัวอย่างและไม่ได้เป็นส่วนหนึ่งของ grammar มาตรฐานของ MicroPython

อาร์กิวเมนต์ที่สี่ในตัวอย่างนี้คือโทเค็นที่เชื่อมโยงกับกฎ ได้แก่ KW_ADD1 โทเค็นนี้ควรกำหนดไว้ใน lexer โดยแก้ไขไฟล์ py/lexer.h

การกำหนดกฎเดิมโดยไม่มีฟังก์ชัน compile ทำได้โดยใช้แมโคร DEF_RULE_NC และละเว้นอาร์กิวเมนต์ฟังก์ชัน compile ออก:

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

อาร์กิวเมนต์ที่เหลือมีความหมายเดิม กฎที่ไม่มีฟังก์ชัน compile จะต้องได้รับการจัดการอย่างชัดเจนโดยกฎทั้งหมดที่อาจมีกฎนี้เป็นโหนด กฎ NC เช่นนี้มักใช้เพื่อแสดงส่วนย่อยของโครงสร้าง grammar ที่ซับซ้อนซึ่งไม่สามารถแสดงในกฎเดียวได้

Note

แมโคร DEF_RULE และ DEF_RULE_NC รับอาร์กิวเมนต์อื่นๆ ด้วย สำหรับความเข้าใจเชิงลึกเกี่ยวกับพารามิเตอร์ที่รองรับ โปรดดูที่ py/grammar.h

การเพิ่มโทเค็นทางศัพท์

กฎทุกข้อที่กำหนดใน grammar ควรมีโทเค็นที่เชื่อมโยงซึ่งกำหนดไว้ใน py/lexer.h เพิ่มโทเค็นนี้โดยแก้ไข 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;

จากนั้นแก้ไข py/lexer.c ด้วยเพื่อเพิ่มข้อความ keyword literal ใหม่:

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

สังเกตว่าชื่อ keyword ขึ้นอยู่กับสิ่งที่ต้องการ เพื่อความสอดคล้อง ควรรักษามาตรฐานการตั้งชื่อให้เหมาะสม

Note

ลำดับของ keyword เหล่านี้ใน py/lexer.c ต้องตรงกับลำดับของโทเค็นใน enum ที่กำหนดไว้ใน py/lexer.h

การ Parse

ในขั้นตอนการ parse นั้น parser จะรับโทเค็นที่สร้างโดย lexer แล้วแปลงให้เป็น abstract syntax tree (AST) หรือ parse tree การนำไปใช้งานของ parser กำหนดไว้ใน py/parse.c

parser ยังคงตารางค่าคงที่สำหรับใช้ในแง่มุมต่างๆ ของการ parse คล้ายกับสิ่งที่ symbol table ทำ

การเพิ่มประสิทธิภาพหลายอย่าง เช่น constant folding กับจำนวนเต็มสำหรับการดำเนินการส่วนใหญ่ ได้แก่ logical, binary, unary เป็นต้น และการปรับปรุงประสิทธิภาพสำหรับวงเล็บรอบนิพจน์ จะดำเนินการในระหว่างขั้นตอนนี้ พร้อมกับการเพิ่มประสิทธิภาพบางส่วนสำหรับสตริง

สิ่งที่ควรทราบคือ docstrings จะถูกละทิ้งและไม่สามารถเข้าถึงได้โดยคอมไพเลอร์ แม้แต่การเพิ่มประสิทธิภาพอย่าง string interning ก็ไม่ถูกนำไปใช้กับ docstrings

รอบการคอมไพล์

เช่นเดียวกับคอมไพเลอร์หลายตัว MicroPython คอมไพล์โค้ดทั้งหมดเป็น MicroPython bytecode หรือ native code ฟังก์ชันที่ทำสิ่งนี้ถูกนำไปใช้งานใน 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);
}

คอมไพเลอร์คอมไพล์โค้ดในสี่รอบ ได้แก่ scope, stack size, code size และ emit แต่ละรอบจะรันโค้ด C เดิมซ้ำกับโครงสร้างข้อมูล AST เดิม โดยมีสิ่งต่างๆ ที่คำนวณในแต่ละครั้งตามผลลัพธ์ของรอบก่อนหน้า

รอบแรก

ในรอบแรก คอมไพเลอร์จะเรียนรู้เกี่ยวกับตัวบ่งชี้ (ตัวแปร) ที่รู้จักและขอบเขตของตัวแปรเหล่านั้น ไม่ว่าจะเป็น global, local, closed over เป็นต้น ในรอบเดียวกัน emitter (bytecode หรือ native code) ยังคำนวณจำนวน label ที่ต้องการสำหรับโค้ดที่สร้างออกมาด้วย

// 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 stack และขนาดโค้ดสำหรับ bytecode หรือ native code หลังจากรอบที่สาม ขนาดโค้ดจะไม่สามารถเปลี่ยนแปลงได้ มิเช่นนั้น jump label จะไม่ถูกต้อง

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

    ...
}

ก่อนรอบที่สอง มีการเลือกประเภทของโค้ดที่จะสร้างออกมา ซึ่งสามารถเป็น native หรือ 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;
}

ตัวเลือก bytecode เป็นค่าเริ่มต้น แต่สิ่งที่ควรสังเกตเป็นพิเศษสำหรับตัวเลือก native code คือมีตัวเลือกเพิ่มเติมผ่าน VIPER ดูส่วน Emitting native code สำหรับรายละเอียดเพิ่มเติมเกี่ยวกับ viper annotations

นอกจากนี้ยังมีการรองรับ inline assembly code ซึ่งคำสั่ง assembly จะเขียนเป็น Python function calls แต่สร้างออกมาโดยตรงเป็น machine code ที่ตรงกัน assembler นี้มีเพียงสามรอบ (scope, code size, emit) และใช้การนำไปใช้งานที่แตกต่างกัน ไม่ใช่ฟังก์ชัน compile_scope ดู inline assembler reference สำหรับรายละเอียดเพิ่มเติม

รอบที่สี่

รอบที่สี่จะสร้างโค้ดสุดท้ายที่สามารถรันได้ ไม่ว่าจะเป็น bytecode ใน virtual machine หรือ native code โดยตรงจาก 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);
    }
}

การสร้าง bytecode

คำสั่งในโค้ด Python มักตรงกับ bytecode ที่สร้างออกมา เช่น a + b จะสร้าง "push a" จากนั้น "push b" จากนั้น "binary op add" บางคำสั่งไม่สร้างสิ่งใดออกมาแต่ส่งผลต่อสิ่งอื่นแทน เช่น ขอบเขตของตัวแปร ตัวอย่างเช่น global a

การนำไปใช้งานของฟังก์ชันที่สร้าง bytecode มีลักษณะดังนี้:

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

เราใช้นิพจน์ unary operator เป็นตัวอย่างที่นี่ แต่รายละเอียดการนำไปใช้งานมีลักษณะคล้ายกันสำหรับคำสั่ง/นิพจน์อื่นๆ เมธอด emit_write_bytecode_byte() เป็น wrapper รอบฟังก์ชันหลัก emit_get_cur_to_write_bytecode() ที่ฟังก์ชันทั้งหมดต้องเรียกใช้เพื่อสร้าง bytecode

การสร้าง native code

คล้ายกับวิธีสร้าง 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 typing Viper annotations ช่วยให้เราจัดการตัวแปรได้มากกว่าหนึ่งประเภท โดยค่าเริ่มต้น ตัวแปรทั้งหมดจะเป็น Python objects แต่ด้วย viper ตัวแปรสามารถประกาศเป็นตัวแปรที่มีประเภทของเครื่องได้ เช่น native integer หรือ pointer Viper สามารถคิดได้ว่าเป็น superset ของ Python ซึ่ง Python objects ปกติได้รับการจัดการตามปกติ ในขณะที่ตัวแปร native machine ได้รับการจัดการในแบบที่ปรับให้เหมาะสมโดยใช้คำสั่ง machine โดยตรงสำหรับการดำเนินการ Viper typing อาจทำลายความเท่าเทียมกับ Python เนื่องจากตัวอย่างเช่น จำนวนเต็มกลายเป็น native integer และสามารถ overflow ได้ (ต่างจาก Python integers ที่ขยายโดยอัตโนมัติไปยังความแม่นยำโดยพลการ)