Comprendre C en apprenant l’assemblage

La dernière fois, Alan a montré comment utiliser GDB comme outil pour apprendre le C. Aujourd’hui, je veux aller plus loin et utiliser GDB pour nous aider à comprendre également l’assemblage.

Les couches d’abstraction sont d’excellents outils pour construire des choses, mais elles peuvent parfois gêner l’apprentissage. Mon objectif dans cet article est de vous convaincre que pour comprendre rigoureusement C, nous devons également comprendre l’assemblage que notre compilateur C génère. Je vais le faire en vous montrant comment démonter et lire un programme simple avec GDB, puis nous utiliserons GDB et nos connaissances de l’assemblage pour comprendre comment fonctionnent les variables locales statiques en C.

Remarque: Tout le code de cet article a été compilé sur un PROCESSEUR x86_64 exécutant Mac OS X 10.8.1 en utilisant Clang 4.0 avec des optimisations désactivées (-O0).

Apprentissage de l’assemblage avec GDB

Commençons par désassembler un programme avec GDB et apprendre à lire la sortie. Tapez le programme suivant dans un fichier texte et enregistrez-le sous simple.c:

int main(){ int a = 5; int b = a + 6; return 0;}

Compilez-le maintenant avec des symboles de débogage et aucune optimisation, puis exécutez GDB:1

$ CFLAGS="-g -O0" make simplecc -g -O0 simple.c -o simple$ gdb simple

Dans GDB, nous allons casser sur main et courir jusqu’à ce que nous arrivions à l’instruction return. Nous mettons le nombre 2 après next pour spécifier que nous voulons exécuter next deux fois:

(gdb) break main(gdb) run(gdb) next 2

Utilisons maintenant la commande disassemble pour afficher les instructions d’assemblage de la fonction en cours. Vous pouvez également passer un nom de fonction à disassemble pour spécifier une fonction différente à examiner.

(gdb) disassembleDump of assembler code for function main:0x0000000100000f50 <main+0>: push %rbp0x0000000100000f51 <main+1>: mov %rsp,%rbp0x0000000100000f54 <main+4>: mov x0,%eax0x0000000100000f59 <main+9>: movl x0,-0x4(%rbp)0x0000000100000f60 <main+16>: movl x5,-0x8(%rbp)0x0000000100000f67 <main+23>: mov -0x8(%rbp),%ecx0x0000000100000f6a <main+26>: add x6,%ecx0x0000000100000f70 <main+32>: mov %ecx,-0xc(%rbp)0x0000000100000f73 <main+35>: pop %rbp0x0000000100000f74 <main+36>: retq End of assembler dump.

La commande disassemble affiche par défaut des instructions dans la syntaxe AT &T, qui est la même syntaxe utilisée par l’assembleur GNU.2 Les instructions de la syntaxe AT & T sont du format mnemonic source, destination. Le mnémonique est un nom lisible par l’homme pour l’instruction. La source et la destination sont des opérandes et peuvent être des valeurs immédiates, des registres, des adresses mémoire ou des étiquettes. Les valeurs immédiates sont des constantes et sont préfixées par un $. Par exemple, x5 représente le nombre 5 en hexadécimal. Les noms de registre sont préfixés par un %.

Registres

Il vaut la peine de faire un petit détour pour comprendre les registres. Les registres sont des emplacements de stockage de données directement sur le processeur. À quelques exceptions près, la taille ou la largeur des registres d’un PROCESSEUR définissent son architecture. Donc, si vous avez un processeur 64 bits, vos registres auront une largeur de 64 bits. Il en va de même pour les processeurs 32 bits (registres 32 bits), les processeurs 16 bits, etc.3 Registres sont très rapides d’accès et sont souvent les opérandes pour les opérations arithmétiques et logiques.

