3MM1LDB Ecran

De Ensiwiki
Aller à : navigation, rechercher
AttentionCette page est maintenue uniquement par les enseignants. Afin de ne pas perturber le déroulement des cours, elle n'a pas vocation à être modifiée par les élèves. Mais si vous avez des modifications à proposer, merci d'en discuter ou d'envoyer un e-mail aux auteurs de la page (cf. historique)


Alternance.png  Alternance  CDROM.png  Informatique 

Fleche gauche.png
Fleche haut.png
Fleche droite.png

Introduction

Le but de cette séance est de programmer la gestion de l'écran dans un noyau de système d'exploitation. On va se limiter à un mode d'affichage très simple géré par toutes les cartes graphiques depuis le début des années 80 (norme CGA).

Spécification de l'affichage à l'écran

L'écran que nous considérons est le mode texte de base des cartes vidéo des PC dans lequel le noyau démarre. L'affichage fait 80 colonnes sur 25 lignes. L'affichage s'effectue en écrivant directement dans la mémoire vidéo pour y placer les caractères et leur couleur. Certaines opérations simples d'entrées-sorties sont nécessaires pour déplacer le curseur clignotant qui indique la position actuelle d'affichage.

Principe

L'écran est couplé à une zone de la mémoire vidéo commençant à une adresse dépendant du mode utilisé (ici l'adresse de début est 0xB8000) : tout ce qui est écrit dans cette zone mémoire est donc immédiatement affiché à l'écran. Dans le mode vidéo utilisé, l'écran peut être vu comme un tableau de 80x25 = 2000 cases. Chaque case représente un caractère affiché à l'écran, et est composée de 2 octets (un octet = uint8_t en C) :

  • le premier octet contient le code ASCII du caractère ;
  • le deuxième octet contient le format du caractère, c'est à dire la couleur du texte, la couleur du fond et un bit indiquant si le texte doit clignoter :
Structure de l'octet de format
bit 7 6 5 4 3 2 1 0
clignote couleur du fond couleur du texte

Attention : le clignotement n'est pas géré correctement par l'environnement d'exécution, vous devez forcer le bit 7 toujours à 0.

Les couleurs disponibles sont listées dans le tableau ci-dessous :

Les 16 couleurs de la palette CGA
valeur couleur valeur couleur valeur couleur valeur couleur
0 noir
4 rouge
8 gris foncé
12 rouge clair
1 bleu
5 magenta
9 bleu clair
13 magenta clair
2 vert
6 marron
10 vert clair
14 jaune
3 cyan
7 gris
11 cyan clair
15 blanc

Les 16 couleurs sont possibles pour le texte, par contre seules les 8 premières peuvent être sélectionnées pour le fond.

Pour afficher un caractère à la ligne lig et à la colonne col de l'écran, on doit donc écrire dans le mot de 2 octets (uint16_t en C) dont l'adresse en mémoire peut être calculé à partir de la simple formule suivante : 0xB8000 + 2 * (lig * 80 + col).

Gestion du curseur

Lorsqu'on écrit dans un terminal en mode texte, on voit s'afficher un curseur clignotant qui indique la prochaine case dans laquelle on va écrire. Dans le mode vidéo que l'on utilise, ce curseur est géré directement par la carte vidéo : il suffit de lui indiquer à quelles coordonnées elle doit l'afficher.

On communique pour cela via des ports d'entrée-sorties : il s'agit de canaux de communication reliant les périphériques et dont les adresses sont fixées. Il existe deux types de ports :

  • les ports de commandes qui servent à indiquer au périphérique l'opération que l'on souhaite effectuer ;
  • les ports de données qui permettent de communiquer effectivement avec le périphérique, en lisant ou en envoyant des données.

Dans l'architecture x86, les ports sont couplés à une plage d'adresses de 64 KiO : l'adresse d'un port est donc une valeur sur 16 bits (uint16_t en C). Cependant, on ne peut pas accéder à cette zone mémoire directement via des pointeurs : on doit utiliser des instructions particulières.

Il existe des instructions assembleur dédiées pour la communication via les ports : sur l'architecture x86, il s'agit de l'instruction in (pour lire une donnée en provenance d'un port et la stocker dans un registre du processeur) et de l'instruction out (pour envoyer une donnée à un port). Ces deux instructions s'écrivent d'une façon particulière :

  • inb %dx, %al : lit un octet de donnée sur le port dont le numéro est dans %dx et le stocke dans %al : attention, vous devez obligatoirement utiliser les registres %al et %dx, à l'exclusion de tout autre ;
  • outb %al, %dx : envoie l'octet contenu dans %al sur le port dont le numéro est dans %dx : là-encore, vous devez obligatoirement utiliser les registres %al et %dx.

