컴파일러

MicroPython의 컴파일 과정은 다음 단계로 이루어집니다:

  • 렉서(lexer)는 MicroPython 프로그램을 구성하는 텍스트 스트림을 토큰으로 변환합니다.

  • 그런 다음 파서(parser)가 토큰을 추상 구문(파스 트리)으로 변환합니다.

  • 이후 파스 트리를 기반으로 바이트코드 또는 네이티브 코드가 생성됩니다.

이 설명을 위해, Python에서 다음과 같이 사용할 수 있는 간단한 언어 기능 add1을 추가해 보겠습니다:

>>> add1 3
4
>>>

add1 문은 정수를 인수로 받아 그 값에 1을 더합니다.

문법 규칙 추가하기

MicroPython의 문법은 CPython 문법을 기반으로 하며 py/grammar.h에 정의되어 있습니다. 이 문법은 MicroPython 소스 파일을 파싱하는 데 사용됩니다.

문법 규칙을 정의하려면 알아두어야 할 두 가지 매크로가 있습니다: DEF_RULEDEF_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_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)은 폐기되어 컴파일러가 접근할 수 없다는 점을 주목할 만합니다. 문자열 인터닝 같은 최적화조차도 독스트링에는 적용되지 않습니다.

컴파일러 패스

많은 컴파일러처럼 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);
}

컴파일러는 코드를 네 번의 패스로 컴파일합니다: 스코프, 스택 크기, 코드 크기, 그리고 생성(emit)입니다. 각 패스는 동일한 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 코드의 문(statement)은 일반적으로 생성되는 바이트코드에 해당합니다. 예를 들어 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 정수와 달리).