PL0-Compiler - Übersetzung in Zwischencode

Vorüberlegungen

Bevor mit der Übersetzung in Zwischencode begonnen werden kann, muss zunächst geklärt werden, wie dieser aussehen soll und wie Knoten des Syntaxbaumes als Zwischencode dargestellt werden sollen. Bei Letzterem ist vor Allem die Repräsentation von Variablen ein nicht trivialer Aspekt.

Elemente des Zwischencodes

Eine Zwischencodedarstellung sollte sich sowohl einfach aus Quellsprachen generieren lassen als auch einfach in Maschinensprachen zu übersetzen sein. Zwischencode ist eine abstrakte Maschinencodedarstellung, die nur die grundlegendsten Operationen enthält, mit denen sich aber sowohl Konstrukte spezieller Programmiersprachen als auch spezielle Maschinenanweisungen emulieren lassen. Damit ist eine Unabhängigkeit von Quell- und Zielsprache gegeben, was Voraussetzung für den modularen Aufbau eines Compilers mit auswechselbaren Front- und Backends ist.

Die gewählte Zwischensprache orientiert sich sehr stark an der des Buches Modern Compiler Implementation in Java. Im Folgenden seien ihre Elemente und deren Bedeutung vorgestellt.

Man unterteilt die Elemente der Zwischensprache grob in zwei Klassen, Ausdrücke (Exp) und Anweisungen (Stm). Ein Programm in Zwischencodedarstellung besteht dabei aus einer Folge von Anweisungen, welche Ausdrücke enthalten können. Ausdrücke können nicht ohne die umgebende Anweisung existieren. Es werden zunächst die Ausdrücke vorgestellt:

CONST(int value)Ein konstanter Zahlenwert.
TEMP(Temp temp)Inhalt eines temporären Speicherplatzes. Ein temporärer Speicherplatz Temp entspricht einem Register in einer echten Maschine, die Anzahl der Temps ist aber unbegrenzt.
BINOP(int binop, Exp left, Exp right)Eine binäre Operation. Dabei ist binop der Operator, left und right sind Ausdrücke, die vor Anwendung der Operation ausgewertet werden. Der Binäroperator kann sein "addieren", "subtrahieren", "multiplizieren", "dividieren", "bitweises Und" (AND), "bitweises Oder" (OR), "bitweises exklusives Oder" (XOR), "logisches Linksschieben" oder "logisches Rechtsschieben".
MEM(Exp exp)Inhalt einer oder mehrerer Speicherzellen. Der Ausdruck exp wird vor dem Speicherzugriff ausgewertet und dient als Speicheradresse. Es werden n Bytes ab dieser Adresse gelesen, dabei ist n die Wortgröße der Zielmaschine. Die Wortgröße wird ist im angeschlossenen Backend definiert und aus diesem über eine definierte Schnittstelle ausgelesen.
CALL(Exp func)Ruft eine Unterprozedur auf und liefert das Ergebnis zurück.



Die Zwischensprache enthält folgende Anweisungen:

