L’assembleur (3)

Le jeu d’instructions des AVR

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

Dans cet article, nous allons commencer à découvrir le jeu d’instructions des microcontrôleurs AVR et à construire un premier programme. Un simple éditeur de texte peut être suffisant pour écrire et enregistrer ce programme, mais si vous voulez le voir fonctionner, il vous faudra un programme d’assemblage qui transforme votre texte en octets à envoyer dans la mémoire programme du MCU et donc un programmateur. Ceci sera l’objet de la quatrième partie ; dans cet article, nous continuerons la théorie mais cette phase est nécessaire avant de passer à la pratique. Encore un peu de patience et vérifiez bien que vous avez de l’aspirine sous la main !

Avant d’entrer dans le vif du sujet, voici la réponse à la petite question posée en fin de l’article 2. Le PC doit être capable d’adresser l’ensemble de la mémoire flash de programme. Sur un MCU ATmega328P, cette mémoire flash va de 0x0000 à 0x3FFF qui en binaire s’écrit 11 1111 1111 1111 (pour faire facilement la conversion entre hexadécimal et binaire, pensez à utiliser la calculatrice de votre ordinateur en mode « Programmeur »). On voit qu’il faut quatorze bits pour représenter cette adresse donc la taille du PC est de quatorze bits. Pour les ATtiny que nous utilisons à LOCODUINO, la taille du PC est de 10/11/12 bits en fonction de la quantité mémoire. Pour le MCU qui équipe la carte Mega, la taille du PC est de 17 bits (ATmega2560 équipé de 256Kbytes donc 128 K X 16, l’adresse la plus haute étant 1FFFF). Je pense que vous aviez trouvé la réponse.

Nombre d’instructions

Comme nous l’avons dit, les microcontrôleurs AVR disposent d’un jeu réduit d’instructions. Certains livres disent un peu plus de 120. Si on se fie aux datasheets des MCU les plus fréquemment utilisés à Locoduino, nous trouvons 120 instructions pour les séries ATtiny (84 et 85), 131 instructions pour l’ATmega328P et 135 instructions pour l’ATmega2560. Ce nombre n’est donc pas si réduit que cela mais il l’est en comparaison du nombre d’instructions des MCU CISC. Et ce nombre est un bon compromis entre la souplesse de programmation et le temps d’apprentissage pour bien comprendre l’ensemble du jeu d’instructions.

Quasiment toutes les instructions sont codées sur un mot de 16 bits (voir plus loin) et ne nécessitent qu’un seul cycle d’horloge pour être exécutées. Il y a bien-sûr des exceptions qui en nécessitent deux mots ou plus, comme les instructions d’appel et de retour de sous-programme, plus complexes, qui peuvent en nécessiter jusqu’à cinq dans certains cas ; tout cela est indiqué dans la datasheet, ce qui vous permet d’avoir une idée assez précise du temps d’exécution de votre programme (on rappelle qu’un cycle d’horloge dure 62,5 ns avec un quartz à 16 MHz).

Structure en pipe-line

Les microcontrôleurs AVR utilisent une structure en pipe-line qui leur permet, pendant qu’ils exécutent une instruction, d’aller chercher l’instruction suivante en séquence, ce qui accélère d’autant le temps d’exécution d’un programme. L’exécution d’un branchement dont la condition est vraie fait que l’instruction lue en séquence n’est pas la bonne et le pipeline doit être rechargé avec l’instruction à la cible du branchement. La conséquence est qu’un branchement prend 1 cycle si la condition est fausse et 2 si la condition est vraie.

La figure 1 montre le chronogramme de ce processus qui est transparent pour vous et n’a pas d’influence sur la façon de programmer.

Figure 1
Figure 1
Travail en Pipe-Line (source Microchip).

Classification des instructions

Les instructions peuvent être classées en plusieurs catégories :

  • Les instructions arithmétiques et logiques
  • Les instructions de branchement
  • Les instructions de manipulation de bits ou de tests de bit
  • Les instructions de transfert de données
  • Les instructions de contrôle du MCU

Les datasheets des microcontrôleurs contiennent un tableau résumant le jeu d’instructions du composant classées par catégories, ce qui permet d’avoir une vue d’ensemble. Ce genre de tableau doit devenir votre référence, et il est important de connaître le paragraphe de la datasheet où on le trouve (son titre est « Instruction set summary »). À titre d’exemple, la figure 2 montre une partie du jeu d’instructions de l’ATmega328P. La première colonne donne la notation mnémonique de l’instruction (par exemple ADD pour additionner). La deuxième colonne donne les opérandes, souvent deux registres de travail R0 à R31 (Rd est le registre de destination du résultat, ce qui veut dire qu’à la fin de l’opération, le résultat sera stocké dans ce registre ; Rr est un autre registre et bien évidemment les lettres d ou r sont à remplacer par le numéro des registres utilisés). Parfois, un registre peut être remplacé par une valeur littérale K. La troisième colonne explique en quoi consiste l’instruction et la quatrième colonne nous en donne une représentation mathématique (par exemple Rd et Rr sont additionnés et le résultat est mis dans Rd (voir le sens de la flèche)).

