Article publié le 9 décembre 2014Mise à jour pour 2018: RenéRebe a réalisé une vidéo intéressante basée sur cet article ( partie 2 )Le week-end dernier, j'ai participé à
Ludum Dare # 31 . Mais avant même que la conférence ne soit annoncée, à cause de mon
récent passe-temps, je voulais faire un jeu à l'ancienne sous DOS. La plate-forme cible est DOSBox. C'est le moyen le plus pratique pour exécuter des applications DOS, malgré le fait que tous les processeurs x86 modernes sont entièrement rétrocompatibles avec les anciens, jusqu'au 8086 16 bits.
J'ai créé et montré avec succès le jeu
DOS Defender lors de la conférence. Le programme fonctionne dans le mode réel du 8038 32 bits. Toutes les ressources sont intégrées dans le fichier COM exécutable, sans dépendances externes, donc le jeu entier est emballé dans un binaire de 10 kilo-octets.
Vous aurez besoin d'un joystick ou d'une manette pour jouer. J'ai inclus la prise en charge de la souris dans la version de Ludum Dare à des fins de présentation, mais je l'ai ensuite supprimée car cela ne fonctionnait pas très bien.
La partie la plus intéressante techniquement est
qu'aucun outil de développement DOS n'était nécessaire pour créer le jeu ! J'ai utilisé uniquement le compilateur Linux C standard (gcc). En réalité, vous ne pouvez même pas créer un défenseur DOS pour DOS. Je vois DOS uniquement comme une plate-forme embarquée, qui est la seule forme sous laquelle
DOS existe encore aujourd'hui . Avec DOSBox et DOSEMU, il s'agit d'un ensemble d'outils assez pratique.
Si vous n'êtes intéressé que par la partie pratique du développement, allez dans la section «Cheat on GCC», où nous écrirons le programme DOS COM «Hello, World» avec GCC Linux.
Trouver les bons outils
Quand j'ai commencé ce projet, je ne pensais pas à GCC. En réalité, je suis allé de cette façon lorsque j'ai découvert le paquet
bcc (Bruce's C Compiler) pour Debian, qui collecte des binaires 16 bits pour 8086. Il est utilisé pour la compilation de chargeurs de démarrage x86 et d'autres choses, mais bcc peut également être utilisé pour compiler des fichiers COM COM DOS. Ça m'a intéressé.
Pour référence: le microprocesseur Intel 8086 16 bits est sorti en 1978. Il n'avait aucune caractéristique bizarre des processeurs modernes: pas de protection de la mémoire, pas d'instructions en virgule flottante et seulement 1 Mo de RAM adressable. Tous les ordinateurs de bureau et portables x86 modernes peuvent toujours prétendre être ce processeur 16 bits 8086 il y a quarante ans, avec le même adressage limité et tout cela. Il s'agit d'une compatibilité assez ancienne. Une telle fonction est appelée
mode réel . Il s'agit du mode dans lequel tous les ordinateurs x86 démarrent. Les systèmes d'exploitation modernes passent immédiatement en
mode protégé avec un adressage virtuel et un multitâche sécurisé. DOS ne l'a pas fait.
Malheureusement, bcc n'est pas un compilateur ANSI C. Il prend en charge un sous-ensemble de K&R C, ainsi que le code assembleur x86 intégré. Contrairement aux autres compilateurs 8086 C, il n'a pas le concept de pointeurs «éloignés» ou «longs», donc un code assembleur intégré est nécessaire pour accéder à d'
autres segments de mémoire (VGA, horloges, etc.). Remarque: les restes de ces "longs pointeurs" 8086 sont toujours conservés dans l'API Win32:
LPSTR
,
LPWORD
,
LPDWORD
, etc. Cet assembleur intégré ne se compare même pas étroitement avec l'assembleur intégré GCC. Dans l'assembleur, vous devez charger manuellement les variables à partir de la pile, et puisque bcc prend en charge deux conventions d'appel différentes, les variables dans le code doivent être codées en dur conformément à l'une ou l'autre convention.
Compte tenu de ces limites, j'ai décidé de chercher des alternatives.
DJGPP
DJGPP - Port GCC sous DOS. Un projet vraiment très impressionnant qui transfère presque tout le POSIX sous DOS. De nombreux programmes sous DOS sont créés sur DJGPP. Mais il ne crée que des programmes 32 bits pour le mode protégé. Si en mode protégé vous devez travailler avec du matériel (par exemple, VGA), le programme fait des requêtes au service de
l'interface DOS en mode protégé (DPMI). Si je prenais DJGPP, je n'aurais pas pu me limiter à un seul binaire autonome, car je devrais avoir un serveur DPMI. Les performances souffrent également des demandes de DPMI.
Obtenir les outils nécessaires pour DJGPP est pour le moins difficile. Heureusement, j'ai trouvé un projet
build-djgpp utile qui exécute tout, au moins sous Linux.
Soit il y a eu une grave erreur, soit les binaires officiels de DJGPP ont été à nouveau
infectés par le virus , mais lorsque j'ai démarré mes programmes dans DOSBox, l'erreur «Not COFF: check for virus» est constamment apparue. Pour vérifier davantage que les virus ne sont pas sur ma propre machine, j'ai configuré l'environnement DJGPP sur mon Raspberry Pi, qui agit comme une salle blanche. Ce périphérique ARM ne peut pas être infecté par le virus x86. Et toujours le même problème se posait, et tous les hachages binaires étaient les mêmes entre les machines, donc ce n'est pas de ma faute.
Donc, étant donné cela et le problème DPMI, j'ai commencé à chercher plus loin.
Fooling gcc
Ce sur quoi je me suis finalement fixé était l'astuce de "tricher" GCC pour construire des fichiers DOS COM en mode réel. L'astuce fonctionne jusqu'à 80386 (ce qui est généralement ce dont vous avez besoin). Le processeur 80386 a été lancé en 1985 et est devenu le premier microprocesseur x86 32 bits. GCC adhère toujours à cet ensemble d'instructions, même dans les environnements x86-64. Malheureusement, GCC ne peut en aucun cas produire du code 16 bits, j'ai donc dû abandonner l'objectif initial de créer un jeu pour 8086. Cependant, cela n'a pas d'importance, car la plate-forme DOSBox cible est essentiellement un émulateur 80386.
En théorie, l'astuce devrait également fonctionner dans le compilateur MinGW, mais il existe une erreur de longue date qui l'empêche de fonctionner correctement («ne peut pas effectuer d'opérations PE sur un fichier de sortie non PE»). Cependant, vous pouvez le contourner, et je l'ai fait moi-même: vous devez supprimer la directive
OUTPUT_FORMAT
et ajouter une étape
objcopy
supplémentaire (
objcopy -O binary
).
Hello World sur DOS
Pour démonstration, nous allons créer le programme COM COM "Hello, World" en utilisant GCC sous Linux.
Il y a un obstacle majeur et significatif à cette méthode:
il n'y aura pas de bibliothèque standard . C'est comme écrire un système d'exploitation à partir de zéro, à l'exception de quelques services fournis par DOS. Cela signifie pas de
printf()
ou similaire. Au lieu de cela, nous demandons à DOS d'imprimer la chaîne sur la console. La création d'une requête DOS nécessite une interruption, ce qui signifie du code assembleur en ligne!
Le DOS a neuf interruptions: 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x2F. La chose la plus importante qui nous intéresse est 0x21, la fonction 0x09 (imprimer une ligne). Entre DOS et BIOS, il existe des
milliers de fonctions nommées d'après ce modèle . Je ne vais pas essayer d'expliquer l'assembleur x86, mais en un mot, le numéro de la fonction est coincé dans le registre
ah
- et l'interruption 0x21 se déclenche. La fonction 0x09 prend également un argument - un pointeur vers une ligne pour l'impression, qui est passée dans les registres
dx
et
ds
.
Voici la fonction
print()
de l'assembleur en ligne GCC. Les lignes passées à cette fonction doivent se terminer par le caractère $. Pourquoi? Parce que DOS.
static void print(char *string) { asm volatile ("mov $0x09, %%ah\n" "int $0x21\n" : : "d"(string) : "ah"); }
Le code est déclaré
volatile
car il a un effet secondaire (impression de ligne). Pour GCC, le code assembleur est opaque et l'optimiseur s'appuie sur les restrictions de sortie / entrée / clobber (trois dernières lignes). Pour de tels programmes DOS, tout assembleur intégré aura des effets secondaires. C'est parce qu'il est écrit non pas pour l'optimisation, mais pour l'accès aux ressources matérielles et au DOS - des choses inaccessibles au simple C.
Vous devez également prendre soin de l'instruction appelante, car GCC ne sait pas que la mémoire pointée par la
string
a déjà été lue. Il est probable qu'un tableau qui prend en charge la chaîne devra également être déclaré
volatile
. Tout cela laisse présager l'inévitable: toute action dans un tel environnement se transforme en une lutte sans fin avec l'optimiseur. Toutes ces batailles ne peuvent pas être gagnées.
Passons maintenant à la fonction principale. Son nom n'est pas important en principe, mais j'évite de l'appeler
main()
, car MinGW a des idées amusantes sur la façon de traiter ces caractères spécifiquement, même s'ils lui demandent de ne pas le faire.
int dosmain(void) { print("Hello, World!\n$"); return 0; }
La taille des fichiers COM est limitée à 65279 octets. En effet, le segment de mémoire x86 fait 64 Ko et DOS télécharge simplement les fichiers COM à l'adresse du segment 0x0100 et s'exécute. Pas de titres, juste un binaire propre. Étant donné que le programme COM, en principe, ne peut pas avoir une taille significative, alors aucune mise en page réelle (autonome) ne doit se produire, le tout est compilé comme une seule unité de traduction. Ce sera un appel GCC avec un tas de paramètres.
Options du compilateur
Voici les principales options du compilateur.
-std=gnu99 -Os -nostdlib -m32 -march=i386 -ffreestanding
Comme les bibliothèques standard ne sont pas utilisées, la seule différence entre gnu99 et c99 est les trigraphes désactivés (comme il se doit), et l'assembleur intégré peut être écrit en
asm
au lieu de
__asm__
. Ce n'est pas le bac de Newton. Le projet sera si étroitement lié à GCC que je ne suis toujours pas préoccupé par les extensions de GCC.
L'option
-Os
réduit autant que possible le résultat de la compilation. Le programme fonctionnera donc plus rapidement. Ceci est important en vue de DOSBox, car l'émulateur par défaut s'exécute lentement comme une machine des années 80. Je veux m'intégrer dans cette limitation. Si l'optimiseur cause des problèmes,
-O0
temporairement
-O0
pour déterminer si votre erreur ou l'optimiseur est là.
Comme vous pouvez le voir, l'optimiseur ne comprend pas que le programme fonctionnera en mode réel avec les restrictions d'adressage correspondantes.
Il effectue toutes sortes d'optimisations invalides qui cassent vos programmes parfaitement valides. Ce n'est pas un bug de GCC, car nous faisons nous-mêmes des choses folles ici. J'ai dû refaire le code plusieurs fois pour empêcher l'optimiseur de casser le programme. Par exemple, nous avons dû éviter de renvoyer des structures complexes à partir de fonctions car elles étaient parfois remplies de déchets. Le vrai danger est que la future version de GCC deviendra encore plus intelligente et cassera encore plus de code. Voici votre ami
volatile
.
Le paramètre suivant est
-nostdlib
, car nous ne pourrons pas lier à des bibliothèques valides, même statiquement.
Les paramètres
-m32-march=i386
compilateur d'émettre le code 80386. Si j'écrivais le chargeur de démarrage pour un ordinateur moderne, la vue sur 80686 serait également normale, mais la DOSBox est 80386.
L'argument
-ffreestanding
requiert que GCC ne
-ffreestanding
pas de code qui accède aux fonctions d'assistance de la bibliothèque standard intégrée. Parfois, au lieu de travailler réellement sur du code, il produit un code pour appeler une fonction en ligne, en particulier avec des opérateurs mathématiques. J'ai eu l'un des principaux problèmes avec bcc, où ce comportement ne peut pas être désactivé. Cette option est le plus souvent utilisée lors de l'écriture de chargeurs de démarrage et de noyaux de système d'exploitation. Et maintenant les fichiers dos dos .com.
Options de l'éditeur de liens
L'
-Wl
utilisée pour passer des arguments à l'éditeur de liens (
ld
). Nous en avons besoin parce que nous faisons tout en un seul appel au CCG.
-Wl,--nmagic,--script=com.ld
--nmagic
désactive l'alignement des pages de section. Premièrement, nous n'en avons pas besoin. Deuxièmement, il gaspille un espace précieux. Dans mes tests, cela ne semble pas être une mesure nécessaire, mais juste au cas où, je laisse cette option.
Le paramètre
--script
indique que nous voulons utiliser un
script de l'éditeur de liens spécial. Cela vous permet de placer avec précision les sections (
text
,
data
,
bss
,
rodata
) de notre programme. Voici le script
com.ld
OUTPUT_FORMAT(binary) SECTIONS { . = 0x0100; .text : { *(.text); } .data : { *(.data); *(.bss); *(.rodata); } _heap = ALIGN(4); }
OUTPUT_FORMAT(binary)
vous dit de ne pas le mettre dans un fichier ELF (ou PE, etc.). L'éditeur de liens doit simplement réinitialiser le code propre. Un fichier COM est juste du code propre, c'est-à-dire que nous donnons la commande à l'éditeur de liens pour créer un fichier COM!
J'ai dit que les fichiers COM sont téléchargés sur
0x0100
. La quatrième ligne y déplace le binaire. Le premier octet du fichier COM est toujours le premier octet du code, mais sera lancé à partir de ce décalage de mémoire.
Ensuite, toutes les sections suivent:
text
(programme),
data
(
data
statiques),
bss
(données avec initialisation zéro),
rodata
(chaînes). Enfin, je marque la fin du binaire avec le symbole
_heap
. Cela nous sera utile plus tard lors de l'écriture de
sbrk()
lorsque nous aurons terminé avec «Hello, World». J'ai indiqué d'aligner
_heap
avec 4 octets.
Presque terminé.
Lancement du programme
L'éditeur de liens connaît généralement notre point d'entrée (
main
) et le configure pour nous. Mais puisque nous avons demandé un problème «binaire», nous devrons le découvrir nous-mêmes. Si la fonction
print()
est la première à s'exécuter, le programme démarrera, ce qui est faux. Le programme a besoin d'une petite rubrique pour commencer.
Il y a une option
STARTUP
dans le script de l'éditeur de liens pour de telles choses, mais pour plus de simplicité, nous allons l'implémenter directement dans le programme. Habituellement, de telles choses sont appelées
crt0.o
ou
Boot.o
, au cas où vous
Boot.o
dessus quelque part. Notre code
doit commencer par cet assembleur intégré, avant toutes les inclusions et autres. DOS effectuera la majeure partie de l'installation pour nous, nous avons juste besoin d'aller au point d'entrée.
asm (".code16gcc\n" "call dosmain\n" "mov $0x4C, %ah\n" "int $0x21\n");
.code16gcc
indique à l'assembleur que nous allons travailler en mode réel, afin qu'il fasse la configuration correcte. Malgré son nom, il
ne produira
pas de code 16 bits! Tout d'abord, la fonction
dosmain
, que nous avons écrite plus tôt, est appelée. Il indique ensuite à DOS à l'aide de la fonction 0x4C («terminer avec le code retour») que nous avons terminé en passant le code de sortie au registre à 1 octet (déjà défini par
dosmain
). Cet assembleur intégré est automatiquement
volatile
car il n'a pas d'entrées et de sorties.
Tous ensemble
Voici l'ensemble du programme en 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; }
Je ne répéterai pas
com.ld
Voici le défi du CCG.
gcc -std=gnu99 -Os -nostdlib -m32 -march=i386 -ffreestanding \ -o hello.com -Wl,--nmagic,--script=com.ld hello.c
Et ses tests dans DOSBox:

Ensuite, si vous voulez de beaux graphismes, la seule question est d'appeler l'interruption et d'
écrire dans la mémoire VGA . Si vous voulez du son, utilisez l'interruption du haut-parleur PC. Je n'ai pas compris comment appeler Sound Blaster. À partir de ce moment, DOS Defender a grandi.
Allocation de mémoire
Pour couvrir un autre sujet, rappelez-vous que
_heap
? Nous pouvons l'utiliser pour implémenter
sbrk()
et allouer dynamiquement de la mémoire dans la section principale du programme. Il s'agit d'un mode réel et il n'y a pas de mémoire virtuelle, nous pouvons donc écrire sur n'importe quelle mémoire à laquelle nous pouvons accéder à tout moment. Certaines zones sont réservées (par exemple, la mémoire inférieure et supérieure) pour l'équipement. Il n'y a donc pas
vraiment besoin d'utiliser sbrk (), mais il est intéressant d'essayer.
Comme d'habitude sur x86, votre programme et vos partitions sont dans la mémoire inférieure (0x0100 dans ce cas), et la pile est dans la mémoire supérieure (dans notre cas, dans la région 0xffff). Sur les systèmes de type Unix, la mémoire renvoyée par
malloc()
vient de deux endroits:
sbrk()
et
mmap()
. Ce que fait
sbrk()
est d'allouer de la mémoire juste au-dessus des segments programme / données, en l'incrémentant «vers le haut» vers la pile. Chaque appel à
sbrk()
augmentera cet espace (ou le laissera exactement le même). Cette mémoire sera gérée par
malloc()
et similaires.
Voici comment implémenter
sbrk()
dans un programme COM. Veuillez noter que vous devez définir votre propre
size_t
, car nous n'avons pas de bibliothèque standard.
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; }
Il définit simplement le pointeur sur
_heap
et l'incrémente selon les besoins. Un peu plus intelligent
sbrk()
fera également attention à l'alignement.
Une chose intéressante s'est produite lors de la création de DOS Defender. J'ai (à tort) considéré que la mémoire de mon
sbrk()
réinitialisée. C'était donc après le premier match. Cependant, DOS ne réinitialise pas cette mémoire entre les programmes. Quand j'ai recommencé le jeu,
il a continué exactement là où je m'étais arrêté , car les mêmes structures de données avec le même contenu ont été chargées en place. Coïncidence assez cool! Cela fait partie de ce qui rend cette plate-forme intégrée amusante.