MOVE(Exp dst, Exp src)Eine Kopieroperation. Der Wert src wird in n Byte des Speichers geschrieben, wobei dst die Startadresse des zu beschreibenden Speichers ist und n die Wortgröße.
EXP(Exp exp)Berechnet den Wert des Ausdrucks exp und verwirft das Ergebnis. Dieses Konstrukt wird im Projekt ausschließlig dazu verwendet, einen Funktionsaufruf (CALL), der vom Typ Exp ist, als Anweisung (Stm) verwenden zu können.
JUMP(Label label)Ein Sprung zur Stelle im Code, die mit LABEL(label) markiert wurde.
CJUMP(int relop, Exp left, Exp right, Label iftrue, Label iffalse)Ein bedingter Sprung. Es werden zuerst die Ausdrücke left und right ausgewertet, anschließend wird auf sie der Relationsoperator relop angewendet. Wenn left und right in der Beziehung relop zueinander stehen, wird zum Label iftrue gesprungen, andernfalls zum Label iffalse. Relationsoperator kann sein "gleich", "ungleich", "größer", "kleiner", "größer gleich" oder "kleiner gleich".
SEQ(Stm left, Stm right)Eine Sequenz von Anweisungen. Die Anweisung left wird vor der Anweisung right ausgeführt.
LABEL(Label label)Markiert eine Stelle im Quellcode, die das Ziel von Sprüngen sein kann.
INPUT(MEM address)Eine Eingabeaufforderung, der eingegebene Wert wird an Speicheradresse address gespeichert.
Ein- und Ausgabe sollten eigentlich in Form einer Laufzeitbibliothek implementiert oder als Assembleranweisungen in den Zwischencode eingebunden werden, denn ein INPUT Knoten ist sehr speziell auf eine Eingabe von Konsole ausgerichtet. Eingaben können aber beispielsweise auch über grafische Benutzerschnittstellen erfolgen. Aus Gründen der Einfachheit und des akademischen Charakters des Compilers wurde aber INPUT in den Zwischencode aufgenommen.
OUTPUT(Expression exp)Ausgabe des Wertes exp auf der Konsole. Es gelten analog die selben Anmerkungen wie beim INPUT.

Alle Elemente des Zwischencodes sind als Knoten implementiert, es wird also der Syntaxbaum in einen Zwischencodebaum transformiert.

Variablenrepräsentation

Wichtig für einen späteren korrekten Programmablauf ist, dass Variablen des Quellprogramms korrekt auf Speicherzellen abgebildet werden. Dabei kann man nicht einfach den Namen der Variablen als Unterscheidungsmerkmal heranziehen, denn Variablen können durchaus den selben Namen haben, wenn sie in unterschiedlichen Gültigkeitsbereichen liegen. Beim rekursiven Aufruf einer Prozedur müssen jedesmal neue Adressen für die deklarierten Variablen vergeben werden, denn jeder Aufruf öffnet einen neuen Gültigkeitsbereich. Eine Möglichkeit, dies umzusetzen sei im Folgenden erläutert. Sie wurde dieser Powerpoint-Präsentation entnommen.

Folgende Grafik zeigt einen Ausschnitt des Hauptspeichers:



Aktivierungssegmente
Abbildung 4.1: Aktivierungssegmente

Die dick eingerahmten Kästchen sind die sogenannten Aktivierungssegmente. Sie enthalten drei reservierte Felder und eine beliebige Anzahl von Variablen. Sie werden in einer Kellerstruktur abgelegt. Auf dem Bild sind drei Aktivierungssgemente zu sehen, der Keller wächst nach unten. Für die Programmausführung ist grundsätzlich das unterste Aktivierungssegment von Bedeutung. In ihm sind die Variablen enthalten, die lokal im gerade abzuarbeitenden Gültigkeitsbereich deklariert wurden. Der Frame-Pointer zeigt immer auf das unterste Segment, der Stack-Pointer zeigt immer auf das Ende des untersten Segmentes. Wird ein neuer Gültigkeitsbereich betreten, z.B. durch Prozeduraufruf, muss ein neues Segment in den Keller gelegt werden. Der Stack-Pointer ist dabei die Anfangsadresse dieses neuen Segmentes. Nach Einrichtung des Segmentes nimmt der Frame-Pointer den Wert des Stack-Pointers an und der Stack-Pointer rückt wieder ans Ende.

Statische Links

Wenn man auf Variablen zugreifen möchte, die in einem übergeordneten Gültigkeitsbereich definiert sind, so muss man auf das entsprechende Aktivierungssegment zugreifen. Dieses erreicht man, indem man dem statischen Link folgt. Es lässt sich feststellen, dass der statische Link zum nächstgelegenen Aktivierungssegment zeigen muss, welches eine Hierarchiestufe über aktuellen neuen Segment steht. Das klingt verwirrend, ist es auch. Darum sei hier ein Beispiel gegeben, welches sich an Abbildung 4.1 orientiert:

