LTY, TITE
Kari Jyrkinen

010730001 Languages, Compilers and Interpreters
Harjoitustyö, kevät 2003




C-kääntäjä

Tehtävän yleinen kuvaus

Kirjoita C-kääntäjä, joka kääntää yksinkertaisia C-kielisiä ohjelmia ajettavaksi konekieliseksi ohjelmaksi. Vain osa C-kääntäjän ominaisuuksista tarvitsee toteuttaa. Yleensä pyritään noudattamaan tiukkaa ANSI C -määrittelyä, paitsi silloin, kun halutaan yksinkertaistaa kääntäjän toimintoja. Jos jotain ei ole määritelty, voitte olettaa määrittelyn noudattavan ANSI C -standardia - tai varmistaa asian minulta. Hyviä lähdeteoksia ovat mm. kirjat Brian W. Kerningham, Dennis M. Richie: The C programming language (ANSI C) tai Jukka Korpela, Timo Larmela: C-ohjelmointikieli.

Kääntäjä toteutetaan flexiä ja bisonia hyväksi käyttäen. Parserin tulee tuottaa TAC-välikoodia, joka sitten muunnetaan assembleriksi ja GNU:n as-kääntäjällä ajettavaksi ohjelmaksi. Kääntäjän tulee tuottaa ELF-määrityksen mukaista koodia, jota voidaan ajaa IA-32 -arkkitehtuurin prosessoreilla (Intel x86-arkkitehtuurin prosessoreilla). Käännettävän tiedoston nimi pitää päättyä .c-lyhenteeseen (esim. testi.c) ja tiedostosta tuotetaan vastaavan niminen ajettava ohjelma ilman tuota .c-lyhennettä (testi). Assembler-koodi tallennetaan vastaavaan .s-päätteiseen tiedostoon (testi.s). as-assemblerin kutsuminen C-koodissa käy näppärästi vaikkapa system-funktion avulla. Kääntäjän käyttö pitää siis onnistua tähän tapaan:

$ ./kaantajannimi testi.c
$ ./testi


Kielen rakenne

Käännettävät ohjelmat koostuvat vain pääfunktiosta main. Funktion määrityksiä  ja -kutsuja ei siis tarvitse toteuttaa. Kaikki muuttujat ovat globaaleja, ja ne voidaan määrittää joko ennen main-funktiota tai heti funktion alussa. Esikäsittelijän komentoja (#include jne.) ei tarvitse myöskään määrittää, sillä ainoa käytettävä C-funktio, printf, toteutetaan määrittelemällä se varatuksi sanaksi. Pääohjelman paluuarvo on kokonaislukutyyppinen ja main-funktio ei ota yhtään argumenttia. Tyhjien sulkujen sijaan vaadimme void-sanan. Return-lause ei ole välttämätön, vaan ohjelman suoritus voi loppua myös koodin loputtua. Ohjelman rakenne on siis tällainen:
[muuttujanmäärittelyjä]

int main (void)
{
[muuttujanmäärittelyjä]
[ohjelmalauseita]
}
Hakasulkeissa olevat osat voivat myös puuttua.

Kommentit ja 'white space'

C-kommentit pitää toteuttaa. Sisäkkäisiä kommentteja ei sallita. Välilyönnit, tabulaattorit ja rivinvaihdot ovat ylimääräisiä merkkejä, jotka voidaan jättää huomiotta lekserissä. Varattuja sanoja, muuttujia ja numeroita pitää kuitenkin yleensä erottaa vähintään yksi välilyönti. "Jos symbolia, joka alkaa kirjaimella tai numerolla, seuraava symboli alkaa myös kirjaimella tai numerolla, täytyy niiden välissä olla vähintään yksi tyhjä väli." (Korpela & Larmela)

Muuttujat

Muuttuja pitää olla määritelty, ennen kuin sitä voidaan käyttää, muuten tulostetaan virheilmoitus käännösvaiheessa. Muuttujan tyyppejä tarvitsee toteuttaa kolme: int, long ja char. Kaikki nämä tyypit ovat kokonaislukuarvoisia, joten niiden tyyppisiä muuttujia voidaan käyttää hyvin samalla tavoin; char-muuttujaan voidaan siis tallettaa niin merkin ASCII-koodi kuin lukuarvokin. Käytän testiohjelmissa long- ja char-tyyppisiä muuttujia ainoastaan asetuslauseiden vasemmalla puolella ja tulostuslauseissa, joten mm. tyyppimuunnoksista ei tarvitse huolehtia. Tosin riittävän pienillä luvuilla eri tyyppisten muuttujien käyttö sekaisin tuskin aiheuttaisi mitään ongelmia, luulen ma :)