La famille x86 possède un certain nombre de registres généraux et spéciaux. Les registres à usage général peuvent être utilisés pour n’importe quelle opération et leur valeur n’a pas de signification particulière pour le processeur. D’autre part, le CPU s’appuie sur des registres à usage spécial pour son propre fonctionnement et les valeurs qui y sont stockées ont une signification spécifique en fonction du registre. Dans notre exemple ci-dessus, %eax et %ecx sont des registres à usage général, tandis que %rbp et %rsp sont des registres à usage spécial. %rbp est le pointeur de base, qui pointe vers la base de la trame de pile actuelle, et %rsp est le pointeur de pile, qui pointe vers le haut de la trame de pile actuelle. %rbp a toujours une valeur supérieure à %rsp car la pile commence à une adresse mémoire élevée et se développe vers le bas. Si vous n’êtes pas familier avec la pile d’appels, vous pouvez trouver une bonne introduction sur Wikipedia.

Une bizarrerie de la famille x86 est qu’elle a maintenu une rétrocompatibilité jusqu’au processeur 16 bits 8086. Comme x86 est passé de 16 bits à 32 bits à 64 bits, les registres ont été étendus et ont reçu de nouveaux noms afin de ne pas rompre la rétrocompatibilité avec le code écrit pour des PROCESSEURS plus anciens et plus étroits.

Prenez la HACHE de registre à usage général, qui a une largeur de 16 bits. L’octet haut est accessible avec le nom AH, et l’octet bas avec le nom AL. Lorsque le 80386 32 bits est sorti, le registre AX étendu, ou EAX, faisait référence au registre 32 bits, tandis que AX continuait de faire référence à un registre 16 bits qui constituait la moitié inférieure de EAX. De même, lorsque l’architecture x86_64 est sortie, le préfixe « R » a été utilisé et EAX constituait la moitié inférieure du registre RAX 64 bits. J’ai inclus un diagramme ci-dessous basé sur un article Wikipedia pour aider à visualiser les relations que j’ai décrites:

|__64__|__56__|__48__|__40__|__32__|__24__|__16__|__8___||__________________________RAX__________________________||xxxxxxxxxxxxxxxxxxxxxxxxxxx|____________EAX____________||xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|_____AX______||xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|__AH__|__AL__|

Retour au code

Cela devrait être suffisamment d’informations pour commencer à lire notre programme désassemblé.

0x0000000100000f50 <main+0>: push %rbp0x0000000100000f51 <main+1>: mov %rsp,%rbp

Les deux premières instructions sont appelées prologue de fonction ou préambule. Nous poussons d’abord l’ancien pointeur de base sur la pile pour l’enregistrer pour plus tard. Ensuite, nous copions la valeur du pointeur de pile sur le pointeur de base. Après cela, %rbp pointe vers la base du cadre de pile de main.

0x0000000100000f54 <main+4>: mov x0,%eax

Cette instruction copie 0 dans %eax. La convention d’appel x86 dicte que la valeur de retour d’une fonction est stockée dans %eax, donc l’instruction ci-dessus nous définit pour renvoyer 0 à la fin de notre fonction.

0x0000000100000f59 <main+9>: movl x0,-0x4(%rbp)

Ici, nous avons quelque chose que nous n’avons jamais rencontré auparavant: -0x4(%rbp). Les parenthèses nous font savoir qu’il s’agit d’une adresse mémoire. Ici, %rbp est appelé le registre de base et -0x4 est le déplacement. Cela équivaut à %rbp + -0x4. Comme la pile se développe vers le bas, la soustraction de 4 à la base de la trame de pile actuelle nous déplace dans la trame actuelle elle-même, où les variables locales sont stockées. Cela signifie que cette instruction stocke 0 à %rbp - 4. Il m’a fallu un certain temps pour comprendre à quoi servait cette ligne, mais il semble que clang alloue une variable locale cachée pour une valeur de retour implicite de main.

Vous remarquerez également que le mnémonique a le suffixe l. Cela signifie que les opérandes seront l ong (32 bits pour les entiers). Les autres suffixes valides sont b yte, s hort, w ord, q uad et t en. Si vous voyez une instruction qui n’a pas de suffixe, la taille des opérandes est déduite de la taille du registre source ou de destination. Par exemple, dans la ligne précédente, %eax a une largeur de 32 bits, de sorte que l’instruction mov est déduite de movl.

