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

↩ Retour ∙ Posté le assembleur

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).

Un exemple de graphisme en mode 13h : Rayman pour DOS (1995)

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 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.

Ici je parle des principes généraux de l’assembleur Intel 16 bits, l’idée est de faire découvrir, de donner envie et de montrer que c’est moins effrayant que ça en a l’air.

  1. Préparation de l’environnement
  2. Un premier programme
  3. Afficher un pixel sur l’écran
  4. Affichage d’une ligne
  5. Affichage d’un rectangle
  6. Préparer une image pour l’afficher
  7. Affichage d’une image
  8. Détails additionnels

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 va se créer un dossier de travail, par exemple ~/workspace/dos/vga chez moi. Puis on va configurer 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 :

  • Ajouter devant 0x, par exemple 0x100.
  • Ajouter après h, par exemple 100h. Dans ce cas, si le nombre commence par une lettre, il faut mettre un zéro devant. On n’écrira donc pas FFh mais 0FFh.

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.

On aurait pu éviter ce second mov étant donné que AH contenait déjà 00h, mais on l’a fait par clarté et pour ne pas casser le programme si on rajoute des instructions plus tard.

La Citroën AX : solide et économique, comme le Intel 8086.

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 la fonction voulue (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 trucs 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.

Pour appeler une procédure on utilise l’instruction call qui stocke dans la pile l’adresse de la prochaine instruction à l’emplacement actuel avant de sauter à l’emplacement de la procédure. L’instruction ret fait l’inverse : elle est située à la fin d’une procédure et permet de revenir où on était en récupérant l’adresse stockée dans la pile.

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és.

Pour que le programme retourne dans le mode texte par défaut (03h) avant de quitter, on doit 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). En effet, il s’agit d’un mode de 256 couleurs, et un octet fait 8 bits, ce qui est le strict nécessaire pour définir une valeur entre 0 et 255 (2ˆ8 = 256).

Palette 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, mais ce n’est pas le but de cet article.

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.

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

mov ax, 0A000h
mov es, ax

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, la prochaine étape est donc d’indiquer l’offset que nous avons calculé. Pour cela, nous allons utiliser DI (destination index) qui est un des différents registres d’offset disponibles sur le processeur. On peut y insérer franco de port la valeur en décimal calculée.

mov di, 32160

Enfin, il nous faut choisir une couleur dans la palette et l’appliquer à notre pixel.

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

Les crochets signifient que ES:DI est une adresse à laquelle on veut écrire, on ne cherche surtout pas à remplacer DI qui contient l’offset que nous avons calculé.

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. En l’occurence, 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 ajouté une instruction inc di qui incrémente le registre DI à chaque passage, ce qui permet d’avancer horizontalement octet par octet pour créer une ligne. On aurait aussi pu écrire add di, 1 , c’est pareil.

Affichage d’un rectangle

Pour afficher un rectangle nous allons rester sur le même principe, en faisant une boucle sur la boucle. La seconde boucle permettra de descendre pour tracer notre 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 nous allons rajouter deux choses, un registre BX qui contient le nombre de lignes ainsi qu’un autre label.

mov bx, 30
colonne:

En dessous du loop, nous allons rajouter 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 de tantôt, 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.

Voici le bout de 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

Pour cet exemple, je vais choisir Lena, une image devenue célèbre de par son usage courant dans les tests de programmes et d’algorithmes de traitement d’image.

Réduction à 150x150 px

Avant de pouvoir insérer cette image dans le programme, nous allons d’abord devoir 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.

Le fichier converti

Ensuite, nous allons devoir extraire les pixels du format BMP pour avoir une suite d’octets à insérer dans le programme. Vous pouvez utiliser mon script qui nécessite Python 3. Il s’exécute dans un terminal avec python3 ./bmptovga.py ou sinon python ./bmptovga.py. Il va demander le nom du fichier .bmp puis créer un fichier .asm.

Affichage d’une image

Quelques petites adaptations sont nécessaires pour afficher une image. Pour commencer, on va mettre notre fichier lenna150.asm 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 "lenna150.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 nous devons 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.

Enfin, plutôt que charger une couleur directement, nous allons 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 lenna150: (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, nous allons donc utiliser SI qui est libre.

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

mov si, lenna150

Dans le label ligne:, nous allons pouvoir extraire 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, lenna150.asm.

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 va utiliser 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 l’utilisation d’une palette VGA personnalisée nous aurait été utile dans le cas de Lena où les teintes sont plutôt dans les tons chauds. Nous aurions eu une image plus détaillée. 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

Concernant le ratio : 320x200 px c’est un format 16:10, pourtant à l’époque du DOS les écrans étaient en 4:3. On aurait du étirer l’image horizontalement pour compenser l’étirement vertical de l’affichage dans cette résolution. On dit que le mode 13h utilise des pixels rectangles. Toutefois DOSBOX a pris le parti d’afficher par défaut le mode 13h en pixels carrés.

À l’époque les développeurs de jeux utilisaient différentes stratégies. Pour mon jeu de Sokoban, j’ai compensé le ratio en utilisant des sprites rectangulaires. Ce n’est pas le choix qu’a fait David Murray pour son jeu Planet X3, il a préféré garder des sprites carrés certainement pour plus de simplicité, et le jeu reste magnifique même en 4:3. Même chose pour SimCity. Cette vidéo parle bien du problème.

Planet X3 en mode 13h (le jeu est en vente ici)