Introduction à l'Assembleur GNU

Sous un système GNU/Linux sur Intel 80386

Auteur

Issam Abdallah

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.

Commentez

- Titre : Introduction à l'Assembleur GNU

- Auteur : Issam Abdallah

- Parution : 18 février 2013

- Mise à jour : 25 mai 2014

- Public visé :

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2014 Issam Abdallah. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- II. Définitions préliminaires

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

- II-B. Le code ASCII

- II-C. Le code BCD

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

- IV. Syntaxe de l'assembleur

- IV-A. Les sections

- IV-B. Les commentaires

- IV-C. Les déclarations

- IV-D. Les symboles

- Les symboles locaux

- IV-D-1. Le symbole « . »

- IV-E. Les Constantes

- Les nombres entiers

- Les caractères

- Les chaînes de caractères

- IV-F. Les expressions

- Préfixes

- Infixes

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

- Les opérandes

- L'ordre des opérandes

- IV-G-3. Les instructions LJMP/LCALL

- IV-H. Les directives

- .ascii ”string_1”, ”string_2” …

- .asciz ”string_1”, ”string_2” …

- .byte expression_1, expression_2 …

- .word expression_1, expression_2 …

- .quad expression_1, expression_2 …

- .int expression_1, expression_2 …

- .long expression_1, expression_2 …

- .fill repeat, size, value

- .org new-lc, fill

- .lcomm symbol, length

- .global symbol

- .set symbol, expression et .equ symbol, expression

- V. Exemples de codes assembleur

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

- Déclaration des variables

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

- Étape 2 : convertir les caractères du buffer

- Étape 3 : trier la liste

- Étape 4 : afficher la liste triée

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

- Le stack frame

- Les étapes à suivre

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

- Le Makefile

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

- Le mode réel

- L’architecture interne du processeur en mode réel

- L’adressage de la mémoire en mode réel

- Le BIOS

- L'IVT : Interrupt Vector Table

- Un programme d’amorçage minimal

- Assemblage et édition de liens

- VI. Références

- VII. Remerciements

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 !

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

- l'assembleur Intel : l'assembleur principal utilisant cette syntaxe 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.

Les codes sources des exemples cités dans ce tutoriel sont téléchargeables sur cette page.

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). Les systèmes de numération 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 … a_1 a_0 … , 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 … a_1.b^1 + a_0.b^0 … , 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 : 2193_10 = 2.10^3 + 1.10^2 + 9.10^1 + 3.10^0 = 2000 + 100 + 90 + 3
= 2193 ;

- b = 2 et N = 1011 : 1011_2 = 1.2^3 + 0.2^2 + 1.2^1 + 1.2^0 = 8 + 0 + 2 + 1
= 11 ;

- b = 16 et N = F3C9 : F3C9_16 = 15.16^3 +3.16^2 +12.16^1 +9.16^0 = 61440+768+192+9
= 62409 ;

- b = 2 et N = 10,11 : 10,11_2 = 1.2^1 + 0.2^0 + 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

II-C - Le code BCD

Le code BCD, Binary Coded Decimal, est utilisé pour coder des nombres d’une façon relativement proche de la représentation humaine usuelle (en base 10). En BCD, les nombres sont représentés en chiffres décimaux et chacun de ces chiffres est codé sur quatre bits :

Décimal

BCD

0

0000

1

0001

2

0010

3

0011

4

0100

5

0101

6

0110

7

0111

8

1000

9

1001

Il est remarquable que les bits 0-3 de chaque code ASCII d’un chiffre décimal sont égaux à son code BCD.

