So funktioniert der Turbo-Basic-Compiler

(2 parte)
from: http://ch.twi.tudelft.nl/~sidney/atari/turbo-basic/compiler-internals.html

Hier nochmals der P-Code:

Zahl 41 02 00 00 00 00
fp->intpeek int->fp push
Zahl 40 01 00 00 00 00
movepull add fp-> int

Im ersten Schritt faßt der Optimierer die Zahl »41 02 00 00 00 00 fp->int« zusammen, indem er den Wert in die Integer-Darstellung umwandelt. Daraus entsteht:

Izahl 200 peek int->fp push
Zahl 40 01 00 00 00 00
movepull add fp->int

Anmerkung: Es gibt zwei Fp-Accus, $D4 bis $D9 und $EO bis $E5. Integerzahlen stehen dabei im Prozessorregister A und Y. Dabei enthält Y das höherwertige und A das niederwertige Byte.

Der Optimierer faßt jetzt »Izahl 200 peek« zu »lpeek 200« zusammen. Das erste Beispiel würde »LDA # <200:LDY # >200:JSR PEEK« als Code erzeugen und das zweite Beispiel »LDA 200:LDY #0«. Jetzt haben wir also:

Ipeek 200 int->fp push
Zahl 40 01 00 00 00 00
movepull add fp-> int

Jetzt wird »push Zahl … movepull add« zu »add # …« zusammengefaßt. Daraus ergibt sich:

Ipeek 200 int->fp add
# 40 01 00 00 00 00 fp->int

Als nächstes bemerkt der Optimierer die Folge »int->fp Add # … fp->int« und versucht diese Befehlsfolge zu vereinfachen. So spart man sich später eine Umwandlung. Da die Zahl kleiner als 256 ist, gelingt dies auch.

Jetzt hat der Optimierer seine Arbeit geleistet. Anschließend wird richtiger Maschinencode erzeugt.

Aus »lpeek 200« entsteht »LDA 200:LDY #0«, »Iadd # 1« wird zu »LDX #1:JSR IADD« (dieser Unterprogrammaufruf ist nötig, da sonst auch ein Overflow stattfinden könnte; Integerwert größer als 65535!)

Das war auch schon alles. An den Befehl COLOR wird noch »STA $C8« angehängt. Insgesamt ergibt sich also:

        LDA 200
        LDY #0
        LDX #1
        JSR IADD
        STA $C8

Ohne Optimierung würde wesentlich mehr Programmcode erzeugt werden, der noch dazu viel langsamer ist:

        LDA #<Z20O
        LDY #>Z20O
        JSR LDOYA
        JSR FPI?
        JSR PEEK
        JSR IFP
        JSR PUSH
        LDA #<Z1
        LDY #>Z1
        JSR MOVEPULL
        JSR ADD
        JSR FPI?
        STA $C8

Außerdem noch im Konstantenbereich:

Z200   .BYTE $41,$02,$00,$00,$00,$00
Z1     .BYTE $40,$01,$00,$00,$00,$00

Durch Optimierung läßt sich also viel Programmcode sparen. Gegenüber einem echten Maschinencode sieht das Ergebnis allerdings immer noch recht umfangreich aus. In Maschinensprache würde nämlich »INC $C8«, was nur 2 Byte benötigt, das gleiche bewirken. Hexadezimal $C8 entspricht dabei dem Dezimalwert 200. In unserem Beispiel codiert der Compiler den Basicbefehl COLOR 1 jedoch optimal, nämlich »LDA #1:STA $C8«.

Optimal compiliert

Allerdings kann der Compiler nur in den seltensten Fällen optimalen Code erzeugen. Schließlich erübrigt sich nur die Umwandlung von FP nach INT und umgekehrt und dies auch nur bei Verwendung von Konstanten. Bei Variablen ist diese Art der Optimierung nicht zulässig. In den meisten Fällen reicht schon die Einsparung einiger Bytes, weil zum Beispiel das Laden einer Variablen und die Umwandlung in einen statt zwei Integerwerte durch einen einzigen Unterprogrammaufruf erledigt wird.

Auch wird nicht alles, was möglich wäre, optimiert. Zum Beispiel berechnet sich »A=10/3« nicht im voraus, obwohl es durchaus sinnvoll wäre. Aber solche Optimierungen kann der Programmierer gegebenenfalls selbst vorwegnehmen. Allerdings sind solche Fälle viel zu selten, als daß sich dies hier lohnen würde. Je länger nämlich der Compiler, desto kürzer die entsprechenden Basic-Programme. Es gibt jedoch einige Berechnungen, deren Optimierung sich besonders lohnt: »^2« erhält eine eigene, schnelle Routine. Im Compiler benötigt »A=B^2« die gleiche Zeit wie »A=B*B«. Es existiert ebenfalls eine schnelle Exponential-Routine, die bereits im Mathematik-Teil der Runtime-Bibliothek vorhanden ist. So oder ähnlich werden alle Befehle mit Parametern behandelt. Sind mehrere Parameter vorhanden, werden diese natürlich zwischengespeichert.

Ein weiterer lohnenswerter Befehl ist der POKE-Befehl. Beispielsweise wird »POKE 16,64« durch »LDA #64:STA 16« übersetzt.

Beim PRINT-Befehl sind noch die Kommata zu beachten. Auch INPUT, READ, GET und LOCATE beinhalten noch einige Probleme. Eine ausführliche Erläuterung würde den Rahmen dieses Artikels jedoch sprengen.

Gesondert zu behandeln sind alle Befehle, die den linearen Programmablauf beeinflussen. Der GOTO-Befehl läßt sich vergleichsweise einfach übersetzen. Zuerst wird die Routine aufgerufen, die einen Integer-Ausdruck übersetzt und optimiert (wie oben); allerdings generiert sie ihn noch nicht in den endgültigen Maschinencode. Zunächst wird nur der P-Code erzeugt. Dann wird anhand des P-Codes überprüft, ob eine Zahl folgt. Wenn ja, wird ein JMP-Befehl compiliert. Zunächst nur »07 Zeile«, woraus sich nach Paß 2 »4C Adresse« ergibt. Wenn nein, wird die Routine für die Erzeugung von Maschinencode aufgerufen und »JSR GOTOX« angehängt. Bei einem berechneten GOSUB wird beispielsweise entweder »JSR GOSUBX« oder vor dem »JMP-($07)«-Befehl der GOTO-Anweisung ein Unterprogrammaufruf compiliert. Dieser enthält dann, auf dem Basic-Stack, die um drei Werte erhöhte Rücksprungadresse des Befehls. Es wird aber nicht einfach ein JSR compiliert, da sonst die Anzahl der Unterprogrammebenen durch den zu kleinen 6502-Stack begrenzt wäre. Außerdern müssen die Parameter für die FOR-NEXT-Schleifen ebenfalls auf diesem Stapel abgelegt werden. Sie belegen jeweils 16 Byte, so daß sehr schnell ein Overflow des 6502-Stack drohen würde. Dies könnte katastrophale Folgen haben und die Kompatibilität zum Interpreter-Basic stark einschränken. Zumindest ist die untere Hälfte der Seite 1 frei, und es gibt Programme, die mehr als 10 geschachtelte Schleifen verwenden. Der Zeitverlust durch diesen simulierten Stack hält sich jedoch in Grenzen.

Einen ähnlichen Stack verwendet der Compiler auch zur Übersetzung von DO-LOOP, REPEAT-UNTIL, WHILE-WEND und FOR-NEXT-Schleifen sowie des EXIT-Befehls.

Bei DO wird die aktuelle Adresse auf diesem Stapel abgelegt. Sie erhält außerdem noch die DO-Befehlskennung. Bei Übersetzung des zugehörigen LOOP wird die Kennung daraufhin überprüft, ob ein DO fehlt und dann ein JMP-Befehl auf die zuvor festgestellte Adresse compiliert. Sollte zwischen DO und LOOP ein EXIT gestanden haben, so hat dieser Befehl seine eigene Kennung auf dem Stack hinterlassen und einen JMP-Befehl mit »Dummy«-Adresse erzeugt. LOOP ersetzt diese Adresse durch die auf den eigenen JMP-Befehl folgende.

Geschwindigkeit ist Trumpf

Bei REPEAT ist der Vorgang ähnlich wie bei DO. Es unterscheidet sich lediglich die Kennung. Wenn UNTIL auf eine REPEAT-Anweisung folgt, wird zuerst der folgende Ausdruck übersetzt. Erst dann wird ein bedingter Sprung angehängt. Da der 6502 nur relativ kurze, bedingte Sprünge kennt, wird mit »BNE *+5:JMP ad« gearbeitet. Natürlich muß auch UNTIL eventuelle EXIT-Befehle berücksichtigen.

WHILE-WEND entspricht dem eben Besprochenen, nur steht hier die Bedingung am Schleifenanfang.

Bei FOR-NEXT-Schleifen ist noch zu beachten, daß zusätzlich der Basic-Stack benutzt wird. Deshalb wird automatisch ein POP-Befehl in den EXIT-Befehl eingeschlossen.

Damit ist die Wirkungsweise des Compilers erklärt. Vielleicht ist Ihnen aber noch nicht ganz klar, wie der Interpreter und der Compiler die Reihenfolge der Berechnungen feststellen. Der Interpreter führt die Berechnungen aus, der Compiler erzeugt einen P-Code. Trotzdem funktionieren beide nach dem gleichen Prinzip. Zur Zwischenspeicherung der Operatoren dient jeweils ein Stack (Stapel). Dazu ein einfaches Beispiel:

8*Sin(A)+16

Der Compiler liest die Ziffer 8. Als Zahl wird sie sofort compiliert. (Der Interpreter würde sie auf dem Zahlenstapel ablegen.) Dann wird das Token für Multiplikation (*) mit der Priorität 5 gelesen. Da der Stapel für Operatoren noch leer ist (Priorität 0), wird das Token dort abgelegt. Als nächstes folgt SIN mit der höchstmöglichen Priorität (9). Weil diese größer ist als die auf dem Stapel (5), wird auch SIN auf den Stapel gelegt. Dann folgt die geöffnete Klammer. Als Spezialfunktion ruft die Klammer dieselbe Routine noch einmal, allerdings rekursiv auf. Es wird also der Wert in Klammern entsprechend übersetzt oder berechnet. Dann kommt die Variable A an die Reihe. Variablen werden, genauso wie Zahlen, sofort compiliert. Dann schließt als Endekennzeichen die Klammer ab. Weiter geht es mit der ursprünglichen Obersetzung. Es folgt nun das Token für Addition (+), dessen Priorität (4) kleiner als die auf dem Stapel ist. Deshalb wird jetzt der SIN-Befehl compiliert. Anschließend wird noch die vorliegende Priorität mit der des »*«-Tokens (5) verglichen. Da sie wieder kleiner ist, wird jetzt die Multiplikation compiliert.

Der leere Stack hat die Priorität 0, also wird das Token für Addition auf dem Stack abgelegt. Dann folgt die Zahl 16 und schließlich das Endekennzeichen mit der Priorität 0. Der Stapel ist anschließend leer, die Addition wird compiliert, und der Vorgang ist beendet. Der entstandene P-Code lautet »Zahl 8 Var A Sin Mult Zahl 16 Add«.

So einfach wie hier beschrieben, ist das Compilieren von Basic-Programmen jedoch nicht. Es sollte an dieser Steile vielmehr gezeigt werden, wie der Turbo-Compiler prinzipiell funktioniert. Sollten alle Vorgänge beim Compilieren beschrieben werden, würde dies ein Buch füllen. Noch ein Tip: Das Standard-Atari-Basic ist wegen eines Betriebssystem-Fehlers nicht in der Lage, »A=NOT NOT A« zu berechnen. Dies hängt mit der Stapelverarbeitung zusammen. Probieren Sie diese Befehlsfolge doch einfach einmal auf Ihrem Atari 800XL aus. Aufgrund der Syntaxkontrolle ist der Befehl nicht zulässig. Die alten Atari-Computer reagierten auf »A=NOT NOT A« mit einem Systemabsturz. Geben Sie die Programmzeile doch einfach einmal unter Turbo-Basic-XL ein. Sie werden sehen, daß auch solche komplexen, logischen Verknüpfungen ordnungsgemäß funktionieren.

(Frank Ostrowski/wb)