Introduction à l'Assembleur GNU

Sous un système GNU/Linux sur Intel 80386

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.

12 commentaires Donner une note à l'article (5)

Article lu   fois.

L'auteur

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

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 Image non disponiblecette 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 … 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    

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

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
                    .data
msg :               .asciz "Hello, World !\n"
len = . - msg
                    .bss
                    .text
                    .global _start

_start :
                     movl $msg,%ecx
                     movl $len,%edx
                     movl $1,%ebx
                     movl $4,%eax
                     int  $0x80           # appel système

exit :
                     movl $0,%ebx
                     movl $1,%eax
                     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 5Exemples de codes assembleur. 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 :

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

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

clean :
        rm -fv hello.o

Important [1] : Le symbole global _start est le point d'entrée par défaut de l'éditeur de liens. Il symbolise l'adresse à partir de laquelle le processeur commencera le fetch des instructions, lorsque le programme sera chargé en mémoire et exécuté. Si vous choisissez un nom de symbole de votre choix comme point d'entrée, vous devez passer l'option -e ou -entry à l'éditeur de liens.

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.

Important [2] : L'ordre des 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.
2.
3.
4.
5.
                          .nom directive
label_1 :                 .nom directive attribut
label_2 :
                          expression
                          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 : . $.

Important [3] : 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 représentent des locations différentes dans la mémoire.

Important [4] : L'assembleur GNU réserve un ensemble de symboles clés pour les directives.

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

Important [5] : Le nom du symbole d'une étiquette doit être unique dans un programme. Autrement dit, vous ne devez jamais utiliser le même symbole pour représenter deux locations (étiquettes) différentes. Les symboles locaux sont la seule exception.

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 opérande 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.

 
Sélectionnez
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 3L'assemblage et l'édition de liens sous Linux, 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 :

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

Important [6] : 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 de la section 3L'assemblage et l'édition de liens sous Linux, 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 :

 
Sélectionnez
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 :

 
Sélectionnez
1.
2.
3.
4.
len = . - msg
SYSSIZE = 0x80000
SYSSEG = 0x1000
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.
2.
3.
movb $0x4f,%al               # 8-bit
movw $0x4ffc,%ax             # 16-bit
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…

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 :

 
Sélectionnez
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.

 
Sélectionnez
1.
2.
msg1 :                   .ascii "Hello, World !\ n"
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.
2.
msg1 :                   .ascii "Hello, World !\0"
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.
2.
3.
4.
descrp :                .word    0x07ff
                        .word    0x0000
                        .word    0x9200
                        .word    0x00C0

Important [7] : 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 représenté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.
2.
3.
4.
_gdt :                .quad    0x0000000000000000
                      .quad    0x00c09A00000007ff
                      .quad    0x00C09200000007ff
                      .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.
2.
3.
matrix3_3 :             .long 22,36,9
                        .long 10,91,0
                        .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 compteur location de la section courante à l'adresse new-lc.

 
Sélectionnez
1.
2.
3.
4.
5.
           .text
           .global      _start
_start :
           .org 510,0
           .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.

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

Important [8] : 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 à l'édition de liens.

Important [9] : Un symbole global déclaré dans un fichier source peut être utilisé dans un autre fichier sans le redéclarer.

.set symbol, expression et .equ symbol, expression

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

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

V. Exemples de codes assembleur

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

Le programme tris.s utilise la méthode de tri à bulles 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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
                          .data
line :                    .byte '\n
space :                   .byte 0x20
msg1 :                    .ascii " << "
len1 = . - msg1
msg2 :                    .ascii " >> "
len2 = . - msg2
                          .bss
                          .lcomm buffer,1024
                          .lcomm list,100
                          .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

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
msg_1 :                             ##               
          movl $msg1,%ecx           ## ECX = l'adresse du message
          movl $len1,%edx           ## EDX = la longueur du message en octets
          movl $1,%ebx              ## EBX = file descriptor = 1 => standard output
          movl $4,%eax              ## Appel système 4 : write
          int $0x80                 ## Interruption logicielle (Exception) ID = 0x80
 
