Build your OS – Der Bellatrix-Code – Seite 2

…Fortsetzung

Menüpunkt Taste Funktion
Run/Compile
Current/View Info
F8 Quelltext
compilieren und Infos anzeigen
Run/Compile
Current/Load RAM
F10 Quelltext
compilieren, in den Propeller Hub-RAM laden und starten
Run/Compile
Current/Load EEProm
F11 Quelltext
compilieren, in den Propeller Hub-RAM laden, in den EEProm flashen
und starten

Bei F8 passiert also erstmal nichts am und im Propeller, sondern der Quelltext wird einfach compiliert, dabei natürlich auf Fehler getestet und abschließend werden Informationen zu dem erzeugten Binärcode angezeigt. So kann man schauen wie viel Speicher dieses Programm verbraucht, getrennt nach Programmcode und Daten. In dieser Infoanzeige kann man auch das compilierte Programm als BIN-Datei speichern – das wird später wichtig, wenn wir fertige Programme auf dem Hive von SD-Card starten wollen. Mit den Funktionen F10/F11 geht es nun aber endlich richtig zur Sache, denn in beiden Fällen wird das Programm nach dem compilieren zum Propellerchip übertragen und gestartet. Der ganze Vorgang dauert nur Augenblicke. Beide Funktionen unterscheiden sich nur in einem Punkt: F11 speichert das Programm zusätzlich noch im angeschlossenen EEProm, von wo es nach jedem Reset oder Einschalten des Propellers automatisch gestartet wird. Mit F10 landet das Programm nur im RAM und ist nach einem Reset natürlich wieder gelöscht, bzw. wird genau durch jenen Code ersetzt, welcher sich im EEProm befindet.

Nochmal zur Erinnerung wie der Systemstart bei den Propellerchips abläuft:

  1. Der Propellerchip versucht an den Pins 30/31 eine serielle Verbindung zum Propeller Tool aufzubauen. Ist das erfolgreich wird ein 32kByte Image in den HubRAM geladen und entweder sofort gestartet (F10) oder erst geflasht und dann gestartet (F11).
  2. Konnte keine serielle Verbindung etabliert werden, versucht der Chip über die Pins 28/29 einen EEProm über I2C auf der untersten Adresse anzusprechen. Bei einem vorhandenen ROM wird nun ebenfalls ein 32K-Image in den HubRAM geladen und gestartet.
  3. Sind die ersten beiden Phasen erfolglos verlaufen, taktet der Chip niedriger und geht in eine Shutdown-Modus.

Aber was genau passiert wenn der Propellerchip einen EEProm findet? Dieser Punkt hat schon zu einigen Mißverständnissen geführt, wobei man sagen muß, dass der Prop da halt im Gegensatz zu anderen Mikrocontrollern auch wieder sein eigenes Lied spielen muß: Von dem EEProm wird ein 32 KB Image in den hRAM geladen und gestartet. Nun ja, genau dieser Start ist aber etwas eigen, denn der Propellerchip startet nicht wie jeder 0815-Mikrokontroller dann an einer bestimmten Adresse im RAM einen Maschinencode, sondern ein SPIN-Objekt! Aber keine Panik, die Sache ist eigentlich ganz easy, denn so haben wir quasi gleich einen Hochsprachen-Loader.

Dazu ein paar Worte zu SPIN selbst. Wie funktioniert dieser SPIN-Compiler und die Abarbeitung auf dem Propellerchip? Das Propeller Tool und der darin intergrierte SPIN-Compiler erzeugt keinen Maschienencode, sondern einen Byte-Tokencode, eine Art Zwischensprache, die im Propeller von einem Tokenprozessor abgearbeitet wird. Das ist übrigens ein ganz  guter Kompromiss zwischen Speicherverbrauch und Geschwindigkeit und auch keine neue Erfindung – Java macht das zum Beispiel genau so! Zur Abarbeitung wird in eine COG dabei eine „Microcode“ geladen, welcher aus einem RISC-Subsystem einen SPIN-Prozessor macht, der den SPIN-Bytecode direkt abarbeitet.