Les deux dernières colonnes sont très intéressantes pour le programmeur : d’une part, on voit sur quels flags (les bits de SREG) l’instruction agit pour les mettre à jour, ce qui permet de savoir ce qui peut arriver (un débordement par exemple), d’autre part la durée en cycles d’horloge correspondant à l’exécution de l’instruction.

Ce tableau n’est hélas pas toujours suffisant, surtout quand on débute en assembleur AVR. En effet, lorsqu’une instruction fait appel à une valeur littérale K, celle-ci peut être limitée à un certain intervalle, ce que ne dit pas le tableau. Heureusement, le document du site Microchip « AVR Instruction Set Manual » explique en détail chaque instruction et comment elle est codée, un document que devrait posséder tout programmeur en assembleur [1].

Figure 2
Figure 2
Résumé du jeu d’instructions (source Microchip).

Codage d’une instruction

Comme nous l’avons dit, les instructions sont représentées par un seul mot de 16 bits (excepté CALL, JMP, LDS et STS qui en utilisent deux). Ces 16 bits sont organisés d’une certaine façon afin de transmettre toutes les informations nécessaires de l’instruction à l’unité de traitement qui va exécuter l’instruction. Un certain nombre de bits est réservé pour le code opératoire qui caractérise ce que fait l’instruction (une addition, une incrémentation, etc.), les autres bits servent alors à coder le ou les numéros de registres de travail utilisés (5 bits sont nécessaires par registre) ou bien un registre d’entrée-sortie (sur 6 bits) ou encore d’autres choses en fonction des différentes possibilités d’adressage. Inutile de rentrer dans le détail car c’est le programme d’assemblage qui va s’occuper de générer les mots de 16 bits correspondant aux instructions de votre programme. La figure 3, extraite du document « AVR Instruction Set Manual » cité plus haut, présente le codage d’une instruction à adressage direct double registre. On remarque sur cet exemple que les 5 bits codant les registres ne sont pas forcément à la suite les uns des autres.

Figure 3
Figure 3
Codage d’une instruction sur 16 bits (source Microchip).

Règles d’écriture d’un programme en assembleur

Chaque programme d’assemblage a sans doute ses propres règles à respecter et en fonction de celui que vous choisirez pour transformer votre texte en code binaire, le mieux sera de vous référer à sa documentation. Il existe néanmoins des grands principes qu’on retrouve d’un assembleur à l’autre.

Par exemple, le listing de votre programme contient quatre colonnes : la première est le champ d’étiquette, la deuxième pour la notation mnémonique de l’instruction, la troisième pour les opérandes et la quatrième pour le commentaire qui commence par un point-virgule (certains assembleurs acceptent le double slash ou le slash étoile comme les commentaires du langage C). Chaque colonne est séparée de la suivante par un caractère de tabulation.

Voici par exemple une ligne de programme en assembleur :

Start :	DEC	R8	; on décrémente le registre R8

On reconnaît bien l’étiquette (il n’y en a pas à chaque ligne), le mnémonique (DEC), l’opérande (registre à décrémenter, ici R8) et le commentaire.

Cette règle de présentation n’est pas toujours respectée ; avec l’assembleur de Studio 7, on peut aussi écrire :

Start :
dec r8	; on décrémente le registre R8

Sur cet exemple, un espace a remplacé le caractère de tabulation et l’emploi de majuscule n’est pas obligatoire. De plus, comme vous pouvez le constater, Studio 7 connaît très bien le nom des registres et il n’est donc pas nécessaire de connaître l’adresse mémoire du registre ; ceci simplifie aussi énormément le travail de rédaction du programme. Un autre avantage de Studio 7 est qu’il ajoute aussi de la couleur, du bleu pour les mnémoniques, du vert pour les commentaires, etc., mais c’est sans doute le cas de tous les programmes d’assemblages modernes.

Voici quelques informations qui concernent le programme d’assemblage avr-as de Microchip :

Les directives de préprocesseur commence par le symbole dièse « # ». Le caractère underscore « _ » est autorisé dans la définition d’une constante pour accroître la lisibilité (exemple : 0b1100_1010 ou bien 0b_11_00_10_10_, mais par contre 0_b11001010 n’est pas valide). Une instruction peut être écrite sur plusieurs lignes si le dernier caractère est un backslash « \ », ce qui est particulièrement utile pour la lisibilité des macros ou d’autres directives. On peut aussi mettre plusieurs instructions par ligne mais ce procédé n’est pas recommandé. Et plein d’autres choses encore qu’il vaut mieux voir au fur et à mesure avec des exemples de programmes.

