Code-Erzeugung - Universität Münster

Werbung
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.
Herunterladen