Und genau das läuft beim Systemstart ab, wenn ein EEProm vorhanden ist:

  1. Ein 32 KByte-Image wird vom EEProm in den RAM kopiert.
  2. Die erste COG lädt aus dem ROM ihren „Microcode“ in den CogRam – den SPIN-Bytecode-Prozessor.
  3. Diese erste COG startet den Bytecode in dem geladenen Image im HubRAM-

Der Propeller startet also immer in SPIN. Aber keine Panik, dieser erste SPIN-Prozessor kann beliebige COG’s mit Maschinencode füttern und sich dann selbst terminieren – somit steht der vollkommenen Beherrschung des Propellers nichts im Weg, außer die eigene Fähigkeit, einen solchen Bootstrapcode  zu realisieren. Für alle Weicheier wie ich eines bin, ist die Luxusversion gedacht,  direkt in einer Hochsprache zu booten… 🙂

LINK: Wikipedia „Zwischencode“

LINK: Wikipedia „Bytecode“

LINK: Wikipedia „Microcode“

Wenn wir dieses Programm laden und mit F10 in den RAM des Propellers laden, oder mit F11 im EEProm speichern, erhalten wir das folgende Bild auf einem angeschlossenen VGA-Monitor. Unser Text erscheint zwar, aber irgendwie sieht es doch ziemlich komisch aus mit diesem ganzen Datenmüll als Rahmen. Die Ursache ist in unserem Programm selbst verborgen, denn was wir da sehen, ist einfach der Inhalt des Bildschirmpuffers, wenn er nicht gelöscht wird. Aber darum kümmern wir uns gleich, vorerst wollen wir schauen was wir da überhaupt in Spin gemacht haben.

Also nochmal zurück zum Quelltext. Das Programm unterteilt sich in folgende Blöcke:

CON – Hier werden Konstanten definiert.
VAR – Hier werden die verschiedenen Variablen definiert
DAT – In diesem Datenblock können vordefinierte Daten wie zum Beispiel Strings definiert und Speicher reserviert werden. Außerdem wird hier der Assemblercode gespeichert.
PUB/PRI – Definition von Spin-Routinen

Tip: Einen guten Überblick über den groben Aufbau eines Programmes bekommt man im Propeller Tool, wenn man in dem Fenster über dem Quelltext die Option „Summary“ wählt. Es ist deshalb gut, auch eigene Programme so zu gestalten, dass sie in der Übersichtsanzeige einen guten Überblick gestatten. Da in dieser Ansicht nur die ersten Zeilen der verschiedenen Blöcke angezeigt werden, ist es sinnvoll, in dieser Zeile an fester Tabulatorposition einen entsprechenden Kommentar zu schreiben. Wer mag kann als Beispiel „ios.spin“ von TriOS laden und in die Summary-Ansicht wechseln – dort hat man so einen guten und thematisch geordneten Überblick über alle Routinen. Bei drei Routinen mag das noch lästig wirken, bei dreißig Routinen wird es fast zur Pflicht! 😉

Spin-Programme werden in Objekte aufgeteilt. Objekte in Spin realisieren ein Bibliothekskonzept und sind keine Konzept zur objektorientierten Programmierung wie es zum Beispiel in C++ Verwendung findet. Wozu verwendet man also Objekte in SPIN?

  • Objekte können dazu dienen, abgeschlossene Einheiten besser handhaben zu können. Man kann ein Objekt als Einheit einfach besser in andere Projekte einbetten.
  • Mit Objekten kann man bestimmte Funktionskomplexe gut voneinander trennen.
  • Mit Objekten kann man die Wiederverwendbarkeit von Code sehr vereinfachen.

LINK: Wikipedia „Objektorientierte Programmierung“

