Ein fĂŒr viele Programmierer wichtiges Kriterium fĂŒr die Beurteilung eines Compilers ist die GĂŒte der Codeerzeugung. Diese hĂ€ngt stark davon ab, nach welchem Prinzip das Back-End des Compilers implementiert ist und ob noch zusĂ€tzliche Optimierungsphasen eingebaut wurden. Manchmal ist es auch möglich, mittels einer Option zwischen einem gut optimierten Code, dessen Generierung recht lange braucht, und einem schlechten Code, der dafĂŒr schnell erzeugt wird, zu wĂ€hlen. Mit solch einem Compiler kann man wĂ€hrend der Testphase eines Programms schnell arbeiten und bei der Fertigstellung doch einen optimierten Code erzeugen.
Wir wollen uns in dieser letzten Folge der Serie ĂŒber Compiler-Bau mit verschiedenen Techniken der Codeerzeugung beschĂ€ftigen. Dabei werden wir auch kurz die Probleme betrachten, die beim Schreiben eines optimierenden Compilers zu lösen sind. Um ein GefĂŒhl fĂŒr die KomplexitĂ€t der Codegenerierung zu bekommen, wollen wir uns zuerst einmal die Aufgaben ansehen, die die Codeerzeugung zu bewĂ€ltigen hat. Es sind dies die Codeauswahl, die Speicherzuteilung, die Registerallokation und die Bestimmung der Berechnungsreihenfolge. Bei der Codeauswahl wird jeder Zwischencodeanweisung (siehe letzte Folge) oder jedem Knoten des attributierten Strukturbaums eine Folge von Instruktionen der Zielsprache zugeordnet. Da es meist möglich ist, ein und dasselbe Sprachkonstrukt mit verschiedenen Befehlsfolgen zu ĂŒbersetzen und in verschiedener Umgebung oft verschiedene Folgen optimal sind, ist eine gute Codeauswahl recht schwierig. Die Speicherzuteilung sorgt dafĂŒr, daĂ jedem Objekt der Quell sprĂ€che ein ausreichend groĂes StĂŒck Speicher zur VerfĂŒgung gestellt wird, und beschreibt gleichzeitig die verschiedenen Möglichkeiten des Zugriffs auf diesen Speicherbereich. Dabei muĂ unter UmstĂ€nden auch fĂŒr eine rechtzeitige Anforderung und Freigabe des Speichers zur Laufzeit gesorgt werden. Die Registerallokation ist fĂŒr eine geschickte Verteilung der Variablen und Zwischenergebnisse auf Prozessorregister zustĂ€ndig. Dies muĂ natĂŒrlich in enger Zusammenarbeit mit der Speicherzuteilung geschehen. Die Bestimmung der Berechnungsreihenfolge ist nicht trivial, da eine Umstellung der Reihenfolge von Berechnungen in manchen FĂ€llen zu einem besseren Code fĂŒhrt. Braucht der linke Operand einer Addition zur Berechnung zum Beispiel zwei und der rechte drei Register, so wird bei einer naiven Codeerzeugung zuerst der linke Operand berechnet und sein Ergebnis in einem Register zwischengespeichert. Dieses Register fĂŒr das Zwischenergebnis plus die drei Register fĂŒr die Berechnung des rechten Operanden fĂŒhren zu einem Bedarf von vier Registern. Wird hingegen zuerst die aufwendigere Berechnung des rechten Operanden (drei Register) durchgefĂŒhrt, dann der linke Operand unter Benutzung von zwei Registern berechnet, wobei gleichzeitig ein Register fĂŒr das Ergebnis des rechten Operanden gebraucht wird, so kommt man hier mit insgesamt drei Registern aus. Die komplette Berechnung kann also in drei statt vier Registern ausgefĂŒhrt werden, wenn die Reihenfolge der Operandenberechnungen richtig gewĂ€hlt wird. Um einen guten Code zu erzeugen, ist es wichtig, daĂ diese vier Aufgaben nicht nur fĂŒr sich alleine gut gelöst werden, sondern auch optimal aufeinander abgestimmt sind. Zu beachten ist auĂerdem, daĂ es oft möglich ist, Speicherplatz zu sparen, indem ein Code gewĂ€hlt wird, dessen Abarbeitung langsamer ist, und umgekehrt kann ein schnellerer Code erzeugt werden, der mehr Speicherplatz belegt. Welches Ăbel dabei weniger schlimm ist, hĂ€ngt vom Anwendungsfall ab und kann bestenfalls per Option eingestellt werden.
Optimal
Die vier Aufgaben der Codeerzeugung können in der Regel problemlos auf dem attributierten Strukturbaum ausgefĂŒhrt werden. Eine Zwischencodeerzeugung ist dabei höchsten zur leichteren PortabilitĂ€t des Compilers auf andere Zielmaschinen nötig. Möchte man nicht nur einen Compiler mit einer guten Codeerzeugung schreiben, sondern einen wirklich optimierenden, so ist eine Zwischencodeerzeugung allerdings unumgĂ€nglich. Eine sehr einfache Form der Optimierung, die auch auf dem Zielcode durchgefĂŒhrt werden kann, ist die Peephole-Optimierung. Der Code wird hierbei durch eine Art Guckloch (daher auch der Name) oder Fenster betrachtet, in dem nur ein kleiner Ausschnitt des Codes sichtbar ist. Sobald der Inhalt dieses Fensters mit einer von mehreren gespeicherten Schablonen ĂŒbereinstimmt, wird er durch ein Ă€quivalentes aber optimaleres CodestĂŒck ersetzt. Trotz der Einfachheit dieses Verfahrens kann es schlechteren Code oft stark verbessern. Aufgrund seiner Natur ist es allerdings nur fĂŒr lokale Optimierungen geeignet. Sehr viel mĂ€chtiger, aber auch komplizierter, ist eine sogenannte DatenfluĂanalyse. Mit ihrer Hilfe können recht vielfĂ€ltige Optimierungen durchgefĂŒhrt werden, deren wichtigste Vertreter wir uns spĂ€ter noch kurz ansehen werden. Eine genaue Behandlung dieses Stoffs wĂŒrde den Rahmen des Artikels aber sprengen, deshalb seien nĂ€her Interessierte auf [ 1 ] verwiesen. In diesem Buch werden die Codeoptimierung und ihre wichtigsten Algorithmen ausfĂŒhrlich behandelt. Allerdings ist gerade die globale DatenfluĂanalyse ein recht harter Brocken. Nach der Codeoptimierung gehen wir gegen Ende dieser Folge kurz auf Werkzeuge zur automatischen Generierung von Back-Ends ein. Zum AbschluĂ werden die beiden inzwischen schon mehrfach zitierten BĂŒcher kurz vorgestellt und eine kleine Aufgabe gestellt. Doch zunĂ€chst werden wir uns ansehen, wie man die vier Aufgaben der Codeerzeugung lösen kann. Dazu werden wir uns hauptsĂ€chlich mit der Codeerzeugung von arithmetischen AusdrĂŒcken befassen. Diese stellen in der Regel neben Prozedur- und Funktionsaufrufen die gröĂten Anforderungen an den Codegenerator, und man kann die verschiedenen Implementierungstechniken recht gut an ihnen studieren.
Eingekellert
Zum Einstieg wollen wir uns erst einmal das einfachste Verfahren zur Codeerzeugung ansehen. Es beruht darauf, daĂ jeder normale arithmetische Ausdruck unabhĂ€ngig von seiner Schachtelungstiefe in einen Postfixausdruck umgewandelt werden kann. Postfix heiĂteinfach, daĂ ein Operator immer nach seinen Operanden steht. Der Postfixausdruck zu â2 + 3â ist also â2 3 +'. Klammerebenen fallen dabei weg, wie man zum Beispiel an dem Postfixausdruck fĂŒr âa := (3 + 4) * 2â sieht, der âa 3 4 + 2 * :=â lautet. â3 4 +â wird dabei zu â7â reduziert, dann â7 2 *' zu â14â ausgewertet und abschlieĂend in die Speicherstelle der Variablen a geschrieben. Solche PostfixausdrĂŒcke lassen sich sehr einfach mit einer Kellermaschine auswerten. Eine Kellermaschine besteht, wie ihr Name schon sagt, im wesentlichen aus einem Keller, der zum Ablegen von Zwischenergebnissen dient. Sie besitzt zum Beispiel die Befehle LOAD, LOADA, ADD, SUB, MUL, DIV und ASSIGN, wobei LOAD den angegebenen Wert oder den Inhalt einer Variablen auf dem Keller ablegt und LOADA dies mit der Adresse einer Variablen tut. ADD, SUB, MUL und DIV nehmen jeweils die zwei obersten Werte vom Keller und addieren, subtrahieren, multiplizieren oder dividieren sie je nach Befehl. Das Ergebnis wird zum SchluĂ auf dem Keller abgelegt. ASSIGN schreibt den Wert, der auf dem Keller zuoberst steht, in die Speicherstelle, deren Adresse im Keller zu zweitoberst liegt. Dem Ausdruck âa := (3 + 4) * 2â und damit auch dem Postfixaudruck âa 3 4 + 2 * :=â entspricht somit das folgende Kellermaschinenprogramm:
LOADA a LOAD #3 LOAD #4 ADD LOAD
#2 MUL ASSIGN
Das Gatterzeichen â#â beschreibt hierbei die Tatsache, daĂ der dahinterstehende Wert direkt benutzt werden soll und keine Speicheradresse darstellt. AuĂerdem muĂ statt der Variablen a natĂŒrlich die Adresse der Speicherstelle angegeben werden, in der der Wert von a stehen soll. In Abb. 1 werden die einzelnen ZustĂ€nde der Kellermaschine grafisch dargestellt.
Abb. 1: 'a := (3 + 4) * 2â, ausgefĂŒhrt auf einer Kellermaschine.
LOAD %1 : MOVE.W %1, -(A7)
LOADA %1: PEA %1
ADD : MOVE.W (A7)+, D0
ADD.W D0, (A7)
SUB : MOVE.W (A7)+, D0
SUB.W D0, (A7)
MUL : MOVE.W (A7)+, D0
MULS (A7), D0
MOVE.W D0, (A7)
DIV : MOVE.W 2(A7), D0
DIVS (A7)+, D0
MOVE.W D0, (A7)
ASSIGN : MOVE.W (A7)+, D0
MOVE.L (A7)+, A0
MOVE.W D0, (A0)
Tabelle 1: Implementierung von Kellermaschinenbefehlen in 68000er-Code
PEA a
MOVE.W #3, -(A7)
MOVE.W #4, -(A7) ; 1
MOVE.W (A7)+, D0 ; 2
ADD.W D0, (A7)
MOVE.W #2, -(A7) ; 3
MOVE.W (A7)+, D0 ; 4
MULS (A7), D0
MOVE.W D0, (A7) ; 5
MOVE.W (A7)+, D0 ; 6
MOVE.L (A7)+, A0
MOVE.W D0, (A0)
*Listing 1: Einfacher 68000er-Code zur Berechnung von âa := (3 + 4) 2â
PEA a
MOVE.W #3, -(A7)
MOVE.W #4, D0
ADD.W D0, (A7)
MOVE.W #2, D0
MULS (A7)+, D0
MOVE.L (A7)+, A0
MOVE.W D0, (A0)
Listing 2: Einfacher Code nach Peephole-Optimierung
Unter jeder Anweisung ist der Zustand nach ihrer Abarbeitung abgebildet. Da es sehr einfach ist, mit einem Mikroprozessor wie dem 68000 eine Kellermaschine zu simulieren, kann ein Kellermaschineprogramm sehr leicht in 68000er-Code umgesetzt werden. Tabelle 1 zeigt die Implementierung der Kellermaschinenbefehle durch 68000er-Code. Dabei ist im LOAD- und LOADA-Befehl %1 ein Platzhalter, der durch den jeweiligen Operanden ersetzt wird. Listing 1 enthĂ€lt die Ăbersetzung der Befehlssequenz fĂŒr den Ausdruck âa := (3 + 4) * 2â mit Hilfe der Schemata aus Tabelle 1. Dieser Code erfĂŒllt die gestellte Aufgabe offensichtlich korrekt, ist aber sehr umstĂ€ndlich. Die Ineffizienz des Codes fĂ€llt an den durch Zahlen markierten Stellen besonders stark ins Auge. Die Anweisungen 1 und 2 bzw. 3 und 4 können sehr leicht zu je einer zusammengefaĂt werden. Die mit 5 und 6 markierten Anweisungen können sogar komplett entfallen, da sie sogenannte inverse Operationen darstellen, d.h. die eine Operation hebt die Wirkung der anderen auf. Allerdings muĂ dann im vorausgehenden MULS-Befehl mit Postinkrement vom Keller gelesen werden. Durch diese Optimierungen wird der Code kĂŒrzer, es entfallen Speicherzugriffe, und es wird weniger Platz auf dem Keller beansprucht.
Interessanterweise können alle drei Verbesserungen ohne weiteres von einen Peephole-Optimierer durchgefĂŒhrt werden. Der Code nach der Peephole-Optimierung ist in Listing 2 abgebildet. Nach diesen Optimierungen ist der wesentliche Kritikpunkt am bisher erzeugten Code, daĂ die Register der 68000 nur sehr spĂ€rlich benutzt werden. Wie man diese besser nutzen kann, werden wir gleich besprechen. Doch vorher wollen wir uns noch ansehen, wie man den Code fĂŒr eine Kellermaschinensimulation direkt aus dem attributierten Strukturbaum gewinnen kann, und wie wir die am Anfang erwĂ€hnten vier Aufgaben der Codeerzeugung bisher gelöst haben. Am Ende der letzten Folge wurde das Thema Zwischencode schon erwĂ€hnt. Im Prinzip sind unsere Kellermaschinenbefehle (LOAD etc.) nichts anderes als Zwischencodebefehle. Wir können sie also in einem Baumdurchlauf direkt aus dem attributierten Strukturbaum erzeugen. Eine attributierte Grammatik (siehe letzte Folge), die genau das leistet, ist in Listing 3 abgebildet. Der Operator *++â dient wie schon in der letzten Folge zur Aneinanderreihung von Zwischencodebefehlen. Mit dieser attributierten Grammatik wird der Strukturbaum des Ausdrucks âa := (3 + 4) * 2â genau in die weiter oben angegebene Sequenz von Kellermaschinenbefehlen transformiert. Da die Umwandlung des Kellercodes nach Tabelle 1 einer einfachen Makroexpansion entspricht, können die einzelnen Kellermaschinenbefehle in der attributierten Grammatik auch gleich durch die entsprechenden 68000er-Befehle ersetzt werden.
Trotzdem ist die EinfĂŒhrung einer Zwischensprache fĂŒr das Design eines Compilers immer sinnvoll, auch wenn sie wie hier schluĂendlich nie explizit vom Compiler benutzt wird. Denn die Zwischensprache erlaubt eine anschaulichere und ĂŒbersichtlichere Darstellung der Codeerzeugung und hilft, daĂ wichtige ZusammenhĂ€nge nicht im Detail des Zielmaschinencodes untergehen. Damit wĂ€re die Codeauswahl komplett. Was noch vollkommen fehlt, ist eine Speicherzuteilung fĂŒr die Variablen. Prinzipiell legt der Compiler alle globalen Variablen einfach hintereinander im Speicherbereich fĂŒr globale Daten ab und berechnet dabei jeweils den Offset vom Anfang dieses Segments oder des kompletten Programms. Dieser Offset wird statt der richtigen Adresse ins Programm eingesetzt. Die korrekte absolute Adresse wird dann beim Programmstart von der Relozierroutine des Betriebssystems ermittelt. Die lokalen Daten der einzelnen Funktionen und Prozeduren werden im Gegensatz dazu auf dem Laufzeit-Stack im Activation-Record der zugehörigen Routine abgelegt, wie dies schon in der zweiten Folge besprochen wurde. Die Speicherzuteilung muĂ also fĂŒr jede Prozedur den Speicherbedarf der lokalen Daten berechnen und dafĂŒr sorgen, daĂ am Anfang jeder Prozedur Code erzeugt wird, der den von der Prozedur benötigten Platz auf dem Stack reserviert. AuĂerdem muĂ der Platz am Ende der Abarbeitung der Prozedur wieder freigegeben werden. FĂŒr jede lokale Variable wird dann der Offset vom Link-Pointer (siehe Folge 2) berechnet. Da das genaue Vorgehen der Speicherzuteilung sehr stark von der zu ĂŒbersetzenden Programmier sprĂ€che abhĂ€ngt, wollen wir hier nicht weiter darauf eingehen. Nach Codeauswahl und Speicherzuteilung bleiben von den vier erwĂ€hnten Aufgaben der Codeerzeugung noch die Registerallokation und die Bestimmung der Berechnungsreihenfolge ĂŒbrig. Beides bleibt in der bisher vorgestellten naiven Codegenerierung im Prinzip unberĂŒcksichtigt, was ein wesentlicher Grund fĂŒr den relativ schlechten Code ist.
RULE Assign ::= Ident ':=' Expr.
ATTRIBUTION
Assign.Code := {LOADA Ident:} ++ Expr.Code ++ {ASSIGN;};
RULE Expr ::= 'Expr' + 'Expr'.
ATTRIBUTION
Expr[0].Code := Expr[1].Code ++ Expr[2].Code ++ {ADD;};
RULE Expr ::= 'Expr - 'Expr'.
ATTRIBUTION
Expr[0].Code := Expr[1].Code ++ Expr[2].Code ++ {SUB;};
RULE Expr ::= 'Expr' * 'Expr'
ATTRIBUTION
Expr[0].Code := Expr[1].Code ++ Expr[2].Code ++ {MUL;};
RULE Expr ::= âExprâ / 'Expr'.
ATTRIBUTION
Expr[0].Code := Expr[1].Code ++ Expr[2].Code ++ {DIV;};
RULE Expr ::= Ident.
ATTRIBUTION
Expr[0].Code := {LOAD Ident;};
Listing 3: Attributierte Grammatik zur Codeerzeugung fĂŒr die Kellermaschine
Abb. 2: Erzeugung von Code fĂŒr eine Kellermaschine
Register machen Beine
Ziel der Registerallokation ist es, möglichst viele Zwischenergebnisse von komplexen Berechnungen im Prozessor zu speichern und so die Anzahl der Speicherzugriffe zu minimieren. Eine Möglichkeit, die Prozessorregister besser zu nutzen, als wir es bisher getan haben, ist, die zur VerfĂŒgung stehenden Register als die obersten Elemente des Kellers zur betrachten. Erst wenn in den Registern kein Platz mehr ist, wird der echte Keller benutzt. Das Ganze ist möglich, da der Compiler die Stack-Belegung zur Ăbersetzungszeit berechnen und die nötigen Registerzugriffe damit erzeugen kann. Sobald ein Prozedur- oder Funktionsaufruf vorkommt, mĂŒssen die Registerinhalte allerdings doch noch auf dem Stack abgelegt werden, da sie möglicherweise wĂ€hrend des Aufrufs verĂ€ndert werden. Dieses Ablegen der Register bezeichnet man als Spilling.
Mit dieser Art der Registerallokation wollen wir uns aber nicht weiter befassen, sondern uns stattdessen gleich ein besseres Verfahren ansehen. Dies setzt die Techniken der semantischen Analyse zur Registerallokation ein. Ein Vorteil des Verfahrens ist, daĂ man die Bestimmung der Berechungsreihenfolge sowie ein paar weitere Optimierungen leicht integrieren kann. AuĂerdem ist dabei eine elegante Einbettung der ersten Phase der Codegenerierung in die semantische Analyse möglich. Die wesentliche Information zur Registerallokation ist hierbei, wieviel Register fĂŒr die Berechnung der einzelnen TeilausdrĂŒcke eines kompletten Ausdrucks gebraucht werden. Als Beispiel wollen wir uns den Strukturbaum des Ausdrucks âa * b + (c + d) * (e + f)â ansehen, der in Abb. 3 dargestellt ist.
Abb. 3: Attributberechnung fĂŒr die Registerallokation
RULE Expr := 'Expr' Operator 'Expr'.
ATTRIBUTION
Expr[0].LeftFirst := (Expr[1].Registers >= Expr[2].Registers);
Expr[1].Registers := IF Expr[1].Registers = Expr[2].Registers
THEN min(Expr[2].Registers + 1, n)
ELSE max (Expr[2].Registers, Expr[3].Registers
END;
Expr[3].Store := FALSE;
Expr[2].Store := (Expr[2].Registers = n) AND (Expr[3].Registers = n)
RULE Expr := Ident.
ATTRIBUTION
Expr.Registers := 1;
Expr.LeftFirst := TRUE;
Listing 4: Attributierte Grammatik zur Registerallokation
An jedem Knoten des Baums steht die Anzahl der Register, die zur Berechnung des kompletten Teilbaums, der von dem Knoten ausgeht, benötigt werden. Zur Berechnung eines Blattes (in unserem Beispiel eines der Bezeichner) wird ein Register gebraucht - und zwar das Register, in das der Wert des Bezeichners geladen wird. FĂŒr jeden Knoten des Strukturbaums wird dann wie folgt entschieden. Benötigen beide UnterbĂ€ume des Knotens gleich viele Register, ist es egal, in welcher Reihenfolge die beiden UnterbĂ€ume berechnet werden, und der Knoten, der die beiden verbindet, benötigt zur Berechnung ein Register mehr als je einer der TeilbĂ€ume. Dieses zusĂ€tzliche Register wird gebraucht, um das Ergebnis der Berechnung des ersten Teilbaums wĂ€hrend der Berechnung des zweiten zwischenzuspeichern. Benötigt einer der UnterbĂ€ume mehr Register als der andere, so wird er zuerst berechnet, und der verbindende Knoten benötigt zur Berechnung genauso viele Register wie der teurere Teilbaum.
Das ergibt sich daraus, daĂ das Ergebnis der ersten, teureren Berechnung wĂ€hrend der Auswertung des zweiten Teilbaums in einem der Register gespeichert werden kann, das gegenĂŒber der ersten Berechnung nicht gebraucht wird. In der Abbildung sind die Kanten des Strukturbaums mit römischen Ziffern gekennzeichnet, die angeben, ob sie als erster oder zweiter Teilbaum berechnet werden. In dem Beispiel wird der Teilausdruck â(c + d) * (e + f)â vor âa * bâ berechnet. Macht man es andersherum, mĂŒssen fĂŒr die Berechnung des gesamten Ausdrucks vier statt drei Register bereitgestellt werden, da bei der teureren Berechnung drei Register plus eins, zum Speichern des Ergebnisses von 'a * b' gebraucht wĂŒrden.
Wie wir sehen, haben wir auf diese Art und Weise die Anzahl der benötigten Register und die Berechnungreihenfolge in einem Rutsch ermittelt. Eins haben wir dabei allerdings auĂer acht gelassen. Ein realer Mikroprozessor hat natĂŒrlich nur eine begrenzte Anzahl von Registern. Daher ist es nötig, fĂŒr jeden Knoten des Strukturbaums zu ĂŒberprĂŒfen, ob das Ergebnis der zugehörigen Berechnung als Zwischenergebnis im Speicher, also etwa auf dem Laufzeit-Stack, abgelegt werden muĂ. Dies ist nie der Fall, wenn einer der TeilbĂ€ume mehr Register als der andere benötigt. Denn dann braucht der Vaterknoten ja die gleiche Zahl von Registern wie der teurere Unterknoten. Brauchen beide TeilbĂ€ume zur Berechnung aber gleichviel Register, muĂ das Ergebnis der Berechnung des ersten Teilbaums im Speicher abgelegt werden, falls die Anzahl der Register je Teilbaum gleich der Anzahl der ĂŒberhaupt vorhandenen Register ist. Der Vaterknoten braucht dann zur Berechnung natĂŒrlich genausoviel Platz wie seine UnterbĂ€ume, also alle Register. Eine attributierte Grammatik, die diese Berechnungen durchfĂŒhrt, ist in Listing 4 angegeben. Das Nichtterminal Operator steht dabei fĂŒr einen beliebigen binĂ€ren Operator wie â+â, etc., und der Wert ânâ gibt die Anzahl der insgesamt verfĂŒgbaren Register an.
Die drei berechneten Attribute sind Registers, LeftFirstâ und Store. Registers gibt an, wieviele Register zur Berechnung des Teilbaums geraucht werden, dessen Knoten das Attribut besitzt. LeftFirst ist TRUE, falls der linke Unterbaum des Knotens zuerst ausgewertet werden soll, und Store ist TRUE, falls das Ergebnis der Berechnung dieses Knotens im Speicher abgelegt werden muĂ, da die Register nicht reichen. Das vorgestellte Berechnungsschema ist natĂŒrlich etwas vereinfacht, da ein Mikroprozessor wie der 68000er Befehle besitzt, die bestimmte Aufgaben mit weniger Registern lösen. Zum Beispiel kann ein Wert aus dem Speicher direkt zu einem Registerinhalt addiert werden. Um diese Befehlsart mit einzubeziehen, wird das Attribut Registers in der zweiten Regel der attributierten Grammatik nicht mit 1, sondern mit 0 initialisiert. Andere Besonderheiten lassen sich meist durch zusĂ€tzliche Regeln, die bestimmte SonderfĂ€lle abhandeln, beschreiben. Allerdings sollte man an dieser Stelle noch nicht versuchen, Probleme der Codeselektion zu lösen, d.h. Optimierungen der Art, daĂ statt einem ADD.L #c, D0â auch der schnellere und kĂŒrzere Befehl â ADDQ.L#c, D0â verwendet werden kann, wenn c zwischen 1 und 8 liegt.
Aus dem so attributierten Strukturbaum kann dann, Ă€hnlich wie dies fĂŒr die Kellermaschinencodes vorgestellt wurde, in einem weiteren Baumdurchlauf Code erzeugt werden. Nur werden die einzelnen Werte nicht auf dem Keller, sondern in Registern abgelegt. Nur wenn das Store-Attribut in einem Knoten gesetzt ist, wird das Ergebnis der Berechnung in den Speicher geschrieben. Da bisher nur festgelegt wurde, welcher Teilausdruck wieviele Register zu seiner Berechnung benötigt, muĂ bei der Auswahl des Codes noch eine Zuordnung der vorhandenen Prozessorregister zu den einzelnen Zwischenergebnissen durchgefĂŒhrt werden. Dazu merkt man sich immer die Liste der aktuell unbelegten Register. Sobald ein neuer Wert geladen wird, wird eines dieser Register belegt. Beim DurchfĂŒhren einer arithmetischen Operation werden die Register, in denen die Operanden gespeichert sind, nach der Operation wieder frei. Allerdings wird bei einem Prozessor wie dem 68000er das Register des zweiten Operanden nach der Operation durch das Ergebnis belegt. Sobald soviel Aufwand getrieben wird, um die Prozessorregister besser auszunutzen, sollte die Auswahl der Instruktionen, die zur Implementierung der Berechnungen der einzelnen Strukturbaumknoten verwandt wird, auch sorgfĂ€ltiger durchgefĂŒhrt werden. Bisher war die Codeauswahl darauf beschrĂ€nkt, pro Knotentyp eine bestimmte Instruktionsfolge zu erzeugen, die die entsprechende Aufgabe, wie Wert laden oder zwei Werte addieren, durchfĂŒhrt. Besser ist es natĂŒrlich pro Knotentyp verschiedene Codesequenzen zur VerfĂŒgung zu stellen, um je nach Situation die beste auswĂ€hlen zu können. Die Kriterien fĂŒr diese Auswahl hĂ€ngen natĂŒrlich stark von der Zielmaschine bzw. von deren Instruktionssatz ab.
Sicherlich wird auch das oben berechnete Store-Attribut zur Auswahl herangezogen, um je nachdem, ob das Ergebnis und/oder einer oder beide Operanden im Speicher liegen, die optimale Befehlsfolge auszuwĂ€hlen. Die Auswahl der Codesequenzen lĂ€Ăt sich zum Beispiel anhand von Tabellen durchfĂŒhren, wie dies etwa in [3] beschrieben wird. Immer mehr Verbreitung findet aber auch die Codeauswahl durch Mustererkennung, die wir gleich noch besprechen werden, und die sich auch sehr gut zum Bau von Generatoren fĂŒr die Codeerzeugung eignet. Vorher sol 1 aber noch erwĂ€hnt werden, daĂ durch die Berechnung geeigneter Attribute auch boolsche VerknĂŒpfungen optimiert ĂŒbersetzt werden können. AuĂerdem ist es möglich, die Berechnung arithmetischer AusdrĂŒcke durch die Verwendung algebraisch identischer AusdrĂŒcke zu optimieren. Zum Beispiel kann es vorteilhaft sein, â(-a) * b' statt âa * (-b)â zu berechnen. Aus â(-x) * (y - z)â wird damit âx * (z - y)â, was eine Negation erspart. Als nĂ€chstes wollen wir uns ein recht gutes Verfahren zur Codeselektion ansehen, das fĂŒr viele verschiedene Typen von Zielmaschinen Verwendung finden kann.
Musterhafter Code
Das Grundprinzip der Codeerzeugung durch Mustererkennung (pattern matching) ist recht einfach. Der Codegenerator sucht in dem Strukturbaum nach TeilbĂ€umen, die auf eins der ihm bekannten Muster passen. Sobald er einen solchen Teilbaum erkennt, erzeugt er einen zu dem Muster gehörenden Code, in den er unter UmstĂ€nden noch Werte aus dem Strukturbaum einsetzt. AuĂerdem wird der Teil- im Strukturbaum durch ein neues Blatt ersetzt. Jedes der Muster zusammen mit dem Code und dem neuen Blatt ist eine Regel des Codegenerators. In Abb. 4 sind Regeln abgebildet, die es erlauben, Code fĂŒr einen einfachen arithmetischen Ausdruck zu erzeugen. Eine Regel besteht aus einem Musterbaum und einem Strukturbaumblatt, die durch einen Pfeil verbunden sind. Eine solche Regel besagt, daĂ ein Teilbaum, der auf das vorgegebene Muster paĂt, in das Blatt umgewandelt werden kann. Ăber dem Pfeil kann noch eine zusĂ€tzliche Bedingung stehen, die die Anwendbarkeit der Regel einschrĂ€nkt, wie dies bei Regel (5) der Fall ist. Steht unter dem Pfeil ein Sternchen (*), kann die Regel auch angewandt werden, wenn die beiden Operanden des Teilbaums gegenĂŒber dem Muster vertauscht sind. In Regel (4) ist es zum Beispiel auch möglich, daĂ die Konstante auf der rechten und das Register auf der linken Seite stehen.
Abb. 4: Regeln fĂŒr einen Codegenerator per Mustererkennung
Abb. 5: Regelanwendung des Codegenerators per Mustererkennung
Unter jeder der Regeln steht der Code, der erzeugt wird, sobald der Codegenerator die Regel anwendet. Die Anwendung der Regel (1) beendet die Codegenerierung, was durch den Punkt auf der rechten Seite des Pfeils angedeutet wird. Die StrukturbaumblĂ€tter mem, reg und const stehen fĂŒr verschiedene Speicherklassen, wobei mem einen Operanden im Speicher, reg einen Registeroperanden und const einen konstanten Wert reprĂ€sentiert. Das Attribut der Speicherklasse wird jeweils in eckiger Klammer angegeben und ist fĂŒr mem hier der Einfachheit halber der Name der Variablen, fĂŒr reg die Registernummer (hier nur Datenregister) und fĂŒr const der Wert der Konstanten.
In Abb. 5 wird mit den eben vorgestellten Regeln der Strukturbaum fĂŒr den Ausdruck âx := 1 + y + zâ in Code umgewandelt. Der Teil des Baums, der auf das Muster der jeweils an ge wendeten Regel paĂt, ist durch einen gestrichelten Rahmen gekennzeichnet. Die Nummer der jeweils angewendeten Regel wird ĂŒber den einzelnen Pfeilen angegeben, unter dem Pfeil steht der durch die Regel erzeugte Code. Zum SchluĂ ist der komplette Code noch einmal am StĂŒck angegeben. Vergleicht man den erzeugten Code mit dem, was die einfache Erzeugung von Kellermaschinecode fĂŒr dieses Beispiel generieren wĂŒrde, so wird die Ăberlegenheit der neuen Methode sehr deutlich. Eine Peephole-Optimierung erĂŒbrigt sich vollstĂ€ndig, da die Mustererkennung spezielle TeilbĂ€ume, die optimiert ĂŒbersetzt werden können, direkt erkennt und entsprechend behandelt. Ein Beispiel ist die Regel (5), die im Sonderfall der Addition einer Konstanten, im Bereich zwischen 1 und 8, den Spezialbefehl ADDQ statt eines normalen ADD verwendet. Offen ist allerdings noch die Frage, wie der Codegenerator wissen soll, daĂ eine Verwendung der ebenfalls in diesem Punkt anwendbaren Regel (4) schlechter ist. Um eine solche Entscheidung zu ermöglichen, werden die einzelnen Regeln meist noch mit einem Kosten weil versehen. Der Codegenerator versucht nun, den Code derart zu erzeugen, daĂ die Summe der Kosten aller fĂŒr einen Strukturbaum angewendeten Regeln minimal wird. Gerade bei der Behandlung von SpezialfĂ€llen können die MusterbĂ€ume durchaus gröĂer sein, als dies in unseren Beispielregeln der Fall ist. Damit können dann auch spezielle Adressierungsarten des Prozessors ausgenutzt werden, die mehrere Knoten des Strukturbaums in einem Befehl berechnen. Die Registerverteilung und die Be Stimmung der Berechnungsreihenfolge kann bei der Codeerzeugung durch Mustererkennung ohne weiteres durch eine vorangehende Attributierung des Strukturbaums festgelegt werden, wie wir sie vorher besprochen haben.
Allgemein
Wie anfangs angekĂŒndigt, haben wir uns bei der Codeerzeugung auf arithmetische AusdrĂŒcke konzentriert und andere Konstrukte einer Programmiersprache, wie Schleifen, Prozeduraufrufe und Ă€hnliches, vernachlĂ€ssigt. Dies ist wegen des begrenzten hier zur VerfĂŒgung stehenden Platzes geschehen und auch nicht so tragisch, da die wichtigsten Probleme und deren Lösung vorgestellt wurden. Gerade Schleifen erfordern eine Reihe von Speziallösungen und können normalerweise nicht ohne gröĂeren Aufwand nennenswert optimiert werden. Die Art und Weise von Prozeduraufrufen ist recht stark von der implementierten Programmiersprache abhĂ€ngig und kann wohl am besten durch die Benutzung von Registern zur ParameterĂŒbergabe und das Weglassen unbenutzter Eigenschaften des Aufrufmechanismusâ optimiert werden. Am wichtigsten ist im Ganzen wohl eine ausdrucksĂŒbergreifende Allokation von Registern, auf die hier aber nicht weitereingegangen werden soll.
Codierungsarbeit
Bisher haben wir uns nur mit Erzeugung von symbolischem Assemblercode beschĂ€ftigt. Um ein Programm ausfĂŒhren zu können, muĂ es aber in echten Maschinencode umgewandelt, also noch assembliert werden. Dazu kann man einen externen Assembler verwenden, der eine Datei mit dem erzeugten symbolischen Assemblercode ĂŒbergeben bekommt. Diese Methode wird des öfteren von Compilern verwendet, die unter dem Betriebssystem UNIX lauten. Durch das Verfahren wird die Portierbarkeit ei nes Compilers erhöht, da eine komplett maschinenabhĂ€ngige Phase verschwindet, und auĂerdem spart der Compiler-Bauer auch etwas Arbeit. Erkauft werden diese Vorteile durch eine Erhöhung der Ăbersetzungszeit, da verstĂ€rkt relativ langsamer I/O durchgefĂŒhrt werden muĂ und der Assembler die textuelle ReprĂ€sentation der Assembler-Datei erst wieder in eine interne Darstellung um-wandeln muĂ. Diese Nachteile kann man durch eine Integrierung des Assemblers in den Compiler vermeiden. AuĂerdem kann ein interner Assembler jede Assembler-Anweisung sofort nach ihrer Erzeugung in Maschinencode ĂŒbersetzen, so daĂ das Programm nie komplett in Form von symbolischen Assembler-Anweisungen vorliegt. Nachdem wir nach der naiven Codegenerierung noch eine Art der Codeerzeugung kennengelernt haben, die weitaus besseren Code erzeugt, wollen wir uns jetzt noch kurz die Möglichkeiten der Codeoptimierung ansehen.
Noch besser...
Bei der Codeoptimierung geht es nicht darum, das Programm möglichst geschickt von einer Form in eine andere zu transformieren, sondern es wird schon erzeugter Code verbessert. Dies ist meist nicht der endgĂŒltige Maschinencode, sondern etwa ein Zwischencode, wie er in der letzten Folge vorgestellt wurde. Eine Möglichkeit solcher Optimierungen ist die schon erwĂ€hnte Peephole-Optimierung. Diese kann den Code zwar nur lokal verbessern, ist dafĂŒr aber recht einfach zu implementieren. Eine sehr viel mĂ€chtigere, allerdings auch aufwendigere Methode zur Optimierung von Programmen stellt die DatenfluĂanalyse dar. Im folgenden wollen wir uns kurz die Vorgehensweise der DatenfluĂanalyse ansehen und dann die Optimierungen, die sie erlaubt, besprechen. Die DatenfluĂanalyse operiert in der Regel auf einem Zwischencode, der dem 3-AdreĂ-Code aus der letzten Folge Ă€hnlich ist. Wichtig ist auĂerdem, daĂ Variablen nicht symbolisch dargestellt werden. Stattdessen sind es Referenzen auf je eine Datenstruktur, die fĂŒr jede der unterschiedlichen Variablen einmal vorkommt.
Eine solche Referenz erlaubt es, fĂŒr diese Variable berechnete Information zu speichern. Der Zwischencode wird auĂerdem nicht linear im Speicher abgelegt, sondern in sogenannte Grundblöcke (basic blocks) aufgeteilt. Jeder Grundblock besteht aus einer Sequenz von Zwischencodeanweisungen, die genau einen Eingang und einen Ausgang besitzt. Der Grundblock kann also nur mit der ersten seiner Anweisungen betreten und in der letzten verlassen werden. Zwischendrin gibt es weder Einsprung- noch Aussprungpunkte. Die Grundblöcke werden ihrerseits zu einem Graphen verbunden, in dem jede Kante (Pfeil) einen möglichen Ăbergang von einem zu einem anderen Grundblock anzeigt. Innerhalb der Grundblöcke interessiert dann hauptsĂ€chlich, welche Variablen beschrieben und welche ausgelesen werden. Durch eine geschickte Kombination dieser Information aller Grundblöcke können jetzt zum Beispiel alle Zugriffe auf den Wert einer Variablen, der an einer bestimmten Stelle in die Variable geschrieben wurde, berechnet werden und damit auch, ab wo eine bestimmte Variable nicht mehr benutzt wird. Erschwert werden solche Berechnungen durch die Benutzung von Arrays und Zeigern, die den Optimierer zwingen, alle Information zu verwerfen, die durch solch einen Zugriff eventuell falsch wird.
Durch diese Berechnungen kann der Optimierer die folgenden Verbesserungen am Code durchfĂŒhren. Die âEliminierung gemeinsamer TeilausdrĂŒckeâ (common subexpression elimination) erkennt mehrfach auftretende gleichartige Berechnungen und sorgt dafĂŒr, daĂ das einmal berechnete Ergebnis mehrfach benutzt wird, ohne es mehrmals zu berechnen. Die âcopy propagationâ behandelt Zuweisungen der Art âx := yâ Solange x nicht weiter verĂ€ndert wird, kann y statt x verwendet werden, was der als nĂ€chstes folgenden Optimierung eventuell die Eliminierung der Zuweisung erlaubt.
Da solche Kopieranweisungen in compilergeneriertem Code gerne Vorkommen, ist die Optimierung nicht so unsinnig, wie sie auf den ersten Blick scheinen mag. Eine weitere Optimierung ist die âEliminierung toten Codesâ (dead-code elimination); sie entfernt, wie ihr Name schon vermuten lĂ€Ăt, Code, dessen Berechnungen nie benutzt werden. Zwei weitere Optimierungen sind im Zusammenhang mit Schleifen möglich. Dies ist einmal die âCodeverschiebungâ (code motion), bei der es darum geht, Berechnungen, die bei den einzelnen Schleifen durchlaufen unverĂ€ndert bleiben, aus der Schleife herauszuziehen, und zweitens die âEliminierung von Induktionsvariablenâ (induction variable elimination) zusammen mit âreduction in strengthâ. Bei diesen beiden geht es darum. Laufvariablen, die in einer Schleife gleichartig verĂ€ndert werden, zu eliminieren oder wenigstens ihre Berechnung zu vereinfachen. Letzteres ist zum Beispiel möglich, sobald die Laufvariable i einer Schleife zu einer Berechnung wie âj := 2 * iâ verwendet und bei jedem Schleifendurchlauf um eins erhöht wird. Statt in jedem Schleifendurchlauf die recht teure Multiplikation durchzufĂŒhren, kann j zu Beginn der Schleife mit dem zweifachen Wert des Startwertes von i initialisiert und anschlieĂend mit âj := j + 2â erhöht werden.
Program ::= 'VAR' VarList ';' StatList 'PRINT' Ident.
VarList ::= Ident
| Ident ',' VarList.
StatList ::= Stat ';'
| Stat ';' StatList.
Stat ::= Assignment
| Conditional
| Loop.
Assignment ::= Ident ':=' Expr.
Conditional ::= 'IF Expr 'THEN' StatList ELSEâ StatList 'END'
Loop ::= 'WHILE' Expr 'DO' StatList 'END'
Expr ::= SimpleExpr RelOp SimpleExpr
| SimpleExpr.
SimpleExpr ::= SimpleExpr AddOp Product
| Product.
Product ::= Product MulOp Primitive
| Primitive.
Primitive ::= Integer
| Ident
| '(' Expr ')'.
RelOp ::= '=' | '#' | '<' | '>' | '=<' | '>='.
AddOp ::= '+' | '-'.
MulOp ::= '*' | '/'.
Listing 5: BNF einer kleinen Arithmetiksprache
Generierte Generatoren
Generatoren zur automatischen Erzeugung von Codegeneratoren können mit der Methode der Codeerzeugung per Mustererkennung recht gut erstellt werden, da diese Methode völlig unabhĂ€ngig von der Zielmaschine funktioniert. AuĂerdem können spezielle FĂ€higkeiten der Zielmaschine recht einfach durch Muster, die die geeigneten FĂ€lle abdecken, ausgenutzt werden. Ein Generator, der nach diesem Prinzip gebaut ist, ist BEG ([2]), was fĂŒr âback end generatorâ steht. Eine Eingabedatei fĂŒr dieses Werkzeug beschreibt die Codegenerierung mit Hilfe von Regeln, die im wesentlichen aus einem Muster, einem Kostenwert und einem ProgrammstĂŒck bestehen. Das ProgrammstĂŒck wird ausgefĂŒhrt, sobald die zugehörige Regel angewendet wird, und erzeugt den entsprechenden Code. AuĂerdem können durch zusĂ€tzliche Angaben auch noch Attribute wĂ€hrend der Codegenerierung berechnet werden. Diese können zum Beispiel zur Bestimmung von Adressierungsarten und zur Berechnung konstanter TeilausdrĂŒcke Verwendung finden. ZusĂ€tzlich zu den Regeln muĂ in der Eingabedatei noch eine Beschreibung der Syntax des Strukturbaums stehen. Aus diesen Eingaben erzeugt BEG einen kompletten Codegenerator in Modula-2 oder C, der wahlweise noch mit einem der beiden in BEG eingebauten Algorithmen zur Registerallokation versehen wird. Der so erstellte Codegenerator erzeugt fĂŒr jeden Strukturbaum garantiert immer den nach den angegebenen Kostenwerten optimalen Code, d.h. den Code, der die minimalen Kosten verursacht.
Hardware
Soviel zum Thema Codegenerierung. Handfeste Informationen zum Thema Compiler-Bau gibt es in den beiden schon mehrfach zitierten BĂŒchern [1] und [3]. Dabei ist [1], das den Spitznamen Drachenbuchâ trĂ€gt, mehr an der Implementierung von Compilern orientiert. Man findet dort zu nahezu jedem Problem, das beim Bau eines Compilers auftritt, Implementierungstechniken zusammen mit teilweise recht raffinierten Tricks. Da das Buch auch gut geschrieben ist, ist es fĂŒr jeden, der sich fĂŒr Compiler-Bau interessiert, zu empfehlen. Das zweite Buch, das auch als âdas silberne Wunderâ bekannt ist, ist eigentlich nur fĂŒr Leute geeignet, die auch etwas an dem theoretischen Hintergrund interessiert sind und die eine formale Beschreibung nicht schreckt. Statt auf viele Implementierungsdetails, wird hier mehr Wert auf groĂe ZusammenhĂ€nge und ĂŒbergeordnete Prinzipien gelegt als im Drachenbuch. Wer nach der LektĂŒre dieser fĂŒnf Folgen zum Thema Compiler-Bau Lust bekommen hat, selbst einen Compiler zu schreiben, oder wer einfach mal ein wenig rumexperimentieren will, der kann die kleine Beispielsprache, deren Grammatik in Listing 5 abgebildet ist, mal des SpaĂes halber implementieren. Ein Programm dieser Sprache besteht aus einer Variablendeklaration, einer Reihe von Anweisungen und dem Befehl, den Wert einer Variablen auszugeben. Als einzigen Datentyp gibt es ganze Zahlen (Integer). Bezeichner (Ident) der Sprache bestehen aus einer beliebigen Folge von Buchstaben. Ansonsten sollte die Sprache selbsterklĂ€rend sein. Das folgende Programm berechnet zum Beispiel die FakultĂ€t der Zahl 5:
VAR fac, n;
n := 5;
ac := 1;
WHILE n > 0 DO
fac := fac * n;
n := n - 1 ;
END;
PRINT fac
Beim Bau eines Parsers fĂŒr die Sprache muĂ man zuerst die Linksrekursionen entfernen, wie in Folge 3 beschrieben. Die daraus resultierende Grammatik lĂ€Ăt sich mit Hilfe des rekursiven Abstiegs einfach implementieren. Die semantische Analyse besteht im wesentlichen daraus zu ĂŒberprĂŒfen, daĂ nur deklarierte Bezeichner verwendet werden, und daĂ der Ausdruck in Verzweigungen und Schleifen als obersten Operator einen Vergleich enthĂ€lt.
Allerdings kann alternativ auch die C-Regel benutzt werden, nach der alle Zahlen auĂer der Null fĂŒr TRUE stehen. Bei der Codeerzeugung sollte man sich mit einem einfachen Kellerautomaten zufriedengeben und symbolischen Assemblercode in eine Datei schreiben, die dann von einem separaten Assembler in Maschinencode ĂŒbersetzt wird.
Das warâs!
Die Compiler-Bau-Serie ist nun an ihrem Ende angelangt. Wenn es gelungen ist die grundlegenden Probleme des Compiler-Baus anschaulich darzustellen und ein GefĂŒhl fĂŒr die wichtigsten Problemlösungen zu vermitteln, so hat die Serie mit Sicherheit ihr Ziel erreicht. NatĂŒrlich wurde vieles nur angerissen, doch schlieĂlich ist der Compiler-Bau ein sehr umfangreiches und komplexes Gebiet, das noch nicht einmal durch einen WĂ€lzer wie das Drachenbuch hundertprozentig abgedeckt wird. Fragen, Kritik und Anregungen an den Autor sind jederzeit willkommen und sollten am besten an die untenstehende Adresse gesandt werden.
Manuel Chakravarty Wilhelm-Leuschner-Str. 2 7500 Karlsruhe-Oberreut
Literatur
[1] Aho/Sethi/Ullman: âCompilers: Principles, Techniques and Toolsâ, Addison-Wesley
[2] Emmelmann: âBEG - a Back End Generatorâ, User Manual (GMD Karlsruhe)
[3] Waite/Goos: âCompiler Constructionâ, Springer