-=(the3fold)=-

New Generation

L'assembleur sous GNU/Linux

La page étant un peu longue, voici ce que vous trouverez, avec des raccourcis pour faciliter la navigation :

1 - L'indispensable théorie

1.1 - Introduction

L'assembleur est un langage de programmation proche du langage machine. Il necessite donc un minimum de connaissances à propos du fonctionnement d'un ordinateur. Contrairement à certains languages plus évolués (Pascal, Java, C...) l'assembleur ne peut être interprété instruction par instruction. Par exemple, en pascal, pour écrir un message on tape "write ('Voici un message');" et l'ordinateur affiche "Voici un message" quand on lance le programme. En assembleur, la tache n'est pas aussi simple et nécessite au moins trois instructions. Avant d'utiliser l'assembleur, il faut comprendre les concepts d'interruption, de registres...

Certes l'assembleur est un language complexe, mais il présente beaucoup d'avantages :

Globalement, il y a peu de raison de choisir de programmer en assembleur de nos jours mis à part à des fins pédagogiques. Il est utile de l'apprendre et de le pratiquer pour mieux comprendre comment fonctionne un ordinateur et comment est executé un programme.

1.2 - Représentation des données

Les nombres entiers positifs

Il faut savoir que si l'homme utilise la base mathématique 10, c'est parce qu'il à dix doigts et que si l'ordinateur utilise la base 2, c'est qu'il n'a que deux états accessibles (tous ceux qui ont pensé que c'était parce que le processeur n'a que deux doigts ont perdu). Avant toute chose, revenons sur la base 10. Le nombre 4532 peut être décomposé en :

4 * 1000 + 5 * 100 + 3 * 10 + 2 * 1

ou encore :

4 * (10^3) + 5 * (10^2) + 3 * (10^1) + 2 * (10^0)

Ceci pour amener que pour toute base N :

VWXYZ = V * (N^4) + W * (N^3) + X * (N^2) + Y * (N^1) + Z * (N^0)

avec V,W,X,Y et Z qui sont tous inferieurs à N.

Cette definition n'utilise que 5 facteurs, mais si le nombre de facteurs augmente, elle ne change pas. L'exposant de N continu de croître vers la gauche. Dans la base 2 :

100 = 1 * (2^2) + 0 * (2^1) + 0 * (2^0) = 4

Cela paraît simple, diront certains, à quoi nous répondrons qu'il n'y a aucun vice caché et qu'il suffit de faire attention pour ne pas se tromper. Exercez-vous et vous verrez, c'est en buchant qu'on devient bucheron (proverbe bien connu).

Maintenant, sachez que le processeur travaille avec des registres de 8, 16, 32 chiffres (ou bits) et même 64 (avec le MMX). Cela veut dire que pour affecter une valeure à un registre, vous devrez envoyer une suite de 8, 16, 32 ou 64 bits. Maintenant, si je dis " J'ai mis la valeure 1110011000101101 à l'adresse 0110010011011000:1111011010100101", vous me répondrez que vous n'aimer pas la cuisine japonaise car vous vous êtes déja cassés les dents en mangeant les baguettes. C'est pour cela que des personnes très sensées ont décidé d'utiliser l'hexadecimal (ou base 16). En effet, chaque chiffre permet de stocker autant d'informations que quatres bits. Je vous vois déjà venir. Vous vous dites "Mais qu'est-ce qui va nous sortir... J'connais que 10 chiffres moi. Comment il veut faire une base 16 avec 10 chiffres ???" He ben les mêmes gars pas bêtes que tout à l'heure ont utilisés les lettres. On à donc:

Hexa Decimal Binaire Hexa Decimal Binaire Hexa Decimal Binaire Hexa Decimal Binaire
0 0 0 4 4 100 8 8 1000 C 12 1100
1 1 1 5 5 101 9 9 1001 D 13 1101
2 2 10 6 6 110 A 10 1010 E 14 1110
3 3 11 7 7 111 B 11 1011 F 15 1111

On a donc pour récaituler FA = 15 * (16^1) + 10 * (16^0) = 250 en décimal, soit en binaire 11111010 ce qui est quand même moins convivial...

On utilise aussi parfois l'octal qui est une base 8 et qui permet de regrouper les bits par trois, mais je n'en parlerai pas ici, et je vous laisserai méditer tout seul.

Maintenant que vous savez passer d'un nombre hexadecimal à un nombre decimal, pourquoi ne pas apprendre à le faire dans l'autre sens? Et même apprendre à passer de décimal à binaire. Pour cela c'est simple : pour toute base N, passer de base 10 à base N reviens à faire une suite de divisions euclidiennes par N, et à "lire" les restes à l'envers:

Pour passer la valeure decimale 243 en hexadécimal, il faut faire 243 / 16. Comme en primaire, il y va 15 fois et il reste 3. On regarde dans le tableau et on voit que 15 en decimal vaut F en hexadecimal. On pose le reste, puis le resultat de la division à gauche du reste et on obtient : 243 = F3. Hyper simple non? On recommence avec 1563 :

1563 / 16 = 97 reste 11 (soit B) que l'on pose à droite.

97 / 16 = 6 reste 1 que l'on pose à gauche de B. Puis on met le 6 tout à gauche et on obtient 1563 = 61B.

J'en entends déjà qui ralent : "C'est hyper-chiant. On est obligé d'utiliser l'hexadecimal ? Moi j'aime bien le vieux decimal..."

Ben non, on est pas obligé d'utiliser l'hexadecimal. On peut aussi utiliser le binaire et le decimal. En fait, on peut préciser la base utilisée en faisant suivre le nombre d'un b en binaire, d'un d en decimal et d'un h en hexadecimal (ainsi que d'un o en octal). Certains assembleurs ont d'autres manières possibles (un nombre commençant par 0 est en octal, un nombre commençant par 0x en hexadecimal ...), mais ces notations sont généralement admises par tous les assembleurs. Ainsi, 243 s'écrit :

243d = F3h = 11110011b

Les nombres entiers negatifs

Jusqu'ici, nous n'avons pas abordé les nombres signés. Nous avons en effet vu que nous pouvions coder l'information dans des octets, mots, doubles mots ... Nous avons vu comment stocker des nombres entiers positifs, dont on peut d'ailleurs se servir pour coder des caractères. Mais comment coder les nombres négatifs ?

La première chose à se demander est combien de bits sont nécessaires pour coder le signe. C'est simple, puisqu'il y a deux signes possibles, 1 seul bit est nécessaire. Nous le nommerons dorénavant bit de signe, et ce sera le premier bit considéré, c'est à dire le bit de poids le plus fort.

Notez que le problème n'est pas d'ordre mathématique, mais que c'est bien un problème de codage de l'information. Dans une étude mathématique, le signe serait simplement marqué par le symbole -, quelle que soit la base.

Opérations sur les nombres signés

Essayons d'appliquer ceci à l'addition de nombres signés. Nous allons additionner 4 et -1, et devrions donc obtenir 3. rappelons que 4 = 00000100, 3 = 00000011 et -1 = 10000001 avec la notation choisie pour les nombres négatifs. On à donc :

00000100

10000001

-----------

10000101 = -5

L'utilisation du bit de poids fort comme bit de signe, sans modifier le codage du reste du nombre n'est pas compatible avec l'addition que nous utilisions avec les nombres positifs.

Pour résoudre ce problème, le codage du signe est un peu plus complexe. On utilise pour coder un nombre négatif le complément à 1 de ce nombre (on inverse tous les bits) au quel on ajoute 1 (on appelle cette opération le complément à 2, et l'instruction NEG permet de l'effectuer). Voici quelques exemples :

Nombre Représentation binaire
(octet)
Complément à 1 Complément à 2 Opposé
0 00000000 11111111 00000000 0
1 00000001 11111110 11111111 -1
2 00000010 11111101 11111110 -2
3 00000011 11111100 11111101 -3
4 00000100 11111011 11111100 -4
8 00001000 11110111 11111000 -8
15 00001111 11110000 11110001 -15
20 00010100 11101011 11101100 -20
127 01111111 10000000 10000001 -127

Notez que sur un octet, on peut coder un nombre signé compris entre -128 et +127.

Vérifions sur quelques exemples que cette notation est compatible avec l'addition :

00000100 + 11111111 = 00000011 ( 4 - 1 = 3 )

00010100 + 10000001 = 10010101 ( 20 - 127 = -107 )

Voila donc pour l'addition et la soustraction.

Multiplications et divisions signées

Même si nous n'avons pas vraiment étudié les multiplications et divisions en arithmétique non signée, on peut remarquer que cela ne fonctionnera pas en arithmétique signée. L'assembleur propose des instructions dédiées : imul et idiv. Nous verrons cela plus tard

Ce sera donc tout concernant cette introduction au nombres signés ;)

Les nombres "réels"

Les caractères et les chaines de caractères

1.3 - Architecture d'un ordinateur

Il faut savoir que l'ordinateur ne travaille qu'avec des "paquets" de bits. Un octet (ou byte) regroupe 8 bits. Un mot (ou word) regroupe 2 octets, soit 16 bits. Un double mot (ou dword) regroupe 4 octets, soit 32 bits.

Une suite de 1024 octets est un kilo octet (ou Ko). De même, un mega octet (Mo) vaut 1024 Ko, et un giga octet (Go) vaut 1024 Mo. Les disques durs feront peut être plusieurs tera octets (To) d'ici quelques années ( 1 To = 1024 Go ).

Pourquoi 1024 au lieu de 1000? C'est simple, en informatique, tout tourne autour de la base 2. La valeure 1024 correspond à 2^10, c'est la puissance de 2 la plus proche de 1000.

La mémoire (ou plutôt les mémoires)

La memoire sert à stocker des informations en vue d'un traitement ulterieur. Certaines memoires permettent de modifier ces informations, d'autres seulement de les consulter. La RAM, aussi appelée mémoire vive, est une mémoire à laquelle on peut accéder soit en lecture, soit en ecriture. C'est dans cette mémoire que seront chargés les programmes et les données. Elle présente l'inconvenient de se vider de son contenu dès qu'on ne l'alimente plus (interruption du courant).

Avant toute chose, l'ordinateur doit savoir que faire quand on l'allume. Le programme de lancement ne peut se trouver dans la RAM puisqu'elle est vide à ce moment. Il se trouve dans la ROM de l'ordinateur. C'est une mémoire destinée seulement à la lecture : il est impossible d'écrire dedans. Cette ROM comprend le BIOS ; c'est lui qui permettra notamment de charger les premiers secteurs du disque dur (le fameux MBR qui contient le boot loader) dans la RAM et de l'executer.

Le disque dur est lui aussi une mémoire, qui peut être accédée en lecture/écriture, mais qui contrairement à la RAM n'est pas volatile (les informations sont conservées lorsque l'on éteint l'ordinateur). C'est un support fixe par opposition aux supports amovibles : disquettes, CD, DVD, bandes magnétiques ... Le disque dur peut être utilisé par le système afin d'étendre virtuellement la capacité de la RAM, mais les accès aux disques dur sont beaucoup plus lents que les accès à la mémoire vive.

Le processeur, mais aussi certains peripheriques sont munis de mémoire cache. Ce sont de petites mémoires comparables à de la mémoire vive destinées à accelerer les accès aux données. En effet, un certain nombre d'accès aux données peuvent être prévus à l'avance, et les données à charger peuvent donc être placées dans le cache avant que l'on doive y accéder. L'acceleration qui en résulte peut être considérable.

La pile

C'est un tableau de type LIFO. On peut le comparer à une pile d'assiettes où chaque assiette serait une donnée. On peut empiler les assiettes, et les desempiler. On est donc obliger de poser et de prendre les assiettes sur le haut de la pile. Pour prendre une assiette au milieu de cette pile, il faut d'abord enlever toutes celles qui sont au dessus (En fait, moi je fais pas comme ça, mais bon ce n'est qu'une image). Le registre de segment SS (voir plus loin) pointe au début de la pile et le registre SP indique le premier emplacement libre de la pile.

