• 30 heures
  • Moyenne

Ce cours est visible gratuitement en ligne.

course.header.alt.is_video

course.header.alt.is_certifying

J'ai tout compris !

Mis à jour le 18/07/2024

Utilisez les procédures et la pile système

Il reste un point important à expliquer pour être sûr que vous avez en main toutes les cartes maîtresses : les appels aux procédures. Comme nous le verrons dans ce chapitre, cet aspect de la programmation se trouve rapidement lié à la notion de pile système.

Structuration en procédure

Premier point important : le « L.A. » ne fait pas la différence entre fonctions, procédures, et sous-programme. Pour lui c’est (encore !) un saut à une adresse. Mais contrairement aux autres sauts, le processeur doit mémoriser l’adresse de départ vers sa procédure car une fois celle-ci traitée, il devra  y revenir. Voyons de nouveau ceci à travers un exemple simple : créons une petite procédure (dans un fichier séparé et inclus au projet) prenant deux arguments en entrée de type char et retournant un entier, et commentons son désassemblage :

Désassemblage
Désassemblage

La procédure est stockée à l’adresse 0x080000242 ; c’est le choix de l’éditeur de liens. Pour la comprendre partons de ce qu’elle fait, à savoir un décalage logique à gauche LSL (❶). Cette instruction LSL Rd, Rn, Rm décale de Rm bits à gauche les bits de Rn et stocke le résultat dans Rd.  Dans notre code, le premier argument d’entrée Val est décalé par le second Nb. En observant le résultat du désassemblage, on constate que l’instruction LSL utilise R2 et R1 pour manipuler ces valeurs. Or on voit que  R2 n’est que la copie de R0 (❷) qui a priori contient le premier argument Val (❸). Ce premier argument a donc été passé par le registre R0. Le second argument Nb est quand à lui directement passé par le registre R1 (❹). Au final le résultat est contenu dans R0 (❺).

La fin est un saut d’un nouveau genre : BX LR. L’instruction de saut BX utilise le contenu du registre spécifié en opérande pour connaître l’adresse où il doit se rendre. Ce qui correspond au return de la fonction (➏). On peut donc légitimement penser que le résultat de la fonction est retourné à travers le registre R0.

Ces observations répondent aux règles suivantes :

  • Ce compilateur utilise R0, R1, R2, R3 pour ranger (dans l’ordre de la liste) jusqu’à 4 arguments d’entrée de la procédure.

  • Le compilateur utilise systématiquement R0 pour retourner un argument. En langage C, il n’y qu’une seule valeur de retour possible associée au return, d’où l’unicité du registre. Si le résultat est codé sur 64 bits (long Int, double), le résultat sera retourné sur les registres R0 et R1.

  • Le processeur stocke dans le registre LR l’adresse de retour d’appel à la procédure.

Observons maintenant un exemple d’appel à cette procédure :

Appel procédure
Appel procédure

Premier constat (❶) : dans cet exemple, le compilateur a choisi R4 pour gérer le compteur de la boucle for.

L’appel à la procédure (❷) correspond à l’instruction BL qui signifie littéralement Branch and Link. Ce qui signifie que PC va être affecté avec l’adresse donné en opérande et que LR va recevoir l’adresse qui suit ce BL (dans notre cas LR sera affecté avec 0x080003A4). Nous pouvons noter que le nom Permutte n'est pas repris par le désassembleur qui ne voit, quand il lit la mémoire code, que l’adresse où se trouve la procédure. Le désassembleur utilise donc un nom générique et le saut est en fait relatif. On voit cependant dans le commentaire qu’il s’agit bien de l’adresse 0x08000242 signalée dans le paragraphe précédent.

La procédure attend ses arguments en R0 et en R1. Nous pouvons voir qu’avant l’appel, il y a les instructions qui permettent de stocker une copie de Carac[1] dans R0 (❸) et une copie du compteur de boucleIndice dans R1 (❹). Au retour la valeur fournie par la procédure dans R0 est rangée dans Carac[2] (❺).

Cet exemple met en lumière le rôle du registre de lien LR. Mais cela soulève un problème : si la procédure Permutte() faisait appel elle aussi à une procédure, comment serait géré LR. Créons une procédure qui ne fait rien de particulier mais qui est appelée parPermutte(), vu qu’elle ne fait rien (NOP est l’instruction qui demande à l’ALU de ne rien faire - NO oPeration) appelons-la PourRire().

Appel de la fonction PourRire()
Appel de la fonction PourRire() 

LorsquePermutte() appelle PourRire() rien de particulier à signaler. On peut juste noter que comme les fonctions sont dans le même fichier, le désassembleur est capable de conserver le nom de la procédure dans le BL. Dans ce cas l’utilisation du registre LR ne pose pas de souci et correspond à ce qui a été vu précédemment.

Si l’on compare maintenant Permutte() avec la version précédente, outre l’appel à PourRire()nous pouvons voir qu’elle est encapsulée entre un PUSH{LR} et un POP {PC}. Ces deux instructions permettent de sauvegarder le registre LR sur la pile système. En effet comme il va être corrompu par l’appel à la procédure PourRire() il est nécessaire de le « mettre au chaud » (PUSH)  pour le restaurer au moment où on en aura besoin (POP).

Fonctionnement de la pile système

PUSH et POP sont deux instructions duales (respectivement d’écriture et de lecture) sur la pile système. La pile système est une structure de données LIFO (Last In First Out) gérée par le registre SP par pré-décrémentation. En clair lorsque le processeur réalise un PUSH {Rx}, le pointeur SP est décrémenté de 4 adresses puis le contenu des 32 bits de Rx est déposé dans ces 4 adresses. Symétriquement POP {Rx} lit les 32 bits qu’il voit à l’adresse contenue dans SP puis incrémente le pointeur SP de 4. Rappelez-vous que c’est le fonctionnement d’un pile d’assiettes dans un placard : chaque fois que vous déposez une assiette (écriture) vous la mettez sur le sommet de pile. Quand vous en reprenez une (lecture) c’est celle qui est sur le haut de la pile qui est prise.

