Vous êtes sur la page 1sur 18

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

1
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]

2
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 à l'introduction à l'assembleur
de Haypo.

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

3
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.

4
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

5
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

6
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

7
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.

8
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

9
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.

10
_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.

11
#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.

12
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

13
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

14
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

15
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

16
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

17
"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.

glouise@club-internet.fr

Mai 2001

18

Vous aimerez peut-être aussi