# Saisir les entiers #############

kybd :                              ##  
          movl $buffer,%ecx         ## ECX = l'adresse du buffer
          movl $1024,%edx           ## EDX = la taille du buffer en octets
          movl $0,%ebx              ## EBX = file descriptor = 0 => standard input
          movl $3,%eax              ## Appel systeme 3 : read
          int $0x80

# Compter les elements de la liste  ##

          subl %esi,%esi
1:
          movl buffer(%esi),%ebx    ## EA = Scale * Index + Displacement
          incl %esi
          cmp %bl,line
          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 :

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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
             decl %esi                 ## index dans le buffer
             movl $0,%edi              ## index dans la liste

new_elem :
             movl $10,%esp             ## pour multiplier par 10
             movl $0,%ebp              ## compter le poids de chaque chiffre

             subl %eax,%eax            ## EAX = 0
             subl %ecx,%ecx            ## ECX = 0

new_digit :
             addl %eax,%ecx            ## ECX = ECX + EAX
             subl %eax,%eax
             decl %esi
             cmpl $0,%esi
             jl   1f                   ## si ESI = 0
             movb buffer(%esi),%al     ## charger un nouveau chiffre dans AL
             cmp  $32,%al
             je   if_space             ## si buffer (%esi) = 32 (espace)
             subl $48,%eax             ## chiffre decimal = ascii - 48
             incl %ebp
             cmpl $1,%ebp
             je   new_digit
             movl %ebp,%ebx            ## multiplier EAX n (poids) fois par 10

mult_10 :
             mul  %esp
             decl %ebx
             cmpl $1,%ebx
             jg   mult_10
             je   new_digit

if_space :
             movl %ecx,list(,%edi,4)   ## sauvegarder le contenu de ECX
             incl %edi
             jmp new_elem

1 :
             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. Le seul 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 ).

D'autre part, un nombre décimal est égal à 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-ALes systèmes de numération. 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 :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
           movl  %edi,%esi

2 :
           movl  $10,%ebx
           movl  list(,%esi,4),%eax
           movl  $stack,%esp

3 :
           movl  $0,%edx
           idivl %ebx
           decl  %esp
           addl  $48,%edx
           movb  %dl,(%esp)
           cmpl  $0,%eax
           jg    3b
           movl  $stack,%edx
           subl  %esp,%edx
           movl  %esp,%ecx
           movl  $1,%ebx
           movl  $4,%eax
           int   $0x80
           movl  $1,%edx
           movl  $space,%ecx
           movl  $1,%ebx
           movl  $4,%eax
           int   $0x80
           decl  %esi
           cmpl  $0,%esi
           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 :

Image non disponible

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.

Image non disponible

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 :
 
Sélectionnez
1.
2.
               push %ebp
               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 :
 
Sélectionnez
1.
2.
               movl %ebp,%esp
               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.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
date : date.o
       gcc -gstabs -o date date.o

date.o : date.s
         gcc -c -o date.o date.s
clean :
        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 à bulles.

L'utilisation de 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 qu'elle 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

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

Image non disponible

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

Une entrée dans ce tableau est notée vecteur parce qu'il redirige le processeur pour exécuter une autre portion du code.

Un programme d'amorçage minimal

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

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
           .code16
           .data
           .text
           .global _start

_start :
           .org 510,0
           .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 déjà été expliquée. La 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 du 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.

Exemple 1 : hello.s

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

 
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 raw binary. Autrement dit, il va générer un code machine ayant exactement une taille de 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é :

 
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.

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.

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

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 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-CLe code BCD), 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 :

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.

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 qu'elle 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.

Le nombre de cylindres (d'un disque dur ou d'une disquette) est égal au nombre de pistes (track) par plateau.

Par contre, la fonction 0x42 de l'interruption BIOS est 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 216 secteurs à la fois (nombre de secteurs à lire) et peut traiter un disque dur (par exemple) de taille 264 ∗ 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 :

 
Sélectionnez
               .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-GSyntaxe des instructions 80386 (IA-32)).

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, ainsi que zoom61 et f-leb pour leur relecture orthographique.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

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.