VAR i,j;
PROCEDURE p1;
    VAR i, k;
    BEGIN
        i := 1;
        j := 2;
        k := 3;
        CALL p1;
    END;

BEGIN
    CALL p1;
END.
                

Das oberste Segment gehört dem Hauptprogramm. Als Variablen werden i und j eingetragen, der Variablenbereich enthält also Platz für zwei Werte. Beim Aufruf der Prozedur p1 im Hauptprogramm wird das neue Segment aufgebaut. Es ist 5 Felder groß: die drei reservierten Felder und zwei Felder für die Variablen i und k. Der statische Link zeigt auf das erste Segment, weil dies das "umgebende" ist. Bei der Zuweisung von 2 an j wird dem statischen Link gefolgt und dann der Wert 2 an die Stelle für die zweite Variable geschrieben (weil j als zweite Variable definiert wurde). Nach der Zuweisung k := 3; wird aus p1 erneut p1 aufgerufen. Ein neues Aktivierungssegment mit neuen Variablen wird angelegt, denn Zuweisungen an i und k dürfen keinen Einfluss auf die Variablen der aufrufenden "Instanz" von p1 haben. Nun darf aber der statische Link nicht einfach auf das aufrufende Segment zeigen, denn dann gäbe es einen Link vom neuen p1 auf das alte p1, was ein Fehler wäre, wenn man diesem Link folgte, um auf das globale j zuzugreifen. Deshalb zeigt der statische Link des dritten Segments auf die gleiche Stelle wie der des zweiten Segments, in diesem Fall nach ganz oben.

Zur weiteren Veranschaulichung sei gezeigt, wie der Zugriff auf Variablen konkret im Zwischencode (und analog im Maschinencode) dargestellt wird:

MEM(+(TEMP(Temp fp), CONST(3)))
MEM(+(MEM(+(TEMP(Temp fp), CONST(0)), CONST(3))))
                

In der ersten Zeile erfolgt ein Zugriff auf die erste Variable des aktuellen Segments an der Stelle Framepointer + 3. (die 3 ist der Offset der ersten Variable, direkt nach den 3 Spezialfeldern). Falls die Wortbreite des Zielsystems mehr als 1 Byte ist (normalerweise 4 Byte, bei 64 Bit Rechnern 8 Byte), muss die 3 durch eine 12 (Offset 3 * 4 Byte) bzw. 24 (Offset 3 * 8 Byte) ersetzt werden. Man erkennt, dass der Zwischencode nicht komplett maschinenunabhängig sein kann. Die Information über die Wortbreite kann aber über eine definierte Schnittstelle aus dem angeschlossenen Backend geholt werden, so dass die Kopplung Zwischencode - Backend zumindest ziemlich lose ist.

In der zweiten Zeile wird zunächst der Inhalt des Frame-Pointers mit dem Offset des statischen Links (0) addiert, um dessen Adresse zu ermitteln. Da das ganze von MEM umgeben ist, bedeutet dies, dass der Wert, der an dieser Stelle des Hauptspeichers steht, gelesen wird. Der Wert des statischen Links ist die Adresse des "umgebenden" Segments. Zu dieser Adresse wird 3 addiert (bzw. 12 oder 24), um die Adresse der ersten Variablen dieses Segments zu berechnen. Mit MEM wird wie oben der Wert dieser Variablen gelesen. Diese MEM Zugriffe lassen sich beliebig schachteln, je nachdem, wie viele Stufen man sich nach oben hangeln muss.

Nun, da geklärt ist, wohin die statischen Links zeigen müssen, und wie sie die Variablenrepäsentation beeinflussen, soll erläutert werden, wie sie zur Laufzeit eigentlich aufgebaut werden. Den statischen Link korrekt zu setzen, wenn ein neues Aktivierungssegment eingefügt wird, bedarf nämlich etwas Aufwands.

