Interfaçage entre assembleur et C
Mélanger assembleur et C dans un même programme reste utile aujourd’hui : optimiser une boucle critique, accéder à une instruction matérielle précise, ou comprendre ce que génère réellement le compilateur. Ce tutoriel, modernisé à partir d’un article historique de Coder Studio, explique comment les deux mondes communiquent : conventions d’appel, assembleur inline GCC, et liaison d’un fichier .s avec du code C. Les exemples ciblent x86-64 sous Linux avec GCC.
La clé : les conventions d’appel
Pour qu’une fonction C appelle une routine en assembleur (ou l’inverse), les deux doivent s’accorder sur où passent les arguments et où revient le résultat. C’est le rôle de la convention d’appel (ABI). Sur x86-64 System V (Linux, macOS), les règles essentielles sont :
- les 6 premiers arguments entiers/pointeurs passent dans les registres, dans l’ordre :
rdi,rsi,rdx,rcx,r8,r9; - la valeur de retour entière revient dans
rax; - les registres
rbx,rbp,r12–r15doivent être préservés par la fonction appelée (callee-saved) : si vous les modifiez, vous devez les restaurer avant de rendre la main ; - les autres (
rax,rcx,rdx,rsi,rdi,r8–r11) sont caller-saved : l’appelant ne compte pas sur leur préservation.
Ignorer ces règles, c’est écraser une valeur dont le C avait besoin et provoquer un plantage difficile à diagnostiquer.
Méthode 1 : l’assembleur inline avec GCC
La façon la plus légère d’insérer de l’assembleur est de l’écrire directement dans le C via asm. La syntaxe étendue de GCC permet de relier des variables C aux registres, sans gérer soi-même la convention d’appel.
#include <stdio.h>
int main(void) {
int a = 20, b = 22, somme;
// addition en assembleur inline
asm volatile (
"addl %[y], %[x]" // x = x + y
: [x] "+r" (a) // sortie : a, en registre, lu et écrit (+)
: [y] "r" (b) // entrée : b, en registre
: // pas de registre supplémentaire écrasé
);
somme = a;
printf("Somme = %d\n", somme); // 42
return 0;
}
Décryptons la structure : asm volatile ( "instructions" : sorties : entrées : clobbers ).
- Le mot-clé
volatileempêche le compilateur de déplacer ou supprimer le bloc s’il croit qu’il est inutile (essentiel dès qu’il y a un effet de bord). - Les opérandes
[nom] "contrainte" (variable_C)font le pont."r"demande un registre ;"+r"signifie « registre lu et écrit ». - La liste des clobbers (après le troisième
:) déclare les registres que votre code modifie hors des opérandes. Si vous touchez à la mémoire de façon opaque, ajoutez"memory".
Cette approche est parfaite pour quelques instructions ponctuelles.
Méthode 2 : lier un fichier .s séparé
Pour une routine plus conséquente, on l’écrit dans un fichier assembleur .s autonome, puis on la déclare côté C comme une fonction externe. Le C ne voit qu’un prototype ; l’éditeur de liens fait le reste.
Le fichier assembleur, carre.s :
.text
.globl carre # rend le symbole visible pour l'éditeur de liens
carre: # int carre(int n)
movl %edi, %eax # 1er argument (n) dans edi -> eax
imull %eax, %eax # eax = eax * eax
ret # valeur de retour dans eax
Le fichier C, main.c :
#include <stdio.h>
// prototype : l'implémentation est en assembleur
int carre(int n);
int main(void) {
printf("carre(7) = %d\n", carre(7)); // 49
return 0;
}
La compilation et l’édition de liens en une commande :
gcc main.c carre.s -o programme
GCC assemble carre.s, compile main.c, puis lie les deux. Le symbole carre déclaré .globl dans l’assembleur correspond exactement au prototype C.
Le passage d’arguments en détail
Reprenons carre à la lumière des conventions. La fonction reçoit son unique argument n dans edi (la moitié basse 32 bits de rdi, puisque n est un int). Le résultat doit repartir dans eax (moitié basse de rax). D’où :
movl %edi, %eax # copie n (edi) vers eax
imull %eax, %eax # eax devient n*n
ret # eax est la valeur de retour
Pour une fonction à deux arguments int f(int a, int b), a arriverait dans edi et b dans esi. Cette correspondance registre ↔ argument est le contrat qui rend l’interfaçage fiable — et c’est aussi ce qui explique pourquoi un désassemblage vous montre ces registres partout.
Un piège fréquent : préserver les registres
Si votre routine assembleur utilise un registre callee-saved (par exemple rbx), vous devez le sauvegarder puis le restaurer :
ma_routine:
pushq %rbx # on sauvegarde rbx avant de l'utiliser
# ... calculs utilisant rbx ...
popq %rbx # on le restaure avant de rendre la main
ret
Oublier ce push/pop, c’est corrompre un registre dont l’appelant C attendait la valeur intacte — un bug typique et déroutant de l’interfaçage bas niveau.
Quand y recourir (et quand s’abstenir)
L’interfaçage assembleur-C garde son intérêt pour : accéder à des instructions spécialisées (cryptographie, SIMD), optimiser un point chaud mesuré, ou écrire du code très proche du matériel. En revanche, pour du code ordinaire, les compilateurs modernes optimisent souvent mieux qu’un humain : n’écrivez de l’assembleur qu’après avoir mesuré un besoin réel, et documentez-le soigneusement — c’est le code le plus difficile à maintenir.
À lire ensuite
- Le langage assembleur — comprendre registres, pile et instructions avant de les interfacer
- Les bases du langage C — pointeurs et compilation, le socle côté C
- Le préprocesseur C et les énumérations — d’autres mécanismes bas niveau du C