l'assembleur  [livre #4061]

par  pierre maurette


Pile
Cadre de pile
Sous-programmes

Ce chapitre purement thématique aurait pu s'intituler variations autour d'une pile . Nous avons choisi de singulariser ce sujet après avoir constaté que ce qui concerne les appels de procédures demandait à être clairement précisé, même en excluant les appels interniveaux de privilèges, qui font partie de la programmation système. Le fonctionnement lui-même de la pile n'est pas toujours bien perçu, comme tout ce qui semble fonctionner à l'envers , comme le placement des données en mémoire selon la convention little endian, ou l'ordre de passage des paramètres vers une procédure.

Ce choix nous a permis de présenter le concept de cadre de pile, qui est une notion souvent confuse, et pourtant fondamentale dès que notre travail concerne conjointement l’assembleur et un langage structuré.

La pile étant l’objet central de ces sujets, nous avons donc choisi de traiter en même temps les instructions la concernant directement. Donc, seront traitées dans ce chapitre un certain nombre de notions qui ne le seront pas ailleurs et vous devrez vous y reporter lors de l'étude des points suivants :

  Le jeu d'instructions : POP , POPA , POPAD , POPF , POPFD , PUSH , PUSHA , PUSHAD , PUSHF , PUSHFD , CALL , RET , ENTER , LEAVE .

  Les procédures, dans le chapitre sur MASM, mais d'une portée plus générale.

 

11.1 La pile

La pile est une zone particulière de la mémoire réservée à des stockages temporaires. Précisons, pour ne pas avoir à y revenir, que si nous disons la pile au singulier, c'est parce que nous nous plaçons dans le cadre du processus individuel, de celui que nous programmons. Il est vrai que chaque processus possède sa propre pile. Nous avons même vu au chapitre Architecture système que chaque tâche possède plusieurs piles, une par niveau de privilège auquel elle souhaite accéder. À un instant donné, une pile et une seule, est en fonctionnement, définie comme nous allons le voir par la valeur actuelle de deux registres, SS et (E)SP.

La pile ressemble à une colonne d’assiettes. Dans une gestion normale, seul le dessus de la pile est facilement accessible. Nous avons donc un accès immédiat à la dernière donnée déposée. Récupérer cette dernière donnée permettra d’accéder à celle déposée juste avant. C’est le principe "dernier entré, premier sorti", en anglais Last In First Out, ou LIFO .

La pile de notre ordinateur est de type LIFO, en pile d’assiettes. À priori, une pile est toujours LIFO. Gérer les horaires des employés en LIFO, dernier arrivé premier parti, est souvent désastreux.

Le pendant à LIFO est premier entré, premier sorti, First In First Out, ou FIFO . L'image n'est plus une pile, mais une file, un tuyau, d'où pipe ou pipeline. La file d'attente est FIFO. Un bon silo se remplit par le haut et se vide par le bas, ce qui évite aux grains du fond de passer trop de temps dans le silo.

LIFO et FIFO sont dans un BATO
figure 11.01 LIFO et FIFO sont dans un BATO [the .swf]