La pile système est donc un zone mémoire commune à l’ensemble de l’application embarquée. Elle ne correspond pas à une zone figée prédéterminée à l’avance dans l’architecture du Cortex-M3. Elle correspond à une réservation spécifique faite au début du fichier startup_stm32f10x_md.s :

Stack_Size      EQU     0x00000400

                AREA    STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem       SPACE   Stack_Size
__initial_sp

Sans entrer dans le détail c’est un espace mémoire de 0x400 (soit 1024) octets par défaut dont l’adresse la plus haute sera repérée par l’étiquette _initial_sp. Si dans vos projets il s’avère nécessaire d’avoir une dimension de pile plus grande, cette valeur peut parfaitement être modifiée.

Voyons maintenant l’utilisation standard d’une pile système à travers l’exemple suivant :

❶
PUSH {R1}
PUSH {R7}
❷
…
POP {R3}
POP{R1}
❸

Voici l’évolution de la pile entre ces 3 points d’observation

Évolution de la pile
Évolution de la pile

Au départ (❶) le pointeur de pile est en haut de la structure. SP pointe soit sur le sommet de la pile, soit sur la dernière valeur écrite.

Après les deux PUSH (❷), le pointeur de pile SP est diminué de 8. Les accès se font toujours en 32 bits soit 4 adresses consécutives. L’écriture est faite après la décrémentation du pointeur, la valeur précédemment pointée (xxxxxxxx) est donc toujours présente dans la structure et le pointeur repère maintenant la dernière valeur écrite, à savoir ici le contenu de R7.

Les deux POP (❸) ne sont pas vraiment cohérents, mais cela est fait exprès dans cet exemple. La cohérence voudrait que l’on restaure une valeur dans le registre qui a été sauvegardé, mais rien n’interdit de faire cette restauration dans un autre registre. Ainsi,  dans cet exemple R3 est affecté avec l’ancienne valeur de R7. Par contre le second POP permet de retrouver la précédente valeur de R1. On notera que R7 est représenté avec une valeur inconnue parce que l’on suppose que sa valeur a pu être changée entre les PUSH et les POP. Suite à ces deux lectures (qui se font avant que le pointeur ne se déplace), le pointeur est revenu 8 adresses plus hautes. Les données ne sont pas à proprement parler effacées, c’est juste le pointeur qui a été déplacé. Seule une prochaine écriture pourra effacer ces données.

Au cours d’un programme l’utilisation en miroir de PUSH et POP permet donc de faire des sauvegardes temporaires de données. Si on reprend l’exemple désassemblé précédent, le compilateur voit que le registre LR va être corrompu pendant la procédure. Il choisit donc de le sauvegarder avec un PUSH{LR}. On s’attend (cohérence évoquée dans le paragraphe précédent) à voir un POP{LR} à la fin de la procédure juste avec le BX LR qui permet de quitter Permutte() pour revenir au point d’appel dans la procédure main. En réalité BX LR équivaut à un transfert du contenu de LR dans PC, donc à un saut. Le compilateur est très malin et le sait. Donc restaurer le contenu de LR sauvegardé sur la pile directement dans PC permet de concaténer ces deux instructions en une seule (POP {PC} = POP {LR} + BX {LR}). C’est toujours une instruction de gagnée même si cela devient moins lisible pour le programmeur néophyte.

En dehors de son utilité pour les sauvegardes, la pile système peut avoir deux autres utilités majeures :

  • Le passage d’arguments à une fonction. Comme nous l’avons évoqué précédemment, le compilateur utilise les 4 registres R0 à R3 pour déposer les arguments d’entrée d’une procédure. Dans le cas où les arguments ne tiennent pas sur ces 4 seuls registres le compilateur choisira de déposer les arguments supplémentaires sur la pile système. La procédure appelée pourra ensuite aller lire ces données sur la pile système, via le pointeur SP.

  • La création de variables locales à une procédure. Dans la grande majorité des cas, le compilateur utilise naturellement les registres généraux pour stocker les données locales (périssables entre 2 appels à la procédure). S’il n’a pas assez de place dans les registres (typiquement création d’un tableau) il va utiliser la pile système comme zone de stockage temporaire pour les données locales. L’exemple de désassemblage suivant détaille ce fonctionnement :

    d

    À l’entrée dans la procédure, un « trou » est créé sur la pile en décalant le pointeur de pile vers le bas (❶). Ce trou de 40 octets (0x28) est la zone de stockage pour les 10 entiers du tableau Tempo. À la fin de la procédure, ce « trou » sera rebouché en remontant le pointeur de pile SP de 40 valeurs(❷). Dans la procédure, lorsqu’il y a nécessité de lire ou d’écrire cette variable locale, cet accès se fait en relatif par rapport au pointeur de pile. Ici (❸) on accède au 4e élément du tableau avec un décalage de 12 octets (le premier élément est l’élément 0 qui se trouve à un décalage nul). Attention toutefois, si des PUSH/POP sont effectués entre temps, les décalages ne seront plus les mêmes car le pointeur de pile aura évolué. Ce n’est pas forcément facile à suivre mais le compilateur sait normalement très bien le faire.

À la fin de ce chapitre, vous êtes capable :

  • d'expliquer comment se déroule au niveau du code assembleur l'appel d'une procédure,

  • de décrire le passage d'arguments lors de l'appel d'une procédure,

  • de retracer l'état de la pile système.

Exemple de certificat de réussite
Exemple de certificat de réussite