Wie bereits erwähnt, muss der statische Link zum nächstgelegenen Aktivierungssegment zeigen, welches eine Hierarchiestufe über dem neuen Segment steht. Dazu ist es nötig, die Hierarchiestufen eines jeden Güligkeitsbereichs zur Laufzeit in Erfahrung zu bringen, sowie herauszufinden, welche Hierarchiestufe zum aktuellen Aktivierungssegment gehört. Aus diesem Grund wird ein sogenanntes level Attribut eingeführt.

Beim Prozeduraufruf wird festgestellt, wie groß die Differenz der Level des neuen und des alten Aktivierungssegments ist. Ist sie -1, so wird der statische Link des neuen Aktivierungssegmentes auf die Anfangsadresse des aufrufenden Segmentes gesetzt, weil eine Prozedur eine direkte Unterprozedur aufgerufen hat. Ist die Differenz 0 oder größer, so wurde eine Prozedur der selben Hierarchiestufe oder einer höheren aufgerufen. Man folgt deshalb differenz + 1 statischen Links, auf das so erhaltene Segment muss der neue statische Link zeigen. Am folgendem Beispiel sei das noch einmal verdeutlicht. Bei Ausführung dieses Programmes:

PROCEDURE p1;
    PROCEDURE p11;
        BEGIN
            CALL p12;
        END;

    PROCEDURE p12;
        PROCEDURE p121;
            BEGIN
                CALL p2;
            END;

        BEGIN
            CALL p121;
        END;

    BEGIN
        CALL p11;
    END;

PROCEDURE p2;
    BEGIN
    END;


BEGIN
    CALL p1;
END.
                

wird folgender Datenstack samt statischer Links aufgebaut:



Setzen der statischen Links
Abbildung 4.2: Setzen der statischen Links

Dynamische Links und Rücksprungadressen

Dynamische Links zeigen immer auf das vorige Segment. Das ist notwendig, damit beim Verlassen eines Segments der Frame-Pointer zum Anfang des vorigen Segments zurückgeschoben werden kann. Die Rücksprungadresse sagt dem Programmzähler, an welcher Stelle im Codesegment die Programmausführung fortgesetzt werden soll, wenn das Segment verlassen wird. Im Zwischencode spielt dieses Feld keine Rolle.


Implementierung

Anpassen des Syntaxbaumes

Nachdem der Syntaxbaum aufgebaut und Symboltabelle gefüllt ist, könnte die eigentliche Übersetzung in Zwischencode beginnen. Das ließe sich erreichen, indem für jeden Knoten gefragt wird, was er darstellt (Binäroperation, Prozeduraufruf, While-Schleife, ...) und man dann die entsprechende Transformation durchführt. Dieses Verfahren ist allerdings wenig objektorientiert. Viel schöner wäre es, wenn jeder Knoten eine Methode hätte, die ihn transformiert. Die Baumknoten erben von einem Oberknoten, so dass immer nur die Transformiermethode der Kinder eines Knotens aufgerufen werden muss, um den gesamten Baum umzuwandeln, ohne dass es notwendig ist, zu wissen, welche Kindknoten tatsächlich vorliegen.

Dazu ist es aber nötig, bei der Generierung des Syntaxbaumes einzugreifen, denn grundsätzlich sind alle Knoten des Syntaxbaumes vom Typ antlr.CommonAST. Glücklicherweise kann man in der Parserdefinition die Klassen der zu erzeugenden Knoten festlegen. Diese Klassen müssen Unterklassen von CommonAST sein. Die endgültige Parserdefinition sieht wie folgt aus:

header {
    package frontend.parser;
    import frontend.symbol.*;
    import java.util.LinkedList;
}
class PL0Parser extends Parser;
options {
    defaultErrorHandler=false;
    buildAST = true;
}