An unserem Beispielcode sehen wir das ja ganz deutlich: Wir wollen einen VGA-Monitor ansteuern? Dann fügen wir im OBJ-Block ein passendes Objekt ein. Mit dem Propeller Tool werden schon sehr viele fertige Objekte mitgeliefert. Man findet sie im Menü „Datei/Open From“. Zu den meisten dort verfügbaren Objekten gibt es gleich noch ein entsprechendes Demo. Diese Demoprogramme laufen ohne Änderung auf dem Demoboard von Parallax, für den Hive müssen dabei oft noch einige kleinere Anpassungen vorgenommen werden, da die externen Komponenten (wie zum Beispiel die VGA-Buchse) an anderen Pins angeschlossen sind.

Objekte kann man vielleicht auch grob in zwei Arten einteilen: Die erste Art von Objekten enthält einfach eine Sammlung von Routinen. Als Beispiel dafür könnte man „float32“ nennen, welche eine Sammlung von mathematischen 32 Bit Fließkommaroutinen enthält. Es befinden sich in diesem Objekt also nur Routinen und es werden keine Hardwareressourcen belegt. Die zweite Art Objekte belegt entsprechende Ressourcen. So startet das Objekt „vga-treiber“ in unserem Fall zwei COG’s, welche an ganz bestimmten und von uns im Programm definierten Pins, ein VGA-Signal ausgeben. Diese Objekte enthalten im Normalfall auch eine Routine „start/stop“ um die Belegung der Ressourcen zur Laufzeit zu initialisieren und um sie wieder freizugeben.
In unserem Programm befinden sich drei Routinen. Die erste Routine wird beim Starten des Programms ausgeführt, ganz unabhängig davon, welchen Namen sie trägt. In unserem Beispiel ist es die Routine „main“. Schauen wir uns diesen Abschnitt genauer an:

PUB main                                     		'hauptroutine

  vga.start(vga_basport, @array, @vgacolors, 0, 0, 0)  	'vga-objekt starten
  print_string(@text)                              	'string ausgeben

  repeat

Als erstes wird mit „vga.start(…)“ die Routine „start“ aus dem Objekt „vga-treiber“ aufgerufen. Im OBJ-Block wird dabei der Name definiert, mit welchem unser VGA-Treiber nun bezeichnet wird – in unserem Fall lautet der Name „vga“. Der Routine werden sechs Parameter übergeben, was in dem Quelltext von „vga-treiber“ folgendermaßen definiert ist:

PUB start(base_pin, array_ptr, color_ptr, cursor_ptr, sync_ptr, mode) : okay

Die einzelnen Parameter haben dabei folgende Bedeutung:

base_pin Dieser Parameter gibt den ersten (kleinsten) von acht Pins an, an welchem das VGA-Signal ausgegeben werden soll. Zum Vergleich sollte man in der Pinbelegung des Hive nachschauen. Dort kann man erkennen, dass base_pin auf dem Wert 8 liegen muß. In unserem Beispiel haben wir diesen Wert als Konstante „vga_basport“ definiert.
array_ptr Übergeben wird ein Zeiger auf ein Speicherbereich mit 3.072 Wörter, die an einer Wortgrenze ausgerichtet sein müssen. Dieser Wert ergibt sich aus dem Treiber selbst, denn in ihm ist die Auflösung von 1024 x 768 Pixeln festgelegt. Den genauen Aufbau dieses Bildspeichers werden wir etwas später noch genauer besprechen. In unserem Testprogramm ist dieser Speicher als Variable mit dem Namen „array“ definiert, das vorgestellte @ ist ein Operator, welcher den Zeiger einer entsprechenden Variable bildet – @array ist also genau jener Zeiger auf unseren gesuchten Speicher.
color_ptr Auch hier wird ein Zeiger einen Speicherbereich erwartet, welcher 64 Longs mit entsprechenden Farbinformationen enthalten muss.
cursor_ptr Wird in unserem Test nicht verwendet und dient der Darstellung eines Mauszeigers. Eine $0 schaltet diese Funktionalität ab und damit werden von dem Treiber nur 2 statt 3 COG’s gestartet.
sync_ptr Für manche Aufgaben ist es nötig entsprechende Änderungen am Bildspeicher mit der Bildwiedergabe zu synchronisieren. Diese Methode wird oft in Spielen oder bei Animationen verwendet, um ein Flackern zu verhindern. Dieser Zeiger weist auf ein Long, in welchem der VGA-Treiber ein Synchronsignal liefert. Ein $0 schaltet auch hier die Funktionalität ab.
mode $0 bedeute 16×16 Pixel Tiles; $1 bedeutet 16×32 Pixel Tiles.