Observez, dans votre supérette habituelle, un employé réapprovisionnant les linéaires (il ne faut plus parler aujourd'hui de rayons, terme réservé aux abeilles et aux cyclistes) : son but est de proposer du FIFO au client, pour optimiser la gestion des dates limites de vente ; mais les rayonnages sont plus prévus pour être réapprovisionnés en LIFO, et le travail s'en trouve un peu compliqué. Il existe effectivement des systèmes de rayonnages (désolé, "linéairage" n'existe pas) accessibles au personnel par l'arrière.

Dans une pile LIFO, un seul curseur suffit pour empiler et dépiler. Dans une file FIFO, il en faut deux, sauf à décaler la file à chaque opération. La queue de préchargement d'instructions du microprocesseur vue sur le 8086/8088 est une FIFO sans curseur. C'est du moins comme cela qu'elle est représentée.

Dans notre PC, la pile est définie par le segment de pile, donc par la valeur du sélecteur de segment dans le registre SS. Chaque tâche aura ainsi sa pile (voir plus haut). L'offset, ou déplacement, par rapport au segment de pile est contenu dans ESP (Extended Stack Pointer) ou SP. Dans les lignes qui suivent, ESP est généralement remplaçable par SP, nous écrirons le plus souvent comme à l'habitude (E)SP.

Le flag B (big) du descripteur du segment de pile SS détermine la taille du pointeur de pile, donc le choix ESP/SP.

Le flag D du descripteur du segment de code CS, et non de SS, détermine l’alignement de la pile (qui, rappelons-le, est une valeur par défaut, susceptible d’être surchargée). Cette taille détermine celle de l’assiette, c’est-à-dire si l’incrément de base sur la pile est de 16 ou 32 bits. Ce point, parfois flou, fera l’objet d’une manipulation un peu plus loin.

Quand les assiettes s'empilent, l'offset diminue. Une explication historique peut être imaginée : mettons-nous dans le cas d'une machine possédant peu de mémoire, ou d'un programme qui doit tenir dans un seul segment, code, données et pile, comme un  .com . Le code va progresser depuis les adresses les plus basses, en montant. Si le début de la pile est placé à la fin de la mémoire et les données empilées en diminuant les adresses, la pile va progresser à la rencontre du code et vice versa. C'est dans ces conditions que la collision, c'est-à-dire le blocage, se produira le plus tard, avec une taille possible pour la pile maximale. Si la pile poussait vers les adresses croissantes, il aurait fallu dès le début de l'étude pifométrer un point de départ optimal pour la pile.

Peu importe la raison, ce détail est perturbant, par exemple quand nous devrons crayonner une image de la pile, ou simplement nous l'imaginer. Nous voulons conserver l'idée de la pile d'assiettes, mais qui pousserait à partir du plafond. Cette inversion apparente peut certainement être un frein puissant à la compréhension. Alors qu'il suffirait de l'éluder.

Nous n'aurons besoin de connaître la réalité mémoire que plus tard, quand nous accèderons directement à des données dans la pile, ou quand nous modifierons manuellement le sommet de cette pile. Pour l'instant, nous avons choisi de l'oublier, de refuser de nous prendre la tête. Nous allons représenter la pile comme nous avons envie de la voir, une pile d'assiettes simplement posée par terre ou sur une table. Il suffira de ne penser aux adresses qu'en cas de nécessité, cas relativement rare. La documentation d'Intel est d'ailleurs un peu ambiguë sur le sujet, qui représente la pile poussant vers le bas, mais utilise l'expression top of stack .

Nous parlerons donc du sommet de la pile, dans son sens intuitif. En plongeant dans la pile, nous retrouvons des données de plus en plus anciennes . Et des adresses de plus en plus hautes , mais n'y pensons pas, qu'est-ce que ça change ?

Ce que nous appelons sommet de la pile est précisément la dernière assiette empilée, et non pas l'endroit où sera déposée la suivante.

Entre deux instructions, (E)SP pointe vers ce sommet de la pile, dont le contenu est connu (ce qui n'est pas le cas de la prochaine position libre sur la pile). En cas de doute, n'hésitez pas à vérifier avec un débogueur, voire avec DEBUG. Nous trouvons dans la documentation Intel la phrase :

«  The next available memory location on the stack is called the top of stack. At any given time, the stack pointer (contained in the ESP register) gives the address (that is the offset from the base of the SS segment) of the top of the stack.  »

Mais la suite, la chronologie des instructions PUSH et POP par exemple, ne laisse aucun doute : (E)SP pointe bien vers une adresse utilisée de la pile. Il reste une ambiguïté sur le terme top of stack , qui semble parfois désigner une place libre de la pile (donc ESP - 4 ou SP - 2). Mais la phrase suivante est claire :

«  When an item is pushed onto the stack, the processor decrements the ESP register, then writes the item at the new top of stack. When an item is popped off the stack, the processor reads the item from the top of stack, then increments the ESP register.  »

Nous conserverons donc la terminologie : le sommet de la pile est pointé à tout moment par SS:ESP (ou SS:SP ), et c'est la dernière adresse utilisée de la pile.

Ceci nous amène à la représentation suivante.

Représentation montante de la pile
figure 11.02 Représentation montante de la pile [the .swf]

 

11.2 Les instructions PUSH et POP

L’instruction PUSH  (pousser) pose une donnée sur la pile, l’instruction POP  récupère la donnée au sommet de la pile. Voici ce qu’il faut savoir sur la famille d’instructions PUSH / POP  :

  PUSH décrémente ESP de 2 ou 4 unités et transfère ensuite la valeur de l'opérande au sommet de la pile, pointé maintenant par SP ou ESP. L'opérande peut être un registre, une mémoire tous deux en 16 ou 32 bits, ou une valeur immédiate en 8, 16 ou 32 bits, ainsi qu'un des registres de segment.

  PUSHA / PUSHAD  réalisent des PUSH sur les registres suivants, dans cet ordre : EAX, ECX, EDX, EBX, ESP, EBP, ESI, EDI, ou leur version 16 bits selon la taille d'opérande par défaut. La valeur sauvée pour (E)SP est celle d'avant le début de l'opération. PUSHA et PUSHAD correspondent au même opcode. Certains assembleurs, comme celui de Delphi, vont les distinguer en préfixant l'opcode. D'autres génèreront le même code, laissant l'attribut de taille d'opérande décider.

  PUSHF / PUSHFD  : même chose que le précédent pour les registres EFLAGS et FLAGS. Les flags VM et RF ne sont pas copiés, mais mis à 0 dans la copie.

  La valeur sur laquelle s’effectue le PUSH de ESP par PUSH ESP est celle d’avant l’instruction PUSH , de même que celle utilisée pour un éventuel calcul d’adresse effective d’opérande. Le 8086 faisait exception à cette règle, mais pas le mode 8086 d’un processeur plus moderne.

  POP transfère la valeur du sommet de la pile dans l'opérande, puis incrémente (E)SP. L'opérande est un registre ou une mémoire, en 16 ou 32 bits, ou un registre de segment, à l'exception de CS.

  POPA / POPAD / POPF / POPFD  sont à POP ce que PUSHA / PUSHAD / PUSHF / PUSHFD sont à PUSH . Simplement, un POP vers certains registres peut être soit dangereux, soit interdit selon le contexte.

À l'aune de tout ce qui vient d'être affirmé, représentons la pile soumise à une instruction d'empilement (un PUSH ) et de dépilement (un POP ).

Effet d'un PUSH et d'un POP
figure 11.03 Effet d'un PUSH et d'un POP [the .swf]

 

L'existence même de certaines instructions peut être ignorée. En revanche, pour d'autres dont PUSH et POP font partie, le fonctionnement doit être bien connu pour pouvoir être prédit avec une bonne précision. Pour découvrir par la pratique ces instructions, saisissez les quelques lignes suivantes sous Delphi (les exemples de ce chapitre sont sur le CD-Rom, dossier piles ) :

asm
  pushad
 
  mov ax, $AAAA
  push ax
 
  mov eax, $BBBBBBBB
  push eax
 
  push byte.$EE
 
  mov ax, $CCCC
  push ax
 
  push byte.$12
 
  mov ax, $DDDD
  push ax
 
  push dword.$EE
 
  mov eax, $FFFFFFFF
  push eax
 
  push dword.$12
 
  push word.$EE
 
  add esp, 32
  popad
end; //asm

Rappelons qu’en BASM, la syntaxe type.valeur_immédiate force le type sous lequel il faut considérer la valeur immédiate.

Dans ces conditions, nous travaillons avec ESP et une taille d’opérande de 32 bits. Notre but est de constater de visu le comportement du programme face à des données de différentes tailles. Le bloc commence par un pushad et se termine par un popad , afin de préserver les registres. Sous débogueur et fenêtre CPU, déroulons en observant jusqu’à l’attaque de add esp, 32 . Cette dernière ligne a le même effet sur le pointeur de pile qu’une série de POP équilibrant les PUSH . La valeur 32 a été calculée en additionnant le nombre d’octets "PUSHés" par chacune de ces instructions.

État de la pile après push word.$EE
figure 11.04 État de la pile après push word.$EE

La première constatation est que les registres/mémoires 16 bits sont empilés sur 16 bits. Cela a pour effet de décaler la pile. Une seule opération sur 16 bits et toutes les données empilées au-dessus vont se trouver désalignées, avec une dégradation des performances qui peut être importante. Une donnée est dite désalignée dans ce contexte quand son adresse n’est pas divisible par 4. Les adresses divisibles par 4 sont celles qui, en hexadécimal, se terminent par 0, 4, 8 et C.

Le traitement des valeurs immédiates appelle un commentaire : de 8 ou 32 bits, elles sont toujours empilées avec la taille du mot par défaut, ici 32 bits. De plus, elles sont complétées comme un nombre signé, c’est-à-dire en faisant courir le bit de signe. Par exemple, EEh est sauvé en tant qu’octet sous FFFFFFEEh , et 12h sous 00000012h . En revanche, si nous empilons EEh en tant que DWORD , c’est bien 000000EEh qui sera sauvé. Les données immédiates de 16 bits sont empilées sur 16 bits.

Ce comportement a une explication. Relire au besoin ce qui est dit des modes 16 et 32 bits au chapitre Architecture système . En mode 32 bits, les tailles d'opérande par défaut sont 8 et 32 bits, et 8 et 16 bits en mode 16 bits.

Historiquement (à partir du 186, le 8086 ne prenant pas d'opérande immédiat pour PUSH , sur la foi des directives de MASM), il n'y avait qu'un seul PUSH , sur 16 bits, plus le PUSH imm8 (valeur immédiate sur 8 bits). Ce dernier étendait la valeur immédiate à 16 bits et la poussait sur la pile. Tous les POP étaient fatalement sur 16 bits. À l'arrivée des modèles 32 bits, la notion de mode (taille d'opérande par défaut) est apparue. Mais, en restant dans les PUSH de valeurs immédiates pour simplifier, nous en sommes restés aux deux mêmes opcodes, avec possibilité pour l'ancien PUSH imm16 de surcharger la taille de l'opérande (vers 16 bits en mode 32 bits et vers 32 bits en mode 16 bits). Le PUSH imm8 étendant la valeur immédiate vers la taille par défaut du mode. Pour les POP , les opcodes ont été conservés, devenant comme les PUSH surchargeables. Récapitulons :

En mode 16 bits :

  PUSH imm8  : imm8 étendu avec propagation du bit de signe à imm16 , puis PUSH imm16 .

  PUSH imm16  : SP décrémenté de 2.

  PUSH imm32  : même opcode par surcharge, SP décrémenté de 4.

  POP reg/mem16  : SP incrémenté de 2.

  POP reg/mem32  : même opcode par surcharge, SP incrémenté de 4.

 

En mode 32 bits :

  PUSH imm8  : imm8 étendu avec propagation du bit de signe à imm32 , puis PUSH imm32 .

  PUSH imm32  : SP décrémenté de 4.

  PUSH imm16  : même opcode par surcharge, SP décrémenté de 2.

  POP reg/mem32  : SP incrémenté de 4.

  POP reg/mem16  : même opcode par surcharge, SP incrémenté de 2.

Nous voyons immédiatement qu’en mode 32 bits les instructions surchargées, non naturelles pour le mode, ont un grave défaut : elles désalignent la pile. Peut-être sera-t-il préférable d'étendre les données 16 bits en 32 bits pour ne pas utiliser les instructions surchargées.

Pour vérifier, il nous faut travailler avec un assembleur qui accepte les deux modes, à l'inverse de Delphi. Nous avons fait quelques tests sous MASM.

En mode 16 bits :

.MODEL small
.386
...
    push bp
    mov  bp, sp
 
    push  BYTE  PTR 1
    push  WORD  PTR 1
    push  DWORD PTR 1
    pop   eax
    pop   ax
    ;pop   al ;erreur
 
    mov sp, bp
    pop bp

Nous constatons sur le fichier listing (voir CD-Rom) que push DWORD PTR 1 et POP EAX sont préfixées par 66h , surcharge de taille d'opérande. Les deux premières et les deux dernières instructions permettent de s'affranchir d'avoir à équilibrer les PUSH et les POP . Ce code sera plus clair en fin de chapitre.

En mode 32 bits, c'est pratiquement le même code :

.386
.model flat, stdcall
...
    push  ebp
    mov   ebp, esp
    
    push  BYTE PTR 11h
    push  WORD PTR 2222h
    push DWORD PTR 33333333h
    
    ;pop al ;erreur
    pop eax
    pop ax
    pop eax
    
    mov esp, ebp
    pop ebp

Pour observer le comportement de la pile, et en particulier son alignement, nous avons utilisé W32Dasm, pour une fois en mode débogueur. Ce n'est pas un outil de développement indiscutable, mais c'est parfois un bon outil pédagogique. Il sera, dans ce chapitre en particulier, très pratique de visualiser une pile autour de ESP, EBP, etc. Voyons comment l'utiliser. Lancez W32dasm89.exe  ou toute autre version. Celle-ci se lance sans installation, ce qui est toujours pratique pour un outil d'usage peu fréquent. Ensuite dans le menu Disassembler/Open File to Disassemble , choisissez son  .exe 32 bits. Vous trouverez facilement le code au point d'entrée du programme.

Le programme désassemblé
figure 11.05 Le programme désassemblé

Vous constatez l'extension à 32 bits du premier PUSH , ainsi que le second resté sous 16 bits. Vous allez lancer le programme sous mode DEBUG : Debug / Load Process . Apparaissent, en plus de celle de désassemblage qui reste à l'écran, deux fenêtres. La première montre le code et permet diverses actions, dont la progression pas à pas.

La fenêtre de commandes
figure 11.06 La fenêtre de commandes

La fenêtre est représentée ici après l'exécution de quelques instructions, par   F8  . Observez le contenu de la troisième fenêtre.

La fenêtre mémoire et registres
figure 11.07 La fenêtre mémoire et registres

Notez la valeur de ESP avant de commencer à progresser dans le code, puis commencez-le pas à pas en observant la mémoire autour de l'adresse pointée par ESP (la pile...). Remarquez que la mémoire est représentée, comme nous le faisons, progressant vers le bas. Nous avons représenté la situation juste avant l'exécution du premier POP .

La valeur actuelle de ESP se termine par E. Or, les adresses alignées sur 32 bits se terminent par 0, 4, 8 ou C. Donc, c'est le pointeur de pile qui est désaligné. Ainsi que la représentation de la pile à l'écran, puisqu'elle prend ce pointeur de pile comme référence. En d'autres termes, à cet instant précis, les lignes de la représentation ne sont plus alignées sur le DWORD (mais sur les WORD impairs, c'est clair ?). Par exemple, 00000011 qui est représenté sur deux lignes est aligné sur le DWORD. C'est plus clair ? Les POP tels que codés ici vont rétablir l'alignement. De toute façon, les séquences initiales et finales auraient eu ce résultat.

Si vous possédez ce programme, ou tout autre débogueur, vous pouvez tester différentes versions du programme et voir comment se comporte la pile.

S'il est possible de décaler temporairement la pile, il est certainement très coûteux en termes de performance de le faire au sein d'une boucle. Hors boucle, ou quand la microseconde importe peu, il peut être pratique d'utiliser des instructions surchargées :

push DWORD PTR  dwVar
pop   WORD PTR  wVar1
pop   WORD PTR  wVar2

 

Vous pouvez, sur la base de cette manipulation, expérimenter sous Delphi les comportements du compilateur et du programme dans différents cas de figure. Vous constaterez que la moindre erreur dans la restitution de ESP est immédiatement sanctionnée, non pas en tant que telle, mais par une exception immanquablement provoquée très rapidement.

C'est pour cette raison que nous avons utilisé le couple PUSHAD POPAD  ; c’est une bonne précaution, quitte à le transformer en fin de travail pour ne sauver que le strict nécessaire. C’est une instruction relativement coûteuse.

Revenons à Delphi, mettons un point d'arrêt sur le pushad et observons les registres avant et après l'instruction, ainsi que le haut de la pile.

Effet de PUSHAD
figure 11.08 Effet de PUSHAD

Seuls les registres EIP et ESP ont été modifiés par l'instruction. ESP a diminué de 20h , soit 32, ce qui correspond bien à 8 registres de 4 octets. La pile est représentée sommet en bas. Nous trouvons EDI au sommet de la pile. C'est donc bien lui qui a été empilé en dernier. Nous retrouvons bien l'ordre annoncé, de EAX à EDI. ESP est sauvé avec sa valeur avant l'instruction. Nous replongeons dans la documentation, qui semble dire que la valeur récupérée n'est pas prise en compte. Vérifions, en traçant les trois lignes :

pushad
mov [esp + 12], 0
popad

Le débogueur est formel, ESP n’est pas rechargé par le POPAD . Donc, il n'est pas inutile, en expérimentation particulièrement, d'utiliser le code suivant :

push eax
mov eax, esp
pushad
;code foireux
popad
mov esp, eax
pop eax

Nous avons choisi EAX plutôt que EBP, puisque EBP est très souvent utilisé pour accéder aux variables locales, c'est un des buts de ce chapitre de montrer comment.

 

11.3 Les instructions CALL et RET, sous-programmes simples

L’instruction CALL  permet au flux du programme de se dérouter vers un module de code plus ou moins indépendant, appelé sous-programme, routine, procédure, fonction selon le contexte, en favorisant le retour de ce flux à l’instruction suivant le CALL . L’instruction RET  marque la fin d’un sous-programme et rend la main au code appelant.

Les notions générales de procédures, sous-programmes, fonctions sont explicitées à la section Procédures du chapitre consacré au macro-assembleur MASM.

La présentation de l’instruction CALL dans la référence Intel est un pavé. Essayons d’en extraire la substantifique moelle. Pour cela, nous allons nettoyer et conserver ce qui concerne la programmation client, par opposition à la programmation système où justement cette instruction joue son rôle. C’est-à-dire que nous restons au même niveau de privilège et dans la même tâche. En d’autres termes, nous continuons à ne voir que l’image de l’environnement que veut bien nous montrer le système d’exploitation.

Dans ces conditions, nous distinguons deux types d’appels :

  Proche, ou NEAR, au cours duquel n’est modifié que le registre (E)IP, le segment de code CS demeurant inchangé.

  Éloigné, ou FAR, au cours duquel sont modifiées les valeurs de (E)IP et de CS. Une utilisation importante de ce type d’appels pourrait être l'appel des fonctions mises à notre disposition par le système d’exploitation.

Dans un CALL NEAR , l’opérande est soit un registre ou une mémoire de 16 ou 32 bits, soit une valeur immédiate, également en 16 ou 32 bits. Dans un CALL FAR , l'opérande, mémoire ou valeur immédiate, pèse 32 ou 48 bits.

Le choix entre 16 et 32 bits est fait par l’attribut de taille d’opérande.

  Dans le cas du registre ou de la mémoire, il s’agit du déplacement dans CS pour atteindre le sous-programme. Tous les modes d’adressage sont utilisables, ce qui ouvre de larges perspectives.

Dans le cas d’une valeur immédiate, il s’agit d’une valeur signée, à ajouter à EIP en vue de déterminer le déplacement dans CS pour atteindre le sous-programme.

Le registre EIP est poussé sur la pile. À ce moment-là, il contient déjà l’adresse (le déplacement dans CS en fait) de l’instruction suivant le CALL . Le déplacement est calculé en fonction de l’opérande, chargé dans EIP, et le sous-programme peut faire son travail jusqu’à rencontrer un RET.

  Dans un CALL FAR , l’opérande est soit directement un pointeur, c’est-à-dire la donnée immédiate de l’adresse sous la forme 16:32 ou 16:16 , soit une référence mémoire contenant un pointeur. Un pointeur occupe 4 ou 6 octets.

Le registre CS est d’abord poussé sur la pile. Ensuite, c’est EIP qui est empilé. Enfin, CS:EIP est chargé de l’adresse donnée par l’opérande.

Dans le cas d’opérandes 16 bits, le mot haut de EIP est mis à 0.

Le pendant du CALL est le RET . Vu ce qui vient d’être exposé, le RET ne va pas nous étonner. Il existe effectivement les versions NEAR et FAR de cette instruction.

  Dans le premier cas, le RET effectue l’équivalent d’un POP EIP .

  Dans le second, il effectue l’équivalent d’un POP EIP , suivi d’un POP CS .

Équivalent, parce que ces instructions n’existent pas ( POP EIP est NEAR RET ), de plus entre les deux POP , l’adresse serait une combinaison bâtarde. Il faut impérativement que les changements sur CS et EIP soient simultanés. Quand une situation de ce type survient, il faut protéger le code d'une façon ou d'une autre, afin que la paire d'instructions ne puisse être interrompue.

Les deux versions de RET peuvent être suivies d’un opérande, une valeur immédiate sur 16 bits. Celle-ci correspond à un nombre d’octets devant être "POPés" à blanc, c’est-à-dire vers nulle part, au retour vers le programme appelant. Dans le cas d’un passage de n octets en paramètres dans la pile vers le sous-programme, l’appelant va "PUSHer" ces octets avant le CALL . RET n évitera les POP correspondants, ou un ADD ESP, n au retour du sous-programme.

La plupart du temps, les assembleurs se chargeront d’une bonne partie de cette cuisine, puisque nous utiliserons des étiquettes plus souvent que des données numériques. Pour tester un CALL sans écrire de sous-programme, vous pouvez saisir sous Delphi par exemple :

  call @suite1
@suite1:
  pop eax
 
  call @suite2
@suite2:
  add esp, 4
 
// Attention, GAG
  call @suite3
@suite3:
  //nop 
  ret

N’utiliser ces blocs qu’un par un. Le troisième ne peut pas fonctionner. Le premier permet de récupérer l’adresse du code dans EAX.

Ces blocs semblent perturber le débogueur de Delphi. Il suffit de penser à utiliser   F7  à la place de   F8   : avec ce dernier, le débogueur attend un RET qu’il ne pourra pas trouver.

Sous Delphi, le CALL se désassemble en call +$00000000 . Mais, si cette syntaxe est saisie, elle est refusée à la compilation. Le cas est fréquent, le désassembleur du débogueur n'utilise pas la même syntaxe que le compilateur !

Considérons une séquence de programme normale avec appels de sous-programmes : le programme dit principal invoque un sous-programme SP1 (adresse de retour ad_ret2 ) qui, lui-même, invoque SP2 (adresse de retour ad_ret3 ). Au premier appel, l'adresse de retour ad_ret2 est empilée. Puis, au second appel, l'adresse ad_ret3 . Dans SP1 et SP2, nous trouvons des PUSH et des POP .

État de la pile à un instant donné
figure 11.09 État de la pile à un instant donné [the .swf]

Les POP sont représentés sous la forme POP {valx} . Il s'agit d'une simple référence. En effet, nous "POPons" (vers un endroit non mentionné) le sommet de la pile, dont nous espérons qu'il contienne la valeur "PUSHée" de valx .

Nous avons figé l'état de la pile en un point particulier de SP2. À ce moment, nous trouvons au sommet de la pile val3 , puisque val4 vient d'être "POPée". En descendant dans la pile, nous trouvons ad_ret3 , adresse de retour du dernier CALL , puis directement ad_ret2 . En effet, le PUSH et le POP de val2 s'équilibrent avant le CALL . Dans le programme principal, val1 venait d’être "PUSHée", juste avant le CALL . Nous trouvons donc cette variable en dessous de ad_ret2 .

Que va-t-il se passer maintenant ? Avant d'arriver à un premier RET , le programme va rencontrer un POP  ; donc la pile sera une case plus bas. Le RET trouvera alors l'adresse ad_ret3 . Une fois le CALL effectué, ad_ret2 sera au sommet de la pile. Le code ne touchera plus à la pile dans SP1, le RET se passera donc bien, à la suite duquel c'est bien val1 qui sera au sommet et qui pourra être dépilé.

Nous avons représenté également l'historique de la pile pendant toute la séquence. Les zones grisées représentent la partie active de la pile.

Attention, l'assembleur n'est pas un langage de haut niveau, et il n'y a aucune raison d'ordre éthique de revenir d'un CALL par un RET à l'instruction qui le suit.

Rappelons que le SS est automatiquement utilisé dans le cas d'un adressage utilisant (E)SP ou (E)BP comme base.

 

11.4 Variables locales

Il n'est pas nécessaire d'accéder à la pile uniquement par des couples PUSH / POP . Ce serait même une contrainte insupportable. Imaginons la situation suivante :

Notre code est par exemple au début d'une procédure. Le sommet de la pile est vraisemblablement occupé par l'offset d’une adresse de retour, si c'est par un CALL NEAR que nous sommes arrivés à cette procédure, mais peu importe.

Dans un premier temps, considérons que rien d'autre que cette adresse de retour ne nous intéresse dans la pile. Enfin, notre procédure laisse au programme appelant le soin de préserver les registres nécessaires. Nous n'avons donc pas besoin de sauver quoi que ce soit en début de procédure.

Nous allons travailler dans le sous-programme sur des données globales, mais nous aurons besoin de 4 variables locales, var1 ... var4 , de même taille que la largeur de la pile, 32 bits pour fixer les idées.

Ces variables locales sont initialisées à 0 ; nous allons les créer sur la pile, intuitivement c'est le meilleur endroit pour des données qui doivent avoir la même durée de vie que la procédure. La suite montrera que cette intuition est bonne. Créons et initialisons, par exemple par le morceau de code :

;début procédure BIDON
xor eax, eax
push eax  ; var4
push eax  ; var3
push eax  ; var2
push eax  ; var1

Ou :

;début procédure BIDON
push 0  ; var4
push 0  ; var3
push 0  ; var2
push 0  ; var1

 

À partir de là, notre routine va dérouler. Elle va accéder à la pile pour par exemple des sauvegardes très temporaires, des appels d'autres sous-programmes, des interruptions. La question se pose donc de savoir comment accéder facilement aux variables locales, sans détruire ce bel édifice, même quand ces variables se seront enfoncées dans la pile, à une profondeur qui va varier pendant le déroulement de la procédure.

Initialement, ESP pointe sur var1 . Les adresses des 4 variables sont donc, dans l'ordre :

SS:ESP  ;

SS:ESP + 4  ;

SS:ESP + 8  ;

SS:ESP + 12 .

Le problème est que ESP va évoluer. Nous allons donc en faire une photographie, en affectant sa valeur actuelle à un autre registre. Nous choisissons EBP, dont il faut bien dire qu'il est un peu fait pour cela, et codons mov ebp, esp .

État de la pile après mov ebp, esp
figure 11.10 État de la pile après mov ebp, esp [the .swf]

À partir de maintenant et pour la durée de la procédure, il faudra veiller à préserver cette valeur dans EBP, même en cas d'appel de sous-programme. Nous pouvons maintenant, à n'importe quel endroit de la procédure, et sans nous soucier de l'état de la pile, invoquer les 4 variables, par exemple ainsi : mov eax, [ebp + 8] , pour transférer  var1 dans EAX. Nous pourrions, si nous disposions de cette possibilité, créer des macros comme : VAR1 TEXTEQU DWORD PTR [EBP + 8] .

Pourquoi avoir finement remarqué que EBP était prédestiné à cet usage ? Quand l'adressage utilise EBP comme registre de base, alors le segment utilisé par défaut pour le calcul de l'adresse est SS, au lieu de DS (Data Segment) pour les références habituelles à des données. Exactement ce qu'il nous fallait. Sinon, nous étions bien embêtés, obligés de surcharger chaque MOV , ADD , etc.

La procédure a terminé son travail, les variables sont devenues inutiles (c'est justement la notion de variable locale, variable jetable). Avant de rencontrer l'instruction RET , pour qu'il soit correctement écrit, équilibré comme nous en avons signalé la nécessité, le code devrait terminer par 4 POP , pour que le sommet de la pile pointe, comme au tout début, vers l'adresse de retour. C'est une manipulation lourde et inutile. Il suffit, à la place de ces dépilages, de coder :

add esp, (4 * TYPE DWORD)
ret

Il est même possible, à partir de n’importe quel endroit de la procédure, sans avoir à se préoccuper du vécu de la procédure, de coder :

add esp, (4 * TYPE DWORD)
mov esp, ebp
ret

Dans ce dernier cas, ces 3 lignes nous permettent de sortir de la procédure avec les honneurs, même si des PUSH demeurent non équilibrés par des POP .

TYPE DWORD ( sizeof fonctionne également) est remplacé par l'assembleur par 4, la taille en octets du type DWORD .

Cette méthode est tout à fait valide. La méthode généralement utilisée est légèrement différente :

La procédure commence par sauver par empilement certains registres, dont EBP.

Ensuite, ESP est copié dans EBP, puis les variables locales sont initialisées.

Il est possible de ne pas les initialiser, en décrémentant simplement le pointeur de pile de la valeur adéquate. L’espace est ainsi réservé.

État de la pile en cours d'une procédure
figure 11.11 État de la pile en cours d'une procédure [the .swf]

En fin de traitement, le MOV ESP , EBP amène la pile prête à la restauration des registres préservés, puis le RET .

Notons que si des paramètres ont été passés par la pile, ils sont en dessous de l’adresse de retour, donc situés également de façon fixe par rapport à EBP. Les variables locales seront accessibles par des [EBP – x] et les paramètres par des [EBP + x] . Un RET n sera alors souvent utilisé.

À l’aide de macros, travailler en assembleur avec des noms de variables et de paramètres sera facile. Mieux, la directive LOCAL  permet d’automatiser ce processus :

essai   PROC    NEAR param1:DWORD
        LOCAL   var1:DWORD
        LOCAL   var2:DWORD
        LOCAL   var3:DWORD
        .
        .
        ; Initialisations variables locales
        mov     var1, 30
        mov     var2, 0
        mov     var3, 12
        ; Travail sur les variables et paramètres
        add     eax, var1
        sub     param1, eax
        .
        .
        .
        ret 2
essai   ENDP

Ces simples méthodes devraient être absolument maîtrisées avant d’aborder la fin de ce chapitre. Terminons par un récapitulatif sur quelques points à peine évoqués.

Pour passer des paramètres à une procédure, comme en langage de haut niveau, il est possible de passer sa valeur ou son adresse. Comme en LHN, le passage d’une adresse (ou pointeur) sur donnée ou structure est une autre façon de recevoir un résultat, par modification de cette donnée ou structure. Adresses ou valeurs, ce sont des octets qu’il va bien falloir passer.

Il existe trois grands types de méthodes pour cela :

  Par la pile : le programme appelant empile un certain nombre de valeurs juste avant l’appel. Il doit ensuite veiller à nettoyer cette portion de pile (simple réajustement du pointeur de pile), à moins d’utiliser un RET n . Ces emplacements pourraient aussi être utilisés pour renvoyer un résultat, une fois les paramètres exploités, mais ce n’est pas une méthode courante. Il est vrai qu’elle amène à des manipulations supplémentaires.

  Par les registres : tous les registres généraux, à l’exception de ESP et de EBP, peuvent sans problème être utilisés pour passer des valeurs et recevoir des résultats en retour.

  Par une liste d’arguments : c’est tout simplement une structure de données. L’adresse de cette structure peut être passée selon une des deux méthodes précédentes, voire connue par la procédure, un déplacement déterminé dans le segment de données par exemple.

L’autre question récurrente concernant l’appel de procédure est celle de la sauvegarde du contexte, en d’autres termes du jeu de registres. Rien n’est sauvé (hors EIP) automatiquement. Savoir si c’est l’appelant ou le sous-programme qui doit faire cette sauvegarde, et si même elle s’impose, est une question de choix, voire de norme. Il est clair néanmoins que, si un sous-programme est amené à modifier un registre de segment, il doit le restaurer. C’est pratiquement toujours sur la pile que sera sauvegardé le contexte, avec l’aide des différents PUSH et POP . C’est d’ailleurs pour certains registres la seule façon de les manipuler globalement.

Pour Delphi, les registres modifiables et ceux qui ne le sont pas sont donnés par la documentation, dans le cas de code inline et dans le cas de procédures écrites entièrement en assembleur.

 

11.5 Les cadres de pile : ENTER et LEAVE

Les instructions ENTER  et LEAVE  et le concept de cadre de pile ont été développés dans le but de faciliter le travail des compilateurs de langages structurés de haut niveau, le C/C++ et le Pascal en tête.

La visibilité et la durée de vie des données sont des points clés de la structuration. Ces règles peuvent varier d’un langage à l’autre, mais des constantes demeurent.

Chaque procédure crée et initialise des variables quand elle débute. Ces variables meurent avec la procédure. Elles portent le nom de variables locales, ou variables automatiques. Les variables locales statiques sont d'un autre acabit, puisqu'elles sont persistantes entre deux appels. Une variable locale statique peut, par exemple, compter les appels à la procédure ou se mettre à 1 après l'exécution d'un code d'initialisation lors d'un premier appel pour ne pas l'exécuter à nouveau.

Pendant leur durée de vie, ces variables automatiques sont visibles depuis les procédures appelées, et leurs descendantes, selon le même schéma. En revanche, une procédure ne voit pas les données de la hiérarchie des procédures appelantes. Remarquons que placer ces variables dans la pile permet de répondre presque automatiquement à ces exigences. Tout est considéré comme procédure, programme principal compris. Toutes les procédures d’une application peuvent voir les variables de ce programme principal.

Bien entendu, ces règles sont affinées, par la possibilité en particulier de cacher des données. Mais le principe est bien celui-là. Une donnée cachée ou locale (par une décision du compilateur) peut rester structurellement visible.

Cachées, privées, publiques, peu importe. Ce sont des contraintes du compilateur. Il reste qu’il est nécessaire que les diverses procédures puissent accéder aux données de celles qui sont hiérarchiquement au-dessus d’elles.

Le concept de cadre de pile, que nous allons maintenant découvrir, ne sera peut-être pas utilisé directement par nos applications en assembleur, bien que cela soit parfaitement possible. En revanche, quand nous travaillons autour d'un programme compilé en C ou Pascal, nous y sommes confrontés en permanence. Si nous ne cochons pas les options d'optimisation, les procédures sont générées avec un cadre de pile. C’est au moins une bonne raison de les connaître.

Les idées sous-jacentes aux cadres de pile sont intellectuellement intéressantes, dans l'optique de la maîtrise de certaines notions, comme la récursivité par exemple.

 

11.5.1 L’instruction ENTER

Au départ, au très simple concept d'imbrication (nesting), nous lions la notion de niveau lexical : un nombre, entre 0 et 31, qui traduit le niveau de profondeur d'une procédure dans la structure d'imbrication. Rien à voir bien évidemment avec des niveaux de privilèges. L'illustration vaut mieux qu'une longue explication.

Les niveaux lexicaux
figure 11.12 Les niveaux lexicaux [the .swf]

Dans cette image, n'apparaît pas le niveau 0, valeur particulière comme nous le verrons.

En termes de données locales, nous pouvons écrire :

  C peut voir B, A et Principal, mais pas dans D.

  B peut voir A et Principal.

  Aucune procédure ne peut voir dans D.

  D peut voir dans A et Principal, mais ni dans B, ni dans C. Etc.

Ce schéma est lisible quel que soit le langage (il traduit la structuration par blocs). Si nous sommes dans un environnement monotâche pur, dire que C ne peut voir les données de D est une tautologie, puisque C et D ne peuvent pas exister en même temps. Mais nous ne sommes justement pas dans un tel environnement. Ramenées au niveau machine, même en multitâche, deux procédures ne peuvent pas s’exécuter au même moment. En revanche, au niveau du gestionnaire de tâches, elles peuvent être actives ensemble ; leurs données propres coexistent donc réellement.

Dans un LHN, ce mécanisme est géré par le compilateur. Ce que nous souhaitons faire en assembleur, c'est faciliter l'implémentation de cette gestion. C'est ce que propose Intel, au travers des instructions ENTER et LEAVE , et surtout de la notion de cadre de pile. Il faut bien dire que l'explication brute de la fonction ENTER est une véritable punition. Il faut voir que les deux instructions ne sont pas indispensables, elles sont émulables de façon très simple. Beaucoup de programmeurs utilisent LEAVE , mais remplacent ENTER par du code de leur cru.

Il existe donc bien deux niveaux de compréhension, celui de la structure et celui de la mécanique pour installer cette structure. Nous allons donc commencer par décrire ce que nous voulons obtenir, et voir ensuite comment le jeu d'instructions nous y aide, par ENTER et LEAVE .

Étape 1, l'existant

Nous partirons de la dernière représentation de la pile, dans le sous-chapitre Variables locales , que vous pouvez relire au besoin. Nous avons légèrement modifié cette représentation, en ce sens que le contexte est sauvegardé au-dessus de la zone des variables.

État de la pile en cours d'une procédure
figure 11.13 État de la pile en cours d'une procédure [the .swf]

Rappelons que, en début de procédure, nous sauvions d'abord EBP sur la pile, puis nous transférions ESP dans EBP. Donc EBP, durant toute la vie de la procédure, pointe juste en dessous des variables locales, et au-dessus (mais à une distance connue) des paramètres et de la sauvegarde de EBP.

Si chaque appel de procédure respecte cette façon de procéder, la messe est presque dite. Nous trouverons, en plongeant dans la pile, des structures appartenant à des procédures de plus en plus anciennes, et constituées, pour ce qui nous intéresse, d'un bloc de variables locales et d'une sauvegarde de EBP. Cette structure constitue l'embryon d'un cadre de pile , chaque procédure ayant le sien.

Imaginons que la procédure représentée a été elle-même invoquée par une procédure analogue. Alors, l' ancien EBP empilé pointe (dans la pile) vers le même ancien EBP du cadre de pile de la procédure appelante, donc juste en dessous des variables locales de cette procédure appelante. Mieux encore, l' ancien EBP de cette procédure, si celle-ci n'est pas le sommet de la pyramide d'appels, pointe encore dans la pile vers un ancien EBP .

Cet ancien EBP constitue une zone exposant les variables de la hiérarchie supérieure. Elle correspond à ce qui sera, en anglais, nommé display et que nous appellerons en français le... display .

Nous sommes donc parvenus à une structure totalement opérationnelle : à chaque procédure est associé un cadre de pile, qui est une zone de la pile formée du bloc de variables locales automatiques et du display. Cela suffirait, et suffit, dans une majorité d'applications et ne demande pas d'instruction particulière. Tout au plus une macro, qui remplacerait le code :

push ebp
mov ebp, esp
sub esp, taille_du_bloc_de_variables

 

Étape 2 : le cadre de pile normalisé

L'amélioration est une modification du display. En l'état actuel des choses, celui-ci ne donne accès qu'à une seule procédure, de niveau lexical immédiatement inférieur. Pour accéder aux variables des procédures plus anciennes, le programme doit en quelque sorte se livrer à chaque fois à une enquête, à coups d'adressages indirects. Cette recherche est encore compliquée du fait que la valeur de EBP est bloquée au cours de la procédure et que justement EBP est très utile pour accéder simplement à la pile, ailleurs qu'en son sommet.

Il a donc été décidé d’effectuer ces recherches en début de procédure, en ajoutant au display l'ensemble des anciens EBP. Ce display sera ainsi constitué, en d'autres termes, de la collection des cadres de piles accessibles.

EBP, pendant toute la procédure, pointe dans la pile vers la première valeur empilée en début de procédure, soit l’EBP de la procédure appelante. Comme dans la version précédente.

Juste après l’initialisation (par l’instruction ENTER , nous allons le voir), pour une procédure de niveau lexical 4, la pile ainsi que EBP et ESP doivent avoir l’allure figurée sur l’illustration.

Cadre de pile niveau lexical 4
figure 11.14 Cadre de pile niveau lexical 4 [the .swf]

Ce que nous pouvons remarquer immédiatement, c’est une certaine redondance, puisque la procédure s’autoréférence et référence deux fois la procédure appelante. Cela permet en partie de résoudre les imbrications plus complexes.

Étape 3 : ENTER et LEAVE

Maintenant que nous connaissons le but à atteindre, il suffirait presque d’écrire que ENTER et LEAVE accomplissent ce travail.

L'instruction ENTER sert à créer un cadre de pile (stack frame). C'est bien souvent la première instruction de la procédure. Elle prend deux opérandes :

  Une valeur immédiate sur 16 bits, qui correspond au nombre d’octets à réserver dans la pile pour le stockage dynamique de la procédure.

  Une donnée immédiate sur 8 bits (un 1 ou un 0), pour le niveau lexical de la procédure. C'est une valeur immédiate ; le niveau lexical d'une procédure est donc codé en dur dans la procédure.

Par l’instruction ENTER , chaque procédure crée un cadre de pile. Pour cela, elle empile tout d’abord EBP, qui pointe donc sur le cadre de pile de la procédure appelante. Puis ESP est sauvé dans EBP, qui maintenant pointe sur le cadre de pile de la procédure en cours.

Par l’intermédiaire de son niveau lexical, elle connaît le nombre de cadres de pile nécessaires à la fabrication de son display. Or, l’ensemble de ces pointeurs constitue le display de la procédure appelante. Il sera donc très facile de les récupérer, par l’intermédiaire de l’EBP sauvé qui, rappelons-le, pointe sur le cadre de pile de cette procédure mère.

Une fois le display créé, par empilements, ESP est décrémenté de la valeur du premier opérande, la taille du bloc de variables locales. Le tour est joué.

Si le second opérande, le niveau lexical, est égal à 0, l’instruction ENTER fonctionne en mode non imbriqué. Seul EBP est empilé et l’espace pour les variables réservé.

Si une procédure en appelle une autre de même niveau lexical, elle lui fournit logiquement son display, mais privé de l’accès à ses propres variables.

Une procédure qui s’invoque elle-même est dans ce cas de figure. C’est parfait dans le cadre de la récursivité : chaque nouvel appel va créer un nouvel espace de données et n’aura pas accès aux données des appels précédents. C’est exactement ce que nous souhaitons.

L’instruction LEAVE , qui précède le RET mais ne le remplace pas, inverse le processus de ENTER  :

  Copie de EBP dans ESP, ce qui correspond à la destruction des variables locales.

  L’équivalent d’un POP EBP , ce qui restitue EBP, mais de plus positionne ESP à la bonne valeur pour exécuter le RET .

La boucle est bouclée.

Au sein d’une procédure, les instructions ENTER et LEAVE sont positionnées ainsi :

; débute de la procédure
enter taille_var, nivo_lex
.
.
.
.
leave
ret

Des variantes existent pour ENTER  : remplacement par du code ordinaire à base de PUSH et de SUB comme déjà vu, déport un peu plus loin dans la procédure.

Vous trouverez sur le CD-Rom, deux exemples (version 16 bits, mais .386 et version 32 bits flat) de mise en œuvre des instructions ENTER et LEAVE , sur la base de deux procédures de niveaux lexicaux 2 et 3, selon le schéma suivant :

test1 PROC
ENTER 64, 2
; initialisation des variables locales
call test2
LEAVE
ret
test1 ENDP
 
test2 PROC
ENTER 128, 3
; initialisation des variables locales
LEAVE
ret
test2 ENDP

Vous pourrez les utiliser pour tester le code à l'aide d'un débogueur de votre choix, W32Dasm allant très bien dans ce cas précis.

Nous y avons joint un marronnier de l'exemple, un calcul récursif de la factorielle de n, pour mémoire :

Factor(n) = n * (n-1) * (n-2) * ... * 2 * 1

Visualiser les diverses valeurs intermédiaires du calcul dans les profondeurs de la pile est assez éducatif.

Rappelons enfin que ces instructions sont efficaces mais coûteuses, à tel point que les processus d’optimisation les remplacent. Nous pourrons soit les utiliser, soit nous en inspirer pour nos propres programmes.

Nous ne pouvons, en revanche, pas ignorer la structure des cadres de pile si nous travaillons dans l’environnement d’un langage de haut niveau.

Envoyer un message à l'auteur

se connecter pour pouvoir écrire

Ce site est créé et maintenu (laborieusement) par Pierre Maurette
hébergé par 1 & 1