L’assembleur (6)

Mélanger assembleur et C/C++

. Par : Christian. URL : https://www.locoduino.org/spip.php?article287

Dans les articles précédents, nous avons découvert comment programmer en assembleur grâce à quelques exemples simples. La difficulté de programmation ne vous a sans doute pas échappé puisque c’est à vous d’organiser vos données, de les ranger en mémoire, de les sauvegarder quand c’est nécessaire pour éviter qu’elles soient corrompues, etc. Et lorsque le programme ne fonctionne pas, il est tout aussi difficile de trouver ce qu’il se passe. Programmer toute une application en assembleur demande donc un nombre d’heures de travail et de sueur très élevé. Heureusement, il existe une solution simple qui consiste à programmer en C/C++ tout en appelant des fonctions écrites en assembleur lorsque ce langage est vraiment nécessaire. C’est ce que nous allons voir maintenant.

Un exemple simple

Le programme que nous allons écrire envoie un certain nombre de flashes sur la LED_BUILTIN d’une carte Uno et le certain nombre constitue un argument qu’il faut passer à la fonction écrite en assembleur. Le programme est donc constitué d’un corps écrit avec les fonctions d’Arduino ou en langage C et d’une fonction écrite en assembleur. Dans ce projet, il y a un fichier en C (extension .C si on utilise Studio 7 et .ino si on utilise l’IDE) et un fichier en assembleur (extension .S pour Studio 7 et pour l’IDE).

Dans cet exemple très simple, c’est un programme en C qui appelle une routine en assembleur mais le contraire est également envisageable même si nous ne développerons pas cela dans cet article. Plusieurs questions se posent : comment une routine en assembleur peut être visible du programme C ? Comment une fonction en C peut être visible d’une routine en assembleur ? Comment les variables sont passées du C au code assembleur et réciproquement ? Et de plus, l’assembleur et le C peuvent-ils utiliser les mêmes variables globales ?

Il y a certaines règles à connaître qui sont rappelées dans une note téléchargeable sur le site de Microchip « Application Note – Atmel AT1886 : Mixing Assembly and C with AVRGCC ». Un programme de démonstration peut aussi être téléchargé pour voir comment le C et l’assembleur travaillent ensemble.

Organisation du projet

Les fichiers sources doivent être placés dans le même répertoire (généralement le répertoire Sources du projet (src)). Les sources en assembleur doivent avoir l’extension .S ce qui permet au compilateur d’appeler le programme d’assemblage et l’éditeur de liens.

Visibilités des fonctions

Une fonction en langage C doit être déclarée externe dans le code assembleur pour que ce dernier puisse la voir. Exemple :
.extern ma_fonction_C

Une routine en assembleur doit être déclarée globale dans le code assembleur pour être visible du compilateur C. Exemple :
.global ma_fonction_assembly

Enfin, un programme en C qui appelle une fonction écrite en assembleur devra avoir un prototype de fonction déclarant comme externe la fonction en assembleur. Exemple :
extern "C" unsigned char ma_fonction_assembly(uint8_t) ;

Dans le corps de notre programme, le prototype de la fonction écrite en assembleur doit être déclaré « extern » ; de plus, au début de la fonction en assembleur, on utilise la notation « .global » pour qu’elle soit vue par le programme en C et qu’elle puisse accéder aux variables. Maintenant que chaque partie sait qu’elle doit travailler avec l’autre, le problème qui reste à résoudre est de passer des arguments lors de l’appel de la fonction et lorsque la fonction renvoie un résultat.

Utilisation des registres

Autant le code en C que le code en assembleur peuvent accéder aux variables indépendamment. En général, on laisse le code en C gérer les variables et on passe des paramètres au code assembleur par des valeurs ou des références. Ces variables doivent être des variables globales pour le C et déclarées comme externes dans le code assembleur. Par exemple :
unsigned char ma_valeur ;        // dans le code en C

.extern ma_valeur                ; dans l’assembleur

C’est par l’intermédiaire des registres généraux (R0 à R31) que les arguments sont passés lorsque le compilateur avr-gcc transforme le programme C en code exécutable. Il convient donc de savoir comment le compilateur utilise ces registres.

Tout d’abord, le registre R0 est un registre temporaire et peut être utilisé par le code généré par le compilateur. Si le code assembleur utilise ce registre et appelle une fonction en C, il est nécessaire de sauver et restaurer ce registre puisque le compilateur peut l’utiliser.

Le registre R1 est toujours considéré par le compilateur comme contenant zéro ; le code assembleur qui utilise ce registre doit le remettre à zéro avant de retourner au programme principal (return) ou d’appeler du code généré par le compilateur.

Les registres R2 à R17 et R28 et R29 sont appelés « call-saved registers », ce qui signifie que l’appel d’une fonction en C laissera ces registres inaltérés. Par contre, une routine en assembleur appelée depuis le C qui utilise ces registres doit les sauver et les restaurer.