Sallittuja muuttujan nimiä ovat merkkijonot, jotka alkavat kirjaimella tai alaviivalla. Näitä voi seurata kirjain, numero tai alaviiva. Rajoitetaan muuttujien pituus korkeintaan kahdeksaan merkkiin. Isot ja pienet kirjaimet ovat eri merkkejä, mutta tämä onkin oletus flexissä. Varattu sana ei voi olla muuttujan nimenä. Itse muuttujan määrittely koostuu muuttujan tyypistä, yhdestä tai useammasta pilkulla erotetusta muuttujan nimestä sekä lauseen päättävästä puolipisteestä. Muuttujanmäärittelyvaiheessa ei tarvitse pystyä alustamaan muuttujien arvoja. Standardin mukaan alustamattoman globaalin kokonaislukumuuttujan arvo on nolla, jos muuta arvoa ei ole määritelty. Tämä alustus hoituu näppärästi assembler-vaiheessa.

Kokonaisluku- ja merkkiliteraalit

Kokonaislukuliteraaleilla tarkoitetaan tavallisia kokonaislukuja, joita käytetään laskutoimituksissa, sijoituksissa ja vertailuissa. Merkkiliteraali merkitään 'x' ja tarkoittaa hipsuissa esiintyvän merkin ASCII-koodia eli lukua. Niinpä sallittuja ovat mm. seuraavat operaatiot:

merkkimuuttuja = 'Z';
inttimuuttuja = 3 * 'g' + 'A';

Toisin sanoen, hipsuissa olevat merkit voidaan palauttaa merkin ASCII-koodina parserin puolelle.

Lauseet ja lausekkeet

Toteutettavat lauseet ovat muuttujan määrittelyjen lisäksi sijoituslause, tulostuslause, if-lause, for-lause, while-lause, return-lause sekä tyhjä lause. Kaikki lauseet päättyvät puolipisteeseen - paitsi siinä tapauksessa, että lauseen loppuosa koostuu ohjelmalohkosta. Toki tällöinkin lause voi päättyä puolipisteeseen, koska tyhjä lause on mahdollinen, mutta puolipistettä ei vaadita. C-kielessä lauseke voi olla muun muassa vertailulauseke tai laskutoimitus  - erottakaamme nämä kuitenkin toisistaan: jatkossa lauseke tarkoittaa laskutoimitusta ja tarvittaessa puhutaan vertailulausekkeesta tai ehdosta. Vertailulauseita on tarpeen käyttää vain if, for ja while-lauseissa, ja vaatikaamme myös niitten käyttöä näissä tapauksissa. Pelkkä laskutoimitus voi olla myös lause, kun perään lisätään puolipilkku. Tässä tosin on järkeä ainoastaan silloin, kun laskutoimituksen osana on dekrementointi tai inkrementointi. Näitä operaatioita voi käyttää osana laskutoimitusta tai erillisinä lauseina, koska lausekkeesta saadaan lause puolipisteellä. Inkrementointi ja dekrementointi riittää toteuttaa postfix-operaationa (muuttuja++), jolloin laskutoimitus itsessään tehdään vanhalla arvolla. Lauseiden hieman C-kieltä rajatumpi syntaksi on seuraava:

muuttuja = lauseke ;
printf
("merkkijono"[, arg1[, arg2[,...]]]) ;
if (ehto) lause1 [else lause2] ;
for (lauseke1; ehto; lauseke2) lause ;
while (ehto) lause ;
return lauseke ;
lauseke ;
;