D’autre part, la plupart des ordinateurs stockent les données dans des octets (d'une taille de 8 bits). Deux méthodes communes permettent d’enregistrer les chiffres BCD de quatre bits dans un tel octet :

- format non compacté, Unpacked BCD : ignorer les quatre bits supplémentaires de chaque octet et leur ajouter quatre bits identiques (0 ou 1) ;

- format compacté, Packed BCD : enregistrer deux chiffres (4 bits) par octet.

Pour illustrer, supposons qu’on stocke successivement les chiffres décimaux 9 et 1 au format BCD :

- format non compacté : décimal –> 1 9, binaire –> 0000 0001 0000 1001 ;

- format compacté : décimal –> 1 9, binaire –> 0001 1001.

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 ! » :

1.                     .data
2. msg :               .asciz "Hello, World !\n"
3. len = . - msg
4.                     .bss
5.                     .text
6.                     .global _start
7.
8. _start :
9.                      movl $msg,%ecx
10.                      movl $len,%edx
11.                      movl $1,%ebx
12.                      movl $4,%eax
13.                      int  $0x80           # appel système
14.
15. exit :
16.                      movl $0,%ebx
17.                      movl $1,%eax
18.                      int  $0x80

Le code est écrit en assembleur GNU. Il utilise le jeu d'instructions d'un processeur de la famille x86-32. Nous en discuterons dans la section 5. Dans cette section, nous allons juste montrer comment le faire tourner sur la machine.

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 :

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). Notre makefile contiendra les commandes (rules) suivantes :

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

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

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 :

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

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, alors le symbole représente une valeur du compteur programme (le Program Counter ou le registre EIP du 80386).
On peut alors utiliser l’étiquette pour se référer dans le programme. On peut par exemple utiliser le nom du symbole de l’étiquette comme opérande de l’instruction jmp pour transférer le contrôle à la portion de code située à l’adresse symbolisée par l’étiquette ;

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) de la chaîne de caractères « Hello, World ! ».

Les symboles locaux

L’assembleur GNU propose dix noms de symboles locaux : « 0 », « 1 » .. « 9 ». Vous pouvez définir un symbole local en écrivant une étiquette de la forme « N : », avec N = 0, 1 .. 9. Pour se référer au dernier symbole N défini, vous pouvez, par exemple, spécifier « Nb » comme operande de l’instruction jmp. De même, pour se référer au premier symbole N suivant, vous pouvez spécifier « Nf » comme opérande. La lettre « b » dans « Nb » est l’abréviation du mot anglais backwards. La lettre « f » dans « Nf » est celle du mot forwards.

1:
##
##
jmp  1b
jmp  1f
##
##
1:
##
jmp  1b
jmp  2f
2:
##
##
2:

La première instruction jmp va transférer le contrôle à la portion de code située à l’adresse symbolisée par la première étiquette « 1 : ». En exécutant le deuxième et le troisième jmp, le processeur va commencer (immédiatement) l’exécution à l’adresse symbolisée par la deuxième étiquette « 1 : ». La quatrième instruction jmp va transférer le contrôle à la portion de code située à l’adresse symbolisée par la première étiquette « 2 : » (troisième étiquette dans le code !).

Vous remarquez, dans l’exemple ci-dessus, qu’on a utilisé le même symbole « 1 » pour représenter deux locations différentes. Ce qui est normalement interdit. Mais ça fonctionnera quand-même. En effet, les noms des symboles locaux sont juste des notations utilisées temporairement par le compilateur et le programmeur. Ils seront transformés en d’autres symboles avant d’être utilisés par l’assembleur. Vous pouvez consulter le manuel de l’assembleur GNU pour savoir comment ces symboles seront transformés.

IV-D-1 - Le symbole « . »

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

Exemple :

Dans le programme de la section 3, avant d’exécuter l’appel système, le registre EDX doit contenir la taille (le nombre de caractères) len du message.L’expression suivante permet de calculer précisément len :

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 14. 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 ! ».

Exemple :

Dans le programme de la section 3, nous avons échappé le caractère saut de ligne dans le message. Ainsi, nous avons utilisé un seul appel système pour afficher à la fois le message et effectuer le saut de ligne :

msg :               .asciz "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.

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 préfixes :

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

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 :

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 :

1. mov source,destination

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

IV-G-3 - Les instructions LJMP/LCALL

Les instructions LJMP (Long Jump) et LCALL (Long Call) permettent de transférer le contrôle à un autre programme situé dans un autre segment (autre que celui où elles sont écrites !). La syntaxe de ces deux instructions est :

Ljmp        $segment,$offset
lcall       $segment,$offset

Le premier opérande de chaque instruction sera chargé dans le registre CS et le deuxième sera chargé dans le registre EIP (ou IP en mode réel !).

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.

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 :

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

.byte expression_1, expression_2 …

Définit et initialise un tableau d'octets.

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

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

.quad expression_1, expression_2 …

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

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

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