Enfin, R18 à R27 et R30 et R31 sont appelés « call-used registers », ce qui signifie que ces registres sont disponibles pour les deux codes. Une routine en assembleur qui appelle une fonction en C doit sauver et restaurer ces registres qu’elle utilise puisque le compilateur ne sauvera aucun des registres que lui utilise.

Passage d’arguments

Les arguments sont alignés sur des numéros pairs de deux registres successifs : R24, R25 en premier, puis R22, R23 ensuite et ainsi de suite en remontant la liste. Tout dépend donc du nombre d’octets de 8 bits à passer en argument (pour plus d’informations sur les types de variables, voir l’article Types, constantes et variables). Par exemple, pour une fonction avec deux arguments a et b de type uint32_t (4 octets, donc 32 bits au total), a est mis dans R22, R23, R24, R25 et b est mis dans R18, R19, R20, R21. La figure 1 montre comment les choses se passent.

Figure 1
Figure 1
Utilisation des call-used registers par le compilateur AVRGCC

Pour passer un caractère (ou un byte dans le langage d’Arduino), nous avons besoin d’un seul octet de 8 bits, de type uint8_t en langage C : le registre R24 sera utilisé mais pas R25 qui pourtant sera inutilisable (un argument de type char consomme donc deux registres). Si par contre nous voulons passer un entier non signé (uint16_t), nous avons besoin de deux octets et nous utiliserons les deux registres R24 et R25. Si nous avons besoin de quatre octets (cas d’une variable de type long en Arduino ou int32_t en langage C), nous utiliserons les quatre registres R22 à R25. Et ainsi de suite comme le montre la figure 1.

Lorsque tous les registres sont utilisés, les arguments additionnels sont passés par l’intermédiaire de la pile ; ils sont poussés sur la pile (PUSH) dans l’ordre de la droite vers la gauche de la liste d’arguments. Encore une fois, un argument mono-octet (de type char par exemple) consomme deux octets de la pile.

Les valeurs de retour utilisent les registres R25 à R18, dépendant de la taille des valeurs (exemple R24, R25, R22, R23, R20, R21, etc.).

Dans le programme que nous voulons écrire, le nombre de flashes sera faible et un seul octet est suffisant : seul le registre R24 sera utilisé et c’est dans ce registre que la fonction écrite en assembleur ira chercher l’information.

Réalisation du programme avec l’IDE

Passons à la pratique en écrivant ce programme avec l’IDE d’Arduino (on peut bien sûr utiliser un autre éditeur de texte). Recopiez les quelques lignes de programme ci-dessous (je n’ai pas mis de bouton Télécharger car je préfère que vous fassiez la manip vous-même et de plus, il y a très peu de lignes) :

  1. // Programme mélangeant C et ASS
  2. // Christian BEZANGER - 17-12-2020
  3. // Ce programme réalise des séries
  4. // de X flashes sur la LED_BUILTIN
  5. // passage d'argument via R24
  6. // ------------------------------------
  7.  
  8. extern "C" void flash_serie(uint8_t); // prototype
  9.  
  10. uint8_t nbreFlash = 4;
  11.  
  12. void setup() {
  13. // put your setup code here, to run once:
  14. pinMode(LED_BUILTIN, OUTPUT);
  15. }
  16.  
  17. void loop() {
  18. // put your main code here, to run repeatedly:
  19. flash_serie(nbreFlash);
  20. delay(2000);
  21. }

À la ligne 8, on peut voir le prototype de la fonction « flash_serie » qui sera écrite en assembleur. Cette fonction ne renvoie aucun résultat ; elle est donc du type void. Par contre, elle a besoin de savoir combien de flashes il faut produire et l’argument qui le dit est du type uint8_t. Dans le programme, cet argument s’appelle nbreFlash (pour nombre de flashes). Pour déclarer que cette fonction est externe au programme C, la syntaxe est extern "C" placée avant le prototype.

Nous trouvons ensuite le setup pour déclarer la LED_BUILTIN en sortie. Dans la fonction loop, à la ligne 19, nous appelons la fonction produisant les flashes, puis nous attendons deux secondes avant de recommencer. Le corps du programme est terminé.

Pour écrire la fonction en assembleur, nous allons ouvrir un deuxième onglet dans l’IDE (petite flèche dans le coin supérieur droit ou bien CTRL + MAJ + N sous Windows ou CMD + MAJ + N sous Mac OS). Une ligne sur fond jaune nous demande de lui entrer un nom, celui que vous voulez mais en précisant que son extension est .S par exemple « fct_assy.S » (pour function assembly). Dans ce nouvel onglet, recopiez les lignes ci-dessous :

  1. ; Fonction provoquant une série de X flashes
  2. ; X est contenu dans R24
  3. ; Christian BEZANGER - 17-12-2020
  4. ; ------------------------------------------
  5.  
  6.  
  7. .global flash_serie
  8.  
  9.  
  10. flash_serie:
  11. sequence:
  12. ldi r17, 32
  13. out 0x05, r17 ; LED_BUILTIN on
  14. call mon_attente
  15. ldi r17, 0
  16. out 0x05, r17 ; LED_BUILTIN off
  17. call mon_attente
  18. dec r24 ; compteur de séquences
  19. brne sequence
  20. ret
  21.  
  22. mon_attente:
  23. ldi r16,20 ; sert à régler le délai d'attente
  24. clr r8
  25. clr r9
  26. boucles:
  27. dec r8 ; trois boucles imbriquées
  28. brne boucles
  29. dec r9
  30. brne boucles
  31. dec r16
  32. brne boucles
  33. ret

