Kääntäjä

MicroPythonin käännösprosessi sisältää seuraavat vaiheet:

  • Lekseri muuntaa MicroPython-ohjelman muodostaman tekstivirran tokeneiksi.

  • Jäsennin muuntaa tokenit sitten abstraktiksi syntaksiksi (jäsennyspuuksi).

  • Tämän jälkeen jäsennyspuun perusteella tuotetaan tavukoodi tai natiivikoodi.

Tätä keskustelua varten lisäämme yksinkertaisen kielipiirteen add1, jota voidaan käyttää Pythonissa näin:

>>> add1 3
4
>>>

add1-lause ottaa kokonaisluvun argumenttina ja lisää siihen 1.

Kielioppisäännön lisääminen

MicroPythonin kielioppi perustuu CPython-kielioppiin ja se määritellään tiedostossa py/grammar.h. Tätä kielioppia käytetään MicroPython-lähdetiedostojen jäsentämiseen.

Kielioppisäännön määrittämiseen tarvitaan kaksi makroa: DEF_RULE ja DEF_RULE_NC. DEF_RULE mahdollistaa säännön määrittelyn siihen liittyvän käännösfunktion kanssa, kun taas DEF_RULE_NC ei sisällä käännösfunktiota (NC).

Yksinkertainen kielioppimäärittely käännösfunktion kanssa uudelle add1-lauseellemme näyttää seuraavalta:

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

Toinen argumentti c(add1_stmt) on vastaava käännösfunktio, joka tulee toteuttaa tiedostossa py/compile.c muuntamaan tämä sääntö suoritettavaksi koodiksi.

Kolmas pakollinen argumentti voi olla or tai and. Se määrittää lauseeseen liittyvien solmujen määrän. Esimerkiksi tässä tapauksessa add1-lauseemme muistuttaa konekielen ADD1-käskyä. Se ottaa yhden numeerisen argumentin. Siksi add1_stmt-säännöllä on kaksi siihen liittyvää solmua. Yksi solmu on itse lausetta varten, eli literaali add1, joka vastaa tokenia KW_ADD1, ja toinen sen argumenttia varten, testlist-sääntöä, joka on ylimmän tason lausekesääntö.

Muista

Tässä esitetty add1-sääntö on vain esimerkki eikä osa MicroPythonin vakiokielioppia.

Tämän esimerkin neljäs argumentti on sääntöön liittyvä token, KW_ADD1. Tämä token tulee määritellä lekserissä muokkaamalla tiedostoa py/lexer.h.

Saman säännön määrittäminen ilman käännösfunktiota tehdään käyttämällä DEF_RULE_NC-makroa ja jättämällä käännösfunktion argumentti pois:

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

Loput argumentit säilyttävät saman merkityksen. Sääntö, jolla ei ole käännösfunktiota, täytyy käsitellä eksplisiittisesti kaikissa säännöissä, joilla tämä sääntö voi olla solmuna. Tällaisia NC-sääntöjä käytetään yleensä ilmaisemaan monimutkaisen kielioppirakenteen osia, joita ei voida ilmaista yhdellä säännöllä.

Muista

Makrot DEF_RULE ja DEF_RULE_NC ottavat muitakin argumentteja. Syvällisemmän ymmärryksen tuetuista parametreista saat tutustumalla tiedostoon py/grammar.h.

Leksikaalisen tokenin lisääminen

Jokaisella kielioppiin määritellyllä säännöllä tulisi olla siihen liittyvä token, joka määritellään tiedostossa py/lexer.h. Lisää tämä token muokkaamalla _mp_token_kind_t-enumia:

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;

Muokkaa sitten myös tiedostoa py/lexer.c lisätäksesi uuden avainsanan literaalitekstin:

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

Huomaa, että avainsana nimetään sen mukaan, mitä haluat sen olevan. Yhdenmukaisuuden vuoksi noudata nimeämiskäytäntöä vastaavasti.

Muista

Näiden avainsanojen järjestyksen tiedostossa py/lexer.c on vastattava tiedostossa py/lexer.h määritellyn enumin tokenien järjestystä.

Jäsentäminen

Jäsennysvaiheessa jäsennin ottaa lekserin tuottamat tokenit ja muuntaa ne abstraktiksi syntaksipuuksi (AST) eli jäsennyspuuksi. Jäsentimen toteutus määritellään tiedostossa py/parse.c.

Jäsennin ylläpitää myös vakioiden taulukkoa, jota käytetään jäsentämisen eri osa-alueilla, samaan tapaan kuin symbolitaulukko tekee.

Useita optimointeja, kuten kokonaislukujen vakioiden lasku useimmille operaatioille, esim. loogisille, binäärisille, unaarisille jne., sekä optimoivia parannuksia lausekkeita ympäröiviin sulkeisiin, suoritetaan tässä vaiheessa, samoin kuin joitakin merkkijonojen optimointeja.