tokens {
    EXPR;
    BLOCK;
    CONSTASSIGN;
    VAR;
    CONSTD;
    PROC;
    PROGRAM<AST=frontend.parsetree.ProcedureNode>;

    ASSIGNOP<AST=frontend.parsetree.AssignNode>;

    NUMBER<AST=frontend.parsetree.NumberNode>;
    IDENT<AST=frontend.parsetree.IdentNode>;

    PLUS<AST=frontend.parsetree.BinopNode>;
    MINUS<AST=frontend.parsetree.BinopNode>;
    TIMES<AST=frontend.parsetree.BinopNode>;
    OVER<AST=frontend.parsetree.BinopNode>;

    EQUALS<AST=frontend.parsetree.BinrelNode>;
    DIAMOND<AST=frontend.parsetree.BinrelNode>;
    LT<AST=frontend.parsetree.BinrelNode>;
    LEQ<AST=frontend.parsetree.BinrelNode>;
    GT<AST=frontend.parsetree.BinrelNode>;
    GEQ<AST=frontend.parsetree.BinrelNode>;

    QUESTION<AST=frontend.parsetree.InputNode>;
    EXCLAM<AST=frontend.parsetree.OutputNode>;
}

{
    SymbolTable symbolTable = SymbolTable.getSymbolTable();
    private boolean pendingMinus = false;
    private LinkedList exceptions = new LinkedList();

        ...

}