La ligne 7 définit que c’est une fonction globale appelée par un programme en C. La ligne 10 est une étiquette portant le nom de la fonction mais cette dernière commence vraiment à la ligne 11 par l’étiquette « sequence ». De la ligne 11 à la ligne 20, on trouve le programme écrit en assembleur qui provoquera une séquence de plusieurs flashes. On utilise R17 pour charger PORTB avec la valeur 32 ou 0, ce qui revient à allumer ou éteindre la LED_BUILTIN (ceci a déjà été expliqué dans les précédents articles pour le programme Blink). Bien évidemment, après avoir allumé ou éteint la LED, on attend un peu grâce à la fonction appelée « mon_attente » écrite de la ligne 22 à la ligne 33, comme nous le faisions pour le programme Blink.

Comme on l’a dit, le nombre de flashes à produire est contenu dans l’argument passé à la fonction lors de l’appel par le programme C et cet argument est transféré dans le registre R24. Les lignes 18 et 19 montrent que après avoir produit un flash, on décrémente le registre R24 et on recommence la séquence tant que R24 n’est pas égal à zéro. Une fois à zéro, il ne reste plus de flash à produire et on retourne au programme principal grâce au return de la ligne 20.

Enregistrez ce programme en lui donnant un nom ; pour ma part, j’ai choisi « PRGM_ASSEMBLEUR_6 » car c’est le programme qui figure dans l’article « L’assembleur (6) ». Téléversez-le dans votre carte Arduino ; vous devez voir la LED faire quatre flashes, attendre deux secondes et recommencer.

Les figures 2 et 3 montrent les deux onglets de notre programme.

Figure 2
Figure 2
Le corps du programme écrit avec les fonctions d’Arduino
Figure 3
Figure 3
L’onglet de l’IDE montrant la fonction écrite en assembleur

Comme vous le constatez, mélanger du C/C++ et de l’assembleur est assez simple avec l’IDE, il faut seulement que le fichier des fonctions en assembleur ait l’extension « .S ».

Réalisation du programme avec Microchip Studio 7

On peut également mélanger du C et de l’assembleur dans Studio 7. Il suffit d’ouvrir un nouveau projet du type « GCC C Executable Project » (ou C++) et de choisir la cible MCU souhaitée (ici ATmega328P). Bien évidemment, il faudra écrire le corps du programme en vrai C et ne plus utiliser ce qui est propre à Arduino (setup et loop entre autres).

La figure 4 montre le programme en C ; avec Studio 7, le prototype est déclaré « extern » sans rien préciser de plus (contrairement à l’IDE où la syntaxe était « extern "C" »).

Figure 4
Figure 4
La fonction main du projet écrite en C

Dans la fenêtre Solution Explorer, sélectionner le dossier portant le nom de votre projet (ici « Mixing_C_ASM ») comme le montre la figure 5 :

Figure 5
Figure 5
La fenêtre Solution Explorer de Studio 7

Faites un clic droit et choisissez Add > New Item (ou encore CTRL + Shift + A). Dans la fenêtre qui s’ouvre, sélectionner « Assembler File (.s) » et donner lui un nom (ici « fct_assy »), puis validez avec Add, comme le montre la figure 6 :

Figure 6
Figure 6
Fenêtre contextuelle de Studio 7 pour choisir et nommer un nouveau fichier

Un nouvel onglet s’ouvre dans lequel on va écrire le programme en assembleur de la fonction flash_serie, comme le montre la figure 7 :

Figure 7
Figure 7
L’onglet de Studio 7 montrant la fonction écrite en assembleur

Vous pouvez construire votre solution (Build Solution) et téléverser le programme dans votre carte Uno. Pour visualiser que l’opération s’est bien passée, on a choisi un nombre de flashes différent de ce qui a été fait avec l’IDE.

Inclure des routines en assembleur dans un programme écrit en C/C++ devient intéressant dès lors qu’on exploite ce que peut apporter l’assembleur par rapport au C/C++ : un code plus compact, s’exécutant plus rapidement et pouvant respecter un timing bien précis. Pour en arriver là, il est nécessaire de comprendre certaines techniques de programmation liées au côté basique du langage assembleur. C’est ce que nous évoquerons dans le prochain article.