Artikel
Im Anfang
war das Wort
Crashkurs Compilerbau
Ein richtiger Informatiker muß seinen eigenen XY-Compiler gebaut haben oder sein eigenes Betriebssystem. Das ist so, weil die Techniken, die dabei angewandt werden, sehr spannend sind und weil fast alle Probleme dabei auftauchen, die in der Informatik wesentlich sind. Wir können hier nur ein paar Einblicke in die Anfangsgründe geben. Wir zeigen, wie interessant das alles ist und was man wissen muß, um den Artikel über Compiler-Compiler zu verstehen.
Das wesentliche am Rechnen, das sind die eindeutigen Vorschriften, die von gegebenen Eingaben (Zahlen oder anderen Elementen) zu Ergebnissen führen. Der Inhalt solcher Vorschriften heißt Algorithmus. Ein Ingenieur, der einen Computer baut, entwirft einen Apparat, auf dem man in Maschinensprache Algorithmen zusammenbauen kann. Algorithmen werden dabei aus Maschinenbefehlen Stück für Stück zusammengesetzt. Heute weiß man: ein Algorithmus, das ist ein Programm, das durch eine Maschine ausgeführt werden kann. Denn jede eindeutige Vorschrift ist gleichzeitig eine Bauanleitung für eine Maschine, die vorschriftsmäßig handelt.
Assembler
Jedermann weiß, daß Maschinenbefehle im Computerspeicher binär verschlüsselt sind, genauso die Eingabedaten. Zusammen bilden sie das Maschinenprogramm, das von Hand in Binärform schwer zu lesen, aufzuschreiben und zu manipulieren ist. Aus diesem Grund gibt es Wortsymbole für die Maschinenbefehle und besondere Maschinenprogramme, die Assembler, die sowohl die Wortsymbole als auch Eingabewerte im Klartext erfassen und in Maschinensprache umsetzen. Ein Assembler setzt die Mnemonics, das sind die Wortsymbole, direkt in Maschinenbefehle um. MOV A,B ist ein Beispiel aus der 80XXX-Welt für einen Assemblerbefehl, der den Inhalt von Maschinenregister B in das Maschinenregister A kopiert. Die Aktion, die ein Maschinenbefehl auslöst, das ist seine Semantik, seine Bedeutung. Die Form, die er besitzt, daß nämlich nach MOV zwei durch Komma getrennte Angaben über die gewünschten Speicherstellen (CPU-Register sind auch Speicherzellen) kommen müssen, das ist die Syntax, die grammatikalisch korrekte Form dieses Befehls. Maschinenbauer und Assemblerprogrammierer haben sie so für ihre Maschine vorgegeben. Jede Maschine besitzt ihre eigenen Befehle und Syntax dazu.
MOV A,B
ADD A,C
MOV D,A
ist ein Algorithmus, der erst die Zahl in B nach A schafft, zu ihr die Zahl in C dazuaddiert und dann das Ergebnis nach D bringt. Als Formel: D=B+C.
Compiler und Linguistik
Das kleine Assemblerprogrammstück oben
besitzt eine klare Semantik, es addiert zwei (ganze) Maschinenzahlen
und liefert das Ergebnis an einer bestimmten Stelle ab. So einfach es
aussieht, so wenig übersichtlich ist es, jedesmal für D=B+C
solche Assemblerzeilen zu schreiben. Deshalb hat man schon früh
begonnen, Assemblerprogramme oder auch Maschinenprogramme zu
schreiben, die Formelklartext (und andere Dinge) erfassen und in
Maschinensprache umsetzen. Formula Translator,
Fortran heißt eines der ersten Programme dieser Art, das bei
IBM entwickelt wurde.
Heute gibt es viele Programmiersprachen, zum Beispiel Basic (das aus
Fortran für Computerneulinge entwickelt wurde), Algol, Pascal,
C, Ada, Prolog, ...
und immer noch Fortran. So verschieden sie sind, so identisch ist
ihre Aufgabe: Man will in ihnen bequem Algorithmen aufschreiben
können. Wie man das fehlerfrei machen kann und was man alles
über solche Sprachen mitteilen kann, das untersuchen sowohl die
Sprachforscher als auch die Informatiker.
Sprachforscher versuchen
den Reichtum der natürlichen Sprachen zu erfassen und
Erkenntnisse zu gewinnen, weshalb es überhaupt möglich ist,
sinnvoll zu sprechen, wie es möglich ist, und was überhaupt
alles sagbar ist. In ihrem Bemühen, diesen Fragen nachzugehen,
sind die Sprachforscher gewissermaßen von oben herab (weil sie
oft fertige Sprachen zu analysieren haben) auf dieselben Erkenntnisse
gestoßen, wie sie die Informatiker von unten herauf (weil sie
sich geeignete Sprachen konstruieren wollten) erarbeiten mußten.
Und es sind dieselben Fragen, die Mathematiker von einem sehr puren,
formalen Standpunkt aus immer schon behandelt haben und behandeln
werden. Es geht um die rein formale Manipulation von Zeichen- und
Symbolketten und was diese dabei an Bedeutung haben könnten.
Mengenlehre
Einer jeden Sprache liegen immer eine Anzahl, eine
Menge gewisser Symbole zugrunde, die Grund- oder Terminalsymbole, aus
welchen alle syntaktischen Elemente aufgebaut sind. Bei geschriebenen
Sprachen ist das oft die Menge der Buchstaben, neudeutsch der
ASCII-Zeichensatz,
das Alphabet.
Wenn man sinnlos beliebige Symbolketten aus einem
Reservoir, einer Menge von Symbolen, zusammenbauen will, so erhält
man die Wörter über diesem Alphabet.
abc,
xyzabdef,
unheimlich stark,
das sind Beispiele für unsinnige und sinnvolle Wörter über dem ASCII-Alphabet. Bei Programmiersprachen – das muß man sich klarmachen – sind die Terminalsymbole oft ganze Schlüsselwörter, die zwar aus ASCII-Zeichen zusammengesetzt sind, hier aber selbst als Einheit auftreten. MOV wäre so ein Terminalsymbol aus der Assemblersprache oben und MOV A,B ein sinnvolles Wort.
Grammatik ist Syntax
Aus der Menge der rein kombinatorisch
zusammengesetzten Wörter über einer Symbolmenge werden
durch die (von Schülern gefürchtete) Grammatik die
korrekten Bildungen ausgesondert. Bei den natürlichen Sprachen
ist die Grammatik durch die Sprachgewohnheiten vorgegeben – meist
nicht eindeutig. Bei Programmiersprachen hat der Compilerbauer die
Grammatik vorgegeben, konstruiert. Zum Beispiel sind Namen von
Variablen in der Grammatik der meisten Sprachen so konstruiert:
Als erstes muß ein Buchstabenzeichen kommen, dann können bis
zu so und soviel weitere Zeichen kommen, Buchstaben oder Ziffern.
Man könnte die grammatikalisch korrekten Wörter über einem
Alphabet einfach aufzählen. Dann müßte man beim
Arbeiten in einer so definierten Grammatik immer eine Vergleichsliste
bei sich führen um die Richtigkeit der gebildeten Wörter
kontrollieren zu können. Das mag bei einfachen Grammatiken
machbar sein. Falls beliebig lange und beliebig viele Wörter in
einer Grammatik vorkommen dürfen, kann eine solche Liste nicht
geführt werden.
Weil aber Programme beliebig lang werden
dürfen und es auch beliebig viele Programme gibt, haben sich die
Informatiker um konstruktive Verfahren bemüht, die es gestatten,
die formal korrekten Programme, also Wörter über dem Vorrat
der Grundsymbole einer Programmiersprache, systematisch aufzuzählen.
Man kann dabei wie folgt vorgehen:
Man nehme die
Menge der Grundsymbole G;
man nehme weiter eine Menge V von
sogenannten syntaktischen Variablen, die als Platzhalter für
grammatikalische Objekte dienen, wobei G und V keine Elemente
gemeinsam haben dürfen;
und man nehme einige Hilfszeichen,
genannt Metasymbole, die nicht zu den Grundsymbolen und zu V gehören
sollen und nur Hilfsmittel sind, die bei der formal präzisen
Beschreibung der korrekten Wörter eingesetzt werden.
Zum Beispiel sei G die Menge der ASCII-Zeichen. V bestehe nur aus der Variablen NAME. Aus der Menge der beliebigen Zusammenstellungen von ASCII-Zeichen sollen nur diejenigen als korrekt gebildete Namen gelten, die wie oben für Namen in Programmiersprachen definiert sind. Als einziges Metasymbol nehme man das Zeichen ::=, was als „Ist formal definiert als“ in den folgenden Definitionen gelesen werden kann.
NAME::=a
NAME::=b
...
NAME::=z
NAME::=A
NAME::=B
...
NAME::=Z
ist eine Liste von Regeln, die schon einmal einige korrekte Namen aufzählt. Wobei die einzelnen Zeilen jeweils eine von mehreren Möglichkeiten darstellen, also ein korrekter Name a oder b oder... sein kann. Den Schritt zu beliebig vielen Namen beliebiger Länge kann man durch rekursive Definitionen machen.
NAME::=NAMEa
NAME::=NAMEb
...
NAME::=NAMEz
NAME::=NAMEA
...
NAME::=NAMEZ
was so gelesen werden sollte: Liegt ein bisher korrekt gebildeter NAME vor, dann ist auch das Wort ein korrekter Name, das entsteht, wenn an den korrekten Namen ein weiterer Buchstabe angehängt wird. Diese Liste von rekursiven Definitionen sei jetzt noch um zehn weitere ergänzt, wobei die Ziffern zur Menge der Grundsymbole gehören:
NAME::=NAME0
NAME::=NAME1
...
NAME::=NAME9
Womit erreicht ist, daß korrekte Namen nach
dem ersten Buchstaben auch eine Ziffer enthalten dürfen.
Betrachtet man jetzt ein beliebiges Wort über G, zum Beispiel
alpha3, dann kann man anhand der vorgegebenen Regeln überprüfen,
ob alpha3 ein korrekter Name ist. Ein Weg, dies mechanisch zu tun,
lautet:
Man nehme das zu überprüfende Wort, hier also
alphaS und teste, ob es als Grundsymbol in einer der Definitionen
rechts vorkommt. Falls ja, ist man fertig. Wenn nein, teste man, ob
es als zulässige Kombination Name und Buchstabe oder Ziffer
rechts vorkommt. Dazu nehme man das letzte Grundsymbol des Wortes und
überprüfe anhand der Definitionenliste, ob die Kombination
NAME, letztes Grundsymbol in der Liste vorkommt. NAME3 kommt in der
Liste vor, ist also ein korrekter Name, wenn alpha ein korrekter Name
ist. alpha ist ein korrekter Name, wenn NAMEa ein korrekter Name ist.
Das ist der Fall, wenn alph, also NAMEh korrekt ist, das ist der
Fall, wenn alp, also NAMEp korrekt ist, wenn also NAME1 korrekt ist
und das ist der Fall, wenn a ein Name ist. Das ist durch die Zeile
NAME::=a im Definitionen-Schema gesichert. alphaS ist ein korrekter
Name. Man hätte hier auch anders vorgehen können und prüfen
können, ob das erste Grundsymbol ein zulässiger Beginn
eines Namens ist und ob der Name dann gut weitergeht. Zu sehen ist
jedenfalls, daß es rein mechanisch entscheidbar ist, ob eine
Zeichenkette ein Name ist.
Über eine Sprache reden
Das griechische Wort für „über“ lautet „meta“. Sprachwissenschaftler und Informatiker bezeichnen sprachliche Hilfsmittel zur Behandlung formaler Sprachen als Metasprache. Das Zeichen ::= wurde oben als ein Metasprachenelement, als Metasymbol genutzt. Es hat sich in der Gemeinde der Informatiker die Backus-Naur-Form als Metasprache durchgesetzt, die unter anderem ::= benutzt und dazu zum Beispiel das Zeichen |, das im umgangssprachlichen Sinn „oder“ symbolisiert. Man kann in dieser Form die Definition eines Namens abgekürzt notieren:
Name ::= Buchstabe
Name ::= Name Buchstabe | Name Ziffer
Buchstabe ::= a | b | c | .... X | Y | Z
Ziffer ::= 0 | 1 | 2 | ... 7 | 8 | 9
wenn man noch die Variablen Buchstabe und Ziffer vereinbart.
Zerlegen und Prüfen
Wirft man einem Compiler ein Programm vor (was ja
nichts anderes als ein Wort im Sinn unserer Diskussion ist), so
beginnt meist als erstes ein Programmteil namens
Scanner das Programm
zu bearbeiten. Er sucht nach den Grundsymbolen (wozu wie gesagt,
neben den Buchstaben auch ganze Wörter der Umgangssprache
gehören können) und präsentiert sie dem Compiler in
abgekürzter Form als sogenannte Token.
Solche Token sind die
interne Repräsentation der Grundsymbole.
Ein Scanner leistet
auch teilweise weitergehende Analysearbeit und sucht nach solchen
Unterstrukturen, die wie oben zum Beispiel Namen sind und markiert
sie. Danach beginnt ein sogenannter Parser
mit der Arbeit. Er überprüft Schritt für Schritt,
ob das Programm grammatikalisch korrekt ist. Dazu muß er
Grammatiktabellen eingebaut haben. Er kann dann Schritt für
Schritt die Token
von Beginn an überprüfen und in den
Regeltabellen nach Einträgen suchen, die den Beginn des
Programmes als möglich anzeigen und zeigen, daß das
Programm so weitergehen darf. Der Parser sucht also nach den
zugelassenen Konstruktionen im Programm und ob es korrekt
zusammengesetzt ist.
Man kann es sich ungefähr
so vorstellen: Besitzt die Programmiersprache zum Beispiel die
Möglichkeit, Formelklartext mit +, *, (, ) zu verarbeiten, dann
könnte sie in einfachen Fällen eine Teilsprache besitzen,
die die Grundsymbole
+, *, (, ), ZAHL
und die syntaktischen Symbole
ERGEBNIS, AUSDRUCK, VARIABLE
enthält. In Backus-Naur-Form könnte die Teilgrammatik lauten:
ERGEBNIS ::= AUSDRUCK | ERGEBNIS + AUSDRUCK
AUSDRUCK ::= VARIABLE | AUSDRUCK * VARIABLE
VARIABLE ::= ZAHL | (ERGEBNIS)
wobei jede „Formel“ zu einem Ziel, zum ERGEBNIS führen soll.
Strukturbäume zur Grammatik
Ist ein algebraischer Ausdruck gegeben, zum Beispiel
ZAHL + ( ZAHL *.459 ZAHL )
dann kann man wie oben bei den Namen überprüfen, ob diese Bildung zugelassen ist. Das heißt, ob ein Weg zum ERGEBNIS führt. Das gelingt, wenn man über dem vorgelegten Ausdruck den Codebaum errichtet, der entsteht, wenn man den Ausdruck anhand der Regeln zurückverfolgt (Bild 1).
- Ein Baum zu einem vorgelegten Ausdruck. Hier wird durch Fortschreiten von den Blättern zur Wurzel bewiesen, daß ein zugelassenes Ergebnis vorliegt.
Die Pfeile geben dabei die Herkunft einer Einheit an, wie sie durch ::= in den Grammatikregeln angegeben ist (aber umgekehrt). Man leitet gewissermaßen durch Fortschreiten im Baum – von den Blättern zur Wurzel – ab, daß man das Endergebnis erreichen kann. Ein syntaktisch inkorrekter Ausdruck würde nicht auf ERGEBNIS führen. Umgekehrt kann man durch systematisches Abwandern aller möglichen Äste von ERGEBNIS aus jede zulässige Formel erzeugen.
Die Codeerzeugung
Hat der Parser zum Beispiel durch Abwandern des zugehörigen Baumes bestimmte Teilkonstruktionen eines Programmes erkannt, so kann er sie einem Codeerzeuger weiterreichen. Der setzt dann in das bisherige Übersetzungsergebnis den zur erkannten Teilkonstruktion zugehörigen Code ein. Das ist ein bißchen leichter gesagt, als getan, denn schon bei einem Assembler muß die Konstruktion MOV A,B mit den sinnvollen Adressen von A und B versehen werden können. Aber es ist bei einer richtig definierten Sprache ohne weiteres ganz mechanisch möglich, die semantischen Aktionen zu einem Programm Schritt für Schritt aus dem Programmtext abzuleiten und in den ausführbaren Code einzufügen. Die Hauptaufgabe eines Compilers ist also die Erkennung von syntaktisch richtigem Programmcode und die Erzeugung des zugehörigen Maschinencodes. Darüber hinaus sollte er natürlich möglichst viele Fehler erkennen und melden, optimieren und, und, und...
Die Metasprache
Der Witz ist, daß man über formale Sprachen in einer formalen Sprache „reden“ kann. Das Metasymbol ::=, das oben zur Formalisierung einer Redeweise diente, kann Grundsymbol einer Sprache sein, mit der man Sprachkonstruktionen beschreiben kann. Oder anders gesagt, man kann die Backus-Naur-Form-Sprache in Backus Naur-Form beschreiben. Das heißt nichts anderes, als daß man einen Compiler bauen kann, der als Semantik in Backus-Naur-Form geschriebene Regeln in einen Parser umsetzt. Ein solcher Compiler ist YACC, den Rolf-Dieter Klein im anschließenden Artikel beschreiben wird. Mit diesem Compiler-Compiler hat er den i860-Assembler entworfen. Das ist eine technologische Leistung, die ihm keiner so schnell nachmacht.
Ulrich Rohde
Literatur
- [1] Goose, Gerhard; Wait, William M.: Compiler Construction. Springer-Verlag, New York, Berlin, Heidelberg, Tokyo, 1984
aus mc 04/91 – Seite 174-177