.long expression_1, expression_2 …

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

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.

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 compteur location de la section courante à l’adresse new-lc.

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

Avance le compteur location (registre EIP) de la section texte (code segment) à l’adresse 510 puis écrit le mot 0xAA55. Les octets 0..509 seront chargés par des zéros.

.lcomm symbol, length

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

1.              .bss
2.              .lcomm buffer,1024

.global symbol

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

1.              .global      _start,main

Par défaut, l’éditeur de liens ld reconnaît le symbole global _start comme point d’entrée à l’édition de liens.

.set symbol, expression et .equ symbol, expression

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

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

1.                           .data
2. line :                    .byte ’\n
3. space :                   .byte 0x20
4. msg1 :                    .ascii " << "
5. len1 = . - msg1
6. msg2 :                    .ascii " >> "
7. len2 = . - msg2
8.                           .bss
9.                           .lcomm buffer,1024
10.                           .lcomm list,100
11.                           .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 25 nombres entiers ;

- stack : pour réserver 1024 octets dans la pile du programme.

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

1. msg_1 :                             ##
2.           movl $msg1,%ecx           ## ECX = l'adresse du message
3.           movl $len1,%edx           ## EDX = la longueur du message en octets
4.           movl $1,%ebx              ## EBX = file descriptor = 1 => standard output
5.           movl $4,%eax              ## Appel système 4 : write
6.           int $0x80                 ## Interruption logicielle (Exception) ID = 0x80
7.
8. # Saisir les entiers #############
9.
10. kybd :                              ##
11.           movl $buffer,%ecx         ## ECX = l'adresse du buffer
12.           movl $1024,%edx           ## EDX = la taille du buffer en octets
13.           movl $0,%ebx              ## EBX = file descriptor = 0 => standard input
14.           movl $3,%eax              ## Appel systeme 3 : read
15.           int $0x80
16.
17. # Compter les elements de la liste  ##
18.
19.           subl %esi,%esi
20. 1:
21.           movl buffer(%esi),%ebx    ## EA = Scale * Index + Displacement
22.           incl %esi
23.           cmp %bl,line
24.           jne 1b

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 (interruption logicielle) immédiatement après son exécution.

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.

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 10..15 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..24, 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 :

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 :

1.              decl %esi                 ## index dans le buffer
2.              movl $0,%edi              ## index dans la liste
3.
4. new_elem :
5.              movl $10,%esp             ## pourmultiplier par 10
6.              movl $0,%ebp              ## compter le poids de chaque chiffre
7.
8.              subl %eax,%eax            ## EAX = 0
9.              subl %ecx,%ecx            ## ECX = 0
10.
11. new_digit :
12.              addl %eax,%ecx            ## ECX = ECX + EAX
13.              subl %eax,%eax
14.              decl %esi
15.              cmpl $0,%esi
16.              jl   1f                   ## si ESI = 0
17.              movb buffer(%esi),%al     ## charger un nouveau chiffre dans AL
18.              cmp  $32,%al
19.              je   if_space             ## si buffer (%esi) = 32 (espace)
20.              subl $48,%eax             ## chiffre decimal = ascii – 48
21.              incl %ebp
22.              cmpl $1,%ebp
23.              je   new_digit
24.              movl %ebp,%ebx            ## multiplier EAX n (poids) fois par 10
25.
26. mult_10 :
27.              mul  %esp
28.              decl %ebx
29.              cmpl $1,%ebx
30.              jg   mult_10
31.              je   new_digit
32.
33. if_space :
34.              movl %ecx,list(,%edi,4)   ## sauvegarder le contenu de ECX
35.              incl %edi
36.              jmp new_elem
37.
38. 1 :
39.              movl %ecx,list(,%edi,4)

L’adresse effective d’un caractère dans le buffer est :

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

L’instruction mul utilise implicitement le registre EAX pour stocker le nombre à multiplier. Le résultat sera chargé dans EDX:EAX. La seule opérande de l’instruction est le multipliant, qui est le contenu (10) du registre ESP dans notre cas.

Étant donné le code ASCII d’un nombre décimal, pour convertir ce code au nombre décimal approprié, on doit soustraire 48.

Exemple : ASCII(3) = 51 = 3 + 48 <==> 3 = ASCII(3) − 48 (voir tableau 2).

D’autre part, un nombre décimal est égale à la somme de ses chiffres multipliés, chacun, par 10n. n est le poids de chaque chiffre dans le nombre.

Donc, pour convertir un nombre du buffer représenté par des caractères, on doit d’abord convertir ces caractères en des nombres décimaux. Puis, on doit appliquer la formule de la section II-A. Pour bien fixer les idées, on va prendre comme exemple le nombre 229 (voir le schéma ci-dessus). 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

Pour convertir tous les éléments de la liste, le processeur procède comme suit : tant que ESI > 0 :

- 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. Ensuite, on multiplie le résultat (AL - 48) n fois par 10 (n est le poids du chiffre). Le résultat de la multiplication sera chargé dans EAX. On doit l’ajouter au contenu de ECX, parce que la multiplication utilise implicitement les registres EAX et EDX. ECX doit être nettoyé à chaque fois qu’on rencontre un espace ;

- un jmp if_space est exécuté si le contenu de 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 4 octets !

Finalement, 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 :

1.            movl  %edi,%esi
2.
3. 2 :
4.            movl  $10,%ebx
5.            movl  list(,%esi,4),%eax
6.            movl  $stack,%esp
7.
8. 3 :
9.            movl  $0,%edx
10.            idivl %ebx
11.            decl  %esp
12.            addl  $48,%edx
13.            movb  %dl,(%esp)
14.            cmpl  $0,%eax
15.            jg    3b
16.            movl  $stack,%edx
17.            subl  %esp,%edx
18.            movl  %esp,%ecx
19.            movl  $1,%ebx
20.            movl  $4,%eax
21.            int   $0x80
22.            movl  $1,%edx
23.            movl  $space,%ecx
24.            movl  $1,%ebx
25.            movl  $4,%eax
26.            int   $0x80
27.            decl  %esi
28.            cmpl  $0,%esi
29.            jge   2b

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 :

Les instructions 22 et 23 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 : les sous-programmes en assembleur

Le but de l'exemple somme.s est de montrer le passage des paramètres à travers la pile à un sous-programme. Une telle opération doit suivre quelques règles (conventions) pour rendre efficace la communication entre le programme appelant et le sous-programme appelé.

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 complètement libéré au retour du sous-programme.

Un sous-programme utilise son propre stack frame pour stocker ses variables locales (variables automatiques en C, qui seront perdues au retour du sous-programme !) durant son exécution. Même la fonction principale main() d’un programme possède son propre stack frame !

Les étapes à suivre

Les étapes nécessaires pour invoquer efficacement un sous-programme sont :

- empiler les paramètres qu’on veut passer au sous-programme ;

- appeler le sous-programme avec l’instruction call ;

- enregistrer et mettre à jour le registre EBP, dans le sous-programme :

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, en les dépilant de la pile ;

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

1.                movl %ebp,%esp
2.                pop %ebp

- charger l'adresse de retour dans EIP : en exécutant l'instruction RET. Tout simplement, elle va charger le nouveau TOS dans le registre EIP ;

- nettoyer la pile : en dépilant les paramètres empilés pendant l'étape 1.

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

Le programme date.s utilise les fonctions C standard scanf et printf pour saisir et afficher la date.

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. Ils doivent être passés dans l'ordre suivant :

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

Ainsi, les paramètres doivent être empilés dans l’ordre inverse à celui dans lequel ils sont écrits dans le prototype de la fonction (parm1, parm2…) ! Autrement dit, le dernier paramètre doit être empilé le premier et le premier paramètre doit être empilé le dernier !

Les valeurs de retour de scanf et printf sont retenues dans 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.

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

Retour sur l’exemple 1

Le dossier exemple_3 contient un sous dossier nommé tri. Le code source qu'il contient est une réécriture du programme tri.s de l’exemple 1. On a modifié le programme par l’utilisation des deux fonctions C définies dans le fichier func.c : saisir() et trier() :

- la fonction saisir(), lorsqu’elle est appelée, va demander à l’utilisateur de saisir (scanf()) une liste d’entiers et de les charger dans un tableau (list) d’entiers ;

- la fonction trier() va trier la liste d’entiers en utilisant l’algorithme de tri à bulle.

L’utilisation des ces deux fonctions, ainsi que l’utilisation de la fonction C standard printf() pour l’affichage, va nous aider à réduire la taille du programme, en éliminant pas mal d'instructions assembleur. Notre code devient plus simple, plus structuré et donc plus compréhensible.

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

Les codes sources des exemples cités dans cette section sont téléchargeables sur cette page.

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

Le mode réel 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’architecture 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.

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 de ce document.

L’adressage de la mémoire en mode réel

Quelle que soit la taille de la mémoire de votre ordinateur, dans le mode réel le processeur peut adresser seulement 1 Mo + 64 Ko de RAM. Les 64 Ko 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 de la mémoire physique, à condition quelle ne soit pas réservée par INTEL.

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

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é par l'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 une instruction 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.

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 (en mode réel) 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 et initialiser les autres périphériques de l’ordinateur, initialiser l'IVT (Interrupt Vector Table)… ;

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 en mémoire le programme stocké dedans, et lui transfère le contrôle.

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) de son secteur d’amorçage. Si ces deux octets contiennent la valeur 0xAA55 (Boot Signature), alors le BIOS déduira que ce périphérique est 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).

Enfin, vous trouverez toutes les informations sur les BIOS sur ce site.

L'IVT : Interrupt Vector Table

Le BIOS met à la disposition de tout programme qui s’exécute en mode réel des routines qui vont l'aider à effectuer quelques opérations compliquées, comme l’accès au matériel de l’ordinateur. Ces routines sont appelées les services ou encore les interruptions BIOS.

À ce propos, un tableau de 256 entrées est stocké par le BIOS à l’adresse absolue entre 0x0 et 0x3FF (le premier Kb de la mémoire). Chaque entrée de ce tableau est de quatre octets et contient deux adresses [segment :offset] comme pointeurs vers une routine d’interruption.

Ces routines sont accessibles via l’instruction int n. Chaque interruption BIOS est identifiée par un nombre n. Le processeur multiplie par 4 le nombre n et utilise le résultat (index) pour localiser, dans l'IVT, le vecteur interruption approprié. Le premier mot (16 bits) sera chargé dans le registre CS (segment) et le deuxième sera chargé dans le registre IP (offset).

Ainsi, le processeur va exécuter la routine convenable.

D’une part parce que l'IVT sera écrasée par le code système. Le noyau Linux, par exemple, sera chargé par son propre programme d’amorçage à l’adresse absolue 0x0, où est installée l'IVT. D’autre part, parce qu'en mode protégé tout système installe sa propre IVT et fournit au programmeur ses propres routines d'interruptions. Autrement dit, on a besoin des interruptions BIOS seulement en mode réel, lorsqu'on n'a pas de système d’exploitation qui tourne.

Un programme d’amorçage minimal

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

1.            .code16
2.            .data
3.            .text
4.            .global _start
5.
6. _start :
7.            .org 510,0
8.            .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 cas de problème de démarrage d’une machine virtuelle sur un flash disk, visitez cette page.

Exemple 1 : hello.s

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.

Assemblage et édition de liens

Les commandes d’assemblage et d’édition de liens sont :

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 raw binary. Autrement dit, il va générer un code machine 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.

La commande suivante permet de désassembler le fichier généré :

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.

Pour charger votre programme de boot sur le secteur d’amorçage de votre flash disk, utilisez le programme dd_rescue.

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 compacté (Packed BCD), dans les registres CX et BX :

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 quatre bits pour coder chaque chiffre décimal. Tandis que, le codage ASCII utilise 8 bits (étendu) ou 7 bits (standard).

On a dit, au début de ce tutoriel (section II-C), que les bits 0-3 de chaque code ASCII d’un chiffre sont égaux à son code BCD. On va utiliser cette propriété pour convertir les codes BCD en des codes ASCII.

Le sous programme bcd_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 :

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.

Exemple 3 : int 0x13, AH = 0x42 : lecture étendue des secteurs

Dans cet exemple, on va montrer comment utiliser la fonction 0x42 de l’interruption BIOS 0x13 pour lire (en mode réel) des secteurs à partir d’un périphérique de stockage de masse (un flash disk dans notre cas).

Le code source de l’exemple contient deux fichiers :

1. boot.s : c’est un programme d’amorçage qui sera stocké dans le premier secteur de notre périphérique de stockage. Son rôle est de lire le deuxième secteur du périphérique et de charger le programme stocké dedans (hello.s) en mémoire et, ensuite, lui transférer le contrôle ;

