Az interpreter írása közben kiderült pár olyan dolog, amire eddig nem is gondoltam, de sokkal könnyebbé teszik a munkát. Nem egészen a tutoriál érdekében
csináltam ezeket, hanem mert ezt a kis scriptnyelvet fel fogom használni az enginemben is. Az egyik ilyen dolog a szemétgyüjtés; a fordítási hibák után ugyanis tipikusan
ottmaradnak a levegöben az allokált dolgok (és az életbe nem lehet öket felszabaditani). Bár a bisonban van egy speciális error nevezetü token, nem sikerült
értelmesen használnom (ennek ellenére egy picit igényesebb fordítónak illene használnia). A szemétgyüjtéshez segítségül hívtam jó öreg template metaprogramming barátomat, úgyhogy
a kóddal nem fárasztok most senkit. Minden fordítás közbeni memóriafoglalás és felszabadítás ezen keresztül kell történjen (most már a stringek is!).
CODE
"\r\n" { ++yylloc.first_line; } // win
"\n" { ++yylloc.first_line; } // unix
A megkülönböztetés azért kell, mert windowson az újsorba ugrás két karakterböl áll. A bison is ad lehetöséget a kódsor nyomonkövetésére a @ operátorral (hasonló mint a $), de ezt nem volt kedvem kipróbálni. Az elözö részbe is utólag beletuszkoltam, de megemlítem mégegyszer, hogy a lexerben elég nagy marhaság volt str-nek nevezni a stringes állapotot, mert így remekül összeütközött a STL-beli stringstream::str() metódussal. Persze át nem javítom, mert lusta vagyok (az új kódban ki van javítva).
Deklaráció
A megnyugtató bevezetés után akkor bele is csapnék a lecsóba: írjunk fordítóprogramot! Eddig tudunk printelni, amivel mindenki nagyon jól elszórakozott,
de gyorsan meg lehet unni. Szeretnénk leírni valami ilyesmiket:
CODE
int i;
int j = 2, k;
Jobb helyeken a típus után álló kifejezést init-declarator-nak hívják, az ebböl álló listát pedig init-declarator-list-nek. Az itt bemutatott nyelv erösen típusos, tehát többszörös deklarációt nem engedek meg (kivéve belsö scopeokban, de azt majd késöbb). Nézzük meg elöször, hogy hogyan kell kifejezni a fenti utasítást.
CODE
declaration: typename init_declarator_list
;
init_declarator_list: init_declarator
| init_declarator_list COMMA init_declarator
;
init_declarator: IDENTIFIER
| IDENTIFIER EQ expr
;
Az expr egyelöre legyen csak NUMBER (késöbb majd tetszöleges kifejezés állhat ott). Rögtön felmerül a kérdés, hogy hogyan tároljuk el a már deklarált változókat (hiszen ellenörizni kell majd, hogy léteznek-e). Ehhez nyújt segítséget a szimbólumtábla, ami tetszöleges konkténer lehet ami tud kulcs-érték párokat tárolni (pl. std::map). Egy kicsit viszont elöre kell gondolkodni. Egy szimbólum ugyanis nem csak változó lehet, hanem függvény is (vagy még más), itt viszont elökerül az élettartam, láthatóság és a névelfedés fogalma. Ezekkel most különösebben nem foglalkozva fogadjuk el, hogy van egy globális scope a függvényeknek, a függvényeken belül pedig tetszöleges mélységü scope lehet (akár elágazás vagy ciklus által). Azaz nem egy darab szimbólumtábla lesz, hanem egy szimbólumtábla lista, és nyilván kell tartani, hogy melyik scope-ban vagyunk éppen. A helyzet egyszerübb mint gondolnánk, söt szinte már magától értetödö:
Egyelöre foglalkozzunk csak az 1-es scope-al (egy függvény törzse, most még csak a main törzse lehet). Ha most ránézünk a declaration nevü szabályra, akkor adódik, hogy annak valamiféle listát kéne megkapnia, amiböl elöállít szimbólumokat, illetve ellenörzi, hogy léteznek-e már.
Hasonló strukturák még várhatóak, de nem mindet fogom leírni. Eddig nem is olyan szörnyü a dolog, csak felépitettünk egy listát arról amit deklaráltunk. Most viszont ideje betenni a szimbólumtáblába, illetve meg kéne határozni a címét is. Mivel valószínüleg mindenki kérdöjelekkel a feje körül várja a kódot, egy rövidke kis interzmezzot fogok tartani az
Assemblyröl
Pontosabban arról, hogy hogyan is müködik egy x86 architekturájú számítógép (merthogy az interpreter is hasonlóan fog). Olyat már biztos mindenki hallott, hogy van olyan, hogy regiszter, meg végrehajtási verem meg valami utasítások is, például mov. A regisztereket képzeljük el 32 bites tárolókként. A regiszterekben levö számok tulajdonságai a bitekböl, illetve az utasításokból határozodnak meg. Az általános célú regiszterek az alábbiak:
A végrehajtási verem nem egy szokványos verem, mert elöször is fejjel lefelé áll. Elemet belerakni az ESP regiszteren keresztül lehet, például ha le akarsz foglalni két darab int-et, akkor az egy sub ESP,8 utasítást jelent. Ebböl akkor következik, hogy az elemekre hivatkozni viszont EBP-n keresztül lehet. Például ez a két int [EBP-4] és [EBP-8] lenne (intel assemblyben a szögletes zárójellel lehet egy pointer által mutatott értékre hivatkozni). Ezen kivül még lehet használni a push utasítást, ami egy regisztert (vagy memóriacim által mutatott értéket) pakol be a verembe. Kiíratni és egyéb érdekes dolgokat pedig kernel hívással lehet, miután beállítottuk a regisztereket. Ez unix alatt int 0x80, windowson int 0x21 (interrupt). A wikipédián lehet találni példa kódokat. Ezek után nézzük meg hogy kéne kiszámolni a deklarált változók címét: egy globális változóban eltároljuk, hogy mennyi változót foglaltunk eddig le, legyen ennek a neve alloc_addr, ami nyilván minden függvény elején vagy végén kinullázódik. A deklarációk parseolásakor pedig felhasználjuk a (EBP-re nézve relatív) cím kiszámolásához. CODE
declaration:
typename init_declarator_list
{
parser_out("declaration -> typename init_declarator_list");
int scope = interpreter->current_scope;
int size = 0;
// csak az aktuális scope-ban nézzük (névelfedés!)
symboltable& table = interpreter->scopes[scope];
symboltable::iterator sym;
symbol_desc* var;
expression_desc* expr;
$$ = interpreter->Allocate<statement_desc>();
// végigmegyünk rajtuk
for( decllist::iterator it = $2->begin(); it != $2->end(); ++it )
{
// ha már deklarálva van, akkor error
sym = table.find((*it)->name);
nassert(0, "Conflicting declaration '" << (*it)->name << "'", sym != table.end());
// létrehozzuk a szimbólumot
var = interpreter->Allocate<symbol_desc>();
var->type = $1;
table[(*it)->name] = var;
// cím kiszámolása
interpreter->alloc_addr += interpreter->Sizeof($1);
var->address = -interpreter->alloc_addr;
size += interpreter->Sizeof($1);
}
// ez lesz a memóriafoglalás
$$->bytecode << OP(OP_SUB_RS) << REG(ESP) << size;
// ...
interpreter->Deallocate($2);
}
;
Ez az OP és REG egyszerü segéd makrók, hogy nem konvertálgassa a fordító implicit módon az értékeket (pl. a regisztereket unsigned charnak nézi). A Sizeof metódus pedig (ki nem találnátok) egy típus méretét adja meg, bájtokban (egyelöre könnyü a dolog, mert csak int-ek vannak). Nem is olyan gáz ez. Amint az ember megírt egy-két ilyen programozási elemet, drasztikusan könnyebb lesz a többi megírása (föleg mert a C, mint nyelv baromi jól ki van találva). A kifejezéses deklarációt most egy picit félrerakom, elöbb fejezzük ki, hogy mi is az a kifejezés.
Aritmetikai kifejezések
Általános iskolában hallottunk ilyeneket matekból, hogy precedencia illetve bal- és jobbasszociativitás (honnan értékelödik ki). Bisonban lehetöség van
megadni az asszociativitást, ha a %left vagy %right kulcsszóval deklaráljuk a nyelvtani jelet. Például:
CODE
%left add_expr sub_expr
%left mul_expr div_expr mode_expr
%right pow_expr
Amit késöbb deklarálsz, annak lesz nagyobb a precedenciája. Bár ez elég kényelmesnek hangzik totál fölösleges. 2-es típusú nyelvekben ugyanis baromi egyszerüen ki lehet fejezni mindezeket a szabályok megfelelö hierarchiába szervezésével: aminek nagyobb a precedenciája az lejjebb kell majd legyen a szintaxisfán. A kiértékelést pedig a bal- vagy jobbrekurzivitással lehet kifejezni. Emlékeztetöül az operátorok precedenciája:
A függvényhívást most még hagyjuk. Hatékony programot szeretnék generáltatni a fordítóval, ezért érdemes megkülönböztetni a kifejezéseket aszerint, hogy konstans kifejezések-e (pl. 5 + 3), illetve regiszterben vannak-e tárolva (address == UNKNOWN_ADDR) vagy a stacken (változók). A konstans kifejezéseket a fordító fogja kiszámolni és csak a végeredményt írja a programba. A stacken levö változókat pedig be kell mozgatni egy regiszterbe mielött valamit csinálnánk velük. Assemblyben többféle lehetöség van, például lehet azt mondani, hogy add EAX, [EBP-4], de például nem lehet olyat mondani, hogy mov [EBP-4], [EBP-8]. Figyelembe véve, hogy az interpreternek minden utasításra egy többszörös elágazáson kell végigmennie (hacsak nem irjuk meg függvénypointerekkel, de most komolyan két utasítás miatt függvény...?), próbáltam minél kevesebb utasítást csinálni, de azokat legalább kicsit kiterjeszteni. Azaz például az utóbbi stacken való buherálást a bájtkód tudja. Az összeadás/kivonás és a szorzás/osztás/maradékképzés eléggé hasonszörü müveletek, ezért azokat lehet egyben kezelni. Azonban nagyon figyelembe kell venni azt, hogy egy ilyen müvelet mindekét operandusa tetszöleges kifejezés lehet, amiket még ez elött kell kiértékelni. Az ilyen kifejezések bájtkódját az Interpreter::Arithmetic_Expr() metódus végzi el, lekezelve az összes lehetséges esetet. Egyet bemutatok itt, mondjuk amikor azt írtuk le, hogy (a + b) * c és a szorzást akarjuk kiértékelni. Az elsö operandus két változó összeadása, aminek már van kódja és az eredmény EAX-ben van. A második operandus egy változó, ami valahol a stacken van (de egyébként nem tudjuk egyikröl sem, hogy konkrétan mi). Elsö dolog, hogy bemásoljuk az elsö kifejezés kódját (vegyük észre, hogy nem kell új kifejezést létrehozni, használhatjuk az elsöt) és lementjük az EAX regisztert (hiszen a második kif. esetleg használja). Ezután beirjuk a bájtkódba a másik kif. kódját, a stackröl pedig kivesszük EBX-be. Végül a lementett EAX-ot kivesszük a veremböl és elvégezzük a szorzást.
CODE
// expr1 in EAX, expr2 on the stack
expr1->bytecode << OP(OP_PUSH) << REG(EAX) << NIL;
expr1->bytecode << expr2->bytecode;
expr1->bytecode << OP(OP_MOV_RM) << REG(EBX) << expr2->address;
expr1->bytecode << OP(OP_POP) << REG(EAX) << NIL;
expr1->bytecode << OP(OP_MUL_RR) << REG(EAX) << REG(EBX);
expr1->address = UNKNOWN_ADDR;
expr1->constexpr = false;
Sose felejtsük el beállítani az eredménykifejezés tulajdonságait, például az értékadás eredménye a stacken lesz (és nem ez az egyetlen, például ++i is ott van!)! Ami egy picit elgondolkodtatóbb az az összehasonlítás. Jó lenne ha azt is le lehetne írni, hogy i = (a < 2);, ugyanakkor az if( a < 2 )-t is le kéne valahogy fordítani. Azaz az összehasonlítás eredményét is egy regiszterbe kéne rakni. It eléggé eltérek az assemblytöl, ott ugyanis az összehasonlítás (cmp) a flag regisztert módosítja és az elöjelböl/túlcsordulásból lehet meghatározni, hogy mi is történt. Ez kényelmetlen, úgyhogy a scriptnyelvben nincs cmp, hanem a setl, setg, setle, stb. utasítások fognak máshogy müködni (konkrétan elvégezni az összeahasonlítást). Ez ráadásul baromi kényelmessé is teszi a dolgot, mert a fenti Arithmetic_Expr() metódust lehet használni. Ami még hátravan az az értékadás, ezt már könnyü kikövetkeztetni: a bal oldal memóriaterületére be kell mozgatni a kifejezés eredményét (szintén mind a három esetet lekezelve).
CODE
assignment: or_level_expr
{
$$ = $1;
}
| lvalue EQ assignment
{
$$ = $1;
if( $3->constexpr )
{
// konstans kifejezés, berakjuk
int a = atoi($3->value.c_str());
$$->bytecode << OP(OP_MOV_RS) << REG(EAX) << a;
$$->bytecode << $3->bytecode << OP(OP_MOV_MR) << $1->address << REG(EAX);
}
else if( $3->address == UNKNOWN_ADDR )
{
// kifejezés eredménye EAX-ban
$$->bytecode << $3->bytecode << OP(OP_MOV_MR) << $1->address << REG(EAX);
}
else
{
// stacken levö érték
$$->bytecode << $3->bytecode << OP(OP_MOV_MM) << $1->address << $3->address;
}
interpreter->Deallocate($3);
}
;
Persze létre lehet hozni olyan szabályokat amik külön lekezelik a konstans kifejezéseket. A többi operátor teljesen hasonló elven müködik, de azt meg kell emliteni, hogy a ++i; operátor balértéket kell kapjon és nem csak EAX-be rakja az eredményt, hanem visszaírja a stackbe is! A másik fajta növelést nem írtam meg, legyen höfö. Ránézve a generált kódra feltünhet, hogy idönként marhaságokat csinál; például beleírja az értéket EAX-ba aztán átrakja EBX-be, hogy szorozzon vele. Ezeket egy külön bájtkód-optimalizáló lépésben el lehet tüntetni (a másik fajta optimalizáció az, amikor a szintaxisfát elemezve dobálsz ki dolgokat).
Elágazás és ciklus
Itt már kezd érdekes lenni a dolog, mert el kell kezdeni ugrálni az utasítások között. Végrehajtáskor nyilván tudjuk, hogy hol vagyunk éppen, ezért
relatív címekre érdemes ugrani (a függvényhívásnál ebböl lesz némi gond, de majd látni fogjuk, hogy egyébként se kerülnénk el a megoldását).
CODE
conditional:
IF LRB expr RRB scope
{
$$ = interpreter->Allocate<statement_desc>();
$$->bytecode << $3->bytecode;
if( $3->constexpr )
{
// ha 0 az értéke, akkor nem kell csinálni semmit
// ha nem 0, akkor bemásoljuk az utasításokat
}
else
{
if( $3->address != UNKNOWN_ADDR )
$$->bytecode << OP(OP_MOV_RM) << OP(EAX) << $3->address;
int off = 0;
// kiszámoljuk, hogy mennyit kell ugrani, ha nem teljesül a feltétel
for( statlist::iterator it = $5->begin(); it != $5->end(); ++it )
{
if( *it )
off += (int)(*it)->bytecode.size();
}
// ha a kif. értéke 0, akkor átugorjuk az utasításokat
$$->bytecode << OP(OP_JZ) << REG(EAX) << off;
// egyébként le fognak futni
for( statlist::iterator it = $5->begin(); it != $5->end(); ++it )
{
if( *it )
{
$$->bytecode << (*it)->bytecode;
interpreter->Deallocate(*it);
}
}
interpreter->Deallocate($3);
interpreter->Deallocate($5);
}
}
| IF LRB expr RRB scope ELSE scope
{
// teljesen hasonló, de az if végén át kell ugrani az else ágat
}
;
Direkt lekezeltem, hogy konstans kifejezések esetén csak az a kód kerüljön be, amelyiknek le kell futni (ez már egy optimalizáció). Höföként meg szabad írni a switch-case-t is, annyit kell megjegyezni, hogy a vizsgált kifejezés hasonlóan bármi lehet, de a case feltételei csak konstans kifejezések (extraként megengedhetünk nem integrált is, pl. float). Ez lehetövé teszi, hogy logaritmikus kereséssel vagy ugrótáblával találjuk meg a megfelelö ágat. A ciklus teljesen hasonszörüen müködik, viszont a ciklusmag végén vissza kell ugrani a feltétel kiértékeléséhez, illetve ha hamisra értékelödött ki, akkor át kell ugrani a ciklusmagot. A break utasítást egyelöre kihagytam.
Scope-ok
A fentiekben már láthattuk, hogy a kontroll utasítások után kapcsos zárójeleken belüli utasítássorozat (blokk utasítás) kell álljon (ez a scope nyelvtani jel), és nyelvi szinten
kötelezö. Önmagában blokk utasítást viszont nem engedek meg (ezt speciel egy megfelelö helyre tett scope-al meg is lehet oldani).
CODE
scope:
scope_start statement_block RB
{
// ha nem top level scope (függvénytörzs), akkor a lefoglalt
// változókat deallokálni kell! (add ESP, n)
// ...
--interpreter->current_scope;
}
;
scope_start:
LB
{
++interpreter->current_scope;
}
;
Ennyi. Ezzel meg van oldva a névelfedés és az automatikus deallokálás. Egy picit érdemes belegondolni, hogy a kapcsos zárójel nélküli elágazást és ciklust hogyan is oldanánk meg. Kell oda scope? Egy utasítás állhat mögötte! Deklaráció? Nem kell csinálni semmit. Azonban nem ugorhatjuk át csak úgy a deklarációt, hogy "úgysincs értelme", mert mi van, ha ez a ciklusmag: int i = myfunc();? Itt már egy kicsit bonyolódik a helyzet, ezért is raktam még félre a nem kapcsos zárójeles ciklust/elágazást.
Függvényhívás
Az utolsó nehezebb feature van már csak hátra: hogy függvényeket és alprogramokat tudjunk csinálni.
A paraméterátadási mód csak érték szerinti lehet (mivel amugyis csak int-eket ismerünk még csak).
Meilött belemennénk ebbe van egy fontos fogalom, amit úgy hívnak, hogy cdecl hívási konvenció.
CODE
push EBP
mov EBP, ESP
// ...
mov ESP, EBP
pop EBP
mov EAX, <visszatérö érték> // VAGY push
A függvénynek ennyi a dolga. A hívónak viszont az a dolga, hogy lementse a regiszterek értékeit, a stackbe berakja a függvény aktuális paramétereit és az aktuális utasítás címét (visszatérési cím), illetve a függvény visszatérése után kiüritse a stacket. Az elsöt nem kell megcsinálni, mert a kifejezések kódjában garantált, hogy a regiszterek konzisztensek. Na jó, de hova a rákba kell elugrani, hogy a függvényhez jussunk? Hiszen még azt se tudjuk, hogy hol lesz a kódban (söt, nem is biztos, hogy parseoltuk már, gondoljunk a C-re). Ezen a ponton elökerül a linkelés fogalma, nevezetesen a hivatkozások feloldása.
Szemfüles olvasóknak feltünhet, hogy EIP-t nem vettem ki a stackröl. Ezt ugyanis a return utasítás fogja megcsinálni (még akkor is, ha nem írtuk le). Még szemfülesebb olvasók észrevették, hogy szerintem nem csak a függvényhívás lehet unresolved external. Höfö kitalálni, hogy még mi (mert nekem bizony fogalmam nincs, ennek ellenére nem töröltem ki a kódból). Na de a return... honnan tudjuk, hogy van vagy nincs? Honnan tudjuk, hogy minden végrehajtási ágon van-e? Egy elég egyszerü megoldást választottam: annyit követelek meg, hogy a fö ágon legyen, a többi nem érdekel. Persze void függvényre nem kell megkövetelni. A return mögött tetszöleges kifejezés állhat, de a típusa meg kell egyezzen a függvény visszatérö értékével. Tehát tudni kéne, hogy melyik függvényben vagyunk éppen. Hasonló meggondolással, mint a scopeoknál eltároltam az aktuális függvényt. A megvalósítás innentöl már könnyü:
CODE
statement:
RETURN expr
{
symbol_desc* func = interpreter->current_func;
nassert(0, "In function '" << func->name <<
"': Invalid return type", func->type != $2->type);
$$ = interpreter->Allocate<statement_desc>();
$$->type = Type_Return;
$$->scope = interpreter->current_scope;
// kiértékelés + berakás EAX-ba
// epilógus és visszatérés
$$->bytecode << OP(OP_MOV_RR) << REG(ESP) << REG(EBP);
$$->bytecode << OP(OP_POP) << REG(EBP) << NIL;
$$->bytecode << OP(OP_POP) << REG(EIP) << NIL;
interpreter->Deallocate($2);
}
;
Az utasításban eltároltam, hogy return-e, illetve a scopeját is, így egy warning formájában jelezni lehet a dead code-okat. A kezdöszimbólum rutinjában már meghatározható a függvények címe, így linkeléskor egyszerüen végig kell menni a bájtkódon, és ha feloldatlan hivatkozást találunk, akkor be kell írni helyette az adott függvény címét.
A babérok begyüjtése
Igen, nagyon sok minden elmagyarázását kihagytam, dehát ezért teszem közzé a kódot...maga a végrehajtás csupa egysoros utasítás, amit egy óvodás programozó is bármikor meg tudna írni. Ennyivel már el tud indulni valamerre az, aki esetleg saját scriptnyelv írására adná a fejét. Na de nézzük akkor, hogy mit föztünk. Rögtön egy jó kis rekurzív programocskával kezdeném: számoljuk ki egy szám faktoriálisát!
Ha a rekurzió müködik, akkor nagy gond már nem lehet (piszlicsáré problémák viszont elég könnyen). Csak a rend kedvéért nézzük meg, hogy a névelfedés müködik-e rendesen.
Ne felejtsük el, hogy ez nem (valid) x86 assembly, csak ahhoz hasonló. Egy tippet viszont adhat ahhoz, hogy hogyan kéne konkrét fordítóprogrammá alakítani a progit.
Summarum
Ez a cikk már kevésbé tekinthetö tutoriálnak, mert feltételezi, hogy bizonyos minimális ismeretekkel rendelkezik már az olvasó. Azoknak szól inkább
akik nem akarnak külsö scriptnyelv libeket használni. Még egy fejezetet terveztem, amiben a linkelést egészítem ki úgy, hogy több forrásfájlt is
tudjon kezelni, illetve bemutatnám, hogy hogyan integrálom bele ezt a nyelvet az enginebe. Még nem tudom mikor fog ez megtörténni.
|