ARM : architecture & assembleur    Enregistrer au format PDF

Un aperçu de l’architecture ARM et de son jeu d’instructions.

Dans cet article il sera question d’aborder l’architecture ARM, ainsi que son jeu d’instructions.


par Tosh

Introduction

L’architecture ARM (Advanced RISC Machines) est un type de processeur présent maintenant sur de nombreux composants embarqués (téléphones, tablettes, routeurs...).

De la version ARMv3 à ARMv7, il s’agissait d’architectures 32 bits, mais depuis ARMv8 sont arrivées les architectures 64 bits.

Dans cet article, il sera question d’ARMv6 (32 bits), qui est la version présente sur le Raspberry PI.

Dans un premier temps, je décrirai rapidement les différents modes dans lequel un CPU ARM peut s’exécuter.

Puis, dans une seconde partie, je présenterai les différents registres généraux que l’on peut utiliser dans le mode User.

Ensuite, une troisième partie sera consacrée au fonctionnement de la Pile sur l’architecture ARM.

L’avant-dernière partie traitera du jeu d’instructions ARM.

Et finalement, l’article se terminera sur les appels de fonctions en ARM, et tentera de montrer comment est traduit un code C en assembleur grâce au compilateur GCC.

Les modes d’exécution

Il y a 9 modes dans lequel un CPU ARM peut s’exécuter :

  • le mode user : un mode non-privilégié dans lequel la plupart des programmes s’exécutent. Il ne sera question que de ce mode dans le reste de l’article ;
  • le mode FIQ : un mode privilégié dans lequel le processeur entre lorsqu’il accepte une interruption FIQ (interruption à priorité élevée) ;
  • le mode IRQ : un mode privilégié dans lequel le processeur entre lorsqu’il accepte une interruption IRQ (interruption à priorité normale) ;
  • le mode Supervisor : un mode protégé pour le système d’exploitation ;
  • le mode Abort : un mode privilégié dans lequel le processeur entre lorsqu’une exception arrive ;
  • le mode Undefined : un mode privilégié dans lequel le processeur entre lorsqu’une instruction inconnue est exécutée ;
  • le mode System : le mode dans lequel est exécuté le système d’exploitation ;
  • le mode Monitor : ce mode a été introduit pour supporter l’extension TrustZones ;
  • le mode Hypervisor : ce mode est utilisé pour ce qui concerne la virtualisation.

Les registres

Il y a 16 registres pouvant être utilisés dans le mode utilisateur (le mode dans lequel les programmes sont exécutés).
Sur ARMv6, tous ces registres sont des registres 32 bits.

  • les registres r0 à r10 sont les registres généraux, pouvant être utilisés pour n’importe quelle opération ;
  • le registre r11 (fp) est le "frame pointer", il sert à indiquer le début du contexte de la fonction en cours (comme ebp sur x86) ;
  • le registre r12 (ip) est l’"intraprocedure register", il sert à stocker temporairement des données lorsque l’on passe d’une fonction à une autre ;
  • le registre r13 (sp) est le "stack pointer", il indique le haut de la pile (comme esp sur x86) ;
  • le registre r14 (lr) est le "link register", il sert à stocker l’adresse de retour lorsqu’une fonction est appelée avec l’instruction "branch with link" (cf. plus bas) ;
  • le registre r15 (pc) est le "program counter", il contient l’adresse de la prochaine instruction à exécuter ;
  • le registre cpsr pour "current program status register" est un registre spécial mis à jour par le biais de différentes instructions. Il est utilisé, par exemple, par les instructions conditionnelles et stocke le mode d’exécution actuel.

La pile

L’architecture ARM possède une pile, tout comme l’architecture x86. Celle-ci est par contre beaucoup plus flexible, car le programme peut choisir la façon dont elle fonctionne.

Il existe 4 types de piles :

  • pile ascendante : lorsque l’on dépose une valeur sur la pile, celle-ci grandit vers les adresse hautes. Le registre sp pointe sur la dernière valeur de la pile ;
  • pile descendante : lorsque l’on dépose une valeur sur la pile, celle-ci grandit vers les adresses basses. Le registre sp pointe sur la dernière valeur de la pile (c’est généralement ce comportement que l’on retrouve dans la plupart des programmes) ;
  • pile ascendante vide : tout comme la pile ascendante, la pile grandit vers les adresses hautes. Par contre, le registre sp pointe sur une entrée vide de la pile ;
  • pile descendante vide : fonctionne comme la pile descendante, sauf que le registre sp pointe sur une entrée vide de la pile.

