Le b.a.-ba de l'assembleur x86 — Partie I
|
02-11-2012, 17h29
(Modification du message : 08-12-2012, 17h31 par ark.)
Message : #1
|
|
spin
Contributeur Messages : 325 Sujets : 15 Points: 38 Inscription : Nov 2011 |
Le b.a.-ba de l'assembleur x86 — Partie I
README: ce tutoriel n'est pas complet, c'est la première partie seulement. J'ai dû couper le tutoriel en deux parties à causes de quelques contraintes techniques, le serveur n'acceptait pas de prendre tout d'un coup. Du coup, même si la partie II est déjà prête, j'en profite pour la retoucher un peu avant de la poster. Je pense que je peux peaufiner un peu
Le b.a.-ba de l'assembleur x86 — Partie I Ce tutoriel est publié sous GNU FDL (pour faire plaisir à galex-713). Introduction Comme certains ici le savent, j'avais entrepris il y a quelques mois d'écrire un tutoriel d'introduction à l'assembleur. Aujourd'hui il n'est pas vraiment finalisé (contraintes de temps), mais nous avons jugé qu'il constitue néanmoins une ressource assez complète pour un mec qui n'a jamais vu d'assembleur. Il n'y a pas vraiment de prérequis, mais le tutoriel est quand même assez rapide, je m'explique ; on se focalisera surtout sur les notions théoriques et le « paradigme » de l'assembleur, ces choses qui en général perturbent les nouveaux venus. Donc on ne consacrera pas beaucoup de temps à expliquer toute les instructions arithmétiques par exemple, ce n'est pas ce qui pose problème dans la compréhension de la programmation en assembleur. La première partie n'est pas fondamentale, elle est juste là pour la culture Processeurs
Langage machine et assembleur Le processeur est capable d’exécuter diverses instructions simples. Ces instructions ne sont ni plus ni moins que de simples valeurs numériques que le processeur comprend nativement. Ces valeurs numériques décrivant les opérations à effectuer constituent le langage machine ou le code natif. Vous en conviendrez, il n’est absolument pas commode pour un humain d’écrire des programmes directement en langage machine, se résumant à une suite de nombres. Le langage assembleur se propose ainsi pour pour résoudre ce problème, nous pouvons voir l’assembleur comme étant la forme humanisée du langage machine : au lieu d’écrire les instructions par leur valeur numérique nous écrivons des instructions par des mnémotechniques qui correspondent au véritable langage machine. Pour illustration, prenons l’exemple d’une instruction qui doit empiler la valeur 42 sur la pile – une zone mémoire dont nous parlerons prochainement. Voici l’instruction, sous sa forme numérique et en base hexadécimale : 6a2a. Voici l’instruction assembleur qui lui correspond : push 42. Le programme assembleur (à ne pas confondre avec la langage assembleur) est un traducteur chargé de générer le langage machine correspondant à la suite d’instructions assembleur donnée. Un tel programme traduit donc notre push 42 en 6a2a. Instructions Nous l’avons vu, une instruction est une opération élémentaire qu’un processeur doit effectuer, mais toutes les instructions ne sont pas forcément compréhensibles par tous les processeurs. Chaque famille de processeurs peut exécuter un certain ensemble défini d’instructions, on appelle cet ensemble un jeu d’instructions. Un processeur prend en charge son jeu d’instructions, éventuellement plusieurs. Ce jeu d’instructions est généralement partagé par d’autre processeurs de la même famille, comme c’est le cas pour les processeurs de la famille x86 qui compte plusieurs modèles de processeurs allant du Intel 8086 de 1978 jusqu’à des processeur plus récents comme les derniers Core i7 de cette année, par exemple. L’intérêt est d’assurer une rétro-compatibilité, un programme écrit pour un 8086 doit fonctionner pour un processeur de la même famille plus récent, même si ce processeur récent est doté de nouveaux jeux d’instructions (plutôt appelées extensions, du genre MMX, SSE, AVX). On distingue plusieurs types de jeux d’instructions, dont notamment les RISC et les CISC ayant chacun leur philosophie. RISC Les processeurs ayant un jeu de type RISC (Reduced Instruction Set Computer) ont la particularité suivante : les instructions sont très simples (élémentaires) et aussi très courtes (en taille mémoire). Il faut que les instructions soient toutes de la même taille (ou presque), c’est la clé du paradigme. L’enjeu est de pouvoir exécuter les instructions très rapidement, en utilisant le principe du pipeline : c’est du taylorisme dans le processeur. Il faut savoir que l’exécution d’une instruction se fait en plusieurs étapes par le processeur ; sans pipeline on attend de finir totalement l’exécution d’une instruction (c’est-à-dire effectuer toutes les étapes aboutissant à l’exécution de l’instruction) avant d’en commencer une autre. On doit alors finir effectuer la dernière étape de l’exécution de l’instruction (plutôt appelée étage, dans le jargon), pour débuter une nouvelle instruction. De cela résulte le fait qu’il y aura toujours des étages inactifs. Le principe du pipeline est d’avoir toujours la totalité des étages en activité, de telle sorte qu’on puisse débuter une nouvelle instruction à chaque étape effectuée (et non à chaque instruction). Donc, pendant qu’on effectué l’étape Z d’une instruction i1 , on peut en même temps effectuer l’étape A d’une autre instruction i2 . Voici une petite analogie : pensez à un tuyau (le nom pipeline est bien explicite pour ça) dans lequel on introduit les instructions. Même si une instruction n’est pas encore complètement sortie du tuyau, on peut quand même commencer à en introduire une autre, ce qui est impossible sans pipeline (on serait obligé d’attendre que la première instruction soit totalement sortie du tuyau). Souvent (mais pas toujours) une instruction de type RISC, grâce à sa sémantique légère, peut être exécutée en une seule période d’horloge (un tic), soit x^(−1) seconde si l’horloge du processeur en question est cadencée à x Hz (x tics par seconde). Ne croyez pas que le super 3 GHz exécutera systématiquement 3 milliards d’instructions à la seconde, même si c’est presque le cas. Les processeurs RISC les plus connus sont certainement les processeurs de la famille POWER de chez IBM, ou encore les ARM, même si ces derniers ne sont pas pur RISC. Vous comprendrez alors pourquoi il est préférable d’avoir des instructions de même taille. Cela évitera de trop déséquilibrer le pipeline. CISC S’opposant au RISC, nous avons le CISC (Complex Instruction Set Computer). Le paradigme est tout autre, il s’agit ici d’avoir des instructions complexes, dans le sens où leur sémantique est assez lourde, l’instruction n’est plus si élémentaire que pour les RISC. Cela pourrait éventuellement rendre l’écriture d’un programme en assembleur plus simple (ou du moins, moins fastidieuse) pour le programmeur, mais pas pour un compilateur, dans les faits. Un code assembleur pour un processeur de type CISC sera dense, car chaque instruction aura une sémantique lourde. Les processeurs x86 sont de type CISC, et ce document parle à propose de ceux-là. Cependant, les processeurs CISC ont aussi un moteur de pipeline, ce n’est pas juste propre aux processeurs RISC, bien que ces dernier basent leur performance sur ce mécanisme principalement. La quatrième génération de Pentium Prescott (de la famille x86, de type CISC donc) possédait d’ailleurs une profondeur de pipeline de 31 étages. Le mythe qui dit « x Hz ⇐⇒ x instructions par seconde » est encore moins vrai sur un processeur CISC, bien-entendu. Autopsie d'une instruction x86 Entrons dans le vif du sujet. Une instruction x86 se compose le plus souvent de deux parties. L’opcode (ou le code opération) et le ou les opérandes. l’opcode décrit l’action à exécuter, les opérandes sont des valeurs avec quoi l’action doit être exécutée. Bien-sûr, il ne peut y avoir qu’un seul opcode par instruction ; le nombre d’opérande peut en revanche varier de 0 à 2 en règles générales, selon l’instruction. Il est donc possible d’avoir des instructions étant uniquement composé d’un opcode. C’est le cas de nop et de ret par exemple. L’opcode correspondant à un push d’une valeur immédiate d’un octet est 0x6a [cf. Manuel Intel], par exemple. Reprenons notre fameux push 42 qui à l’avantage de rester une instruction simple, et regardons sa structure : push 42 En jaune nous avons l’opcode et en rose le seul opérande nécessaire qui peut être n’importe quoi du moment que ça tient sur le nombre de bits autorisé (on explicitera bien assez tôt cette notion). Ici c’est 8 bits, soit un octet. Expérimentations Il est temps de passer un peu à la pratique. Nous allons nous munir d’un assembleur (j’utilise fasm, mais libre à vous d’en utiliser un autre, du moment que vous faites un binaire pur avec) et d’un éditeur hexadécimal, je me contenterai du hexdump sans prétention, ça suffira bien. On s’amusera à regarder le langage machine d’un petit programme écrit en assembleur. Nul besoin de connaître l’assembleur, on se focalise sur le langage machine pour l’instant. Assemblons notre fameux push 42, ainsi qu’un petit push "spin", nous allons peut-être être surpris. Remarques en passant : on utilise l'extension .s, .S ou .asm le plus généralement pour des fichiers de code assembleur. Dans la majorité des assembleurs, le commentaire est précédé du point-vigule. Code ASM :
; fichier : push.s use32 n’est pas une instruction, c’est juste une directive pour fasm, elle sert à lui indiquer qu’on travaille en 32 bits, car "spin" tient en 4 octets, soit 32 bits. Pour les détenteurs de processeurs 64 bits, n’essayez pas d’empiler une valeur immédiate de 8 octets comme "spinspin", ça n’est pas possible et vous verrez pourquoi. On assemble notre programme : Code : $ fasm push.s ou pour NASM : Code : $ nasm -f bin push.s Là, on a bel et bien un programme, mais il ne se lancera jamais. Et de toute façon on a pas à le lancer, c’est pas ce qui nous intéresse. Bon, regardons les entrailles du programme : Code : $ hexdump push.bin On ne regardera pas la première colonne, elle ne nous intéresse pas (elle indique juste les adresses). Le programme lui-même est le suivant (toujours en hexadécimal bien-sûr) : 2a6a 7368 6970 006e. À présent essayons de comprendre notre programme en langage machine, voici les informations dont nous aurons besoin :
Il existe en réalité plusieurs instructions push : une pour chaque famille d’opérandes : valeur immédiate de 8, 16 ou 32 bits (noté imm8, imm16, imm32 dans la littérature Intel), registres de 8, 16, 32 ou 64 bits (noté r8, r16, r32, r64) et il existe même un push spécialement pour un registre en particulier qui est DS. Pas de panique, on expliquera les registres bien assez tôt Souvenez-vous de ces notations, vous en aurez besoin quand vous irez consulter le manuel (et ce sera dans pas longtemps). Le set des instructions push n’est pas un cas isolé, il en est ainsi pour la grande majorité des autres set comme les instructions add par exemple, chaque instruction add est adaptée à une combinaison précise d’opérandes. Ainsi, quand nous introduirons de nouvelles instructions, nous indiquerons chaque combinaison d’opérandes possible. Par abus de langage nous disons « l’instruction add », mais il conviendrait de dire « les instructions add ». Le programme assembleur génère automatiquement la bonne instruction à partir de la combinaison d’opérandes donnée, le programmeur ne s’en soucie pas en règles générales, sauf quelques exceptions rares. Là est un grand intérêt du programme assembleur, entre autres. Vous l’avez sans doute remarqué, c’est totalement le désordre ! Voici donc l’explication à tout cela qui devra servir de morale : les processeurs x86 lisent tout en LSB (Less Significant Byte), c’est à dire de l’octet le plus faible à l’octet le plus fort. On parle aussi de l’endianess d’un processeur. En l’occurrence, notre x86 est un processeur dit little-endian – en français, petit boutiste. Pour avoir la suite d’instruction en MSB (Most Significant Byte), soit une façon plus humaine de la lire, nous avons alors simplement à permuter les octets deux à deux comme l’illustre la figure suivante. Je rappelle qu’un chiffre en hexadécimal correspond à un quartet – 4 bits –, donc deux chiffres correspondent à un octet. Il existe des processeurs qui lisent les octets à la mode MSB (les ARM par exemple), ce sont des processeurs dit big-endian – en français, gros boutiste. On a donc tout bien dans l’ordre, et on voit bien ce qu’il se passe. Tous les opcodes ne sont pas sur 1 octet, les jeux CISC ont tellement d’instructions qu’il devient nécessaire de coder les opcode des dernières instructions sur plus d’un octet, comme c’est le cas pour cpuid qui a pour opcode 0x0fa2 de 2 octets. Cela illustre la différence fondamentale entre les jeux RISC et les jeux CISC ayant beaucoup plus d’instructions de tailles très variables... Registres Je suppose que cette notion ne vous est pas parfaitement familière, mais dans le cas contraire sachez qu’un registre est un tout petit emplacement mémoire directement gravé sur le circuit du processeur, l’accès à un registre se fait donc de façon extrêmement rapide. Les processeurs x86 disposent de beaucoup de registres – surtout les derniers – que nous n’allons pas énumérer, mais il faut dans un premier temps avoir un aperçu général de leur utilité. La taille des registres peut varier de 16 bits à 256 bits pour les derniers modèles. En réalité, les registres d’usage général ne varient que de 16 bits à 64 bits. Nous comptons quatre registres à usage général : RAX, RBX, RCX et RDX qui ont une capacité de 64 bits chacun. Sur les modèles moins récents, nous comptons aussi ces quatre mêmes registres, mais dans leur version 32 bits : EAX, EBX, ECX et EDX. Avec un processeur 64 bits, il est toujours possible d’accéder aux registres dans leur version 32 bits, ce derniers sont les parties basses – désignant les 32 bits de poids faible – des registres 64 bits. De même, il est possible d’accéder aux registre 16 bits qui sont : AX, BX, CX et DX. Là encore c’est le même principe, il s’agit juste des parties basses des registres EAX, EBX, ECX et EDX. À partir des registres 16 bits, il est à nouveau possible d’accéder aux registre de 8 bits, mais cette fois il est possible de spécifier la partie haute ou la partie basse : AH, AL, BH, BL, CH, CL, DH et DL. Les signifient High pour les parties hautes, et L signifie Low pour les parties basses. Parfois c'est pratique d'utiliser même les registres 8 bits, dès lors que nous n'avons pas toujours besoin de gaspiller 64 ou 32 bits. Pas la peine de tout retenir, on comprendra mieux lors des exemples concrets, là encore, pas de panique. Ces registres-là sont dit à usage général, il permettent de faire un peu ce qu’on veut mais le plus habituel est de stocker les résultats intermédiaires dedans, lors de calcul sur des valeurs contenues dans ceux-là. Nous manipulerons ces registres bientôt lors de l’implémentation de quelques algorithmes, dans un premier temps. Structure élémentaire sur processeur Il est important d’avoir une idée – même un vision vague – de la structure d’un processeur. Dans un processeur simple, nous pouvons compter trois éléments principaux : un bloc central qui sera le chef d’orchestre du processeur, un bloc de plusieurs registres dont nous avons parlé et enfin une unité de calcul, qu’on appelle l’ALU (Arithmetic and Logic Unit). Le bloc central peut communiquer avec les registres, avec l’ALU et avec la mémoire externe au processeur – la mémoire RAM (Random Access Memory) que vous connaissez tous je suppose. Nous savons bien qu’un programme est une suite d’instructions exécutables, mais cette suite doit bien est stockée quelque-part : c'est dans la RAM (après avoir été chargé par le système d'exploitation, en général). Ainsi, le bloc central ira récupérer une instruction dans la RAM, l’interpréter et enfin l’exécuter, puis elle recommence ; on appelle ça le fetch-decode-execute cycle. Pour savoir où chercher dans la RAM, le bloc central (qui a aussi accès aux registres) a recours à un registre spécial : (E|R)IP ( Instruction Pointer), le pointeur d'instruction. Ce registre contient toujours l’adresse de la prochaine instruction à aller récupérer (fetch), il est donc incrémenté au fil de l'exécutions des instructions. Dans notre processeur schématique, c’est ce bloc contenant aussi l’étape execute qui fera exécuter les instructions. Ces informations ne sont pas à connaître absolument pour commencer à programmer en assembleur, mais elle apportent néanmoins une idée assez correcte de ce qu'est un programme et de comment il est exécuté. Pour les électroniciens : l’ALU est l’unité de calcul arithmétique et logique, comme son nom l’indique. Il s’agit d’un circuit logique combinatoire (complexe) où les opérations sont implémentées directement en portes logiques. L’ALU prend symboliquement deux entrées, effectue l’opération désirée dessus et renvoie en sortie le résultat. Cette unité prend en charge maintes opérations dont l’addition, la soustraction, la multiplication, la division, le ET logique, le OU logique, le XOR logique etc. Enfin, le bloc des registres contient tous nos registres d’usage général, ou les pointeurs comme EIP par exemple, mais aussi un registre très important : il s’agit du registre de flags. Nous verrons son utilité d’ici peu. À suivre... |
|
« Sujet précédent | Sujet suivant »
|
Sujets apparemment similaires… | |||||
Sujet | Auteur | Réponses | Affichages | Dernier message | |
La pile en assembleur x86 | supersnail | 6 | 414 |
30-11-2012, 21h27 Dernier message: gruik |
Utilisateur(s) parcourant ce sujet : 1 visiteur(s)