I. Petit rappel improvisé▲
C'est en 1974 que naît le premier microprocesseur de la série, le 8080. Son nom évoque probablement les années 80 à venir, lesquelles inaugurent l'explosion de l'informatique, tout comme son très proche cousin, le Z-80, surs ensemble du 8080 avec lequel il est full compatible.
Le microprocesseur 8080 est une pure merveille. Mais d'une part, c'était un microprocesseur 8 bits (cela signifie que son bus de lecture-écriture est de huit fils parallèles en conséquence de quoi on ne peut lire ou écrire en mémoire que huit bits à la fois soit un octet) et d'autre part il ne pouvait adresser que 64K0, ce qui s'est vite avéré insuffisant. Sa structure interne est d'une rare simplicité avec ses huit registres 8 bits A, B, C, D, E, H, L et M. Un registre est une sorte de variable, c'est un circuit qui se trouve à l'intérieur du microprocesseur (ce pourquoi on parle de « registre interne ») et cette variable peut recevoir une valeur. Comme ces registres sont sur 8 bits, on peut leur donner une valeur comprise entre 0 et 255 i.e. en binaire entre 00000000 et 11111111. Le registre A est appelé « accumulateur », car il accumule les résultats, suite à une opération arithmétique, le résultat se trouve dans A. M est un faux registre, il s'agit du contenu pointé par HL où H signifie high et L low. H est donc l'octet haut d'adresse et L l'octet bas d'adresse, HL pointe donc une des 65536 cases mémoire numérotées (en décimal) de 0 à 65535 et M est l'octet pointé par HL. Par exemple l'instruction mov A,M signifie que le contenu pointé par HL est lu dans le registre A (l'accumulateur). Il est intéressant de remarquer la structure octalisante du 8080 avec des huit registres, ses huit types d'opérations (addition, addition avec carry, soustraction, soustraction avec carry, ET logique, OU logique, OU Exclusif logique et comparaison), ses huit types de sauts conditionnels en fonction de quatre indicateurs Z, C, S et P (zéro, carry, signe et parité). C'est pourquoi, la programmation en octal était très aisée, en une après-midi on connaissait pratiquement par cœur tous les codes octaux et on lisait l'assembleur directement en octal (base 8). Les deux premiers bits du code opératoire indiquent la nature de l'opération et les deux chiffres octaux suivants indiquent soit un type d'opération soit un registre impliqué dans l'opération. Ainsi si le code opératoire commence par « 01 », cela signifie « mov », ensuite viennent les deux registres impliqués. Les registres étaient indicés de 0 à 7 (BCDEHLMA). Par exemple mov A,B (le registre B est recopié dans A) s'écrit en octal 170 (soit 01111000 en binaire), car 7 signifie A et 0 signifie B. Inversement mov B,A s'écrit 107. Bref, le 8080 fut un microprocesseur minimal, mais parfaitement efficace puisqu'on pouvait tout programmer avec. L'expérience montre qu'un jeu d'instructions réduit suffit largement à la programmation en assembleur, le 8080 en a donné la preuve avec élégance.
Vient ensuite le 8086. Deux grandes différences avec le 8080, d'une part c'est un microprocesseur 16 bits et d'autre part il pointe la mémoire par segments. La segmentation fut donc la grande différence avec le 8080. Avec le 8080, la mémoire était pointée directement par une valeur 16 bits alors qu'avec le 8086 on pointe la mémoire avec deux valeurs, un registre de segment et un offset c'est-à-dire une sorte d'indice ou de pointeur à l'intérieur du segment. Le registre de segment correspond aux 16 bits de poids fort d'une adresse 20 bits (en conséquence de quoi un segment pointe toujours une adresse divisible par 16) et l'offset une valeur 16 bits qui sert de pointeur dans le segment, l'addition de ces deux valeurs, la valeur 16 bits du segment auquel on adjoint quatre zéros à droite pour former une valeur 20 bits divisible par 16 et la valeur 16 bits de l'offset, l'addition de ces deux valeurs donc fournit l'adresse 20 bits invoquée. Le 8086 peut donc adresser 16 fois 64KO soit un MO (un méga-octet). Il a quatre registres de segment, CS (code segment), DS (data segment), ES (extra segment) et SS (stack segment). Il a quatre registres généraux 16 bits, AX, BX, CX et DX (accumulateur, base, compteur et données) lesquels sont accessibles séparément en deux fois huit bits avec AH et AL pour AX (AH étant la partie haute de AX et AL sa partie basse donc AX=AH*256+AL), de même BH et BL pour BX, CH et CL pour CX, DH et DL pour DX. Par exemple l'instruction
MOV
AX
, 1027
qui signifie que le registre AX reçoit la valeur 1027, équivaut au couple d'instructions
MOV
AH
,4
MOV
AL
,3
puisque 4*256+3=1027.
Il a également deux registres d'index SI et DI (source index et destination index) et BP (base pointeur) utilisé dans certains modes d'adressage.
Ainsi, le 8086 ne pointe pas directement la mémoire. C'est toujours un couple de valeurs qui simulent une adresse 20 bits, par exemple DS:SI pointe l'adresse DS*16+SI. Puisque IP (instruction pointer) pointe par définition le code segment CS, le code opératoire qui va être lu est à l'adresse CS*16+IP. Par défaut, tous les modes d'adressage mémoire font intervenir le segment de données prévu à cet effet à savoir DS sauf les modes avec BP qui se calculent avec le registre de segment SS (stack segment). Par exemple l'instruction
MOV
AL
,[SI
]
signifie que le contenu pointé par SI est lu dans le registre 8 bits AL. Mais le registre SI n'est que l'offset à l'intérieur d'un segment, le registre de segment impliqué est ici DS (data segment), l'adresse 20 bits lue sera donc DS*16+SI. Sinon, si on veut utiliser un autre registre de segment, il faut alors préfixer l'instruction et écrire par exemple
MOV
AL
,ES
:[SI
]
qui signifie qu'on ne calcule plus l'adresse via DS, mais ES, l'octet lu dans AL est donc celui situé à l'adresse ES*16+SI. D'ailleurs le désassemblage donnerait
ES
:
MOV
AL
,[SI
]
ce qui montre clairement qu'on prévient ainsi le microprocesseur que le calcul de l'adresse se fera pour l'instruction suivante avec ES et non DS. Le 8086 a quatre pointeurs (SI, DI, BX et BP) et huit modes d'adressage :
BX
+
SI
+
dep
BX
+
DI
+
dep
BP
+
SI
+
dep
BP
+
DI
+
dep
SI
+
dep
DI
+
dep
BP
+
dep
BX
+
dep
Toutes les syntaxes admettent un déplacement compris entre -32768 et +32767. En l'absence de préfixation, le registre de segment impliqué est DS sauf pour les trois modes où BP intervient.
Arrive enfin le 80386, le microprocesseur 32 bits. Sa structure est grosso modo identique à celle du 8086, mais tous les registres sont maintenant sur 32 bits et sont préfixés par la lettre E pour étendu (extended). On a donc les registres généraux EAX, EBX, ECX et EDX, les deux registres d'index ESI et EDI, le pointeur de base EBP. Puisque 32 bits d'adressage correspondent à 4GO, la segmentation n'est plus nécessaire, mais les registres de segments sont maintenus pour des raisons de compatibilité ascendante. Pour plus d'informations sur le 80386 en tant que tel, je vous renvoie à d'autres informations.
On peut considérer le 80386 et ses successeurs comme des microprocesseurs bancals, car il incluent la notion de segmentation simplement pour être compatibles avec leurs prédécesseurs alors qu'avec 32 fils d'adresse on accède à 4GO de mémoire vive, en conséquence de quoi la notion de segmentation est devenue totalement inutile.
II. Principe de l'assembleur▲
Le registre 32 bits EIP (instruction pointer) lit l'instruction qu'il pointe en mémoire vive. Le premier octet de chaque instruction est un code opératoire en fonction duquel le microprocesseur déduit les opérandes, il sait donc en fonction du code opératoire qu'il décode sur combien d'octets est codée l'instruction et la signification de ces octets. Puis le microprocesseur exécute cette instruction lue. Si un sous-programme est appelé par l'instruction CALL, l'adresse 32 bits pointée par EIP juste après le CALL qu'il vient de lire est sauvegardée dans la pile. La pile est un espace mémoire spécial où l'on peut sauvegarder des valeurs pour les récupérer ensuite. Quand on écrit par exemple
PUSH
EAX
cela signifie que le registre 32 bits EAX est sauvegardé dans cet espace particulier, vous n'avez pas besoin de savoir où. Pour récupérer ensuite EAX on écrit simplement
POP
EAX
Bien entendu c'est au programmeur à gérer ses PUSH et ses POP. Si vous sauvegardez EAX par PUSH EAX et que vous écriviez par la suite POP EBX, il est clair que vous récupérez dans EBX la valeur de EAX. En général, les PUSH correspondent aux POP en sens inverse, ils obéissent à la loi LIFO (last in, first out). Par exemple, imaginons une fonction qui va utiliser les registres EAX, EBX et ESI, on écrira
PUSH
EAX
PUSH
EBX
PUSH
ESI
ici se trouve le corps de la fonction
utilisant EAX
, EBX
et ESI
POP
ESI
POP
EBX
POP
EAX
RET
On voit donc que les POP s'effectuent dans l'ordre inverse des PUSH, la dernière valeur empilée étant ESI, c'est la première à être dépilée de manière à ce qu'elle soit bien lue dans ESI. L'intérêt d'une telle démarche est que le programme appelant n'a pas perdu le contenu de ses registres. Si par exemple EAX est égal à 2 avant cet appel, on aura bien EAX égal à 2 au retour de la fonction. Si toutefois votre fonction assembleur renvoie une valeur dans EAX que la fonction est censée calculer, il ne faut alors plus programmer le couple PUSH EAX/POP EAX sinon la valeur ne serait pas retournée. En C++ on se pose la question de savoir si on envoie un paramètre en tant que valeur ou en tant que référence via l'opérateur &, en assembleur on se pose la question de savoir si on doit ou non « pusher » les registres. Par défaut en C++, un paramètre est du type valeur (cela correspond au couple PUSH/POP en assembleur) sinon, si on utilise l'opérateur &, la valeur est retournée à l'appelant (ce qui équivaut à l'absence de PUSH/POP pour le registre concerné, son contenu est donc renvoyé à l'appelant).
Le registre EIP pointe toujours juste après l'instruction que le microprocesseur va exécuter. Si cette instruction est un CALL, EIP pointe précisément l'adresse de retour, la valeur de EIP est donc sauvegardée en pile (comme si finalement on écrivait PUSH EIP). Puis EIP pointe le début de la fonction puisqu'un CALL correspond aussi à un saut à une adresse, un saut correspondant à une affectation de EIP. L'instruction CALL FONCTION où FONCTION est l'adresse où débute une fonction correspond donc à PUSH EIP et à LEA EIP, FONCTION (mais ces instructions n'existent pas, c'est uniquement pour vous donner une correspondance). Quand le microprocesseur rencontre l'instruction RET, il suppose qu'il y a en pile l'adresse de retour (c'est au programmeur à faire en sorte qu'il en soit ainsi), il lit donc cette adresse dans EIP qui pointe donc, si la programmation est correcte, l'adresse sauvegardée c'est-à-dire juste après le CALL. On comprend donc que l'instruction RET équivaudrait à l'instruction moins parlante POP EIP qui n'existe pas.
Bien entendu, c'est à vous de gérer correctement la pile. Imaginons que dans l'exemple précédent, nous ayons oublié de programmer le POP EAX final juste avant le RET. Que va-t-il se passer? Le microprocesseur continue sa route, il décode l'instruction RET, il charge donc dans EIP l'adresse qu'il suppose être l'adresse de retour qu'il dépile, mais comme il s'agit de EAX oublié, EIP va pointé à l'adresse EAX c'est-à-dire n'importe où en mémoire où le programme se plantera inévitablement.
Un des principes de base de l'assembleur est donc la gestion correcte de la pile, en gros, sauf astuce grossière et rarissime, autant de PUSH que de POP, autant de CALL que de RET. Cela dit, dans la programmation Windows, cette règle n'est plus vraie, il est fréquent de mettre en pile via PUSH les arguments d'une fonction Windows puis de l'appeler par CALL, la fonction « sait » combien d'arguments se trouvent en pile, elle les lit puis c'est elle qui se charge de les dépiler, on voit donc dans un programme assembleur Windows régulièrement une série de PUSH suivie d'un CALL à la fonction Windows, mais sans les POP qui deviendraient fautifs puisque c'est la fonction elle-même qui a régularisé la pile.
Le principe général de l'assembleur est très simple : à chaque instruction exécutée, le microprocesseur positionne des flags en fonction du résultat de l'opération.
Ces flags sont à disposition du programmeur qui peut ou non les tester. Flag signifie « drapeau » en anglais et un drapeau « indique » quelque chose, le drapeau vert au bord de la mer « indique » qu'il est autorisé de se baigner, le drapeau d'une pendule d'échecs indique si le joueur est « tombé » ou non c'est-à-dire s'il a ou non dépassé le temps de réflexion. Les flags sont donc des indicateurs qui indiquent la façon dont la dernière opération s'est déroulée. Les deux flags les plus importants sont ZF et CF, « zéro flag » et « carry flag ». On teste un flag en programmant un saut conditionnel en fonction de la position de ce flag. Si la condition testée est réalisée, le saut a lieu (EIP change de valeur, il pointe l'adresse de saut et le programme continue là) sinon, si la condition n'est pas réalisée, EIP ne saute pas et donc le programme continue comme si de rien n'était (puisque rien n'est). Imaginons que l'on veuille savoir si le registre AL est nul ou non, on écrira
TEST
AL
,AL
JZ
ALNUL //
saute à l'adresse ALNUL si ZF=1
ici le saut n'a pas eu lieu donc AL n'
est pas nul
ALNUL
:
ici le saut a eu lieu donc AL
est nul
D'une manière générale, il faut considérer que les flags répondent à une question. Ainsi le flag ZF (zéro flag) répond à la question : le résultat de la dernière opération est-il nul? ZF=1 oui, ZF=0 non. JZ signifie « saute à l'adresse indiquée si Z c'est-à-dire si ZF=1 », JNZ signifie « saute à l'adresse indiquée si Non Z c'est-à-dire si ZF=0 ».
Le flag CF (carry flag) répond à la question : le résultat de la dernière opération a-t-il provoqué un dépassement de capacité? CF=1 oui, CF=0 non. On appelle « dépassement de capacité » le fait que le résultat de l'opération ne peut pas tenir dans le registre qui reçoit le résultat. Imaginons l'instruction
add
al
,6
Cette instruction signifie qu'on ajoute 6 au registre 8 bits AL. Si le résultat « tient » sur 8 bits, on aura CF=0, mais si on dépasse le maximum autorisé à savoir 255 puisqu'on est sur 8 bits, on aura CF=1 et le résultat 8 bits ne sera plus le résultat réel, mais le résultat modulo 256. On en conclut aisément que CF est dans tous les cas le bit 8 de l'opération (le neuvième bit), lequel bien sûr est à 0 s'il n'y a pas de dépassement et à 1 s'il y a dépassement, il s'agit tout simplement de la retenue binaire de l'opération. Si CF=0 après une opération 8 bits, cela signifie que le résultat est correct tel quel, mais si CF=1 on n'a alors que le résultat modulo 256. De même suite à une opération 16 bits (par exemple add ax,6), CF représentera le bit 16 de l'opération (le dix septième bit) et suite à une opération 32 bits (par exemple add eax,6), CF représentera le bit 32 de l'opération (le trente troisième bit). À noter que, suite à un ADD, si ZF=1, on a alors obligatoirement CF=1 aussi, car si la résultat est nul, il y a forcément eu dépassement de capacité. JC signifie « saute à l'adresse indiquée si CF=1 », JNC signifie « saute à l'adresse indiquée si CF=0 ». En réalité ZF traite de l'égalité ou de l'inégalité alors que CF traite de la supériorité ou de l'infériorité. Par exemple pour savoir si ax est égal à bx on écrira
cmp
ax
,bx
La question que pose cette instruction est : ax=bx? et c'est l'indicateur ZF qui y répond, ZF=1 oui, ZF=0 non.
Si l'on veut savoir si bx est strictement inférieur à ax, on écrira la même instruction à savoir
cmp
ax
,bx
mais on testera cette fois-ci CF qui répond à la question : ax<bx? CF=1 oui, CF=0 non (ou bien sûr la question associée inverse équivalente bx>ax?). Ceci se comprend aisément, car la comparaison exécute la soustraction virtuelle ax-bx. Or, le maximum que l'on peut soustraire à ax est ax lui-même, mais au-delà, il y aura un dépassement de capacité. C'est pourquoi, ces deux indicateurs couvrent à eux seuls 99% des besoins, on ne trouve dans les listings que les sauts JZ/JNZ/JC/JNC et bien sûr le saut inconditionnel JMP. Les autres indicateurs sont très rarement testés.
Bien entendu, c'est au programmeur à être cohérent dans sa programmation. Si vous programmez un saut en fonction de Z suite à une instruction qui ne positionne pas Z, le saut aura lieu ou non en fonction de l'état de Z à ce moment-là, le microprocesseur ne s'occupe pas de savoir si vous avez précédemment positionné de façon cohérente l'indicateur testé. C'est pourquoi il ne suffit pas de connaître le jeu d'instructions, mais il faut pour chaque type d'instruction connaître le comportement des indicateurs. D'une manière générale, voici ce qu'il faut à peu près savoir.
Les instructions d'affectation du type MOV, PUSH, POP, LEA (load effective adresse), XCHG (swap de deux registres), XLAT (chargement dans AL de l'octet pointé par BX+AL), les manipulations de chaînes (REP MOVSB, REP MOVSW, REP MOVSD, qui recopient ECX contenus mémoire de ESI source vers EDI destination), les initialisations mémoire (REP STOSB, REP STOSW, REP STOSD qui recopient ECX fois respectivement AL, AX ou EAX à partir de EDI) etc. ne positionnent aucun indicateur. C'est d'ailleurs assez pratique, car on peut insérer ce type d'instructions avant un saut conditionnel puisque ces chargements ne modifient pas les flags. De même les sauts conditionnels ou non et les appels qui ne sont autres que des chargements divers et variés de EIP.
Les incrémentations (INC) et décrémentations (DEC) positionnent tous les indicateurs sauf CF. On épargne ici CF qui reste inchangé. D'ailleurs, si ZF=1 après une incrémentation, le résultat est nul et il y a eu dépassement de capacité, ZF eût donc fait double emploi avec CF suite à une incrémentation, ce pourquoi il a été épargné.
Les opérations arithmétiques, addition (ADD), addition avec CF (ADC, cela signifie qu'on ajoute en plus l'indicateur CF, ceci revient à incrémenter le résultat si CF=1, ADC équivaut à ADD si CF=0), soustraction (SUB), soustraction avec carry (SBB, on soustrait en plus CF), comparaison (CMP) positionnent tous les indicateurs.
Les instructions logiques, ET logique (AND), OU logique (OR), OU exclusif (XOR) positionnent tous les indicateurs et remettent CF à 0. C'est très pratique, s'il y a eu une instruction logique, on sait qu'on a CF=0, c'est très fréquemment utile. À noter l'instruction TEST qui est un ET logique non destructif. Quand vous écrivez AND BH,DH, vous faites un ET logique bit à bit entre les deux registres 8 bits BH et DH et le résultat de ce ET logique se trouve après exécution dans BH, mais quand vous faites TEST BH,DH, le résultat n'est transféré nulle part, cette instruction ne fait que positionner les indicateurs en fonction du résultat d'ailleurs perdu. TEST est donc un ET logique virtuel de même que CMP est une soustraction virtuelle, le résultat est perdu, mais on a positionné les indicateurs en fonction de ce résultat, si donc ZF=1 après une comparaison, cela signifie que le résultat de la soustraction eût été nul, en conséquence de quoi les deux opérandes comparés sont égaux. À noter l'instruction XOR AL,AL qui est un Ou exclusif entre AL et lui-même, c'est une petite astuce pour remettre AL à 0 (ou bien sûr AX ou EAX, car ces syntaxes sont extensibles, on peut écrire XOR AX,AX ou XOR EAX,EAX). Imaginons maintenant qu'on veut savoir la position du bit 3 et AL, on écrira TEST AL, 00001000b, si ZF=1 après exécution de cette instruction alors le résultat du ET logique aurait été nul en conséquence de quoi le bit 3 testé est nul donc au zéro logique et si ZF=0 alors le résultat du ET logique n'aurait pas été nul en conséquence de quoi le bit 3 est au 1 logique, le bit testé est donc l'inverse de ZF, il est à 1 si ZF=0 et à 0 si ZF=1. Imaginons qu'on veuille positionner au 1 logique les bits 2 et 10 de BX, on écrira OR BX, 0000010000000100b. Imaginons qu'on veuille annuler le bit 8 de EDX, on écrira AND EDX, 11111111111111111111111011111111b ou encore, si l'on veut éviter ce type d'écriture binaire AND EDX, 0FFFFFEFFh (dans la notation assembleur, on doit faire précéder les valeurs hexadécimales par 0 lorsque celles-ci commencent par une lettre pour éviter la confusion possible avec un label). Imaginons qu'on veuille inverser la valeur du bit 5 de CH, on écrira XOR CH,00100000b. En règle générale, on utilise le ET logique pour lire les bits ou les positionner au 0 logique, le OU logique pour positionner les bits au 1 logique et le OU exclusif pour inverser l'état logique des bits.
Les instructions de rotations et décalages, ROL et ROR (rotation circulaire à gauche et à droite, ROR rotation left, ROR rotation right), RCL et RCR (décalage avec CF entrant), SHL et SHR (décalage avec 0 entrant, SHL et SHR équivalent donc à RCL et RCR si CF=0, shift left et shift right) et enfin SAR (décalage à droite avec réinjection du bit de signe, shift arithmétique right) ne positionnent que CF qui représente donc le bit sortant du décalage. À noter qu'il n'y a que 7 types de rotations et décalages (et non 8), car SAR n'a pas sa réplique à gauche (le très gentil tasm32 accepte cependant l'instruction SAL, mais le convertit en SHL sans autre forme de procès). En effet, s'il est signifiant de réinjecter le bit de signe i.e. le bit le plus à gauche sur un décalage à droite, il n'est pas signifiant de réinjecter le bit 0 sur un décalage à gauche. SAR permet de diviser par deux en maintenant le signe tel quel, si donc la valeur était négative (en considérant le complément à 2), elle restera négative après SAR, car le bit de signe a été réinjecté. À noter que s'agissant des rotations et décalages, on a le choix entre l'exécuter une seule fois (e.g. ROR AL,1) ou CL fois (e.g. ROR AL,CL), dans ce cas bien sûr il faut renseigner CL par un mov juste avant par exemple MOV CL,3, ce qui aura pour effet de faire exécuter trois fois le décalage ou la rotation.
En résumé, les instructions
d'affection ne positionnent aucun indicateur
d'incrémentation positionnent tous les indicateurs sauf CF
arithmétiques positionnent tous les indicateurs
logiques positionnent tous les indicateurs sauf CF qui est remis à 0
de rotations et décalages ne positionnent que CF.
III. La mémoire▲
La mémoire n'est jamais qu'une suite d'octets consécutifs. Le 8080 ne pouvait lire ou écrire que huit bits à la fois (byte), le 8086 pouvait lire ou écrire deux octets consécutifs (word), le 80386, lui, peut lire ou écrire jusqu'à quatre octets consécutifs (double word). Mais que la mémoire soit pointée par byte, word ou double word, elle reste une suite d'octets numérotés et ces numéros ou indices sont des adresses. Par conséquent, la règle est simple :
en assembleur, il n'y a que des adresses et des contenus.
Le plus important donc, après avoir compris la pile et les indicateurs (flags) en relation avec les différents types d'instruction, est de comprendre la mémoire. Et pour comprendre la mémoire, il faut savoir pointer une adresse et savoir lire et écrire à cette adresse, c'est tout. Tout le reste en découlera. De même en langage C, si P est un pointeur de type char*, *P est le contenu pointé par P et &P est l'adresse ou se trouve écrit le pointeur P. P est le contenu d'adresse &P et *P le contenu d'adresse P. On voit donc que P est à la fois un contenu et une adresse, ce qui se simulera en assembleur par le fait qu'un pointeur par exemple ESI soit lui-même écrit à une adresse A. L'adresse A équivaut à &P, si je lis le contenu 32 bits à cette adresse dans ESI, ESI équivaut à P qui pointe lui-même quelque part et si je lis l'octet pointé par ESI, cet octet lu correspond à *P.
IV. Syntaxe d'accès avec C++Builder▲
Il y a deux cas, le cas où la mémoire à pointer se trouve dans la pile et le cas où elle se trouve dans le tas (heap en anglais). C'est sur ce plan-là que les syntaxes pour pointer la mémoire diffèrent. Quand vous déclarez une zone mémoire char zone[50] à l'intérieur d'une fonction, cette zone se situe en pile, mais quand vous déclarez un pointeur P de type char* et que vous allouiez pour P de la mémoire par new ou malloc, seul le pointeur déclaré (char* P;) se trouve en pile, mais la mémoire accordée par le système se situe dans le tas. Dans le premier cas, on fera pointer par exemple ESI à l'adresse zone par lea esi, zone alors que dans le deuxième cas, il faut lire le contenu P dans ESI pour que ESI pointe la mémoire allouée par new ou malloc et on doit alors écrire mov esi, [P] (les crochets entourant P sont facultatifs, on voit que cette notation est un peu ambiguë, car en réalité on lit dans esi quatre octets à partir de l'adresse &P ce qui fait que dans cette instruction assembleur P ne signifie pas P du C++, mais &P et esi équivaut alors après exécution au P du C++). ESI pointant correctement la mémoire (puisqu'il équivaut au P du C++), la suite du programme sera la même, simplement dans le premier cas ESI pointe une zone dans la pile et dans le deuxième il pointe une zone dans le tas.
V. Premier cas : mémoire déclarée dans la pile▲
Vérifions par un petit exemple. Entrez dans C++Builder, sauvez le projet vierge pour l'instant dans un répertoire de test en gardant unitxx et projectxx (où xx est un nombre sur deux chiffres, par exemple si vous en êtes à votre test numéro 14, sauvegardez le projet avec les noms unit14 et projet14). Commencez par rajouter au début de unitxx.cpp l'instruction #pragma inline, c'est cette instruction qui déclare qu'il y aura de l'assembleur dans le source cpp. Donc pour l'instant unitxx.cpp va ressembler à ceci.
#include <vcl.h>
#pragma hdrstop
#include "Unit14.h"
//---------------------------------------------------------------------------
#pragma package(smart_init)
#pragma resource "*.dfm"
#pragma inline
TForm1 *
Form1;
//---------------------------------------------------------------------------
__fastcall TForm1::TForm1
(
TComponent*
Owner)
:
TForm
(
Owner)
{
}
Nous allons vérifier qu'on pointe parfaitement la mémoire en mettant un bouton sur Form1, en créant un gestionnaire d'événement OnClick associé à ce bouton (il suffit de double-cliquer sur le bouton après l'avoir déposé n'importe où sur Form1). À l'intérieur de ce gestionnaire nous allons déclarer une chaîne de caractères que l'on va initialiser avec un message quelconque, on va déclarer une autre zone de caractères que l'on ne va pas du tout initialiser puis en assembleur, nous allons recopier la chaîne initialisée dans la zone prévue à cet effet puis nous allons afficher dans un MessageBox le contenu de cette zone où s'est effectuée la recopie. On constatera alors que la chaîne a bien été recopiée puisque c'est la chaîne recopiée que MessageBox va afficher et ce sera pour nous la preuve qu'on pointe correctement la mémoire.
void
__fastcall TForm1::Button1Click
(
TObject *
Sender)
{
char
Message[]=
"
Ceci est un message, vive l'assembleur!
"
;
char
Zone[100
];
_asm
{
cld
lea esi,Message
lea edi,Zone
mov ecx,40
REP MOVSB
}
Application->
MessageBox
(
Zone, "
ok
"
,MB_OK);
}
On déclare d'abord notre message puis une zone mémoire arbitrairement assez longue pour accueillir une recopie du message initialisé. Ensuite on déclare qu'on va maintenant écrire en assembleur avec la directive _asm (on peut aussi écrire asm tout court ou encore __asm avec deux underscores). L'instruction cld signifie « clear DF » c'est-à-dire « mets à zéro le flag DF », ce flag « direction flag » indique si la copie répétitive qui suit doit se faire par incrémentation ou décrémentation, à 0 ce sera par incrémentation et à 1 ce sera par décrémentation. C'est un flag particulier que l'utilisateur positionne et que le microprocesseur utilise. Ensuite on fait pointer esi sur Message (esi pointe donc le premier code ASCII du message) puis edi pointe le premier octet de la zone où la recopie va se faire puis on met le compteur ecx à 40, car c'est le nombre de caractères incluant le zéro de fin de chaîne à recopier puis on se contente d'écrire REP MOVSB ce qui signifie de recopier par incrémentation (car nous avons mis DF à 0) et par byte (MOVSW serait une recopie par word et MOVSD par double word) la source pointée par ESI dans la destination pointée par EDI. Vous remarquez qu'on n'utilise pas du tout les registres de segment qui sont initialisés une fois pour toutes par le système, on ne s'en occupe donc pas du tout (alors qu'en 8086 il fallait impérativement initialiser DS, le data segment, pour pointer correctement la mémoire).
Bien entendu, dans ce tout premier essai il nous a fallu calculer à la main la longueur du message et nous avons trouvé 40 incluant le zéro de fin de chaîne. Nous allons améliorer cette première tentative en écrivant une petite routine assembleur qui va nous calculer la longueur du message. On va donner à cette routine l'adresse ESI qui pointera le début du message, cette routine renverra le nombre de caractères dans le registre ECX utilisé comme compteur.
_asm
{
LongECX
:
push esi
mov ecx,0
LongECX10
:
mov al, [esi]
inc ecx
inc esi
test al,al
jnz LongECX10
pop esi
ret
}
Cette routine est une fonction comme une autre donc vous l'insérez dans le source C/C++ où vous voulez entre deux fonctions C++. En entrant dans le sous-programme LongECX, ESI est censé pointer correctement la mémoire. Cela dit, on sauvegarde son adresse en pile, ainsi au retour, esi pointera toujours le début de la chaîne à recopier et ce, bien qu'il va bouger dans la routine elle-même. On initialise le compteur ECX à 0. Puis la boucle commence. On lit dans AL l'octet pointé par esi. À la place de mov AL, [esi] nous aurions pu écrire mov AL, byte ptr [esi], mais la précision byte ptr n'est pas utile ici puisque le compilateur sait bien que AL est sur 8 bits. On n'écrit donc byte ptr ou word ptr ou encore dword ptr que lorsque cette précision est nécessaire. Par exemple, si l'on veut écrire 0 à l'adresse ESI, il faudra préciser s'il s'agit de 0 sur 8 bits, 0 sur 16 bits ou 0 sur 32 bits et donc choisir entre mov byte ptr [esi],0 ou mov word ptr [esi],0 ou encore mov dword ptr [esi],0. L'octet étant lu dans AL, on incrémente le compteur ecx et le pointeur esi puis on teste al par l'instruction test al,al qui fait un ET logique entre al et lui-même et ce, à seule fin de positionner les flags, car nous voulons savoir si l'octet lu est nul ou non. Cette instruction est strictement équivalente à and al,al ou même or al,al, car le ET logique et le OU logique sont idempotents. Si l'octet lu dans AL n'est pas nul, on saute à l'adresse LongECX10 où l'on va lire encore la mémoire (mais esi a avancé d'une case entre temps donc on va lire l'octet suivant). Tant qu'on aura à ce stade ZF=0, le saut aura lieu. Quand ZF sera au 1 logique après le test, la boucle s'arrêtera, et ce, parce que le saut n'aura plus lieu. En effet, le saut a lieu si NZ, mais n'a pas lieu si Z. On restitue alors à esi sa position d'origine par pop esi puis on retourne à l'appelant par l'instruction ret avec ECX qui contient la longueur de la chaîne incluant le zéro de fin. On voit qu'ici une erreur de programmation aurait été d'écrire le couple PUSH ECX/POP ECX, car alors le contenu de ECX ne serait pas retourné. La relation entre le C++ et l'assembleur est inversée au sens où rien en C++ correspond au PUSH/POP en assembleur (argument du type valeur) alors que l'opérateur & en C++ correspond à rien en assembleur (absence de PUSH/POP, argument du type référence). Dans ces conditions, la fonction de recopie précédente sera légèrement modifiée, notamment ECX étant calculé par la fonction LongECX, il n'a plus à être initialisé par programme. Donc après avoir donné l'adresse Message à esi par lea esi, Message on appelle la fonction LongECX par call LongECX, puis on positionne EDI, mais comme ECX a été calculé par la routine LongECX, on supprime l'instruction qui lui donnait dans notre exemple précédent la valeur 40. Voici l'ensemble de l'unité cpp qui contient maintenant plus d'assembleur que de C++. Remarquez bien la place de la routine LongECX à savoir comme une fonction C/C++ entre deux fonctions.
#include <vcl.h>
#pragma hdrstop
#include "Unit14.h"
//-------------------------------------------------------
#pragma package(smart_init)
#pragma resource "*.dfm"
#pragma inline
TForm1 *
Form1;
//-------------------------------------------------------
__fastcall TForm1::TForm1
(
TComponent*
Owner)
:
TForm
(
Owner)
{
}
//-------------------------------------------------------
_asm
{
LongECX
:
push esi
mov ecx,0
LongECX10
:
mov al, [esi]
inc ecx
inc esi
test al,al
jnz LongECX10
pop esi
ret
}
//-------------------------------------------------------
void
__fastcall TForm1::Button1Click
(
TObject *
Sender)
{
char
Message[]=
"
Ceci est un message, vive l'assembleur!
"
;
char
Zone[100
];
_asm
{
cld
lea esi,Message
call LongECX
lea edi,Zone
REP MOVSB
}
Application->
MessageBox
(
Zone, "
ok
"
,MB_OK);
}
//-------------------------------------------------------
Remarquez que l'assembleur commence juste après le constructeur de TForm1. En effet, C++Builder ne permet pas de commencer par des lignes d'assembleur, cela provoque une erreur à la compilation. Les premières directives asm se situent donc juste après Tform1::TForm1.
VI. Deuxième cas : mémoire déclarée dans le tas▲
Modifions le programme précédent. Nous allons déclarer notre message de la même façon, mais nous allons déclarer un pointeur P de type char* puis nous allons allouer à ce pointeur une zone de 100 octets par new. Cette zone va alors se situer dans le tas. On ne va donc plus pointer la zone via l'instruction lea (load effective adresse), car lea signifie qu'on donne au pointeur l'adresse directement et cette adresse nous ne la connaissons pas, mais nous savons qu'elle se situe à l'adresse &P donc on va écrire mov edi, [P](car &P du C++ correspond à P en assembleur, il en est ainsi dans la notation), ce qui signifie que les 32 bits situés à partir de l'adresse &P sont lus dans EDI, le microprocesseur va donc lire quatre contenus aux adresses consécutives &P, &P+1, &P+2 et &P+3, ce qui va faire quatre octets qu'il va donner au registre 32 bits edi (adresse de destination de la future recopie du message initialisé). Dans ces conditions, EDI pointe la mémoire allouée par new. La recopie s'effectue de la même façon.
void
__fastcall TForm1::Button1Click
(
TObject *
Sender)
{
char
Message[]=
"
Ceci est un message, vive l'assembleur!
"
;
char
*
P;
P=
new char
[100
];
_asm
{
cld
lea esi,Message
call LongECX //ECX=longueur de la chaîne
mov edi, [P]
REP MOVSB
}
Application->
MessageBox
(
P, "
ok
"
,MB_OK);
delete P;
}
Vous voyez clairement le rapport entre l'assembleur et le C. Si j'écris par exemple lea edi, P, cela signifie que edi pointe en pile l'adresse où se trouve le pointeur P, edi est alors égal à &P, si j'écris comme dans le programme mov edi, [P] cela signifie que je lis le pointeur P (les crochets sont d'ailleurs facultatifs) donc EDI équivaut alors à P et alors byte ptr [EDI] équivaut à *P que ce soit en lecture ou en écriture. Pour résumer :
lea edi, P signifie edi=&
P
mov edi, P signifie edi=
P
mov [EDI], AL signifie *
P=
AL
mov al, [EDI] signifie AL=*
P
inc edi signifie P++
dec edi signifie P--
Vous pouvez consulter le programme assembleur créé par C++Builder, il suffit de l'ouvrir, son nom est unitxx.asm. Pour faire du pas à pas en assembleur, positionnez votre curseur sur la première instruction assembleur push esi puis faites F4 (exécution jusqu'au curseur). Là, le programme s'exécute, cliquez sur le bouton, cela provoque l'arrêt du programme à l'endroit où vous avez positionné le curseur. Faites Alt v d c, vous obtenez la fenêtre CPU (Alt active le menu, v pour voir, d pour debug et c pour CPU) ou encore appuyez simultanément sur ctrl et Alt et faites c, la fenêtre CPU s'affiche. Le mieux est le mode mixte où l'on vous donne à la fois le C++ et l'assembleur, cliquez à droite dans la fenêtre où se trouve le code et sélectionnez l'option « mixte » si elle n'est pas activée, cela permet le double affichage C++/assembleur. Si vous vous êtes perdu suite à des scrolls pour consulter le code de la fenêtre assembleur, cliquez à droite et faites « allez à l'EIP en cours », vous vous retrouvez à l'instruction où l'on est arrêté. En haut à droite, vous avez les registres internes du microprocesseur, en bas à gauche une zone mémoire quelconque et en bas à droite la pile. Regardez la valeur de esp, le pointeur de pile, vous constatez que la fenêtre de pile vous affiche l'état de la pile, une petite flèche verte vous montre la position du pointeur de pile esp et en regard la dernière valeur empilée. Si vous voulez consulter de la mémoire, utilisez la fenêtre en bas à gauche prévue à cet effet, cliquez à droite et choisissez « aller à l'adresse ». En général on veut consulter une adresse contenue dans un registre, donc on donne le nom de ce registre et on vous affiche le contenu de la mémoire à cet endroit. Mais vous pouvez aussi donner une adresse, dans ce cas n'oubliez pas d'entrer le préfixe « 0x » de manière à donner une valeur en hexadécimal (sinon C++Builder considère que c'est du décimal).
VII. Utilisation de l'assembleur sous DOS▲
Nous allons maintenant essayer notre assembleur sous DOS via une « invite de commandes » et non plus sous C++Builder. Commençons par créer un répertoire asm à l'intérieur du répertoire CBuilder5. Vous pouvez créer ce répertoire sous Windows. Sinon, utilisez une « invite de commandes », allez sous le répertoire Cbuilder5. Par exemple sous Windows98, l'invite de commandes vous fait aller au répertoire Windows, le prompt est donc ceci :
C:\Windows>
On commence par descendre à la racine :
C:\Windows>
cd
..
Le prompt devient alors :
C:\>
Puis on se rend alors au répertoire Cbuilder5 :
C:\>
cd
"program files"\borland\cbuilder5
Là on crée le répertoire asm :
C:\Program Files"\Borland\CBuilder5>
mkdir
asm
C'est dans ce répertoire que nous allons développer en assembleur. Ce répertoire étant créé, sortons de l'invite de commandes puis rentrons-y à nouveau. On se retrouve sous le répertoire Windows. Là, créons un petit go.bat qui va nous faire aller directement au répertoire asm. Pour cela, entrons sous Edit, le petit éditeur de texte du DOS (vous pouvez créer ce petit « point bat » sous Windows avec NotePad, le bloc-notes standard, ce sera la même chose). Donc entrons sous Edit :
C:>
\Windows>
edit
go.bat
L'éditeur s'ouvre et vous donne la main, entrez simplement la ligne :
cd
..\"program files"\borland\cbuilder5\asm
puis sauvez le document, faites Alt (pour activer le menu) puis f (pour activer l'option « fichier ») puis e (pour enregistrer) puis de nouveau Alt puis f puis q pour quitter.
Vous êtes de nouveau sous le répertoire Windows, il ne vous reste plus qu'à entrer la commande go pour exécuter ce go.bat qu'on vient d'écrire.
C:>
\Windows>
go
On se rend ainsi à notre répertoire asm plus facilement. Le prompt est donc (maintenant que nous sommes « chez nous ») :
C:>
\Program Files\Borland\CBuilder5\asm>
Là nous créons un petit c.bat pour compiler et lier c'est-à-dire créer l'exécutable exe à partir d'un fichier assembleur asm. Donc on entre sous Edit :
C:>
\Program Files\Borland\CBuilder5\asm>
edit
c.bat
Là, vous êtes sous éditeur de texte, entrez ces deux lignes :
tasm32 -ml prg
ilink32 -x -c prg ,,,..\lib\import32
puis sauvez ce document, faites Alt (pour activer le menu) puis f (pour activer l'option « fichier ») puis e (pour enregistrer) puis de nouveau Alt puis f puis q pour quitter. Il suffit d'apprendre une fois pour toutes cette série de six touches pour sauvegarder un document sous Edit : Alt f e Alt f q.
On suppose là que notre programme va s'appeler prg.asm. Attention à la syntaxe, les espaces sont importants ainsi que le nombre de virgules. Comme vous le voyez, l'assembleur s'appelle tasm32 (turbo assembleur 32 bits) et le lieur ilink32 (incremental link 32 bits). Pour connaître les options de l'assembleur et du lieur, il suffit d'entrer leur nom sous DOS, la commande tasm32 vous donnera donc toutes les options de l'assembleur et la commande ilink32 toutes les options du lieur. Remarquez aussi que nous faisons le lien avec la librairie import32, ce qui nous permettra d'accéder aux fonctions Windows. Nous sommes prêts pour écrire notre premier petit programme en assembleur. Vous pouvez également remplacer prg par %1 qui représente le premier argument qui sera donné à la commande c (il faut donc faire deux fois ce remplacement, car prg apparaît deux fois), dans ce cas on ne compilera pas par c tout court, mais par c suivi du nom du programme par exemple c prg. Entrons maintenant sous Edit et créons le programme prg.asm.
C:>
\Program Files\Borland\CBuilder5\asm>
edit
prg.asm
Là, entrez le programme suivant ou mieux, comme c'est un peu long, faites un copier-coller sous Windows avec NotePad, le résultat sera le même, sauvegardez ce fichier sous le nom prg.asm.
.386
locals
jumps
.model flat,STDCALL
extern MessageBoxA :
Proc
.data
titre db "In
girum imus nocte et con
sumimur igni",0
texte db "Bienvenue dans les tutoriaux",10,10
db "Vive l'assembleur!",0
.code
Programme:
push 0 ; type
de fenêtre
push offset titre
push offset texte
push 0
call
MessageBoxA
ret ; retour sous DOS
End Programme
Vous reconnaissez le « data segment » annoncé par .data à l'intérieur duquel nous avons créé des chaînes de caractères via la directive db (define byte), le « code segment » annoncé par .code, là où se trouve le programme. Il est important de commencer par un libellé, ici Programme, et de terminer par End suivi de ce même nom donc End Programme. Ce programme est très court, car il s'agit d'un essai. On sauvegarde en pile le type de fenêtre voulu (ici 0 qui correspond à une fenêtre simple avec le bouton OK, correspondant à MB_OK) puis l'adresse « titre » (où se trouve le titre de la fenêtre, chaîne de caractères terminée par zéro), puis l'adresse « texte » puis enfin 0 (zéro, indication supplémentaire utilisée par le système). Puis on appelle MessageBoxA (qui se trouve dans import32, la librairie standard avec laquelle nous avons lié le programme dans notre c.bat) puis on termine par l'instruction ret, retour au programme appelant donc retour sous DOS.
Il ne reste plus qu'à compiler et lier ce programme pour en obtenir l'exécutable donc on entre simplement la commande c qui va exécuter notre petit c.bat créé précédemment.
C:>
\Program Files\Borland\CBuilder5\asm>
c
Le programme se compile sans erreur. On l'exécute maintenant en entrant son nom, donc :
C:>
\Program Files\Borland\CBuilder5\asm>
prg
Une fenêtre Windows s'affiche avec le bouton OK.
Vous constatez qu'ici nous n'avons pas programmé les POP alors qu'il y a quatre PUSH, cela est dû à la syntaxe d'appel des fonctions Windows, les arguments se situent dans la pile avant l'appel. Comme la fonction le « sait », c'est elle qui se charge de dépiler ces valeurs de manière à ce que la pile soit régularisée au retour. Si vous voulez un listing de votre programme avec les codes hexadécimaux, rajouter l'option -l à la commande tasm32 c'est-à-dire écrivez dans le c.bat la ligne
tasm32 -ml -l prg
C'est la même ligne que précédemment, mais on a rajouté l'option -l. Dans ces conditions, notre commande c de création de l'exécutable fournira en plus le fichier prg.lst qui est le listing complet du programme totalement construit et très bien présenté, à consulter via NotePad sous Windows ou via Edit sous DOS.
VIII. Test sous C++Builder▲
Maintenant, si vous voulez tester le programme assembleur DOS précédent sous C++Builder pour faire du pas à pas, ce n'est pas immédiat, il faut alors quelque peu modifier la présentation du programme précédent pour lui donner une écorce C++, car il est en assembleur pur. Créez sous Windows un nouveau répertoire de travail. C'est une précaution importante, car nous allons modifier ce programme en le faisant devenir un programme C++ avec la directive _asm. Or, dans ces conditions, C++Builder crée lui-même un asm suite à la compilation et donc écraserait le prg.asm d'origine si l'on travaillait dans le même répertoire (ou alors il faudrait changer le nom du programme, mais un répertoire nouveau est préférable pour ne pas mélanger l'assembleur avec le C++).
Le même programme que précédemment pourrait se présenter de la façon suivante :
#pragma inline
void
main
(
void
)
{
_asm
{
Programme
:
push 0
push offset titre
push offset texte
push 0
call MessageBoxA
jmp fin
titre db "
In girum imus nocte et consumimur igni
"
,0
texte db "
Bienvenue dans les tutoriaux
"
,10
,10
db "
Vive l''assembleur!
"
,0
extern
MessageBoxA : Proc
fin
:
}
}
On commence par #pragma inline pour indiquer au compilateur C++ qu'il y aura de l'assembleur. On crée le programme maître void main(void){} et à l'intérieur des parenthèses on crée la directive _asm{}, on recopie le programme, mais on remplace ret par jmp fin, on crée le label fin juste avant la parenthèse fermante de la directive asm, on recopie les chaînes de caractères, mais on rajoute une quote au message « vive l''assembleur » (une double quote n'en vaut qu'une en assembleur), on déclare la fonction externe MessageBoxA, on supprime « End Programme » qui ne vaut qu'en assembleur pur. Ce fichier étant au point, sauvez-le dans le répertoire nouveau précédemment créé sous le nom prg.cpp. Puis faites « Fichier|Nouveau » et choisissez « Expert console », là donnez dans la fenêtre le nom du programme prg.cpp avec le chemin correct et cochez la case « Spécifiez le source du projet ». Puis faites « Fichier|Enregistrer le projet sous » et donnez un nom à ce projet (laissez par exemple Projet1 proposé), car même un simple programme console doit avoir un nom de projet associé en C++Builder. Maintenant, vous pouvez exécuter le programme et donc aussi l'exécuter au pas à pas en mode debug avec la fenêtre CPU.