Développer en Assembleur

====== L'assembleur avec C + + Builder ======

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.

Historique


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

9

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.

<code>

#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)

{}

{/code>

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

10

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.

<code>

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);

}

</code>

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 <code>__asm</code>
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.

<code>

_asm

{

LongECX: push esi

mov ecx,0

LongECX10: mov al, [esi]

inc ecx
inc esi
test al,al
jnz LongECX10

pop esi

ret

}

</code>




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.




<code>

#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);

}

//-------------------------------------------------------

</code>

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.


<code>

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;

}

</code>




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 rentronsy

à nouveau. On se retrouve sous le répertoire Windows. Là créons un

15

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 blocnotes

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:

<code>

push 0 ; type de fenêtre

push offset titre

push offset texte

push 0

call MessageBoxA

ret ; retour sous DOS

End Programme

</code>

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

17

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.

Commentaires

Posts les plus consultés de ce blog

Base de Données Sybase IQ

Sécurité des Applications

Principes de la Programmation Orientée Objet