Afficher une image en VGA mode 13h (assembleur x86)

Depuis la nuit des temps, les PC possèdent différents modes texte et graphique. Chaque mode possède une résolution et un nombre de couleurs spécifique. Un de ces modes est assez facile à manipuler et permet d'afficher jusqu'à 256 couleurs en 320x200, il s'agit du mode 13h, populaire à l'époque des jeux et applications MS-DOS.

Ici on va voir pas à pas comment dessiner à l'écran sur DOS en utilisant l'assembleur Netwide (NASM) depuis un environnement de type Unix pour générer le fichier exécutable (on va travailler avec DOSBOX).

Les modes vidéo classiques pour PC sont nombreux et certains ne sont pas très intéressants ou alors complexes à manipuler, comme le mode 12h qui fournit du 640x480 en 16 couleurs mais planaire, c'est à dire que chaque canal (rouge, vert, bleu, fluo) doit être manipulé séparément. Le mode 13h auquel on va s'intéresser permet de manipuler intuitivement les couleurs et la position des pixels à l'écran. Il offre un bon compromis entre simplicité et agrément.

Je suis loin d'être experte ni en assembleur ni même en développement pour DOS, je voulais juste partager ce que j'ai appris et réussi à faire !

Capture d'écran du jeu vidéo Rayman
Un exemple de graphisme en mode 13h : Rayman pour DOS (1995)

Préparation de l'environnement

Pour commencer, on a besoin des outils suivants : NASM, Dosbox et un éditeur de texte.

Pour Fedora on installe ça comme ça :

sudo dnf install nasm dosbox

Pour macOS (en supposant que brew est préalablement installé) :

brew install nasm dosbox

Ensuite on se crée un dossier de travail, par exemple ~/workspace/dos/vga chez moi. Puis on configure Dosbox pour monter le dossier et y accéder au démarrage. On ouvre Dosbox une première fois pour vérifier que ça fonctionne, puis on édite le fichier de configuration (dont le nom peut changer selon la version).

Sur Fedora Linux il est situé dans ~/.dosbox/dosbox-0.74-3.conf, sur macOS dans ~/Library/Preferences/DOSBox 0.74-3 Preferences.

Tout à la fin dans la section [autoexec] j'ai rajouté les lignes suivantes :

mount C: ~/workspace/dos/vga
C:

Un premier programme

Dans le dossier on peut créer un fichier texte, disons main.asm. Voici un premier contenu :

org 100h

; Passage en mode 13h
    mov ax, 13h
    int 10h

; Attente appui touche
    mov ah, 00h
    int 16h

; On retourne au DOS
    ret

Les commentaires sont les lignes qui commencent par le point-virgule. On peut aussi rajouter un commentaire à la fin d'une ligne.

Ce que tout cela signifie

La notation hexadécimale

Les nombres qui possèdent un h à la fin sont des nombres héxadécimaux, donc en base 16. On les représente avec des caractères de 0 à F. Par exemple FF égale 255 et FFFF égale 65535. Par défaut, pour notre assembleur, un nombre est décimal, mais lorsqu'on va utiliser des interruptions ou des adresses mémoires on va utiliser des nombres héxadécimaux. C'est pratique parce qu'on sait qu'un nombre d'un ou deux caractères tient dans un octet (8 bits) et qu'un nombre de 3 ou 4 caractères tient dans un mot (16 bits). Il y a deux manières de les noter dans notre code :

La directive org

Concernant org, il s'agit d'une directive et non pas d'une instruction (car org n'existe pas dans le processeur). Cette directive s'adresse à notre assembleur pour lui dire que notre programme doit commencer à un certain endroit (à 100h). Ainsi lors de l'assemblage, le programme va être adapté pour que les adresses internes correspondent bien à l'endroit où le DOS démarre notre programme. C'est une particularité du format d'exécutable COM, contrairement au EXE qui n'a pas besoin de ça et qui permet de faire des fichiers plus gros (+ de 64 kb), mais le EXE est plus compliqué pour la gestion de la mémoire ; ici on va travailler sur un fichier COM à l'ancienne.

