This document is posted to help you gain knowledge. Please leave a comment to let me know what you think about it! Share it to your friends and learn new things together.
• Grammatik: Formalismus, um die Syntax einer Sprache festzulegen
Eine Grammatik G ist ein Tupel G = (VT, VN, R, S) mit
• VT: Menge der Terminalzeichen (Token)
• VN: Menge der Nonterminalzeichen
• R: Menge der Produktionsregeln
• S: einem Startzeichen.
Die Sprache zu einer Grammatik ist die Menge der herleitbaren Wörter. Die Produktions-regeln (oder kurz: Produktionen) sind die Bildungsregeln für gültige Worte.
1 Lexikalische AnalyseEingabe ist das Quellprogramm in Form einer Zeichenkette.Das Quellprogramm wird in eine Folge von Tokens aufgebrochen.Überflüssige Zwischenräume und Kommentare werden entfernt.
2 Syntax-AnalyseErkennen der Strukturbausteine des Programms.Die syntaktische Struktur wird mittels der Produktionsregel der Sprache bestimmt, d.h. es werden entsprechende syntaktische Einheiten gebildet (z.B. "Faktor", "Term", "Ausdruck", "Anweisung")
2a Semantische AnalysePrüfung auf semantische Fehler, insbesondere Typprüfungen, gegebenenfalls Typ-wandlung.
3 CodegenerierungFür jede syntaktische Einheit des Programms werden entsprechende Maschinenanwei-sungen generiert. Das Resultat ist das Objektprogramm.
3a Optimierung (in manchen Compilern)Entfernen redundanter Anweisungen, Verwenden von Registern statt Speicherstellen für Zwischenvariable, Verkleinern von Schleifen durch Herausziehen invarianter Anweisun-gen usw.
Der Programmtext wird Zeichen für Zeichen bearbeitet, und der Analysator
• bestimmt, ob ein Zeichen ein terminales Symbol (z.B. + oder - ) ist oder ob es mit seinem Nachbarn zur Bildung eines "größeren" terminalen Symbols wie einem Namen, einer Zahl oder einem reservierten Wort zusammengefasst werden kann,
• entfernt syntaktisch überflüssige Zeichen, wie mehrfache Zwischenräume (Leerzeichen) und eingeschobene Kommentare für den menschlichen Leser,
• weist unzulässige Zeichen im Quellprogramm als Fehler zurück.
Terminale Symbole werden vom lexikalischen Analysator an den Syntaxanalysator in Form von Tokens weitergegeben. Jedes Token repräsentiert ein besonderes terminales Symbol.
Ein Token hat zwei Komponenten:
1) seinen Typ, der angibt welche Art eines terminalen Symbols das Token enthält (z.B. falls, +, Zahl),
2) seinen Wert, der angibt, welchen Wert das terminale Symbol erhält. So ist der Wert eines Zahlen-Tokens einfach die Zahl, die es enthält, und der Wert eines Namens-Token ist der Name, den es enthält. (Tatsächlich sind die Namens- und Zahlen-token die einzigen hier erwähnten Arten, die sinnvollerweise einen Wert annehmen. Die Werte der anderen Tokens können als undefiniert betrachtet werden.)
Ein Zerlegungsbaum veranschaulicht den Herleitungsvorgang für ein Wort in einer gege-benen Grammatik. Ein Zerlegungsbaum ist ein Baum mit den folgenden Eigenschaften:
(1) Die Wurzel ist das Startzeichen.
(2) Jedes Blatt ist ein Token.
(3) Jeder innere Knoten ist ein Nonterminalzeichen.
(4) Sei A ein innerer Knoten und seien x1,x2,...,xN die Nachfolger von A in der Reihen-folge von links nach rechts. Es existiert dann eine Produktionsregel A → x1x2...xNin der Grammatik.
Unter Zerlegung (parsing) versteht man den Prozess der Entscheidung, ob eine Folge von Tokens durch die gegebene Grammatik erzeugt werden kann. Dazu wird ein Zerlegungs-baum (parse tree) aufgebaut.
Es gibt verschiedene Techniken zum Aufbau eines Zerlegungsbaums für eine gegebene Folge von Tokens. Man unterscheidet die Top-Down- und die Bottom-Up-Zerlegung:
• Die Top-Down-Zerlegung beginnt mit dem Startsymbol.
• Die Bottom-Up-Zerlegung mit dem zu analysierenden Wort der Eingabesprache.
Modul Parsing-Zuweisung{Skizze eines Moduls für das Parsing einer Zuweisung. Fehler in einem der Schritte führen zur Fehlermeldung beim rufenden Modul.}
Prüfe, ob der Typ des nächsten Token “Übertrage" istParsing-AusdruckPrüfe, ob der Typ des nächsten Tokens "nach" istPrüfe, ob der Typ des nächsten Tokens "Name" ist(die übrigen Module analog dazu)
Anmerkung 1Die Struktur der Module entspricht der Struktur der Produktionen!
Anmerkung 2Rekursion ist zulässig und sehr häufig!
Anweisung → SchleifeSchleife → Solange Bedingung
führe aus AnweisungDies führt zu rekursiven Modulaufrufen für “Anweisung“.
Beim Entwurf von Programmiersprachen wird bereits darauf geachtet, dass eine einfache Zerlegung durch den Compiler möglich ist. Insbesondere sollte Backtracking beim Parsingmöglichst selten vorkommen.
Im einfachsten Fall entscheidet das jeweils nächste Symbol (Token) im Eingabestrom des Parsers, wie die weitere Zerlegung zu erfolgen hat (lookahead = 1). Dann gibt es überhaupt kein Backtracking. Eine so aufgebaute Grammatik führt zu besonders effizienten Parsern.
Beispiel: die Sprache Basic: Es gibt ein eindeutiges Schlüsselwort zu Beginn jeder Anwei-sung.
Eine weitere Überlegung zielt auf die Verbesserung der Fehlererkennung ab. So können z. B. Trennzeichen (als terminale Symbole) eingeführt werden, die das Lokalisieren von Syntax-fehlern erleichtern.
• die Zuweisung von Hauptspeicherplatz zu den statischen Datenelementen des Pro-gramms,
• die Generierung der Maschineninstruktionen für die ausführbaren Anweisungen,
• die Generierung von externen Referenzen zur Verwendung durch den Binder (“linkageeditor“)
• zu den Laufzeitroutinen für Standard-Unterprogramme (Prozeduren und Funktio-nen bzw. Methoden (zum Beispiel "read", "sin")
• zu separat übersetzten Unterprogrammen.
Der Zerlegungsbaum (Parse Tree) wird als Parameter an den Code-Generator über-geben.
Für jeden Anweisungstyp gibt es Routinen zur Code-Erzeugung. Sie werden auch als "se-mantische Routinen" oder "semantische Aktionen" bezeichnet, da sie eine bedeutungs-gerechte Umsetzung der programmiersprachlichen Anweisungen in Maschineninstruktionen gewährleisten.
Die Symboltabelle enthält die Zuordnung von Namen zu Speicheradressen. Sie
• wird während der lexikalischen Analyse aufgebaut und steht allen Phasen des Compilers zur Verfügung.
• enthält Namen, Datentyp und Hauptspeicheradresse jedes im Programm vorkommenden Datenelements.
• dient bei der Erzeugung des Maschinencodes zur Umsetzung von Namen in Adressen.
• wird nach Abschluss der Übersetzung überflüssig. Ausnahmen:• symbolische Debugger brauchen sie zur Laufzeit,• externe Referenzen, die erst beim Binden aufgelöst werden, müssen Speicher-
adressen in den „fremden“ Modulen zugeordnet werden können.
Von einer Eingabesprache wird in eine Ausgabesprache übersetzt, zum Beispiel in einen Zwischencode (z. B. Java Bytecode), Assembler oder direkt in binäre Maschineninstruk-tionen.
Die syntaktische Struktur der Eingabesprache wird mittels einer Grammatik spezifiziert.
Den Produktionsregeln werden semantische Aktionen zugewiesen. Die semantischen Akti-onen berechnen Werte, meist vom Typ String, für die Nonterminale.
Die Übersetzung ist dann eine Abbildung wie folgt:
• Sei I eine Eingabe:
• Erzeuge den Zerlegungsbaum für I.
• Sei x das Nonterminalzeichen eines Knotens des Zerlegungsbaums. Berechne den Wert von x gemäß der semantischen Aktion, die dem Knoten zugewiesen ist.
• Der Wert des Startzeichens ist die Ausgabe, d.h. die Übersetzung von I (dies ist in der Regel noch nicht das endgültige Maschinenprogramm.)
In unserer einfachen Beispielsprache sei ein arithmetischer Ausdruck (expression) wie folgt definiert:
ausdruck → NAME operator NAME
operator → + | -
Wir betrachten die Code-Erzeugung für eine einfache Einadress-Akkumulatormaschine. Der generierte Code hat die Form:
LOAD A1ADD A2
oderLOAD A1SUB A2
Das Resultat steht im Akkumulator.Die Adressen zu den Namen A1 und A2 werden in der Symboltabelle gefunden.Wir wählen die Technik der Top-Down-Zerlegung. Zu den Nonterminalen definieren wir die semantischen Aktionen in Form von Modulen in Pseudocode.
Der Zerlegungsbaum wird depth-first (in postorder traversal) abgearbeitet. An jedem Knoten wird die zugeordnete semantische Aktion durch Aufruf des entsprechenden Moduls ausge-führt.
Modul Erzeuge:Bedingung generiert die Maschineninstruktionen
1000 LOAD 1456 ;lade X
1001 SUB 100 ;subtrahiere 1
1002 JZ WEITER ;jump bei Accumulator = 0
Modul Erzeuge:Ausdruck generiert die Maschineninstruktionen
Optimierende Compiler haben eine weitere Phase, in der sie versuchen, den Objektcode zu optimieren. Dazu ist ein tiefergehendes Verständnis der Programmsemantik erforderlich. Optimierungsmöglichkeiten sind
• Verwendung von Registern statt Hauptspeicherstellen für Zwischenergebnisse
• “Herausziehen“ von invarianten Ausdrücken aus Schleifen.
Java-Beispiel: for (r=1; r<=n; r++){
pi = 3.141592; /* kann herausgezogen werdenf = pi * r * r;
…}
•Vermeidung der Mehrfachauswertung von Ausdrücken
Java-Beispiel: u = 2.0 * pi * r;
f = pi * r * r;
Hier kann das Zwischenergebnis für “pi * r“ zweimal verwendet werden.
• Wenn ergänzend zur Syntax der Sprache auch noch die Zielsprache (Maschinenspra-che) des Compilers angegeben wird, kann man in manchen Fällen auch die Codeerzeu-gungsphase des Compilers automatisch generieren. Einen solchen Generator bezeichnet man als Compiler-Compiler. Schwierig ist allerdings die korrekte semantische Zuordnung von Folgen von Maschineninstruktionen zu den Knoten des Zerlegungsbaumes.
• Werkzeuge zum automatischen Generieren von Compilern werden zum Beispiel mit dem Betriebssystems Unix ausgeliefert (lex, yacc).
• Einsatzgebiete sind das Prototyping von neuen Sprachen, Datenbanksprachen, Kom-mando-Interpreter, Datenstruktur-Beschreibungssprachen usw.
Optimierende Compiler haben eine weitere Phase, in der sie versuchen, den Objektcode zu optimieren. Dazu ist ein tiefergehendes Verständnis der Programmsemantik erforderlich. Optimierungsmöglichkeiten sind
• Verwendung von Registern statt Hauptspeicherstellen für Zwischenergebnisse
• “Herausziehen“ von invarianten Ausdrücken aus Schleifen.
Java-Beispiel: for (r=1; r<=n; r++){
pi = 3.141592; /* kann herausgezogen werdenf = pi * r * r;
…}
•Vermeidung der Mehrfachauswertung von Ausdrücken
Java-Beispiel: u = 2.0 * pi * r;
f = pi * r * r;
Hier kann das Zwischenergebnis für “pi * r“ zweimal verwendet werden.
•Parameter n wird in R15 übergeben.•Es werden drei Worte auf dem Stack reserviert und R4 als Zeiger auf die lokalen Variablen verwendet.•@R4 enthält Parameter n•2(R4): Variable s•4(R4): Variable i
Ausschnitt aus der kompilierten, nicht optimierten Version...push r4 ; Retten von R4sub #6, r1 ; Anlegen von 3 Worten: n, s, imov r1, r4 ; Verwenden von R4 als "local variables"
mov r15, @r4 ; Speichern von nmov #1, 4(r4) ; Initialisieren von i mit 1
.L2: ; Beginn der for-Schleifecmp 4(r4), @r4 ; Vergleiche i mit njge .L5 ; Falls n >= i, springe in Schleifenrumpfjmp .L1 ; Ansonsten springe zu Ende
.L5:mov 4(r4), r15 ; Lade i in R15add 4(r4), r15 ; Addiere i zu R15mov r15, 2(r4) ; Speichere R15 in sadd #1, 4(r4) ; Erhöhe Zähler i um einsjmp .L2 ; Fahre mit nächstem Schleifendurchlauf fort
.L1:add #6, r1 ; Löschen der lokalen Variablen vom Stackpop r4 ; Wiederherstellen von R4ret ; Rücksprung ...
b) Kompilierung mit Optimierung :•Da die Schleife nichts Sinnvolles berechnet oder zurückgibt, wird ihr Rumpf komplett wegoptimiert.•Die Schleife läuft trotzdem n mal durch, wobei n als Zähler verwendet wird und rückwärts zählt.
Ausschnitt aus der kompilierten, optimierten Version:
…cmp #1, r15 ; Vergleiche n mit 1jl .L8 ; Falls n<1, springe zu Ende.L6:add #-1, r15 ; Ziehe eins von n abjne .L6 ; Falls jetzt n!=0, wiederhole.L8:ret …
a) Kompilierung ohne Optimierung•Parameter werden hier in R14 und R15 übergeben.•Es werden vier Worte im Speicher angelegt, und R4 wird als Zeiger auf lokale Variablen verwendet.•@R4: Parameter a•2(R4): Parameter b•4(R4): Variable c•6(R4): Variable d
b) Kompilierung mit Optimierung: Der Ausdruck wird vereinfacht zu (a+b)*2+6
Ausschnitt aus der kompilierten, optimierten Version:...add r14, r15 ; Addiere a und brla r15 ; Multipliziere Ergebnis mit 2add #6, r15 ; Addiere 6 zum Ergebnisret ; Rücksprung...
a) Kompilierung ohne Optimierung•Übergabe des Parameters n geschieht in R15.•Es werden drei Worte Speicher für lokale Variablen auf dem Stack reserviert.•R4 zeigt auf den Anfang des Bereiches für Variablen.•@R4: Parameter n•2(R4): Variable result•4(R4): Variable i
Ausschnitt aus der kompilierten, nicht optimierten Version:
…push r4 ; Retten des Registers R4sub #6, r1 ; Anlegen von 3 Worten: n, result, imov r1,r4 ; Verwendung von R4 als "local variables"
mov r15, @r4 ; Speichern von nmov #0, 2(r4) ; Initialisieren von result mit 0mov #0, 4(r4) ; Initialisieren von i mit 0.L2: ; Beginn der for-Schleifecmp 4(r4), @r4 ; Vergleiche i mit njge .L5 ; Ist n >= i, springe in die Schleifejmp .L3 ; Ansonsten springe ans Ende.L5:add 4(r4), 2(r4) ; Addiere i zu resultadd #1, 4(r4) ; Erhöhen des Zählers ijmp .L2 ; Sprung zum nächsten Schleifendurchlauf.L3:mov 2(r4), r15 ; Übergeben den Wert von result in R15
add #6, r1 ; Löschen der lokalen Variablenpop r4 ; Wiederherstellen von R4ret ; Rücksprung…
b) Kompilierung mit Optimierung :•Die Schleife wurde ersetzt durch eine do-while-Schleife mit anfänglicher Abfrage, ob n kleiner als null ist.•Alle Werte werden diesmal in Registern gehalten:•R15: enthält zu Beginn Parameter n, wird später für result verwendet.•R14: Variable i•R13: Kopie des Parameters n
Ausschnitt aus der kompilierten, optimierten Version:...mov r15, r13 ; Verschiebe Parameter n nach R13mov #0, r15 ; Initialisiere result mit 0mov #0, r14 ; Initialisiere i mit 0cmp #0, r13 ; Vergleiche n mit 0jl .L8 ; Falls n<0, springe sofort zum Ende.L6:add r14, r15 ; Addiere i zu resultadd #1, r14 ; Erhöhe Zähler i um einscmp r14, r13 ; Vergleiche i mit njge .L6 ; Falls n<=i, erneuter Schleifendurchlauf.L8:ret...
• Wenn ergänzend zur Syntax der Sprache auch noch die Zielsprache (Maschinensprache) des Compilers angegeben wird, kann man in manchen Fällen auch die Codeerzeugungsphase des Compilers automatisch generieren. Einen solchen Generator bezeichnet man als Compiler-Compiler. Schwierig ist allerdings die korrekte semantische Zuordnung von Folgen von Maschineninstruktionen zu den Knoten des Zerlegungsbaumes.
• Werkzeuge zum Compiler-Generieren werden zum Beispiel mit dem Betriebssystems Unix ausgeliefert (lex, yacc).
• Einsatzgebiete sind das Prototyping für neue Sprachen, Datenbanksprachen, Kommando-Interpreter, DatenstrukturBeschreibungssprachen usw.
6.4 Interpretation vs. Kompilierung vs. Java Bytecode
Interpretation
• Verarbeitung des Programms durch einen Interpreter
• schrittweise Ausführung des Programms, jede Anweisung ist ein Schritt
• syntaktische Analyse der Anweisung; falls korrekt, Verzweigung zu einem Inter-pretationsmodul. Keine Erzeugung von Maschinencode für die Anweisungen!
• Wegen des hohen Aufwands während der Verarbeitung nur zweckmäßig für einfache Sprachen (z. B. BASIC: erstes Wort jeder Anweisung ist Schlüsselwort, verzweigt zum Interpretationsmodul)
• Vorteil: schnelleres Testen, schnelle Ausführung von einfachen, kurzen Programmen, Fehleranalyse auf Quellcode-Ebene leicht
• Nachteile: Bei wiederholter Ausführung einer Anweisung, z.B. in Schleifen oder Unter-programmen, muss die Syntaxanalyse jeweils neu durchgeführt werden. "Code-Opti-mierung" nicht möglich, da Interpretationsmodule allgemeingültig sein müssen.
• Übersetzung aus einer höheren Programmiersprache in Maschinensprache durch einen Compiler
• Das Programm wird als Ganzes vor der Ausführung übersetzt, die Maschinenbefehle als "Objektprogramm" (Objektcode) abgespeichert.
• Auch bei wiederholter Ausführung von Anweisungen (Schleifen) erfolgt die Übersetzung nur einmal (vorab). Das gesamte Objektprogramm kann immer wieder geladen und gestartet werden, ohne neu übersetzt zu werden.
Vorteile
• Effizienter, schneller Programmcode (optimiert)• Separate Übersetzung von Modulen möglich• Komplexe Programmiersprachen möglich
Nachteile
• Mehr Aufwand für einfache, kurze Programme.• Der Compiler selbst ist ein großes, komplexes Stück Software• Fehleranalysen für Laufzeitfehler erschwert, da Quellprogramm zur Laufzeit nicht
Java Bytecode ist ein maschinenunabhängiger Zwischencode. Aus der Sprache Java wird in Bytecode kompiliert. Der Bytecode wird dann auf der Zielmaschine interpretiert.
• Die Kompilation besteht aus den Schritten Lexikalische Analyse, Semantische Analyse und Codeerzeugung.
• In der Codeerzeugung lässt sich der Grad der Codeoptimierung wählen.
• Neben der Kompilation gibt es für einfache Sprachen noch die Interpretation, bei der der Code der höheren Programmiersprache direkt interpretiert wird. Es werden keine Maschi-nenbefehle erzeugt.
• Aus der Sprache Java wird in der Regel Bytecode kompiliert, der dann auf der Zielma-schine interpretiert wird.