Sijoituslauseessa sijoitetaan muuttujaan muuttuja, luku tai laskutoimituksen tulos. C-kielessä on mahdollista kirjoittaa lause a=b=c;, mutta tätä ei ole tarpeen määritellä. Tulostuslauseessa annetaan parametrina vähintään merkkijono, jossa voi olla C:ssä käytettyjä kentänmäärittelyjä (esim. %d) sekä erikoismerkkejä (esim. \n). Merkkijonon sisällöstä ei tarvitse välittää, koska se annetaan vastaavan C-funktion käsiteltäväksi. Parametreina voidaan antaa myös muuttujia tai lukuja, jos halutaan. Periaatteessa parametreja voi olla määrittelemätön määrä, mutta jos ette halua kikkailla linkitetyllä listalla, riittää mahdollistaa enintään kolmen argumentin käyttö. Argumenttien tyyppejä ei tarvitse tarkastaa vastaamaan kentänmäärittelyjä, koska C-kääntäjäkään ei tee tätä. Tarpeen on vain tallettaa merkkijono sellaisenaan sekä mahdolliset argumentit.

If, for ja while-lauseissa lauseet voivat olla myös lohkoja. Lohko ympäröidään aaltosulkeilla ja sen sisältö koostuu lausejonosta, siis myös nämä ohjausrakenteet ja lohkot ovat sallittuja. Toki lause tai lohko voi olla pelkkä tyhjä lause, jolloin ei tehdä mitään. If-lauseen else-osa ei ole pakollinen. Lauseiden ehdot ovat muotoa lauseke vertailuoperaattori lauseke ja vertailuoperaattoreita ovat >, >=, <, <=, == ja !=. Ehto voi olla siis vaikkapa muotoa muuttuja + 1 != 5 * i2. For-lauseessa lauseke voi olla laskutoimitus, sijoitus, tulostus tai tyhjä - voit tosin kelpuuttaa tällä paikalla myös minkä tahansa aikaisemmin määritellyn C-lauseen, jos haluat. While-lauseen avulla kirjoitettuna for-lauseen toiminta on seuraava:
lauseke1;
while (ehto)
{
lause;
lauseke2;
}
Return-lause palauttaa laskutoimituksen tuloksen, muuttujan arvon tai luvun - lauseke voi myös puuttua. Tyhjä lause koostuu pelkästä puolipisteestä, joka ei tee mitään. Laskutoimitukset voivat koostua yhteen-, vähennys-, kerto- ja jakolaskuista. Termejä voidaan ryhmitellä suluilla. Unaarinen minus on mahdollinen; unaarista plussaa ei C-kielessä ole. Operaattoreiden sidontajärjestys on seuraava:
()				vasemmalta oikealle
++, --, unarinen minus oikealta vasemmalle
*, / vasemmalta oikealle
+, - vasemmalta oikealle

Vertailuoperaattoreille ei sidontajärjestystä tarvitse määritellä, koska sallimme vain yhden vertailun per lause.

Varatut sanat

Varattuja sanoja ovat char, else, for, if, int, long, return, void ja while. Myös funktioiden nimiä printf ja main voidaan pitää varattujen sanojen kaltaisina. Varattuja sanoja ei voi eikä tarvitse voida käyttää muuttujan niminä.

Esimerkkikoodi

Seuraava koodinpätkä pitäisi mennä kääntäjästäsi lävitse. Kääntäjän ohjelmointivaiheessa ei kannattane käyttää koko koodia kerralla testaukseen, vaan vain koodin osia.

/* Esimerkkiohjelma */

int a;
long _x2, z;

int main (void)
{
int i, j;
char nimi6;
nimi6 = 'x';
_x2 = 42;
i = _x2++*5 + 10/2;
printf("i:n arvo on: %d\n", i);
if (a != 0)
printf("a ei ole 0\n");
else
{
printf("a on 0\n");
a++;
}
for(printf("Asetuslause voi olla mitä tahansa\n"); i>0; i--); /* Silmukan lause on tyhjä */
while(i<20)
{
i++;
while(i<20)
{
i++; /* Yksikin lause voi muodostaa myös lohkon */
}
}
i--;
return i;
}

Symbolitaulu ja TAC-välikoodin generointi

