Westfälische Wilhelms-Universität Münster Ausarbeitung Code-Erzeugung im Rahmen des Seminars „Übersetzung von künstlichen Sprachen“ Katja Funke Themensteller: Prof. Dr. Herbert Kuchen Betreuer: Prof. Dr. Herbert Kuchen Institut für Wirtschaftsinformatik Praktische Informatik in der Wirtschaft Inhaltsverzeichnis 1 Vom Zwischencode zum Zielcode ............................................................................ 3 2 Grundlagen der Code-Erzeugung .............................................................................. 4 2.1 2.1.1 2.1.2 2.1.3 2.1.4 2.1.5 Der Input ....................................................................................................... 4 Der Output .................................................................................................... 4 Codeselektion................................................................................................ 5 Registerzuteilung .......................................................................................... 5 Instruktionsanordnung .................................................................................. 6 2.2 Die Zielmaschine .............................................................................................. 6 2.3 Programmdarstellungen .................................................................................... 7 2.3.1 2.3.2 2.3.3 2.4 3 Fakten zum Design des Code-Generators......................................................... 4 Basisblöcke ................................................................................................... 7 Flussgraphen ................................................................................................. 8 Basisblöcke als DAG .................................................................................... 9 Informationen über Variablen......................................................................... 10 Aspekte der Code-Erzeugung .................................................................................. 11 3.1 Ein einfacher Code-Generator ........................................................................ 11 3.2 Registerzuteilung und Registerauswahl.......................................................... 14 3.2.1 3.2.2 Globale Registerzuteilung........................................................................... 14 Registerzuteilung durch Graphfärbung....................................................... 15 3.3 Peephole Optimization.................................................................................... 16 3.4 Optimale Code-Erzeugung ............................................................................. 18 3.4.1 3.4.2 Markierungsphase....................................................................................... 19 Generierungsphase...................................................................................... 20 4 Fazit ......................................................................................................................... 22 A Die Prozedur gencode .......................................................................................... 24 Literaturverzeichnis ........................................................................................................ 25 II Kapitel 1: Vom Zwischencode zum Zielcode 1 Vom Zwischencode zum Zielcode Ein Compiler übersetzt ein Quellprogramm in ein semantisch äquivalentes Zielprogramm, das schließlich vom Prozessor ausgeführt wird. Dieser Übersetzungsschritt wird in mehreren Phasen vollzogen, die sich grundsätzlich in die beiden Teile Analyse und Synthese gliedern lassen. Die Analyse wird vom so genannten Front End des Compilers vorgenommen, das das Quellprogramm strukturiert und auf syntaktische und semantische Fehler überprüft. Anschließend wird aus dem Quellprogramm Zwischencode erzeugt. Im Syntheseteil, der auch Back End genannt wird, wird der Zwischencode in Zielcode übersetzt. In manchen Compilern wird der Zwischencode vor der Code-Erzeugung optimiert, um eine bessere Qualität des Zielcodes zu erreichen. Von einem Code-Generator wird gefordert, dass sein Output korrekt und von hoher Qualität ist. Die Qualität des generierten Codes wird durch seine Laufzeit und seinen Speicherplatzbedarf bestimmt. Im Rahmen dieser Arbeit werden wichtige Aspekte der Code-Erzeugung vorgestellt und Verfahren zur Bewältigung der einzelnen Teilaufgaben bei der Generierung von Zielcode beschrieben. Alle Ausführungen stützen sich hautsächlich auf [ASU86, Kap. 9]. Zunächst werden einige Grundlagen beschrieben, die für die Code-Erzeugung notwendig sind. Wichtige Teilaufgaben der Code-Erzeugung sind die Codeselektion, die Registerzuteilung, sowie die Instruktionsanordnung. Diese Teilaufgaben werden in Kapitel 2.1 einführend beschrieben. Die in dieser Arbeit verwendete Zielmaschine wird in Kapitel 2.2 vorgestellt. Da es für viele Verfahren der Code-Erzeugung sinnvoll ist, den Zwischencode zunächst in eine geeignete Darstellungsform zu bringen, werden in Kapitel 2.3 verschiedene Programmdarstellungen vorgestellt. In Kapitel 3 wird gezeigt, wie die eigentliche Code-Erzeugung durchgeführt wird und wichtige Teilaufgaben werden vertiefend behandelt. Ein erster Ansatz zur Code-Erzeugung ist der einfache Code-Generator, der in Kapitel 3.1 vorgestellt wird. In Kapitel 3.2 werden verschiedene Strategien zur Registerzuteilung und Registerauswahl beschrieben. Erste Ansätze zur Verbesserung der Qualität des erzeugten Zielcodes werden in Kapitel 3.3 der Arbeit vorgestellt. Schließlich wird in Kapitel 3.4 ein Verfahren beschrieben, das optimalen Code erzeugt. Die Arbeit endet mit einer Zusammenfassung und einem Ausblick auf weitere Aspekte, die sich an die Code-Erzeugung anschließen. 3 Kapitel 2: Grundlagen der Code-Erzeugung 2 Grundlagen der Code-Erzeugung 2.1 Fakten zum Design des Code-Generators 2.1.1 Der Input Der Input des Code-Generators besteht aus dem Zwischencode, der durch das Front End des Compilers erzeugt wurde, sowie Informationen aus der Symboltabelle. Aus den Informationen der Symboltabelle werden später für die Ausführung des Programms die Adressen der Datenobjekte bestimmt, die im Zwischencode als Variablen vorkommen, [ASU86, S. 514]. Es gibt verschiedene Varianten für die Darstellung der Zwischensprache. Im Folgenden wird 3-Adress-Code der Form x := a op b als Zwischensprache verwendet. Es wird vorausgesetzt, dass das Quellprogramm vor der Code-Erzeugung vom Front End lexikalisch, syntaktisch und semantisch analysiert und auf Fehler geprüft wurde. Des Weiteren wird vorausgesetzt, dass eine Typüberprüfung durchgeführt wurde und alle notwendigen Operatoren zur Typkonvertierung eingefügt wurden. In der Phase der Code-Generierung wird somit davon ausgegangen, dass der Input frei von Fehlern ist. 2.1.2 Der Output Der Output des Code-Generators ist das Zielprogramm. Hierfür gibt es verschiedene Darstellungsformen: Absolute Maschinensprache, Objektcode oder Assemblersprache, [ASU86, S. 514]. Ein Programm in absoluter Maschinensprache hat den Vorteil, dass es an eine feste Stelle im Speicher verschoben werden kann und sofort ausführbar ist. Die Erzeugung von Objektcode erlaubt es, Unterprogramme separat zu kompilieren. Verschiedene relative Objektdateien können durch das Linken verbunden und für die Ausführung geladen werden. Einerseits müssen die Kosten für das Binden und Laden der einzelnen Objektdateien getragen werden, aber andererseits ist diese Vorgehensweise sehr flexibel und bietet die Möglichkeit, Unterprogramme separat zu kompilieren und andere bereits kompilierte Programme aus einem Unterprogramm heraus aufzurufen, [ASU86, S. 515]. Ein Programm in Assemblersprache ist einfacher zu generieren. Die Nutzung symbolischer Instruktionen und Makros des Assemblers vereinfachen die Code-Erzeugung. Allerdings ist anschließend an die Code-Generierung ein zusätzlicher Assemblierungsschritt notwendig, um den Assemblercode in die 4 Kapitel 2: Grundlagen der Code-Erzeugung Maschinensprache des Prozessors zu übersetzen. Im Folgenden wird Assemblercode als Zielsprache für die Code-Erzeugung verwendet. 2.1.3 Codeselektion Die Aufgabe der Codeselektion ist es, für ein Zwischenprogramm ein semantisch äquivalentes Zielprogramm zu erzeugen. Es gibt stets mehrere Möglichkeiten für Zielprogramme, die sich jeweils durch ihre Kosten für Laufzeit und Speicherplatzbedarf unterscheiden. Eine Zielmaschine mit einem großen Befehlssatz bietet viele verschiedene Möglichkeiten zur Implementierung einer bestimmten Operation. Da die Kostenunterschiede zwischen den verschiedenen Implementierungen jedoch entscheidend sein können, ist es möglich, dass eine naive Übersetzung des Zwischenprogramms zwar zu einem korrekten, aber ineffizienten Zielprogramm führt, [ASU86, S. 516]. Die Schwierigkeit der Codeselektion wird durch die Größe des Befehlssatzes in der Prozessorarchitektur der Zielmaschine bestimmt. Hierbei können zwei Klassen von Architekturen unterschieden werden: CISC (Complex Instruction Set Computer) und RISC (Reduced Instruction Set Computer). Beide Architekturen unterscheiden sich im Wesentlichen durch die Komplexität der zur Verfügung gestellten Befehle, sowie die Anzahl der Prozessorregister, [WM97, Kap. 12.2]. CISCs bieten eine komplexe Befehlslogik, aber nur wenige Prozessorregister. RISCs haben hingegen nur wenige, sehr einfache Befehle, aber häufig mehr als 100 Prozessorregister. Werkzeuge zur Erzeugung von Codeselektoren können in [ASU86, Kap. 9.12] nachgelesen werden. 2.1.4 Registerzuteilung In realen Maschinen wird eine Speicherhierarchie eingesetzt, die durch die Schnelligkeit des Zugriffs und die Verweildauer von Inhalten bestimmt ist. In den meisten Fällen besteht diese Hierarchie aus Prozessorregister, Cache, Hauptspeicher und Hintergrundspeicher, [WM97, S. 570]. Für die Code-Erzeugung sind die beiden Ebenen Prozessorregister und Hauptspeicher von Interesse. Die Register liegen in der Regel auf dem Prozessorchip. Zum Zugriff auf die Werte in den Registern muss der Chip demnach nicht verlassen werden. Der Zugriff auf den Hauptspeicher erfolgt über den Systembus zwischen Prozessor und Speicher. Der Zugriff auf die Register ist deutlich schneller als der Zugriff auf den Hauptspeicher. Es ist demnach erstrebenswert, Objekte möglichst geschickt in Registern zu halten, um damit die Ausführungsgeschwindigkeit zu erhöhen. Die Aufgabe der Registerzuteilung besteht darin, dass der Code-Generator 5 Kapitel 2: Grundlagen der Code-Erzeugung guten Gebrauch von den meist wenigen Registern der Zielmaschine macht. Die Benutzung von Registerzuteilung Registern und kann in zwei Registerauswahl, Probleme [ASU86, S. aufgespaltet 517]. werden: Während der Registerzuteilung wird entschieden, welche Werte eines Programms in Registern abgelegt werden sollen. In der anschließenden Registerauswahl wird das konkrete Register bestimmt, in dem eine Variable abgelegt werden soll. Eine optimale Registerauswahl für Variablen zu treffen ist sehr schwierig. Mögliche Strategien für die Registerzuteilung werden in Kapitel 3.2 vorgestellt. 2.1.5 Instruktionsanordnung Die Anordnung der Befehlssequenz kann die Effizienz des Zielprogramms beeinflussen. Einige Anordnungen benötigen weniger Register für die Ablage von Zwischenergebnissen als andere. Die Bestimmung der besten Reihenfolge ist ein sehr schwieriges Problem, das im Folgenden zunächst dadurch vermieden wird, dass der Code für das Zielprogramm in der Reihenfolge der Befehle des Zwischenprogramms erzeugt wird. 2.2 Die Zielmaschine Die Voraussetzung zur Erzeugung eines guten Code-Generators ist die Kenntnis der Zielmaschine und ihres Befehlssatzes. In einer grundlegenden Darstellung der CodeErzeugung ist es nicht möglich, die Feinheiten jeder möglichen Zielmaschine detailliert zu beschreiben, um guten Code für die Sprache dieser Maschine zu erzeugen. Aus diesem Grund wird in dieser Arbeit eine Registermaschine als Zielcomputer verwendet, die für eine Reihe von Minicomputern repräsentativ ist. Der verwendete Zielcomputer ist eine Byte-adressierbare Maschine mit vier Bytes pro Wort und n UniversalRegistern, R0,R1,…,Rn-1. Die Maschine nutzt 2-Adress-Befehle in Assemblersprache der Form OP source, destination wobei op eine Operation ist und source und destination jeweils Datenfelder sind. Unter anderen stehen die folgenden Operationen zur Verfügung, [ASU86, Kap. 9.2]: MOV speichert source in destination ADD addiert source zu destination SUB subtrahiert source von destination 6 Kapitel 2: Grundlagen der Code-Erzeugung Die Felder für source und destination sind nicht groß genug, um Speicheradressen zu beinhalten. Aus diesem Grund können die in [ASU86, Kap. 9.2] vorgestellten Adressierungsarten verwendet werden, um source und destination einer Anweisung anzugeben. Die Kosten für einen Befehl betragen jeweils 1 zuzüglich der Kosten, die sich aus der jeweiligen Adressierungsart für source und destination ergeben. Adressierungsarten, die lediglich Register einbeziehen, verursachen keine zusätzlichen Kosten. Adressierungsarten, die Speicheradressen oder Literale benutzen, kosten jeweils 1, weil diese Operanden mit dem Befehl aus dem Hauptspeicher geladen oder dorthin gespeichert werden müssen. Um Speicherplatz zu sparen, sollte die Länge der verwendeten Befehle möglichst kurz gehalten werden. Für die meisten Maschinen hat dies den zusätzlichen Vorteil, dass die Laufzeit sinkt. Es dauert meist länger, einen Befehl aus dem Speicher zu laden, als ihn schließlich auszuführen. Somit reduziert die Verkürzung der Befehlslänge gleichzeitig die Ausführungszeit. Generell ist es sinnvoll die Werte, die als nächstes zu verarbeiten sind, in Registern abzulegen. 2.3 Programmdarstellungen 2.3.1 Basisblöcke Viele Algorithmen zur Code-Erzeugung setzen eine Aufteilung des Zwischenprogramms in Basisblöcke voraus. Ein Basisblock ist eine maximale Folge von Anweisungen in 3-Adress-Code, die immer nacheinander ausgeführt werden, [Gü96, Kap. 8.1.1]. Es gibt keine Verzweigungen in einen Basisblock hinein oder aus einem Basisblock heraus. Wird die erste Anweisung eines Basisblocks ausgeführt, dann werden sequentiell auch alle anderen Anweisungen ausgeführt. Jeder Basisblock besitzt einen eindeutig festgelegten Blockanfang und ein Blockende. Um eine Folge von 3Adress-Befehlen in Basisblöcke zu zerlegen, kann der folgende Algorithmus verwendet werden, [ASU86, S. 529]: Zunächst werden die Blockanfänge nach folgenden Regeln bestimmt: (i) Der erste Befehl des Programms ist ein Blockanfang. (ii) Jeder Befehl, dessen Adresse als Sprungmarke eines bedingten oder unbedingten Sprungs auftritt, ist ein Blockanfang. (iii) Jeder Befehl, der unmittelbar auf eine bedingte oder unbedingte Verzweigungsanweisung auftritt, ist ein Blockanfang. Anschließend wird für jeden Blockanfang sein Basisblock bestimmt: Der Basisblock besteht aus dem Blockanfang und allen Befehlen bis zum, aber nicht einschließlich, nächsten Blockanfang oder bis 7 Kapitel 2: Grundlagen der Code-Erzeugung einschließlich zum Ende des Programms. Folgendes Beispiel zeigt die Aufteilung einer Befehlsfolge in 3-Adress-Code in ihre Basisblöcke B1,…, B6: B1: B2: B3: B4: B5: B6: (1) x := (2) z := (3) m := (4) y := (5) if m (6) y := (7) if y (8) z := (9) i := (10)if i (11)y := (12)i := (13)goto (14)x := (15)z := 5 3 x m > z < x m > x i 6 z x * + z m + + z * z x then goto 8 y then goto 14 z x then goto 3 z 3 * y + y Da die Anweisungen in einem Basisblock immer alle nacheinander ausgeführt werden, geben Basisblöcke statische Informationen über den Programmfluss. Die Reihenfolge, in der Basisblöcke durchlaufen werden, ist hingegen für verschiedene Programmdurchläufe mit verschiedenen Parametern unterschiedlich. 2.3.2 Flussgraphen Die verschiedenen Möglichkeiten zum Durchlaufen der Basisblöcke werden in einem gerichteten Graphen, der Flussgraph genannt wird, dargestellt. Für die Code-Erzeugung ist es oft hilfreich, den Kontrollfluss des Zwischenprogramms in einem Flussgraphen darzustellen. Die Knoten des Flussgraphen repräsentieren die Berechnungen in den Basisblöcken. Es gibt einen Startknoten, er wird durch den Basisblock gebildet, dessen Blockanfang der erste Befehl des Programms ist, [ASU86, S. 532]. Die Kanten des Flussgraphen stellen den Kontrollfluss dar. Eine Kante von Basisblock Bi zu Basisblock Bj existiert genau dann, wenn Block Bj während eines Programmdurchlaufs direkt nach Block Bi ausgeführt werden kann. In diesem Fall ist Bj entweder das Ziel eines Sprungbefehls in Bi oder der Basisblock Bj folgt im Programmtext direkt auf Bi und der letzte Befehl von Bi ist kein unbedingter Sprung [Gü96, Kap. 8.1.1]. Für die in Kapitel 2.3.1 dargestellte Zerlegung einer Befehlsfolge in ihre Basisblocke ergibt sich der in Abbildung 1 dargestellte Flussgraph. 8 Kapitel 2: Grundlagen der Code-Erzeugung B1 B2 B6 B3 B4 B5 Abbildung 1: Flussgraph für die Befehlsfolge in Kapitel 2.3.1, [Gü96, Kap. 8.1.1] 2.3.3 Basisblöcke als DAG Gerichtete azyklische Graphen (englisch: directed acyclic graph, DAG) sind geeignete Datenstrukturen zur Darstellung von Basisblöcken. Es ist wichtig, DAGs nicht mit Flussgraphen zu verwechseln. Jeder Knoten eines Flussgraphen könnte durch einen DAG dargestellt werden, weil jeder Knoten eines Flussgraphen einen Basisblock repräsentiert. Ein DAG für einen Basisblock ist ein gerichteter azyklischer Graph, der die folgenden Informationen enthält: • Eine Beschriftung für jeden Knoten. An den Blättern ist diese Beschriftung ein Bezeichner, d.h. ein Variablenname oder eine Konstante. Die Variablennamen an den Blättern werden mit 0 indiziert, weil sie die anfänglichen Werte der Variablen darstellen. Innere Knoten werden mit dem Symbol eines Operators beschriftet. • Für jeden Knoten gibt es eine Liste angefügter Bezeichner, hierbei sind Konstanten nicht zulässig. Um einen DAG für einen Basisblock zu konstruieren, wird jede Anweisung des Basisblocks abgearbeitet. Bei einer Anweisung der Form x := y op z wird zunächst nach den beiden Knoten gesucht, die die aktuellen Werte für y und z repräsentieren. Diese können entweder Blätter im DAG sein oder innere Knoten, falls y und/oder z durch frühere Anweisungen im Basisblock bereits bestimmt wurden. Falls es bisher noch keine Knoten für y und z gibt, werden zunächst zwei Blätter erzeugt und mit y0 bzw. z0 beschriftet. Anschließend wird ein Knoten generiert, der mit op beschriftet wird und zwei Kinder bekommt. Das linke Kind ist der Knoten für y, das rechte Kind ist der Knoten für z. An diesen neu generierten Knoten wird der Bezeichner x angefügt. Falls 9 Kapitel 2: Grundlagen der Code-Erzeugung es bereits einen Knoten gibt, der den gleichen Wert wie y op z bezeichnet, wird kein neuer Knoten erzeugt, sondern dem existierenden Knoten der zusätzliche Bezeichner x angefügt. Die Konstruktion eines DAG aus einem Basisblock im 3-Adress-Code bietet somit die Möglichkeit, mehrfach auftretende Teilausdrücke zu ermitteln. Für eine Zuweisung der Form x:=y wird kein neuer Knoten generiert, stattdessen wird x in die Liste angefügter Bezeichner des Knotens, der den aktuellen Wert von y enthält, eingefügt. Der Algorithmus zur Erzeugung eines DAG für einen Basisblock kann in [ASU86, Kap. 9.8] nachgelesen werden. Der DAG für den folgenden Basisblock im 3Adress-Code ist in Abbildung 2 dargestellt: t1 := 4 * i t2 := a[t1] t3 := 4 * i t4 := b[t3] t5 := t2 * t4 t6 := prod + t5 prod := t6 t7 := i + 1 i := t7 + t6, prod prod0 * [] a0 t2 t5 t4 [] * b0 4 t1, t3 i0 + t7, i 1 Abbildung 2: Darstellung des Basisblocks als DAG, [ASU86, S. 548] 2.4 Informationen über Variablen Zur Code-Erzeugung werden für jede Variable des Quellprogramms Informationen darüber benötigt, ob sie am Ende des Basisblocks, in dem sie gesetzt wurde, noch lebt und ob sie in diesem Block noch einmal verwendet wird. Ein 3-Adress-Befehl der Form x := y + z setzt x und verwendet y und z. Die Definitions-Verwendungs-Kette (def- use-Kette) einer Variablen x, die durch einen Befehl i gesetzt wurde, wird dadurch beschrieben, dass ein 3-Adress-Befehl j die Variable x als Operand benutzt und es einen Programmpfad von i zu j gibt, auf dem x zwischenzeitlich kein Wert zugeordnet wird. Eine Variable in einem Basisblock ist lebendig an einem Programmpunkt p, wenn sie auf einem Programmpfad vom Beginn des Programms bis zum Punkt p gesetzt wurde 10 Kapitel 3: Aspekte der Code-Erzeugung und es einen Pfad vom Punkt p zu einer Verwendung dieser Variablen gibt, auf dem sie nicht gesetzt wird [WM97, S. 591]. Eine Variable ist demnach an einem bestimmten Programmpunkt lebendig, wenn ihr dortiger Wert noch benötigt werden kann, z.B. in einem anderen Basisblock. Um festzustellen, ob eine Variable am Ende eines Basisblocks noch lebendig ist, kann die in [ASU86, Kap. 10] vorgestellte Datenflussanalyse angewendet werden. Ohne die Durchführung einer Datenflussanalyse muss angenommen werden, dass alle nicht-temporären Variablen am Ende eines Basisblocks lebendig sind. 3 Aspekte der Code-Erzeugung 3.1 Ein einfacher Code-Generator Der im Folgenden vorgestellte Algorithmus generiert Assemblercode für eine Folge von 3-Adress-Befehlen, die einen Basisblock bilden. Es wird angenommen, dass für jeden Operator in einem Zwischencode-Befehl ein entsprechender Operator in der Zielsprache existiert. Der Algorithmus nutzt die Vorteile der Ablage von Werten in Registern aus. Ergebnisse können so lange wie möglich in Registern gehalten werden. Um Fehler zu vermeiden, wird jeder Wert am Ende eines Basisblocks sowie beim Ausführen von Prozeduraufrufen gespeichert, [ASU86, S.536]. Zur effektiven Ausnutzung der Register werden während der Code-Erzeugung die folgenden Informationen verwaltet: Register-Inhalte und Variablen-Positionen, [Gü96, Kap. 8.3.2]. Register-Inhalte verwalten für jedes Register R zu jedem Zeitpunkt die Menge von Variablennamen, deren Wert in R gespeichert ist. Zu Beginn eines Basisblocks sind alle Register leer. Variablen-Positionen verwalten für jede Variable v die Stelle v’, an der sich der aktuelle Wert von v befindet. Dies mag ein Register, eine Speicheradresse oder eine Position im Kellerspeicher sein. Zu Beginn eines Blocks befinden sich alle Variablen im Speicher. Der Algorithmus zur Code-Erzeugung verwendet eine Hilfsfunktion getreg, die eine Speicherstelle L für den Wert x, der durch den 3-Adress-Befehl x := y op z gesetzt wird, wie folgt auswählt [ASU86, S.538]: 11 Kapitel 3: Aspekte der Code-Erzeugung 1. Die Funktion getreg liefert ein Register, in dem sich y bereits befindet, falls y nicht lebendig ist und nach der Ausführung von x := y op z nicht mehr verwendet wird. 2. Ist 1. nicht möglich, liefert getreg ein leeres Register, falls vorhanden. 3. Ist 2. nicht möglich und x wird im aktuellen Basisblock noch verwendet oder op ist eine Operation, die ein Register benötigt, liefert getreg ein belegtes Register R. Der Wert von R wird mit MOV R,M gespeichert. Es gibt keinen besten Weg für die Auswahl des belegten Registers. Geeignet wäre die Wahl eines Registers, das die Anzahl der Lade- und Speicher-Befehle minimiert. 4. Falls x nicht im aktuellen Basisblock verwendet wird oder kein passendes belegtes Register gefunden werden kann, liefert getreg die Speicherstelle von x im Hauptspeicher. Zur Code-Erzeugung werden die Anweisungen des Basisblocks nacheinander abgearbeitet. Der Algorithmus führt für jeden 3-Adress-Befehl der Form x := y op z dabei die folgenden Schritte aus, [ASU86, S. 537]: 1. Es wird die Hilfsfunktion getreg aufgerufen, um die Speicherstelle L auszuwählen, an der das Ergebnis der Berechnung y op z abgelegt werden soll. 2. Die Variablen-Position y’ wird für y bestimmt. Falls sich der Wert von y aktuell sowohl im Speicher als auch in einem Register befindet, wird das Register für y’ bevorzugt. Falls sich der Wert von y aktuell nicht in L befindet, wird y mit dem Befehl MOV y’,L nach L kopiert. 3. Es wird der Befehl OP z’,L generiert, wobei z’ die aktuelle Variablen-Position von z ist. Auch hier wird das Register gegenüber dem Speicher bevorzugt, falls sich z in beiden befindet. Die Variablen-Position für x wird aktualisiert, um zu verwalten, dass sich x an der Speicherstelle L befindet. Falls L ein Register ist, wird sein Register-Inhalt aktualisiert, um zu vermerken, dass es den Wert von x beinhaltet und x wird aus allen anderen Register-Inhalten entfernt. 4. Falls die aktuellen Werte von y und/oder z nicht weiter verwendet werden, am Ende des Basisblocks nicht lebendig sind und sich in Registern befinden, 12 Kapitel 3: Aspekte der Code-Erzeugung werden die Register-Inhalte so verändert, dass diese Register nach der Ausführung von x:=y op z nicht länger y und/oder z beinhalten. Ein wichtiger Sonderfall ist der 3-Adress-Befehl x:=y. Hierbei sind zwei Situationen zu unterscheiden: Entweder befindet sich y in einem Register oder ausschließlich im Speicher. Falls y in einem Register abgelegt ist, müssen die Register-Inhalte und Variablen-Positionen so angepasst werden, dass der Wert von x nun nur in dem Register zu finden ist, in dem sich y befindet. Falls y nicht mehr verwendet wird und am Ende des Basisblocks nicht mehr lebendig ist, kann y aus der Liste der RegisterInhalte dieses Registers entfernt werden. Falls sich y ausschließlich im Speicher befindet, wird die Hilfsfunktion getreg benutzt, um ein Register zu finden, in das y geladen wird. Dieses Register wird die Speicherstelle für x. Alternativ kann der Befehl MOV y,x erzeugt werden. Dies ist besonders dann geeignet, wenn der Wert von x im Basisblock nicht mehr verwendet wird. Nachdem die oben beschriebenen Schritte für alle 3-Adress-Befehle im Basisblock ausgeführt wurden, werden alle Variablen, die am Ende des Basisblocks lebendig sind und sich nicht im Speicher befinden mithilfe von MOV-Befehlen im Hauptspeicher abgelegt. Ohne die Durchführung einer Datenflussanalyse muss davon ausgegangen werden, dass alle benutzerdefinierten Variablen am Ende des Basisblocks lebendig sind. Die Zuweisung d:=(a-b)+(a-c)+(a-c) kann durch folgende Befehlsfolge im 3Adress-Code dargestellt werden: t u v d := := := := a a t v – – + + b c u u Am Ende dieser Befehlsfolge ist die Variable d lebendig. In Tabelle 1 wird der Zielcode dargestellt, der mithilfe des oben beschriebenen Algorithmus erzeugt wurde, unter der Annahme, dass die beiden Register R0 und R1 zur Verfügung stehen. Zwischencode Zielcode t := a - b MOV a,R0 SUB b,R0 u := a – c MOV a,R1 SUB c,R1 v := t + u ADD R1,R0 Register-Inhalte alle Register leer R0 enthält t R1 ist leer R0 enthält t R1 enthält u R0 enthält v VariablenPositionen t in R0 t in R0 u in R1 u in R1 13 Kapitel 3: Aspekte der Code-Erzeugung d := v + u ADD R1,R0 MOV R0,d R1 enthält u R0 enthält d v in R0 d in R0 d in R0 und im Speicher Tabelle 1: Zielcode für die Zuweisung d:=(a-b)+(a-c)+(a-c), [ASU86, S. 539] 3.2 Registerzuteilung und Registerauswahl Das Laden eines Wertes aus einem Register ist preiswerter als das Laden aus dem Hauptspeicher. Somit sind Befehle, die nur Register als Operanden verwenden, schneller als solche, die Operanden aus dem Speicher einbeziehen. Im Folgenden werden verschiedene Strategien für die Registerzuteilung und die Registerauswahl während der Code-Erzeugung vorgestellt. Ein erster Ansatz besteht darin, bestimmte Werte in einem Programm jeweils speziellen Registern zuzuordnen. Beispielsweise kann es Register für Basisadressen, Register für arithmetische Berechnungen oder ein Register für den obersten Wert im Kellerspeicher geben. Dadurch wird das Design des Compilers vereinfacht. Nachteilig ist allerdings, dass bei zu strikter Anwendung dieses Ansatzes, die Register ineffizient genutzt werden. Dennoch ist es in jedem Fall angebracht, einige Register für bestimmte Zwecke zu reservieren und die restlichen Register dem Compiler zur freien Verwendung zur Verfügung zu stellen. 3.2.1 Globale Registerzuteilung Der in Kapitel 3.1 vorgestellte Algorithmus setzte Register für die Speicherung von Werten während eines einzelnen Basisblocks ein. Am Ende eines Basisblocks wurden alle lebendigen Variablen im Hauptspeicher abgelegt. Um einige der sich daraus ergebenden kostspieligen Speicher- und Ladeoperationen einzusparen, wird im Folgenden ein Verfahren vorgestellt, das häufig benutzten Variablen Register zuordnet und diese Register global, über die Grenzen einzelner Basisblöcke hinweg, konsistent beibehält, [ASU86, S. 542]. Programme verbringen die meiste Zeit in inneren Schleifen. Aus diesem Grund ist es sinnvoll, einen häufig benutzten Wert während einer Schleife in einem festen Register abzulegen. Eine innere Schleife ist eine Schleife, die keine anderen Schleifen beinhaltet, [ASU86, S. 534]. Um innere Schleifen in einem Programm zu finden, kann seine Darstellung als Flussgraph benutzt werden. In [ASU86, Kap. 9.7] wird hierzu ein Verfahren vorgestellt, das für jede Variable die mögliche Kostenersparnis für das 14 Kapitel 3: Aspekte der Code-Erzeugung Speichern im und Laden aus dem Hauptspeicher bestimmt, wenn ihr Wert während des Durchlaufens einer inneren Schleife in einem Register gespeichert wird. Bei der Registerzuteilung werden schließlich die Variablen ausgewählt, für die die Kosteneinsparung am größten ist. Eine mögliche Strategie bei der globalen Registerzuteilung ist die Bestimmung einer festen Anzahl an Registern, in denen die meist verwendeten Werte beim Durchlaufen einer inneren Schleife gespeichert werden, [ASU86, S. 542]. Die ausgewählten Werte können in verschiedenen Schleifen unterschiedlich sein. Nicht zugeordnete Register können dafür genutzt werden, lokale Werte innerhalb eines Basisblocks zu speichern. Diese einfach umzusetzende Strategie hat jedoch den Nachteil, dass eine feste Anzahl an Registern nicht immer die richtige Anzahl ist, die für die globale Registerzuteilung zur Verfügung gestellt werden muss. 3.2.2 Registerzuteilung durch Graphfärbung Im Laufe der Code-Erzeugung ist es möglich, dass für eine Berechnung ein Register benötigt wird, aber kein freies Register zur Verfügung steht. In diesem Fall muss der Wert eines belegten Registers im Hauptspeicher zwischengespeichert werden. Die im Folgenden vorgestellte Registerzuteilung durch Graphfärbung bietet eine einfache systematische Technik, bei der die Register so zugeteilt werden, dass Zwischenspeicherungen vermieden werden. Hierbei werden die Register der Zielmaschine global für alle Berechnungen innerhalb einer Prozedur vergeben. Zunächst wird unter der Annahme, dass beliebig viele Register zur Verfügung stehen, jeder modifizierten Variable und jeder Operation ein symbolisches Register zugeteilt. Anschließend müssen diese unbeschränkt vielen symbolischen Register einer beschränkten Anzahl realer Register der Zielmaschine zugeordnet werden. Dabei wird das Ziel verfolgt, möglichst wenige Werte im Hauptspeicher zwischenzuspeichern. Es ist zu beachten, dass zwei symbolischen Registern nie dasselbe reale Register zugeordnet werden darf, wenn die Werte in diesen beiden Registern lebendig sind, [WM97, S. 591]. Diese Einschränkung bei der Zuteilung von symbolischen Registern zu realen Registern wird durch den Registerkollisionsgraph einer Prozedur dargestellt. Die Knoten dieses Graphen werden durch die symbolischen Register gebildet. Es existiert eine Kante zwischen zwei Knoten, wenn der Wert des einen Knotens lebendig ist, während der Wert des anderen Knotens gerade gesetzt wird, [ASU86, S. 546]. Das Problem der Registerzuteilung besteht darin, die Knoten des Registerkollisionsgraphs mit k Farben zu färben, wobei zwei Knoten, die durch eine Kante verbunden sind, nicht 15 Kapitel 3: Aspekte der Code-Erzeugung die gleiche Farbe bekommen dürfen und k die Anzahl der zur Verfügung stehenden realen Register in der Zielmaschine ist. Um zu entscheiden, ob der Registerkollisionsgraph k-färbbar ist, kann folgende Heuristik angewendet werden, [ASU86, S. 546]: Existiert im Graphen G ein Knoten n, der weniger als k Nachbarn besitzt, so bleibt für n mit Sicherheit eine Farbe übrig. Der Knoten n kann mit all seinen Kanten aus G entfernt werden und es entsteht ein neuer Graph G’. Dieses Vorgehen wird rekursiv fortegesetzt. Ergibt sich daraus schließlich ein leerer Graph, so war der Ausgangsgraph G mit k Farben färbbar und die Knoten werden in umgekehrter Reihenfolge, in der sie entfernt wurden, eingefärbt. Damit ist das Problem gelöst. Falls jedoch ein Graph entsteht, der keinen Knoten mit einem Grad kleiner k besitzt, so ist der Graph G nicht k-färbbar. In diesem Fall muss ein Knoten ausgewählt werden, der gemeinsam mit seinen Kanten aus dem Graphen entfernt wird und es wird versucht, den erhaltenen Graphen mit k Farben zu färben. Die Entfernung des Knotens entspricht einer Zwischenspeicherung seines zugehörigen symbolischen Registers, [WM97, S. 592]. Für die Auswahl des Knotens zum Zwischenspeichern können folgende Kriterien in Betracht gezogen werden: Zum einen der Grad des Knotens, denn die Entfernung eines Knotens mit vielen Kanten steigert die Chance dafür, dass der verbleibende Graph k-färbbar ist. Zum anderen sollten die Kosten für die Zwischenspeicherung bei der Auswahl des Knotens bedacht werden. Zwischenspeicherungen innerhalb einer inneren Schleife sollten möglichst vermieden werden [ASU86, S. 546]. 3.3 Peephole Optimization Bei der sequentiellen Abarbeitung der Anweisungen des Zwischencodes wird oft Zielcode erzeugt, der redundante Befehle oder suboptimale Konstrukte enthält. Aus diesem Grund ist es sinnvoll, den generierten Code mithilfe verschiedener optimierender Transformationen zu verbessern. Auch wenn eine Optimierung im mathematischen Sinn nicht erreicht werden kann, ist es dennoch möglich, die Qualität des Zielcodes hinsichtlich der Laufzeit und des Speicherplatzbedarfs zu erhöhen. Eine einfache Technik zur Verbesserung des erzeugten Codes ist die peephole optimization („Guckloch-Optimierung“), [Gü96, Kap. 8.1.4]. Hierbei wird, anschaulich gesprochen, ein kleines bewegliches Fenster, das peephole, das einen Ausschnitt für nur wenige Befehlszeilen bietet, über die Befehlsfolge des Zielcodes geschoben, um diese zu 16 Kapitel 3: Aspekte der Code-Erzeugung analysieren und, falls möglich, durch kürzere oder schnellere Befehle zu ersetzen. Um einen maximalen Erfolg bei der Verbesserung des Zielcodes zu erzielen, ist es notwendig, das Verfahren mehrmals zu wiederholen. Im Folgenden werden einige mögliche Transformationen, die im Rahmen der peephole optimization durchgeführt werden können, vorgestellt. Eine Möglichkeit zur Verbesserung des Zielcodes ist die Entfernung redundanter Anweisungen. Wird ein naiver Algorithmus zur Code-Erzeugung angewendet, so ist es möglich, dass unnötige Operationen zum Laden und Speichern generiert werden, wie das folgende Beispiel aus [ASU86, S. 554] zeigt: (1) (2) MOV MOV R0,a a,R0 Die zweite Anweisung kann entfernt werden, denn die erste Anweisung stellt sicher, dass sich der Wert von a bereits in R0 befindet, falls (1) immer direkt vor (2) ausgeführt wird. Eine zweite Transformation im Rahmen der peephole optimization ist die Entfernung unerreichbarer Befehle. Eine Anweisung ohne Sprungmarke, die unmittelbar auf einen unbedingten Sprung folgt, kann entfernt werden, weil sie während des Programmdurchlaufs nie erreicht wird. Dies kann wiederholt werden, um eine Folge von Befehlen zu entfernen. Darüber hinaus können mithilfe der peephole optimization eine Reihe algebraischer Vereinfachungen vorgenommen werden. Typische Befehle hierfür sind beispielsweise x:=x+0 oder x:=x*1. Solche Anweisungen können entfernt werden, ohne die berechneten Werte zu verändern. Eine weitere Möglichkeit ist die Ersetzung teurer Operationen durch äquivalente billigere Operationen der Zielmaschine. Bestimmte Maschinenbefehle sind bedeutend billiger als andere. Beispielsweise kann die Operation x2 durch die billigere Anweisung x*x implementiert werden. Und Multiplikation oder Division durch zwei lassen sich durch einen Links- oder Rechts-Shift preiswerter implementieren. Als letzte mögliche Transformation soll in dieser Arbeit auf die Nutzung bestimmter Anweisungen der Zielmaschine verwiesen werden, um spezielle Operationen effizienter zu implementieren. Hierzu zählt beispielsweise das automatische Inkrementieren oder Dekrementieren von Operanden. 17 Kapitel 3: Aspekte der Code-Erzeugung Weitere Verbesserungen des erzeugten Codes können durch eine gesonderte CodeOptimierung erreicht werden, die im Anschluss an die Code-Erzeugung erfolgen kann. Hierzu sei der Leser auf [ASU86, Kap. 10] verwiesen. 3.4 Optimale Code-Erzeugung Im Folgenden wird ein Verfahren zur Code-Erzeugung für einen Basisblock vorgestellt, das die Darstellung des Basisblocks als DAG benutzt. Die Repräsentation als DAG erleichtert es, die Anordnung der Befehle des Zielcodes anzupassen. Die Anordnung von Berechnungen kann die Kosten des resultierenden Zielcodes stark beeinflussen. Gegeben sei beispielsweise folgender Basisblock im 3-Adress-Code: t1 t2 t3 t4 := := := := a + c + e t1 - b d t2 t3 Dieser Basisblock wird durch den DAG in Abbildung 3 repräsentiert. Der DAG ist ein Baum. - + a0 t4 - t1 b0 t3 + t2 e0 c0 d0 Abbildung 3: Darstellung des Basisblocks als DAG, [ASU86, S. 558] Der in Kapitel 3.1 vorgestellte Algorithmus erzeugt für den Zwischencode dieses Basisblocks folgenden Zielcode: MOV ADD MOV ADD MOV MOV SUB MOV SUB MOV a, R0 b, R0 c, R1 d, R1 R0, t1 e, R0 R1, R0 t1, R1 R0, R1 R1, t4 18 Kapitel 3: Aspekte der Code-Erzeugung Wird nun die Reihenfolge der Befehle im Zwischencode dahingehend geändert, dass die Berechnung von t1 unmittelbar vor der Berechnung von t4 erfolgt, so liefert der Algorithmus aus Kapitel 3.1 folgende Befehlsfolge: MOV ADD MOV SUB MOV ADD SUB MOV c, R0 d, R0 e, R1 R0, R1 a, R0 b, R0 R1, R0 R0, t4 Durch die Ausführung der Berechnungen in dieser neuen Reihenfolge, können die zwei Befehle MOV R0,t1 und MOV t1,R1 zur Zwischenspeicherung von t1 eingespart werden. Mit dem im Folgenden vorgestellten Algorithmus ist es möglich, die optimale Reihenfolge für Anweisungen in einem Basisblock zu bestimmen, falls der DAG, der den Basisblock repräsentiert, ein Baum ist. Der erzeugte Code ist hinsichtlich der Programmlänge und der Benutzung temporärer Variablen optimal. Der Algorithmus besteht aus zwei Phasen, der Markierungsphase zur Ermittlung des Registerbedarfs der Teilbäume und der Generierungsphase, in der der eigentliche Code entsprechend des vorher bestimmten Registerbedarfs erzeugt wird, [WM97, S. 586]. Der Algorithmus wird unter der Annahme vorgestellt, dass der Baum für den Zwischencode nur binäre Operatoren besitzt. 3.4.1 Markierungsphase Die erste Phase des Algorithmus ist die Markierungsphase, in der jeder Knoten im Baum mit seinem Registerbedarf, der notwendig ist, um den jeweiligen Teilbaum ohne Zwischenspeicherungen zu berechnen, beschriftet wird [ASU86, S. 561]. Zur Bestimmung dieses Registerbedarfs wird der Baum bottom-up durchlaufen. Die tatsächlich zur Verfügung stehende Anzahl an Registern in der Zielmaschine wird hierbei außer Acht gelassen. Linke Blätter werden mit 1 beschriftet, rechte Blätter mit 0. Linke Blätter sind Blätter, die linke Kinder binärer Operatoren sind, sie müssen für die Berechnung in ein Register geladen werden. Rechte Blätter werden als Operanden benutzt und benötigen deshalb kein Register. Daraus ergibt sich der Registerbedarf für einen inneren Knoten n, dessen linkes Kind mit r1 und dessen rechtes Kind mit r2 beschriftet sind: 19 Kapitel 3: Aspekte der Code-Erzeugung ⎧max(r1 , r2 ), falls r1 ≠ r2 regbedarf (n) = ⎨ ⎩r1 + 1, falls r1 = r2 Für den Baum aus Abbildung 1 ergibt sich der in Abbildung 2 dargestellte markierte Baum. t4 2 t1 a 1 1 b t3 0 e 2 t2 1 1 c 1 d 0 Abbildung 2: Mit Registerbedarf beschrifteter Baum, [ASU86, S. 562] 3.4.2 Generierungsphase Die Generierungsphase erhält den markierten Baum T als Input und erzeugt daraus optimalen Zielcode, der T auswertet und das Ergebnis in einem Register ablegt. Dies geschieht in einem Durchlauf durch den Baum: Um den Befehl für die Operation T1 op T2 an der Wurzel des Baumes zu erzeugen, wird zunächst der Code für die beiden Teilbäume T1 und T2, die die Kinder der Wurzel sind, generiert. Die Reihenfolge, in der T1 und T2 bearbeitet werden, wird durch ihren jeweiligen Registerbedarf bestimmt. Um den Befehl für die Wurzel zu erzeugen wird vorausgesetzt, dass sich der Wert von T1 in einem Register befindet. Dieses Register wird in dem Befehl für op als Operand verwendet. Werden zwischenzeitlich mehr Register benötigt, als zur Verfügung stehen, so werden Ergebnisse für Teilbäume zwischengespeichert und bei Bedarf wieder geladen. Der Algorithmus zur Code-Erzeugung benutzt die rekursive Prozedur gencode(n), deren formale Darstellung im Anhang zu finden ist. Die Prozedur benutzt die beiden Kellerspeicher rstack und tstack. Der Keller rstack verwaltet die verfügbaren Register. Zu Beginn des Algorithmus wird dieser Keller mit der Gesamtmenge der verfügbaren Register initialisiert. Insgesamt gibt es r verfügbare Register. Das oberste Register im Keller rstack wird als Ergebnisregister für T verwendet. Der Keller tstack verwaltet die Adressen von verfügbaren Zwischenspeicherstellen im Hauptspeicher. Beide Keller stellen die Operationen push, pop, top und swap zur Verfügung. Der Befehl swap(rstack) vertauscht die beiden obersten Register in 20 Kapitel 3: Aspekte der Code-Erzeugung rstack. Zur Erzeugung optimalen Zielcodes wird gencode auf der Wurzel von T ausgeführt. Die verschiedenen Fälle beim Bearbeiten der Knoten werden durch fünf Muster beschrieben: • Im Fall 0 ist der aktuelle Knoten ein linkes Blatt und wird in ein Register geladen. In den restlichen vier Fällen ist der aktuelle Knoten ein innerer Knoten mit einem linken Kind n1 und einem rechten Kind n2: • Im Fall 1 ist n2 ein rechtes Blatt und n1 ein Teilbaum, der mit dem Aufruf gencode(n1) ausgewertet wird. • Im Fall 2 sind die Kinder n1 und n2 des aktuellen Knotens beides Teilbäume, wobei für die Auswertung von n2 mehr Register benötigt werden als für n1. Deshalb wird n2 vor n1 ausgewertet. • Im Fall 3 sind n1 und n2 ebenfalls Teilbäume, wobei diesmal n1 aufwändiger zu bestimmen ist und deshalb zuerst ausgewertet wird. • Im Fall 4 werden für beide Teilbäume n1 und n2 mindestens r Register benötigt, um sie ohne Zwischenspeicherungen zu berechnen. Aus diesem Grund wird zuerst der rechte Teilbaum n2 ausgewertet und sein Ergebnis zwischengespeichert. Bei der Anwendung des vorgestellten Algorithmus auf den markierten Baum in Abbildung 2 mit der Initialisierung rstack = R0,R1 werden folgende Aufrufe der Prozedur gencode und Befehle für den Zielcode erzeugt: gencode(t4) gencode(t3) gencode(e) print MOV e, R1 gencode(t2) gencode(c) print MOV c, R0 print ADD d, R0 print SUB R0, R1 gencode(t1) gencode(a) print MOV a, R0 print ADD b, R0 print SUB R1, R0 21 Kapitel 4: Fazit Der erzeugte Zielcode ist optimal hinsichtlich der Anzahl der erzeugten Befehle, wenn der Zwischencode keine gemeinsamen Teilausdrücke enthält und somit seine Darstellung als DAG ein Baum ist. Dies kann mit folgenden Argumenten begründet werden, [WM97, S. 588]: • Für jeden inneren Knoten wird genau ein Befehl erzeugt. • Für jedes linke Blatt wird ein Ladebefehl erzeugt. • Für jeden Knoten, dessen Kinder mehr als r Register benötigen, wird eine Zwischenspeicherung erzeugt. Falls ein Basisblock mehrfach auftretende Teilausdrücke enthält, ist seine entsprechende Repräsentation als DAG kein Baum mehr. In diesem Fall kann die Markierung der Knoten nicht direkt vorgenommen werden und die Prozedur gencode lässt sich nicht anwenden. Der DAG muss zunächst in Teilbäume zerlegt werden, für die jeweils nach dem oben beschriebenen Verfahren Zielcode erzeugt wird, [ASU86, S. 567]. Die Erzeugung optimalen Zielcodes kann dabei jedoch nicht garantiert werden. 4 Fazit In der vorliegenden Arbeit wurden die zentralen Aufgaben der Code-Erzeugung behandelt. Ausgehend von den Grundlagen zur Code-Erzeugung wurde zunächst ein einfacher Code-Generator vorgestellt, der die Befehlsfolge eines Basisblocks sequentiell abarbeitet und daraus Zielcode erzeugt. Dieser Code-Generator speichert alle lebendigen Variablen am Ende des bearbeiteten Basisblocks im Hauptspeicher. Daraus ergeben sich unter Umständen unnötige Speicher- und Ladeoperationen. Um dieses Vorgehen zu verbessern, wurden Strategien zur Registerzuteilung und Registerauswahl vorgestellt, die eine globale Verwendung von Registern über Grenzen von Basisblöcken hinweg vornehmen. Weitere Code-Verbesserungen lassen sich durch Transformationen erreichen, die redundante Befehle eliminieren oder komplizierte Befehle vereinfachen. Hierzu wurden verschiedene Verfahren im Rahmen der peephole optimization vorgestellt. Für den Sonderfall, dass der DAG eines Basisblocks ein Baum ist, wurde ein Algorithmus behandelt, der hinsichtlich der Anzahl der erzeugten Befehle optimalen Zielcode erzeugt. Enthält der Ausgangsausdruck jedoch mehrfach auftretende Teilausdrücke und ist der entsprechende DAG somit kein Baum, kann die Erzeugung optimalen Zielcodes mit diesem Algorithmus nicht garantiert werden. Um höhere 22 Kapitel 4: Fazit Anforderungen an die Qualität des Codes zu erfüllen, sollte sich an die Code-Erzeugung eine Code-Optimierung anschließen. In diesem zusätzlichen Schritt wird der erzeugte Code mithilfe verschiedener Transformationen verbessert, um seine Laufzeit sowie seinen Speicherplatzbedarf zu verringern. Als Zielsprache für die Code-Erzeugung wurde in dieser Arbeit der Assemblercode gewählt. Um ausführbaren Code zu erzeugen, ist anschließend ein Assemblierungsschritt notwendig, der die Assemblersprache in das Befehlsformat der Zielmaschine übersetzt. Mit der Code-Generierung ist die Übersetzung des Quellprogramms in die Zielsprache grundsätzlich abgeschlossen und das Programm kann von der Zielmaschine ausgeführt werden. Eine Verbesserung des erzeugten Codes durch eine anschließende Code-Optimierung ist optional. 23 Anhang B: Titel von Anhang 2 A Die Prozedur gencode procedure gencode(n); begin /*case 0*/ if n is a left leaf representing operand name and n is the leftmost child of its parent then print ‘MOV’ ||name||’,’||top(rstack) else if n is an interior node with operator op, left child n1, and right child n2 then /*case 1*/ if regbedarf(n2) = 0 then begin let name be the operand represented by n2; gencode(n1); print op ||name||’,’||top(rstack) end /*case 2*/ else if 1 ≤ regbedarf(n1) < regbedarf(n2) and regbedarf(n1) < r then begin swap(rstack); gencode(n2); R := pop(rstack); gencode(n1); print op ||R||’,’||top(rstack); push(rstack, R); swap(rstack) end /*case 3*/ else if 1 ≤ regbedarf(n2) ≤ regbedarf(n1) and regbedarf(n2) < r then begin gencode(n1); R := pop(rstack); gencode(n2); print op ||top(rstack)||’,’||R; push(rstack, R) end /*case 4, regbedarf(n1) ≥ r and regbedarf(n2) ≥ r*/ else begin gencode(n2); T := pop(tstack); print ‘MOV’||top(rstack)||’,’T; gencode(n1); push(tstack, T); print op||T||’,’top(rstack) end end 24 Literaturverzeichnis [ASU86] Alfred V. Aho, Ravi Sethi, Jeffrey D. Ullman: Compilers: Principles, Techniques, and Tools, Addison-Wesley, 1986. [Gü96] Ralf H. Güting: Übersetzerbau, Fernuniversität, Gesamthochschule in Hagen, Fachbereich Informatik, 1996. [WM97] Reinhard Wilhelm, Dieter Maurer: Übersetzerbau: Theorie, Konstrukte, Generierung, Springer, 1997.