0x0000000100000f60 <main+16>: movl x5,-0x8(%rbp)

Maintenant, nous entrons dans la viande de notre programme d’échantillons! La première ligne d’assemblage est la première ligne de C dans main et stocke le nombre 5 dans le prochain emplacement de variable locale disponible (%rbp - 0x8), à 4 octets de notre dernière variable locale. C’est l’emplacement de a. Nous pouvons utiliser GDB pour vérifier cela:

(gdb) x &a0x7fff5fbff768: 0x00000005(gdb) x $rbp - 80x7fff5fbff768: 0x00000005

Notez que les adresses mémoire sont les mêmes. Vous remarquerez que GDB configure des variables pour nos registres, mais comme toutes les variables de GDB, nous le préfixons avec un $ plutôt que le % utilisé dans l’assemblage AT & T.

0x0000000100000f67 <main+23>: mov -0x8(%rbp),%ecx0x0000000100000f6a <main+26>: add x6,%ecx0x0000000100000f70 <main+32>: mov %ecx,-0xc(%rbp)

Nous déplaçons ensuite a dans %ecx, l’un de nos registres à usage général, y ajoutons 6 et stockons le résultat dans %rbp - 0xc. C’est la deuxième ligne de C dans main. Vous avez peut-être compris que %rbp - 0xc est b, ce que nous pouvons vérifier dans GDB:

(gdb) x &b0x7fff5fbff764: 0x0000000b(gdb) x $rbp - 0xc0x7fff5fbff764: 0x0000000b

Le reste de main est juste un nettoyage, appelé épilogue de la fonction:

0x0000000100000f73 <main+35>: pop %rbp0x0000000100000f74 <main+36>: retq 

Nous pop l’ancien pointeur de base hors de la pile et le stockons dans %rbp puis retq revient à notre adresse de retour, qui est également stockée dans le cadre de la pile.

Jusqu’à présent, nous avons utilisé GDB pour désassembler un programme C court, passé en revue la façon de lire À LA syntaxe d’assemblage & T, et couvert les registres et les opérandes d’adresse mémoire. Nous avons également utilisé GDB pour vérifier où nos variables locales sont stockées par rapport à %rbp. Nous allons maintenant utiliser nos compétences nouvellement acquises pour expliquer le fonctionnement des variables locales statiques.

Comprendre les variables locales statiques

Les variables locales statiques sont une fonctionnalité très intéressante de C. En un mot, ce sont des variables locales qui ne sont initialisées qu’une seule fois et conservent leurs valeurs sur plusieurs appels à la fonction où elles sont définies. Un cas d’utilisation simple pour les variables locales statiques est un générateur de style Python. En voici un qui génère tous les nombres naturels jusqu’à INT_MAX:

/* static.c */#include <stdio.h>int natural_generator(){ int a = 1; static int b = -1; b += 1; return a + b;}int main(){ printf("%d\n", natural_generator()); printf("%d\n", natural_generator()); printf("%d\n", natural_generator()); return 0;}

Une fois compilé et exécuté, ce programme imprime les trois premiers nombres naturels:

$ CFLAGS="-g -O0" make staticcc -g -O0 static.c -o static$ ./static123

Mais comment cela fonctionne-t-il? Pour comprendre les locaux statiques, nous allons sauter dans GDB et regarder l’assemblage. J’ai supprimé les informations d’adresse que GDB ajoute au démontage pour que tout s’adapte à l’écran:

$ gdb static(gdb) break natural_generator(gdb) run(gdb) disassembleDump of assembler code for function natural_generator:push %rbpmov %rsp,%rbpmovl x1,-0x4(%rbp)mov 0x177(%rip),%eax # 0x100001018 <natural_generator.b>add x1,%eaxmov %eax,0x16c(%rip) # 0x100001018 <natural_generator.b>mov -0x4(%rbp),%eaxadd 0x163(%rip),%eax # 0x100001018 <natural_generator.b>pop %rbpretq End of assembler dump.

La première chose que nous devons faire est de comprendre quelle instruction nous suivons. Nous pouvons le faire en examinant le pointeur d’instruction ou le compteur de programme. Le pointeur d’instruction est un registre qui stocke l’adresse mémoire de l’instruction suivante. Sur x86_64, ce registre est %rip. Nous pouvons accéder au pointeur d’instruction en utilisant la variable $rip, ou bien nous pouvons utiliser l’architecture indépendante $pc:

(gdb) x/i $pc0x100000e94 <natural_generator+4>: movl x1,-0x4(%rbp)

Le pointeur d’instruction contient toujours l’adresse de la prochaine instruction à exécuter, ce qui signifie que la troisième instruction n’a pas encore été exécutée, mais est sur le point de l’être.

Parce que sachant que l’instruction suivante est utile, nous allons faire en sorte que GDB nous montre l’instruction suivante chaque fois que le programme s’arrête. Dans GDB 7.0 ou plus tard, vous pouvez simplement exécuter set disassemble-next-line on, qui affiche toutes les instructions qui composent la ligne de source suivante, mais nous utilisons Mac OS X, qui n’est livré qu’avec GDB 6.3, nous devrons donc recourir à la commande display. display est comme x, sauf qu’il évalue son expression chaque fois que notre programme s’arrête:

(gdb) display/i $pc1: x/i $pc 0x100000e94 <natural_generator+4>: movl x1,-0x4(%rbp)

Maintenant, GDB est configuré pour toujours nous montrer l’instruction suivante avant d’afficher son invite.

Nous avons déjà dépassé le prologue de la fonction, que nous avons couvert plus tôt, nous allons donc commencer dès la troisième instruction. Cela correspond à la première ligne source qui attribue 1 à a. Au lieu de next, qui se déplace vers la ligne source suivante, nous utiliserons nexti, qui se déplace vers l’instruction d’assemblage suivante. Ensuite, nous examinerons %rbp - 0x4 pour vérifier notre hypothèse selon laquelle a est stocké à %rbp - 0x4.

(gdb) nexti7 b += 1;1: x/i $pc mov 0x177(%rip),%eax # 0x100001018 <natural_generator.b>(gdb) x $rbp - 0x40x7fff5fbff78c: 0x00000001(gdb) x &a0x7fff5fbff78c: 0x00000001

Ils sont les mêmes, tout comme nous nous y attendions. L’instruction suivante est plus intéressante:

mov 0x177(%rip),%eax # 0x100001018 <natural_generator.b>

C’est là que nous nous attendons à trouver la ligne static int b = -1;, mais elle semble sensiblement différente de tout ce que nous avons vu auparavant. D’une part, il n’y a aucune référence au cadre de pile où nous nous attendrions normalement à trouver des variables locales. Il n’y a même pas de -0x1! Au lieu de cela, nous avons une instruction qui charge 0x100001018, située quelque part après le pointeur d’instruction, dans %eax. GDB nous donne un commentaire utile avec le résultat du calcul de l’opérande mémoire et un indice nous indiquant que natural_generator.b est stocké à cette adresse. Exécutons cette instruction et voyons ce qui se passe:

(gdb) nexti(gdb) p $rax = 4294967295(gdb) p/x $rax = 0xffffffff

Même si le désassemblage indique %eax comme destination, nous imprimons $rax, car GDB ne configure que les variables pour les registres pleine largeur.

Dans cette situation, nous devons nous rappeler que si les variables ont des types qui spécifient si elles sont signées ou non signées, les registres ne le font pas, donc GDB imprime la valeur de %rax unsigned. Essayons à nouveau, en lançant %rax sur un signe int:

(gdb) p (int)$rax = -1

Il semble que nous ayons trouvé b. Nous pouvons vérifier cela en utilisant la commande x:

(gdb) x/d 0x1000010180x100001018 <natural_generator.b>: -1(gdb) x/d &b0x100001018 <natural_generator.b>: -1

Ainsi, non seulement b est stocké à une adresse mémoire faible en dehors de la pile, mais il est également initialisé à -1 avant même que natural_generator ne soit appelé. En fait, même si vous désassembliez l’ensemble du programme, vous ne trouveriez aucun code qui définit b sur -1. En effet, la valeur de b est codée en dur dans une section différente de l’exécutable sample et elle est chargée en mémoire avec tout le code machine par le chargeur du système d’exploitation lorsque le processus est lancé.4

Avec cela, les choses commencent à avoir plus de sens. Après avoir stocké b dans %eax, nous passons à la ligne de source suivante où nous incrémentons b. Cela correspond aux deux instructions suivantes:

add x1,%eaxmov %eax,0x16c(%rip) # 0x100001018 <natural_generator.b>

Ici, nous ajoutons 1 à %eax et stockons le résultat en mémoire. Exécutons ces instructions et vérifions le résultat:

(gdb) nexti 2(gdb) x/d &b0x100001018 <natural_generator.b>: 0(gdb) p (int)$rax = 0

Les deux instructions suivantes nous ont mis en place pour revenir a + b:

mov -0x4(%rbp),%eaxadd 0x163(%rip),%eax # 0x100001018 <natural_generator.b>

Ici, nous chargeons a dans %eax, puis ajoutons b. À ce stade, nous nous attendons à ce que %eax soit égal à 1. Vérifions:

(gdb) nexti 2(gdb) p $rax = 1

%eax est utilisé pour stocker la valeur de retour de natural_generator, nous sommes donc tous configurés pour l’épilogue qui nettoie la pile et retourne:

pop %rbpretq 

Maintenant, nous comprenons comment b est initialisé, voyons ce qui se passe lorsque nous exécutons à nouveau natural_generator:

(gdb) continueContinuing.1Breakpoint 1, natural_generator () at static.c:55 int a = 1;1: x/i $pc 0x100000e94 <natural_generator+4>: movl x1,-0x4(%rbp)(gdb) x &b0x100001018 <natural_generator.b>: 0

Parce que b n’est pas stocké sur la pile avec d’autres variables locales, il est toujours nul lorsque natural_generator est à nouveau appelé. Peu importe combien de fois notre générateur est appelé, b conservera toujours sa valeur précédente. En effet, il est stocké en dehors de la pile et initialisé lorsque le chargeur déplace le programme en mémoire, plutôt que par l’un de nos codes machine.

Conclusion

Nous avons commencé par examiner comment lire l’assemblage et comment désassembler un programme avec GDB. Ensuite, nous avons couvert le fonctionnement des variables locales statiques, ce que nous n’aurions pas pu faire sans démonter notre exécutable.

Nous avons passé beaucoup de temps à alterner entre la lecture des instructions de montage et la vérification de nos hypothèses dans GDB. Cela peut sembler répétitif, mais il y a une raison très importante pour faire les choses de cette façon: la meilleure façon d’apprendre quelque chose d’abstrait est de le rendre plus concret, et l’une des meilleures façons de rendre quelque chose de plus concret est d’utiliser des outils qui vous permettent de retirer des couches d’abstraction. La meilleure façon d’apprendre ces outils est de vous forcer à les utiliser jusqu’à ce qu’ils soient une seconde nature.

  1. Vous remarquerez que nous utilisons Make pour construire `simple.c’est sans makefile. Nous pouvons le faire car Make a des règles implicites pour construire des exécutables à partir de fichiers C. Vous trouverez plus d’informations sur ces règles dans le (http://www.gnu.org/software/make/manual/make.html#Implicit-Rules). ↩

  2. Vous pouvez également avoir la syntaxe Intel de sortie GDB, qui est utilisée par NASM, MASM et d’autres assembleurs, mais cela ne fait pas partie du champ d’application de cet article. ↩

  3. Les processeurs avec des jeux d’instructions SIMD comme MMX et SSE pour x86 et AltiVec pour PowerPC contiennent souvent des registres plus larges que l’architecture CPU.↩

  4. Il est préférable d’enregistrer une discussion sur les formats d’objets, les chargeurs et les lieurs pour un futur article de blog.↩

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée.