19 juin 2002
L'assembleur
avec C++Builder
par
Gilles Louise
Dans ce qui suit, nous nous limitons d'une part à C++Builder pour PC
et d'autre part à l'assembleur du 80386 de chez Intel. Pour utiliser
cet assembleur, vous avez deux possibilités : soit insérer dans
votre programme C/C++ des lignes d'assembleur, soit utiliser l'assembleur
en tant que tel sous DOS via une "invite de commandes" et donc écrire
un programme intégralement en assembleur. Nous abordons l'un et l'autre
aspect de la question.
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, sursensemble 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.
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.
Qua 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 manipulation 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 intouché. 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ées sont égales. À 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 veuille 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éciamales 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.
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.
Syntaxes 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.
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érifiez 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.
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).
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 consumimur 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.
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écutez le programme et donc aussi l'exécuter
au pas à pas en mode debug avec la fenêtre CPU.
|
|