Les instructions mov

Pour travailler avec le CPU, on utilise des petites zones de mémoire appelées des registres. Il est possible de faire des opérations (comme écraser, additionner, soustraire...) entre une cellule de RAM et un registre ou deux entre registres, mais pas entre deux valeurs en RAM.

Dans notre premier code, chaque mov écrit dans un registre la valeur indiquée. Si quelque chose était présent avant dans le registre, c'est écrasé par la nouvelle valeur. Ainsi le premier mov inscrit la valeur 13h (19 en décimal) dans AX, donc les 16 bits du registre contiennent 0013h.

Le deuxième mov inscrit la valeur 00h (0 aussi en décimal) dans le registre AH. Là il faut savoir qu'en fait le registre AX (qui fait 16 bits) est composé de deux demi-registres, qui sont AH (high) et AL (low) qui sont chacun de 8 bits. C'est pareil pour BX, CX et DX.

Ici on aurait pu éviter ce second mov étant donné que AH contenait déjà 00h et que rien ne vient modifier le registre entre temps, mais on l'a fait par clarté et pour ne pas casser le programme si on rajoute des instructions plus tard.

Les interruptions

De façon générale, le concept de l'interruption est... d'interrompre le déroulement régulier du programme pour effectuer une tâche prioritaire avant de retourner à ce qu'on faisait. Il en existe plusieurs types, par exemple les interruptions matérielles (une frappe clavier ou un clic de souris). Ici on va plutôt parler des interruptions logicielles qu'on déclenche nous-mêmes avec l'instruction int et qui nous permettent d'exécuter des procédures situées en dehors de notre programme ; soit fournies par le BIOS, soit par le DOS.