2. hello.s : le binaire de ce programme sera stocké dans le deuxième secteur de notre périphérique de stockage (juste après le secteur d’amorçage). À son exécution, ce programme va afficher un message à l’écran en utilisant l’interruption BIOS 0x10.

En fait, le BIOS propose une autre fonction de l'interruption 0x13 pour lire des secteurs à partir d’un périphérique de stockage. C’est la fonction classique numéro 0x2 (AH = 0x2).

Cette fonction est très compliquée parce quelle utilise l’adressage CHS (Cylinder/Head/Sector) pour localiser les secteurs à lire. De plus elle limite le nombre de cylindres (pas plus de 1024 cylindres) et le nombre de secteurs (pas plus de 63 secteurs) à lire.

Par contre, la fonction 0x42 de l'interruption BIOS et très simple à utiliser et permet de lire n’importe quel secteur du périphérique. Cette fonction traite le périphérique comme un tableau contigu de secteurs (512 octets), où chaque élément (secteur) est identifié uniquement par son numéro (index) ! Cette méthode d’adressage des secteurs de disque dur est souvent nommée Logical Block Addressing, LBA.

Le tableau suivant liste les paramètres qu’on doit spécifier pour l'interruption 0x13 :

AL

0x42, numéro de la fonction

DL

index du périphérique de stockage (flash disk par exemple)

DS:SI

[segment :offset] pointeur vers le DAP : Disk Address Packet

L'index de périphérique de stockage sera chargé automatiquement par le BIOS, au démarrage, dans le registre DL. D’autre part, le DAP est une structure de données qu’on doit spécifier à l'interruption 0x13. Elle doit contenir exactement les informations suivantes :

Offset

Taille

Description

0x0

1 octet

La taille du DAP = 16 octets

0x1

1 octet

inutilisé, doit être égal à 0

0x2 – 0x3

2 octets

nombre de secteurs à lire

0x4 .. 0x7

4 octets

[segment :offset] : un pointeur vers la zone mémoire dans laquelle on va stocker les données lues

0x8 .. 0xf

8 octets

le numéro du premier secteur qu’on va lire : 0 .. (264 − 1)

Ainsi, la fonction 0x42 peut lire 2^16 secteurs à la fois (nombre de secteurs à lire) et peut traiter un disque dur (par exemple) de taille 2^64 ∗ 512 octets ! Et je vous laisse faire le calcul.

En cas d'erreur de lecture, le bit CF du registre EFLAGS sera mis à 1. Une instruction JC (Jump short if carry) peut être utilisée pour traiter l’erreur, en répétant la lecture par exemple.

Le code de notre programme d’amorçage (boot.s) est le suivant :

.code16
.text
.global _start
_start :

mov     %cs,%ax
mov     %ax,%ds      # DS = 0 x7c0
mov     %ax,%es      # ES = 0 x7c0
mov     %ax,%ss      # SS = 0 x7c0
mov     $0x100,%sp   # TOS = 256 (512 octets)
movb    $0x42,%ah    # fonction 0x42 : lecture étendue des secteurs
movw    $dap,%si     # SI = dap : pointeur vers la structure
int     $0x13
ljmp    $0x900,$0x0  # JMP vers [segment:offset] =
# 0x900 x 0x16 + 0x0 = 0x9000

## DAP : Disk Address Packet
dap :          .byte   0x10         # taille de dap = 16 octets
.byte   0x0          # inutilisé : égale 0
.word   0x1          # nombre de secteurs à lire
## buffer = [ segment:offset] : mémoire vers
## laquelle on va transférer le contenu des secteurs lus
.word   0x0000       # offset
.word   0x900        # segment
.quad   0x1          # secteur2 : l'index du premier secteur

.org    510
.word   0xaa55

À son exécution, notre programme va lire le deuxième secteur du périphérique de stockage (secteur numéro 1) et charger les données stockées dedans (binaire du programme hello.s) dans la mémoire à l’adresse physique 0x9000 :

segment :offset = 0x0900 :0x0000

adresse physique = 0x0900 * 0x10 + 0x0000 = 0x9000

L'instruction ljmp va transférer le contrôle au programme situé à cette adresse (voir section IV-G).

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.