Interfaçage C - Assembleur
De nos jours, le développement d’applications se réalise presque exclusivement en C/C++. Le langage assembleur ne s’utilise plus que pour l’implémentation de certaines fonctions très proches du processeur. En suivant quelques règles et conventions, il est assez simple d’interfacer des routines développées en assembleur avec des fonctions réalisées en C/C++, ou vice-versa.
Conventions
Petit retour en arrière ! En programmation C/C++, la réalisation d’un programme modulaire nécessite bien naturellement un fichier d’implémentation (.c/.cpp file) pour les classes et leurs méthodes réalisant la fonction de l’application, mais également d’un fichier d’en-tête (.h/.hpp file). Ce fichier d’en-tête déclare quant à lui les parties publiques du module, tels que les types, les classes, les constantes ou les fonctions. Ces déclarations sont indispensables aux autres modules du programme afin qu’ils puissent connaître l’interface et interagir avec le module, son API (Application Program Interface).
Lors de la génération du code binaire, le compilateur doit respecter une ABI (Application Binary Interface) définissant des conventions d’appel des fonctions et méthodes lié à l’architecture du processeur cible. L’ABI définit également le rôle de chaque registre du processeur à usage général, tels que le passage d’arguments, le retour du résultat de la fonction ou des variables temporaires, ainsi que la responsabilité de leur intégrité, de leur sauvegarde, ceci aussi bien du côté de l’appelant (caller) que du côté de l’appelée (callee). Il définit également la structure de la pile, notamment l’emplacement des arguments de la fonction. Pour résoudre les dépendances entre les modules, l’appel des fonctions et l’accès aux variables globales, l’éditeur de liens doit connaître l’identifiant de leur symbole, de leur nom.
Identifiant des fonctions
C et C++ n’utilisent pas les mêmes conventions pour la génération des identifiants des fonctions (Name Mangling). C n’utilise que le nom de la fonction comme identifiant. En revanche C++ a des exigences plus strictes, dues en particulier au polymorphisme des fonctions (même nom de fonction, mais des arguments différents).
Pour interfacer des routines assembleur avec des fonctions C/C++, les
conventions C sont généralement utilisées. Là également, la réalisation
d’un fichier d’en-tête (.h file) est nécessaire. Afin de respecter les
conventions C, alors que l’interface est utilisée par du code C++, il
est indispensable d’enrober la déclaration des fonctions avec la
directive extern "C" { }
. Pour que l’interface puisse également être
employée par du code C, il faut tester si l’interface est compilée par
un compilateur C ou un compilateur C++ en testant le symbole
__cplusplus
.
L’exemple ci-dessous présente la déclaration de la fonction my_routine
dans un fichier d’en-tête ainsi que le squelette de son implémentation
en assembleur.
#ifdef __cplusplus
extern "C" {
#endif
extern void my_routine(void);
#ifdef __cplusplus
}
#endif
en assembleur
.text
.align 2
.global my_routine
.type my_routine, %function
my_routine:
bx lr
.size my_routine, .-my_routine
Usage des registres
Le document “Procedure Call Standard for the Arm Architecture”1 décrit et définit l’utilisation des 16 registres à 32 bits des processeurs ARM visibles pour les jeux d’instructions ARM, Thumb et Thumb-2.
Les registres R0 à R3 servent à passer les quatre premiers arguments d’une fonction. Les registres R0 et R1 servent également à retourner le résultat de la fonction. Ces quatre registres peuvent également être utilisés librement sans devoir préalablement sauver leur contenu sur la pile (scratch register).
Les registres R4 à R10 servent à stocker le contenu de variables locales d’une routine. Leur contenu doit être préservé. Si la routine souhaite en faire usage, elle doit impérativement sauver leur contenu sur la pile avant de les utiliser.
Selon les options de compilation, le registre R11 sert de pointeur de trame (Frame Pointer). Il peut cependant également être utilisé pour stocker le contenu de variables locales. Dans tous les cas son contenu doit être préservé.
Le registre R12 (IP) peut être utilisé librement sans devoir préalablement sauver son contenu (scratch register).
Les registres R13
, R14
et R15
(SP
, LR
et PC
) ont des rôles
bien définis et doivent être manipulés en conséquence. Concernant le
pointeur de pile (SP
), l’ABI spécifie que celui doit toujours être
aligné sur 4 octets (SP % 4 == 0
), mais qu’aux interfaces publiques
cette contrainte est étendue à 8 octets (SP % 8 == 0
).
Appel de fonction
Les jeux d’instructions des processeurs ARM proposent deux techniques pour l’appel d’une fonction ou d’une routine, l’appel direct et l’appel indirect. A chacune de ces techniques, une instruction:
bl label
: appel directblx Rx
: appel indirect
L’instruction bl label
offre un appel direct à la fonction identifiée
par son étiquette (label
), mais souffre d’une restriction. L’offset
entre la fonction appelante et la fonction appelée ne doit pas dépasser
±32 MiB pour le jeu d’instructions ARM, ±16 MiB pour Thumb-2 et ±4 MiB
pour Thumb.
// direct call of the function "my_routine"
bl my_routine
L’instruction blx Rx
offre un appel indirect à une fonction. L’adresse
de la fonction à appeler étant contenue dans un registre (Rx
), cette
technique permet d’accéder une fonction placée à une adresse quelconque
dans l’ensemble de l’espace mémoire du processeur. Les pointeurs de
fonction en C ou les méthodes virtuelles en C++, utilisent également
cette technique pour l’appel de fonction par l’intermédiaire d’une
variable, variable contenant l’adresse de la fonction à appeler.
// indirect call of the function "my_routine"
ldr r0, =my_routine+1
blx r0
Note
Pour continuer l’exécution du code avec les jeux d’instructions Thumb/Thumb-2, l’adresse utilisée pour un appel indirect d’une fonction doit impérativement être impaire. Le code étant toujours aligné sur des adresses paires, il suffit d’incrémenter l’adresse de la fonction par 1 pour conserver l’exécution du code en Thumb/Thumb-2.
// function call through a function pointer
ldr r0, =my_function_pointer
ldr r0, [r0]
orr r0, #1 // make sure we keep the Thumb mode
blx r0
Les deux instructions (bl
et blx
) sauvent automatiquement l’adresse
de retour dans le registre LR, adresse pointant sur l’instruction placée
juste après l’instruction d’appel. En exécutant l’instruction bx lr
,
la fonction appelée retourne à la fonction appelante. Cette dernière
peut ainsi poursuivre son exécution.
.global my_routine
.type my_routine
my_routine:
// ...
bx lr
.size my_routine, .-my_routine
Si la fonction appelée fait elle-même appel à d’autres fonctions, elle doit d’abord sauver le contenu du registre LR sur la pile avec l’instruction push {lr}
. L’instruction pop {pc}
lui permet de retourner à la fonction appelante.
.global my_routine
.type my_routine
my_routine:
push {lr}
// ...
pop {pc}
.size my_routine, .-my_routine
Passage des arguments
Le passage des arguments à une fonction pose un certain nombre de questions:
- Quelles techniques existe-t-il pour passer des arguments ?
- Quels mécanismes existe-t-il pour réaliser ce passage d’arguments ?
Techniques
En C/C++, trois techniques existent pour le passage des arguments à une fonction:
- Passage par valeur (en C/C++)
- Passage par adresse (en C/C++)
- Passage par référence (en C++)
Avec la technique de passage par valeur (call by value), la fonction appelante crée une copie de la donnée réelle pour la fonction appelée. La fonction appelée reçoit une copie de la donnée réelle comme argument formel, qu’elle peut lire et modifier sans que la donnée réelle ne soit altérée. Pour la donnée réelle et l’argument formel, il existe deux emplacements distincts. La modification de l’argument formel par la fonction appelée n’a aucune conséquence sur la donnée réelle.
Les techniques de passage par référence et de passage par adresse sont du point vu du processeur strictement identique. Avec ces deux techniques (call by reference), la fonction appelante donne à la fonction appelée l’emplacement en mémoire de la donnée réelle, son adresse. La fonction appelée reçoit comme argument formel l’adresse de la donnée réelle en mémoire. La donnée réelle est ainsi partagée entre la fonction appelante et la fonction appelée. Il n’existe qu’un seul emplacement en mémoire (une seule adresse). Si la fonction appelée modifie la donnée, en passant par l’adresse contenue dans l’argument formel (accès indirect), cette modification sera visible et disponible pour la fonction appelante une fois la fonction appelée terminée.
Ces deux techniques sont naturellement également valables pour des appels de routines implémentées en assembleur.
Mécanismes
Les techniques pour le passage des arguments étant connues, il reste à imaginer des mécanismes pour les réaliser. En regardant l’infrastructure proposée par un processeur et selon le nombre et la taille des arguments, les compilateurs utilisent deux mécanismes:
- Passage par les registres
- Passage par la pile
Les registres offrent une méthode excessivement efficace et rapide pour le passage des arguments à une fonction. Par contre, ce mécanisme se bute au nombre limité de registres du processeur. A contrario, la pile ne souffre pas de limite de taille et offre une approche généralisée. Ces avantages sont contrebalancés par la lenteur des accès à la mémoire. Le choix d’une approche plutôt qu’une autre dépend fortement des possibilités du processeur et du langage de programmation. Les compilateurs modernes utilisent couramment les deux mécanismes.
Passage des arguments par les registres
Sur les processeurs ARM, le passage des arguments par les registres est limité. L’ABI permet le passage de seulement quatre arguments, mais au maximum quatre mots de 32 bits. Si le nombre d’arguments est supérieur à 4 ou que la taille des premiers arguments dépasse les quatre mots de 32 bits, le reste devra impérativement passer par la pile. L’ABI spécifie que les quatre mots de 32 bits doivent être placés dans les registres R0 à R3. R0 contient le premier argument/mot et R3 le dernier.
L’exemple ci-dessous présente l’appel d’une fonction avec quatre
arguments, a
, b
, c
et d
, de type int
, soit 4 mots de 32 bits.
Selon la convention, le registre R0 contient la valeur de l’argument
a
, R1 celui de b
, R2 celui de c
et R3 celui de d
.
extern void my_function(int a, int b, int c, int d);
my_function(1, 2, 3, 4);
Le code assembleur ci-dessous représente l’implémentation cet appel.
ldr r0, =1
ldr r1, =2
ldr r2, =3
ldr r3, =4
bl my_function
Le deuxième exemple présente l’appel d’une fonction avec trois arguments
a
, b
, et c
, de type uint64_t
pour le premier et int32_t
pour
les 2 suivants, soit 1 mot de 64 bits et 2 mots de 32 bits. Selon la
convention, les registres R0 et R1 contiennent l’argument a
, R2
l’argument b
et R3 c
.
extern void my_function2(uint64_t a, int32_t b, int32_t c);
my_function2(0x1122334455667788, 5, 6);
voici l’implémentation assembleur
ldr r0, =0x55667788
ldr r1, =0x11223344
ldr r2, =5
ldr r3, =6
bl my_function2
Passage des arguments par la pile
Le passage d’arguments par la pile suppose une convention précisant l’ordre dans lequel la fonction appelante doit pousser sur la pile le contenu de ces arguments. La fonction appelée accède ces valeurs en utilisant le pointeur de pile (SP).
L’ABI spécifie que dès le 5e argument ou le 5e mot de 32 bits les données doivent passer par la pile. Ces données doivent être placées sur la pile dans l’ordre inverse de leur déclaration; le 5e mot sera au sommet de la pile, suivi du 6e et ainsi de suite.
L’exemple ci-dessous présente une fonction my_sum
effectuant
l’addition de sept arguments de 32 bits chacun.
extern int my_sum(int a, int b, int c, int d, int e, int f, int g);
int sum = my_sum(1, 2, 3, 4, 5, 6, 7);
Les 4 premiers arguments sont passés par les registres et les 3 suivants par la pile.
Pour passer les arguments e
, f
et g
, la fonction appelante doit
préalablement réserver l’espace suffisant sur la pile. Pour cela, elle
décrémente le pointeur de pile (SP
) du nombre de mots à placer sur la
pile tout en respectant les conventions. Dans le cas présent, on
décrémente le SP de 4 mots de 32 bits (3 mots pour les arguments et 1
mot pour respecter la convention d’alignement). Ensuite, il est possible
de placer les arguments sur la pile. Après l’appel, la fonction
appelante doit corriger le pointeur de pile pour rétablir sa valeur
initiale.
subs sp, #4*(3+1) // reserve space on stack
movs r0, #7 // push on stack g=7
str r0, [sp, #8]
movs r0, #6 // push on stack f=6
str r0, [sp, #4]
movs r0, #5 // push on stack e=5
str r0, [sp, #0]
movs r3, #4 // store in r3 d=4
movs r2, #3 // store in r2 c=3
movs r1, #2 // store in r1 b=2
movs r0, #1 // store in r0 a=1
bl my_sum // call the fonction
adds sp, #4*(3+1) // restore value of SP
Pour la fonction appelée, les quatre premiers arguments sont directement disponibles dans les registres R0 à R3. Pour les trois suivants, elle doit les accéder indirectement en utilisant le pointeur de pile (SP) et un offset. Cet offset est le décalage entre le sommet de pile et l’emplacement de l’argument dans la pile.
.global my_sum
.type my_sum, %function
my_sum:
adds r0, r1 // r0 = a + b
adds r0, r2 // r0 = a + b + c
adds r0, r3 // r0 = a + b + c + d
ldr r1, [sp, #0]
adds r0, r1 // r0 = a + b + c + d + e
ldr r1, [sp, #4]
adds r0, r1 // r0 = a + b + c + d + e + f
ldr r1, [sp, #8]
adds r0, r1 // r0 = a + b + c + d + e + f + g
bx lr
.size my_sum, .-my_sum
Retour du résultat
Pour le retour du résultat d’une fonction, deux stratégies sont imaginables:
- Retour par les registres
- Retour par la mémoire
Le retour par les registres est la solution la plus efficace.
Cependant elle se bute au nombre de registres disponibles. Selon l’ABI,
seuls les registres R0 et R1 sont disponibles pour le retour des types
de données fondamentaux du langage C/C++, tels que bool
, int
, long
ou float
. Si la taille du type fondamental est plus petite ou égale à
un mot de 32 bits, le retour s’effectue par le registre R0. Si elle est
de 64 bits, le retour s’effectue par les registres R0 et R1. Le registre
R0 est également utilisé pour le retour de données d’un type complexe
pour autant que la taille du type soit inférieure ou égale à quatre
octets.
Le retour par la mémoire est la solution pour le retour de données de types complexes dont la taille est supérieure à quatre octets. Pour retourner des données par la mémoire, la fonction appelante passe à la fonction appelée l’emplacement mémoire où stocker le résultat, l’adresse.
struct Result {
int r1;
int r2;
};
struct Result my_complex(int a);
void my_complex2(struct Result* r, int a);
Dans l’exemple ci-dessus, la fonction my_complex2
et la fonction
my_complex
utilisent exactement les mêmes registres pour le passage
des arguments. La fonction my_complex2
l’effectue de façon explicite,
tandis que pour la fonction my_complex
cette tâche est laissée au
compilateur.
Altérations des registres
Par nature, une fonction doit cohabiter avec les autres fonctions de l’application. Le nombre de registres d’un processeur étant limité, les fonctions ne peuvent pas en réserver pour leur usage exclusif. Elles ont l’obligation de se les partager. Pour éviter qu’une fonction appelée modifie le contenu de registres utilisés par la fonction appelante, chaque fonction a l’obligation de sauver le contenu des registres dont elle a besoin avant de les modifier et de les restaurer avant de retourner vers la fonction appelante. Selon l’ABI le contenu des registres R4 à R11 doit être préservé.
.global my_context
.type my_context
my_context:
push {r4-r6, lr}
// ...
pop {r4-r6, pc}
La pile sert naturellement de stockage pour ces sauvegardes.
L’instruction push { ... }
, placée comme prologue de la fonction,
sauve le contenu des registres placés entre les deux accolades.
L’instruction pop { ... }
les restaure. Elle se place comme épilogue
de la fonction.