On syytä huomata, että docstring-merkkijonot hylätään eivätkä ne ole kääntäjän käytettävissä. Edes optimointeja, kuten merkkijonojen internointi, ei sovelleta docstring-merkkijonoihin.

Kääntäjän läpikäynnit

Kuten monet kääntäjät, MicroPython kääntää kaiken koodin MicroPython-tavukoodiksi tai natiivikoodiksi. Tämän toteuttava toiminnallisuus on määritelty tiedostossa py/compile.c. Tärkein metodi, joka sinun tulisi tuntea, on tämä:

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

Kääntäjä kääntää koodin neljällä läpikäynnillä: näkyvyysalue, pinon koko, koodin koko ja tuottaminen. Jokainen läpikäynti suorittaa saman C-koodin samalle AST-tietorakenteelle, ja eri asioita lasketaan joka kerta edellisen läpikäynnin tulosten perusteella.

Ensimmäinen läpikäynti

Ensimmäisellä läpikäynnillä kääntäjä oppii tunnetut tunnisteet (muuttujat) ja niiden näkyvyysalueen, joka voi olla globaali, paikallinen, suljettu (closed over) jne. Samalla läpikäynnillä tuottaja (tavukoodi tai natiivikoodi) laskee myös tuotettavalle koodille tarvittavien nimiöiden määrän.

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

Toinen ja kolmas läpikäynti

Toinen ja kolmas läpikäynti sisältävät Pythonin pinon koon ja koodin koon laskemisen tavukoodille tai natiivikoodille. Kolmannen läpikäynnin jälkeen koodin koko ei voi muuttua, muuten hyppynimiöt olisivat virheellisiä.

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

    ...
}

Juuri ennen toista läpikäyntiä valitaan tuotettavan koodin tyyppi, joka voi olla joko natiivi tai tavukoodi.

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

Tavukoodivaihtoehto on oletus, mutta natiivikoodivaihtoehdossa kannattaa huomata erikseen, että on olemassa toinenkin vaihtoehto VIPER-tyypin kautta. Katso lisätietoja viper-annotaatioista osiosta Natiivikoodin tuottaminen.

Tukea on myös inline-konekoodille, jossa konekielen käskyt kirjoitetaan Python-funktiokutsuina, mutta tuotetaan suoraan vastaavana konekoodina. Tällä assemblerilla on vain kolme läpikäyntiä (näkyvyysalue, koodin koko, tuottaminen) ja se käyttää eri toteutusta, ei compile_scope-funktiota. Katso lisätietoja viitteestä inline-assemblerin viite.

Neljäs läpikäynti

Neljäs läpikäynti tuottaa lopullisen suoritettavan koodin, joko virtuaalikoneessa suoritettavan tavukoodin tai suoraan suorittimen suorittaman natiivikoodin.

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

Tavukoodin tuottaminen

Python-koodin lauseet vastaavat yleensä tuotettua tavukoodia, esimerkiksi a + b tuottaa ”push a”, sitten ”push b” ja sitten ”binary op add”. Jotkin lauseet eivät tuota mitään, vaan vaikuttavat sen sijaan muihin asioihin, kuten muuttujien näkyvyysalueeseen, esimerkiksi global a.

Tavukoodia tuottavan funktion toteutus näyttää suunnilleen tältä:

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

Käytämme tässä esimerkkinä unaarisia operaattorilausekkeita, mutta toteutuksen yksityiskohdat ovat samankaltaisia muille lauseille/lausekkeille. Metodi emit_write_bytecode_byte() on käärefunktio päärunkofunktion emit_get_cur_to_write_bytecode() ympärillä, jota kaikkien funktioiden on kutsuttava tavukoodin tuottamiseksi.

Natiivikoodin tuottaminen

Samoin kuin tavukoodia tuotetaan, tiedostossa py/emitnative.c tulisi olla vastaava funktio jokaiselle koodilauseelle:

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

Ero tässä on se, että meidän on käsiteltävä viper-tyypitys. Viper-annotaatioiden avulla voimme käsitellä useampaa kuin yhtä muuttujatyyppiä. Oletuksena kaikki muuttujat ovat Python-objekteja, mutta viperillä muuttuja voidaan myös määritellä konetyypitetyksi muuttujaksi, kuten natiiviksi kokonaisluvuksi tai osoittimeksi. Viperiä voi ajatella Pythonin laajennettuna joukkona, jossa tavalliset Python-objektit käsitellään tavalliseen tapaan, kun taas natiivit konemuuttujat käsitellään optimoidulla tavalla käyttämällä operaatioihin suoria konekäskyjä. Viper-tyypitys voi rikkoa Python-yhteensopivuuden, koska esimerkiksi kokonaisluvuista tulee natiiveja kokonaislukuja ja ne voivat ylivuotaa (toisin kuin Pythonin kokonaisluvut, jotka laajenevat automaattisesti mielivaltaiseen tarkkuuteen).