I. Introduction

L'assembleur est un langage dit bas niveau, c'est-à-dire qu'il est très proche du langage machine.

Autrement dit, pour programmer en assembleur vous devez :

  • apprendre une architecture : Intel par exemple ;
  • avoir quelques connaissances basiques sur les systèmes d'exploitation : Linux par exemple ;
  • maîtriser un assembleur : l'assembleur GNU par exemple.

Apprendre une architecture, c'est comprendre le fonctionnement d'un processeur : les registres, l'adressage et l'organisation de la mémoire, les interruptions… et tout ce que vous avez appris dans le cours d'architecture des ordinateurs. Vous avez, sans doute, une idée claire et suffisante sur l'architecture Intel (IA ou x86) pour aborder ce tutoriel. D'autre part, apprendre un assembleur c'est apprendre une syntaxe pour programmer. C'est l'objectif de ce tutoriel !

Un langage assembleur, ou simplement un assembleur, est une représentation symbolique du langage machine (données binaires et instructions du processeur). Il existe deux styles d'assembleurs :

  • l'assembleur Intel : l'assembleur principal utilisant ce style est NASM ;
  • l'assembleur AT&T : l'assembleur principal est l'assembleur GNU ou simplement as.

Ce tutoriel va vous donner la description minimale pour coder en assembleur GNU sous un système GNU/Linux en utilisant le jeu d'instructions Intel 80386.

II. Définitions préliminaires

II-A. Les systèmes de numération

Pour être traitée par un circuit numérique (un processeur, une mémoire RAM…), une information doit être convertie en un format adaptable. Pour cela, elle doit être représentée dans un système de numération caractérisé par une base b (b > 1 est un entier). Ainsi, on a une infinité de tels systèmes.

Les plus utilisés sont : le décimal (b = 10), le binaire (b = 2), l'octal (b = 8), l'hexadécimal (b = 16).

Tableau 1 : Les systèmes de numération

Décimal Binaire Octal Hexadécimal
0 0000 0 0
1 0001 1 1
2 0010 2 2
3 0011 3 3
4 0100 4 4
5 0101 5 5
6 0110 6 6
7 0111 7 7
8 1000 10 8
9 1001 11 9
10 1010 12 A
11 1011 13 B
12 1100 14 C
13 1101 15 D
14 1110 16 E
15 1111 17 F

On écrit un nombre N = an an−1 … a1 a0 … , a−1 a−2 … a−m dans un système de numération de base b selon l'expression suivante :

N = ∑−m≤i≤n (ai.bi) = an.bn+ an−1.bn−1 … a1.b1 + a0.b0 … , a−1.b−1 + a−2.b−2 … a−m.b−m

Avec i le poids (position) du chiffre (digit) ai dans le nombre N.

Exemples :

  • b = 10 et N = 2193 : 219310 = 2.103 + 1.102 + 9.101 + 3.100 = 2000 + 100 + 90 + 3
    = 2193 ;
  • b = 2 et N = 1011 : 10112 = 1.23 + 0.22 + 1.21 + 1.20 = 8 + 0 + 2 + 1
    = 11 ;
  • b = 16 et N = F3C9 : F3C916 = 15.163 +3.162 +12.161 +9.160 = 61440+768+192+9
    = 62409 ;
  • b = 2 et N = 10,11 : 10,112 = 1.21 + 0.20 + 1.2−1 + 1.2−2 = 2 + 0 + 0,5 + 0,25
    = 2,75.

En fait, nous avons converti les quatre nombres N représentés dans les systèmes de numération de bases b au format décimal.

Il est clair que la base b d'un système de numération est égale au nombre de chiffres (digits) qui peuvent être utilisés pour représenter un nombre. Ainsi, le système binaire utilise deux digits {0 , 1}, le système décimal utilise dix digits {0 , 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9} et ainsi de suite.

D'autre part, pour adapter un système de numération à un circuit numérique, il faut associer un niveau de tension (voltage) à chaque chiffre. Imaginez que nous décidions d'utiliser le système décimal pour construire un circuit numérique. Il faut donc utiliser dix niveaux de tension. Ce qui nous donne un circuit très compliqué. Par contre, le système binaire, ayant la plus faible base (b = 2), est le plus adapté. Il nécessite seulement deux niveaux de tension, par exemple : 0V pour le digit 0 et 5V pour le digit 1. Pour cette raison, les 0 et les 1 sont les deux seuls symboles du langage machine.

II-B. Le code ASCII

Le code ASCII (American Standard Code for Information Interchange) est un code alphanumérique utilisé pour l'échange des informations entre deux ou plusieurs ordinateurs ou entre un ordinateur et ses périphériques.

Le processeur ne comprend pas la représentation humaine des caractères. Il utilise le codage ASCII (une séquence de 0 et 1), pour identifier les différents caractères alphanumériques. Par exemple, lorsque vous appuyez sur la touche « A » de votre clavier, le processeur reçoit son code ASCII.

Le codage ASCII standard utilise 7 bits pour représenter les caractères de l'alphabet (minuscule et majuscule), les chiffres décimaux, les caractères de ponctuation et les caractères de contrôle :

Tableau 2 : La table ASCII standard