Dans notre code, la première interruption 10h permet de demander au BIOS de changer de mode vidéo. Elle prend deux paramètres : dans AH le service (ou fonction) voulu (nous on veut 00h pour le changement de mode) et dans AL le mode vidéo voulu (13h en l'occurence).

La seconde interruption 16h permet de travailler avec le clavier. Sa fonction 00h (paramètre AH) permet de demander la lecture d'un caractère et donc d'attendre que l'utilisateur appuie sur une touche. Elle ne prend pas d'autre paramètre mais renvoie dans AH et AL les codes de la touche appuyée (on ne s'en sert pas ici).

Avec le DOS on utilise souvent l'interruption 21h qui permet d'accéder à différents services du système.

L'instruction ret

Comme un CPU possède peu de registres, régulièrement on se retrouve obligé de stocker les valeurs des registres dans un coin pour bricoler autre chose, puis restaurer les anciennes valeurs pour retourner à ce qu'on faisait avant.

Pour jongler avec la mémoire, il existe un outil pratique : c'est la pile. C'est une zone de mémoire vive dans laquelle on empile les valeurs dont on va se resservir, puis qu'on dépile une fois qu'on en a besoin. C'est comme une pile d'assiette, c'est à dire que pour accéder à la troisième assiette il faut avoir dépilé les deux premières avant.

La pile est utilisée par plusieurs instructions !

Ce qui est intéressant dans le cadre d'un programme DOS, c'est qu'à l'ouverture d'un programme, le DOS empile automatiquement la valeur 0000. En faisant ret depuis le fil principal on exécute alors l'instruction située à l'emplacement 0000 de la RAM, et là le DOS y a situé automatiquement une interruption 20h qui sert à retourner à l'invite de commandes, donc à terminer proprement.

Raymond Chen explique ça en détail ici, son blog est excellent.

Création d'un exécutable

Pour créer le fichier exécutable, on assemble avec une commande :

nasm -o main.com main.asm

Ça crée un fichier COM exécutable pour DOS. Si tout se passe bien, l'assembleur n'affiche pas de message.

Dans DOSBOX, il suffit de taper main.com ou simplement main pour que le programme se lance. Tout ce qu'il fait, c'est afficher un écran noir. Une fois qu'on appuie sur une touche on revient au prompt mais le texte est gros et pixellisé : c'est normal, nous avons basculé en mode 13h et nous y sommes resté.e.s.

Prompt DOS en mode 13h

Pour que le programme retourne dans le mode texte par défaut (03h) avant de quitter on peut rajouter les lignes suivantes à la fin (mais avant le ret) :

; Retour en mode texte
mov ax, 03h
int 10h

On appelle la fonction 00h (changer de mode) pour le mode 03h, dans l'interruption 10h.

Afficher un pixel sur l'écran

On passe définitivement aux choses sérieuses !!! Ce qui est bien avec le DOS et le mode 13h, c'est qu'on peut se contenter de mettre un nombre dans une zone de mémoire pour colorer un pixel.

La zone de mémoire qui nous intéresse commence à l'adresse A000:0000 et s'étend sur 64000 octets (=320x200). Il s'agit d'un mode de 256 couleurs et un octet fait 8 bits ce qui est le nécessaire pour définir une valeur entre 0 et 255 (2ˆ8 = 256).

Palette de couleurs par défaut en mode 13h
La palette par défaut en mode 13h [source]

Les couleurs sont arrangées dans cet ordre un peu bizarre parce que la palette est rétro-compatible avec le mode 16 couleurs (la première ligne). Il est possible de redéfinir la palette à partir des 262 144 couleurs disponibles (64ˆ3), ce qui est très pratique pour avoir des teintes personnalisées ou pour remplir les 8 dernières cases.

Autre chose bien pratique, on passe d'une ligne de pixels à l'autre sans coupure. Il n'y a pas de complexité liée au balayage. Ainsi, pour afficher un pixel pile au milieu de l'écran, on doit descendre de 100 lignes (100x320) et aller vers la droite d'une demi-ligne (320/2). Ce qui nous donne 32000 + 160 = 32160. Ce sera donc notre décalage par rapport à l'adresse de base.

VGA c'est pour Video Graphics Array, c'est littéralement de ça qu'il s'agit !

Les ordinateurs de cette époque, comme par exemple les différents IBM PS/2, avaient entre 512 Kio et 1 Mio de mémoire vive et certains étaient extensibles jusqu'à 4 Mio. Problème : les registres du Intel 8086 étaient de 16 bits tout au plus, ce qui permet de contenir des adresses allant jusqu'à 65535 (FFFF en hexadécimal) donc 64 Kio. Pour résoudre le problème, on utilise un système de segments et d'offsets. Contrairement à ce que voudrait l'intuition, les segments ne s'enchainent pas tous les 65535 octets mais plutôt tous les 16 octets. Ils se chevauchent, donc une zone mémoire peut être accessible par plusieurs adresses. Cf. cours de Benoît M.

Ce qui est essentiel ici c'est surtout de savoir que le framebuffer VGA commence au segment 0A000h et à l'offset 0000h.

Dans notre programme, juste après le passage en mode 13h, on ajoute la première partie de notre adresse dans le registre de segment ES (pour extra segment). On ne peut pas y écrire directement, on doit passer par un registre général.

mov ax, 0A000h
mov es, ax

La prochaine étape est d'indiquer l'offset que nous avons calculé. Pour cela on utilise DI (destination index) qui est un des différents registres d'offset disponibles sur le processeur. On peut y insérer directement la valeur en décimal calculée.

mov di, 32160

Enfin on peut choisir une couleur dans la palette (ici un genre de orange) et l'appliquer à notre pixel.

mov al, 058h
mov [es:di], al

Les crochets signifient que ES:DI est une adresse de mémoire à laquelle on veut écrire.

Pixel sur fond noir

Affichage d'une ligne

Maintenant que nous avons un pixel nous pouvons utiliser une boucle pour générer une ligne. Les étapes suivantes seront l'affichage de plusieurs lignes pour composer un rectangle, puis enfin afficher notre image. Pour faire une boucle, on utilise simplement l'instruction loop.

Remplaçons notre ligne mov [es:di], al par la structure suivante :

mov cx, 50
ligne:
    mov [es:di], al
    inc di
    loop ligne

La ligne d'origine qui insère la couleur à l'écran est toujours la même, par contre des choses se sont rajoutées autour. La première chose notable est ligne:, il s'agit d'un label, c'est une sorte de marque-page qui marque un endroit dans le code. Ils permettent de facilement se repérer et d'y faire des sauts. Le rôle de l'instruction loop est de décrémenter CX puis de revenir au label tant que CX est plus grand que zéro. Ainsi on boucle 50 fois et le décompte est automatique. On a aussi une instruction inc di qui incrémente le registre DI à chaque passage pour avancer horizontalement octet par octet pour créer une ligne. On aurait aussi pu écrire add di, 1 , c'est pareil.

Ligne sur fond noir

Affichage d'un rectangle

Pour afficher un rectangle on reste sur le même principe en faisant une boucle sur la boucle. La seconde boucle permet de descendre pour tracer la forme ligne par ligne. Problème : on ne peut pas imbriquer les utilisations de loop puisque cette instruction fonctionne en utilisant le registre CX (on ne peut pas en choisir un autre). Ce n'est pas grave, on peut utiliser un saut conditionnel qui fait à peu près la même chose.

Au dessus de mov cx, 50 on rajoute un registre BX qui contient le nombre de lignes (hauteur du rectangle) ainsi qu'un autre label.

mov bx, 30
colonne:

En dessous du loop, on rajoute aussi quelques instructions :

    add di, 320 ; on passe à la ligne suivante
    sub di, 50  ; on se remet au début dans la ligne
    dec bx
    cmp bx, 0
    jne colonne

En ce qui concerne add et sub la première instruction permet de passer à la ligne suivante (une ligne fait 320 pixels). La seconde instruction permet de revenir au début de cette même ligne. On pourrait très bien utiliser une seule instruction et faire add di, 270 mais séparer le processus en deux instructions peut être plus clair ou plus pratique pour plus tard.

Les trois dernières instructions sont équivalentes au loop, avec simplement l'utilisation du registre BX à la place de CX. jne signifie jump if not equal et va sauter au label indiqué tant que le résultat de l'instruction du dessus n'est pas l'égalité. Étant donné qu'on décrémente BX à chaque tour, au bout de 30 fois BX va égaler zéro et le jump ne fera plus effet.

Carré plein sur fond noir

Code complet entre le passage en mode 13h et l'attente clavier :

; affichage d'un rectangle
mov ax, 0A000h
mov es, ax      ; ES = segment du framebuffer VGA
mov di, 32160   ; milieu de l'écran (en base 10)
mov al, 058h    ; couleur orange clair
mov bx, 30      ; nombre de lignes
colonne:
    mov cx, 50  ; nombre de pixels par ligne
    ligne:
        mov [es:di], al
        inc di
        loop ligne
    add di, 320 ; on passe à la ligne suivante
    sub di, 50  ; on se remet au début dans la ligne
    dec bx
    cmp bx, 0
    jne colonne

Préparer une image pour l'afficher

Avant de pouvoir insérer une image dans le programme on doit la convertir en 256 couleurs dans la palette VGA. Je vous renvoie à ce tutoriel qui explique comme le faire avec GIMP. Vous pouvez jouer avec les options de luminosité et de contraste comme je l'ai fait pour avoir le meilleur rendu possible après conversion.

Photo de Lena (jeune femme, image d'exemple)
Réduction à 150x150 px
Photo convertie en 256 couleurs
Le fichier converti

Ensuite il faut extraire les pixels du format BMP pour avoir une suite d'octets à insérer dans le programme. J'ai fait un script en Python pour ça : bmptovga.

Affichage d'une image

Quelques petites adaptations sont nécessaires pour afficher une image. Pour commencer on peut importer notre fichier .asm généré par le script dans le même dossier que main.asm puis l'importer dans notre programme. Pour cela, on ajoute la ligne suivante à la toute fin, après le ret.

%include "image.asm"

Pourquoi à la fin ? Pour éviter que cette partie de ressource image soit interprétée comme du code ; en effet, il n'y a pas de séparation stricte entre le code et les données. Une autre solution moins simple aurait été d'insérer un saut jmp et un label pour sauter par-dessus la ressource.

L'autre chose à modifier est notre position de début de dessin dans le registre DI. L'image fait 150x150 px, pour l'afficher centrée il faut qu'elle débute aux coordonnées (320-150)/2 ; (200-150)/2, ce qui nous donne 85;25. On convertit cette coordonnée avec la formule x + (320 * y), ce qui nous donne 8085.

Ensuite il faut modifier BX et CX qui sont respectivement à 30 et 50 pour les mettre tous les deux à 150 ainsi que sub di.

On peut déjà tester si c'est bon !

Grand carré centré sur fond noir

Plutôt que charger chaque couleur directement on peut indiquer l'adresse de la ressource image dans un registre. On peut donc retirer le mov al. La méthode consiste maintenant à aller chercher la valeur située à l'adresse du label image: (déclaré dans le fichier .asm), qui contient la couleur du premier pixel de l'image. En incrémentant notre registre, on se déplace d'adresse en adresse et à chaque fois on récupère le pixel correspondant.

Pour faire ça nous ne pouvons pas utiliser AL, c'est une limitation du processeur. Les seuls registres d'index disponibles sont BP, SI, DI et BX. Les deux derniers sont déjà utilisés mais SI est libre.

On remplace donc l'ancienne instruction qui donne la couleur par :

mov si, image

Dans le label ligne:, on extrait la valeur (le code couleur) à l'adresse située dans SI pour la stocker dans AL, on rajoute donc cette instruction juste après le label :

mov al, [si]

Les crochets signifient qu'on copie dans AL la valeur à l'adresse contenue dans SI, mais pas SI directement. La ligne suivante reste intacte, puisqu'on copie toujours notre valeur à l'adresse indiquée par ES:DI.

Ensuite on incrémente DI pour se déplacer de pixel en pixel sur la ligne. On va juste en dessous rajouter un incrément pour SI sous la forme inc si, pour se déplacer en même temps d'octet en octet dans l'image.

Maintenant tout est prêt et notre image va apparaître.

Fichiers complets : main.asm, image.asm.

Photo sur fond noir, dans le DOS

Détails additionnels

Optimisation 16 bits

Plutôt que travailler octet par octet pour afficher l'image, on peut aussi profiter des registres 16 bits et travailler mot par mot. Dans ce cas, on utilise AX plutôt que AL dans la boucle ligne mais il faut alors remplacer inc di et inc si par add di, 2 et add si, 2 pour avancer de deux octets à la fois. Par conséquent, il faut diviser par deux la valeur de CX qui définit le nombre d'itérations de la boucle. En 75 itérations on parcourt 150 pixels.

    mov cx, 75
    ligne:
        mov ax, [si]
        mov [es:di], ax
        add di, 2
        add si, 2
        loop ligne

Utilisation d'une palette

On ne l'a pas vu ici pour des raisons de simplicité mais on pourrait utiliser une palette VGA personnalisée qui correspond mieux à l'image. L'inconvénient de cette approche, par exemple dans le cas d'un jeu, est qu'il faut changer de palette selon le contexte.

Ratio d'image

Le mode 13h fournit un canevas de 320x200 : ça correspond à un format 16:10, pourtant à l'époque du DOS les écrans étaient en 4:3, ce sont des pixels rectangles. On aurait pu étirer notre image horizontalement (anamorphose) pour compenser l'étirement vertical de l'affichage. DOSBOX a fait le choix par défaut d'afficher le mode 13h en pixels carrés.

Pour mon jeu de Sokoban, j'ai compensé le ratio en utilisant des sprites rectangulaires. Ce n'est pas le choix qu'a fait l'auteur de Planet X3, il a préféré garder des sprites carrés certainement pour plus de simplicité et le jeu reste très joli même en 4:3. Même chose pour SimCity. Cette vidéo parle bien du problème.

Capture d'écran du jeu Planet X3
Planet X3 pour DOS

Pour aller plus loin