Quelques questions en assembleur (Linux)

a marqué ce sujet comme résolu.

Bonjour ! :)

Je me suis décidé à me (re)lancer très récemment dans l'assembleur, plus par curiosité qu'autre chose, afin de faire (peut être) tomber cette foutue pièce qui n'a jamais voulu se décoincer avec ce langage, ce qui a toujours été source de frustration. ^^

Bref pour le moment j'essaye de faire au plus simple et devinez quoi … je bloque déjà. :'(

Un "bon" objectif que je me suis fixé serait de réaliser une petite calculatrice. Je code sous Linux pour du x86_64 en utilisant la notation Intel.

Ce que je veux faire pour le moment: additionner 2 registres et afficher le résultat

Mon petit code:

1
2
3
4
5
6
7
8
global _start

section .text

_start
    mov rax, 2
    mov rbx, 4
    add rax, rbx

Pas de soucis pour les MOV (enfin je crois) par contre: comment faire pour afficher le résultat (logiquement contenu dans le registre RAX) ? Les nombres sont volontairement petits pour tenir dans un registre (j'ai lu le tuto qui propose l'exercice mais très honnêtement … je n'y ai pas compris grand chose ^^).

Tant que j'y suis vu que ça sera l'exercice suivant: Comment faire pour que l'utilisateur entre lui même un nombre ? (je considère toujours que le résultat pourra être contenu dans un seul registre)

Désolé si mes questions semblent "connes" mais je m'arrache les cheveux depuis tout à l'heure sur ce problème et je pense que la solution ne doit vraiment pas être compliquée (pour le reste par contre… ^^').

Merci d'avance. :)

Tu veux appeler printf() et scanf() ou, soyons fous, tu veux implémenter tes propres routines ?

Tu peux faire ça en un seul registre, genre :

1
2
mov rax, 2
add rax, 4

Pour que l'utilisateur entre son nombre, tu dois utiliser scanf() ou implémenter ta routine, ça se fera avec les appels système read et write (pour afficher à la volée).

Tu auras besoin de ça : http://blog.rchapman.org/post/36801038863/linux-system-call-table-for-x86-64

+1 -0

Euh … on va déjà commencer par printf/scanf je pense… ^^

Mais si tu as le "code" de ces routines ça ne serait pas de refus non plus bien que je pense être actuellement incapable de faire de même. Et autre question: printf/scanf etc sont des fonctions propre au C ou ce sont des fonctions plus bas niveau (et donc pas forcément propre à ce langage) auquel le C fait appel ? (parce que c'est un peu "bizarre" de faire appel à une fonction d'un langage plus haut niveau (même si je peux en comprendre la raison))

Je t'invite à aller lire mon tuto d'assembleur x86_64 sous Linux, où j'explique pourquoi « juste afficher un nombre à l'écran », c'est dur. ^^ Et je donne un moyen d'y parvenir, très imparfait puisque ne faisant pas appel à toute la puissance du langage (on n'est pas encore très loin dans le cours).

Simple conseil pour la suite : rien n'est simple en assembleur. Coder en assembleur, c'est comme partir dans la jungle avec sa bite et son couteau, mais sans couteau.

+6 -0

Justement j'ai lu ton tuto (le premier exemple de code "ça va", par contre le deuxième je suis déjà super paumé ^^) mais tu abordes le cas d'un nombre qui est trop grand pour être contenu dans un registre si j'ai bien compris.

Mais pour le cas d'un "petit" nombre, est ce qu'il est possible de l'afficher "directement" ? C'est ça que je cherche à faire en gros.

Non non ! Je fais afficher un nombre à 4 chiffres, donc ça tient parfaitement dans un registre. Mais les seuls nombres que tu pourras afficher « directement » (et encore, faut quand même bidouiller) sont ceux compris entre 0 et 9.

En fait, le problème, c'est qu'il faut convertir le nombre en chaîne de caractères avant de pouvoir l'afficher, et l'assembleur ne fait pas cela nativement, il faut le coder soi-même.

+0 -0

Ok donc on ne peut pas afficher un nombre quelqu'il soit avec une simple instruction donc en gros ce que j'essaye de faire depuis des heures est impossible quoi… xD

Je savais que c'était chaud mais là c'est un peu exagéré quand même :P Même en mattant la solution (parce que rien ne me venait à l'esprit) j'ai vraiment beaucoup de mal xD

Si ça ne te dérange pas, ça ne serait pas de refus. ^^

Un gros merci. :)

EDIT: En fait en lisant ça au calme c'est pas "si compliqué" au final. Y a juste ce genre de ligne "mov rdi, chiffres+3" où j'ai un peu de mal à voir où tu veux en venir sinon le reste est compréhensible finalement. ^^

Par contre j'ai encore un peu de mal à me dire "Tel registre sert à ça, faut déplacer ça dans tel registre" etc mais bon je viens à peine de me replonger dedans, c'est normal que ça soit flou (le C m'était aussi flou au départ, pourtant maintenant ça se passe souvent très bien :P ).

EDIT2: Sur base de ta solution je suis "parvenu" à ça (j'ai mis un ? à cote des lignes qui me paraissent encore obscures) … et j'arrive à afficher un nombre MIRACLE ! :D (bon par contre je pense que là je ne peux afficher qu'un seul chiffre de 0 à 9 ^^)

 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
%define EXIT_SUCCESS    0
%define SYS_EXIT        60
%define SYS_WRITE       1
%define STDOUT          1

global _start

section .data
tampon dq 0

section .text 

_start:
    mov rax, 5              ; place la valeur 5 dans rax
    mov rdx, rax            ; copie la valeur de rax dans rdx
    add rdx, 0x30           ; ajoute 30 à rdx pour obtenir le code ASCII
    mov [tampon], rdx       ; ?
    mov rsi, tampon         ; ?
    mov rdi, tampon         ; ?
    movsb                   ; ?

    mov rax, SYS_WRITE      ; numéro de l'appel système
    mov rdi, STDOUT         ; numéo de la sortie (1 = écran)
    mov rsi, tampon         ; ?
    mov rdx, 1              ; ?
    syscall

    mov rax, SYS_EXIT
    mov rdi, EXIT_SUCCESS
    syscall
+0 -0

Juste comme ça. Tu peux afficher (et récupérer) directement un nombre sur la sortie (et l'entrée) standard. Il faut juste tricher un peu.

Vu que tu essayes de faire de l'assembleur, je suppose que tu as une petite idée de comment fonctionne la compilation d'un programme écrit en C. Si tu regardes un peu, que tu compiles de l'assembleur ou du C, tu arrives à un moment à la partie édition de lien qui permet (en faisant vite) d'utiliser dans un fichier des fonctions définis dans un autre fichier.

Donc si la gestion des entrées/sorties te bloque (ce qui serait compréhensible puisque c'est le plus difficile dans ce que tu veux faire), tu peux très bien commencer par les écrire en C et les utiliser en assembleur. À la fin, tu pourra alors facilement les réécrire en assembleur si tu le souhaite.

En terme de code, c'est vraiment simple :

1
2
3
4
5
6
7
.intel_syntax no_prefix
.globl main

main:
mov rdi, 5
call print_int
ret
1
2
3
4
5
#include <stdio.h>

void print_int(int a) {
    printf("%d", a);
}

Il te suffit alors de compiler les deux fichiers, tu obtiens deux .o et tu les link ensemble. Chez moi, ça donne :

1
2
3
yasm -f elf64 -p gnu main.s
gcc -c print_int.c
gcc -o main print_int.o main.o

Alors, certes, tu ne fais pas tout en assembleur, mais c'est la méthode la plus simple pour (au moins temporairement) mettre de côté les difficultés et se concentrer un peu plus sur ce que tu veux faire.

En fait, Que20, qu'est-ce qui t'a poussé à vouloir découvrir l'asm ? Voir comment un programme fonctionne en réalité vis-a-vis du noyau Linux ? Ou bien l'aspect algorithmique : le "paradigme" de l'asm, savoir comment implémenter des choses qui pourtant sont si naturelles dans des langages comme le C voire le Python ?

+0 -0

Pour donner une idée de la complexité de l'assembleur, le noyau Linux a commencé à remplacer certaines parties en assembleur (x86 et ARM) par du C et obtient un code plus lisible… et plus rapide.

SpaceFox

Je pinaille, mais l'article que tu cites sur-interprète sa propre source qui, elle, dit juste qu'on a obtenu de petites accélérations et des micro-optimisations en réorganisant le code assembleur, en vue de préparer son remplacement par du C.

@Que20 : concernant le code de Berdes, il utilise la syntaxe de gas, donc son code ne marchera pas avec NASM. Et il passe par une fonction C, ce qui est la méthode la plus « simple » pour afficher du texte à l'écran en assembleur, mais que tu voudrais éviter, si j'ai bien compris. Alors restons dans le monde de l'assembleur où le C n'existe pas et lançons-nous dans les explications.

Première chose à savoir, le noyau Linux a la main sur le contrôle des périphériques : à moins d'écrire toi-même un pilote pour ta carte graphique, tu ne peux pas afficher de texte à l'écran sans passer par le système d'exploitation. Cela se fait au moyen de l'instruction syscall.

Deuxième chose à savoir, cette instruction ne devine pas magiquement ce que tu veux que le système d'exploitation fasse pour toi, il faut le lui expliquer, en mettant des valeurs spécifiques dans certains registres. Dans le cas qui nous intéresse, les valeurs sont les suivantes.

  1. Dans rax, on met 1, qui donne l'ordre « écris quelque chose dans un fichier ».
  2. Dans rdi, on met 1 qui désigne le fichier stdout, un « fichier magique » qui représente la sortie dans la console.
  3. Dans rsi, on met une adresse dans la RAM où se trouve le début du texte qu'on veut écrire.
  4. Dans rdx, on met la longueur en octets du texte que l'on veut écrire.

Voilà pour la partie simple. Maintenant, nous avons un registre qui contient une valeur numérique (dont on va considérer que c'est un entier positif sur 64 bits, donc compris entre 0 et 264 - 1), et nous devons le transformer en un espace mémoire dans la RAM contenant la représentation en texte de ce nombre.

J'insiste sur la différence entre les deux ! Si ton registre contient le nombre 124, il n'existe aucun moyen magique pour le transformer en la chaîne de caractères '124', et c'est pourtant ce qu'on cherche à faire.

Alors réfléchissons un peu. Un entier positif sur 64 bits peut contenir entre 1 et 20 chiffres. Donc pour le représenter sous forme textuelle, nous devons réserver 20 octets dans la RAM, que nous allons initialiser à 0.

En effet, quand on demandera au système d'afficher la chaîne de caractères, les caractères ayant pour valeur numérique 0 ne seront tout simplement pas affichés. De cette manière, nous pouvons afficher des nombres ayant un nombre variable de chiffres avec le même programme. Mais passons au code.

1
2
3
section .data
    chiffres: dq 0, 0
              dd 0

Pour rappel, la pseudo-instruction dq réserve 8 octets de mémoire, et dd en réserve 4, ce qui fait un total de 20, le tout initialisé à 0.

Bien, passons au cœur du problème. En dehors de tout contexte informatique, comment fais-tu pour écrire un nombre en chiffres ? Tu le décomposes sous la forme $a_n \times 10^n + a_{n-1} \times 10^{n-1} + … + a_2 \times 10^2 + a_1 \times 10^1 + a_0 \times 10^0$, avec les $a$ compris entre 0 et 9, puis tu remplaces chaque $a$ par le chiffre qui permet de le représenter.

Dans la vraie vie, tu commences par le début. Mais en assembleur, tu ne peux pas savoir facilement où se trouve ce début, donc c'est plus simple de commencer par la fin : on va traiter le chiffre des unités, puis le chiffre des dizaines, puis les centaines, etc.

Alors commençons par le cas simple, on va simplement essayer de placer le chiffre des unités de notre nombre (contenu au départ dans rax) à la dernière position de notre chaîne de caractères. Procédons par étapes.

  1. Notre nombre de départ est contenu dans rax.
  2. Pour trouver le chiffre des unités, nous devons procéder à une division euclidienne par 10 de notre nombre : le chiffre des unités sera le reste de la division. On utilise pour cela l'instruction div, qui divise le nombre sur 128 bits contenu dans rdx:rax par le nombre contenu dans le registre de 64 bits donné en opérande. Par exemple div rbx divise le contenu de rdx:rax par le contenu de rbx.
  3. Je rappelle que nous allons diviser rdx:rax par 10. Or, nous voulons diviser seulement rax. Il faut donc nous assurer que rdx vaut 0. On utilisera donc mov rdx, 0 (il existe un moyen plus optimisé pour y parvenir, mais faisons simple).
  4. On ne peut pas écrire div 10, il faut impérativement placer la valeur dans un registre : on devra donc écrire mov rbx, 10 avant de faire la division.

Résumons un peu, notre code prend donc cette forme pour l'instant.

1
2
3
4
    mov rax, 1234567890
    mov rdx, 0
    mov rbx, 10
    div rbx

À l'issue de cette opération, rdx contient le chiffre des unités (toujours sous sa forme numérique, pas le caractère qui sert à le représenter !), et rax contient le nombre de départ divisé par 10, arrondi à l'entier inférieur bien sûr (dans notre exemple, ça donne 123456789). Que faire ensuite ?

Pour commencer, il faut transformer le chiffre contenu dans rdx en le caractère qui le représente. Pour cela, il faut lui ajouter 0x30, car le code ASCII des chiffres est compris entre 0x30 pour '0' et 0x39 pour '9'. Mais on peut faire légèrement plus sioux.

C'est un point que je n'ai pas encore abordé dans mon cours, et que je n'utilise donc pas dans ma solution ! Il est possible d'accéder aux 8 derniers bits de rdx directement, au moyen du registre dl : notre chiffre des unités étant compris entre 0 et 9, il tient forcément dans les 8 bits de dl. L'intérêt de la manœuvre, c'est que le code machine généré en additionnant 0x30 à dl est nettement plus court qu'en l'additionnant à rdx. On obtient donc l'instruction add dl, 0x30.

Maintenant, ça y est, dl contient le caractère qui représente le chiffre des unités de notre nombre de départ. Il ne reste plus qu'à le placer à la dernière position de l'emplacement mémoire que nous avons réservé dans la RAM.

Celui-ci est identifié par le label chiffres, qui représente une adresse, pas la valeur qui s'y trouve. Le premier octet réservé se trouve à l'adresse chiffres, le deuxième se trouve à l'adresse chiffres+1, le troisième à l'adresse chiffres+2, etc. Le dernier se trouve donc à l'adresse chiffres+19.

Pour remplacer le contenu de l'octet à cette adresse par le contenu de dl, on va utiliser naturellement l'instruction mov. Mais avec ceci de particulier que l'adresse doit être écrite entre crochets, pour préciser que l'on veut accéder au contenu qui y est situé. Cela donne l'instruction mov [chiffres+19], dl.

Tu peux dès à présent tester le fonctionnement du programme complet, qui a la forme suivante.

 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
global _start

section .data
    chiffres: dq 0, 0
              dd 0

section .text

_start:
    mov rax, 1234567890
    mov rbx, 10
    mov rdx, 0
    div rbx

    add dl, 0x30
    mov [chiffres+19], dl

    mov rax, 1
    mov rdi, 1
    mov rsi, chiffres
    mov rdx, 20
    syscall

    mov rax, 60 ; Ces trois lignes mettent fin au programme.
    mov rdi, 0
    syscall

Comment faire à présent pour afficher aussi le chiffre des dizaines, celui des centaines, etc. ? Si tu te rappelles, à l'issue de la division, rax contient le nombre de départ divisé par 10, arrondi à l'entier inférieur. Le chiffre des unités de cette nouvelle valeur de rax n'est autre que le chiffre des dizaines du nombre de départ.

Il suffirait donc de répéter l'opération de division par dix, l'ajout de 0x30 à dl, et le déplacement de dl à l'adresse chiffres+18 cette fois ! Puis encore une fois pour les centaines, en déplaçant alors dl à chiffres+17. Etc.

Et comment s'y prend-on, alors ? Au moyen d'une instruction que je n'ai pas présentée non plus dans mon cours, appelée loop. Celle-ci commence par soustraire 1 à la valeur de rcx, puis, si le résultat est différent de 0, elle saute dans le code à l'adresse fournie en opérande (généralement un label).

En mettant au départ 19 dans rcx, on pourra ainsi réitérer le code 19 fois, et il ne restera plus que l'éventuel 20e chiffre à gérer. Voyons donc ce que ça donne.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    mov rax, 1234567890
    mov rbx, 10
    mov rcx, 19 ; Jusqu'ici, on fait de l'initialisation.

debut_boucle:   ; Un joli label pour marquer le début de la boucle.
    mov rdx, 0
    div rbx
    add dl, 0x30
    mov [chiffres+rcx], dl ; Vois comme j'utilise aussi RCX ici.
    loop debut_boucle

Sauf que là, il y a un petit problème : au lieu de 1234567890, le programme affiche 0000000001234567890. Aïe ! Manifestement, il continue à convertir le nombre en chiffres bien au-delà de ce qui est nécessaire. Alors comment faire ?

Il faut vérifier avant chaque « tour » de l'opération que rax ne vaut pas 0. Si c'est le cas, on saute directement à la fin. Pour cela, on va utiliser deux instructions que je n'ai pas non plus présentées encore dans mon cours.

  1. test rax, rax fait des tests sur rax. Je n'entre pas dans les détails de ce qui se passe, c'est trop compliqué pour l'instant.
  2. jz <label> saute dans le code à l'adresse donnée en opérande si le résultat de l'opération précédente est 0.

Notre code devient donc le suivant.

1
2
3
4
5
6
7
8
9
    test rax, rax
    jz fin_boucle
    mov rdx, 0
    div rbx
    add dl, 0x30
    mov [chiffres+rcx], dl ; Vois comme j'utilise aussi RCX ici.
    loop debut_boucle

fin_boucle:

Ce n'est pas encore parfait : le vingtième chiffre n'est toujours pas écrit. Pour rappel, à la fin du 19e tour (ou avant si on a arrêté plus tôt), rax contient la valeur du 20e chiffre, ou 0 s'il n'y a pas de vingtième chiffre. Donc si rax vaut 0, on peut passer directement à l'affichage, sinon, il faut traiter le dernier chiffre. Pas besoin de s'embêter, on déplace directement la somme de 0x30 et de al (qui est à rax ce que dl est à rdx) à l'adresse chiffres. Voici donc le bout de code supplémentaire.

1
2
3
4
5
6
7
fin_boucle:
    test rax, rax
    jz affichage
    add al, 0x30
    mov [chiffres], al

affichage:

Il ne reste plus qu'un tout petit détail à régler : si notre nombre de départ vaut 0, il ne sera jamais traité. Il faut donc dès le départ vérifier si rax vaut 0. Si ce n'est pas le cas, on passe directement au début de la boucle. Sinon, on déplace le caractère '0' à l'adresse chiffres, et on passe directement à l'affichage. On va avoir besoin de quelques instructions supplémentaires (toujours pas vues dans mon cours).

  1. jnz <label> est presque identique à jz <label>, mais le saut s'effectue si le résultat de l'opération précédente est différent de 0.
  2. jmp <label> saute dans le code à l'adresse fournie en opérande, sans condition.

Notre code complet et enfin parfaitement fonctionnel est donc le suivant.

 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
40
41
42
43
44
global _start

section .data
    chiffres: dq 0, 0
              dd 0

section .text

_start:
    mov rax, 1234567890
    mov rbx, 10
    mov rcx, 19 ; Jusqu'ici, on fait de l'initialisation.

    test rax, rax
    jnz debut_boucle
    add al, 0x30
    mov [chiffres], al
    jmp affichage

debut_boucle:   ; Un joli label pour marquer le début de la boucle.
    test rax, rax
    jz fin_boucle
    mov rdx, 0
    div rbx
    add dl, 0x30
    mov [chiffres+rcx], dl ; Vois comme j'utilise aussi RCX ici.
    loop debut_boucle

fin_boucle:
    test rax, rax
    jz affichage
    add al, 0x30
    mov [chiffres], al

affichage:
    mov rax, 1
    mov rdi, 1
    mov rsi, chiffres
    mov rdx, 20
    syscall

    mov rax, 60 ; Ces trois lignes mettent fin au programme
    mov rdi, 0
    syscall

Comme tu vois, c'est loin d'être simple. Et pour être le plus concis et générique possible, cela demande d'utiliser de nombreuses notions (les sauts, les boucles, les conditions, les registres de plus petite taille, etc.). C'est pourquoi j'ai proposé dans mon cours une version simplifiée, utilisant moins de notion, afin de pouvoir présenter un TP aussi tôt que possible. :)

Même ainsi, le code est fonctionnel, mais pas optimal. Il faudrait rajouter des %define pour éviter les nombres magiques, utiliser xor rdx, rdx plutôt que mov rdx, 0 (car le code machine généré est beaucoup plus court), et d'autres petites améliorations du même acabit.

+5 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte