Compiler selbstgeschneidert

Teil 2: Der Scanner

Helmut Richter

Bescheidenheit ist nicht gerade eine Zier dieser Serie. Immerhin wird hier ein Compiler für die Programmiersprache DOIT beschrieben, und zwar in allen Einzelheiten. Dadurch kann man nicht nur die Arbeitsweise des Compilers verstehen, sondern ihn auch in weiten Grenzen an eigene Wünsche anpassen. Sie kennen DOIT nicht? Dann haben Sie den ersten Teil dieser SErie nicht gelesen. Diesmal geht es bereits um den Scanner des DOIT-Compilers, der als Turbo-Pascal-Programm auch ohne die übrigen Compiler-Teile funktioniert und mit dem DOIT-Programme analysiert werden können — und zwar lexikalisch.

Der DOIT-Compiler arbeitet nach dem Prinzip der syntaxgesteuerten Einphasenübersetzung, was nichts anderes bedeutet, als daß der Übersetzungsvorgang in einem Rutsch erfolgt und vom Parser gesteuert wird. Aber das stand ja bereits alles im ersten Teil. Jedenfalls kommt dieses Konzept sehr dem Ziel entgegen, den Scanner als weitgehend selbstständiges Unterprogramm zu schreiben, das auch 'stand alone' lauffähig ist und später einfach mit dem Parser kombiniert werden kann.

Der Scanner mit Namen GETSYM besitzt zudem in der hier beschriebenen Fassung zusätzliche Prozeduren, um ihn mit Programmen füttern und testen zu können. Sie können entweder als Datei von der Diskette eingelesen werden oder vom Terminal aus zeilenweise eingegeben werden. In beiden Fällen gibt der 'stand-alone-Scanner' alle erkannten Informationen, die er später an den Parser weiterreicht, auf dem Schurm aus. Diese Funktion ist natürlich auch zum Austesten des kompletten Compilers nützlich und läßt aich danach leicht entfernen. Doch nun zum Scanner selbst.

Seine Aufgabe ist es, den Quelltext in einen Strom von Grundsymbolen (Token) der Sprache DOIT zu zerlegen und die erkannten Token an den Parser hinaufzureichen. Dazu liest er den Quelltext sequentiell, also Zeichen für Zeichen, und verzweigt entsprechend den angetroffenen Zeichen. Es gibt Grundsymnole für Identifier, Integer-, Hex- und Character-Konstanten, Operatoren und Sonderzeichen. Die Menge der Grundsymbole wird im Aufzählungstyp symbol festgelegt, und das erkannte Token speichert die Variable SYM vom Typ symbol.

Wurde ein Identifier erkannt, muß zusätzlich geprüft werden, ob es sich um ein reserviertes Wort handelt (BEGIN, DO, OD, EXIT,...) oder ob der Identifier eine Konstante ist. Im ersten Fall gibt GETSYM das entsprechende Symbol zurück (BEGINsy, DOsy, ODsy,...). Es ist kein spezielles Symbol für Prozedurnamen vorgesehen. Somit kann erst der Parser anhand der Symboltabelle entscheiden, ob ein Identifier vielleicht einen Prozeduraufruf darstellt. Ebenso gibt es bei den Operatoren verschiedene Fälle, (:=. >, <,...), die gesondert untersucht werden (becomes, gtr, lss,...).

Symbole mit einer Länge von einem Zeichen können sofort identifiziert werden, bei mehrstelligen Symbolen wird erst einmal in Unterprogramme verzweigt. Findet GETSYM am Anfang eines Symbol-Holzyklus einen Buchstaben (etwa 'A'), so kann das Token nur ein Identifier sein. Ist das erste neue Symbol ein '<'. so kann es ein Kleinerzeichen (lss), ein Kleinergleichzeichen (leq) oder ein Ungleichheitszeichen (neq) sein. Der Scanner kann auch bereits eine erste Fehlererkennung (-behandlung) vornehmen, beispielsweise die Erkennung einer falschen Characterkonstanten oder einer zu großen Zahl. Falls gewünscht kann er außerdem das Erzeugen einer List-Datei übernehmen.

Im Detail

Das Scanner-Modul besteht aus drei Teilen, die wiederum von folgenden Unterprogrammen gebildet werden:

1. FUELLEPUFFER, HOLEZEICHEN, GETCH.
Diese Routinen lesen den Quelltext und stellen das nächste gültige Zeichen bereit.

2. GETIDENTIFIER, GETNUMBER, HEXAWERTE, KOMMENTAR.
Diese Prozeduren identifizieren und bearbeiten Symbole, die länger als ein Zeichen sind, wie zum Beispiel Identifier, Konstanten und Kommentare.

3. ZEIGESATZ, SYMNAME, PROMPT, DISPLAY.
Sie können später aus dem Scanner entfernt werden, um die Compiler-Größe zu reduzieren, da sie lediglich Testoptionen wie Ausdrucke und Dialoge bereitstellen.


   symbol = (
(*    Symbole fuer Sonderzeichen, Operatoren und *)
(*    Ausdruecke:                                *)
      null,       odd,        times,     divsy,
      modsy,      plus,       minus,
      eql,        neq,        lss,       leq,
      gtr,        geq,
      comma,      rparent,    THENsy,    lparent,
      becomes,
(*    Symbole fuer die Konstanten:               *)
      CharCon,    IntCon,     HexCon,
(*    Symbole, die am Ende eines Statements oder *)
(*    einer Deklaration stehen können:           *)
      ELSEsy,     ENDsy,      FIsy,
      ODsy,       INTsy,      CHARsy,    semicolon,
      period,     eofsy,
(*    Symbole, die am Anfang eines Statements    *)
(*    oder einer Deklaration stehen können:      *)
      MODULEsy,   Identifier, BEGINsy,   CONSTsy,
      VARsy,      PROCsy,     DOsy,      IFsy,
      EXITsy,     USEsy,      READsy,    WRITEsy)
Dies ist die Menge der Grundsymbole der Sprache DOIT, die vom Scanner erkannt werden müssen. Die Reihenfolge, in der sie in der Definition als Aufzählungstyps symbol aufgeführt sind, erleichtert die Fehlerbehandlung.
Besondere Sorgfalt ist dem Bindeglied zwischen dem Quelltext (dem Programm) und der eigentlichen Symbolerkennung zu widmen, der Prozedur GETCH. Sie liefert das nächste Zeichen des zu analysierenden Textes mit Hilfe von HOLEZEICHEN in der Variablen CH. Das Flag LESEN hat dabei die Aufgabe, das Überlesen von Zeichen zu verhindern. Bei der Bearbeitung von Symbolen, die länger als ein Zeichen sind, kann nämlich der Fall auftreten, daß CH bereits das nächste Zeichen enthält und HOLEZEICHEN deswegen nicht aufgerufen werden darf, wie folgendes Beispiel zeigt.

Bei der Zuweisung 'XB1 := 6;' erkennt die Prozedur SCANNER den Identifier XB1 am Anfangszeichen 'X' und ruft die Prozedur GETIDENTIFIER auf. Sie addiert nun so lange die folgenden Zeichen zum Namen des Bezeichners, bis CH die Menge der in Identifier erlaubten Zeichen (IdCharacter) verläßt. CH=':' zeigt also das Ende des Namens and und SYM=identifier wird gemeldet. Beim nächsten Aufruf verzweigt der Scanner zunächst wieder zu GETCH. Würde nun tatsächlich ein weiteres Zeichen gelesen, so enthielte CH ein '=' statt des ':', und statt der Zuweisung würde ein Vergleich erkannt.

Um nicht ständig zeichenweise auf den Quelltext zugreifen zu müssen, benutzt GETCH einen Zeichenpuffer mit Namen SATZ, der eine ganze Programmzeile enthält. Es könnten auch zwei oder zehn Zeilen sein, reine Willkür. Jedesmal, wenn GETCH ein Zeichen dem Puffer entnimmt, wird der Zählindex ICHAR des Puffers erhöht. Ist das Pufferende erreicht, erkennbar durch ICHAR = SATZENDE (=LENGTH(Satz)), lädt FUELLEPUFFER einen neuen Satz.

Die Prüfung der Gültigkeit des neuen Zeichens in HOLEZEICHEN geschieht mit
(ORD(CH) > 31) OR
(CH = CPMeof);
Es werden also alle nicht druckbaren Steuerzeichen unterhalb des Blanks, das den Dezimalwert 32 hat, überlesen, mit Ausnahme des Zeichens für das Textende. Zu den überlesenen Zeichen gehören also auch LF und CR.

Groß und klein

Als letzte Aktion vewandelt GETCH alle Buchstaben mittels der Standardprozedur UPCase in Großbuchstaben. Somit wird es gleichgültig, ob Identifier groß oder klein geschrieben werden. Wünscht man eine Unterscheidung zwischen Groß- und Kleinschrift (etwa wie in Modula-2 oder ELAN), so braucht nir dieses eine Konvertierungsstament weggelassen zu werden. Dies ist einerseits eine syntaktische Einschränkung: reservierte Wörter können nur erkannt werden, wenn sie groß geschrieben sind. Andererseits entsteht auch eine syntaktische Erweiterung. 'Begin' ist kein reserviertes Wort mehr, sondern ein Identifier. Es ist ein Charakteristikum solcher Entwurfsentscheidungen, daß einfache Programmänderungen weitreichende syntaktische Auswirkungen nach sich ziehen können. Es ist jedem selbst überlassen, wie er sich entscheidet. Wählt man die Kleinschreibung, sollte man bereits beim Schreiben von Testprogrammen daran denken.

Wenn der Quelltext als fortlaufender Zeichenstrom verstanden wird, dürfen die Zeilenendezeichen CR und LF nicht als Trennzeichen beachtet werden. Das kann aber bei folgendem Programmteil zu fehlerhaftem Verhalten führen:
BEGIN <CR> <LF>
X:=X+6;<CR> <LF>
Der Scanner liest 'BEGIN', was für ihn zunächst einmal einen Identifier darstellt, überliest das Zeilenende und findet ein weiteres Zeichen. Somit erkennt er den Identifier BEGINX statt des reservierten Wortes BEGIN. Das unsichtbare Trennzeichen 'Zeilenende' muß also erkannt werden.

Eine Lösung wäre die Verwendung einer logischen Variablen, beispielsweise EOLN2, die bei jedem Aufruf von FUELLEPUFFER auf true gesetzt wird und so das erreichte Zeilenende anzeigt. Die hier benutzte Lösung ist, in FUELLEPUFFER an die gelesene Zeile (SATZ) ein Blank anzuhängen. Die damit verbundene semantische Einschränkung, daß kein Symbol über das Zeilenende hinausgehen darf, stört in der Praxis wegen der Zeilenlänge von 254 Zeichen nicht und befreit uns von diesem lästigen Problem.

In der Case-Anweisung der Prozedur Scanner ist erkennbar, daß füe alle Symbole, die länger als ein Zeichen sind, eigene Unterprogramme vorhanden sind. Sie werden nun der Reihe nach vorgestellt.


PROCEDURE Scanner;
   ... lokale Deklarationen ...
BEGIN
  REPEAT
    sym := Null;
    Nochmal := FALSE;
    GetCh;
(* an dieser Stelle alle Blanks ueberlesen *)
    WHILE ch = ' ' DO getch;
    CASE ch OF