program:        block DOT! {#program = #([PROGRAM, "PROGRAM"], program); resolveUnknown();};

block:          (constdecl!)? (vardecl!)? (procdecl)* statement;

constdecl:      "CONST" constassign (COMMA constassign)* SEMICOLON;
constassign:    i:IDENT EQUALS n:NUMBER { checkRange(n); defineConst(i, n); };

vardecl:        "VAR" i1:IDENT {defineVar(i1);} (COMMA i2:IDENT {defineVar(i2);})* SEMICOLON;

procdecl:       "PROCEDURE"^<AST=frontend.parsetree.ProcedureNode>
                i:IDENT {defineProcedure(i);} SEMICOLON! block SEMICOLON! {popScope();};

statement:      (i:IDENT {checkForVariable(i);} ASSIGNOP^ expression
                | "CALL"^<AST=frontend.parsetree.CallNode> i2:IDENT {checkForProcedure(i2);}
                | QUESTION^ i3:IDENT {checkForVariable(i3);}
                | EXCLAM^ i4:IDENT {checkForValue(i4);}
                | "BEGIN"^<AST=frontend.parsetree.BeginNode>
                statement (SEMICOLON! statement)* "END"!
                | "IF"^<AST=frontend.parsetree.IfNode> condition "THEN"! statement
                | "WHILE"^<AST=frontend.parsetree.WhileNode> condition "DO"! statement)?;

condition:      "ODD"^<AST=frontend.parsetree.OddNode> expression
                | expression (EQUALS^ | DIAMOND^ | LT^ | LEQ^ | GT^ | GEQ^) expression;

expression:     (PLUS^<AST=frontend.parsetree.PlusNode>
                | MINUS^<AST=frontend.parsetree.MinusNode> {pendingMinus = true;})?
                term {pendingMinus = false;}((PLUS^ | MINUS^) term)*;

term:           factor ((TIMES^ | OVER^) factor)*;

factor:         i:IDENT {checkForValue(i);}
                | n:NUMBER {checkRange(n);}
                | LPAREN! expression RPAREN!;
            

Die Tokens-Sektion wurde erweitert, damit die Festlegung der Knotenart nicht innerhalb der eigentlichen Grammatik geschehen muss. Alles was sich aus bereits genannten Gründen (siehe Definition des Scanners) nicht als Token verwenden ließ (Schlüsselworte), wurde allerdings notgedrungen in der Grammatik ausgezeichnet. Man beachte, dass auch in der Regel expression die Knoten PLUS und MINUS ausgezeichnet wurden. Der Grund ist, dass PLUS und MINUS hier Vorzeichen sind und keine Binäroperationen.

Die komplette Parser- und Scannerdefinition zum Übersetzen steht zum Download bereit. Die Syntax für das Übersetzen lautet unter Windows

java -cp antlr.jar; antlr.Tool PL0_Grammar.g
            

Das Resultat sind ein Scanner und ein Parser in Java. Sie liegen im Quellcode vor.

Aufbau der neuen Syntaxbaumknoten

Alle oben verwendeten Knoten sind Unterklassen von PL0Node, welche wiederum von CommonAST aus dem ANTLR-Paket abgeleitet ist. PL0Node definiert die Methoden convertStatement(), convertExpression() und convertConditional(Label t, Label f). Diese Methoden werden in den Unterklassen nach Bedarf implementiert. Im Folgenden seien einige wichtige Implementierungen näher erläutert, die Implementation der restlichen Knoten ist ähnlich.

AssignNode

Variablenzuweisungen resultieren in einem Zusweisungsknoten, dem sogenannten AssignNode. Eine Zuweisung ist eine Anweisung im Quelltext, also ein Statement. Deshalb implementiert AssignNode die Methode convertStatement:

public Statement convertStatement() {
    PL0Node dest = (PL0Node)getFirstChild();
    PL0Node src = (PL0Node)dest.getNextSibling();
    return new MOVE(dest.convertExpression(), src.convertExpression());
};
                

Ein Zuweisungsknoten hat immer zwei Kinder: das Zuweisungsziel und den Zuweisungswert. Diese beiden Kinder werden mit getFirstChild() und getNextSibling() (definiert in BaseAST, der Oberklasse von CommonAST) geholt und abgespeichert. Dabei sind sowohl dest als auch src Werte und damit vom Typ Expression (dest ist eine Adresse, src ist der zuzuweisende Wert). Für beide wird daher die Methode convertExpression() aufgerufen, die Ergebnisse stehen innerhalb eines MOVE Knotens im resultierenden Zwischencodebaum: der Wert src wird im Speicher auf die Adresse dest geschoben. Wie man erkennt, wird der Zwischencodebaum rekursiv absteigend erzeugt.

IfNode

Eine bedingte Anweisung wird im Syntaxbaum als sogenannter IfNode dargestellt. Dieser hat als Kinder einen Bedingungsknoten (BinrelNode bei binären Vergleichen oder OddNode bei Prüfung auf Ungeradheit einer Zahl) und einen Anweisungsknoten. Bei der Übersetzung wird zuerst geprüft, ob die Bedingung erfüllt ist. Falls ja, wird zur Anweisung gesprungen und diese ausgeführt, andernfalls wird an eine Stelle nach dieser Anweisung gesprungen. Eine bedingte Anweisung der Form

IF a < b THEN <doSomething>;
                

wird beispielsweise übersetzt zu

CJUMP(<, a, b, true, false)
LABEL(true)
<doSomething>
JUMP(false);
LABEL(false);
                

Man beachte, dass dies eine vereinfachte Zwischencodedarstellung ist. In echtem Zwischencode wären beispielsweise die Variablen a und b bereits in Anweisungen zur Adressberechnung umgewandelt. Der unbedingte Sprung zum Label false kann theoretisch wegfallen, dies wird aber der Optimierungsstufe überlassen. Wenn der Sprung hier noch erhalten bleibt, lässt sich bei der Optimierung eventuell durch Neuordnung von Codeblöcken besserer Code generieren.

Eine kleine Schwierigkeit ergibt sich, weil bei der Umwandlung des Bedingungsknotens in Zwischencode die Labels, die in der CJUMP-Anweisung stehen, die gleichen sein müssen, die im IfNode als Sprungziele vereinbart sind. Schwierigkeit deshalb, weil jedes erzeugte Label einen neuen Namen bekommt, so dass im IfNode und im Bedingungsknoten nicht die gleichen Labels erzeugt werden können. Die Lösung ist, die im IfNode erzeugten Labels der convertConditional() Methode des Bedingungsknotens als Parameter zu übergeben. Der endgültige Code sieht so aus:

public Statement convertStatement() {
    Label t = new Label();                      //erzeugt Label mit eindeutiger ID
    Label f = new Label();                      //erzeugt ein weiteres Label mit eindeutiger ID
    PL0Node condNode = (PL0Node)getFirstChild();
    PL0Node execNode = (PL0Node)condNode.getNextSibling();
    Statement condStm = condNode.convertConditional(t, f);      //t und f werden für die
                                                                //Konvertierung des Bedingungs-
                                                                //knotens übergeben
    Statement execStm = execNode.convertStatement();
    return new SEQ(condStm, new SEQ(new LABEL(t),
            new SEQ(execStm, new SEQ(new JUMP(f),
            new LABEL(f)))));
};
                

ProcedureNode

Der Prozedurknoten ist etwas Besonderes. Er hat keine der createXXX() Methoden von PL0Node implementiert, da niemals andere Knoten die Umwandlung eines ProcedureNode anstoßen können. Stattdessen gibt es die Methoden createIntermediateCode() und createAll(). Dies sieht folgendermaßen aus:

public CodeContainer createIntermediateCode() {
    cc = new CodeContainer();
    PL0Node node = (PL0Node)getFirstChild();
    createAll(node, 0);
    return cc;
}

private void createAll(PL0Node node, int number) {
    if (node instanceof IdentNode) {
        node = (PL0Node)node.getNextSibling();
    }
    while (node instanceof ProcedureNode) {
        String procname = node.getFirstChild().getText();
        ScopeDef sd = (ScopeDef)table.lookup(procname);
        table.pushScope(sd);
        createAll((PL0Node)node.getFirstChild(), sd.getScopeNumber());
        table.popScope();
        node = (PL0Node)node.getNextSibling();
    }
    if (node != null) {
        cc.addCode(node.convertStatement(), number);
    }
}
                

Man beachte, dass das Hauptprogramm auch vom Typ ProcedureNode ist, da es fast identisch zu einer Prozedur aufgebaut ist. Der einzige Unterschied liegt darin, dass das Hauptprogramm keinen Prozedurnamen hat.

Die Umwandlung des Syntaxbaumes in den Zwischencodebaum beginnt mit dem Aufruf von createIntermediateCode(). Es wird ein CodeContainer erzeugt. Dieser CodeContainer ist im Wesentlichen eine Hashmap. In dieser Hashmap werden als Werte die erzeugten Zwischencodebäume gespeichert; jeder Gültigkeitsbereich (also jede Prozedur und das Hauptprogramm) werden zu einem Eintrag im CodeContainer. Der zugehörige Schlüssel wird die sogenannte scopeNumber (siehe Numerieren der Prozeduren).

In der Methode createAll(PL0Node node, int number) werden alle Unterprozeduren der aktuellen Prozedur bearbeitet. Nachdem dies geschehen ist, wird der Anweisungsblock der Prozedur in Zwischencode transformiert und im CodeContainer unter der scopeNumber gespeichert.

Detaillierte Erklärung:
Der Knoten node, der createAll() übergeben wird, ist immer ein ProcedureNode. Ein ProcedureNode hat als erstes Kind den Prozedurnamen, danach beliebig viele weitere ProcedureNodes und einen Anweisungsknoten (meist ein BeginNode). Das Hauptprogramm hat als erstes Kind keinen Prozedurnamen. In der If-Anweisung wird getestet, ob das erste Kind des zu bearbeitenden Knotens dieser Prozedurname ist. Falls ja, wird er übersprungen, er ist unwichtig. In einem Durchlauf der folgenden While-Schleife wird jedesmal eine weitere ProcedureDef bearbeitet. Zuerst wird der Name der Prozedurdefinition ermittelt und mit dessen Hilfe die korrekte ScopeDef aus der Symboltabelle geholt. Diese ScopeDef wird auf den ScopeStack gelegt. Als nächstes wird die createAll() Methode für diese in der aktuellen Prozedur enthaltene Prozedur ausgeführt. Nachdem alle Unterprozeduren bearbeitet wurden, wird die ScopeDef wieder vom Stack genommen und der nächste Knoten wird in node gespeichert.

Wenn alle ProcedureNodes abgearbeitet sind und als nächstes ein Anweisungsknoten im Baum steht, wird dieser in Zwischencode umgewandelt und unter seiner scopeNumber im CodeContainer gespeichert. Auf diese Art werden alle Prozeduren nacheinander transformiert und abgespeichert.

Die Benutzung des scopeStack verdient vielleicht besondere Aufmerksamkeit. Er wird benutzt, damit beim Auftauchen eines Bezeichners im Syntaxbaum festgestellt werden kann, auf welchen Gültigkeitsbereich sich dieser bezieht. Zur Erinnerung: Ein Bezeichner kann auch umgebende Gültigkeitsbereiche referenzieren. Mit dem Stack wird sichergestellt, dass das Auflösen des Bezeichnernamens in der korrekten Variable, Konstante oder Prozedur resultiert.

IdentNode

Die eben angesprochene Nutzung des scopeStack soll nun am Beispiel der IdentNode konkretisiert werden. In Abschnitt Variablenrepräsentation wurde bereits ausführlich auf die Variablenrepräsentation im Zwischencode eingegangen. Daraus ergibt sich folgender Transformationscode:

public Expression convertExpression() {
    int[] pos = SymbolTable.getSymbolTable().lookupPos(getText());
    Expression ident = new TEMP(frame.fp());
    for (int i = 0; i < pos[0]; i++) {
        ident = new MEM(new BINOP(BINOP.PLUS, ident, new CONST(frame.STATIC_LINK  * frame.wordSize())));
    }
    ident = new MEM(new BINOP(BINOP.PLUS, ident, new CONST((pos[1] + frame.getMinOffset()) * frame.wordSize())));
    return ident;
}
                

Die Methode lookupPos() der Symboltabelle liefert ein zweispaltiges Array zurück. An Stelle 0 wird mit Hilfe des scopeStack gespeichert, wieviele Gültigkeitsbereiche traversiert werden mussten, um den Bezeichner zu finden. Das entspricht der Anzahl der statischen Links, die zu verfolgen sind. In der For-Schleife wird dementsprechend für jeden Gültigkeitsbereich rekursiv eine Static-Link-Verfolgung aufgebaut. Die Stelle 1 des Arrays pos liefert die Position innerhalb des Gültigkeitsbereichs, an der der Bezeichner definiert wurde. Daraus wird der Offset berechnet, über den auf die Variable innerhalb des Aktivierungssegments zugegriffen werden kann. Zur Erinnerung: vor den Konstanten und Variablen gibt es reservierte Felder, den statischen und den dynamischen Link sowie die Rücksprungadresse.

CallNode

Zum Abschluss sei der Prozeduraufrufknoten vorgestellt:

public Statement convertStatement() {
    PL0Node parameter = (PL0Node)getFirstChild();
    ScopeDef sd = (ScopeDef)SymbolTable.getSymbolTable().lookup(parameter.getText());
    int number = sd.getScopeNumber();
    return new EXP(new CALL(new CONST(number)));
};
                

Es wird in der Symboltabelle nachgesehen, auf welche Prozedur sich der Prozedurname bezieht (Es können mehrere Prozeduren in verschiedenen Gültigkeitsbereichen den gleichen Namen haben). Wurde diese Prozedur gefunden, wird der Zwischencodebaum aufgebaut. Der Parameter des CALL ist die scopeNumber der referenzierten Prozedur. Der CALL Knoten ist aber ein Ausdruck und keine Anweisung (siehe Elemente des Zwischencodes). Er wird deshalb in einen EXP Knoten eingebettet.


previous Die SymboltabelleLinearisierung des Zwischencodes next


Valid HTML 4.01!