Décimal Caractère Décimal Caractère Décimal Caractère
0 NUL 43 + 86 V
1 SOH 44 , 87 W
2 STX 45 - 88 X
3 ETX 46 . 89 Y
4 EOT 47 / 90 Z
5 ENQ 48 0 91 [
6 ACK 49 1 92 \
7 BEL 50 2 93 ]
8 BS 51 3 94 ^
9 HT 52 4 95 _
10 LF 53 5 96
11 VT 54 6 97 a
12 ff 55 7 98 b
13 CR 56 8 99 c
14 SO 57 9 100 d
15 SI 58 : 101 e
16 DLE 59 ; 102 f
17 DC1 60 < 103 g
18 DC2 61 = 104 h
19 DC3 62 > 105 i
20 DC4 63 ? 106 j
21 NAK 64 @ 107 k
22 SYN 65 A 108 l
23 ETB 66 B 109 m
24 CAN 67 C 110 n
25 EM 68 D 111 o
26 SUB 69 E 112 p
27 ESC 70 F 113 q
28 FS 71 G 114 r
29 GS 72 H 115 s
30 RS 73 I 116 t
31 US 74 J 117 u
32 Space 75 K 118 v
33 ! 76 L 119 w
34 77 M 120 x
35 # 78 N 121 y
36 $ 79 O 122 z
37 % 80 P 123 {
38 & 81 Q 124 |
39 ' 82 R 125 }
40 ( 83 S 126
41 ) 84 T 127 DEL
42 * 85 U    

III. L'assemblage et l'édition de liens sous Linux

Le code suivant est celui du programme classique permettant d'afficher, sur la console, le message « Hello, World ! » :

 
Sélectionnez
  1.                     .data 
  2. .saut :             .byte '\n 
  3. $msg_ :             .asciz "Hello, World !" 
  4. len = . - $msg_ 
  5.                     .bss 
  6.                     .text 
  7.                     .global _start 
  8.  
  9. _start : 
  10.                      # jmp exit 
  11. /* afficher : 
  12.                      Hello, World ! */ 
  13.  
  14.                      movl $msg_,%ecx 
  15.                      movl $len,%edx 
  16.                      movl $1,%ebx 
  17.                      movl $4,%eax 
  18.                      int $0x80           # appel système 
  19.  
  20.                      movl $.saut,%ecx 
  21.                      movl $1,%edx 
  22.                      movl $1,%ebx 
  23.                      movl $4,%eax ; int $0x80 
  24.  
  25. exit : 
  26.                      movl $0,%ebx 
  27.                      movl $1,%eax 
  28.                      int $0x80 

Le code est écrit en assembleur GNU. Il utilise le jeu d'instructions du processeur Intel 80386, le premier processeur 32 bits d'Intel. Nous en discuterons dans la section 5. Dans cette section, nous allons juste montrer comment le faire tourner sur la machine.

Décompressez l'archive hello.tar.gz. Elle contient deux fichiers :

  • hello.s : contient ce code source ;
  • makefile : le makefile minimal pour générer l'exécutable.

Pour générer un fichier exécutable à partir du code source, nous utiliserons un programme dit assembleur et un programme dit éditeur de liens (linker). Sous un système GNU/Linux, l'assembleur est le programme as et l'éditeur de liens est le programme ld (loader).

Le schéma suivant montre les étapes à suivre pour générer l'exécutable :

Image non disponible

L'assembleur as prend comme entrée le fichier hello.s, ayant obligatoirement l'extension .s, pour générer le fichier objet hello.o. Ả son tour, l'éditeur de liens va lier les différents morceaux du code objet et affecter à chacun son adresse d'exécution (run-time address) et produire l'exécutable.

Celui-ci est au format ELF (Executable Linkable Format). C'est le format des fichiers binaires utilisés par les systèmes UNIX. C'est l'alternative à l'ancien format a.out (assembler output). Si vous voulez changer le nom de l'exécutable (de a.out à un nom de votre choix), vous pouvez passer l'option -o hello au programme ld. C'est la même option utilisée par le compilateur gcc. Notre makefile contient les commandes (rules) suivantes :

 
Sélectionnez
  1. hello : hello.o 
  2.         ld -o hello hello.o 
  3. hello.o : hello.s 
  4.           as -o hello.o hello.s 
  5.  
  6. clean : 
  7.         rm -fv hello.o 

L'exécutable n'est que l'image mémoire du programme. Il décrit comment le programme (les sections .text, .data…) sera chargé dans la mémoire (par le noyau).

Voici les commandes d'assemblage, d'édition de liens et d'exécution :

 
Sélectionnez
  1. #make 
  2. #./hello 

IV. Syntaxe de l'assembleur

Reprenons le code source du programme hello.s. Il contient trois sections : .data, .bss et .text.

Chaque section contient un ensemble de déclarations et des lignes de commentaires.

IV-A. Les sections

Un programme écrit en assembleur GNU peut être divisé en trois sections. Trois directives assembleur sont réservées pour déclarer ces sections :

  1. .text (read only) : la section du code. Elle contient les instructions et les constantes du programme. Un programme assembleur doit contenir au moins la section .text ;
  2. .data (read-write) : la section des données (data section). Elle décrit comment allouer l'espace mémoire pour les variables initialisables du programme (variables globales) ;
  3. .bss (read-write) : contient les variables non initialisées. Dans notre code, cette section est vide. On peut donc l'éliminer.

L'ordre de sections dans le code source n'est pas significatif. N'importe quelle section peut être vide !

IV-B. Les commentaires

Il existe deux méthodes pour commenter un code source. Les commentaires écrits entre /* et */ peuvent occuper plusieurs lignes. Un commentaire préfixé par un # ne peut occuper qu'une seule ligne.

IV-C. Les déclarations

Les déclarations peuvent prendre quatre formats :

 
Sélectionnez
  1.                           .nom directive 
  2. label_1 :                 .nom directive attribut 
  3. label_2 : 
  4.                           expression 
  5.                           instruction       op1 , op2 , ... 

Une déclaration se termine par le caractère saut de ligne \n ou par le caractère « ; ».

Une déclaration peut commencer par une étiquette (label). Une étiquette peut être suivie d'un symbole clé qui détermine le type de la déclaration. Si le symbole clé est préfixé par un point, alors la déclaration est une directive assembleur. Les attributs d'une directive peuvent être un symbole prédéfini, une constante ou une expression.

IV-D. Les symboles

Un symbole est une séquence de caractères choisis parmi :

  1. Les lettres de l'alphabet : a .. z, A .. Z ;
  2. Les chiffres décimaux : 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ;
  3. Les caractères : . $.

Un symbole ne doit jamais commencer par un chiffre. De plus, la casse est significative. Ainsi, on peut utiliser les symboles msg, Msg et MSg dans le même programme : ils pointent vers des zones mémoires différentes.

L'assembleur GNU réserve un ensemble de symboles clés pour les directives. Le symbole saut n'est pas réservé. Donc on peut l'utiliser pour déclarer le symbole .saut.

Une étiquette est un symbole suivi, immédiatement, du caractère « : ». Elle a deux usages :

  1. Si l'étiquette est écrite dans la section du code (.text), alors le symbole représente une valeur du location counter de la section .text (le Program Counter ou le registre EIP du 80386).
    Ainsi, si on décommente la ligne 10 du code du programme Hello, World ! ci-dessus, le message ne sera pas affiché. À son exécution, l'instruction jmp va charger le registre EIP avec l'adresse indiquée par le symbole exit et continuera l'exécution à la nouvelle location ;
  2. Si l'étiquette est écrite dans la section de données (.data), alors le symbole représente une adresse dans la zone mémoire des données. Par exemple, le symbole $msg_ représente l'adresse du premier octet (code ASCII) du message « Hello, World ! » dans la zone mémoire des données.

Le symbole « . »

Le symbole spécial « . » peut être utilisé comme une référence à une adresse au moment de l'assemblage.

Exemple :

Avant d'exécuter l'appel système (ligne 18), il faut charger le registre EDX avec la taille len du message $msg_. L'expression suivante permet de calculer et charger dans len cette taille :

 
Sélectionnez
  1. len = . - $msg_ 

Ici, le symbole « . » fait référence à l'adresse de l'octet situé juste après le message dans la zone mémoire des données. Donc, len est égale 13. C'est exactement le nombre d'octets de notre message.

IV-E. Les Constantes

Les nombres entiers

Un nombre entier peut être représenté dans plusieurs systèmes de numération. L'assembleur GNU en identifie quatre : le binaire, le décimal, l'octal et l'hexadécimal.

  1. Un nombre binaire est un 0b ou 0B suivi de zéro ou plusieurs chiffres binaires {0, 1}.
    Exemple : 0b10011.
  2. Un nombre octal est un 0 suivi de zéro ou plusieurs chiffres octaux {0, 1, 2, 3, 4, 5, 6, 7}.
    Exemple : 09122.
  3. Un nombre décimal ne doit pas commencer par 0. Il contient zéro ou plusieurs chiffres décimaux {0..9}.
    Exemple : 97340.
  4. Un nombre hexadécimal est un 0x ou 0X suivi de zéro ou plusieurs chiffres hexadécimaux {0..9, A, B, C, D, E, F}.
    Exemple : 0x6FC9D.

Les caractères

Comme le montre le tableau 2, chaque caractère est identifié par son code ASCII. Dans un code assembleur, on définit un caractère en écrivant son code ASCII approprié. En utilisant, la syntaxe de l'assembleur GNU, un caractère peut être écrit comme une apostrophe suivie immédiatement de son symbole. Exemple :

  • 'A est le code ASCII 65 de A ;
  • '9 désigne le code ASCII 100 de 9.

Les chaînes de caractères

Une chaîne de caractères (string) est une séquence de caractères écrite entre guillemets. Elle représente un tableau contigu d'octets en mémoire. Exemple : « Hello, World ! ».

La manière d'obtenir des caractères spéciaux dans une chaîne de caractères est de les échapper en les faisant précéder d'un antislash « \ ».

Exemple :

Dans le programme hello.s, nous avons utilisé deux appels système : le premier pour afficher le message et le second pour exécuter un saut de ligne. En fait, nous pouvons écrire le saut de ligne dans le message et utiliser un seul appel système pour afficher à la fois le message et effectuer le saut de ligne. C'est en échappant le caractère saut de ligne dans le message. Nous utilisons la même mnémonique pour symboliser un saut de ligne, en langage C :

 
Sélectionnez
  1. printf (”Hello, World !\n”) ; 

Le caractère « n », lorsqu'il est préfixé par un antislash, est équivalent au code ASCII (10) du caractère saut de ligne. Les caractères spéciaux du langage C sont les mêmes en assembleur GNU. Le compilateur gcc du langage C utilise le programme as pour assembler un code source C.

IV-F. Les expressions

Une expression spécifie une adresse ou une valeur numérique. Une expression entière est un ou plusieurs arguments délimités par des opérateurs. Les arguments sont des symboles, des chiffres ou des sous-expressions. Une sous-expression est une expression écrite entre deux parenthèses. Les opérateurs peuvent être des préfixes ou des infixes :

Préfixes

Les opérateurs préfixes prennent un argument absolu. as propose deux opérateurs de préfixe :

  • L'opérateur complément bit-à-bit (bitwise not).
    Exemple : ∼ 0b10001110 = 10001110 = 01110001 ;
  • L'opérateur négation - (complément à deux) : −N = ∼ N + 1.
    Exemple : N = 142 = 0b10001110, −142 = 01110001 + 1 = 01110010.

Infixes

Un opérateur de type infixe prend deux arguments : ∗, /, %, <, <<, >, >>, +, −, == …

Exemples d'expressions :

 
Sélectionnez
  1. len = . - msg 
  2. SYSSIZE = 0x80000 
  3. SYSSEG = 0x1000 
  4. ENDSEG = SYSSEG + SYSSIZE                             # ENDSEG = 0x81000 

IV-G. Syntaxe des instructions 80386 (IA-32)

Les opérandes

  • Les registres : les opérandes de type registre doivent être préfixés par un %.
    Exemples : %eax, %ax, %al, %ah, %ebx, %ecx, %edx, %esp, %ebp, %esi, %edi
  • Les opérandes immédiats : les opérandes de ce type doivent être préfixés par un $.
    Exemples : $4, $0x79ff03C3, $0b10110, $07621, $msg
  • Les opérandes mémoire : lorsqu'un opérande réside dans le segment de données du programme, le processeur doit calculer son adresse effective (EA : Effective Address) en se basant sur l'expression suivante :
    EA = base + scale * index + disp.

    L'assembleur GNU utilise la syntaxe AT &T qui traduit l'expression précédente en celle-ci :
    EA = disp (base, index, scale)
    avec :
    - disp : un déplacement facultatif. disp peut être un symbole ou un entier signé ;
    - scale : un scalaire qui multiplie index. Il peut avoir la valeur 1, 2, 4 ou 6. Si le scale n'est pas spécifié, il est substitué par 1 ;
    - base : un registre 32 bits optionnel. Souvent, on utilise EBX et EBP (si l'opérande est dans la pile) comme base ;
    - index : un registre 32 bits optionnel. Souvent, on utilise ESI et EDI comme index.

La taille des opérandes peut être codée dans le dernier caractère de l'instruction : b indique une taille de 8 bits, w indique une taille de 16 bits et l indique une taille de 32 bits.

Exemples :

 
Sélectionnez
  1. movb $0x4f,%al               # 8-bit 
  2. movw $0x4ffc,%ax             # 16-bit 
  3. movl $0x9cff83ec,%eax        # 32-bit 

L'ordre des opérandes

L'assembleur GNU utilise la syntaxe AT &T. Ainsi, une instruction mov doit être écrite comme suit :

 
Sélectionnez
  1. mov source,destination 

Cet ordre doit être respecté avec le reste des instructions : sub, add…

IV-H. Les directives

Les directives sont des pseudo-opérations. Elles sont utilisées pour simplifier, pour le programmeur, quelques opérations complexes telles que l'allocation de la mémoire. Comme on l'a dit dans la section précédente, le nom d'une directive est un symbole clé préfixé par un point.

Il existe plus de 70 directives définies par l'assembleur GNU. Les plus utilisées sont :

.ascii ”string_1”, ”string_2” …

Définit zéro ou plusieurs chaînes de caractères séparées par des virgules.

 
Sélectionnez
  1. msg1 :                   .ascii "Hello, World !\ n" 
  2. bootMsg :                .ascii "Loading system ..." 

.asciz ”string_1”, ”string_2” …

Définit zéro ou plusieurs chaînes de caractères séparées par des virgules et dont chacune se termine par le caractère '\0.

Ainsi, ces deux déclarations sont équivalentes :

 
Sélectionnez
  1. msg1 :                   .ascii "Hello, World !\0" 
  2. msg2 :                   .asciz "Hello, World !" 

.byte expression_1, expression_2 …

Définit et initialise un tableau d'octets.

 
Sélectionnez
  1. tab_byte :              .byte 23,'%,0xff,9,'\b 

Le premier élément est le nombre 23 et il est situé à l'adresse tab_byte, suivi par le code ASCII du caractère %

.word expression_1, expression_2 …

Définit et initialise un tableau de mots binaires (16 bits ou word).

 
Sélectionnez
  1. descrp :                .word    0x07ff 
  2.                         .word    0x0000 
  3.                         .word    0x9200 
  4.                         .word    0x00C0 

Avec les processeurs de la famille x86, l'adresse d'un mot, double mot ou d'un quad-mot (64 bits) est l'adresse de son LSB (Least Significant Byte). Ainsi, le quad-mot 0x00C09200000007ff se trouve à l'adresse indiquée par descrp. C'est l'adresse de l'octet 0x07ff.

.quad expression_1, expression_2 …

Définit et initialise un tableau de quad-mots.

 
Sélectionnez
  1. _gdt :                .quad    0x0000000000000000 
  2.                       .quad    0x00c09A00000007ff 
  3.                       .quad    0x00C09200000007ff 
  4.                       .quad    0x0000000000000000 

Un .quad est équivalent à quatre .word successifs.

.int expression_1, expression_2 …

Définit et initialise un tableau d'entiers (quatre octets).

 
Sélectionnez
  1. tab_int :               .int 3*6,16,190,-122 

.long expression_1, expression_2 …

Définit et initialise un tableau d'entiers (quatre octets).

 
Sélectionnez
  1. matrix3_3 :             .long 22,36,9 
  2.                         .long 10,91,0 
  3.                         .long 20,15,1 

.fill repeat, size, value

Réserve repeat adresses contiguës dans la mémoire et les charge avec les valeurs value de taille size.

 
Sélectionnez
  1. idt :                   .fill 256,8,0 

Définit un tableau contenant 256 quad-mots à l'adresse idt. Chaque élément du tableau contient zéro comme valeur.

.org new-lc, fill

Avance le location counter de la section courante à l'adresse new-lc.

 
Sélectionnez
  1.            .text 
  2.            .global      _start 
  3. _start : 
  4.            .org 510,0 
  5.            .word 0xAA55 

Avance le compteur de programme (registre EIP) de la section text (code segment) à l'adresse 510 puis écrit le mot 0xAA55 (la signature d'un secteur d'amorçage). Les octets 0..509 sont chargés avec des zéros.

.lcomm symbol, length

Déclare un symbole local et lui attribue length octets sans les initialiser.

 
Sélectionnez
  1.              .bss 
  2.              .lcomm buffer,1024 

La directive doit être écrite dans la section .bss.

.global symbol

Déclare un symbole global et visible pour l'éditeur de liens ld.

 
Sélectionnez
  1.              .global      _start,main 

Par défaut, l'éditeur de liens ld reconnaît le symbole global _start comme point d'entrée. Donc ce symbole doit être déclaré dans la section de code.

.set symbol, expression et .equ symbol, expression

Ceux deux directives sont similaires à l'expression : symbol = expression.

 
Sélectionnez
  1.              .set SYSSIZE,0x80000 
  2.              .equ SYSSEG,0x1000 

V. Exemples de codes assembleur

V-A. Exemple 1 : tri à bulle d'une liste d'entiers

Le programme tris.sTéléchargez l'exemple 1 utilise la méthode de tri à bulle pour trier une liste d'entiers positifs entrés au clavier. La liste triée sera ensuite affichée sur la console.

Déclaration des variables

 
Sélectionnez
  1.                           .data 
  2. saut :                    .byte '\n 
  3. space :                   .byte 0x20 
  4. msg1 :                    .ascii " << " 
  5. msg2 :                    .ascii " >> " 
  6.                           .bss 
  7.                           .lcomm buffer,1024 
  8.                           .lcomm list,100 
  9.                           .lcomm stack,1024 
  • buffer : une mémoire tampon pour stocker temporairement les caractères entrés au clavier ;
  • list : c'est un tableau d'entiers qui peut stocker une liste de 25 nombres entiers (quatre octets) ;
  • stack : pour réserver 1024 octets dans la pile (stack) du programme.

Étape 1 : entrer la liste d'entiers au clavier

 
Sélectionnez
  1. msg_1 :                                             
  2.           movl $msg1,%ecx           ## ECX = l'adresse du message 
  3.           movl $4,%edx              ## EDX = la taille du message 
  4.           movl $1,%ebx              ## l'écran (stdout) 
  5.           movl $4,%eax              ## appel système 4 : write 
  6.           int $0x80                 ## Exception : ID = 0x80 
  7.  
  8. kybd :                                 
  9.           movl $buffer,%ecx         ## ECX = l'adresse du buffer 
  10.           movl $1024,%edx           ## EDX = la taille du buffer 
  11.           movl $0,%ebx              ## clavier (stdin) 
  12.           movl $3,%eax              ## appel systeme 3 : read 
  13.           int $0x80 
  14.  
  15. # Compter les octets de buffer 
  16.  
  17.           movl $0,%esi 
  18. count : 
  19.           movl buffer(%esi),%ebx    ## EA = Scale * Index + Displacement 
  20.           incl %esi 
  21.  
  22.           cmp %bl,line 
  23.           jne count 

Les registres %eax, %ebx, %ecx, %edx doivent être initialisés correctement avant l'exécution de l'instruction int $0x80. L'instruction int n génère une exception immédiatement après son exécution.
Une exception est une interruption logicielle (ou encore une interruption programmée).

L'ID (Interrupt Identifier) de l'exception est codé dans l'instruction (n). Le noyau Linux utilise le nombre 128 (0x80) pour identifier les interruptions programmées des autres interruptions (NMI, Divide Error, Trap…). Ainsi, l'instruction int $0x80 (ou int $128) permet à notre programme (application) d'accéder au matériel (clavier, écran…) en exécutant un appel système. Le fichier /usr/include/asm-x86/unistd 32.h liste tous les appels système Linux (pour une machine occupée par un processeur de la famille x86).

On va utiliser les appels système 1, 3 et 4. Le registre %eax doit explicitement contenir le numéro de l'appel système. Également, le registre %ebx doit contenir le descripteur (file descriptor) du périphérique.

Les instructions 8..13 vont charger le buffer avec la liste des caractères (les entiers), en exécutant l'appel système 3 (read). Les entiers entrés au clavier doivent être séparés par un espace (seulement un !).

En exécutant les instructions 17..23, le processeur va calculer le nombre de caractères entrés et le charger dans le registre %esi.

Pour illustrer, supposons qu'on a entré la liste : 19 208 9756 101 229. Le buffer contient 18 caractères :

Image non disponible

Le caractère saut de ligne doit être entré à la fin pour valider la saisie.

Étape 2 : convertir les caractères du buffer

On a dit, au début de ce tutoriel, que l'ordinateur utilise le codage ASCII pour échanger de l'information avec les périphériques d'entrée/sortie. Ainsi, et comme le montre le schéma ci-dessus, la liste des entiers est reçue par l'ordinateur comme un flux (stream) de codes ASCII. Chaque code représente un caractère.

Donc, pour qu'on puisse effectuer des traitements (arithmétiques, comparaisons…) sur des entiers, nous devons d'abord convertir le buffer en une liste d'entiers. C'est l'objectif des lignes de code suivantes :

 
Sélectionnez
  1.              decl %esi                 ## index dans le buffer 
  2.              movl $0,%edi              ## index dans la liste 
  3.  
  4. new_elem : 
  5.  
  6.              movl $10,%esp             ## multiplier par 10 
  7.              movl $0,%ebp              ## compter le poids de chaque chiffre 
  8.  
  9.              subl %eax,%eax            ## nettoyer EAX 
  10.              subl %ecx,%ecx            ## nettoyer ECX 
  11.  
  12. new_char : 
  13.  
  14.              addl %eax,%ecx            ## ajouter le nouveau chiffre dans ECX 
  15.              subl %eax,%eax 
  16.  
  17.              decl %esi 
  18.              cmpl $0,%esi 
  19.              jl out                    ## si ESI = 0, on termine 
  20.  
  21.              movb buffer(%esi),%al     ## AL <- un nouveau chiffre 
  22.              cmp $32,%al 
  23.              je if_space               ## si buffer (%esi) = space 
  24.  
  25.              subl $48,%eax             ## nombre = ascii - 48 
  26.  
  27.              incl %ebp                 ## incrémenter la position 
  28.  
  29.              cmpl $1,%ebp 
  30.              je new_char 
  31.  
  32.              movl %ebp,%ebx            ## multiplier EAX par 10 n (poids) fois 
  33.  
  34. mult_10 : 
  35.  
  36.              mul %esp 
  37.              decl %ebx 
  38.              cmpl $1,%ebx 
  39.              jg mult_10 
  40.              je new_char 
  41.  
  42. if_space : 
  43.  
  44.              movl %ecx,list(,%edi,4)   ## sauvegarder le contenu de ECX 
  45.              incl %edi 
  46.  
  47.              jmp new_elem 
  48.  
  49. out : 
  50.  
  51.              movl %ecx,list(,%edi,4)   ## sauvegarder le dernier nombre 

Cette portion de code va demander au processeur de parcourir le buffer en partant de l'adresse buffer(18). buffer(%esi) est l'EA d'un caractère dans le buffer :

EA = disp + base = buffer + %esi = buffer(base, 0, 1) = buffer(%esi)

On décrémente ESI de 1 (ligne 1). Puis, tant que ESI > 0, le processeur procède comme suit :

  • charger l'octet à l'adresse buffer(%esi) dans le registre %al et le comparer à 32 (le code ASCII du caractère SPACE) ;
  • si le contenu de %al est différent de 32, on soustrait 48 de %al (voir tableau 2). Ensuite, on multiplie (lignes 34..40) le résultat (%al - 48 ) n fois par 10 (n est le poids du chiffre). Le résultat de la multiplication sera chargé dans %ecx. On doit l'ajouter au contenu de %ecx, parce que la multiplication utilise explicitement les registres %eax et %edx. %ecx doit être nettoyé à chaque fois que l'on rencontre un espace.
  • un jmp if_space est exécuté, si %al est égal à 32. %ecx contient la somme de tous les chiffres de chaque nombre après la multiplication par 10. Cette somme n'est autre que le code binaire (32 bits) du nombre. On le sauvegarde à l'adresse :

    EA = disp + scale * index = list + %edi * 4 = list(0, index, 4) = list(,%edi,4)

    Le scale est égal 4 parce que la taille d'un entier est de quatre octets !

Pour bien fixer les idées, on va prendre comme exemple le nombre 229. Pour le convertir, le processeur doit effectuer le calcul suivant :

229 = (50 − 48) ∗ 102 + (50 − 48) ∗ 101 + (57 − 48) ∗ 100 = 2 ∗ 100 + 2 ∗ 10 + 9 ∗ 1

Le registre %edi contient le nombre d'entiers de la nouvelle liste (list).

Étape 3 : trier la liste

Voir le programme sort.c.

Étape 4 : afficher la liste triée

Pour afficher un élément de la liste, l'ordinateur doit envoyer le code ASCII de chacun de ses chiffres au périphérique de sortie standard (l'écran, fd = 1). D'autre part, les entiers de la liste sont stockés dans la mémoire au format binaire (32 bits). On doit donc les convertir en un flux de caractères. Autrement dit, on doit calculer les codes ASCII (48..57) de chacun de ces chiffres.

C'est l'objectif de cette portion de notre code :

 
Sélectionnez
  1.            movl %edi,%esi 
  2.  
  3. screen : 
  4.            movl $10,%ebx 
  5.            movl list(,%esi,4),%eax 
  6.  
  7.            movl $stack,%esp 
  8.  
  9. next2 : 
  10.            movl $0,%edx 
  11.            divl %ebx                   ## division non signée 
  12.  
  13.            decl %esp 
  14.            addl $48,%edx 
  15.            movb %dl,(%esp) 
  16.            cmpl $0,%eax 
  17.            jg next2 
  18.  
  19.            movl $stack,%edx 
  20.            subl %esp,%edx              ##  edx <-- taille de 991 : 
  21.                                        ##  edx <-- 0xf0000100 - 0xf00000f4 
  22.                                        ##  edx <-- 0xc = 12 octets 
  23.            movl %esp,%ecx              ##  ecx <-- adresse de 991 dans la pile 
  24.            movl $1,%ebx 
  25.            movl $4,%eax 
  26.            int $0x80 
  27.  
  28.            movl $1,%edx 
  29.            movl $space,%ecx 
  30.            movl $1,%ebx 
  31.            movl $4,%eax 
  32.            int $0x80 
  33.  
  34.            decl %esi 
  35.            cmpl $0,%esi 
  36.            jge screen 

Les propriétés de la pile vont nous faciliter cette tâche. Une conversion « décimal-décimal » (division entière par 10) nous permettra d'extraire les chiffres de chaque entier. Pour avoir le code ASCII de chaque chiffre, on doit ajouter 48 à son code obtenu par division entière (divl). Le schéma suivant montre la conversion du nombre 269 :

Image non disponible

Les instructions 19..20 permettent de calculer le nombre d'octets empilés (12 octets pour le nombre 269). Le registre %edx doit contenir ce nombre pour exécuter l'appel système. Le registre %ecx pointe vers le TOS (Top Of Stack), c'est-à-dire vers le premier chiffre. À l'exécution de l'appel système, le processeur va afficher 2 puis 6 puis 9 (c'est exactement 269) puis un espace.

