Comment le débogueur GDB et d’autres outils utilisent les informations de cadre d’appel pour déterminer les appels de fonction actifs


  • Français


  • Dans mon article précédent, j’ai montré comment debuginfo est utilisé pour mapper entre le pointeur d’instruction actuel (IP) et la fonction ou la ligne qui le contient. Cette information est précieuse pour montrer quel code le processeur est en train d’exécuter. Cependant, avoir plus de contexte pour les appels qui mènent à la fonction et à la ligne en cours d’exécution est également extrêmement utile.

    Par exemple, supposons qu’une fonction dans une bibliothèque ait un accès mémoire illégal en raison d’un pointeur nul passé en paramètre dans la fonction. Le simple fait de regarder la fonction et la ligne actuelles montre que l’erreur a été déclenchée par une tentative d’accès via un pointeur nul. Cependant, ce que vous voulez vraiment savoir, c’est le contexte complet des appels de fonction actifs menant à cet accès au pointeur nul, afin que vous puissiez déterminer comment ce pointeur nul a été initialement transmis à la fonction de bibliothèque. Ces informations de contexte sont fournies par un backtrace et vous permettent de déterminer quelles fonctions pourraient être responsables du paramètre bidon.

    Une chose est sûre : déterminer les appels de fonction actuellement actifs est une opération non triviale.

    Enregistrements d’activation de fonction

    Les langages de programmation modernes ont des variables locales et permettent la récursivité là où une fonction peut s’appeler elle-même. De plus, les programmes concurrents ont plusieurs threads qui peuvent avoir la même fonction en cours d’exécution en même temps. Les variables locales ne peuvent pas être stockées dans des emplacements globaux dans ces situations. Les emplacements des variables locales doivent être uniques pour chaque appel de la fonction. Voici comment cela fonctionne:

    1. Le compilateur produit un enregistrement d’activation de fonction chaque fois qu’une fonction est appelée pour stocker des variables locales dans un emplacement unique.
    2. Pour plus d’efficacité, la pile de processeur est utilisée pour stocker les enregistrements d’activation de fonction.
    3. Un nouvel enregistrement d’activation de fonction est créé en haut de la pile du processeur pour la fonction lorsqu’elle est appelée.
    4. Si cette fonction appelle une autre fonction, un nouvel enregistrement d’activation de fonction est placé au-dessus de l’enregistrement d’activation de fonction existant.
    5. Chaque fois qu’il y a un retour d’une fonction, son enregistrement d’activation de fonction est supprimé de la pile.

    La création de la fiche d’activation de la fonction est créée par code dans la fonction appelée le prologue. La suppression de l’enregistrement d’activation de la fonction est gérée par l’épilogue de la fonction. Le corps de la fonction peut utiliser la mémoire qui lui est réservée sur la pile pour les valeurs temporaires et les variables locales.

    Les enregistrements d’activation de fonction peuvent être de taille variable. Pour certaines fonctions, il n’y a pas besoin d’espace pour stocker les variables locales. Idéalement, l’enregistrement d’activation de la fonction n’a besoin que de stocker l’adresse de retour de la fonction qui a appelé ce fonction. Pour d’autres fonctions, un espace important peut être nécessaire pour stocker des structures de données locales pour la fonction en plus de l’adresse de retour. Cette variation des tailles de trame conduit les compilateurs à utiliser des pointeurs de trame pour suivre le début de la trame d’activation de la fonction. Maintenant, le code du prologue de la fonction a la tâche supplémentaire de stocker l’ancien pointeur de cadre avant de créer un nouveau pointeur de cadre pour la fonction actuelle, et l’épilogue doit restaurer l’ancienne valeur du pointeur de cadre.

    La façon dont l’enregistrement d’activation de la fonction est présenté, l’adresse de retour et l’ancien pointeur de trame de la fonction appelante sont des décalages constants par rapport au pointeur de trame actuel. Avec l’ancien pointeur de cadre, le cadre d’activation de la fonction suivante sur la pile peut être localisé. Ce processus est répété jusqu’à ce que tous les enregistrements d’activation de fonction aient été examinés.

    Complications d’optimisation

    Il y a quelques inconvénients à avoir des pointeurs de frame explicites dans le code. Sur certains processeurs, il y a relativement peu de registres disponibles. Avoir un pointeur de cadre explicite entraîne l’utilisation de plus d’opérations de mémoire. Le code résultant est plus lent car le pointeur de trame doit se trouver dans l’un des registres. Avoir des pointeurs de cadre explicites peut contraindre le code que le compilateur peut générer, car le compilateur ne peut pas mélanger le prologue de la fonction et le code de l’épilogue avec le corps de la fonction.

    L’objectif du compilateur est de générer du code rapide dans la mesure du possible, de sorte que les compilateurs omettent généralement les pointeurs de trame du code généré. Conserver les pointeurs de cadre peut réduire considérablement les performances, comme le montre L’analyse comparative de Phoronix. L’inconvénient d’omettre les pointeurs de trame est que la recherche de la trame d’activation et de l’adresse de retour de la fonction appelante précédente ne sont plus de simples décalages par rapport au pointeur de trame.

    Informations sur la trame d’appel

    Pour faciliter la génération de backtraces de fonction, le compilateur inclut DWARF Call Frame Information (CFI) pour reconstruire les pointeurs de trame et trouver les adresses de retour. Ces informations supplémentaires sont stockées dans le .eh_frame partie de l’exécution. Contrairement aux informations de débogage traditionnelles pour les informations sur les fonctions et l’emplacement des lignes, le .eh_frame section est dans l’exécutable même lorsque l’exécutable est généré sans informations de débogage, ou lorsque les informations de débogage ont été supprimées du fichier. Les informations sur le cadre d’appel sont essentielles pour le fonctionnement des constructions de langage telles que lancer-attraper en C++.

    Le CFI a une entrée de description de trame (FDE) pour chaque fonction. Au cours de l’une de ses étapes, le processus de génération de trace trouve le FDE approprié pour la trame d’activation en cours examinée. Considérez le FDE comme un tableau, chaque ligne représentant une ou plusieurs instructions, avec ces colonnes :

    • Adresse de trame canonique (CFA), l’emplacement vers lequel pointerait le pointeur de trame
    • L’adresse de retour
    • Informations sur les autres registres

    L’encodage du FDE est conçu pour minimiser l’espace requis. Le FDE décrit les modifications entre les lignes plutôt que de spécifier entièrement chaque ligne. Pour compresser davantage les données, les informations de départ communes à plusieurs FDE sont factorisées et placées dans des entrées d’informations communes (CIE). Cela rend le FDE plus compact, mais cela nécessite également plus de travail pour calculer le CFA réel et trouver l’emplacement de l’adresse de retour. L’outil doit démarrer à partir de l’état non initialisé. Il parcourt les entrées du CIE pour obtenir l’état initial lors de l’entrée de la fonction, puis il passe au traitement du FDE en commençant par la première entrée du FDE et traite les opérations jusqu’à ce qu’il atteigne la ligne qui couvre le pointeur d’instruction en cours d’analyse. .

    Exemple d’utilisation des informations sur la trame d’appel

    Commencez par un exemple simple avec une fonction qui convertit Fahrenheit en Celsius. Les fonctions en ligne n’ont pas d’entrées dans le CFI, donc le __attribute__((noinline)) pour le f2c fonction garantit que le compilateur conserve f2c comme fonction réelle.

    #include <stdio.h>
    
    int __attribute__ ((noinline)) f2c(int f)
    {
        int c;
        printf("converting\n");
        c = (f-32.0) * 5.0 /9.0;
        return c;
    }
    
    int main (int argc, char *argv[])
    {
        int f;
        scanf("%d", &f);
        printf ("%d Fahrenheit = %d Celsius\n",
                f, f2c(f));
        return 0;
    }

    Compilez le code avec :

    $ gcc -O2 -g -o f2c f2c.c

    Le .eh_frame y a-t-il comme prévu :

    $ eu-readelf -S f2c |grep eh_frame
    [17] .eh_frame_hdr  PROGBITS   0000000000402058 00002058 00000034  0 A  0   0  4
    [18] .eh_frame      PROGBITS   0000000000402090 00002090 000000a0  0 A  0   0  8

    Nous pouvons obtenir les informations CFI sous une forme lisible par l’homme avec :

    $ readelf --debug-dump=frames  f2c > f2c.cfi

    Générer un fichier de démontage du f2c binaire afin que vous puissiez rechercher les adresses des f2c et main les fonctions:

    $ objdump -d f2c > f2c.dis

    Trouvez les lignes suivantes dans f2c.dis voir le début de f2c et main:

    0000000000401060 <main>:
    0000000000401190 <f2c>:

    Dans de nombreux cas, toutes les fonctions du binaire utilisent le même CIE pour définir les conditions initiales avant l’exécution de la première instruction d’une fonction. Dans cet exemple, les deux f2c et main utilisez le CIE suivant :

    00000000 0000000000000014 00000000 CIE
      Version:                   1
      Augmentation:              "zR"
      Code alignment factor: 1
      Data alignment factor: -8
      Return address column: 16
      Augmentation data:         1b
      DW_CFA_def_cfa: r7 (rsp) ofs 8
      DW_CFA_offset: r16 (rip) at cfa-8
      DW_CFA_nop
      DW_CFA_nop

    Pour cet exemple, ne vous souciez pas des entrées de données Augmentation ou Augmentation. Étant donné que les processeurs x86_64 ont des instructions de longueur variable de 1 à 15 octets, le «facteur d’alignement du code» est défini sur 1. Sur un processeur qui n’a que 32 bits (instructions de 4 octets), il serait défini sur 4 et permettrait un codage plus compact du nombre d’octets auxquels s’applique une ligne d’informations d’état. De la même manière, il existe le «facteur d’alignement des données» pour rendre les ajustements à l’emplacement du CFA plus compacts. Sur x86_64, les emplacements de pile ont une taille de 8 octets.

    La colonne de la table virtuelle qui contient l’adresse de retour est 16. Ceci est utilisé dans les instructions à la fin du CIE. Ils sont quatre DW_CFA instructions. La première consigne, DW_CFA_def_cfa décrit comment calculer l’adresse de trame canonique (CFA) vers laquelle un pointeur de trame pointerait si le code avait un pointeur de trame. Dans ce cas, le CFA est calculé à partir de r7 (rsp) et CFA=rsp+8.

    La deuxième consigne DW_CFA_offset définit où obtenir l’adresse de retour CFA-8. Dans ce cas, l’adresse de retour est actuellement pointée par le pointeur de pile (rsp+8)-8. Le CFA commence juste au-dessus de l’adresse de retour sur la pile.

    Le DW_CFA_nop à la fin du CIE se trouve un rembourrage pour maintenir l’alignement dans les informations DWARF. Le FDE peut également avoir un rembourrage à la fin de l’alignement.

    Trouver le FDE pour main dans f2c.cfiqui couvre le main fonction de 0x40160 jusqu’à, mais non compris, 0x401097:

    00000084 0000000000000014 00000088 FDE cie=00000000 pc=0000000000401060..0000000000401097
      DW_CFA_advance_loc: 4 to 0000000000401064
      DW_CFA_def_cfa_offset: 32
      DW_CFA_advance_loc: 50 to 0000000000401096
      DW_CFA_def_cfa_offset: 8
      DW_CFA_nop

    Avant d’exécuter la première instruction de la fonction, le CIE décrit l’état de la trame d’appel. Cependant, au fur et à mesure que le processeur exécute des instructions dans la fonction, les détails changent. D’abord les consignes DW_CFA_advance_loc et DW_CFA_def_cfa_offset correspondre à la première instruction dans main à 401060. Ceci ajuste le pointeur de pile vers le bas de 0x18 (24 octets). Le CFA n’a pas changé d’emplacement mais le pointeur de pile l’a fait, donc le calcul correct pour CFA à 401064 est rsp+32. C’est l’étendue de l’instruction de prologue dans ce code. Voici les premières instructions dans main:

    0000000000401060 <main>:
      401060:    48 83 ec 18      sub        $0x18,%rsp
      401064:    bf 1b 20 40 00   mov        $0x40201b,%edi

    Le DW_CFA_advance_loc applique la ligne actuelle aux 50 octets de code suivants dans la fonction, jusqu’à ce que 401096. Le CFA est à rsp+32 jusqu’à ce que l’instruction de réglage de la pile à 401092 achève l’exécution. Le DW_CFA_def_cfa_offset met à jour les calculs du CFA de la même manière que l’entrée dans la fonction. Ceci est attendu, car la prochaine instruction à 401096 est l’instruction de retour (ret) et extrait la valeur de retour de la pile.

      401090:    31 c0        xor        %eax,%eax
      401092:    48 83 c4 18  add        $0x18,%rsp
      401096:    c3           ret

    Ce FDE pour f2c fonction utilise le même CIE que le main fonction, et couvre la gamme de 0x41190 pour 0x4011c3:

    00000068 0000000000000018 0000006c FDE cie=00000000 pc=0000000000401190..00000000004011c3
      DW_CFA_advance_loc: 1 to 0000000000401191
      DW_CFA_def_cfa_offset: 16
      DW_CFA_offset: r3 (rbx) at cfa-16
      DW_CFA_advance_loc: 29 to 00000000004011ae
      DW_CFA_def_cfa_offset: 8
      DW_CFA_nop
      DW_CFA_nop
      DW_CFA_nop

    Le objdump sortie pour le f2c fonction en binaire :

    0000000000401190 <f2c>:
      401190:	53                   	push   %rbx
      401191:	89 fb                	mov    %edi,%ebx
      401193:	bf 10 20 40 00       	mov    $0x402010,%edi
      401198:	e8 93 fe ff ff       	call   401030 <puts@plt>
      40119d:	66 0f ef c0          	pxor   %xmm0,%xmm0
      4011a1:	f2 0f 2a c3          	cvtsi2sd %ebx,%xmm0
      4011a5:	f2 0f 5c 05 93 0e 00 	subsd  0xe93(%rip),%xmm0        # 402040 <__dso_handle+0x38>
      4011ac:	00 
      4011ad:	5b                   	pop    %rbx
      4011ae:	f2 0f 59 05 92 0e 00 	mulsd  0xe92(%rip),%xmm0        # 402048 <__dso_handle+0x40>
      4011b5:	00 
      4011b6:	f2 0f 5e 05 92 0e 00 	divsd  0xe92(%rip),%xmm0        # 402050 <__dso_handle+0x48>
      4011bd:	00 
      4011be:	f2 0f 2c c0          	cvttsd2si %xmm0,%eax
      4011c2:	c3                   	ret

    Dans le FDE pour f2cil y a une instruction d’un seul octet au début de la fonction avec le DW_CFA_advance_loc. A la suite de l’opération d’avance, il y a deux opérations supplémentaires. UN DW_CFA_def_cfa_offset change le CFA en %rsp+16 et un DW_CFA_offset indique que la valeur initiale dans %rbx est maintenant à CFA-16 (le haut de la pile).

    En regardant ça fc2 code de démontage, vous pouvez voir qu’un push sert à sauvegarder %rbx sur la pile. L’un des avantages de l’omission du pointeur de cadre dans la génération de code est que des instructions compactes telles que push et pop peut être utilisé pour stocker et récupérer des valeurs de la pile. Dans ce cas, %rbx est sauvé parce que le %rbx est utilisé pour passer des arguments au printf fonction (en fait convertie en une puts appel), mais la valeur initiale de f passé dans la fonction doit être enregistré pour le calcul ultérieur. Le DW_CFA_advance_loc 29 octets à 4011ae montre le prochain changement d’état juste après pop %rbxqui récupère la valeur originale de %rbx. Le DW_CFA_def_cfa_offset note que la pop a changé CFA pour être %rsp+8.

    GDB utilisant les informations de trame d’appel

    Le fait de disposer des informations CFI permet à GNU Debugger (GDB) et à d’autres outils de générer des backtraces précises. Sans informations CFI, GDB aurait du mal à trouver l’adresse de retour. Vous pouvez voir que GDB utilise ces informations si vous définissez un point d’arrêt à la ligne 7 de f2c.c. GDB place le point d’arrêt avant le pop %rbx dans le f2c fonction est terminée et la valeur de retour n’est pas en haut de la pile.

    GDB est capable de dérouler la pile, et en prime est également capable de récupérer l’argument f qui était actuellement enregistré sur la pile :

    $ gdb f2c
    [...]
    (gdb) break f2c.c:7
    Breakpoint 1 at 0x40119d: file f2c.c, line 7.
    (gdb) run
    Starting program: /home/wcohen/present/202207youarehere/f2c
    [Thread debugging using libthread_db enabled]
    Using host libthread_db library "/lib64/libthread_db.so.1".
    98
    converting
    
    Breakpoint 1, f2c (f=98) at f2c.c:8
    8            return c;
    (gdb) where
    #0  f2c (f=98) at f2c.c:8
    #1  0x000000000040107e in main (argc=<optimized out>, argv=<optimized out>)
            at f2c.c:15

    Informations sur la trame d’appel

    Les informations de trame d’appel DWARF fournissent un moyen flexible pour un compilateur d’inclure des informations pour un déroulement précis de la pile. Cela permet de déterminer les appels de fonction actuellement actifs. J’ai fourni une brève introduction dans cet article, mais pour plus de détails sur la façon dont le DWARF implémente ce mécanisme, voir le Spécification naine.

    Source

    Houssen Moshinaly

    Pour contacter personnellement le taulier :

    Laisser un commentaire

    Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

    Copy code