Voici une image pour mieux comprendre :

Jeu d’instructions

Une instruction ARMv6 est tout le temps codée sur 32 bits (ou 16 bits pour le THUMB mode, cf. plus bas). Voici à quoi ressemble une instruction ARM :

  1.  0x18bd8070  ->  popne   {r4, r5, r6, pc}

À noter que contrairement à l’x86, toutes les instructions ARM doivent avoir leurs adresses alignées sur 4 octets (ou 2 octets pour le THUMB mode).


Mnémoniques conditionnels

Presque chaque instruction ARM peut être exécutée (ou non) suivant une condition. Voici la liste des mnémonique :

  • eq : égal
  • ne : pas égal
  • cs/hs : plus grand ou égal (non-signé)
  • cc/lo : plus petit (non-signé)
  • hi : plus grand (non-signé)
  • ls : plus petit ou égal (non-signé)
  • mi : négatif
  • pl : positif ou nul
  • vs : overflow
  • vc : pas d’overflow
  • ge : plus grand ou égal (signé)
  • lt : plus petit (signé)
  • gt : plus grand (signé)
  • le : plus petit ou égal (signé)
  • al : toujours vrai

Instructions arithmétiques

  • Syntaxe : opconds Rd, Rs, Operand
  • op est un mnémonique parmi : add, sub, rsb, adc, sbc, rsc
  • cond est un mnémonique conditionnel (optionnel)
  • s indique si le registre cpsr est modifié par l’instruction (optionnel)
  • Rd est le registre de destination
  • Rs est le registre source
  • Operand peut être un registre ou une constante.
  • Exemples
  1. addeq r0, r0, #42   ; Ajoute 42 à r0 (si égal)
  2. subs r1, r2, r3     ; Stock le résultat de r2-r3 dans r1 (cpsr modifié)

Télécharger


Instructions logiques

  • Syntaxe : opconds Rd, Rs, Operand
  • op est un mnémonique parmi : and, eor, tst, teq, orr, mov, bic, mvn
  • cond est un mnémonique conditionnel. (optionnel)
  • s indique si le registre cpsr est modifié par l’instruction. (optionnel)
  • Rd est le registre de destination
  • Rs est le registre source
  • Operand est un registre ou une constante
  • Exemples
  1. andle r5, r2, #13    ; Stock le résultat de r2 & #13 dans r5 (si <=)

Instructions de multiplications

  • Syntaxe 1 : mulconds Rd, Rm, Rs
  • Syntaxe 2 : mlaconds Rd, Rm, Rs, Rn
  • cond est un mnémonique conditionnel (optionnel)
  • s indique si le registre cpsr est modifié par l’instruction (optionnel)
  • Rd est le registre de destination
  • Rm est le premier opérande
  • Rs est le deuxième opérande
  • Rn est le troisième opérande pour mla
  • Exemples
  1. mul r5, r0, r1      ; Stock dans r5 le résultat de (r0 * r1)
  2. mla r2, r5, r6, r3  ; Stock dans r2, le résultat de (r5 * r6 + r3)

Télécharger


Instructions de comparaison

  • Syntaxe : opcond Rs, Operand
  • op est un mnémonique parmis : cmp, cmn
  • cond est un mnémonique conditionnel. (optionnel)
  • Rs est un registre pour le premier operand
  • Operand est un registre ou une constante
  • L’instruction cmp soustrait Operand à Rs, et modifie le registre flag
  • L’instruction cmn additionne Operand à Rs et modifie le registre flag
  • Exemples
  1. cmp r0, #5   ; soustrait 5 à r0, et modifie le registre cpsr
  2. cmn r4, r6   ; additionne r4 et r6, et modifie le registre cpsr

Télécharger