V-B. Exemple 2 : utiliser les sous-programmes en assembleur

Le but de l'exemple somme.sTéléchargez l'exemple 2 est de montrer le passage des paramètres à travers la pile à un sous-programme (fonction ou procédure).

Une telle opération doit suivre quelques règles (conventions) pour rendre efficace la communication entre le programme appelant et le sous-programme appelé. Ces règles vont préciser :

  • comment le sous-programme va repérer et utiliser les paramètres passés ;
  • comment le programme appelant va repérer les valeurs de retour ;
  • comment utiliser les variables locales dans le sous-programme.

Le stack frame

Le processeur alloue, dynamiquement, un espace de stockage dans la pile pour chaque sous-programme. Cet espace est appelé stack frame. Il sera utilisé par le sous-programme pour stocker ses variables locales. Tout comme les variables automatiques en langage C, les variables locales seront perdues après le retour du sous-programme. Le stack frame sera complètement libéré après l'exécution de l'instruction RET. Le registre %esp pointe vers le TOS. Le registre %ebp contient l'adresse du premier élément.

Image non disponible

Les étapes à suivre

Les étapes nécessaires pour invoquer efficacement un sous-programme à partir du programme principal (ou à partir d'un sous-programme) sont :

  • empiler les paramètres ;
  • appeler le sous-programme ;
  • enregistrer et mettre à jour le registre %ebp, dans le sous-programme :
 
Sélectionnez
  1.                push %ebp 
  2.                movl %esp,%ebp 
  • sauvegarder le contexte : empiler le contenu des registres du processeur pour ne pas les perdre durant l'exécution du sous-programme ;
  • exécuter le sous-programme : au cours de cette étape, le registre %esp ne doit pas être modifié. Le registre %ebp est utilisé pour se référer aux données dans la pile ;
  • libérer l'espace mémoire alloué aux variables locales ;
  • restaurer les anciennes valeurs des registres ;
  • restaurer la valeur de %ebp : cette étape va détruire le stack frame. Écrire ces deux lignes de code avant l'instruction RET :
 
Sélectionnez
  1.                movl %ebp,%esp 
  2.                pop %ebp 
  • nettoyer la pile : en dépilant les paramètres empilés pendant l'étape 1.
  • charger l'adresse de retour dans %eip : en exécutant l'instruction RET. Tout simplement, elle va charger le nouveau TOS dans le registre %esp ;

Le code somme.c montre comment effectuer un passage par adresse et un passage par valeur en langage C. Cependant, le code somme.s montre ces deux méthodes de passage des paramètres en assembleur GNU.

Ce programme fait appel à la fonction C printf(). L'exemple 3 va expliquer comment le faire.

V-C. Exemple 3 : interfacer du code assembleur et du langage C

Cet exempleTéléchargez l'exemple 3 montre comment utiliser correctement une fonction C de la bibliothèque standard GNU libc dans un code écrit en assembleur GNU.

Les deux programmes date.c et date.s utilisent les fonctions scanf et printf pour saisir et afficher la date :

  • le programme date.c montre comment appeler une fonction standard dans un code C ;
  • le programme date.s montre comment l'appeler dans un programme assembleur GNU.

En langage C, les paramètres sont passés entre parenthèses : printf(param,…) et scanf(param,…).

Ce n'est pas le cas en assembleur : les paramètres doivent être passés à travers la pile !

L'ordre des paramètres dans la pile est significatif ! Vous devez le respecter ! Ainsi, les stack frames des deux fonctions doivent être organisés comme suit :

Adresse Paramètre
0(%ebp) l'ancien EBP
4(%ebp) adresse de retour
8(%ebp) adresse de format (pformat et sformat)
12(%ebp) valeur/adresse de la variable jour
16(%ebp) valeur/adresse de la variable mois
20(%ebp) valeur/adresse de la variable an

Les valeurs de retour de scanf et printf sont passées au programme principal (main) par le registre EAX.

Les étapes 3-9 d'appel des sous-programmes en assembleur sont invisibles, puisque nous ne sommes pas responsables de l'implémentation de la fonction standard. On va juste y faire appel !

Le Makefile

Pour construire l'exécutable à partir d'un code source écrit purement en assembleur, on a utilisé les programmes as et ld. C'est le cas de l'exemple 1. Mais pour assembler et faire l'édition de liens d'un programme assembleur invoquant des fonctions C, on doit utiliser un compilateur. Sous Linux, le compilateur C est le programme gcc.

 
Sélectionnez
  1. date : date.o 
  2.        gcc -gstabs -o date date.o 
  3.  
  4. date.o : date.s 
  5.          gcc -c -o date.o date.s 
  6. clean : 
  7.         rm -fv date date.o 

Le compilateur gcc va transformer (compiler) notre code (C + assembleur) en un code cible (code assembleur). Puis il va faire appel à as pour assembler et à ld pour construire l'exécutable. Le point d'entrée de ld doit être le symbole global main. C'est l'adresse à partir de laquelle le processeur commencera le fetch des instructions lorsque le code sera chargé en mémoire et exécuté.

tri.s

Dans cet exemple, on va réécrire le code de l'exemple 1. Mais cette fois-ci, on va utiliser la fonction standard printf pour afficher la liste des entiers. De plus, on va utiliser la fonction saisir() pour saisir au clavier une liste d'entiers et la fonction trier() pour trier la liste (voir le fichier func.c).

Notre code devient plus simple et plus structuré.

Pour terminer la saisie des entiers (fonction saisir()), entrez le nombre -1 puis appuyez sur la touche Enter.

V-D. Exemple 4 : un programme d'amorçage

Un secteur d'amorçage (Boot Sector) est, généralement, constitué des 512 premiers octets d'un support de stockage (disquette, disque dur, CD/DVD, flash disk) qui peut contenir un programme d'amorçage (Bootstrap program). Pour un CD/DVD, une disquette ou un disque dur, un tel secteur n'est autre que le premier secteur (piste 0, tête 0, secteur 1).

D'autre part, un programme d'amorçage est un programme 16 bits exécutable, qui sera chargé par le programme de démarrage du BIOS, dans la mémoire RAM durant la séquence d'amorçage de l'ordinateur.

À travers cet exemple, on va montrer comment écrire un tel programme et comment le faire tourner sur une machine occupée par un processeur de la famille x86 (x86-32 ou x86-64). Vous pouvez télécharger le code source de l'exemple ici :
http://asm.developpez.com/telecharger/detail/id/3506/Un-programme-d-amorcage

Il contient deux fichiers sources :

  • hello.s : un programme d'amorçage permettant d'afficher sur l'écran le message « Hello, World ! » ;
  • rtc.s : un programme d'amorçage permettant de lire l'horloge temps réel (RTC :Real Time Clock) de l'ordinateur et de l'afficher sur l'écran.

Le BIOS

Pour une machine compatible avec l'IBM PC, le BIOS (Basic Input Output System) est le premier programme (microcode ou firmware) exécuté par le processeur juste après la mise sous tension de l'ordinateur. Le programme (les routines) BIOS est stocké dans une mémoire non volatile (ROM) intégrée à la carte mère. Il a deux fonctions principales :

  1. exécuter les tests POST (Power-On Selft-Test) : vérifier l'intégrité du programme BIOS lui-même, tester la RAM, tester les autres périphériques de l'ordinateur… ;
  2. chercher un périphérique amorçable (bootable) pour démarrer le système (charger le noyau du système en mémoire). C'est en vérifiant, dans l'ordre, le secteur d'amorçage de chaque périphérique connecté à l'ordinateur et configuré dans la liste (du BIOS) des périphériques d'amorçage. Lorsque le BIOS rencontre un secteur d'amorçage, il charge le programme stocké dedans, dans la mémoire et lui transfère le contrôle.

Le mode réel

Le 80386 a introduit aux processeurs de la famille x86 la capacité à fonctionner en trois modes : mode protégé, mode virtuel et mode réel (ou mode 8086).

Ce qui nous intéresse, dans cet exemple, c'est le mode réel. C'est le mode de fonctionnement du processeur immédiatement après la mise de l'ordinateur sous tension. Dans ce mode, certains registres du processeur sont initialisés, par INTEL, à des valeurs prédéfinies. En outre, la protection, les interruptions et la pagination sont désactivées et l'espace mémoire complet est disponible.

Ainsi, le processeur peut exécuter uniquement des programmes 16 bits. Cet état du processeur est adéquat pour exécuter un programme d'amorçage.

L'état interne du processeur en mode réel

En mode réel, un processeur de la famille x86 (x86-32 ou x86-64) exécute uniquement des codes 16 bits conçus pour le 80286 et le 8086. Par conséquent, son architecture interne est presque identique à celle du 8086.

La mémoire

Quelle que soit la taille de la mémoire de votre ordinateur, dans le mode réel le processeur peut adresser seulement 1Mo + 64Ko de RAM. Les 64Ko sont utilisés pour adresser les périphériques d'entrées/sorties. Le processeur peut, alors, seulement manipuler des octets et des mots (16 bits).

Cet espace mémoire peut être localisé dans n'importe quelle zone dans la mémoire physique, à condition quelle ne soit pas réservée par INTEL.

Les registres

Le tableau suivant montre comment certains registres (le 30486, le Pentium I et tous les processeurs de la famille P6) sont initialisés en mode réel :

Registre Contenu
EFLAGS 0x0002
EIP 0xFFF0
CS Sélecteur : 0xF000
Base : 0xFFFF0000
Limite : 0xFFFF
DS,SS,ES,FS,GS Base : 0x00000000
Limite : 0xFFFF
GDTR,IDTR Base : 0x00000000
Limite : 0xFFFF

Pour plus de détails, jetez un coup d'œil sur le chapitre 9 dans ce document :
http://download.intel.com/design/processor/manuals/253668.pdf

En mode protégé, un processeur de la famille x86 utilise la règle suivante pour calculer une adresse physique :

Adresse linéaire = Adresse physique = Base + Offset

On l'a appelée adresse physique parce qu'en mode réel la pagination sera désactivée.

D'autre part, en mode réel l'adresse physique est normalement calculée en utilisant cette règle :

Adresse logique = Offset : Base[segment selector]

Adresse physique = Base x 16 + Offset

Cependant, à la réinitialisation du processeur (RESET), l'adresse de Base est égale à 0xFFFF0000 (voir le tableau ci-dessus). Ainsi, l'adresse de départ est formée par addition de la valeur de l'adresse de base et le contenu du registre EIP (offset). Ainsi :

Adresse physique = 0xFFFF0000 + 0xFFF0 = 0xFFFFFFF0

Juste après la mise sous tension de l'ordinateur, le processeur utilise la première règle pour localiser et exécuter le programme BIOS. Ensuite, le processeur va suivre la règle normale pour la traduction des adresses en mode réel (processeur 8086).

La première instruction qui sera exécutée par le processeur, après RESET, devrait être à l'adresse physique 0xFFFFFFF0. Cette instruction va amener le processeur à exécuter un saut (JMP) vers le programme BIOS. Lorsque celui-ci prend le contrôle, il commence par exécuter les tests POST. Une fois ces tests passés avec succès, le programme BIOS va chercher un périphérique amorçable pour démarrer le système.

Un programme d'amorçage minimal

Pour vérifier si un support de stockage est amorçable ou non, le BIOS teste simplement les deux derniers octets (octets 510 et 511) du secteur d'amorçage. Si ces deux octets contiennent la valeur 0xAA55 (Boot Signature) alors le BIOS considèrera ce périphérique comme amorçable. Alors, il chargera les 512 octets du code à l'adresse physique 0x7C00 de la mémoire et lui transfèrera le contrôle (JMP 0x7C00). À ce stade, le rôle du BIOS se termine.

Le code suivant est celui d'un programme d'amorçage minimal :

 
Sélectionnez
  1.            .code16 
  2.            .data 
  3.            .text 
  4.            .global _start 
  5.  
  6. _start : 
  7.  
  8.            .org 510,0 
  9. signature : 
  10.            .word 0xaa55 

Un support de stockage, dont le secteur d'amorçage contient le binaire de ce programme, sera certainement amorçable. Mais, comme vous voyez, ce programme ne fera rien à son exécution (un écran noir et vide sera affiché). Il va juste rendre votre support de stockage amorçable. La directive .org a été déjà expliquée. Le directive .code16 va demander à l'assembleur d'assembler un code 16 bits (mode réel).

En fait, tout Linuxien a un programme d'amorçage prêt sur son ordinateur. C'est le stage1 de chargeur de boot GRUB. Ce fichier est stocké dans le répertoire /usr/lib/grub/stage1. Son rôle est d'initialiser le processeur au démarrage et de charger en mémoire le noyau GRUB (stage2).

Pour tester un programme d'amorçage, vous pouvez utiliser une machine virtuelle au lieu de redémarrer votre ordinateur plusieurs fois. Le logiciel VirtualBox, par exemple, vous permettra d'en créer une.

En cas de problème de démarrage d'une machine virtuelle sur un flash disk, visitez cette page :

http://www.twm-kd.com/software/boot-virtualbox-machine-from-a-usb-flash-drive

Exemple 1 : hello.s

 
Sélectionnez
  1.            .code16 
  2.            .text 
  3.            .global_start 
  4. _start : 
  5.            mov %cs,%ax           # ax = 0x7c0 
  6.            mov %ax,%ds           # ds = 0x7c0 
  7.            mov %ax,%es           # es = 0 x7c0 
  8.            mov %ax,%ss           # ss = 0 x7c0 
  9.            mov $0x100,%ax        # 256 words dans la pile (512 octets) 
  10.            mov %ax,%sp           # sp = 256 (TOS) 
  11.            mov $0x2f,%bl 
  12.            mov $0,%dh            # colonne 0 
  13.            mov $0,%dl            # ligne 0 
  14.            mov $0x1301,%ax       # afficher une chaîne de caractères 
  15.            mov $msg,%bp          # offset du message ES:BP 
  16.            mov $13,%cx           # taille du message 
  17.            int $0x10 
  18. msg : 
  19.            .asciz "hello, world !" 
  20.            .org 510 
  21. signature : 
  22.            .word 0xaa55 

Comme on l'a dit, au démarrage de l'ordinateur ce programme sera chargé dans la mémoire à l'adresse 0x7C00 et prendra le contrôle. Le registre CS (base) sera chargé avec la valeur 0x7C0 et le contenu de EIP (offset) sera 0. Ainsi, le processeur commencera la recherche des instructions (fetch) et l'exécution à partir de l'adresse physique :

CS x 16 + EIP = 0x07C0 x 0x10 + 0x0000 = 0x7C00

Notre programme possède les mêmes segments de données de pile et de code (DS = SS = ES = CS = 0x7C0). On a utilisé l'interruption 0x10 du BIOS pour afficher notre message. Une liste des interruptions est disponible sur cette page :

http://www.ctyme.com/intr/int.htm

Vous pouvez choisir un autre segment de pile.

Assemblage et édition de liens

On donne les commandes d'assemblage et d'édition de liens :

 
Sélectionnez
as -o hello.o hello.s
ld --oformat binary -Ttext 7c00 -Tdata 7c00 -o hello hello.o

On a utilisé l'option -oformat pour spécifier à l'éditeur de liens le format binaire du fichier de sortie (hello). Le format binaire doit être choisi. Si on édite les liens sans utiliser cette option, la taille du fichier généré dépassera 512 octets. Quand on passe cette option à l'éditeur de liens, il va générer un fichier de sortie au format binaire (raw binary). Autrement dit, il va générer un code machine sans en-têtes (headers) ayant exactement une taille 512 octets. Également, les options -Ttext et -Tdata sont utiliséEs pour indiquer à l'éditeur de liens que les sections de texte et de données doivent être chargéES à l'adresse 0x7C00.

Pour désassembler le fichier généré et afficher les informations qu'il contient, vous pouvez utiliser la commande suivante :

 
Sélectionnez
objdump -b binary -mi8086 -D hello

L'option -b va spécifier le format de fichier d'entrée (binaire). L'option -m va spécifier l'architecture pour laquelle on va désassembler. Nous avons spécifié l'architecture i8086.

Finalement, pour tester votre programme vous devez le copier dans le secteur d'amorçage d'un flash disk par exemple. Connectez votre flash disk, puis vérifiez son nom avec la commande fdisk -l. Le nom doit commencer par le préfixe sdb suivi par son numéro. Soyez prudent, les noms qui commencent par sda sont réservés pour les partitions de disque dur de votre ordinateur. Une confusion et vous risqueriez d'effacer les données stockées sur une partition ou d'endommager votre système !

Pour copier le fichier binaire dans le secteur d'amorçage de votre flash disk, utilisez la commande :

 
Sélectionnez
dd_rescue hello /dev/sdb

Exemple 2 : rtc.s

Dans cet exemple, nous allons écrire un programme d'amorçage qui, à son exécution, va lire l'horloge temps réel (RTC) de l'ordinateur en utilisant l'interruption BIOS numéro 0x1A et l'afficher au format hh:mm:ss.

A l'exécution de cette interruption, le processeur va lire l'horloge RTC et stocker sa valeur au format BCD (Binary Coded Decimal), dans les registres CX et BX :

Image non disponible

Pour afficher l'horloge, nous devons, d'abord, convertir chacun de ses chiffres codés au format BCD en son code ASCII approprié. Le codage BCD utilise 4 bits pour coder chaque chiffre décimal. Tandis que le codage ASCII utilise 8 bits (étendu) ou 7 bits (standard). Le tableau suivant donne le codage BCD et le codage ASCII des chiffres décimaux :

Décimal BCD ASCII (décimal) ASCII (binaire)
0 0000 48 00110000
1 0001 49 00110001
2 0010 50 00110010
3 0011 51 00110011
4 0100 52 00110100
5 0101 53 00110101
6 0110 54 00110110
7 0111 55 00110111
8 1000 56 00111000
9 1001 57 00111001

Les bits 0-3 de chaque code ASCII d'un chiffre sont égaux à son code BCD.

Le sous programme bsd_to_ascii va réaliser cette conversion (voir le code source rtc.s). Pour l'illustrer, nous allons prendre un exemple. Supposons que l'horloge lue soit 23:45:37. Le contenu des registres CX et BX sera :

Image non disponible

Nous allons expliquer comment convertir l'heure (h2h1) du format BCD au format ASCII.

Étape 1 :

Décalage logique (SHR) de 4 bits du contenu du registre AL :

SHR $4, %al : 0010 0011 » 4 = 0000 0010

Étape 2 :

Addition de 48 :

0000 0010 + 00110000 = 110010 = 5010 = ASCII(2)

Ensuite, on charge le contenu de AL dans la mémoire à l'adresse rtc(%si).

Étape 3 :

Maintenant, nous allons convertir h2. Tout d'abord, on charge le contenu du registre CH dans AL. Ensuite, on ajuste le contenu de AL avec l'instruction AAA :

AAA : 0010 0011 ==> 0000 0011

Étape 4 :

Addition de 48 :

0000 0011 + 00110000 = 0011 0011 = 5110 = ASCII(3)

Ensuite, on charge le contenu de AL dans la mémoire à l'adresse rtc(%si).

Les mêmes étapes seront utilisées pour convertir les minutes et les secondes. Utilisez les mêmes commandes (exemple 1) pour assembler, éditer les liens et tester cet exemple.

VI. Références

  • Intel 80386 Programmer's Reference Manual ;
  • le manuel de l'assembleur GNU ;
  • le manuel du linker ld ;
  • PC Assembly Language, par Paul A. Carter.

VII. Remerciements

Je remercie Obsidian pour sa relecture technique et zoom61 pour sa relecture orthographique.