Artikel veröffentlicht am 9. Dezember 2014Update für 2018: RenéRebe hat ein interessantes Video basierend auf diesem Artikel gemacht ( Teil 2 )Letztes Wochenende habe ich an
Ludum Dare # 31 teilgenommen. Aber noch bevor die Konferenz angekündigt wurde
, wollte ich wegen meines
jüngsten Hobbys ein Spiel der alten Schule unter DOS machen. Die Zielplattform ist DOSBox. Dies ist die praktischste Methode zum Ausführen von DOS-Anwendungen, obwohl alle modernen x86-Prozessoren bis zum 16-Bit-8086 vollständig abwärtskompatibel mit alten sind.
Ich habe das
DOS Defender- Spiel erfolgreich erstellt und auf der Konferenz gezeigt. Das Programm arbeitet im Real-Modus des 32-Bit-80386. Alle Ressourcen sind in die ausführbare COM-Datei integriert, keine externen Abhängigkeiten, sodass das Spiel vollständig in eine 10-Kilobyte-Binärdatei gepackt ist.
Zum Spielen benötigen Sie einen Joystick oder ein Gamepad. Ich habe die Mausunterstützung für die Präsentation in die Veröffentlichung für Ludum Dare aufgenommen, sie dann aber gelöscht, weil sie nicht sehr gut funktioniert hat.
Der technisch interessanteste Teil ist, dass
keine DOS-Entwicklungstools benötigt wurden, um das Spiel zu erstellen ! Ich habe nur den regulären Linux C-Compiler (gcc) verwendet. In Wirklichkeit können Sie nicht einmal einen DOS Defender für DOS erstellen. Ich sehe DOS nur als eingebettete Plattform, die die einzige Form ist, in der
DOS heute noch existiert . Zusammen mit DOSBox und DOSEMU ist dies ein ziemlich praktischer Satz von Werkzeugen.
Wenn Sie sich nur für den praktischen Teil der Entwicklung interessieren, gehen Sie zum Abschnitt „Cheat on GCC“, in dem wir das DOS-COM-Programm „Hello, World“ mit GCC Linux schreiben.
Die richtigen Werkzeuge finden
Als ich dieses Projekt startete, dachte ich nicht an GCC. In Wirklichkeit ging ich diesen Weg, als ich das
bcc- Paket (Bruce's C Compiler) für Debian entdeckte, das 16-Bit-Binärdateien für 8086 kompiliert. Es wird zum Kompilieren von x86-Bootloadern und anderen Dingen verwendet, aber bcc kann auch zum Kompilieren von DOS-COM-Dateien verwendet werden. Es hat mich interessiert.
Als Referenz: Der Intel 8086 16-Bit-Mikroprozessor wurde 1978 veröffentlicht. Es hatte keine bizarren Eigenschaften moderner Prozessoren: keinen Speicherschutz, keine Gleitkommaanweisungen und nur 1 MB adressierbaren RAM. Alle modernen x86-Desktops und -Laptops können sich noch vor vierzig Jahren als dieser 16-Bit-Prozessor 8086 ausgeben, mit der gleichen eingeschränkten Adressierung und all dem. Dies ist eine ziemlich abwärtskompatibel. Eine solche Funktion wird als
Real-Modus bezeichnet . Dies ist der Modus, in dem alle x86-Computer gestartet werden. Moderne Betriebssysteme wechseln sofort in den
geschützten Modus mit virtueller Adressierung und sicherem Multitasking. DOS hat das nicht getan.
Leider ist bcc kein ANSI C-Compiler. Es unterstützt eine Teilmenge von K & R C sowie integrierten x86-Assembler-Code. Im Gegensatz zu anderen 8086 C-Compilern gibt es kein Konzept für "ferne" oder "lange" Zeiger. Daher ist integrierter Assembler-Code erforderlich, um auf
andere Speichersegmente (VGA, Uhren usw.) zuzugreifen. Hinweis: Die Überreste dieser "langen Zeiger" 8086 bleiben in der Win32-API erhalten:
LPSTR
,
LPWORD
,
LPDWORD
usw. Dieser integrierte Assembler ist nicht einmal eng mit dem integrierten Assembler GCC vergleichbar. In Assembler müssen Sie Variablen manuell aus dem Stapel laden. Da bcc zwei verschiedene Aufrufkonventionen unterstützt, sollten die Variablen im Code gemäß der einen oder anderen Konvention fest codiert werden.
Angesichts dieser Einschränkungen habe ich mich entschlossen, nach Alternativen zu suchen.
DJGPP
DJGPP - GCC-Port unter DOS. Ein wirklich sehr beeindruckendes Projekt, das fast die gesamte POSIX unter DOS überträgt. Viele DOS-portierte Programme werden auf DJGPP erstellt. Er erstellt jedoch nur 32-Bit-Programme für den geschützten Modus. Wenn Sie im geschützten Modus mit Hardware (z. B. VGA) arbeiten müssen, sendet das Programm Anforderungen an den Dienst
der DOS-Schnittstelle für
den geschützten Modus (DPMI). Wenn ich DJGPP genommen hätte, hätte ich mich nicht auf eine einzelne Standalone-Binärdatei beschränken können, da ich einen DPMI-Server haben müsste. Die Leistung leidet auch unter Anfragen nach DPMI.
Die notwendigen Tools für DJGPP zu bekommen ist, gelinde gesagt, schwierig. Glücklicherweise habe ich ein nützliches
build-djgpp- Projekt gefunden, das alles
ausführt , zumindest unter Linux.
Entweder gab es einen schwerwiegenden Fehler oder die offiziellen DJGPP-Binärdateien wurden erneut
mit dem Virus infiziert , aber als ich meine Programme in DOSBox startete, wurde ständig der Fehler "Nicht COFF: Auf Viren prüfen" angezeigt. Um weiter zu überprüfen, ob sich die Viren nicht auf meinem eigenen Computer befinden, habe ich die DJGPP-Umgebung auf meinem Raspberry Pi eingerichtet, der als Reinraum fungiert. Dieses ARM-basierte Gerät kann nicht mit dem x86-Virus infiziert werden. Und immer noch trat das gleiche Problem auf, und alle binären Hashes waren zwischen den Maschinen gleich, also ist es nicht meine Schuld.
Angesichts dieses und des DPMI-Problems begann ich weiter zu suchen.
Narren gcc
Was ich schließlich beschlossen habe, war der knifflige Trick, GCC zu „betrügen“, um DOS-COM-Dateien im Real-Modus zu erstellen. Der Trick funktioniert bis zu 80386 (was normalerweise erforderlich ist). Der 80386-Prozessor wurde 1985 auf den Markt gebracht und war der erste 32-Bit-x86-Mikroprozessor. GCC hält sich auch in x86-64-Umgebungen an diese Anweisungen. Leider kann GCC in keiner Weise 16-Bit-Code produzieren, so dass ich das ursprüngliche Ziel, ein Spiel für 8086 zu entwickeln, aufgeben musste. Dies spielt jedoch keine Rolle, da die Ziel-DOSBox-Plattform im Wesentlichen ein 80386-Emulator ist.
Theoretisch sollte der Trick auch im MinGW-Compiler funktionieren, aber es gibt einen langjährigen Fehler, der verhindert, dass er korrekt funktioniert („PE-Operationen können nicht für Nicht-PE-Ausgabedateien ausgeführt werden“). Es kann jedoch umgangen werden, und ich habe es selbst gemacht: Sie sollten die Anweisung
OUTPUT_FORMAT
entfernen und einen zusätzlichen
objcopy
Schritt hinzufügen (
objcopy -O binary
).
Hallo Welt unter DOS
Zur Demonstration erstellen wir das DOS COM-Programm „Hello, World“ mit GCC unter Linux.
Diese Methode weist ein großes und bedeutendes Hindernis auf:
Es wird keine Standardbibliothek geben . Es ist, als würde man ein Betriebssystem von Grund auf neu schreiben, mit Ausnahme einiger Dienste, die DOS bereitstellt. Das heißt kein
printf()
oder ähnliches. Stattdessen bitten wir DOS, die Zeichenfolge auf der Konsole zu drucken. Das Erstellen einer DOS-Anforderung erfordert einen Interrupt, dh Inline-Assembler-Code!
DOS hat neun Interrupts: 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x2F. Das Wichtigste, was uns interessiert, ist 0x21, die Funktion 0x09 (Zeile drucken). Zwischen DOS und BIOS gibt es
Tausende von Funktionen, die nach diesem Muster benannt sind . Ich werde nicht versuchen, den x86-Assembler zu erklären, aber kurz gesagt, die Funktionsnummer bleibt im
ah
Register hängen - und der 0x21-Interrupt wird ausgelöst. Die Funktion 0x09 nimmt auch ein Argument an - einen Zeiger auf eine Zeile zum Drucken, die in den Registern
dx
und
ds
wird.
Hier ist die
print()
Funktion des GCC-Inline-Assemblers. An diese Funktion übergebene Zeilen müssen mit dem Zeichen $ enden. Warum? Weil DOS.
static void print(char *string) { asm volatile ("mov $0x09, %%ah\n" "int $0x21\n" : : "d"(string) : "ah"); }
Der Code wird als
volatile
deklariert, da er einen Nebeneffekt hat (Zeilendruck). Für GCC ist der Assembler-Code undurchsichtig, und der Optimierer stützt sich auf Einschränkungen bei Ausgabe / Eingabe / Clobber (letzte drei Zeilen). Für solche DOS-Programme hat jeder eingebaute Assembler Nebenwirkungen. Dies liegt daran, dass es nicht für die Optimierung geschrieben wurde, sondern für den Zugriff auf Hardwareressourcen und DOS - Dinge, auf die einfaches C nicht zugreifen kann.
Sie müssen sich auch um die aufrufende Anweisung kümmern, da GCC nicht weiß, dass der Speicher, auf den die
string
zeigt, jemals gelesen wurde. Es ist wahrscheinlich, dass ein Array, das die Zeichenfolge unterstützt, ebenfalls als
volatile
deklariert werden muss. All dies deutet auf das Unvermeidliche hin: Alle Aktionen in einer solchen Umgebung werden zu einem endlosen Kampf mit dem Optimierer. Nicht alle diese Schlachten können gewonnen werden.
Nun zur Hauptfunktion. Sein Name ist im Prinzip nicht wichtig, aber ich vermeide es, ihn
main()
, weil MinGW lustige Ideen hat, wie man solche Charaktere spezifisch verarbeitet, auch wenn sie ihn bitten, dies nicht zu tun.
int dosmain(void) { print("Hello, World!\n$"); return 0; }
COM-Dateien sind auf 65279 Byte begrenzt. Dies liegt daran, dass das x86-Speichersegment 64 KB groß ist und DOS die COM-Dateien einfach auf die 0x0100-Segmentadresse herunterlädt und ausführt. Keine Überschriften, nur eine saubere Binärdatei. Da das COM-Programm im Prinzip keine signifikante Größe haben kann, sollte kein reales Layout (freistehend) auftreten, wird das Ganze als eine einzige Übersetzungseinheit kompiliert. Dies ist ein GCC-Aufruf mit einer Reihe von Parametern.
Compiler-Optionen
Hier sind die wichtigsten Compileroptionen.
-std=gnu99 -Os -nostdlib -m32 -march=i386 -ffreestanding
Da Standardbibliotheken nicht verwendet werden, besteht der einzige Unterschied zwischen gnu99 und c99 in den getrennten Trigraphen (wie es sein sollte), und der integrierte Assembler kann als
asm
anstelle von
__asm__
. Dies ist nicht Newtons Mülleimer. Das Projekt wird so eng mit GCC verbunden sein, dass ich mir immer noch keine Sorgen um die GCC-Erweiterungen mache.
Die Option
-Os
reduziert das Kompilierungsergebnis so weit wie möglich. Das Programm wird also schneller arbeiten. Dies ist im Hinblick auf DOSBox wichtig, da der Standardemulator langsam wie eine 80er-Maschine läuft. Ich möchte in diese Einschränkung passen. Wenn der Optimierer Probleme verursacht,
-O0
vorübergehend
-O0
um festzustellen, ob Ihr Fehler oder der Optimierer hier ist.
Wie Sie sehen können, versteht der Optimierer nicht, dass das Programm im Real-Modus mit den entsprechenden Adressierungsbeschränkungen arbeitet.
Es führt alle Arten von ungültigen Optimierungen durch, die Ihre perfekt gültigen Programme beschädigen. Dies ist kein GCC-Fehler, da wir selbst hier verrückte Dinge tun. Ich musste den Code mehrmals wiederholen, um zu verhindern, dass der Optimierer das Programm bricht. Zum Beispiel mussten wir vermeiden, komplexe Strukturen von Funktionen zurückzugeben, da diese manchmal mit Müll gefüllt waren. Die wirkliche Gefahr besteht darin, dass die zukünftige Version von GCC noch intelligenter wird und noch mehr Code bricht. Hier ist dein Freund
volatile
.
Der nächste Parameter ist
-nostdlib
, da wir auch statisch keine Verknüpfung zu gültigen Bibliotheken herstellen können.
Die Parameter
-m32-march=i386
Compiler an, den Code 80386 auszugeben. Wenn ich den Bootloader für einen modernen Computer schreiben würde, wäre die Sicht auf 80686 ebenfalls normal, aber die DOSBox ist 80386.
Das Argument
-ffreestanding
erfordert, dass GCC keinen Code
-ffreestanding
, der auf die
-ffreestanding
der integrierten Standardbibliothek zugreift. Manchmal wird anstelle von tatsächlich arbeitendem Code ein Code zum Aufrufen einer integrierten Funktion erstellt, insbesondere bei mathematischen Operatoren. Ich hatte eines der Hauptprobleme mit bcc, bei dem dieses Verhalten nicht deaktiviert werden kann. Diese Option wird am häufigsten beim Schreiben von Bootloadern und Betriebssystemkernen verwendet. Und jetzt die dos dos .com Dateien.
Linker-Optionen
Die
-Wl
verwendet, um Argumente an den Linker (
ld
) zu übergeben. Wir brauchen das, weil wir alles in einem Anruf bei GCC erledigen.
-Wl,--nmagic,--script=com.ld
--nmagic
deaktiviert die Seitenausrichtung von Abschnitten. Erstens brauchen wir es nicht. Zweitens verschwendet es wertvollen Platz. In meinen Tests scheint dies keine notwendige Maßnahme zu sein, aber nur für den Fall, ich lasse diese Option.
Der Parameter
--script
gibt an, dass wir ein spezielles
Linker-Skript verwenden möchten. Auf diese Weise können Sie die Abschnitte (
text
,
data
,
bss
,
rodata
) unseres Programms genau platzieren. Hier ist das
com.ld
Skript.
OUTPUT_FORMAT(binary) SECTIONS { . = 0x0100; .text : { *(.text); } .data : { *(.data); *(.bss); *(.rodata); } _heap = ALIGN(4); }
OUTPUT_FORMAT(binary)
weist Sie an, dies nicht in eine ELF-Datei (oder PE usw.) zu legen. Der Linker sollte nur den sauberen Code zurücksetzen. Eine COM-Datei ist nur sauberer Code, das heißt, wir geben dem Linker den Befehl, eine COM-Datei zu erstellen!
Ich sagte, dass COM-Dateien auf
0x0100
hochgeladen
0x0100
. Die vierte Zeile verschiebt die Binärdatei dort. Das erste Byte der COM-Datei ist immer noch das erste Byte des Codes, wird jedoch von diesem Speicheroffset aus gestartet.
Dann folgen alle Abschnitte:
text
(Programm),
data
(statische Daten),
bss
(Daten ohne Initialisierung),
rodata
(Zeichenfolgen). Schließlich markiere ich das Ende der Binärdatei mit dem Symbol
_heap
. Dies wird später beim Schreiben von
sbrk()
wenn wir mit „Hallo Welt“ fertig sind. Ich habe angegeben,
_heap
mit 4 Bytes auszurichten.
Fast fertig.
Programmstart
Der Linker kennt normalerweise unseren Einstiegspunkt (
main
) und richtet ihn für uns ein. Da wir jedoch ein „binäres“ Problem angefordert haben, müssen wir es selbst herausfinden. Wenn die Funktion
print()
als erste ausgeführt wird, startet das Programm damit, was falsch ist. Das Programm benötigt eine kleine Überschrift, um loszulegen.
Für solche Dinge gibt es im Linker-Skript eine
STARTUP
Option, die der Einfachheit halber jedoch direkt im Programm implementiert wird. Normalerweise heißen solche Dinge
crt0.o
oder
Boot.o
, falls Sie irgendwo darauf
Boot.o
. Unser Code
muss mit diesem eingebauten Assembler beginnen, bevor Einschlüsse und dergleichen vorgenommen werden. DOS übernimmt den größten Teil der Installation für uns. Wir müssen nur zum Einstiegspunkt gehen.
asm (".code16gcc\n" "call dosmain\n" "mov $0x4C, %ah\n" "int $0x21\n");
.code16gcc
teilt dem Assembler mit, dass wir im Real-Modus arbeiten werden, damit die richtige Konfiguration vorgenommen wird. Trotz des Namens wird
kein 16-Bit-Code erzeugt! Zunächst wird die
dosmain
Funktion, die wir zuvor geschrieben haben, aufgerufen. Anschließend teilt er DOS mit der 0x4C-Funktion („Mit Rückkehrcode beenden“) mit, dass wir den Exit-Code an das 1-Byte-Register übergeben (bereits von
dosmain
). Dieser eingebaute Assembler ist automatisch
volatile
da er keine Ein- und Ausgänge hat.
Alle zusammen
Hier ist das ganze Programm in C.
asm (".code16gcc\n" "call dosmain\n" "mov $0x4C,%ah\n" "int $0x21\n"); static void print(char *string) { asm volatile ("mov $0x09, %%ah\n" "int $0x21\n" : : "d"(string) : "ah"); } int dosmain(void) { print("Hello, World!\n$"); return 0; }
Ich werde
com.ld
nicht wiederholen
com.ld
Hier ist die GCC-Herausforderung.
gcc -std=gnu99 -Os -nostdlib -m32 -march=i386 -ffreestanding \ -o hello.com -Wl,--nmagic,--script=com.ld hello.c
Und seine Tests in DOSBox:

Wenn Sie schöne Grafiken wünschen, müssen Sie nur den Interrupt aufrufen und
in den VGA-Speicher schreiben . Wenn Sie Ton wünschen, verwenden Sie den PC-Lautsprecher-Interrupt. Ich habe nicht herausgefunden, wie ich Sound Blaster nennen soll. Von diesem Moment an wuchs DOS Defender auf.
Speicherzuordnung
_heap
daran, dass
_heap
um ein anderes Thema zu
_heap
? Wir können es verwenden, um
sbrk()
zu implementieren und Speicher im Hauptabschnitt des Programms dynamisch zuzuweisen. Dies ist ein realer Modus und es gibt keinen virtuellen Speicher, sodass wir in jeden Speicher schreiben können, auf den wir jederzeit zugreifen können. Einige Bereiche (z. B. unterer und oberer Speicher) sind für Geräte reserviert. Es besteht also keine
wirkliche Notwendigkeit, sbrk () zu verwenden, aber es ist interessant, es zu versuchen.
Wie bei x86 üblich, befinden sich Ihr Programm und Ihre Partitionen im unteren Speicher (in diesem Fall 0x0100) und der Stapel im oberen Speicher (in unserem Fall im Bereich 0xffff). Auf Unix-ähnlichen Systemen stammt der von
malloc()
zurückgegebene
malloc()
von zwei Stellen:
sbrk()
und
mmap()
. Was
sbrk()
tut, ist, Speicher direkt über Programm- / Datensegmenten zuzuweisen und ihn in Richtung des Stapels „nach oben“ zu erhöhen. Jeder Aufruf von
sbrk()
vergrößert diesen Platz (oder lässt ihn genau gleich). Dieser Speicher wird von
malloc()
und dergleichen verwaltet.
So implementieren Sie
sbrk()
in einem COM-Programm. Bitte beachten Sie, dass Sie Ihre eigene
size_t
definieren müssen, da wir keine Standardbibliothek haben.
typedef unsigned short size_t; extern char _heap; static char *hbreak = &_heap; static void *sbrk(size_t size) { char *ptr = hbreak; hbreak += size; return ptr; }
Es setzt einfach den Zeiger auf
_heap
und erhöht ihn nach Bedarf. Ein etwas schlaueres
sbrk()
wird auch bei der Ausrichtung vorsichtig sein.
Bei der Erstellung von DOS Defender ist etwas Interessantes passiert. Ich habe (fälschlicherweise) angenommen, dass der Speicher von meinem
sbrk()
zurückgesetzt wurde. So war es nach dem ersten Spiel. DOS setzt diesen Speicher zwischen den Programmen jedoch nicht zurück. Als ich das Spiel erneut startete, wurde
es genau dort fortgesetzt, wo ich aufgehört hatte , da dieselben Datenstrukturen mit demselben Inhalt geladen wurden. Ziemlich cooler Zufall! Dies ist ein Teil dessen, was diese eingebettete Plattform zum Spaß macht.