Je crois que je comprends comment le linux x86-64 ABI utilise des registres et une pile pour passer des paramètres à une fonction (cf. discussion ABI précédente ). Ce qui me dérange, c'est si/quels registres devraient être conservés dans un appel de fonction. Autrement dit, quels registres sont garantis pour ne pas être encombrés?
Voici le tableau complet des registres et leur utilisation dans la documentation [ PDF Link ]:
r12
, r13
, r14
, r15
, rbx
, rsp
, rbp
sont les registres sauvegardés par l'appelé - ils ont un "Oui" dans la colonne "Préservé entre les appels de fonction".
L'ABI spécifie ce qu'un logiciel conforme aux normes est autorisé à attendre. Il est écrit principalement pour les auteurs de compilateurs, de linkers et d'autres logiciels de traitement de langage. Ces auteurs souhaitent que leur compilateur produise du code qui fonctionnera correctement avec du code compilé par le même (ou un autre) compilateur. Ils doivent tous accepter un ensemble de règles: comment les arguments formels des fonctions sont-ils passés de l'appelant à l'appelé, comment les valeurs de retour de fonction sont-elles renvoyées de l'appelé à l'appelant, quels registres sont conservés/rayés/non définis à travers la limite de l'appel, et ainsi de suite sur.
Par exemple, une règle stipule que le code d'assembly généré pour une fonction doit enregistrer la valeur d'un registre conservé avant de modifier la valeur et que le code doit restaurer la valeur enregistrée avant de revenir à son appelant. Pour un registre de travail, le code généré n'est pas requis pour enregistrer et restaurer la valeur du registre; il peut le faire s'il le souhaite, mais le logiciel conforme aux normes n'est pas autorisé à dépendre de ce comportement (s'il le fait, il ne s'agit pas d'un logiciel conforme aux normes).
Si vous écrivez du code Assembly, vous êtes responsable de jouer selon ces mêmes règles (vous jouez le rôle du compilateur). Autrement dit, si votre code modifie un registre préservé de l'appelé, vous êtes responsable de l'insertion d'instructions qui enregistrent et restaurent la valeur de registre d'origine. Si votre code Assembly appelle une fonction externe, votre code doit passer des arguments de la manière conforme aux normes, et cela peut dépendre du fait que, lorsque l'appelé revient, les valeurs de registre préservées sont en fait préservées.
Les règles définissent comment les logiciels conformes aux normes peuvent s'entendre. Cependant, il est parfaitement légal d'écrire (ou de générer) du code qui ne pas respecte ces règles! Les compilateurs le font tout le temps, car ils savent que les règles n'ont pas besoin d'être suivies dans certaines circonstances.
Par exemple, considérons une fonction C nommée foo qui est déclarée comme suit et n'a jamais pris son adresse:
static foo(int x);
Au moment de la compilation, le compilateur est certain à 100% que cette fonction ne peut être appelée que par un autre code du ou des fichiers en cours de compilation. La fonction foo
ne peut jamais être appelée par autre chose, étant donné la définition de ce que signifie être statique. Parce que le compilateur connaît tous les appelants de foo
au moment de la compilation, le compilateur est libre d'utiliser la séquence d'appel qu'il veut (jusqu'à et y compris ne pas faire d'appel du tout, c'est-à-dire en insérant le code pour foo
dans les appelants de foo
.
En tant qu'auteur du code Assembly, vous pouvez également le faire. C'est-à-dire que vous pouvez implémenter un "accord privé" entre deux routines ou plus, tant que cet accord n'interfère pas ou ne viole pas les attentes des logiciels conformes aux normes.
Approche expérimentale: démonter le code GCC
Surtout pour le plaisir, mais aussi pour vérifier rapidement que vous avez bien compris l'ABI.
Essayons d'encombrer tous les registres avec l'assemblage en ligne pour forcer GCC à les enregistrer et les restaurer:
principal c
#include <inttypes.h>
uint64_t inc(uint64_t i) {
__asm__ __volatile__(
""
: "+m" (i)
:
: "rax",
"rbx",
"rcx",
"rdx",
"rsi",
"rdi",
"rbp",
"rsp",
"r8",
"r9",
"r10",
"r11",
"r12",
"r13",
"r14",
"r15",
"ymm0",
"ymm1",
"ymm2",
"ymm3",
"ymm4",
"ymm5",
"ymm6",
"ymm7",
"ymm8",
"ymm9",
"ymm10",
"ymm11",
"ymm12",
"ymm13",
"ymm14",
"ymm15"
);
return i + 1;
}
int main(int argc, char **argv) {
(void)argv;
return inc(argc);
}
Compilez et démontez:
gcc -std=gnu99 -O3 -ggdb3 -Wall -Wextra -pedantic -o main.out main.c
objdump -d main.out
Le démontage contient:
00000000000011a0 <inc>:
11a0: 55 Push %rbp
11a1: 48 89 e5 mov %rsp,%rbp
11a4: 41 57 Push %r15
11a6: 41 56 Push %r14
11a8: 41 55 Push %r13
11aa: 41 54 Push %r12
11ac: 53 Push %rbx
11ad: 48 83 ec 08 sub $0x8,%rsp
11b1: 48 89 7d d0 mov %rdi,-0x30(%rbp)
11b5: 48 8b 45 d0 mov -0x30(%rbp),%rax
11b9: 48 8d 65 d8 lea -0x28(%rbp),%rsp
11bd: 5b pop %rbx
11be: 41 5c pop %r12
11c0: 48 83 c0 01 add $0x1,%rax
11c4: 41 5d pop %r13
11c6: 41 5e pop %r14
11c8: 41 5f pop %r15
11ca: 5d pop %rbp
11cb: c3 retq
11cc: 0f 1f 40 00 nopl 0x0(%rax)
et nous voyons donc clairement que les éléments suivants sont poussés et sautés:
rbx
r12
r13
r14
r15
rbp
Le seul manquant dans la spécification est rsp
, mais nous nous attendons à ce que la pile soit restaurée bien sûr. Une lecture attentive de l'Assemblée confirme qu'elle est maintenue dans ce cas:
sub $0x8, %rsp
: Alloue 8 octets sur la pile pour enregistrer %rdi
Dans %rdi, -0x30(%rbp)
, ce qui est fait pour la contrainte d'assemblage en ligne +m
lea -0x28(%rbp), %rsp
restaure %rsp
avant le sub
, c'est-à-dire 5 sauts après mov %rsp, %rbp
%rsp
Testé dans Ubuntu 18.10, GCC 8.2.0.