Im weiteren finden sich in unserem Testprogramm nur noch zwei weitere Routinen. Die Routine „print_string“ ist recht einfach aufgebaut und gibt einfach eine Zeichenkette aus, deren Adresse ihr übergeben wird. Die Ausgabe erfolgt dabei solange, bis das Ende mit dem Wert $0 erreicht ist – man spricht in diesem Fall von einem 0-terminierten String. Den String selbst haben wir im DAT-Block definiert.
Viel interessanter dagegen ist die Routine „print(c)“, auch wenn sie nur ein einzelnes Zeichen ausgibt. Was passiert da?

PRI print(c) | i, k                 'Ausgabe eines Zeichens

  k := color << 1 + c & 1           'farbwert des tiles berechnen
  i := $8000 + (c & $FE) << 6 + k   'wert für ein tile zusammensetzen
  array.word[row * cols + col] := i 'oberes tile des zeichens setzen
  array.word[(row + 1) * cols + col] := i | $40 'unteres tile
  ++col

Nun, dazu muss man einige Kleinigkeiten bezüglich des Bildaufbaus dieses VGA-Treibers wissen. Die aufmerksame Drohne mag schon eine scheinbare Unklarheit am Anfang des Programms entdeckt haben: Während die Anzahl der Spalten als Konstante mit „cols=64“ scheinbar korrekt definiert ist, ist die Anzahl der Zeilen mit „rows=48“ genau doppelt so groß definiert. Aber wir haben doch 64 Zeichen in 24 Zeilen? Der Grund für diese Differenz wurzelt im konkreten Aufbau der Bildschirmzeichen. Jedes Zeichen besteht aus 16×32 Pixeln und wird aus zwei übereinander liegende 16×16 Pixel Tiles (Kachel) zusammengesetzt – ein zeichen hat also eine „Rastergröße“ von 16×32 Pixeln. Die Konstante „rows“ und die Variable „row“ gibt dabei also nicht die Textzeile an, sondern zählt die Tiles-Zeilen, die Zeichenzeile ergibt sich aus row/2. Nun wird wahrscheinlich auch die print(c)-Anweisung ein wenig verständlicher. Hier wird für jedes Zeichen erst das obere Tile und darauf folgend eine Zeile tiefer das zweite Tile gesetzt. Doch wo befinden sich diese Tiles? In der print-Routine wird für jedes Tile doch nur ein Word in den Bildschirmpuffer geschrieben? Dieses Word enthält dabei auch noch zwei Informationen: Die unteren 6 Bits (0..63) ist die Nummer eines Farbwertes in der Farbtabelle – wählt also die Farbe für das Tile aus. Die oberen 10 Bits enthalten die Basisadresse eines Feldes mit 16 Datenworten – das Tile. Unsere Routine schreibt ja ein Textzeichen mit dem internen Zeichensatz des Propellers und die verwendete ROM-Zeichentabelle beginnt ab $8000 (siehe auch Propeller Handbuch dazu). Wie man gut erkennen kann, ist es durchaus möglich, die Basisadresse eines Tiles auch auf einen entsprechenden Datenbereich im hRAM (Hub-RAM) zu setzen, in welchem sich dann ein frei definierbares eigenes Zeichen befindet. Aber diesen Spaß wollen wir uns für ein späteres Tutorial aufheben. Uns soll es an dieser Stelle genügen zu verstehen, wie unser Zeichen prinzipiell auf den Bildschirm kommt.