Bien sûr, il existe des équivalents pour lire des valeurs sur plus de 8 bits (inw, ouw, etc.) mais on ne les utilisera pas dans ce projet.

Dans les cartes vidéos VGA que l'on utilise dans ce TP, le port de commande gérant la position du curseur est le 0x3D4 et le port de données associé est le 0x3D5. La position du curseur est un entier sur 16 bits calculé via la formule suivante : pos = col + lig * 80. Cette position doit être envoyée en deux temps à la carte vidéo : on envoie d'abord l'octet de poids faible puis l'octet de poids fort de la position. La succession d'opérations à effecter est donc la suivante :

  1. envoyer la commande 0x0F sur le port de commande pour indiquer à la carte que l'on va envoyer la partie basse de la position du curseur
  2. envoyer cette partie basse sur le port de données
  3. envoyer la commande 0x0E sur le port de commande pour signaler qu'on envoie maintenant la partie haute
  4. envoyer la partie haute de la position sur le port de données.

Les caractères à afficher

On considère dans ce TP les caractères de la table ASCII (man ascii), qui sont numérotés de 0 à 127 inclus. Les caractères dont le code est supérieur à 127 (accents, etc.) seront ignorés.

Les caractères de code ASCII 32 à 126 doivent être affichés en les plaçant à la position actuelle du curseur clignotant et en déplaçant ce curseur sur la position suivante : à droite, ou au début de la ligne suivante si le curseur était sur la dernière colonne.

Les caractères de 0 à 31, ainsi que le caractère 127 sont des caractères de contrôle. Le tableau ci-dessous décrit ceux devant être gérés. Tous les autres caractères de contrôle doivent être ignorés.

Caractères de contrôle à gérer
Code de contrôle Mnémonique Syntaxe en C Effet
8 BS \b Recule le curseur d'une colonne s'il n'est pas sur la première colonne
9 HT \t Avance à la prochaine tabulation (colonnes 1, 9, 17, ..., 65, 73, 80)
10 LF \n Déplace le curseur sur la ligne suivante, colonne 1
12 FF \f Efface l'écran et place le curseur sur la colonne 1 de la ligne 1
13 CR \r Déplace le curseur sur la ligne actuelle, colonne 1

Appels de fonctions en assembleur x86_32

Dans ce mini-projet, vous allez devoir écrire des fonctions en assembleur x86_32 (32 bits donc), et les faire interagir avec des fonctions écrites en C. Il n'est pas possible de mélanger du code x86_64 avec du C compilé en 32 bits, car les conventions de passage de paramètres sont très différentes, comme on va le voir ci-dessous.

Rappels généraux

Comme on l'a vu à la première séance, le x86_32 comprend moins de registres que le x86_64 : tous les registres %r8 ... %r15 n'existent simplement pas. Les autres existent, mais pas dans leurs versions 64 bits (par exemple, vous pouvez utiliser %al, %ax et %eax mais pas %rax).

Le bus adresse est sur 32 bits, donc les pointeurs sont tous codés sur 4 octets (au lieu de 8 en x86_64). De même, vous ne pouvez pas utiliser d'accès mémoire sur 64 bits (par exemple : movq (%rdx), %rax) : vous devez donc vous limiter aux suffixes b, w et l.

Les pointeurs de pile

Le x86_32 comprend 2 registres dont le rôle est lié à la pile d'exécution : %esp et %ebp (ce sont bien sûr les versions 32 bits de %rsp et %rbp qu'on a déjà manipulé en 64 bits).

%esp est le pointeur de pile : il contient en permanence l'adresse de la dernière case occupée dans la pile d'exécution. On ne le manipule pas directement en général pour éviter de risquer de déséquilibrer la pile.

%ebp est le pointeur de base : il contient l'adresse de la base du contexte d'exécution de la fonction en cours d'exécution. Dans notre cas, il pointera en permanence sur la case dans laquelle on a sauvegardé le %ebp précédent. On se sert de %ebp pour accéder aux variables locales et aux paramètres de la fonction en cours d'exécution, via un adressage indirect avec déplacement (e.g. -4(%ebp), 8(%ebp), etc.).

Instructions d'appel et de retour

L'instruction call permet d'appeler une fonction : elle fonctionne exactement comme en 64 bits.

L'instruction réciproque s'appelle ret : là-encore, c'est la même chose qu'en 64 bits.

Instructions de sauvegarde et restauration dans la pile