Ainsi, quand on rajoute un element sur la pile, le registre SP est decrémenté de 2 (pour un mot) ou 4 (double-mot) afin de pointer apres ce dernier element. De même, quand on enleve un element de la pile, SP est incrémenté de 2 ou 4. On ne peut pas placer des données plus petites que des mots sur la pile. Le registre SP sera donc toujours pair.

  Adresse Valeure  
  SS:03FE 142F derniere donnée de la pile
SP > SS:03FC ?? emplacement libre
  SS:03FA ?? emplacement libre
  SS:03F8 ?? emplacement libre
  SS:03F6 ?? emplacement libre

Dans cet exemple, SP pointe juste apres le dernier element de la pile qui vaut 142F.

Le processeur

Les 386,486 et 586 (couramment appellés Pentium) sont des puces electroniques comportants plusieurs millions de transistors. Ce sont des processeurs 32 bits, ils peuvent donc travailler avec des doubles mots. Bien que leur bus de données soit en 32 bits, l'accès aux peripheriques se fait rarement sur 32 bits. La fréquence de ces processeurs peut varier de 16 MHz jusqu'à 1GHz. Une fréquence de 1 GHz signifie que le processeur tourne à une vitesse de 1 Milliard de cycles par seconde ! Mais les peripheriques doivent suivre le processeur pour que celui-ci soit utilisé pleinement : si le bus est trop lent ou si la carte graphique rame, l'ordinateur sera ralentit.

Les registres

Le processeur contient des registres dont les fonctions sont bien precises :
- traiter les données en provenance de la memoire
- contenir les adresses de début de programme, de début de données
- indiquer le résultat d'operations arithmétiques
- ...

Les registres generaux

Ils servent à manipuler des données, à transferer des données lors de l'appel d'interruptions, à stocker des résultats intermediaires ...

Ces registres sont :

EAX, EBX, ECX, EDX, ESI, EDI, ESP, EBP. Ce sont des registres de 32 bits. Leurs 16 bits de poids faible forment respectivement les registres AX, BX, CX, DX, SI, DI, SP, BP. Les registres 16 bits se terminant en X se divisent encore en deux registres de 8 bits chacun :

- AH, BH, CH et DH sont les registres composés des 8 bits de poids fort de AX, BX, CX et DX (H pour high)

- AL, BL, CL et DL sont les registres composés des 8 bits de poids faible de AX, BX, CX et DX. (L pour low)

Avant les 386, les registres 32 bits n'existaient pas. Ils ont étés rajoutés quand les processeurs sont passés à 32 bits. On les a appellés E-- pour Extended (etendu)

Par exemple, si EAX = A8C9BF31 on à : AX = BF31, AL = 31 et AH = BF. Pour modifier les 16 bits de poids fort de EAX, il faut manipuler EAX en entier. Modifier AL ou AH modifie aussi AX et EAX. Mais AL et AH peuvent être modifiés indépendaments.

Les registres de segment

Ils sont utilisés pour stocker l'adresse de début d'un segment. Il peut s'agir de l'adresse du début des instructions du programme, du début des données ou du début de la pile. Ce sont :

CS : Code Segment / Segment de code : Debut des instructions du programme ou d'une sous routine
DS : Data Segment / Segment de données : Addresse de début de données du programme
ES : Extra segment / Segment extra : Utilisé par certaines instructions de copie de bloc.
SS : Stack segment / Segment de pile : Pointe sur le debut de la pile
FS : Segment supplémentaire (386 et +) :
GS : Segment supplémentaire (386 et +) :

Le registre IP

IP signifie Instruction Pointer. C'est ce registre de 16 bits qui contient l'offset (déplacement à effectuer) par rapport au début de segment CS pour se positionner sur la prochaine instruction à effectuer. Il est donc modifié automatiquement lors de l'appel de procedure ou pendant le déroulement du programme. Le programmeur n'a pas à se soucier de ce registre, il est entierement géré par le processeur. Depuis les 386, ce registre est le registre EIP et fait 32 bits.

Le registre de Flag

C'est un registre qui comporte plusieurs informations contenues sur un bit (aussi appellé indicateur) :