Tarvitset symbolitaulun, johon talletat määritellyt muuttujat ja niiden tyypit. Myös tulostuksessa käytettävät merkkijonot on talletettava jonnekin.  Symbolitauluksi riittää staattinen taulukko, mutta toki hash-taulukkoa tai linkitettyä listaa voi myös käyttää. Näistä jälkimmäistä käytetään sinänsä harvemmin kääntäjissä, koska staattinen taulukko on nopeampi. Symbolitauluun ei tarvitse tallettaa muuttujien arvoja, koska ne määritelleen vasta ajonaikaisesti. Eri tyyppisille parametreille on toki mahdollista luoda myös omat symbolitaulunsa.

TAC-komento ja komennon parametrit kannattaa tallettaa tietueeseen, jotka talletetaan linkitettyyn listaan. Tietue voi näyttää vaikkapa tällaiselta:
struct tac
{
    int operation;
    struct tacarg a;
    struct tacarg b;
    struct tacarg c;
}
Argumentit voivat olla tietuita tai yhtä hyvin osoittimia symbolitauluun. Jos käytät erillisiä argumenttitietueita, tietueen rakenne voi olla jotain tämän tyylistä:
struct tacarg
{
int argumentype;
union
{
int number; /* Kokanaislukuliteraareille */
struct symtab *symbol; /* Muuttujille */
char *label; /* Labeleille */
} argument;
}
Huomaa, että kaikki toiminnallisuus esitetään TAC-välikoodina. Seuraavien TAC-käskyjen pitäisi riittää välikoodin generointiin:
Tarvitset myös apumuuttujia, jotka pitää niin ikään lisätä symbolitauluun, sekä labeleita hyppyjä varten. Apumuuttujat ja labelit kannattaa aloittaa alaviivalla, jolloin on pienempi mahdollisuus sille, että ne sekaantuvat ohjelmakoodin muuttujan nimien kanssa.

Virheiden käsittely

Jos ja kun ohjelmasta löytyy virhe, tulostetaan virherivin numero ja yritetään jatkaa koodin jäsentämistä. Viiden virheen jälkeen lopetetaan syötteen jäsentäminen kokonaan. Assembleria ei tietenkään enää tarvitse eikä saa generoida, jos ohjelmasta on löytynyt yksikin virhe.

Assemblerin generointi

Assembler generoidaan muuten samannimiseen tiedostoon kuin itse koodi, mutta päätteenä käytetään .c:n sijaan .s:ää. Älkää poistako tätä aputiedostoa kääntämisen loputtua, sillä saatan tarvita sitä harkkatyönne testaukseen. Perusasiat assemblerista on käyty lävitse harjoituksissa 12, joten niitä en tähän kirjaa. Muuten pyrin antamaan kaiken tarvittavan tiedon tässä, mutta lisätietoa löytyy mm. seuraavien linkkien takaa:
Assemblerin kääntäminen ja linkittäminen ajettavaksi ohjelmaksi käy näppärimmin gcc-kääntäjän avulla, joka kutsuu as-assembleria ja linkittää tarvittavat kirjastot mukaan; eli siis tyyliin gcc -o ohjelma ohjelma.s. Jos haluatte kääntää ohjelman as:n avulla (as -o ohjelma.o ohjelma.s), pitää linkkeriä kutsua seuraavilla optioilla: ld -m elf_i386 -dynamic-linker /lib/ld-linux.so.2 /usr/lib/crt1.o /usr/lib/crti.o -lc ohjelma.o /usr/lib/crtn.o -o ohjelma.

Intellin komentokanta on laaja, mutta meille riittää muutaman käskyn käyttö. Tietotyyppejä ja rekistereitä ei tarvita myöskään monta. Operoidaan oletuksena 32-bittisillä operandeilla. Täppäpä muutava huomioitava asia:

Komentojen syntaksi

Seuraavassa assembler-komentojen toiminta on käyty lyhyesti lävitse, erityisesti parametrien käyttö. imm32 tarkoittaa kokonaislukua (32-bit), r32 rekisteriä (32-bit) ja r/m32 joko rekisteriä tai muuttujaa, muistipaikkaa (32-bit). Aina voi tietenkin pelata varman päälle ja siirtää kaikki operandit ensin rekistereihin ja sitten suorittaa itse operaatio.

Add - laskee yhteen operandit ja tallettaa tuloksen jälkimmäiseen operandiin
addl    imm32, r/m32
addl    r32, r/m32
addl    r/m32, r32


Call - kutsuu parametrina saamaansa funktiota (tai järjestelmäkutsua)
call    printf
call    exit

 
Cdq - needed with idiv, to change doubleword to quadword
cdq

Cmp - kahden operandin vertaus; vertaustulos talletetaan statusrekisteriin. Tämän komennon jälkeen voidaan kutsua ehdollisia hyppyjä, joka käy tarkistamassa hypyn ehdon totuuden statusrekisteristä.
cmpl    imm32, r/m32
cmpl    r32, r/m32
cmpl    r/m32, r32


Dec - parametrin vähentäminen yhdellä ts. dekrementointi
decl    r/m32

Idiv - merkin säilyttävä jakolasku. Jaettava talletetaan rekisteriin %eax ja kutsutaan komentoa cdq, jolloin saadaan 'sign-extend' rekisteriin %edx. Jakaja annetaan parametrina. Rekisteriin %eax palautetaan jakolaskun tulos (ja rekisteriin %edx jakojäännös).
idivl    r/m32

Imul - merkin säilyttävä kertolasku. Parametrit kerrotaan keskenään ja tulos tallennetaan jälkimmäiseen rekisteriin.
imull    r/m32, r32
imull    imm32, r32

Inc - parametrin kasvattaminen yhdellä ts. inkrementointi
incl    r/m32

Jl, jg, jle, jge, je, jne - ehdolliset hypyt. Ehdon paikkaansapitävyys tarkastetaan edellä kutsutun cmp-komennon perusteella.
jCC    label

Jmp - ehdoton hyppy
jmp    label

Mov - arvojen siirto muistin ja rekistereiden väillä sekä kokonaisluvun talletus muuttujaan tai rekisteriin
movl    r32, r/m32
movl    r/m32, r32
movl    imm32, r/m32


Neg - negaatio
negl    r/m32

Pop - arvojen haku pinosta
popl    r/m32

Push - arvojen laitto pinoon
pushl    r/m32
pushl    imm32

Ret - paluu kutsuvaan funktioon tai proseduuriin; paluuarvon voimme unohtaa, vaikka se synteksissa onkin mahdollinen
ret

Sub - vähentää ensimmäisen operandin toisesta operandista ja tallettaa tuloksen toiseen operandiin
subl    imm32, r/m32
subl    r32, r/m32
subl    r/m32, r32


TAC:ista assembleria

TAC:in muuntaminen assembleriksi on varsin suoraviivaista - tässäpä muutama esimerkki:
(TAC_ADD,a,b,c):
fprintf(f,"\t movl %s, %%eax\n", getNameOrValue(c));
fprintf(f,"\t addl %s, %%eax\n", getNameOrValue(b));
fprintf(f,"\t movl %%eax, %s\n", getNameOrValue(a));

(TAC_DIV,a,b,c)
fprintf(f,"\t movl %s, %%eax\n", getNameOrValue(b));
fprintf(f,"\t cdq);
fprintf(f,"\t movl %s, %%ecx\n", getNameOrValue(c));
fprintf(f,"\t idiv %%ecx\n");
fprintf(f,"\t movl %%eax, %s\n", getNameOrValue(a));

(TAC_JLE,a,b,c)
fprintf(f,"\t movl %s, %%eax\n", getNameOrValue(b));
fprintf(f,"\t cmpl %s, %%eax\n", getNameOrValue(a));
fprintf(f,"\t jle %s", getNameOrValue(c));
Muut TAC-käskyt menevät samaan tyyliin. Printf-funktion käytöstä kannattaa katsoa esimerkkejä harjoituksista.

Vihjeitä

Työ kannattaa jakaa osiin. Kirjoita ensin lekseri, joka jakaa syötteen oikein osiin. Lisää sitten vähitellen sääntöjä parseriin, kunnes se hyväksyy määritellyn kielen. Lisää toiminnallisuus, muuttujien lisääminen symbolitauluun ja kolmen osoitteen koodin generointi. Generoi lopuksi assembler.