On utilise l'instruction push pour empiler une valeur, c'est à dire :

  • déplacer le pointeur de pile %esp qui pointe sur la dernière case occupée ;
  • copier la valeur dans la case mémoire pointée maintenant par %esp.

Par exemple, pushl %eax est équivalent à :

subl $4, %esp
movl %eax, (%esp)

L'instruction réciproque s'appelle pop et elle permet de dépiler la valeur en sommet de pile. Par exemple, popl %eax est équivalent à :

movl (%esp), %eax
addl $4, %esp

Ces instructions existent aussi en 64 bits mais on ne les a pas utilisé car les contraintes d'alignement sont plus fortes qu'en 32 bits.

Gestion des registres

Le compilateur GCC classe les registres généraux du processeur dans 2 catégories, comme sur x86_64 :

  • les registres scratch sont des registres « de calcul » qui peuvent être utilisés librement dans les fonctions en assembleur ;
  • les registres non-scratch sont des registres « précieux » dans lesquels le compilateur peut stocker des valeurs importantes.

Sur x86_32 :

  • les registres scratch sont %eax, %edx, %ecx et %eflags;
  • les registres non-scratch sont %ebx, %esi, %edi, %ebp et %esp.

Notez bien qu'il s'agit d'une convention différente de celle sur x86_64 (notamment pour %edi et %esi qui ne servent plus à passer des paramètres).

Cette convention est importante lorsqu'on appelle une fonction C depuis du code assembleur :

  • la fonction en assembleur ne doit pas laisser de valeurs importantes dans les registres scratch sans les sauvegarder avant d'appeler la fonction C : en effet, l'appel à la fonction C pourrait parfaitement détruire le contenu de ces registres, car GCC considère qu'il peut s'en servir comme bon lui semble ;
  • la fonction en assembleur peut par contre laisser des valeurs importantes dans les registres non-scratch car on est sur qu'ils ne seront pas modifiés par l'appel à la fonction C.

Et réciproquement bien sûr lorsqu'on écrit une fonction assembleur appelée depuis du C : il ne faut pas utiliser les registres non-scratch sans les avoir sauvegardés au préalable, car le compilateur s'attend à retrouver les valeurs qu'il y avait placé avant l'appel à la fonction assembleur.

Conventions pour le cadre de pile

Lorsqu'on écrit un programme complet en assembleur, en mélangeant des fonctions C et assembleur qui s'appellent les unes les autres, on doit respecter des conventions de gestion de la pile. La pile est gérée de façon très similaire au x86_64, avec une exception notable concernant les paramètres passés à la fonction appelée.

Sur x86_32, les paramètres ne sont pas passés dans des registres mais sont mis dans la pile par l'appelant de la fonction (par des instructions push). Un cadre de pile a la structure suivante :

3MM1LDB pile32.png

Notez qu'en 32 bits, la pile doit être alignée sur des adresses multiples de 4 : il suffira de réserver un nombre d'octets multiples de 4 pour les variables locales et les éventuels temporaires.

Dans ce schéma, les adresses sont croissantes vers le bas mais les valeurs sont empilées en haut car il s'agit d'une pile au sens algorithmique du terme. Les différentes zones sont détaillées ci-dessous :

  • les paramètres sont ceux passés à la fonction en cours d'exécution, ils sont mis par l'appelant de la fonction (par des instructions push) ;
  • l'adresse de retour est l'adresse de l'instruction suivant l'appel de la fonction en cours d'exécution dans la fonction appelante, cette adresse est mise dans la pile automatiquement lors de l'appel de la fonction avec l'instruction call ;
  • le « %ebp précédent » est la sauvegarde du pointeur de base de la fonction appelante, il est sauvegardé à l'entrée de la fonction par l'instruction enter ;
  • les variables locales de la fonction en cours d'exécution, si elles existent, sont localisées dans son cadre de pile, à des adresses fixes par rapport à %ebp, on peut réserver l'espace nécessaire en déplaçant le pointeur de pile %esp de la taille à réserver en utilisant le premier paramètre de l'instruction enter ;
  • on peut éventuellement réserver de la place dans le cadre de pile pour sauvegarder des registres non scratch si besoin (zone des temporaires).

On voit donc qu'au lieu de récupérer les paramètres passés par la fonction appelante dans des registres, on doit aller les chercher dans la pile. Par exemple, si on reprend l'exemple classique du PGCD, on pourra écrire en assembleur x86_32 :

    .globl pgcd
    // uint32_t pgcd(uint32_t a, uint32_t b)
    // a : %ebp + 8
    // b : %ebp + 12
pgcd:
    enter $0, $0
    // while (a != b) {