Premier programme : Blink bien sûr !

Voici le moment d’écrire un premier programme et pour ne pas déroger aux bonnes vieilles traditions, nous allons faire clignoter la LED de notre carte Uno. Cette LED est reliée à la sortie 13 de la carte Uno ; on peut aller chercher le schéma de construction de la carte Uno sur le site d’Arduino pour constater que cette sortie 13 est en fait la broche 19 du MCU qui correspond à PB5 (bit 5 du PORTB) comme le montre la figure 4 [2].

Figure 4
Figure 4
Partie du schéma de la carte Uno (source Arduino).

Tout comme dans le programme Blink en C, la première chose à faire est de configurer en sortie cette sixième ligne du PORTB (voir note 2 en fin d’article) et pour cela, il faut mettre à 1 le bit 5 du registre de direction du port qui s’appelle DDRB. Comme le montre la figure 5, l’instruction qui fait cela est SBI P,b (Set bit).

Figure 5
Figure 5
Instructions SBI et CBI (source Microchip).

L’instruction dans notre programme est donc sbi DDRB,5. Cette instruction réalise la même chose que le setup de Blink.

Maintenant que nous avons initialisé la ligne du port en sortie, il ne reste plus qu’à écrire une boucle pour allumer la LED, attendre un peu, éteindre la LED, attendre un peu ; c’est ce que faisait la fonction loop de Blink. Cette boucle commence avec l’étiquette « start : » et se termine avec l’instruction RJMP (Relative jump) qui force le programme à revenir exécuter l’instruction située à l’adresse représentée par « start : ».

Pour allumer la LED, il suffit d’écrire un 1 dans le PORTB au niveau du bit 5 et pour cela on utilise la même instruction SBI. Pour éteindre la LED, il suffit de remettre ce bit à 0 ; pour cela, on utilise l’instruction CBI P,b (Clear bit, voir figure 5 également).

Entre ces deux instructions, il faut attendre un certain délai ; pour cela, on va construire un petit sous-programme qui commence par l’étiquette « delay : ». Ce sous-programme utilise trois boucles imbriquées faisant intervenir les registres R8, R9 et R16 qu’il suffit de décrémenter. Avant de l’appeler, on charge le registre R16 avec une certaine valeur avec l’instruction LDI R16,40 (cette valeur en décimal pouvant être adaptée pour régler la fréquence de clignotement). Ensuite, on appelle le sous-programme par l’instruction RCALL (Relative call).

Voyons maintenant comment fonctionne le sous-programme. Les registres R8 et R9 sont mis à 0 dès le début, donc dès qu’on les décrémente une première fois, ils passent à 255. Les décrémentations suivantes les feront atteindre 0, mais tant que ce n’est pas le cas, le branchement fait revenir le sous-programme à l’étiquette « loop : ». L’instruction est BRNE (Branch if not equal, sous-entendu zéro). Logiquement, il faudrait préciser à quelle adresse le MCU doit se brancher mais le programme d’assemblage remplacera l’étiquette par la bonne adresse.

Le registre R16 a un rôle un peu différent car on commence par le charger avec une certaine valeur (ici 40 en décimal). L’instruction pour le faire est LDI (Load immediate) comme le montre la figure 6.

Figure 6
Figure 6
Instruction LDI (source Microchip).

Ensuite, ce registre joue son rôle de façon identique en étant décrémenté à l’intérieur du sous-programme delay mais le fait de lui avoir donné une certaine valeur initiale fait qu’on peut jouer sur le temps mis par delay pour se terminer ; si on augmente la valeur, le temps augmente, si on la diminue, le temps diminue.

Nous avons utilisé le registre R16 car l’instruction LDI est une des rares instructions (avec SBCI, SUBI, CPI, ANDI et ORI) qui ne peut s’appliquer qu’à la deuxième moitié des registres de travail (R16 à R31) comme nous l’avions déjà mentionné plus haut.

L’instruction qui permet d’appeler le sous-programme est RCALL (Relative subroutine call) et l’instruction qui permet de revenir au programme principal à la fin du sous-programme est RET (Subroutine return).

Nous aurions pu utiliser CALL (Direct subroutine call) à la place de RCALL mais nous aurions perdu un cycle horloge : RCALL est plus rapide car l’instruction contient la distance à laquelle se trouve le sous-programme alors que CALL contient l’adresse absolue. RCALL est par conséquent codée sur 1 mot alors que CALL en nécessite 2. Ici RCALL est possible car la distance entre l’appel et le sous-programme est de moins de 2 kilo-octets. De même nous aurions pu utiliser l’instruction JMP (Direct jump) à la place de RJMP.