(* Symbole, die laenger als ein Byte sein koennen: *)
      'A'..'Z' : GetIdentifier;
      '0'..'9' : getnumber;
      ''''     : CharacterKonstante;
      '$'      : HexaWerte;
      '('      : KlammerAuf;
      '>'      : Groesser;
      '<'      : Kleiner;
      ':'      : DoppelPunkt;
(* Symbole mit einem Byte sein Laenge: *)
      '!'      : sym := WRITEsy;
      '?'      : sym := READsy;
      ')'      : sym := rparent;
      ';'      : sym := semicolon;
      '.'      : sym := period;
      ','      : sym := comma;
      '='      : sym := eql;
      '/'      : sym := divsy;
      '#'      : sym := modsy;
      '@'      : sym := odd;
      '*'      : sym := times;
      '+'      : sym := plus;
      '-'      : sym := minus;
      #$1A     : sym := eofsy;
      ELSE       SonderZeichen;
    END (* Case *);
  UNTIL NOT Nochmal;
END (* Scanner *);
Der Scanner besteht fast ausschließlich aus einem Case-Statement, in dem entsprechend zum gelesenen Zeichen zu bestimmten Unterprogrammen verzweigt wird. Nur bei Symbolen, die nur aus einem Zeichen bestehen, kann SYM sofort belegt werden.

Blick von oben in den Scanner

Ist das erste Zeichen eines neuen Symbols ein Buchstabe, so überprüft GETIDENTIFIER ob es sich um ein reserviertes Wort (Schlüsselwort) oder einen Identifier handelt. Dazu wird die Schlüsselworttabelle (SWT) des Scanners durchsucht und im Falle des Erfolges die Symbolvariable SYM durch eine explizite Typzuweisung (kein Standard-Pascal) belegt. Dazu enthält die Schlüsselworttabelle in SWT[k].NR die Position des Symbols im Aufzählungstyp symbol. Ist die Suche in der Schlüsselworttabelle erfolglos, handelt es sich um einen Identifier.


  SwT[ 1].s := 'BEGIN';  SwT[ 1].NR := 32;
  SwT[ 2].s := 'CHAR';   SwT[ 2].NR := 26;
  SwT[ 3].s := 'CONST';  SwT[ 3].NR := 33;
  SwT[ 4].s := 'DO';     SwT[ 4].NR := 36;
  SwT[ 5].s := 'ELSE';   SwT[ 5].NR := 21;
  ... usw ... (siehe INITSCANNER)


TYPE symbol = ( ...
       CHARsy, semicolon, period, eofsy,
       MODULEsy, Identifier, BEGINsy, CONSTsy,
       VARsy, PROCsy, DOsy, IFsy, EXITsy, ...);
Die Schlüsselworttabelle enthält in der Komponente SWT[k].NR die Position des Schlüsselwortes im Aufzählungstyp symbol.
Ist das erste Zeichen eines neuen Symbols eine Ziffer, überprüft GETNUMBER, ob es sich um eine maximal fünfstellige Zahl handelt und ob sie in eine gültige Integerzahl umgewandelt werden kann.

Die Prozedur HexaWert und HexWert wandeln eine Ein-Byte-Hexzahl in einen Character um, belegen SYM mit HexCon und behandeln eventuelle Fehler. Sie geben den errechneten Wert der Konstanten in CharVal zurück.

Liegt eine '(' vor, kann es sich um einen Kommentar handeln, der ignoriert wird, oder um eine 'Klammer auf' handeln. Dies tritt zunächst nur in Rechenausdrücken (Expressions) auf. Untersucht wird dies in KLAMMERAUF. Ähnliche Unterscheidungen bei arithmetischen Operatoren geschehen in den Unterprogrammen GROESSER, KLEINER und GLEICH.

Wählt man für die Zuweisungen ':=' statt '=', so läßt sich stets schon auf Scanner-Ebene eindeutig entscheiden, ob Zuweisungen oder Vergleiche gemeint sind. Im Gegensatz zu Pascal und DOIT ist das in BASIC und FORTRAN nicht möglich. Das Unterprogramm DOPPELPUNKT erfüllt diese Aufgabe.

Die Bearbeitung all dieser 'Problemchen' in eigenen Prozeduren ist vielleicht übertrieben, erhöht jedoch die Lesbarkeit des Scanners.

Conditional Compiling

Mit der Bearbeitung aller im Scanner nicht ausdrücklich geprüfter Zeichen ('null'-Symbole) in der Prozedur SONDERZEICHEN kann man sich auf einfache Weise eine Hintertür aufhalten. Bis auf ':' landen hier alle Nullsymbole. Somit besteht die Möglichkeit, Compiler-Optionen nach Art der Pseudo-Ops bei Assemblern zu realisieren:

&G- : keinen Code generieren
&L- : Listing aus
&L+ : Listing ein
&IF <Konstante:Character>
... Codegenerierung abhängig von der logischen Konstanten
...
&ENDIF
und so weiter...

Die letzte Anweisung erlaubt es, im Quelltext Testoptionen durch Belegung einer einzigen Konstanten zu aktivieren oder auszuschalten. Dadurch wird der Objektcode des ferigen Programms nicht mit Testhilfen belastet. Andererseits ist es möglich, mit einer einzigen neuen Übersetzung alle zusätzlichen Hilfen zur Verfügung zu haben. Conditional Compiling läßt sich leicht in den Scanner einfügen, kann aber erst funktionieren, wenn der Parser schon die Symboltabelle aufgebaut hat.


[Listing]
Compiler nach Maß — falls Conditional Compiling, ähnlich wie bei größeren Assemblern, gewünscht ist, braucht nur diese Prozedur an Stelle des Unterprogramms SONDERZEICHEN im Listing des Scanners eingesetzt zu werden.

Die Prozeduren SYMNAME, PROMPT, DISPLAY, BYEBYE und ANALYSE dienen nur dem Zweck, den Vorgang der Analyse in der Testphase des Moduls SCANNER haarklein verfolgen zu können. Sie fliegen später genau wie das Hauptprogramm aus dem Compiler heraus.

Hat man das Testprogramm nach fingerverschleißendem Tippen auf Diskette, füge man noch das kleine Testprogramm dazu, lehne sich zurück und lasse den Scanner das DOIT-Programm im 'Single-Step' (Leertaste drücken) in seine Grundsymbole zerpflücken.


module test;
(* Testmodul für DOIT *)
VAR x  : INT;
VAR ch : CHAR;
(* Kleiner Operatorentest *)
       /#*+-=<>=<=>=
(* ganz schön haarig, nicht? *)
BEGIN
  IF i <> 6 THEN i = 6;
  x := y + 6;
  ? x;
  DO
    i := i + 1;
    IF i >= 10 THEN EXIT; FI;
  OD;
  ! x;
(* Kann er auch
   zwei-Zeilen-Kommentare? *)
END.
Diese kleine DOIT-Programm kann zum Testen des Scanners benutzt werden.
[Listing]
Der Scanner ist in dieser Version 'stand alone' lauffähig und kann mit beliebigen DOIT-Programmen gefüttert werden. Alle erkannten Informationen, die er später an den Prser weiterreicht, werden auf dem Bildschirm ausgegeben.
[1. Teil] [Inhalt] [3. Teil]

Bearbeitet von Werner Cirsovius
April 2011
© Heise Verlag