while:
    movl 12(%ebp), %eax
    cmpl 8(%ebp), %eax
    je fin_while
    // if (a > b) {
    ja else
    // a = a - b;
    movl 12(%ebp), %eax
    subl %eax, 8(%ebp)
    jmp fin_if
else:
    // b = b - a:
    movl 8(%ebp), %eax
    subl %eax, 12(%ebp)
fin_if:
    jmp while
fin_while:
    // return a;
    movl 8(%ebp), %eax
    leave
    ret

La séquence d'appel de cette fonction sera (si par exemple dans la fonction appelante, on écrit pgcd(15, 10);) :

...
pushl $10
pushl $15
call pgcd
addl $8, %esp
...

On note bien qu'on doit :

  • copier les paramètres dans l'ordre inverse de leur déclaration, de façon à ce que le premier paramètre (dans l'ordre de déclaration) soit le plus « proche » à partir de la fonction appelée ;
  • ré-équilibrer la pile après l'appel en ajoutant à %esp la taille des paramètres qu'on avait empilé.

Si on oublie de ré-équilibrer la pile, le code fonctionnera vraisemblablement, mais en cas de nombreux appels récursifs, on risque de provoquer un débordement de pile.

Travail demandé

Le but final est d'écrire une fonction void console_putbytes(char *chaine, int32_t taille) qui affiche une chaine de caractères à la position courante du curseur. Attention, vous devez respecter le nom et la spécification de cette fonction car elle est appelée par d'autres fonctions du noyau, par exemple printf.

A part celles pour laquelle c'est noté explicitement, toutes les fonctions doivent être écrites en C. Pour les fonctions à écrire en assembleur, il est recommandé de procéder en deux temps :

  1. écrire la fonction en C et la tester ;
  2. traduire le code C systématiquement en assembleur x86_32.

Pour tester en C les fonctions accédant aux ports d'entrée-sortie, vous pouvez utiliser les pseudo-fonctions C : uint8_t inb(uint16_t port) et void outb(uint8_t val, uint16_t port) (qui ne font en fait qu'appeler les instructions assembleur équivalentes).

Les fonctions en assembleur doivent être écrites dans des fichiers fct_xxxx.S (n'essayez pas d'inclure du code assembleur directement dans du code C : écrire du code assembleur inline implique de respecter des contraintes complexes et complique grandement la mise au point des fonctions). Notez que les fichiers doivent avoir une extension en majuscules : la différence entre un fichier .s et .S est que GCC fait passer le pré-processeur sur les fichiers .S avant d'appeler l'assembleur, ce qui permet d'inclure des fichiers d'en-tête .h et donc d'utiliser des constantes.

Pour arriver au but final vous pouvez par exemple implanter dans cet ordre :

  1. une fonction uint16_t *ptr_mem(uint32_t lig, uint32_t col) qui renvoie un pointeur sur la case mémoire correspondant aux coordonnées fournies : cette fonction doit être écrite en assembleur x86_32 ;
  2. une fonction void ecrit_car(uint32_t lig, uint32_t col, char c) qui écrit le caractère c aux coordonnées spécifiées (vous pouvez aussi ajouter des paramètres pour permettre de préciser la couleur du caractère et celle du fond) : cette fonction doit être écrite en assembleur x86_32 ;
  3. une fonction void place_curseur(uint32_t lig, uint32_t col) qui place le curseur à la position donnée : cette fonction doit être écrite en assembleur x86_32 ;
  4. une fonction void efface_ecran(void) dont on vous laisse deviner le but
  5. une fonction void traite_car(char c) qui traite un caractère donné (c'est à dire qui l'affiche si c'est un caractère normal ou qui implante l'effet voulu si c'est un caractère de contrôle)
  6. une fonction void defilement(void)qui fait remonter d'une ligne l'affichage à l'écran (il pourra être judicieux d'utiliser memmove définie dans string.h pour cela)
  7. la fonction console_putbytes demandée, qui va sûrement utiliser les fonctions précédentes

Afin de vérifier le bon fonctionnement de vos différentes fonctions, le plus simple est de faire un affichage avec printf (définie dans stdio.h), car printf utilise console_putbytes pour l'affichage à l'écran.

Le module de gestion de l'écran doit garder en interne la position courante du curseur, ainsi que les différents attributs (couleur du texte, du fond), dans des variables globales.

Le bout de bibliothèque C fourni comprend de nombreuses fonctions utiles : il faut s'en servir pour ne pas ré-inventer (et perdre du temps à mettre au point) du code redondant ! Vous trouverez la documentation des fonctions C dans les pages man habituelles : par exemple, man memmove ou man sprintf.