La figure 7 montre le listing complet de notre programme.

Figure 7
Figure 7
Une façon de coder le programme Blink en assembleur.

Avec deux fois la valeur 40 dans le registre R16 avant d’appeler le sous-programme delay, nous obtenons un clignotement de fréquence approximative 1 Hz (en fait, on compte 124 allumages de la LED en 120 secondes ; voir ci-dessous). Vous pouvez bien-sûr jouer sur ces valeurs pour diminuer ou augmenter les périodes allumage ou extinction dans certaines limites bien entendu.

Calcul du délai de temporisation

Pour calculer le temps d’exécution du sous-programme delay, il faut connaître le nombre de cycles que requiert chaque instruction pour être exécutée et se rappeler qu’un cycle dure 62,5 nanosecondes avec un quartz de 16 MHz comme on en trouve sur une carte Uno. Les boucles sont constituées de deux instructions : DEC et BRNE. DEC prend un cycle et BRNE prend deux cycles s’il y a branchement vers l’étiquette et un cycle s’il n’y a aucun branchement (sortie de boucle). La première boucle (sur R8) tourne 255 fois avec branchement et une fois sans branchement, soit :
255 x (1 + 2) + (1 + 1) = 767 cycles.
La boucle suivante (sur R9) fait de même :
255 x (767 + 1 + 2) + 767 + 1 + 1 = 197119 cycles.
La dernière boucle (sur R16) le fait 40 fois :
39 x (197119 + 1 + 2) + 197119 + 1 + 1 = 7884879 cycles.
Il faut ajouter deux cycles en début de sous-programme correspondant à la mise à zéro de R8 et R9, et quatre cycles correspondant au RET en fin de sous programme [3]. Cela fait donc un total de 7 884 885 cycles, qu’on multiplie par 62,5 nanosecondes, le temps de cycle du microcontrôleur à 16 MHz, et on obtient une temporisation de 0,492805312 seconde.

Dernières remarques pour le fun

Le programme d’assemblage transforme notre programme écrit avec des notations mnémoniques en valeurs binaires. La première instruction est du type SBI (Set Bit in I/O Register) dont la syntaxe est « SBI A, b » où A représente un registre d’entrée-sortie avec une adresse comprise entre 0 et 31, et b le numéro du bit à positionner à 1. Son codage en binaire serait 1001 1010 AAAA Abbb (en effet, il faut 5 bits pour coder l’adresse du registre comprise entre 0 et 31 et 3 bits pour coder le numéro du bit à positionner qui est compris entre 0 et 7). Comme DDRB a pour adresse 0x04 (voir datasheet du MCU ATmega328P), notre première instruction SBI DDRB, 5 est alors représentée en binaire par 1001 1010 0010 0101. Cette valeur vaut 9A25 en hexadécimal.

Cependant, ce mot de 16 bits qui code l’instruction est stocké en mémoire flash avec l’octet de poids faible dans l’adresse faible et l’octet de poids fort dans l’adresse forte, ou si vous préférez l’octet de poids faible en premier suivi de l’octet de poids fort [4]. La figure 8 représente le fichier HEX de notre programme où le code exécutable du programme a été surligné (pour plus de détails sur les fichiers HEX au format Intel, voir l’article Du sketch à l’exécutable). On remarque que le mot de 16 bits 9A25 apparaît sous la forme des deux octets qui le composent 25 et 9A.

Figure 8
Figure 8
Fichier HEX du programme montrant le code exécutable qui a été surligné.

Enfin, la figure 9 montre la cartographie de la mémoire flash donnée par Studio 7. L’octet de poids faible 25 est stocké à l’adresse 0x0000, suivi de l’octet de poids fort 9A stocké à l’adresse 0x0001. Notre programme occupe 34 octets (17 instructions) et le dernier octet (95) est stocké à l’adresse 0x0021.

Figure 9
Figure 9
La cartographie de la mémoire Flash donnée par Studio 7.

Notre programme Blink à la sauce assembleur ne contient que 17 instructions et n’occupera donc que 34 octets dans la mémoire de programme. Enfin, à la condition d’avoir un moyen pour téléverser nos octets dans la carte Uno. C’est ce que nous verrons la prochaine fois.

[1Pour accéder à ce document, voici l’adresse : http://ww1.microchip.com/downloads/...

[2Les informaticiens ont l’habitude de compter en partant de 0. Le bit 5 correspond donc au sixième bit du port ou encore à la sixième ligne d’E/S du PORT

[3L’instruction RET prend 4 cycles si la taille de la mémoire programme est au plus 128 KB, et 5 cycles au-delà jusqu’à 8 MB.

[4Cette particularité est appelée little-endian alors que l’inverse est appelé big-endian (poids fort en premier suivi du poids faible) ; certains microcontrôleurs peuvent implémenter les deux systèmes.