Instructions d’accés mémoire

  • Syntaxe 1 : opcondbt Rd, [Rs]
  • Syntaxe 2 : opcondb Rd, [Rs + off] !
  • Syntaxe 3 : opcondbt Rd, [Rs], off
  • op est un mnémonique parmi : ldr, str
  • cond est un mnémonique conditionnel (optionnel)
  • b permet de transferer que le byte le moins significatif (optionnel)
  • t n’est pas utilisé en user mode.
  • Rd est le registre de destination (pour ldr), ou le registre à transférer (pour str)
  • Rs contient l’adresse pour charger ou transférer des données
  • offset est un offset appliqué à Rs
  •  ! indique que l’offset est ajouté à Rs (le registre Rs est alors modifié)
  • Exemples
  1. ldrb r0, [r4]         ; Charge dans r0, le byte de l'adresse r4
  2. str r2, [r1], #42     ; Copie à l'adresse r1, r2, et ajoute 42 à r1
  3. str r1, [r6 + #75]!   ; Copie à l'adresse r6+75 r1, et ajoute 75 à r1

Télécharger


Instructions d’accès mémoire (multi-registres)

  • Syntaxe : opcondmode Rs !, reglist^
  • op est un mnémonique parmis : ldm, stm
  • cond est un mnémonique conditionnel (optionnel)
  • mode est un mnémonique parmi :
    • ia incrémentation de l’adresse après chaque transfert
    • ib incrémentation de l’adresse avant chaque transfert
    • da décrémentation de l’adresse après chaque transfert
    • db décrémentation de l’adresse avant chaque transfert
    • fd pile descendante
    • ed pile descendante vide
    • fa pile ascendante
    • ea pile ascendante vide
  • Rs contient l’adresse où charger/transferer les registres.
  •  ! est utilisé pour écrire dans Rs l’adresse finale (optionnel)
  • reglist est une liste de registre
  • ^ n’est pas utilisé dans le mode user.
  • Exemples
stmfd sp!, {r0}    ; Sauvegarde le registre r0, sur la pile.
ldmfd sp!, {fp,pc} ; Copie dans fp et pc, deux valeurs de la pile.

À noter que les instructions push et pop sont un alias de stmfd sp !, reglist et ldmfd sp !, reglist.


Instructions de branchement

  • Syntaxe 1 : opcond label
  • Syntaxe 2 : bxcond Rs
  • op est un mnémonique parmis b, bl
  • cond est un mnémonique conditionnel (optionnel)
  • label est l’adresse où effectuer le branchement
  • Rs est le registre contenant l’adresse du saut
  • b (branch) effectue un branchement vers le label
  • bl (branch with link) copie l’adresse de la prochaine instruction dans le registre lr avant d’effectuer le branchement
  • bx effectue un branchement vers l’adresse contenue dans Rs, et passe en mode THUMB si le bit 0 du registre Rs est à 1
  • Exemples
  1. bl label ; lr = instruction+4, puis saute vers label
  2. b label  ; Effectue un branchement vers label

Télécharger


Interruption logicielle

  • Syntaxe : swicond expression
  • cond est un mnémonique conditionnel (optionnel)
  • expression est une valeur ignorée par le processeur

swi est l’instruction permettant de générer une interruption logicielle. Elle est utilisée par exemple pour les appels systèmes Linux.
Sur Linux, le numéro de l’appel système est placé dans le registre r7, et les arguments sur la pile.


Le mode THUMB

Un petit mot sur le mode THUMB.

Le mode THUMB a été créé afin de diminuer la taille du code. En effet, les instructions ne sont plus codés sur 32 bits comme le mode normal, mais sur 16 bits.

Pour passer du mode normal au mode THUMB, il suffit d’utiliser l’instruction bx (je vous renvoie au paragraphe concernant les instructions de branchement).

Ce mode peut être très utile afin de supprimer les octets nuls d’un shellcode par exemple, et d’en diminuer la taille.


Autres instructions

Il y a certaines instructions dont je n’ai pas parlé dans cet article (PSR transfert, coprocessor data transfert...), je vous renvoie au manuel ARM si ça vous intéresse.

Appels de fonctions

Dans cette partie, je vais tenter de montrer la forme du code Assembleur généré par GCC.

Tout d’abord, lorsqu’une fonction est appelée, les arguments sont passés dans les registres r0 à r3. Si une fonction possède plus de 4 arguments, alors les autres arguments sont placés sur la pile.

La valeur de retour d’une fonction est quant à elle placée dans le registre r0.

Une fonction commence généralement par un prologue, et se termine par un épilogue. Entre les deux, se trouve le corps de la fonction.

Le prologue se charge de sauvegarder le contexte de la fonction appelante, décrit notamment par les registres fp et lr.

L’épilogue, lui, s’occupe de recharger le contexte de la fonction appelante, puis retourne vers l’adresse située juste après l’appel.

Analysons un bout de code C, pour voir comment est généré le code assembleur (sans aucune options d’optimisation).

  1.     #include <stdio.h>
  2.  
  3.  
  4.  
  5.     void foo(const char *s) {
  6.  
  7.         printf("%s", s);
  8.  
  9.     }
  10.  
  11.  
  12.  
  13.     int main(void) {
  14.  
  15.  
  16.  
  17.         foo("Hello World");
  18.  
  19.         return 0;
  20.  
  21.      }
  22.  
  23.  

Télécharger

Voici le code assembleur correspondant :

  1.         000083cc <foo>:
  2.  
  3.         83cc:       e92d4800        push    {fp, lr}
  4.  
  5.         83d0:       e28db004        add     fp, sp, #4
  6.  
  7.         83d4:       e24dd008        sub     sp, sp, #8
  8.  
  9.         83d8:       e50b0008        str     r0, [fp, #-8]
  10.  
  11.         83dc:       e59f3010        ldr     r3, [pc, #16]   ; 83f4 <foo+0x28>
  12.  
  13.         83e0:       e1a00003        mov     r0, r3
  14.  
  15.         83e4:       e51b1008        ldr     r1, [fp, #-8]
  16.  
  17.         83e8:       ebffffc0        bl      82f0 <_init+0x20>
  18.  
  19.         83ec:       e24bd004        sub     sp, fp, #4
  20.  
  21.         83f0:       e8bd8800        pop     {fp, pc}
  22.  
  23.         83f4:       00008488        .word   0x00008488
  24.  
  25.  
  26.  
  27.     000083f8 <main>:
  28.         83f8:       e92d4800        push    {fp, lr}
  29.  
  30.         83fc:       e28db004        add     fp, sp, #4
  31.  
  32.         8400:       e59f000c        ldr     r0, [pc, #12]   ; 8414 <main+0x1c>
  33.  
  34.         8404:       ebfffff0        bl      83cc <foo>
  35.  
  36.         8408:       e3a03000        mov     r3, #0
  37.  
  38.         840c:       e1a00003        mov     r0, r3
  39.  
  40.         8410:       e8bd8800        pop     {fp, pc}
  41.  
  42.         8414:       0000848c        .word   0x0000848c
  43.  
  44.        

Télécharger

  • En 0x8400, l’adresse de la chaine "Hello World" est placée dans le registre r0
  • En 0x8404, on effectue un branchement vers foo, en sauvegardant l’adresse de la prochaine instruction dans lr (link register)
  • En 0x83cc et 0x83d0 on a le prologue de la fonction foo. On sauvegarde le registre fp (frame pointer) et le registre lr (link register) sur la pile, puis on place dans fp, l’adresse de sp - 4
  • En 0x83d4, on reserve une place sur la pile (8 bytes) pour des variables temporaires.
  • En 0x83d8, on sauvegarde le registre r0 dans l’espace mémoire que l’on vient de réserver sur la pile.
  • En 0x83dc, on place dans r3, l’adresse de la chaine "%s" (0x8488). Puis en 0x83e0, on place r3 dans r0
  • En 0x83e4, on place dans r1 la variable qu’on a sauvegardé sur la pile en 0x83d8.
  • En 0x83e8, on appelle la fonction printf. r0 contient l’adresse de la chaine "%s", et r1 contient l’adresse de la chaine "Hello world".
  • En 0x83ec et 0x83f0, on a l’épilogue de la fonction foo. On commence par remettre la pile dans le contexte de main, puis on restaure fp, puis pc. En restaurant pc, on revient dans la fonction main (car le registre lr avait été sauvegardé lors du prologue, et contenait l’adresse située après l’appel de foo)

Conclusion

L’article touche à sa fin, et j’espère qu’il a su vous donner un petit aperçu de l’architecture ARM ainsi que de son jeu d’instructions.

N’étant pas du tout expert en ARM, il se peut que des éléments soient incorrects ou incomplets. N’hésitez pas à me le faire remarquer afin que je puisse les corriger !

Références

Document(s) joint(s)

Documentations publiées dans cette rubrique