Also noch mal grob einige Gedanken zum Bildschirmaufbau und den Varianten die möglich sind:

Rasterbildschirm: Das ist die einfachste und direktesete Variante – jedem Pixel auf dem Bildschirm stehen eine entsprechende Anzahl Bits im Bildschirmpuffer zur Verfügung. Nehmen wir für jeden Pixel ein Byte, so haben wir 256 Farbe zur Auswahl – entweder direkt, oder aus einer Farbpalette. Im einfachsten Fall ist der Bildschirm monochrom – jedes Bit entspricht dabei einem Pixel.

Komprimierter Rasterbildschirm (Tiles): Wie funktioniert Kompression? Jede sich wiederholende Sequenz wird in einem separaten Bereich gespeichert und in den eigentlichen Daten nur eine Referenz zu dieser Sequenz. Genau so funktioniert eine auf Tiles basierende Grafik. Das eigentliche Bild wird in sich wiederholende „Kacheln“ unterteilt und jede Kachel muß nun nur noch einmal gespeichert werden! Auf unserem Textbildschirm besteht jedes Zeichen aus zwei solchen Kacheln, die im Zeichensatz im ROM gespeichert sind. Im Bildschirmpuffer muß jetzt also nicht mehr für jedes Zeichen eine 16×32 Pixel-Grafik gespeichert werden, sondern nur noch ein Zeiger auf diese Grafik.

Vektoren: Bei einem Vektorbildschirm wird die ganze Geschichte noch etwas exotischer, da dabei von jedem Vektor nur Anfangs- und Endkoordinaten gespeichert werden, und diese in Echtzeit erzeugt werden. Die Vectrex-Konsole ist ein Beispiel dafür. Da wir am Hive keine Vaktor-Hardware haben, sondern sowohl VGA als auch TV Zeilenorientiert arbeiten, müsste eine Vektorgrafik zeilenorientiert sein. Sicher ein interessantes Experiment für Spiele, da sich damit sehr hohe Kompressionsraten erzielen lassen.

Unser Textbildschirm ist mit einer Tiles-Grafik realisiert, um den Zeichensatz im ROM zu nutzen. Jedes Bildschirmzeichen besteht aus zwei untereinander liegenden Tiles.

Bildschirm löschen

Wie schon bemerkt, sieht unser Bildschirm recht seltsam aus, da wir ihn vor unserer Ausgabe noch nicht gelöscht haben. Nehmen wir folgende Änderung an unserem Quelltext in der Routine „main“ vor:

CON
SPACETILE    = $8000 + $20 << 6

PUB main | i
vga.start(vga_basport, @array, @vgacolors, 0, 0, 0)
  repeat i from 0 to tiles
    array.word[i] := spacetile
  print_string(@text)
  repeat


Nun erhalten wir einen „sauberen“ Bildschirm, da wir den Puffer vorher mit den Werten für ein Leerzeichen füllen. Nun wollen wir das Ganze noch ein wenig komfortabler und universeller gestalten, denn eine Funktion zum löschen des Screens brauchen wir ja immer wieder. Da wäre es doch gut, wenn wir diese Funktion mit in die Printroutine einbauen, und über ein bestimmtes Steuerzeichen abrufbar machen. Also bauen wir die Routine „main“ und „print“ nochmal um.

PUB main | i
  vga.start(vga_basport, @array, @vgacolors, 0, 0, 0)
  print($100)
  print_string(@text)
  repeat

PRI print(c) | i, k
  case c
    $20..$FF:           'zeichen?
      k := color << 1 + c & 1
      i := $8000 + (c & $FE) << 6 + k
      array.word[row * cols + col] := i
      array.word[(row + 1) * cols + col] := i | $40
      ++col
    $100:               'screen löschen?
      wordfill(@array, spacetile, tiles)
      col := row := 0

Jetzt können wir mit dem Steuerzeichen $100 jederzeit unseren Bildschirm löschen.

Fortsetzung des Tutorials: Build your OS – Der Bellatrix-Code – Seite 3