Kompilieren der DOS-COM-Datei mit dem GCC-Compiler

Artikel veröffentlicht am 9. Dezember 2014
Update 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" : /* no output */ : "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" : /* no output */ : "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.

Source: https://habr.com/ru/post/de412867/


All Articles