CF : Carry Flag / Indicateur de retenue : 1 si une retenue se produit dans une instruction arithmétique
PF : Parity Flag / Indicateur de parité : 1 si un nombre pair de bits est à 1 après une operation arithmétique
ZF : Zero Flag / Indicateur de resultat nul : 1 si le resultat de la derniere operation arithmetique est 0
SF : Sign Flag / Indicateur de signe : En arithmetique signée, indique si le resultat d'une operation est negatif
OF : Overflow Flag / Indicateur de debordement : 1 si le resultat en arithmetique signée a donné un signe different de celui attendu.

2 - Les principales instructions

L'assembleur est un langage qui est en bijection avec le langage machine. C'est à dire qu'il est possible de passer de l'un à l'autre simplement en connaissant la table de conversion et l'endroit où débute le programme. Le langage machine est la suite d'octet qui compose un programme. On peut le considérer comme une suite de 1 et de 0 (des bits), mais en fait ces bits sont toujours regroupés, au moins par huit (un octet). Une commande peut contenir un seul ou plusieurs octets, celà dépend de la commande et peut être déterminé en regardant le premier octet. Des tables décrivant ces opcodes peuvent être trouvées sur internet, par exemple sur http://ref.x86asm.net/.

L'assembleur est une façon plus lisible, plus facile à retenir et semblable pour différents types de processeurs (pour lesquels les opcodes peuvent être totalement différents) d'écrire les opcodes. Le langage offre aussi des facilités pour programmer de manière un peu plus efficace (macros, labels, fonctions, variables ...). Mais l'assembleur et le langage machine restent très proches, ce qui oblige le programmeur à savoir pas mal de choses sur le fonctionnement de l'ordinateur et ce qui rend les programmes non-portables mais qui permet potentiellement d'obtenir les meilleurs performances. Je dis bien potentiellement car d'une part, les compilateurs moderne sont très bon pour générer du code optimisé, donnant un résultat bien meilleur que du code écrit par un humain, d'autre part, la complexité du langage fait que des optimisations de plus haut niveau (au niveau de l'algorithme lui même par exemple) peuvent être rendues plus difficiles.

2.1 - Copier des données !

mov movl ...

2.2 - Effectuer des opérations

inc dec add sub mul div

2.3 - Sauter

cmp cmpb jmp ja jae jb je jne jnz jz

2.4 - D'autres instructions utiles

int pop push xor

3 - Les premiers pas

Avant de commencer, quelques rappels sur les commandes qui pourront être utiles :

3.1 - Bonjour !

Nous allons commencer par un programme simple qui affiche un message. Cela va nous permettre de voir plusieurs points. D'une part comment assembler nos sources en fichiers objets, puis comment lier ces fichiers objets, mais aussi comment faire un appel système sous Linux. Voyons tout de suite la source du fichier Bonjour.S.

.section .text
.global _start
_start:
  mov $4, %eax
  mov $1, %ebx
  mov $texte, %ecx
  mov length, %edx
  int $0x80

  xor %ebx, %ebx
  mov $1, %eax
  int $0x80

.section .data
texte:
  .string "Bonjour !\n"
length:
  .int length - texte

La première partie du programme consiste en l'appel de l'interruption 0x80 (c'est l'interruption permettant d'utiliser les appels systèmes proposés par Linux). La fonction désirée est précisée dans eax. En l'occurence, nous appelons la fonction 4, qui correspond à l'écriture. Les paramètres de l'appel sont passés dans les autres registre. On précise dans ebx le descripteur de fichier correspondant à la sortie standard (0 correspond à l'entrée standard, 1 correspond à la sortie standard, 2 à la sortie d'erreur), on place dans ecx un pointeur sur le texte à afficher, et on indique la longueur du texte dans edx. La deuxième partie du programme consiste en l'appel de la fonction 1 qui correspond à l'appel système de sortie de programme.

Vous pouvez trouver la liste des appels systèmes sous Linux dans le manuel syscalls: man 2 syscalls. On peut aussi trouver sur internet des sites intéressants pour en parcourir la liste, comme par exemple kernelgrok qui offre la possibilité de rechercher un appel système et offre des liens vers le code source correspondant.

Concernant la syntaxe, on remarquera que les noms de registres sont précédés du symbole %, que les valeurs immédiates sont précédées d'un $ (dans le cas d'une étiquette, la valeur correspondant est l'adresse pointée) et que les étiquettes utilisées telles quelles permettent d'accéder à la valeur pointée.

Il reste maintenant à compiler ce fichier. Il est possible de le faire avec gcc en utilisant les options adequates, mais gcc attend généralement une fonction main pour pouvoir créer un executable, et lie par défaut à des bibliothèques standards. C'est pourquoi je préfère utiliser as pour générer les fichiers objets, puis ld pour la phase de liaison lorsque le programme est entièrement en assembleur. Ainsi, as Bonjour.S -o Bonjour.o, puis ld Bonjour.o -o Bonjour devrait permettre de créer l'executable Bonjour.

On peut aussi utiliser un Makefile pour simplifier la phase de compilation pour des programmes de taille plus importantes. Un Makefile est disponible dans l'archive disponible en téléchargement

3.2 - Horloge

Voici un programme un peu plus complexe qui affiche le timestamp UNIX courant, c'est à dire le nombre de secondes écoulées depuis le 1er janvier 1970 à minuit. Vous pouvez vérifier le résultat en comparant à celui de la commande UNIX date +%s.

.section .text
.global _start
_start:
  /* Récupère le timestamp UNIX et les microsecondes avec l'appel système gettimeofday */
  mov $0x4e, %eax
  mov $timestamp, %ebx
  xor %ecx, %ecx
  int $0x80

  /* Convertit le résultat en chaine de caractère et l'affiche
        - eax contient le timestamp à afficher
        - esi est l'index du caractère courant
   */
  mov timestamp, %eax
  xor %esi, %esi
  /* Prévois un retour chariot à la fin du nombre et incrémente la taille en conséquence */
  push $10
  inc %esi
convertit:
  xor %edx, %edx
  mov $10, %ecx
  div %ecx
  add $48, %edx
  push %edx
  inc %esi
  cmp $0, %eax
  jnz convertit

affiche:
  cmp $0, %esi
  jz fin
  dec %esi

  /* Affiche un caractère */
  mov $4, %eax
  mov $1, %ebx
  mov %esp, %ecx
  mov $1, %edx
  int $0x80
  add $4, %esp
  jmp affiche

fin:
  /* Quitte */
  xor %ebx, %ebx
  mov $1, %eax
  int $0x80

.section .data
timestamp:
  .int 0
microsecondes:
  .int 0

Le timestamp est récupéré en utilisant l'appel système gettimeofday qui correspond à la fonction 0x4E de l'interruption 0x80. Cette fonction renvoi le nombre de seconde écoulé depuis le 1er janvier 1970 mais aussi les microsecondes. En C, on utiliserait une structure timeval qui est composée de deux champs, un de type timeval et un autre de type suseconds_t. Ces deux types sont des entiers sur 32 bits. On obtient le même résultat en déclarant deux variables de type int à la suite. La variable microsecondes n'est pas utilisée dans le code ci-dessus mais pourrait l'être.

Le gros du code, entre l'appel à la fonction gettimeofday et l'appel à la fonction quit sert à convertir l'entier obtenu dans timestamp en caractères pour l'afficher. Vous pouvez, en exercice, faire de même avec les microsecondes afin d'afficher les deux informations.

Le principe est le suivant: le nombre est divisé par 10 jusqu'à atteindre 0. A chaque fois, le reste est poussé sur la pile après avoir ajouté 48 pour obtenir le caractère correspondant et un compteur est incrémenté dans esi. On dépile ensuite tous les caractères pour les afficher un par un. Le passage par la pile permet d'avoir les nombres dans le bon ordre puisque la division par 10 donne les chiffres composant le nombre de droite à gauche.

Vous pouvez retrouver cet exemple dans l'archive disponible en téléchargement

3.3 - Addition de deux nombres

Voici un programme encore plus complexe qui demande deux nombres et renvoi leur somme en résultat. Vous pouvez remarquer qu'il est un peu long, principalement à cause de la duplication de code qui pourrait être évitée par l'utilisation de fonctions. Nous verrons plus tard comment faire cela. Voyons tout de suite la source du fichier Addition.S.

.section .text
.global _start
_start:
  /* Demande un 1er nombre */
  mov $4, %eax
  mov $1, %ebx
  mov $texte1, %ecx
  mov length1, %edx
  int $0x80

  /* Lit le 1er nombre depuis l'entrée standard (descripteur de fichier 0) */
  mov $3, %eax
  mov $0, %ebx
  mov $nombre1_texte, %ecx
  mov $24, %edx
  int $0x80

  /* Demande un 2nd nombre */
  mov $4, %eax
  mov $1, %ebx
  mov $texte2, %ecx
  mov length2, %edx
  int $0x80

  /* Lit le 2nd nombre depuis l'entrée standard (descripteur de fichier 0) */
  mov $3, %eax
  mov $0, %ebx
  mov $nombre2_texte, %ecx
  mov $24, %edx
  int $0x80

  /* Convertit le 1er nombre
        - eax contient le résultat
        - ebx pointe sur la chaine de caractère
        - esi est l'index du caractère courant
   */

  xor %eax, %eax
  mov $nombre1_texte, %ebx
  xor %esi, %esi
convertit1:
  mov $10, %ecx
  mul %ecx
  mov (%ebx, %esi, 1), %dl
  /* On arrête dès qu'un caractère rencontré n'est pas un chiffre */
  cmp $48, %edx
  jb finished1
  cmp $57, %edx
  ja finished1
  sub $48, %edx
  add %edx, %eax
  inc %esi
  jmp convertit1
finished1:
  xor %edx, %edx
  mov $10, %ecx
  div %ecx
  mov %eax, nombre1

  /* Convertit le 2nd nombre
        - eax contient le résultat
        - ebx pointe sur la chaine de caractère
        - esi est l'index du caractère courant
   */

  xor %eax, %eax
  mov $nombre2_texte, %ebx
  xor %esi, %esi
convertit2:
  mov $10, %ecx
  mul %ecx
  mov (%ebx, %esi, 1), %dl
  /* On arrête dès qu'un caractère rencontré n'est pas un chiffre */
  cmp $48, %edx
  jb finished2
  cmp $57, %edx
  ja finished2
  sub $48, %edx
  add %edx, %eax
  inc %esi
  jmp convertit2
finished2:
  xor %edx, %edx
  mov $10, %ecx
  div %ecx
  mov %eax, nombre2

  /* Additionne les deux nombres */
  add nombre1, %eax
  mov %eax, resultat

  /* Convertit le résultat en chaine de caractère et l'affiche
        - eax contient le résultat
        - esi est l'index du caractère courant
   */

  xor %esi, %esi
  /* Prévois un retour chariot à la fin du nombre et incrémente la taille en conséquence */
  push $10
  inc %esi
convertit3:
  xor %edx, %edx
  mov $10, %ecx
  div %ecx
  add $48, %edx
  push %edx
  inc %esi
  cmp $0, %eax
  jnz convertit3

convertit4:
  cmp $0, %esi
  jz fin
  dec %esi

  /* Affiche un caractère */
  mov $4, %eax
  mov $1, %ebx
  mov %esp, %ecx
  mov $1, %edx
  int $0x80
  add $4, %esp
  jmp convertit4

fin:
  /* Quitte */
  xor %ebx, %ebx
  mov $1, %eax
  int $0x80

.section .data
texte1:
  .string "Entrez le 1er nombre: "
length1:
  .int length1 - texte1
texte2:
  .string "Entrez le 2nd nombre: "
length2:
  .int length2 - texte2
texte3:
  .string "J'ai lu "
length3:
  .int length3 - texte3
nombre1:
  .int 0
nombre2:
  .int 0
resultat:
  .int 0

.section .bss
/* On permet jusqu'à 24 caractères pour chaque nombre */
  .lcomm nombre1_texte, 24
  .lcomm nombre2_texte, 24

L'affichage des messages et la sortie du programme se font de la même manière que dans le premier exemple. La lecture des entrées de l'utilisateur se font en utilisant l'appel système 3 avec comme descripteur de fichier le numéro 0 qui est l'entrée standard.

Comme l'utilisateur rentre une chaine de caractères, il faut la convertir en entier, ce qui est fait en bouclant sur chaque caractère, en le convertissant en sa valeur décimale (en soustrayant 48, le code ASCII de 0, les autres chiffres le suivant). Le premier caractère n'étant pas un chiffre est considéré comme la fin du nombre. On peut ensuite les additioner. Une fois l'addition effectuée, l'opération inverse est nécessaire pour pouvoir afficher le résultat. C'est le même procédé que dans l'exemple précédent.

Vous pouvez retrouver cet exemple dans l'archive disponible en téléchargement

3.4 - Exercices

Afin de pratiquer un peu, voici quelques exercices:

4 - Créer une librairie

Pour nos exemples simples, nous avons pu mettre la totalité de nos programmes dans un seul fichier. Mais si l'on veut pouvoir écrire des programmes plus complexes, il faut pouvoir les découper en sous parties indépendantes et réutilisables. Pour celà, il nous faut pouvoir écrire des fonctions dans un fichier et les appeler depuis un autre fichier. Voyons comment faire ...

4.1 - Exemple simpliste

Pour ce premier exemple, nous allons revenir sur notre exemple affichant "Bonjour" et le découper en deux parties: un fichier contenant une fonction affiche_bonjour affichant "Bonjour" et un fichier contenant le symbole _start et appelant la fonction affiche_bonjour.

Voici le premier fichier affiche_bonjour.S qui contient la fonction affiche_bonjour et les données dont elle a besoin:

.section .text
.global affiche_bonjour
_start:
  mov $4, %eax
  mov $1, %ebx
  mov $texte, %ecx
  mov length, %edx
  int $0x80
  ret

.section .data
texte:
  .string "Bonjour !\n"
length:
  .int length - texte

Voici le second fichier SimpleLib.S qui appelle affiche_bonjour puis quitte:

.section .text
.global _start
_start:
  call affiche_bonjour

  xor %ebx, %ebx
  mov $1, %eax
  int $0x80

Pour générer l'executable, il ne reste plus qu'à assembler les deux fichier séparément (sic !) puis à les lier ensemble à l'aide de ld afin de produire l'executable SimpleLib:

$ as affiche_bonjour.S -o affiche_bonjour.o
$ as SimpleLib.S -o SimpleLib.o
$ ld SimpleLib.o affiche_bonjour.o -o SimpleLib

La commande nm permet de voir les symboles contenus dans chaque fichier:

$ nm affiche_bonjour.o
00000000 T affiche_bonjour
0000000b d length
00000000 d texte

$ nm SimpleLib.o
U affiche_bonjour
00000000 T _start

$ nm SimpleLib
08048082 T affiche_bonjour
080490a9 D __bss_start
080490a9 D _edata
080490ac D _end
080490a5 d length
08048074 T _start
0804909a d texte

On voit que le fichier affiche_bonjour.o contient le symbole affiche_bonjour dans la section text (d'où le T), c'est à dire dans le code et les symboles length et texte dans les données initialisées (d'où le d). Les minuscules indiquent des symboles locaux alors que les majuscules indiquent des symboles globaux (utilisables depuis un autre fichier). Si le mot clef .global n'était pas utilisé pour exporter le symbole affiche_bonjour, la commande ld échouerait avec l'erreur "référence indéfinie".

Dans le fichier SimpleLib.o, affiche_bonjour apparait avec un U, qui signifie undefined. Celà signifie qu'un symbole est utilisé dans ce fichier mais n'est pas présent. Il devra donc être trouvé par ld au moment de l'édition de liens pour produire un executable. Il contient aussi le symbole _start qui est le point d'entrée de notre programme.

Enfin, l'executable final contient les symboles _start, affiche_bonjour, texte et length, ainsi que 3 symboles ajoutés par ld:

Vous pouvez retrouver cet exemple dans l'archive disponible en téléchargement