Budapesti Műszaki és Gazdaságtudományi Egyetem Villamosmérnöki és Informatikai Kar
Drótos Péter
C-FORDÍTÓ FEJLESZTÉSE ALKALMAZÁS-SPECIFIKUS MIKROPROCESSZORHOZ
KONZULENS
Horváth Péter BUDAPEST, 2015
Tartalomjegyék 1 Kivonat...................................................................................................................... 4 2 Abstract .................................................................................................................... 5 3 Bevezetés ................................................................................................................... 6 4 Irodalmi áttekintés ................................................................................................... 7 4.1 Fordítási fázisok .................................................................................................. 7 4.2 A modularitás és az újrafelhasználhatóság előnyei – Retargeting ......................... 9 4.3 LLVM ............................................................................................................... 10 4.4 Tervezési útmutatók........................................................................................... 11 5 Felhasznált eszközök .............................................................................................. 13 5.1 A célprocesszor adatai ....................................................................................... 13 5.2 Szoftver eszközök .............................................................................................. 13 6 Az implementáció menete....................................................................................... 14 6.1 Kódszerkezet ..................................................................................................... 14 6.2 TableGen ........................................................................................................... 15 6.3 Regiszterkészlet ................................................................................................. 15 6.4 Néhány alapvető utasítás.................................................................................... 16 6.5 Aritmetikai és logikai utasítások ........................................................................ 17 6.6 További adattípusok támogatása ........................................................................ 18 6.7 Ugró utasítások .................................................................................................. 19 7 A fordító használata ............................................................................................... 21 7.1 Egy egyszerű program ....................................................................................... 21 7.2 Típusok és operátorok........................................................................................ 23 7.3 Feltételes blokkok, ciklusok, függvények ........................................................... 23 7.4 Egy komplex példaprogram ............................................................................... 24 7.4.1 Inicializálás ................................................................................................. 24 7.4.2 Függvényhívás ............................................................................................ 26 7.4.3 A hívott függvény ....................................................................................... 28 8 Összefoglalás ........................................................................................................... 33 Irodalomjegyzék ........................................................................................................ 34 Függelék A: Mephisto utasításkészlet ....................................................................... 35 Függelék B: Telepítési és használati útmutató (Linux) ............................................ 39
HALLGATÓI NYILATKOZAT Alulírott Drótos Péter, szigorló hallgató kijelentem, hogy ezt a szakdolgozatot meg nem engedett segítség nélkül, saját magam készítettem, csak a megadott forrásokat (szakirodalom, eszközök stb.) használtam fel. Minden olyan részt, melyet szó szerint, vagy azonos értelemben, de átfogalmazva más forrásból átvettem, egyértelműen, a forrás megadásával megjelöltem. Hozzájárulok, hogy a jelen munkám alapadatait (szerző(k), cím, angol és magyar nyelvű tartalmi kivonat, készítés éve, konzulens(ek) neve) a BME VIK nyilvánosan hozzáférhető elektronikus formában, a munka teljes szövegét pedig az egyetem belső hálózatán keresztül (vagy hitelesített felhasználók számára) közzétegye. Kijelentem, hogy a benyújtott munka és annak elektronikus verziója megegyezik. Dékáni engedéllyel titkosított diplomatervek esetén a dolgozat szövege csak 3 év eltelte után válik hozzáférhetővé. Kelt: Budapest, 2015. 12. 06.
...……………………………………………. Drótos Péter
1 Kivonat Egy mikroprocesszor tervezésekor felmerülő elvárások megértéséhez a tervezőnek ismernie kell az eszköz későbbi felhasználásának lehetséges módjait. Egy általános célú központi egység felhasználásának nagy részét a különböző magas szintű programnyelveken (C, C++, Java) írt és fordítóprogram segítségével generált gépi kódok végrehajtása teszi ki. Ezek az utasítássorozatok azonban a magas szintű absztrakciók miatt nem mindig képesek a közvetlenül gépi utasításokkal írt programok erőforráskihasználását és futási hatékonyságát elérni. Jelentős gyorsulás érhető el azonban több különböző – önmagában akár feleslegesnek tűnő – funkció megvalósításával, amelyek kifejezetten az ilyen típusú felhasználást segítik elő. Munkám során – hogy megismerjem a mikroprocesszorok általános felhasználási módjait – egy tanszéki projekt keretein belül készült alkalmazás-specifikus, a hardveres gyorsítás elvét megvalósító mikroprocesszorhoz készítettem C fordítót. A program segítségével a projekten dolgozókkal képesek leszünk bonyolult algoritmusokat C nyelven megfogalmazni, majd azokat futtatásra alkalmas gépi kóddá alakítani, lehetőséget teremtve ezzel a különböző gyorsító áramkörök használatával elért futási idő összemérésére, a processzor általános célú utasításaiból felépülő megvalósításokkal.
2 Abstract Knowing and understanding the use cases of a microprocessor is mandatory for the designer of such device. For most of its time, a general purpose central processing unit is executing code written in high abstraction level languages (e.g. C, C++ and Java) and translated to machine code by compiler software. Unfortunately the programs made this way are unable to reach the execution and resource efficiency of hand-written machine code due to the translation between the different abstraction levels. Implementing some features supporting this use case directly however, may result in a decent speedup. My goal with this project was to familiarize with the most common scenarios, how a microprocessor might be used, which I did through the development of a Ccompiler. The target of the compiler is an application specific instruction set processor, made for a project of the department. With this tool in hand, we will be able to implement complex algorithms in C language and then translate those to executable machine code, making it possible to compare runtime results from the different accelerator circuitries with the solutions using only the device’s native instructions.
5
3 Bevezetés Akárcsak a mikroelektronikában a digitális áramkörök gyártási komplexitása, úgy a belőlük felépülő, nagy programtárolási és végrehajtási kapacitású számítógépek képességeit kihasználó utasítássorozatok összetettsége is meghaladta az ember által részletesen átlátható és tervezhető mértéket. A technológiában rejlő további lehetőségek kiaknázásához elengedhetetlen a tervezést, vagy a programozás esetében a megfogalmazást magasabb absztrakciós szintre emelni. Új funkcionális elemkészletet fogalmazunk meg, amik akár egészen összetett műveleteket is képesek elvégezni. Ezekből építkezünk, majd amikor a gyártásra – vagy program esetében a végrehajtásra – kerül sor, a számítógépre bízzuk, hogy a magas szintű megfogalmazásainkat alakítsa át egy olyan alacsony szintű, részletes leírássá, ami a fenti folyamatok során feldolgozható. Az ilyen automatikus átalakítások persze általában nem képesek elérni a kézi tervezés és optimalizálás hatékonyságát, de ha belegondolunk, hogy segítségükkel több nagyságrendnyi extra teljesítményt nyerhetünk, azért cserébe a hatékonyságvesztés az esetek többségében megengedhető. Nem mellesleg ugyancsak a mi malmunkra hajtja a vizet, hogy a magas szintű leírások erős szerkezeti megkötéseit egy hibás megfogalmazás következtében áthágva, az eszközök azonnal tudtunkra adják, hogy hibát vétettünk, sőt sokszor még iránymutatást is adhatnak, hogyan javítsuk ki azt. A dolgozat első fejezetében áttekintem a fordítóprogramok fejlesztéséhez kapcsolódó irodalmat. A második fejezetben bemutatom, az implementáció folyamatát, illetve a munka során felmerült kérdéseket. Az utolsó fejezetben ismertetem a program képességeit és néhány példával szemléltetem a működését.
6
4 Irodalmi áttekintés A fordító, vagy angol nevén compiler, egy olyan számítógépes program, ami képes az egyik programozási nyelven megírt programot lefordítani egy másik nyelv szabályainak megfelelő leírássá, vagyis az egyik nyelvről egy másikra fordítja a programot. Egy ilyen eszköz segítségével teremthetünk kapcsolatot például az alacsony absztrakciós szintű, gépi futtatásra alkalmas és a magas absztrakciós szintű, emberi gondolkozáshoz közelebb álló programnyelvek között. Szükségességének oka, hogy míg az ember gondolkodása egy ideig képes volt lépést tartani a számítógépek végrehajtásának komplexitásával és egy-egy programozó hatékonyabb munkát tudott végezni, mint az egymás munkáit felhasználó csapat tagjai, mára ez teljesen megváltozott, a gépi végrehajtás több nagyságrenddel felgyorsult, így szinte lehetetlen, de legalábbis nagyon költséges lenne a fejlesztési folyamatokat közvetlenül a gépi nyelven végezni. Ennek következtében a fejlesztési folyamat is teljesen átalakult. A magas szintű nyelveken ugyanis, könnyebb a kód működésének megértése és módosítása, így a csapatbeli fejlesztési munka is hatékonyan hangolható össze, jelentősen növelve ezzel a termelékenységet. A magas absztrakciónak azonban ára van. Teljesítménykiesést jelenthet, egyrészt az absztrakció esetleg szükségtelenül szoros megkötéseinek megvalósítása, de az is lehetséges, hogy a fordítóprogram egyszerűen nem ismeri teljesen, vagy nem képes egészében kihasználni az architektúra elemeit és azoknak minden képességét. Az ezekben az esetekben keletkező erőforrás-veszteség, legtöbbször elhanyagolható, de néhány szélsőséges esetben jelentős különbség jelentkezhet. Ilyenkor lehet érdemes visszanyúlni, közvetlenül a gépi kódoláshoz, és a nagy veszteséget okozó részleteket közvetlenül implementálni.
4.1 Fordítási fázisok Egy fordító megvalósítása korántsem egyszerű feladat. Mindenképp érdemes felosztani a problémát, egyrészt a könnyebb átláthatóság, másrészt az egyes egységek újrafelhasználhatósága céljából. Hasznos, ha azokon a pontokon daraboljuk fel a folyamatot, ahol a programból készült adatállomány jól definiált struktúrákat vesz fel. Gyakori, hogy első körben a programot leíró aktuális reprezentáció szerint, három nagy fázisra bontják a rendszert, ahogy ezt a 4-1. ábra mutatja [1]. 7
4-1. ábra: Háromfázisú felbontás
Első az ún. frontend, ami a magas szintű nyelvből egy köztes reprezentációt (Intermediate Representation, továbbiakban IR) készít.
A második már vagy közvetlenül ezen a belső leíráson végzi el az optimalizálási lépéseket, vagy a nagyobb hatékonyság és modularitás érdekében tovább alakítja azt.
Legvégül pedig az ún. backend gépi kóddá alakítja a rendelkezésre álló leírást.
Előfordul persze, hogy az igazán eredményes transzformáció érdekében ezek a lépcsők sem szigorúan sorban következnek egymás után. Lehetséges például, hogy a fordítónak iteratívan, oda-vissza kell lépkednie az egyes fokozatok között, amíg el nem éri a megfelelő eredményt. Bár a három nagy csoportot a megvalósítás során érdemes még tovább bontani, már itt jól láthatóan elkülönül egymástól a bemeneti nyelv feldolgozása és a kimeneti kód létrehozása. Az alábbi alfejezetekben egy szintén általánosan használt felosztás mentén további részekre bontva mutatom be a folyamatot.
Frontend A pipeline legelső eleme, itt kezdődik meg a forráskód feldolgozása. A bemenet tehát egy szövegfájl, ami megfelel a fordítani kívánt programozási nyelv minden szabályának. Első lépésben a forráson egy lexikai értelmezést végzünk, a beolvasott szöveget ún. tokenek halmazára bontjuk. Ezek lehetnek például olyan változónevek, kulcsszavak vagy numerikus értékek, melyek mindegyike egy-egy szimbólumnak felel meg az adott programban. Ezt követi az ún. parsing, avagy szintaktikai értelmezés. Ami a már rendelkezésre álló tokenek listájából a nyelv szabályainak megfelelően felépít egy fa struktúrát, ami a program szerkezetét reprezentálja. Ezen a szintaxis fán végzünk azután típusellenőrzést, ami megvizsgálja, hogy a program megfelel-e a konzisztenciai kritériumoknak. Itt derül kit például, ha egy változót nem deklarálunk használat előtt, vagy maga az adott környezetbeli használat értelmetlen. 8
IR és optimalizálás Ez a lépés opcionális, ha nem valósítjuk meg, akkor a backendnek közvetlenül a szintaxis fából kell dolgoznia, azonban a modularitás érdekében érdemes lehet egy egyszerű, akár forrástól, akár céltól, vagy akár mindkettőtől független belső leíró nyelvet alkalmazni. Ebben a fázisban érdemes elvégezni az optimalizálás nagy részét is, hiszen itt egy általunk szabadon választott leíráson dolgozhatunk.
Backend A fordítók utolsó feladata, hogy a belső leírást futtatható bináris gépi kóddá alakítsa. A regiszterfoglalás során a változók regiszter nevekké, illetve a bináris kódban sorszámokká, vagy címekké képződnek le. Itt a fordító – ismerve az adott függvény szerkezetét – fordítási időben dönti el, a műveletvégzések alatt hol helyezi el a változókat és a részeredményeket. Ezután kezdhető el az ún. assembly generálása. A leírásban szereplő műveletek mindegyikéhez ki kell választani a megfelelő utasítást, esetleg utasítássorozatot. Egyes esetekben azonban bonyolult minták előfordulását is vizsgálnunk kell, hiszen lehetnek több alapműveletet megvalósító komplex utasításaink is. A teljes fordítási folyamat végén az ún. assembler és a linker kialakítja a végső, immár futtatható programot. Az assembly nyelv tulajdonképpen a bináris gépi kód szöveges, cím független megfelelője, melyben a függvények és elágazások címkékkel vannak megjelölve. Ebben a lépésben a linker egymás után fűzi a szükséges függvényeket, majd az assembler az utasítások, regiszterek neveit átalakítja a nekik megfelelő bináris kóddá, a konstansokat kettes számrendszerbe váltja és az ugrásokhoz, függvényhívásokhoz használt címkéket lecseréli a tényleges, tárban elfoglalt bináris címre.
4.2 A modularitás és az újrafelhasználhatóság előnyei – Retargeting A fenti felosztás moduláris megvalósításának segítségével, azonos interfész esetén, szabadon felcserélhetjük az egyes frontendeket, backendeket és optimalizáló modulokat. Egyetlen fordító estén ezzel nem sokat nyerünk, de nézzük meg, mi a helyzet akkor, hogyha N különböző forrásnyelv és M különböző gépi célnyelv között kell átalakításokat végeznünk. Abban az esetben, ha nem modulárisan alkotjuk meg a fordítókat, azok egyetlen részét sem tudjuk újra felhasználni, így külön fordítót kell 9
készítenünk mind az
∗
lehetséges esetre. Ugyanakkor, ha alkalmazzuk a
háromfázisú tervezést és közös belső leírást választunk, ahogy a 4-2. ábra mutatja, összesen N darab frontend, M darab backend és egyetlen optimalizáló elkészítésével teljesíteni tudjuk a feladatot, amit ráadásul még azokkal is megoszthatunk, akik az N és M nyelveken kívül, mással is dolgoznak. Ha például egy ilyen belső leírás és a hozzá tartozó optimalizáló modul szabadon hozzáférhető és széles körben használt, nagyon sokan, a mi projektünktől akár teljesen eltérő be- és kimeneti nyelvekkel dolgozók is számunkra értékes fejlesztéseket végezhetnek azon. Az alkalmazkodásunkért cserébe egy sokkal kiforrottabb eszközt kapunk, mint amit mi magunk azonos időráfordítással készíthettünk volna.
4-2. ábra: Retargeting
4.3 LLVM Az LLVM eredetileg egy mozaikszó (Low Level Virtual Machine), ugyanakkor maga a projekt neve is. Létrejöttekor az LLVM egy nagy halom, jól definiált interfésszel rendelkező, újrafelhasználható könyvtárcsomag volt. Ez tehát nem egy fordító program, hanem ún. library gyűjtemény, egy fordító infrastruktúra, amit aztán a megfelelő eszközök segítségével felhasználhatunk az egyedi feladatok megvalósítására [2]. Fontos megjegyezni, hogy más fordító megoldások nagyon kevés újrahasznosítható kódot tartalmaztak. Például az első sorban Linux rendszereken elérhető, de igen széles körben elterjedt és sokféle front- és backendet megvalósító GNU Compiler Collection (GCC) [3] részeit sem lehet ilyen módon felhasználni, többek között az izolálhatatlan rétegstruktúra miatt.
10
LLVM IR Az LLVM fontos eleme az LLVM IR, a fordítók két oldalát elválasztó egységes program struktúra. Az LLVM IR egy alacsony szintű, virtuális, szintaktikájában inkább az assemblyhez hasonlító, azonban erősen típusos programnyelv. A fordítók ezt a leírást használják a programok belső tárolására, valamint az optimalizálási lépések nagy részét is ezen végzik. Ekkor még a céleszköz regiszter szerkezete nem ismert és a nyelv sem akar a programra erőltetni semmilyen, megkötéseket jelentő struktúrát, ezért az LLVM IR regiszterkészletének elemszáma korlátlan és minden ideiglenes (nem nevesített) tárolója szigorúan csak egyszer kaphat értéket.
A háromfázisú tervezés megvalósítása LLVM segítségével A frontend eszköz feladata ez esetben a forráskód ellenőrzése és értelmezése, akár közvetlenül, vagy esetleg egy szintaxis fa építésén keresztül, a végén azonban feltétlen szükség a program LLVM IR nyelvre fordítása. Ezután a kód hatékonyságának növelése érdekében optimalizálási lépések következhetnek, melyek mind be-, mind kimenete a fenti IR leírás. Végül pedig egy gépi kód generálását megvalósító modul hajtja végre a célnyelvre történő átalakításokat.
4.4 Tervezési útmutatók Sokan foglalkoztak már azzal, hogyan kell, akár nulláról indulva, vagy például LLVM segítségével fordító programot készíteni. Ezek az útmutatók azonban, valószínűleg a feladat komplexitása miatt, sokszor hiányosan, vagy nagyon magas szinten, kevés magyarázattal kezelik a problémát. Az azonban a legtöbből kivehető, hogy érdemes inkrementálisan végezni a fejlesztést. Először minimális, de már önállóan is működő funkcionalitást próbáljunk megvalósítani, majd azt igyekezzünk minél jobban tesztelni, hogy megbizonyosodjunk, annak helyes működéséről mielőtt tovább bővítenénk az eszköz képességeit. Létezik egy általános útmutató, amely végigvezet a teljes folyamaton. Szabadon megválaszthatjuk a felhasználni kívánt eszközöket, például az implementálás programnyelvét. Nem feltétlen választja szét a fordító különböző fázisait, azonban segítségével teljes mértékben ki tudjuk használni a cél architektúra minden lehetőségét [4]. Magához az LLVM-hez is rendelkezésre áll egy dokumentum, ami nagy léptékben ugyan, de végigvezet a szükséges fázisokon, hogyan kell megtervezni és
11
felépíteni egy a könyvtárba illeszthető backendet [5]. Ez azonban egy kezdő számára nem sok segítséget jelent. Ennél sokkal részletesebb az az útmutató, ami egy a mi projektünkben használthoz hasonló, szintén MIPS alapokra épülő processzorhoz, az ún. Cpu0-hoz készít LLVM backendet. Ezen útmutató hibája azonban, hogy bár a forráskód teljes egészében rendelkezésre áll, sőt fejezetről fejezetre külön felbontva elérhető, sajnos elég kevés magyarázó szöveget tartalmaz, ami igencsak megnehezíti a kód megértését és így a szükséges módosítások elvégzését [6].
12
5 Felhasznált eszközök 5.1 A célprocesszor adatai Ahhoz, hogy gépi kódot készítsek, szükségem volt a céleszköz strukturális, program végrehajtási szempontból vett felépítésére, azaz az architektúra részleteire. Ezt az ún. Mephisto projekt dokumentációjából ismerhettem meg. A processzor felépítésének érdekessége, hogy nem csak egy teljesen általános célú – egész értékeken dolgozó – utasítás- és regiszterkészletet valósít meg, hanem ezt kiegészíti még egy konfigurálható gyorsító áramkör is, amely egy külön utasításon keresztül érhető el. Emellett megjelenik még egy speciális regiszterablak technika, amivel futás közben a regisztercsoportokat váltogatva gyorsíthatjuk a függvények kontextus váltását. Ezeken a funkciókon kívül, még néhány fixpontos utasítás, és az hozzájuk tartozó 64-bites akkumulátor regiszter segíti a működést. Mivel a dokumentáció még fejlesztés alatt áll, ezért a függelékbe illesztettem a számomra fontosnak ítélt és felhasznált részleteket.
5.2 Szoftver eszközök A fejlesztői eszközök és segédprogramok bőséges választéka miatt a munkát UNIX környezetben, Ubuntu operációs rendszer alatt végeztem. Ezen a rendszeren rendelkezésre állt az alábbi összes szoftver. Az LLVM Compiler Infrastructure segítségével a megvalósításnál elég volt a backendre koncentrálnom. Alább az 5-1. ábra illusztrálja a teljes fordítás menetét. A frontend fordítást, azaz a C forráskód LLVM IR kóddá transzformálását Clang fordítóval végeztem. Ezt fordítottam azután tovább az általam implementált backend fordítóval Mephisto assembly nyelvre, végül pedig a tanszéki Mephisto assemblerrel készítettem el magát a végrehajtható bináris gépi kódot.
5-1. ábra: Fordítási pipeline
13
6 Az implementáció menete A fordító teljes funkcionalitásának megközelítése érdekében, az LLVM környezetben készítettem el egy backend modult, ami az LLVM IR-t fordítja át a processzor assembly nyelvére. A fordítás további fázisaihoz az előző fejezetben listázott eszközöket használtam fel. A feladat komplexitását illetően tudni érdemes, hogy egy ilyen komplett backend modul nagyságrendileg 10.000 sor forráskódot jelent. Szerencsére azonban, bár a cél architektúrák jelentősen eltérnek, mégis sok folyamatot azonos módon kell elvégezni mindegyiken. A kód nagy része ezért könnyedén átvehető más fordítókból. Mivel a Mephisto MIPS alapokra épül, az egyik lehetőség közvetlenül a MIPS fordító felhasználásával dolgozni. A Cpu0 architektúra viszont bizonyos szempontból még közelebb áll a Mephisto-hoz, mint az eredeti MIPS, ráadásul a fejezetekre tagolt forráskód és a könyv kommentárja segítségével a megértése és így a felhasználása is egyszerűbb. Kiindultam tehát a működő Cpu0 forráskódból. A kódot felbontottam a könyvben jelölt fejezetek szerint és először mindig az aktuális fejezetet módosítottam, majd hozzáfésültem a többit is. Lecseréltem az egyes paramétereket és rutinokat, megvalósítottam a hiányzó funkciókat, és a szükségtelenek közül is elhagytam néhányat. Végül ellenőriztem az adott szekció működését és továbbléptem a következő, számomra is hasznos rész megvalósítására.
6.1 Kódszerkezet A forráskód fejezetenkénti felbontását alkalmaztam. A forrásfájlokban, mindig az adott fejezethez tartozó blokkban dolgoztam. Ennek eléréséhez a C preprocesszor szintaktikájához hasonló, feltételes szerkezet jelöli az adott fejezetben lefordítandó állományt. Mivel azonban ezek a szerkezetek – ahogyan a kódrészletet tartalmazó 6-1. ábra mutatja – többnyire kommentezett sorokban vannak, az előfordító egyszerűen átugorja ezeket. Script segítségével viszont elvégezhető a forrás felbontása.
6-1. ábra: Kódszerkezet
14
6.2 TableGen A TableGen eszköz már az LLVM infrastruktúra része. Segítségével egyszerűen és tömören fogalmazhatjuk meg a cél architektúra tulajdonságainak nagy részét, például az utasítások és a regiszterek paramétereit. Az adatszerkezeteket ráadásul a C++-ban megszokott osztályszerkezet segítségével, származtatással is definiálhatjuk, majd ezekből is több példányt hozhatunk létre. Ily módon, könnyegén strukturálhatjuk a specifikus paramétereket, és megkímél minket a redundanciát jelentő kódismétléstől is. Fordításkor a TableGen eszköz a rendelkezésére bocsátott .td állományokban példányosított egyedek közül, a bemeneti paraméterek alapján szükségesnek ítélteket, egy-egy általános struktúrába teríti ki, ahol megegyező leírással, azok összes paramétere hozzáférhető. Szerencsére, a munka során a módosítások nagy részét elegendő volt ezeken az állományokon elvégezni. Néhány esetben viszont, közvetlenül a C++ állományokat kellett megváltoztatni, ami igen sok és fáradtságos munkába került.
6.3 Regiszterkészlet Jelen verzióban a regiszter ablak funkciót nem implementálom, így összesen 32 darab általános célú regiszter áll a fordító rendelkezésére. Vannak természetesen további, speciális célú regiszterek, ezekkel majd az esetleges felhasználásukkor foglalkozom. A általános célú regiszterek csoportosítását a nekik szánt funkció szerint elvégeztem, van tehát 8-8 globális, bemeneti, lokális és kimeneti regiszter. Felépítésükben és használatukban ezek teljesen egyformák. A .td állományban megadtam, hogy ezek mind az LLVM IR-ben használt i32 típusú regiszterek, azaz 32 bites egész (vagyis nem lebegőpontos) értékeket tárolnak. Ezen a szinten az értékek előjelességének értelmezése már nem a típusban, hanem az utasításokban válik el, így ezzel itt szükségtelen foglalkozni. Megadtam továbbá a tárolók assemblyben használt nevét, illetve a függvényeknél használt speciális stack funkciót megvalósító Stack Pointer (továbbiakban SP) számára kijelöltem és lefoglaltam a $g7 globális regisztert, ennek példányosításakor már az SP nevet használtam, hogy a fordítóban, így lehessen rá hivatkozni. A többi regiszternél az assembly jelöléssel konzisztens neveket választottam, csupán a megkülönböztethetőség kedvéért, a kisbetűket nagyokra cseréltem (pl. $g0 → G0, $o7 → O7, stb.) A regiszterekből ezután egy halmazt készítettem, ez a halmaz lesz majd az egyes utasítások be- és kimenete, amit majd az utasítások leírásakor adok meg. 15
6.4 Néhány alapvető utasítás Az első célom a lehető legegyszerűbb program, a műveletvégzés nélküli, konstans visszatérési értékű függvény lefordítása. A példát a 6-2. ábra kódrészlete illusztrálja.
6-2. ábra: Egyszerű program
Ahhoz, hogy értéket adhassunk vissza, valahol léteznie kell valamilyen tárolónak, ami felveszi az adott értéket. A függvény például a stack-re, vagy akár regiszterekbe is írhatja a visszatérési értéket. Mivel a Cpu0 egy teljesen általános stack kezelést valósít meg, ami számomra is megfelel, így én is azt használtam. Ebben a megoldásban a visszatérési értéknek vagy a stack-en foglalunk helyet és be is írjuk azt, vagy akár megadhatunk regisztereket is, melyekben közvetlenül, a memória kikerülésével valósítható meg az értékek visszaadása. Tegyük fel, hogy a visszatérési érték a memóriában foglalt helyre kerül. Mivel RISC processzorral dolgozunk, ezért a memóriába külső adatot közvetlenül író utasítás nem áll rendelkezésre. A visszatérés értékét előbb valamelyik regiszterbe kell betöltenünk, azután írhatjuk a regiszter tartalmát a memóriába. Szükségképpen a megfelelő .td állományban definiáltam a 16-bites közvetlen értéket a regiszter alsó 16 bitjébe töltő (i2rfl) utasítást, valamint a 32-bites memóriaírás (sw) és a teljesség kedvéért az olvasás (lw) utasításokat. Az értékbetöltő utasításnál nem sok választásom volt, az egységes utasítás szóhossz miatt ugyanis csak 16 bites adatot lehet az utasítás kódszavába rejteni. A memória írás és olvasás esetében azonban léteznek byte- és félszókezelő utasítások is. Ugyanakkor, mivel a main() visszatérési értéke int, ami alapértelmezésben leggyakrabban 32-bites értéket jelöl, és a Clang is az i32 típust társítja hozzá, ezért célszerű a 32-bites memória műveleteket használni. Ezen kívül már csak a visszatérő (ret) utasításra van szükség, ez is bekerült a leírásba. A regiszter betöltő i2rfl utasítás 16-bites adatszélessége miatt ezzel még csak olyan egész értékeket tud a függvény visszaadni, amelyek felső 16 bitje csupa 0. Kicsit bővítve a lehetőségeket, megvalósítottam a felső 16 bit értékét betöltő (i2rfh) utasítást is, de ezzel a problémának csak egy részét oldottam meg. Most már betölthettem olyan értékeket is, amelyeknek az alsó 16 bitje volt a csupa 0. Tetszőleges 32-bites integer 16
betöltéséhez azonban még egy utasításra szükségem volt. Rendelkezésre áll egy, a regiszter tartalmának alsó 16 bitjén, és egy szintén 16 bites külső adaton VAGY műveletet végző (ori) – a felső 16 bitet nem módosító, vagy másképpen fogalmazva az operandust felül nullákkal kiegészítő – utasítás. A kettő kombinálásával keletkező minta, már nem egy önálló gépi utasítás, hanem egy pipeline szerű utasításkapcsolat eredménye. Ha ily módon összekapcsolom a fenti i2rfh és az ori utasításokat, akkor külön-külön az i2rfhval beállíthatom a felső, az ori-val pedig az alsó 16 bit értékét, és így tetszőleges 32 bitet kaphatok. Ekkor természetesen fel kell bontanom a beállítani kívánt 32-bites értéket külön az alsó és a felső félszóra, és ezekkel kell végrehajtani a két utasítást, de ez fordítási időben könnyedén megtehető. Ezzel egy univerzális megoldást kaptam 32-bites értékek betöltésére, ugyanakkor nagy pazarlás lenne minden esetben ezt a módszert használni, hiszen e két utasítás helyett, sokszor önmagában főleg az i2rfl, de néha az i2rfh is elegendő. Ezen optimalizálási lehetőség kiaknázásához elég volt, az előbbi mindhárom ún. illesztési mintát megvalósítanom és a definíciók sorrendjével egy egyszerű prioritáskülönbséget felállítanom köztük. Ennek eredménye, hogy az utasítás illesztő algoritmus, előbb megpróbál egy utasításból álló mintát illeszteni a betöltendő értékre, majd ha az nem sikerült, csak akkor használja a két utasításból álló általános megoldást.
6.5 Aritmetikai és logikai utasítások Az utasítások definiálása a TableGen-nek köszönhetően kifejezetten egyszerű. Elegendő a közös ősosztályokból leszármaztatni egy-egy megfelelő típust, ami az adott utasítás összes paraméterét eltárolja. Ebben a lépésben definiáltam az egész számokon végzett aritmetikai és logikai műveletek, vagyis azokat, amelyeknek a kimenete és legalább egy bemenete is általános célú regiszter. Az összeadó és kivonó műveletek érdekessége, hogy a processzorban kétfajta utasítás is tartozik hozzájuk. Az egyik – az ún. unsigned – nem jelez az esetleges túlcsordulás esetén, míg a másik utasításcsoport kivételként kezeli ezt az esetet. Mivel az LLVM IR nem különbözteti meg a két utasítást, más információt pedig nem tudunk gyűjteni a használat céljáról, az utasítás választó algoritmus szempontjából ezek teljesen egyenértékűek. Hibakeresési célokra kiváló funkció ugyan a túlcsordulás elleni védelem, azonban legtöbbször nem alkalmazzák ezt a megoldást. Mivel általánosan elmondható, hogy a felhasználók tudatában vannak a jelenségnek, sőt néha építenek is rá, ezért én is a másik megoldást alkalmazom, és alapértelmezésben a kivételt nem generáló utasításoknak adok nagyobb prioritást. Az előbbiek tükrében felmerül a kérdés, hogy érdemes-e mégis külön definiálni ezeket az 17
utasításpárokat, hiszen a választó algoritmus úgyis azt találja meg, amelyiket előrébb írtuk a leírásban. Gondoljunk azonban arra is, hogy ezeket az utasításokat esetleg valamely komplex minta illesztésénél is felhasználhatjuk, ahol nem feltétlenül építünk a számábrázolás tartományára, illetve ami még fontosabb, feltételhez köthetjük az egyes utasítások felhasználását, így akár fordítási időben dönthetünk, szeretnénk-e mégis az előbb említett hibakeresés üzemmódot használni. Érdekes még a forgató, avagy az ún. shiftelő műveletek esete, mivel a processzor egy utasításban mindig csak egy helyi értéket tol el az adott regiszteren. Ez jelentősen eltér a C nyelvben szereplő << és >> operátorok működésétől, hiszen ott, a műveletek jobb oldalán, szabadon megadhatjuk a forgatások mértékét, sőt tehetjük ezt akár változó érték használatával is. A fix értékű forgatások implementálása mindenképp megoldható fordítási időben is, például egymás után illesztett, egy forgatást végző utasításokkal. Ennél okosabb és gyorsabb megoldás, ha kihasználjuk, hogy az N-nel való balra shiftelés 2 -nel való szorzásnak felel meg, a szorzás pedig már rendelkezésre áll az utasításkészletben. A dolgunk mindössze annyi, hogy fordítási időben kiértékeljük a 2 értéket, és ezt futás alatt betöltve egy regiszterbe elvégezzük a szorzást. Az így kapott utasítás pipeline be- és kimenetei közvetlenül összekapcsolódnak (összefüggő fa építhető belőle), így egyszerűen definiálható a .td állományban és a mintaillesztő algoritmus fel tudja használni. A változóval adott mérték esetén más a helyzet, a futási idejű kiértékeléshez ciklust kell alkalmaznunk, akár a 2 -nel való szorzást, akár az N darab egymás utáni, egy helyi értékű forgatást akarjuk használni. Ezzel viszont az a probléma, hogy a ciklus megvalósításához szükséges ugró utasítások – mivel eredményük nem egy regiszterben képződik, hanem a program végrehajtásában – nem kapcsolhatóak össze a ciklus magját megvalósító aritmetikai műveletekkel, ami miatt a mintaillesztő algoritmus sem tudja felhasználni. Mivel maga a CPU sem támogatja ezen utasítások végrehajtását és a ciklus a C környezetben is megvalósítható, ezzel egyelőre nem foglalkozom.
6.6 További adattípusok támogatása A korábban említett 32-bites memória műveletek mellett a processzor támogatja a 16 és 8 bites, vagyis a félszavas és a bájtos memória műveleteket. Ez esetben is van már létező megoldás, hiszen a 8 és 16 bites típusokat a MIPS és a Cpu0 is támogatja, így az utasítások nevének módosítása mellett más teendőnk nincsen. Támogatott továbbá a 64-
18
bites egész értékek kezelése is, egyelőre a szorzást kivéve. Ezek a műveletek fordítási időben átalakulnak, és futáskor már 32-bites regiszterpárokkal dolgoznak.
6.7 Ugró utasítások Ha meg akarjuk bontani egy gépi program sorfolytonos végrehajtását, szükségünk van legalább egy ún. ugró utasításra. Ez az utasítás paraméterként megkap egy cím jellegű adatot, de az eredmény, ez esetben nem egy általános célú regiszterbe, hanem a programszámlálóba kerül. Az utasítás végrehajtása után a következő utasítást már a programszámláló által mutatott új címről olvassa be a processzor. Ilyen jellegű ugró utasítás szükséges például függvényhíváskor, illetve függvény végi visszatérésnél, esetleg a ciklust, vagy case szerkezetet megtörő, kilépő utasításnál. Az utasításkészletünk rendelkezik, egy, a két utóbbi művelethez felhasználható, mellékhatással nem rendelkező, relatív című ugró utasítással és támogat egy-egy további, az első két műveletet egyszerűsítő utasítást is. A függvényhívást elősegítő, relatív címzésű (call) utasítás a programszámláló ugrás előtti értékét elmenti, hogy a függvény végeztével, innen folytathassa a végrehajtást. A közvetlen címzésű visszatérő utasítás így már paraméter nélkül hívható, és a korábban eltárolt számláló értékét tölti vissza. A másik eset, amikor szükségünk van ugró utasításra, ha elágazást szeretnénk illeszteni a programba. Az elágazás azt jelenti, hogy a programvégrehajtás, egy futási idejű változó függvényében, több lehetséges folytatás közül választ ki egyet. A legegyszerűbb eset, ha kétfelé ágazik a program. Tetszőleges különböző útra ágazás is visszavezethető kaszkádosított kettéágazásokra, akárcsak multiplexerek esetében, így elég, ha ezekkel foglalkozunk. Adódik a lehetőség, hogy az egyik ág a folyamatos végrehajtás legyen, azaz az elágazó utasítás az egyik esetben ne csináljon semmit. A másik ág végrehajtásához azonban mindenképp valahova máshova kell átugranunk a memóriában. Ahhoz, hogy ezt megtehessük, a legegyszerűbb megoldás a feltételes ugró utasítások támogatása. Szerencsére ebben az esetben is rendelkezésre áll két regiszter tartalmát összehasonlító, és az eredmény függvényében ugró, vagy helyben maradó néhány utasítás. Szerencsére az assembler képes feloldani a programban elhelyezett címkéket, nem szükséges az assembly-be fix címeket generálni. Ennek köszönhetően az egyes függvények önállóan fordíthatóak és szabadon rendezhetőek egymás után az assembly fájlban.
19
Mivel
az
utasításkészlet
csak
regiszterek
közötti
egyenlőséget
vagy
egyenlőtlenséget vizsgáló feltételes ugrást tartalmaz, például a logikai (bool) értékek szerinti elágazás végrehajtásához, minden esetben egy 0 vagy 1 értéket kell betölteni az egyik szabad regiszterbe. Ez pedig, bár sokszor elkerülhető lenne, a programozási gyakorlatban egy meglehetősen gyakori eset, mint a 6-3. ábra példáján.
6-3. ábra: Feltétel megfogalmazása
A problémán talán úgy lehet fogást találni, hogy az elágazások mindig egy-egy boolean érték függvényeként értékelődnek ki, amihez inkább kapcsolódna egy, a regiszter 0 értékét vizsgáló feltételes utasítás. Jelen megvalósítás esetében, a fordító, e függvények végrehajtásához, egy regiszterbe 0 értéket tölt be és azzal hasonlítja össze a logikai értéket tároló regiszter tartalmát. Az optimalizáló algoritmusoknak köszönhetően nem minden egyes ugrásnál tölti be a 0 értéket, hanem amíg van szabad regiszter, addig nem írja ezt felül és később már betöltés nélkül használhatja. A Cpu0 esetében például a 0. regiszter fizikailag 0 értékre van rögzítve. Rám azonban semmilyen megkötés nem vonatkozik és a különbségek kiélezése érdekében szándékosan nem használom ezt a lehetőséget. Ez természetesen programtól függő mértékben hatással van a teljesítményre, de cserébe nyerünk egy szabad regisztert.
20
7 A fordító használata A fordító nem azzal a céllal készült, hogy bármely C nyelvi szabványnak kimerítően megfeleljen. A munka során arra törekedtem, hogy a processzor által támogatott általános utasítások elérhetőek legyenek a C nyelv típusain és operátorain keresztül. Azonban még ennél is fontosabb szempont volt, hogy az ugró utasítások segítségével feltételesen végrehajtható blokkokat, ciklusokat és függvényeket lehessen megfogalmazni a magasabb szintű nyelv elemein keresztül. A fordítás két különálló lépésben zajlik, az első a C forráskód átalakítása LLVMIR reprezentációvá, a második az LLVM-IR transzformálása Mephisto assembly nyelvre. Az utóbbi, az LLVM infrastruktúrához tartozó Target Independent Code Generator-ba illesztett, általam készített backend segítségével végezhető el, az előbbi pedig bármelyik C – LLVM kompatibilis fordítóval megvalósítható. Mivel az LLVM infrastruktúra a backend miatt úgy is rendelkezésre áll, a frontend fordítást is az ebbe beépíthető Clang programmal végeztem. Ezen programok telepítéséhez, és használatához a függelékben található részletes leírás. Az alábbi fejezetben részletesen bemutatom és példakódokkal is illusztrálom a program képességeit, valamint felhívom a figyelmet néhány fontosabb hiányosságára is.
7.1 Egy egyszerű program A lehető legegyszerűbb önálló programként vegyünk egy olyan függvényt, aminek egyetlen utasítása egy konstans visszatérési érték beállítása. Ennek a gépi futtatásra nézve mindössze annyi a feladata, hogy helyet foglaljon a visszatérési értékének, és beállítsa azt.
7-1. ábra: Egyszerű program C
Érdekességképpen az ehhez tartozó LLVM-IR programot a 7-2. ábra mutatja. A C nyelvi elemek nagy része jól felismerhető az IR kódban is, azonban van benne egy lépés, ami nem feltétlenül az, aminek elsőre gondolhatnánk. Ebben ugyanis, amikor a program helyet foglal egy i32 típusú változónak, majd 0-val inicializálja azt, nem a 21
visszatérési értéket állítja be. Ez valószínűleg már egy optimalizációs lépés eredménye, amit az ugró utasításoknál is taglaltam, azaz, hogy a 0 érték belső tárolása sok műveletet egyszerűsíthet, így már az IR is igyekszik a maga módján, erről gondoskodni.
7-2. ábra: Egyszerű program IR
A lenti 7-3. ábra az ennek megfelelő assembly kódot mutatja. A kódból és a fenti példából látszik, hogy nem közvetlen a C program fordítása, hanem az IR transzformálása történik a fordítóban, hiszen ezt a lépést a C kódban nem írtuk le, sőt a backend-ben sem definiáltunk ilyen optimalizálási műveletet.
7-3. ábra: Egyszerű program Assembly
A $g7 a már fent említett SP szerepét valósítja meg, ennek segítségével tárolhatjuk a függvény paramétereit, visszatérési értékét, és lokális változóit. A program 8 bájt helyet foglal a stack-en, 2 darab 4 bájtos (32 bites) típus számára. Az egyiket a fenti IR-ben is látható, lokális konstans 0 számára, amit aztán be is tölt a rekeszbe, míg a másikat a visszatérési érték számára, amit azonban nem tárol el a memóriában. Ez utóbbi egy a backend által megvalósított hatékonyságnövelési lépésnek köszönhető, ami a visszatérési értéket egy, vagy több kimeneti (ez esetben $o0) regiszteren keresztül adja vissza a hívó programnak. Érdemes még megfigyelni, hogy mivel a regiszterbeli visszatérési értéket a programnak mindenképp be kell állítani, nem használ el egy másik, általános célú regisztert a memóriába írandó 0 betöltéséhez. A program végére a nop utasítás a visszatérés ugró jellege miatt, a processzor pipeline struktúrájába, egy úgynevezett buborékot tesz be, a soron következő utasítás helyett, hogy elkerülje annak, esetleg érvénytelen végrehajtását. Ez jelen esetben csak biztonsági lépés, ennek a processzornak ezt explicit jelölés nélkül is, önállóan meg kell tennie.
22
7.2 Típusok és operátorok Az előző példa ugyan már egy önálló futásra képes program, sok hasznát viszont egyelőre nem vesszük. Ahhoz, hogy valódi számításokat végezzük a processzorral, szükségünk van műveletekre. A műveletvégzést C-ben operátorok jelölik. A műveletek hatékony elvégzéséhez segítséget nyújthat, hogyha az int típus mellett a feladathoz esetleg jobban illeszkedő, más típusok is definiáltak. A fordító képes kezelni az alapvető C operátorok azon részét, amiket a processzor is támogat, ugyanakkor van néhány, ami jelen verzióban nem került megvalósításra. Érdemes megjegyezni, hogy a műveletek támogatottsága az első sorban az LLVM-IR műveletekre vonatkozik, ezek nagy része viszont egyértelműen megfeleltethető egy-egy C operátornak, így közvetve ugyan, de hiányuk kihat a C nyelvi elemek támogatottságára is. Elsőként említeném, hogy mivel az utasításkészletben nincs támogatás az osztást, illetve maradékképzést megvalósító műveletekre, a / és a % operátorok működése nem definiált. Egy másik fontos pont a << illetve >> operátorok korlátolt használhatósága. A processzor ugyanis nem tartalmaz ún. barrel shifter egységet, ami egy regiszter tartalmának tetszőleges mértékű forgatását tenné lehetővé egyetlen utasításon belül. A fenti operátorok működése ezért csak két speciális esetben definiált, ha a >> operátor jobb oldalán konstans 1 érték, illetve a << operátor esetén tetszőleges konstans érték szerepel, mivel ez visszavezethető 2 -nel való szorzásra. További megkötés, ami ugyan a frontend oldalán kiküszöbölhető lenne, de alapbeállítások esetén nem használható a ?: operátor. Ez ugyanis a feltétel teljesülésétől függően ad vissza egy értéket, ami alapértelmezés szerint a frontendben, feltételes mozgató utasítássá fordul. Még egy további megkötés a fenti típus független esetek mellett, hogy a 64 biten megvalósított long long típus szorzása sem támogatott. A típusok közül 1, 8, 16, 32 és 64 bites egész értékek kerültek megvalósításra, ez lefedi az C által generált egész értékű C típusok teljes spektrumát. Támogatott továbbá a pointerek és így a tömbök használata is. A változókat azonban csak lokális környezetben lehetséges definiálni, az assembler segítségével ugyanis nem lehetne inicializálni a globális változók értékét, és így a működésük sem lenne teljes értékű.
7.3 Feltételes blokkok, ciklusok, függvények Az egyik legfontosabb feladata a C nyelvnek az assemblyvel szemben a változók nevesítése mellett az utasítások blokkokba vagy függvényekbe szervezése, melyek akár 23
feltételesen vagy ciklusban is végrehajthatóak. Ezt a feladatot sikerült is teljesíteni, aminek köszönhetően a processzorra jól átlátható kód készíthető, jelentősen növelve ezzel a
fejlesztés
hatékonyságát
és
az
elkészült
program
módosíthatóságát,
újrafelhasználhatóságát.
7.4 Egy komplex példaprogram A fordító készítésének nem titkolt szándéka volt, hogy a processzorhoz megvalósított gyorsító áramkörök teljesítménye ne csak kézzel írt, hanem fordító által generált assembly programmal is összemérhető legyen. A program most már készen áll arra, hogy egy ilyen algoritmust, a megfelelő megfogalmazás mellett, lefordítson futtatható gép kóddá. Az egyik modulhoz – ami gyors Fourier transzformációt (FFT) végez – el is készíttettem a fordítóval egy lehetséges megvalósítást. Ezen a programon keresztül igyekszem illusztrálni a rendszer működését, a megvalósított és átvett funkciókat.
7.4.1 Inicializálás A forrás természetesen C nyelven készült, a fenti megkötéseknek megfelelően. Szükségünk van először is egy fő függvényre, ezt akarjuk végrehajtani a processzorral. Tekintve, hogy maga a program nem operációs rendszer alatt fog futni, a főprogram nevét és visszatérési típusát szabadon megválaszthatjuk. Mivel a processzor indítási paramétereinek beállítása úgyis assembly nyelven történik, lesz lehetőségünk megadni, hogy az inicializálás végrehajtása után melyik függvényt hajtsa végre a processzor. Mivel a globális változók használata nem támogatott, a főprogram feladata lesz továbbá az adatok és a segédértékek beállítása lokális változóként. Az értékek ebben az esetben kettes komplemens kódolással ábrázolt fixpontos törtekként értelmezendőek, ahol az alsó 20 bit van elkülönítve a tört rész számára. Ennek megfelelően az egész rész 1-es helyi értéke a 20. bit. Az alábbi példában (7-4. ábra) a 32 elemű vektoron végzett FFT-hez a vektor elemeinek valós értéke 1-re van beállítva.
7-4. ábra: Inicializálás C
24
Ennek a résznek az LLVM-IR megfelelőjét ábrázolja a 7-5. ábra és a 7-6. ábra kódrészlete. A függvény először helyet foglal a nulla érték tárolására, majd a lokális változóknak. Az alloca utasítás egy pointer értéket ad vissza, amit egy-egy virtuális regiszterbe tesz a program.
7-5. ábra: Helyfoglalás IR
Ezután inicializálja a lefoglalt memóriát, vagyis a kapott címekre írja be az inicializálási adatot. Természetesen, hogyha valamely változó inicializálási értéke más változókon végzet aritmetikai műveltekként jön létre, akkor azokat a műveleteket is itt végzi el, még az eltárolás előtt. Érdemes megfigyelni, hogy az inicializált változók értékét minden esetben visszaírja a program a memóriába, nem csupán virtuális regiszterekben tárolja azokat. A 7-4. ábra és a 7-6. ábra értékei közti különbséget egyébként az okozza, hogy a fordító itt decimális számként jeleníti meg a fent hexadecimálisan megadott értékeket.
7-6. ábra: Inicializálás IR
Az inicializálás gépi kódú megfelelőjét a 7-7. ábra mutatja. A függvény elejére a fordító, ún. epilógust illeszt. Ebben a memóriafoglalásokat egybegyűjti és egy utasítással az összes lokális változónak helyet foglal a stack-en, azaz letekeri a $g7 regisztert. Ne felejtsük el, hogy a stack visszafelé terjeszkedik és mindig az utolsó elhelyezett adatra mutat. A 608 byte elsőre soknak tűnhet, mert ez 152 integert jelent. Ebből 32-32 az adatok valós és képzetes része, további 16 a cosinus tábla, 6 a meghívott ún. butterfly műveletet végző függvény paramétertáblája, és 1-1 a konstans 0 és a főprogram visszatérési értékének a helye. Ez eddig csak 88. Látni fogjuk később, hogy a maradék 64 szóban, bár sem a C kódban, sem az IR-ben nincs erre utasítás, az adatok pointereit fogja automatikusan eltárolni a fordító, mivel ezek többször is felhasználásra kerülnek. Ezután az általam definiált betöltő utasításoknak megfelelően a 0 értéket i2rfl-lel, míg a 0x100000 értékeket i2rfh-val tölti be. Azt is érdemes megfigyelni, hogy a program nem 25
olvassa be minden sw utasítás előtt újra a konstans értéket, hanem megjegyzi, hogy az már szerepel az egyik regiszterben, sőt még azt is figyeli, hogy fel akarja-e később is használni azt a konstanst a program, és ha igen, akkor nem is írja felül addig az azt tároló regisztert.
7-7. ábra: Inicializálás Assembly
7.4.2 Függvényhívás A főprogram másik feladata az adatok inicializálása mellett, hogy az FFT algoritmusnak megfelelő ún. butterfly műveleteket meghívja. Ezt a C kódban (7-8. ábra) olyan függvényhívásokkal érjük el, amiben az adatokat cím szerint adjuk át, hogy a függvény módosíthassa azokat. A számításhoz szükséges cosinus tábla adatait viszont nyugodtan átadhatjuk érték szerint, sőt mivel a függvény módosítja is ezeket, így ezzel letudjuk a másolatkészítést is, hogy az eredeti értékek változatlanul maradjanak.
7-8. ábra: Függvényhívás C
Az IR kódrészletben (7-9. ábra) látható, hogy az érték szerint átadott változókat a program betölti a memóriából és a címeket tároló ún. virtuális regiszterekkel együtt adja át azokat. Vegyük észre, hogy a függvényhívás paraméterei itt mind virtuális regiszterek.
7-9. ábra: Függvényhívás IR
Assemblyben elsőre már egy kicsit bonyolultabbnak tűnik a helyzet, de ha tudjuk, hogy mit keresünk, ezen is könnyedén ki lehet igazodni. Fontos információ a későbbiekre nézve, hogy ez a hívás az FFT első főciklusából származik, azaz a program először fér hozzá többek között például az adatként használt változók mutatóihoz is. A 7-10. ábra 26
kódrészletének elején például könnyedén felismerhető a két érték szerint átadott adat regiszterbe töltése. Ezeket azután a program fel is írja egyből a stack-re, ahova még a főprogram elején, az epilógusban foglalt helyet. Ez után látható, hogy a $g7 regiszterhez hozzáad valamilyen ofszetet és az eredményt két memória rekeszbe is eltárolja. A cél rekeszek közül az egyik a stack-en a 12-es offset. Mivel a stack legtetején kell állnia a hívott függvény paramétereinek, és a cosinus tábla értékeit a 20-as és a 16-os ofszetű rekeszekbe rakta, már érezhetjük, hogy ez az egyik cím szerint átadott paraméter lesz. Ne felejtsük el, hogy az adatokat lokális változóként hoztuk létre, ezért azok a stack-re kerültek. Fordítás közben a program nem ismeri a SP aktuális állását, ezért futás közben, aritmetikával kell kiszámolnia a változók címét, a SP és az ofszet összeadásával. A másik cél rekeszbe való elhelyezés pedig egy eredetileg optimalizálásnak szánt lépés, ami viszont jelen esetben igen sokba kerül. A fordító ugyanis, mivel a program többször is hivatkozik az adat változók címére – minden FFT főciklusban pontosan egyszer – igyekszik megspórolni a címszámító aritmetikai műveletek egy részét és a kiszámított pointer értékeket eltárolja a memóriába. Ez a lépés az IR-ben nincs benne, ezt a backend fordító adja hozzá a programhoz.
7-10. ábra: Függvényhívás címszámítással Assembly
A második főciklusban – ahogy a 7-11. ábra kódrészletéből is látszik – ezzel szemben már nincs aritmetika a pointerek kiszámításához, azokat a program egyszerűen előhúzza a megfelelő memória rekeszből. Ez a lépés a Mephisto esetében sajnos nem éri el a célját, mivel ez esetben a memóriaelérés sokkal lassabb, mint az a néhány megspórolt aritmetikai művelet.
27
Az FFT algoritmust ismerve tudhatjuk, hogy minden főciklus először a 0. elemen hajt végre műveletet, tehát az alsó és a felső kódrészletben is szerepelnie kell a reals_0 és az imags_0 változók címeinek. Például a fenti részletben 152[$g7]-re eltárolt imags_0 változó címét, lent már csak felveszi a rekeszből, és ugyanúgy, mint az előbb most is a 8[$g7] rekeszbe rakja, mint a hívandó függvény paramétere. Ha tovább haladunk akár a fenti, akár a lenti kódban, feltűnhet, hogy a másik két kiszámított, vagy betöltött pointer nem kerül be a hozzájuk tartozó függvényparaméter rekeszbe, de azt is észrevehetjük, hogy nem is az eddig használt $g0, $g1 regiszterekbe lettek betöltve. Ennek oka egy, szerencsére már jelen esetben is hasznos, hatékonyságnövelő lépés, miszerint a hívó maximum két paramétert közvetlenül, a $i0, $i1 regisztereken keresztül ad át a hívottnak. A memóriával ugyan nem spórol, hiszen a láthattuk, hogy ez esetben is mind a 6 paraméternek megvan a helye, de egy-egy futási idő szempontjából drága memória műveletet megúszhatunk ezzel. A lenti részletben látható, hogy a $i0-ba szánt értéket a 156[$g7] rekeszből veszi elő, amit a fenti részlet töltött be oda, ezek alapján, az a reals_0 változó címe lesz. Az ezután következő call utasítás már magáért beszél, itt történik meg a PC jelenlegi értékének eltárolása és a programvégrehajtás ezután a címkének megfelelő helyről folytatódik. A végső nop pedig, ahogy korábban említettem, azt biztosítja, hogy az ugrás alatt ne kezdje meg más, érvénytelen utasítás feldolgozását a processzor (Mephisto esetében erre egyébként sem lenne szükség).
7-11. ábra: Függvényhívás címszámítás nélkül Assembly
7.4.3 A hívott függvény A főprogram által hívott butterfly műveletet megvalósító függvényt a 7-12. ábra mutatja. Ez a példa alkalmas arra, hogy illusztráljam a függvények paraméter átvételét, illetve a ciklusok és feltételes blokkok működését.
28
7-12. ábra: FFT függvény C
Paraméter átvétel Az IR kód (7-13. ábra) nem egyszerűen felhasználja az átvett virtuális regiszterek értékét, hanem helyet foglal, és a memóriába másolja azokat, majd onnan fér hozzá használat során.
7-13. ábra: FFT paraméterek IR
A főprogramhoz hasonlóan az assembly (7-14. ábra) most is a függvény epilógusával kezdődik, azaz helyfoglalás gyanánt ismét letekeri a Stack Pointer-t. Ezután 29
adatokat tölt be, de nem abból a tartományból, amit most foglalt. Neki most vannak ugyanis bemenő paraméterei, amiket nem ő, hanem a hívó helyezett el a stack tetején, azaz még az átállítás előtti SP által mutatott területen. Ezek eléréséhez, a hívóbeli indexekhez, kénytelen még a függvény a saját módosításának a mértékét is hozzáadni. Így a főprogramban 20[$g7] értékkel címzett rekesz itt a 68[$g7] címen elérhető. Az ily módon, és a $i0, $i1 regiszterekből közvetlen átvett adatokat ezután, mint bármely más lokális változót, a saját stack területén helyezi el a függvény.
7-14. ábra: FFT paraméterek Assembly
Ciklus és feltételes blokkok Az inicializálás után következő for ciklust négy blokkra bontja a fordító, amiket fel is címkéz. Az első blokk a ciklus inicializálása, a második a belépési feltétel vizsgálata, harmadik maga a ciklusmag, végül pedig a ciklusmag utáni műveletek. Ezek a blokkok pontosan ebben a sorrendben követik egymást a program leírásában, de természetesen a végrehajtás nem lesz ilyen egyszerű. Az inicializálással és a ciklusmaggal most nem foglalkozom, azok egyszerű aritmetikai műveletek. A belépési feltétel kiértékelése pontosan azt csinálja, amit a C kódban megadtunk, megvizsgálja az i változó értékét, és ha kisebb, mint 10, akkor a végrehajtás átugrik a ciklusmagra, egyébként pedig a ciklus után következő utasítássorozatra. Ez egyébként pontosan így nézne ki, bármely egyszerű feltételes (if) szerkezetnél is. Egy if-else, szerkezet annyiban különbözne ettől, hogy a feltételbe foglalt blokk végén mindenképp ki kell ugranunk, hogy átlépjünk az else ágba foglalt blokk utasításain. Ez a case szerkezet esetében nem automatikus, ezért van ott szükség a break utasításra, ha így akarjuk használni.
30
7-15. ábra: Ciklusfeltétel IR
A for ciklus harmadik blokkját a 7-16. ábra kódrészlete mutatja. Ez a ciklusmag minden egyes lefutása után végrehajtandó. Ennek a blokknak a végére kerül be egy feltétel nélküli ugró utasítás, ami a ciklus belépési feltételének kiértékelésére vezeti vissza a végrehajtást. A program mindaddig a ciklusban marad, amíg a ciklusmag, vagy az utána végrehajtandó blokk műveleteinek hatására, a belépési feltétel egyszer csak nem teljesül.
7-16. ábra: Feltétel nélküli ugrás IR
Az assembly már egy kicsit trükkösebb. A ciklusfeltétel kiértékelésének kódrészletét a 7-17. ábra mutatja. Gondoljuk végig, mit is kell kiértékelni. Ha i < 10, lépjünk be a ciklusmagba, egyébként ugorjuk át azt. Mivel feltételes ugró utasítás, ami igaz kiértékelés estén ide, hamis esetén oda ugrik nincs, ezt a lépést ketté bontja a fordító egy ugrás, ha igaz, és egy ugrás, ha hamis utasításra. Ebből az elsőt el is hagyhatjuk, mivel a ciklusmag sorfolytonosan követi a belépési feltétel kiértékelését, tehát a belépéshez nem kell tennünk semmit, a végrehajtás automatikusan innen folytatódik. Megvalósítanunk tehát csak az ugrás, ha hamis utasítást kell. A fordító itt valamiért egy érdekes, nem triviális megoldást választ az i < 10 kifejezés nem teljesülésének vizsgálatára. A triviális választás az lenne, ha simán végrehajtanánk az i < 10 művelet kiértékelését, és ugrás, ha hamis, például ugrás, ha egyenlő 0 utasítással lépnénk tovább. Jelen esetben az i változó inicializálása után viszont, nemhogy nem közvetlenül 10-es értékkel végez el egy összehasonlítást (slti), hanem érdekes módon a 9-es értéket tölti a regiszterbe. Az eset minden bizonnyal az IR háromparaméteres ugró utasításának automatikus felbontásából következik, amit a lehető legáltalánosabban próbáltak implementálni a fejlesztők. A csel abban rejlik, hogy megcseréli az összehasonlítás két oldalát, vagyis az i < 10 helyett a 9 < i egyenlőtlenséget értékeli ki. Az utóbbi kifejezést negálva 9 ≥ i, az egyenlőséget meg nem engedve és felhasználva, hogy i egész 10 > i, a két oldalt felcserélve pedig visszakapjuk az i < 10 kifejezést. Az egyetlen különbség a negálás 31
következtében, hogy most már igaz érték esetén kell ugrani. Korábban említettem, hogy mivel csak egyenlőséget és egyenlőtlenséget vizsgáló ugró utasítások állnak rendelkezésre, a logikai értéktől függő ugráshoz, vagy a 0 vagy az 1 konstans értékkel kell összehasonlítani a kifejezést. Láthattuk, hogy más utasításoknál is többször előjön a 0 érték felhasználása, így ezeknél az utasításoknál is érdemes azt választani, én is így tettem. Az igaz érték felismeréséhez tehát azt kell vizsgálnunk, hogy a regiszter tartalma különbözik-e a nullától, ahogy ez történik is a kódban.
7-17. ábra: Ciklusfeltétel Assembly
A ciklusmag és a mag utáni blokk végrehajtását követően a végrehajtást természetesen vissza kell irányítani a belépési feltételt vizsgáló blokkra, hogy ha szükséges újra beléphessen a ciklusba a program. Ezt egy egyszerű feltétel nélküli ugró utasítással tehetjük meg.
32
8 Összefoglalás A félév során az LLVM fordító infrastruktúra segítségével a Mephisto assembly nyelvhez készítettem egy backend modult. Ezt összekötve a Clang fordítóval, és kiegészítve néhány beépített UNIX eszközzel, egy olyan pipeline-t kaptam, aminek a bemenete – bizonyos megkötések mellett – C nyelvű forrás, kimenete pedig Mephisto assembly. Ez egy jól használható prototípus, ami a projekthez kapcsolódó feladatok egy részének megfelel. A további fejlesztések során bővíteni lehet a támogatott utasítások készletét. Érdemes megvalósítani továbbá a processzor által kínált speciális lehetőségeket, mint például a regiszterablak technika. Illetve, ami talán a legfontosabb, megpróbálni kiaknázni a konfigurálható áramkör használatát, akár közvetlenül a C nyelvből. A jelenlegi folyamatot összekapcsolva például egy C forrású hardver leíró nyelv fordítóval, a bemenet szétválasztható lehetne például egy gyakran használt, hardverben implementált függvény, míg a többi funkciót assemblyben valósítjuk meg.
33
Irodalomjegyzék [1]
Torben Ægidius Mogensen: Basics of Compiler Design, Anniversary edition, ISBN 978-87-993154-0-6, lulu.com, 2010.
[2]
Chris Lattner: LLVM, http://www.aosabook.org/en/llvm.html (utoljára megtekintve 18:22, 2015. október 26.)
[3]
Brian Gough: An Introduction to GCC,First printing ISBN 0-9541617-9-3, Network Theory Limited, 2004 március
[4]
Abdulaziz Ghuloum: An Incremental Approach to Compiler Construction, Scheme and Functional Programming, 2006.
[5]
LLVM Dokumentáció: Writing an LLVM Backend, http://llvm.org/docs/WritingAnLLVMBackend.html (utoljára megtekintve 10:50, 2015. október 27.)
[6]
Chen Chung-Shu: Tutorial: Creating an LLVM Backend for the Cpu0 Architecture, 3.6.4. Kiadás, 2015. július 15.
34
Függelék A: Mephisto utasításkészlet A fordító elkészítéséhez először is fel kellett mérni, hogy a processzor architektúrájának a felépítése befolyásolja-e a működés logikáját. Mivel a dolgozat célja egy általános célú fordító elkészítése, első sorban az ehhez a működéshez szükséges utasítások megvalósításával foglalkoztam. Elhagytam a fixpontos műveletvégzést megvalósító DSP áramkör utasításait, nem használtam a gyorsító áramkört, a rendszerhívást, valamit a be- és kimeneti regisztereket és a gyors környezetváltást segítő regiszterablak technikát sem alkalmaztam. Ennek fényében elvonatkoztathatunk a felépítéstől, hiszen a fennmaradó utasítások teljesen általános célt szolgálnak, az általuk végzett művelet a formális definícióból egyértelműen kinyerhető. Egy meglehetősen fontos paraméter azonban, hogy a processzor az ún. Harvard architektúrát valósítja meg, azaz az utasítások és az adatok számára külön memóriát használ. Ebből adódóan az utasításokhoz, és azok memóriaterületéhez a végrehajtás alatt semmilyen módon nem férhetünk hozzá. Az alábbi felsorolás tartalmazza az utasításkészlet bemutatásához szükséges elnevezések feloldását.
$g0 - $g7: az aktuális ablak első nyolcas blokkjának regiszterei $i0 - $i7: az aktuális ablak második nyolcas blokkjának regiszterei $l0 - $l7: az aktuális ablak harmadik nyolcas blokkjának regiszterei $o0 - $o7: az aktuális ablak negyedik nyolcas blokkjának regiszterei RF: általános célú regiszter fájl DM: adat memória FFin: bemeneti FIFO FFout: kimeneti FIFO APT: gyorsító áramkör paraméter tábla ACC: DSP akkumulátor IP: utasítás számláló EPS: beépített stack pointer CWP: aktuális regiszterablak pointer EI: megszakítás engedélyező regiszter CAUSE: kivétel regiszter
$g a globális, $i a bemeneti, $l a lokális és $o a kimeneti regisztereket jelöli.
35
kódszó
paraméter lista
formális definíció
ADAT MOZGATÁS RF[r][31:16] = egész(c)
i2rfh
RF[r][15:0] = 0000h r,c
i2rfl
RF[r][31:16] = 0000h RF[r][15:0] = egész(c) RF[r1][31:24] = DM[ r2 + egész(c) ] RF[r1][23:16] = DM[ r2 + egész(c) + 1 ]
lw
RF[r1][15:8] = DM[ r2 + egész(c) + 2 ] RF[r1][7:0] = DM[ r2 + egész(c) + 3 ] RF[r1][31]...RF[r1][16] = DM[ r2 + egész(c) ][7] RF[r1][15:8] = DM[ r2 + egész(c) ]
lh
RF[r1][7:0] = DM[ r2 + egész(c) + 1 ] RF[r1][31]...RF[r1][16] = 0000h RF[r1][15:8] = DM[ r2 + egész(c) ]
lhu
RF[r1][7:0] = DM[ r2 + egész(c) + 1 ] lb
r1,c[r2]
RF[r1][31]...RF[r1][8] = DM[ r2 + egész(c) ][7] RF[r1][7:0] = DM[ r2 + egész(c) ] RF[r1][31]...RF[r1][8] = 000000h
lbu
RF[r1][7:0] = DM[ r2 + egész(c) ] DM[ r2 + egész(c) ] = RF[r1][31:24] DM[ r2 + egész(c) + 1 ] = RF[r1][23:16]
sw
DM[ r2 + egész(c) + 2 ] = RF[r1][15:8] DM[ r2 + egész(c) + 3 ] = RF[r1][7:0] DM[ r2 + egész(c) ] = RF[r1][15:8]
sh
DM[ r2 + egész(c) + 1 ] = RF[r1][7:0]
sb
DM[ r2 + egész(c) ] = RF[r1][7:0]
ff2rf rf2ff
RF[r] = FFin r
wba
FFout = RF[r] RF[r] = átméretez(ACC)
rf2apt
r1,r2
a2ff
-
APT[r1] = RF[r2] FFout = átméretez(ACC)
36
EGÉSZ SZÁM ARITMETIKA ÉS LOGIKA add
RF[r1] = RF[r2] + RF[r3]
addu
RF[r1] = RF[r2] + RF[r3]
sub
RF[r1] = RF[r2] - RF[r3]
subu
RF[r1] = RF[r2] - RF[r3]
mul
RF[r1] = átméretez( RF[r2] * RF[r3] )
and
RF[r1] = logikai_és(RF[r2],RF[r3])
or
r1,r2,r3
xor
exception on overflow
exception on overflow
exception on overflow
RF[r1] = logikai_vagy(RF[r2],RF[r3]) RF[r1] = logikai_xor(RF[r2],RF[r3]) ha ( egész(RF[r2]) < egész(RF[r3]) ) akkor RF[r1] = 1
slt
egyébként RF[r1] = 0 ha ( természetes(RF[r2]) < természetes(RF[r3]) ) akkor RF[r1] = 1
sltu
egyébként RF[r1] = 0 addi
RF[r1] = RF[r2] + egész(c)
addiu
RF[r1] = RF[r2] + egész(c)
subi
RF[r1] = RF[r2] - egész(c)
subiu
RF[r1] = RF[r2] - egész(c)
andi
r1,r2,c
ori
exception on overflow
exception on overflow
RF[r1] = logikai_és(RF[r2],összefűz(0000h,c)) RF[r1] = logikai_vagy(RF[r2],összefűz(0000h,c)) ha ( egész(RF[r2]) < egész(c) ) akkor RF[r1] = 1
slti
egyébként RF[r1] = 0 ha ( természetes(RF[r2]) < természetes(c) ) akkor RF[r1] = 1
sltiu
egyébként RF[r1] = 0
sll
RF[r1] = logikai_shift_balra(RF[r2])
srl
RF[r1] = logikai_shift_jobbra(RF[r2])
sra
r1,r2
RF[r1] = aritmetikai_shift_jobbra(RF[r2])
rol
RF[r1] = forgatás_ballra(RF[r2])
ror
RF[r1] = forgatás_jobbra(RF[r2])
FIXPONTOS DSP ARITMETIKA fx_mul
ACC = RF[r1] * RF[r2]
fx_ma
ACC = ACC + ( RF[r1] * RF[r2] )
fx_mn
r1,r2
fx_mna
ACC = ACC – ( RF[r1] * RF[r2] ) ACC = átméretez( ( RF[r1] * RF[r2] )2 )
fx_subsqr fx_mulf fx_maf fx_mnf
ACC = -1 * ( RF[r1] * RF[r2] )
ACC = RF[r] * FFin r
fx_mnaf
ACC = ACC + ( RF[r] * FFin ) ACC = -1 * ( RF[r] * FFin ) ACC = ACC – ( RF[r] * FFin ) gyorsító hívás
accel
-
ACC = accelerator.result ha (exception) akkor CAUSE = accelerator.exception_code
37
ELÁGAZÁS jmp
c
ugrás ( IP + egész(c) ) ha ( RF[r1] == RF[r2] ) akkor ugrás ( IP + egész(c) )
beq
egyébként nincs műveletvégzés r1,r2,c
bne
ha ( RF[r1] != RF[r2] ) akkor ugrás ( IP + egész(c) ) egyébként nincs műveletvégzés ha ( RF[r] == átméretez(ACC) ) akkor ugrás ( IP + egész(c) )
beqa r,c
egyébként nincs műveletvégzés ha ( RF[r] != átméretez(ACC) ) akkor ugrás ( IP + egész(c) )
bnea
egyébként nincs műveletvégzés
STACK KEZELÉS DM[ESP] = IP+1[31:24] DM[ESP+1] = IP+1[23:16] call
c
DM[ESP+2] = IP+1[15:8] DM[ESP+3] = IP+1[7:0] ESP = ESP – 4 ugrás ( IP + egész(c) )
ret
-
initesp
ESP = ESP + 4 ugrás (összefűz(DM[ESP],DM[ESP+1],DM[ESP+2],DM[ESP+3])) ESP = RF[r] DM[ESP] = RF[r][31:24] DM[ESP+1] = RF[r][23:16] DM[ESP+2] = RF[r][15:8]
push
DM[ESP+3] = RF[r][7:0] r
ESP = ESP - 4 ESP = ESP + 4 RF[r][31:24] = DM[ESP]
pop
RF[r][23:16] = DM[ESP+1] RF[r][15:8] = DM[ESP+2] RF[r][7:0] = DM[ESP+3]
icwp dcwp
CWP += 16 CWP -= 16
EGYÉB ei
c
EI = c DM[ESP] = IP+1[31:24] DM[ESP+1] = IP+1[23:16] DM[ESP+2] = IP+1[15:8]
syscall
-
DM[ESP+3] = IP+1[7:0] ESP = ESP – 4 CAUSE = 2 ugrás EXCEPTION_VECTOR
lcr
r
RF[r] = CAUSE
nop
-
nincs műveletvégzés
38
Függelék B: Telepítési és használati útmutató (Linux) Munkám során az LLVM és a Clang 3.6.0-ás verzióját használtam, ehhez a verzióhoz készült a backend modul is, így a telepítés menetét is ezen mutatom be. A két programcsomag az LLVM projekt hivatalos oldaláról, az llvm.org-ról tölthető le, jelen esetben a forrás állományokra (Sources) van szükség. Szükséges továbbá a Mephisto backend forrás állománya is, ez a dolgozat mellékleteként elérhető. Válasszunk
egy
tetszőleges
könyvtárat
a
program
számára
(például
/home/<username>/llvm vagy másképpen ~/llvm). Csomagoljuk ki ide – például a grafikus felület segítségével – az LLVM forrásban lévő könyvtárat és nevezzük át src-re (~/llvm/src). Ugyanoda, ahova kicsomagoltuk, hozzunk létre egy könyvtárat a lefordított program számára release néven (~/llvm/release). Ezután csomagoljuk ki a clang forrásban található könyvtárat és nevezzük át clang-ra, majd másoljuk át az előbb kicsomagolt
és
átnevezett
src
könyvtáron
belül
a
tools
alkönyvtárba
(~/llvm/src/tools/clang). Végül pedig csomagoljuk ki a Mephisto backend forrás állományban található src könyvtárat (tartsuk meg a nevét) és fésüljük össze (merge) az eredeti src könyvtárral (~/llvm/src). Fájlok ütközésekor természetesen cseréljük le az eredetit. cmake -DCMAKE_BUILD_TYPE=Release -DLLVM_TARGETS_TO_BUILD=Cpu0 \ -G "Unix Makefiles" ../src/
Nyissunk egy terminált, ebben navigáljunk az előbb készített release könyvtárba (cd ~/llvm/release). Itt adjuk ki a fenti parancsot. Ha a cmake program nem található, az utasításoknak megfelelően telepítsük azt. A parancs sikeres lefutása után adjuk ki a make parancsot. Ekkor egy státuszlapot kapunk, ami %-os értékkel jelzi, hol tart a fordítás. Ha a fordítás kész, adjuk hozzá a programok keresési útvonalához a release könyvtár bin alkönyvtárát. PATH=$PATH:~/llvm/release/bin
Ezt a beállítást a terminál kilépés után elfelejti, így vagy minden alkalommal meg kell ismételnünk, vagy hozzá kell adnunk az egyik belépéskor lefutó scripthez. Ezután már használhatjuk a clang és llc programokat. A teljes fordítás négy lépésből áll. Példaképp az aktuális könyvtárban lévő test.c forrásállományt fordítjuk a test.meph assembly kódra. 39
clang -c test.c -emit-llvm -o test.bc llc -march=cpu0 -relocation-model=static -filetype=asm test.bc -o test.sed sed -e '/^[[:space:]]*[\.\$\;].*$/d' < test.sed > test.awk awk '/:/{printf $1;next;}1' < test.awk > test.meph
A négy parancs egyben is elvégezhető, ha a közbülső állományok helyett csővezetéket alkalmazunk. clang -c test.c -emit-llvm -o - | \ llc -march=cpu0 -relocation-model=static -filetype=asm - -o - \ sed -e '/^[[:space:]]*[\.\$\;].*$/d' \ awk '/:/{printf $1;next;}1' > test.meph
40