- C-Kurs
- C-Kurs: Exkurse
Bis jetzt haben wir das Werkzeug cl65 einfach so benutzt, ohne uns Gedanken darüber zu machen, was es eigentlich genau tut. Der Name cl65 steht für Compile and Link, was darauf hindeutet, dass dabei mehrere Schritte durchgeführt werden.
Am Ende dieses Abends werden wir wissen, welche Werkzeuge sich hinter das Fassade von cl65 verbergen. Damit werden wir viel besser verstehen, was denn eigentlich schief läuft, wenn etwas nicht funktioniert.
Wer sich gut mit Assembler und dem C64-Kernal auskennt, wird heute fast alle Bytes verstehen, die in der Programmdatei ankommen.
Bevor wir aber richtig loslegen, schauen wir uns noch ein paar Dinge an, die in dem Puzzle fehlen.
Wer die Aufgaben in den letzten Tagen bearbeitet hat, wird es schon wissen: Eine Funktion kann man nur von Stellen im Quelltext aufrufen, an denen sie dem Compiler bereits bekannt sind (es gibt ein paar Ausnahmen - aber die schauen wir uns gar nicht erst an).
1: #include <stdio.h> 2: 3: int main(void) 4: { 5: hello(); 6: } 7: 8: void hello(void) 9: { 10: puts("Hello world!\n"); 11: }
Dieses Programm lässt sich nicht mit dem cc65 übersetzen. Der Compiler arbeitet das Programm von oben nach unten durch. In Zeile 5 wird die ihm unbekannte Funktion hello() aufgerufen. Der Compiler versucht mit bestimmten Annahmen über diese Funktion fortzufahren, stolpert dann aber über weitere Fehler und gibt auf.
Nach dem Korrigieren der Reihenfolge lässt sich das Programm übersetzen:
#include <stdio.h> void hello(void) { puts("Hello world!\n"); } int main(void) { hello(); }
Aber nicht immer ist es möglich, Funktionen zu definieren, bevor sie verwendet werden. Das ist z.B. der Fall, wenn sich Funktionen gegenseitig aufrufen. Auch Bibliotheks-Funktionen wie puts kann der Compiler verwenden, obwohl sie sich nicht in unserem Quelltext definiert sind.
Fügen wir zur ersten Programmversion eine Zeile hinzu:
1: #include <stdio.h> 2: 3: /* Dieser Prototyp macht dem Compiler unsere Funktion bekannt */ 4: void hello(void); 5: 6: int main(void) 7: { 8: hello(); 9: } 10: 11: void hello(void) 12: { 13: puts("Hello world!\n"); 14: }
Der Prototyp teilt dem Compiler mit, dass es eine Funktion mit dem Namen hello gibt, sowie deren Parameter und Rückgabetyp. Die offizielle Bezeichnung für einen solchen Prototyp ist Funktionsdeklaration (function declaration).
Dieses Programm lässt sich problemlos übersetzen. Merken wir uns das Format einer solchen Deklaration: Der Funktionskopf abgeschlossen mit einem Semikolon.
Jetzt liegt es nahe, dass auch Funktionen wie puts irgendwo einen Prototyp haben. Die Detektive unter Euch haben sicher schon einmal die Festplatte nach der Datei stdio.h durchsucht...
Schauen wir uns doch einmal die stdio.h an. Unter Linux liegt sie übrigens normalerweise in /usr/local/lib/cc65/include. Tatsächlich, dort gibt es einen Prototyp für puts:
int __fastcall__ puts (const char* s);
Wir kennen noch nicht jedes Detail dieser Deklaration, trotzdem erkennen wir den Aufbau eines Prototyps wie er oben beschrieben ist.
Nur, wie kommt die Deklaration in unseren Quelltext? Dafür sorgt der Präprozessor. Dieser Teil des Compilers ist der erste Schritt, der beim Übersetzen eines Programms getan wird. Der Präprozessor ist nichts weiter als eine Art Textverarbeitung, die über spezielle Anweisungen gesteuert werden kann.
Diese Anweisungen erkennt man daran, dass sie mit einer Raute (#) beginnen, die immer in der ersten Spalte des Quelltextes stehen sollte. Eine dieser Anweidungen haben wir schon öfters gesehen:
#include <dateiname>
Diese Anweisung sorgt dafür, dass der Präprozessor den Inhalt der angegebenen Datei an dieser Stelle einfügt. Ist der Dateiname in spitzen Klammern geschrieben, sucht der Präprozessor zuerst in den Standardverzeichnissen. Diese sind im Compiler eingebaut oder können ihm auf der Kommandozeile genannt werden.
Es gibt die gleiche Anweisung mit Anführungszeichen:
#include "dateiname"
In diesem Fall beginnt der Präprozessor die Suche nach der Datei in dem Verzeichnis, in dem der aktuelle Quelltext liegt.
Die Dateien, die in Programme auf diese Weise eingebunden werden, sind normalerweise Header-Dateien (header files, *.h). Unter anderem befinden sich in solchen Dateien „Funktionsköpfe“, also Deklarationen.
Eine andere oft verwendete Anweisung ist:
#define BLA bla bla
Nachdem der Präprozessor diese Anweisung erhalten hat, ersetzt er im nachfolgenden Quelltext das Wort BLA durch den Text bla bla. BLA wird nur ersetzt, wenn es als eigenständiges Wort vorkommt.
Eine sinnvolle Anwendung für #define ist das Festlegen von konfigurationsartigen Werten, die im Programm verwendet werden:
#define DANGEROUS_LIMIT 1000 ... if (x > DANGEROUS_LIMIT) { puts("Warnung: X ist jetzt ziemlich gefaehrlich!");
Soll die „gefährliche Grenze“ geändert werden, braucht man nur den Wert hinter #define ändern und nicht das gesamte Programm zu durchsuchen. Man könnte zum Beispiel eine Reihe solcher Konfigurationswerte in eine Datei namens „config.h“ packen und dann mit #include in alle relevanten Programmteile einbinden. Das, was ein #define definiert, nennt man Makro. Ein Makro ist keine Variable im herkömmlichen Sinn, sondern eine textuelle Ersetzung. Die Namen von Makros schreibt man üblicherweise groß.
Es gibt noch ein paar weitere Möglichkeiten, die der Präprozessor uns bietet. Diese schauen wir uns an, wenn wir sie brauchen.
So, nun wissen wir, woher der Compiler die Funktion puts kennt. Aber wie kommt der Programm-Code der Funktion in unser Programm? Auch dafür gibt es ein Werkzeug: Der Linker.
Der Linker bindet einzelne Programmteile (Objektdateien) und Bibliotheken zu einem fertigen, ausführbaren Programm zusammen. Damit ist der Linker das letzte Glied in der Werkzeugkette, der Toolchain.
Schauen wir uns doch einmal an einem konkreten Beispiel an, wie genau der Compiler ein Programm erzeugt und welche Teile in das finale Ergebnis mit einfließen.
Als Versuchsobjekt benutzen wir ein kleines Programm, was wir erstmalig auf zwei Quelltexte verteilen. Größere Programme wie z.B. der Firefox-Browser oder OpenOffice bestehen oft aus mehreren hundert Quelltexten, auch Module genannt. Auch Computerspiele und Anwendungen für den C64 könnten sich über mehrere Dutzend Quelltexte erstrecken.
Unser Hauptprogramm befindet sich in der Datei main.c:
#include "hello.h" int main(void) { hello(); }
Wir sehen schon, dass das Programm so nicht vollständig ist: Die Funktion hello ist in diesem Quelltext nicht definiert. Der aufmerksame Leser hat sicher schon bemerkt, dass eine Header-Datei mit dem Namen hello.h eingebunden wird. Die Anführungszeichen deuten auf eine Datei im gleichen Verzeichnis hin.
Also legen wir diese Header-Datei1) daneben:
void hello(void);
Jetzt wird der Compiler mit dem Quelltext zufrieden sein. Der Präprozessor bindet hello.h in den Quelltext main.c ein. Dadurch ist die Funktion hello() bekannt und main.c kann übersetzt werden.
Jetzt fehlt noch unsere Implementierung der Funktion hello(). Diese legen wir in hello.c ab:
#include <stdio.h> #include "hello.h" void hello(void) { puts("Hello world!\n"); }
Nanu? Warum binden wir in die hello.c auch die hello.h ein? Der Compiler braucht den Prototypen der Funktion hello hier doch gar nicht?! Das stimmt, aber der Compiler hat dadurch die Möglichkeit zu überprüfen, ob Parameter und Rückgabetyp des Prototypen aus der Header-Datei und der tatsächlichen Funktionsdefinition übereinstimmen. Andernfalls würde er eine Fehlermeldung ausgeben. Mit dieser Maßnahme kann man den einen oder anderen Fehler leichter finden.
Jetzt beginnen wir, dieses Programm Schritt für Schritt per Hand zu übersetzen. In den Beispielen werden die einzelnen Werkzeuge des cc65 mit bestimmten Kommandozeilenparametern aufgerufen. Bitte schaut in die Dokumentation des cc65, um deren Bedeutung genauer zu ergründen.
Zuerst rufen wie den Präprozessor auf. Dieser ist im Fall des cc65 in den Compiler eingebaut, wir müssen also mit einem zusätzlichen Schalter die eigentliche Übersetzung abschalten.
# Rufe nur den Praeprozessor auf cc65 -E hello.c > hello.i cc65 -E main.c > main.i
Die Ausgabedatei main.i haben wir durch eine Ausgabeumleitung mit “>“ angelegt. Weil ich es nicht mit “-o“ hinbekommen habe. Schauen wir uns doch eine dieser beiden Dateien an, die das erste Zwischenergebnis enthalten:
void hello(void); int main(void) { hello(); }
Hier hat der Präprozessor seine Arbeit erledigt: Der Inhalt der hello.h wurde in main.c eingebunden. Außerdem entfernt der Präprozessor für den Compiler nutzlose Leerzeichen und Kommentare.
Wir haben dann in unserem Verzeichnis die fünf Dateien main.c, hello.c, hello.h, main.i, hello.i.
Im nächsten Schritt lassen wir den Compiler die beiden vorbereiteten Quelltexte in Assembler übersetzen:
# Kompiliere die beiden Quelltexte, Ausgabe: Assembler (.s) cc65 -O -t c64 hello.i cc65 -O -t c64 main.i
In diesem Schritt entstehen die beiden Dateien hello.s und main.s. Schauen wir uns wieder eine der beiden an, hier die main.s:
; ; File generated by cc65 v 2.12.9 ; .fopt compiler,"cc65 v 2.12.9" .setcpu "6502" .smart on .autoimport on .case on .debuginfo off .importzp sp, sreg, regsave, regbank, tmp1, ptr1, ptr2 .macpack longbranch .import _hello .export _main ; --------------------------------------------------------------- ; int __near__ main (void) ; --------------------------------------------------------------- .segment "CODE" .proc _main: near .segment "CODE" jmp _hello .endproc
Dieser Assembler-Quelltext beinhaltet diverse Steuerinformationen für den Assembler. Interessant sind .import _hello und .export _main. Die Import-Anweisung teilt dem Assembler mit, dass die Adresse des Labels _hello außerhalb dieses Quelltextes zu finden sein wird. Die Export-Anweisung bewirkt, dass die Adresse von _main nach außen sichtbar wird. Muss ja auch, sonst könnte ja niemand unser main aufrufen.
Wir sehen auch schon die Namenskonvention, die der Compiler benutzt: Den Funktionsnamen wird ein Unterstrich vorangestellt, um ein Assembler-Label zu erzeugen.
Die einzige Assembler-Instruktion, die aus unserer Funktion main entstand, ist jmp _hello.
Die Schritte, die unsere haben unsere Programmmodule bis jetzt durchlaufen haben, sind in folgendem Bild dargestellt:
Den einen oder anderen Assembler habt Ihr sicher alle schon benutzt. Bei „normalen“ Assemblern wie TurboAss, Acme, DreamAss u.a. ist das Ergebnis bereits ausführbarer Code. Wenn dieser Code Opcodes wie JMP oder JSR enthält, ist er an eine bestimmte Position im Speicher gebunden. Lädt man ihn an eine andere Adresse, läuft er in diesem Fall nicht mehr.
Der Assembler ca65 hingegen kann besondere Dateien erzeugen. Diese enthalten sowohl Code, als auch viele zusätzliche Informationen über den Code und über die darin enthaltenen Sprungmarken (Labels). Mit diesen Informationen kann der Linker, den wir uns im nächsten Schritt ansehen werden, den Code an beliebige Stellen im Speicher legen.
Dieses speziellen Dateien heißen Objektdateien und haben die Endung “.o“.
# Assembliere die Programme, Ausgabe: Objekt (.o) ca65 -t c64 hello.s ca65 -t c64 main.s
Beispiel: Mit od65 -S hello.o kann man sich die Größe der benötigten Speicherbereiche ansehen.
Jetzt haben wir zwei einzelne Objektdateien vorliegen, die unseren Code enthalten. Im fertigen Programm müssen diese zusammengefügt werden. Außerdem muss auch der Code für die verwendete Funktion puts irgendwo herkommen. Und für ein vollständiges Programm fehlen dann z.B. noch die Startzeile „SYS 2061“ und der Code, der beim Starten offenbar immer auf Groß-/Kleinschrift umschaltet und unser main aufruft.
Das Werkzeug, das all diese Teile zusammenfügt, ist der Linker („Verbinder“). Auf der Kommandozeile geben wir dem Linker an, für welche Maschine die Ausgabe erzeugt werden soll. Die Information nutzt er, um die richtige Konfiguration zu verwenden. Nur so kann er z.B. wissen, dass das Programm bei der Adresse 0×0801 beginnen soll.
Als nächstes übergeben wir auf der Kommandozeile die Namen aller Objektdateien, die zum Programm gehören sollen.
Zuletzt geben wir die Bibliotheken an, in denen der Linker nach weiteren Funktionen suchen soll. In diesen sucht er nach allen Funktionen und Variablen, die benutzt werden, aber bis jetzt nicht definiert sind. Eine Bibliothek ist übrigens ein Gebinde aus vielen Objektdateien, die im Prinzip auf die gleiche Art und Weise erzeugt werden wie unsere Objektdateien oben.
Der Linker fügt dann die Objekte aus der Bibliothek zu unserem Programm hinzu, die die fehlenden Symbole (z.B. Funktionen) enthalten. Gibt es danach immernoch nicht aufgelöste Symbole, bekommen wir eine Fehlermeldung.
Also starten wir den Linker:
# Binde die Objekte mit der Laufzeitumgebung und Bibliothek, Ausgabe: hello.prg ld65 -t c64 -o hello.prg hello.o main.o c64.o --lib c64.lib
Wir sehen hier eine uns unbekannte Objektdatei c64.o. Diese findet der Compiler in seinen Standardverzeichnissen. Die c64.o enthält die sogenannte Laufzeitumgebung (runtime environment), die alle Vorbereitungen und Aufräumarbeiten für unser Programm vornimmt. Den Quelltext für dieses Modul findet ihr in den Quelltexten des cc65 in libsrc/c64/crt0.s.
Auch die Implementierung der Funktion puts kann übrigens in den cc65-Quelltexten gefunden werden. Sie liegt in libsrc/common/puts.c. Wenn wir uns deren Quelltext ansehen, finden wir heraus, dass sie eine weitere Funktion namens write aufruft. Diese wiederum finden wir in libsrc/cbm/write.s. Dort mündet unsere Ausgabe dann in Aufrufen von Kernal-Routinen.
Jetzt haben wir wirklich alles identifiziert, was im fertigen Programm landet. In der folgenden Grafik sieht man die gerade beschriebenen Vorgänge:
In den seltensten Fällen muss man alle diese Schritte einzeln durchführen. Bei größeren Projekten kann es allerdings sehr sinnvoll sein, das Kompilieren und das Linken einzeln auszuführen. In dem Fall bleiben die Objektdateien als Zwischenergebnis erhalten. Ändert man nur einen Quelltext, braucht im Idealfall nur eine Objektdatei neu erstellt und das Programm mit dem Linker neu gebunden zu werden. Das kann schonmal ein Stündchen Zeit sparen.
In unseren Programmen können wir weiterhin den einfachen Weg cl65 benutzen. Dieses Werkzeug führt alle oben beschriebenen Schritte aus und kann auch mit mehreren Quelltexten umgehen.
cl65 -O -o hello.prg hello.c main.c
Der Schalter -t c64 kann bei diesem Werkzeug weggelassen werden, weil der C64 hier die Voreinstellung ist.
Jetzt wissen wir, wie der Compiler tickt. Und nächstes mal programmieren wir wieder richtig.
In diesem Thread im Forum64 können Fragen zu dieser Lektion gestellt werden:
http://www.forum64.de/wbb3/index.php?page=Thread&threadID=28180
In diesem Thread diskutieren Experten, was hier nicht stimmt und wie der Kurs verbessert werden kann:
http://www.forum64.de/wbb3/index.php?page=Thread&threadID=28181
Hier sind ein paar interessante Fragen aus dem oben genannten Forum:
(Noch keine)
So, das war's schon für heute, weiter geht's mit 06-abend6