20 So funktionieren Compiler Tüftelnde Dolmetscher Compiler übersetzen Quellcode in lauffähige Programme und Bibliotheken. Das klingt simpel, ist es aber nicht: Im Inneren moderner Compilersuiten analysiert ein mehrstufiger Prozess die Sourcen, weist auf Fehler hin, erzeugt Zwischencodes und Tabellen, sortiert viel um und macht sich über den Zielprozessor Gedanken. Tim Schürmann besjunior, 123RF Unter ihrem Blech arbeitet die vermeintlichen Blackbox Compiler komplexe Prozesse ab, deren Implementierung gute Kenntnisse in Automatentheorie und formalen Sprachen verlangt. Es verwundert also nicht, wenn Compilerbau als Lehrinhalt für angehende Informatiker oder Automatisierungstechniker Standard ist. Wer nicht in einer solchen Vorlesung saß oder eine Auffrischung brauchen könnte, für den fasst dieser Artikel die Grundlagen zusammen. Als Ideengeber diente ein Vorlesungsskript (siehe Kasten Lehrstoff Compilerbau ). Grob vereinfacht durchläuft ein Compiler drei Schritte: Er liest zunächst den Quellcode ein, analysiert ihn und synthetisiert das fertige Programm (Abbildung 1). Im ersten Schritt liest der Compiler den Quellcode zeichenweise ein und versucht Schlüsselwörter, Variablennamen, Zahlen, Strings und alle weiteren Bestandteile zu identifizieren. Um diese lexikalische Analyse kümmert sich der Scanner. Er überliest zudem alle Kommentare. Syntax In der zweiten Phase prüft eine weitere Komponente die Syntax und die Semantik des Programms. Ihr fällt beispielsweise auf, wenn ein Programmierer eine Variable nutzt, die er noch gar nicht deklariert hat, oder wenn ein Semikolon am Ende eines Ausdrucks fehlt. Moderne Compiler trennen dabei noch einmal zwischen syntaktischer und semantischer Analyse: Die als Parser bezeichnete Komponente des Compilers versucht die Syntaxelemente zu erkennen. Ist dies geglückt, ruft sie eine entsprechende Funktion auf, die semantische Routine. Diese prüft beispielsweise, ob der Programmierer die Variable auch tatsächlich deklariert hat. Diese Trennung erlaubt es, Aufgaben besser zu optimieren und den Quellcode effizienter zu analysieren. Zudem lassen sich die beiden Komponenten so besser warten und portieren. Die Syntax- und Semantik-Analyse entpuppt sich aber als nicht-triviale Aufgabe, wie ein simples Beispiel zeigt: Der Compiler für einen dummen Taschenrechner soll eine Textdatei mit einem Rechenausdruck einlesen. Der Taschenrechner kann nur Zahlen addieren und multiplizieren, Punkt- geht vor Strichrechnung. Im Zweifel geben die Klammern die Reihenfolge vor. Erlaubt ist etwa (1+3)*(4+5) aber nicht: (1+)*4 Der Compiler muss in diesem Fall verstehen, welcher der beiden Ausdrücke gültig ist und welcher nicht. Grammatik Damit der Compiler den zweiten Ausdruck als fehlerhaft erkennt, teilt der Compilerbauer ihm zunächst mit, wie der korrekte Quellcode aufgebaut ist. Das geschieht über Regelsätze. Ähnlich wie bei regulären Ausdrücken prüft der Compiler dann, ob der Quellcode diese Regeln einhält. Im Beispiel ist eine einzelne Zahl Lehrstoff Compilerbau Wollen (und müssen) Informatikstudenten lernen, was die Programmiersprachen-Welt im Innersten zusammenhält, besuchen sie am besten eine Vorlesung zum Thema Compilerbau. So eine hielt auch Mathematik-Professor Peter Köhler im Sommersemester 2016 an der Uni Gießen [1]. Mit seiner freundlichen Genehmigung hat sich das Linux-Magazin durch die Aufzeichnungen gearbeitet und fasst die wichtigsten Punkte zusammen.
offensichtlich ein gültiger Ausdruck. Liegen zwei Ausdrücke»x«und»y«vor, dann Quellcode sind auch»x + y«,»x * y«und»(x)«gültige Ausdrücke. Die Regeln lassen sich auch formal in Kurzschreibweise notieren. Durchaus beliebt ist die Backus-Naur- Form (BNF). Für das Beispiel ergibt sich damit der Kürzelsalat aus Listing 1: Das Programm (»program«) besteht hier aus einem Ausdruck (»expr«). Der wiederum liegt dann vor, wenn es sich entweder um einen»term«handelt, oder wenn es sich ebenfalls um einen»term«handelt, dem aber das Zeichen»+«und dann wieder ein Ausdruck folgen. Analog sind die übrigen Regeln zu lesen. Die letzte Regel ist nötig, da Zahlen aus mehreren Ziffern (»digits«) bestehen können. Diese Regeln, so genannte Produktionen oder Ableitungsregeln, bilden die Grammatik der Quellsprache. Wie die deutsche Grammatik gibt diese Grammatik Regeln vor, welche die Programmiersprache konstituieren. Kürzel wie»term«oder»factor«heißen Symbole. Alle Symbole, die auf der linken Seite stehen, nennt der Fachmann Nicht-Terminale, alle anderen Terminale. Letztgenannte wären im Beispiel»digit«,»+«,»*«,»(«und»)«. Für eine Programmiersprache dürfen mehrere Grammatiken existieren. Welche der Entwickler wählt, hängt von der Sprache und den Anforderungen ab. Mit der obigen Grammatik lassen sich unendlich lange Ausdrücke und somit Programme schreiben. Das gelingt nur, weil einige Regeln einen rekursiven Aufbau besitzen. Die im Beispiel genutzte Grammatik bezeichnet die Grammatiktheorie als kontextfreie Grammatik (oder Typ 2). Sie wird als regulär bezeichnet, wenn alle Regeln einer der beiden Formen folgen: U ::= t U ::= Vt Lesen Analysieren Übersetzen Scanner Dabei ist»t«ein Terminalzeichen,»U«und»V«sind Nicht-Terminal-Zeichen. Top-down-Parser syntaktische und semantische Analyse Zur großen Freude der Entwickler lässt sich aus der Grammatik recht elegant ein Compiler basteln: Für alle Nicht- Terminale erstellt der Entwickler dazu jeweils eine Funktion, die sich um die entsprechende Auswertung kümmert. Sie ruft bei Bedarf die entsprechenden Kolleginnen zu Hilfe. Die zu»expr«gehörende Listing 1: Backus-Naur-Form 01 program ::= expr 02 expr ::= term term + expr 03 term ::= factor factor * term 04 factor ::= number ( expr ) 05 number ::= digit digit number Listing 2:»expr()«01 int expr() { 02 int wert = term(); 03 if (File.ReadNextChar() == '+') wert += expr(); 04 return wert; 05 } Code erzeugen Abbildung 1: Grober Aufbau eines Compilers: Code einlesen, analysieren und ausführbares Programm erzeugen. Maschinencode Grundlagen 12/2017 Titelthema www.linux-magazin.de 21
22 Abbildung 2: Die Projekt-Webseite stellt den unter Linux recht bekannten Parser-Generator Bison vor. Funktion»expr()«besitzt beispielsweise einen Aufbau, wie ihn Listing 2 kurz skizziert. Gemäß der zweiten Regel in Listing 1 beginnt ein korrekter Ausdruck wie»1+2«in jedem Fall mit einem»term«. Die Funktion»expr()«bittet daher im ersten Schritt die noch zu implementierende Kollegin»term()«um eine Auswertung. Als Antwort liefert»term()«im Beispiel die»1«zurück. Im zweiten Schritt schaut sich»expr()«anschließend das nächste Zeichen im Quellcode an. Handelt es sich bei diesem um ein»+«, muss gemäß der zweiten Regel von Listing 1 wieder ein Ausdruck folgen, weshalb sich»expr()«selbst aufruft. Das Ergebnis dieses»expr()«liefert im Beispiel die»2«, die der Compiler auch gleich selbst zu dem schon berechneten Zwischenergebnis addiert. Damit wäre der Compiler am Ende eines gültigen Ausdrucks angekommen, weshalb»expr()«im Beispiel den fertig berechneten Wert zurückgibt. Ein ausgewachsener Compiler könnte an dieser Stelle eine passende Anweisung in Maschinencode generieren. Für alle anderen Nicht-Terminale generiert der Entwickler Funktionen nach dem gleichen Prinzip. Setzt er dann»program()«auf die Quellcodedatei an, erhält er automatisch das fertig übersetzte Programm im Beispiel wäre das die fertig berechnete Zahl. Da die Verarbeitung mit»program«beginnt, bezeichnen Informatiker dieses Symbol auch als Startsymbol. Die Aufrufe der einzelnen Funktionen lassen sich in diesem Szenario als Baum darstellen, den so genannten Syntaxbaum. Scheitert ein Programmierer bei dem Versuch, für seinen Code einen solchen Baum aufzumalen, hat er mit ziemlicher Sicherheit auf dem Weg einen Fehler gemacht. Bottom-up-Parser Im bisher beschriebenen Prozess geht der Parser einfach vom Startsymbol aus (im Beispiel»program«) und versucht dann, für jedes eingelesene Zeichen passende Regeln in der Grammatik zu finden. Solche Parser nennt die Informatik Topdown-Parser. Andere Parser gehören zu den so genannten Bottom-up-Parsern und gehen nach einem entgegengesetzten Schema vor: Sie versuchen den Quellcode mit Hilfe der Grammatikregeln so lange zu ersetzen (Reduktion), bis zum Schluss nur noch das Startsymbol übrig bleibt. In der Praxis nutzen solche Bottom-up-Parser üblicherweise einen zu Beginn leeren Stack, auf den sie alle bereits erkannten Nicht-Terminale und alle gelesenen Zeichen werfen. Nach jedem neu gelesenen Zeichen prüfen sie, ob die obersten Elemente auf dem Stack zur rechten Seite einer Grammatikregel passen. Ist das der Fall, nehmen diese Parser die Elemente vom Stack und legen das Nicht-Terminal von der linken Seite der gefundenen Grammatikregel oben drauf. Das ganze Verfahren läuft so lange, bis zum Schluss lediglich noch das Startsymbol übrig und gleichzeitig das Ende des Quellcodes erreicht ist. Klappt das nicht, liegt wahrscheinlich ein Fehler vor. Arbeitet ein Bottom-up-Parser nach diesem Muster, heißt er im Fachjargon auch Shift-Reduce-Parser. Die Programmierung von Scanner und Parser ergibt sich recht gradlinig aus der Grammatik der Sprache. Dieser Vorgang lässt sich auch automatisieren: Tools wie Flex [2] oder Bison [3] erzeugen aus der Grammatik automatisch den Quellcode für passende Scanner und Parser (Abbildung 2). Meist spannt der Parser den Scanner ein: Wann immer der Parser weitere Informationen benötigt, fordert er diese vom Scanner an. Im obigen Beispiel in Listing 2 würde die Funktion»expr()«somit nicht das nächste Zeichen selbst einlesen, sondern statt»file.readnextchar()«einfach den Scanner befragen. Anders als in dem Beispiel liest der nicht immer nur das nächste Zeichen ein. Scanner-Interna: Automaten Der Quellcode der meisten Programmiersprachen besteht aus Zahlen, (Variablen-)Namen, Schlüsselwörtern, Operatoren und Trennzeichen (Delimiter), zu denen etwa»+«,» «und»;«gehören. Namen und Schlüsselwörter setzen sich aus mehreren Buchstaben und Zeichen zusammen. Das gilt bei vielen Sprachen auch für einige Delimiter, etwa»+=«oder»!=«. Beim Erkennen von»int«,»&&«und allen anderen Bestandteilen der Sprache hilft dem Scanner ein so genannter endlicher Automat. Den darf sich der interessierte Laie wie einen Fahrkartenautomaten vorstellen: Der Kunde wirft 20 Cent ein, woraufhin der Automat in den Zustand 20 Cent bezahlt wechselt. Wirft der Kunde einen Euro nach, ändert der Automat den Zustand in 120 Cent bezahlt. Das geht so weiter, bis der Automat den Zustand Preis komplett bezahlt erreicht. Analog lässt sich auch ein Automat basteln, der statt Münzen die Buchstaben aus dem Quellcode entgegennimmt. Mit jedem gelesenen Zeichen wechselt der Automat in einen anderen Zustand: Hat
der Scanner ein»i«gelesen, wechselt er in den Zustand i gelesen, kommt das»n«hinzu, wechselt er in den Zustand n gelesen. Folgt das»t«, wechselt der Automat schließlich in den Zustand int erkannt. Dieser Automat besitzt dabei einen Anfangszustand und mindestens einen Endzustand. Der Automat zum Erkennen von»int«hat den Anfangszustand noch kein Zeichen gelesen und die beiden Endzustände int erkannt und nicht das Schlüsselwort int. Tritt der erstgenannte Endzustand ein, sprechen Informatiker davon, dass der Automat das Wort»int«akzeptiert. Ein Automat lässt sich auch in Form eines Diagramms aufzeichnen, in dem Kästen (Knoten) die einzelnen Zustände repräsentieren. Die Start- und Endzustände hebt das Diagramm dabei entsprechend hervor. Pfeile (Kanten) zeigen an, von welchem Zustand der Automat in welchen anderen Zustand wechselt. Der jeweilige Wechsel findet dabei nur dann statt, wenn der Automat eines der an dem Pfeil notierten Zeichen gelesen hat (Abbildung 3). Aus einer regulären Grammatik lässt sich nicht nur ein Parser, sondern auch ein endlicher Automat konstruieren, der die Sprache der Grammatik akzeptiert: Zunächst erstellt der Programmierer für alle Nicht-Terminale einen Zustand, hinzu kommen der Anfangs- und der Endzu- stand. Danach fügt er für ein gelesenes Zeichen»t«einen Übergang vom Zustand»s«zum Zustand»n«ein, wenn es eine Grammatikregel in der Form»n ::= st«gibt. Ganz nebenbei entsteht so ein Bottom-up-Parser für die Grammatik. Den Automaten muss er nur noch in den eigenen Compiler einbauen. Bei einem einfachen Szenario könnte er nun das nächste Zeichen einlesen und mit»if«- Abfragen oder in einem»switch«-konstrukt prüfen, um welches Zeichen es sich handelt, und sich schließlich in Form einer Variablen merken, in welchen neuen Zustand der Automat gewechselt ist. Hat der Scanner das nächste Element im Quellcode erkannt (wie etwa das Schlüsselwort»int«), ersetzt er es durch ein Symbol und gibt dieses an den Parser zurück. Das bereits erwähnte Tool Flex Interpreter, Assembler und Übersetzer Anders als der Compiler liest ein Interpreter den Quellcode ein und führt ihn direkt aus, es entsteht folglich kein Objektcode. Die klassischen Interpreter analysieren jeden einzelnen Befehl im Quellcode nacheinander. Diese Vorgehensweise nutzten beispielsweise die Basic- Interpreter in den 1990er Jahren. Moderne Interpreter überführen zunächst den kompletten Quellcode in eine spezielle optimierte interne Darstellung. Diesen Zwischenoder Bytecode führt der Interpreter dann wesentlich schneller aus. Mitunter übersetzt ein Just-in-Time-Compiler die interne Darstellung nutzt ebenfalls die Strategie mit endlichen Automaten. Symboltabelle Um den Quellcode im Speicher kompakt darzustellen, ersetzt der Scanner jedes erkannte Schlüsselwort, jeden Variablennamen und alle weiteren Elemente jeweils durch ein Symbol. Die recht langen Variablennamen könnte er etwa gegen zwei Zahlen tauschen: Die erste Zahl dient als Ersatz für den Variablennamen. Die zweite gibt die Zeile in einer Tabelle, der so genannten Symboltabelle, an, in der dann der vom Entwickler genutzte Variablenname steht. Während der Syntax- und Semantik-Analyse landen in der Symboltabelle noch weitere Informationen, etwa der Typ der Variablen. in Maschinensprache, was die Ausführungsgeschwindigkeit noch einmal erhöht. In der Praxis verwendet etwa Java dieses Vorgehen. Ein Assembler ist eine spezielle Form des Compilers, der ein Programm in Assemblersprache oder in Maschinensprache übersetzt. Da Assembler in der Regel eine symbolische Darstellung der Maschinenbefehle ist, ähneln sich beide Sprachen. Meist fasst der Oberbegriff Übersetzer alle drei, also Compiler, Assembler und Interpreter, zusammen. So lässt sich am Ende sagen, dass ein Übersetzer ein Quellprogramm in ein Objektprogramm überführt. Grundlagen 12/2017 Titelthema www.linux-magazin.de 23
24 + Letter =, Delimiter ID Number + =, + Ziel Start *, (,), ; Letter, http:// www.staff.uni-giessen.de/ ~gc1079/ Abbildung 3: Der Automat wechselt den Zustand, sobald er auf ein bestimmtes Zeichen trifft. Um möglichst schnell einen Eintrag in der Symboltabelle zu finden, jagen viele Compiler den Variablennamen durch eine schnell zu berechnende Hashfunktion. Die wiederum spuckt eine Zahl aus, die der Compiler dann als Index in der Symboltabelle verwendet. Trifft der Compiler später wieder auf den Variablennamen, berechnet er einfach den Hashwert und erhält so umgehend den Speicherort in der Symboltabelle mit allen wichtigen Informationen über die Variable etwa ihren Typ. Der Compiler muss also nicht umständlich die komplette Tabelle durchforsten. Sowohl Scanner als auch die semantische Routine erzeugen neben der Symboltabelle während ihrer Arbeit noch einige weitere Tabellen. In ihnen liegen unter anderem die Schachtelungsstruktur für die Schleifen und die Schleifenvariablen. Auf die Informationen in den Tabellen greift der Compiler auch im späteren Verlauf immer wieder zurück. Interne Darstellung des Quellcodes Die von ihm ermittelten Symbole reicht der Scanner an den Parser weiter. Am Ende der Syntax- und Semantik-Analyse steht eine interne Darstellung des Quellcodes. Wie genau diese Darstellung aussieht, hängt vom Compiler und den Sprachen ab. So könnte das Programm als Syntaxbaum vorliegen oder in der polnischen Notation. Viele Compiler nutzen auch Quadrupel, aus der Anweisung»A = B + A«würde dann etwa: +, B, A, T1 =, T1, A»T1«ist eine temporäre Variable, die der Compiler erzeugt. Die Operanden liegen dabei anders als hier im Beispiel in der internen Darstellung als Zeiger auf die passenden Zeilen in der Symboltabelle vor. Bis zu diesem Zeitpunkt hat der Compiler das Quellprogramm jedoch lediglich analysiert. Diese erste Phase nennen Experten aus diesem Grund auch die Analysephase. Code-Erzeugung Im nächsten Schritt optimiert eine weitere Komponente die interne Darstellung. In der Regel optimiert der Compiler die Laufzeit und weist den Variablen Laufzeit-Speicherplätze zu. Im obigen Beispiel würde der Compiler beispielsweise versuchen die temporäre Variable»T1«zu eliminieren. In der letzten Phase erzeugt der Compiler schließlich den ausführbaren Maschinencode. Den bezeichnen Programmierer allgemein auch als Objektcode oder landläufig einfach als Code. Unter Linux handelt es sich üblicherweise entweder um eine (dynamische) Bibliothek oder das ausführbare Programm. Einige Compiler produzieren allerdings auch Assemblercode, den im Anschluss noch ein nachgeschalteter Assembler in Maschinensprache umwandelt. Aus dem Beispiel»A = A + B«könnte der Compiler etwa folgenden Code generieren: lda a add b sto a ; Lade a in den Akku ; Addiere b zum Akku ; Speichere Akku nach a Kontrollstrukturen wie»if«,»while«und»for«lassen sich in der Regel mit den Sprunganweisungen des Prozessors abbilden. Komplexe Schleifen, etwa das in C enthaltene»for«, ersetzt wahlweise eine (längere)»while«-schleife. Weil er den Prozessor-Befehlssatz kennt und ihm die Informationen aus den Tabellen helfen, gestaltet der Compiler den Code zudem kompakter. Bei Funktionsaufrufen kommt der Stack zum Einsatz: Vor dem Start einer Funktion wirft der Compiler deren Argumente und die Rücksprungadresse auf den Stack. Dann führt der Prozessor die Funktion aus. Schließlich muss der Compiler den Stack bereinigen, wobei ihn aktuelle Prozessoren mit speziellen Befehlen unterstützen. Den vorgestellten Ablauf halten nicht alle Compiler ein. So genannte One- Pass-Compiler verzichten beispielsweise auf eine interne Darstellung des Codes und generieren den Objektcode in einem einzigen Durchlauf. Dabei erzeugen die semantischen Routinen auch gleichzeitig die passenden Maschinenbefehle. Des Weiteren sind die vorgestellten Phasen mitunter ineinander verzahnt. (kki) n Infos [1] Compilerbau-Vorlesungsskript: [http:// www. staff. uni giessen. de/ ~gc1079/] [2] Flex: [https:// github. com/ westes/ flex] [3] Bison: [https:// www. gnu. org/ software/ bison/]