Optimisation du compilateur et son effet sur les informations de ligne du débogueur

Dans mon article précédent, j’ai décrit les informations DWARF utilisées pour mapper des fonctions régulières et en ligne entre un binaire exécutable et son code source. Les fonctions peuvent être des dizaines de lignes, vous aimeriez donc savoir précisément où se trouve le processeur dans votre code source. Le compilateur inclut un mappage d’informations entre les instructions et des lignes spécifiques dans le code source pour fournir un emplacement précis. Dans cet article, je décris les informations de mappage de ligne et certains des problèmes causés par les optimisations du compilateur.
Commencez avec le même exemple de code de l’article précédent :
#include <stdlib.h>
#include <stdio.h>
int a;
double b;
int
main(int argc, char* argv[])
{
a = atoi(argv[1]);
b = atof(argv[2]);
a = a + 1;
b = b / 42.0;
printf ("a = %d, b = %f\n", a, b);
return 0;
}
Le compilateur n’inclut les informations de mappage de ligne que lorsque le code est compilé avec les informations de débogage activées (le -g
option):
$ gcc -O2 -g example.c -o example
Examen des informations de numéro de ligne
Les informations de ligne sont stockées dans un format lisible par machine, mais une sortie lisible par l’homme peut être générée avec llvm-objdump
ou odjdump
.
$ llvm-objdump --line-numbers example
Pour la fonction principale, vous obtenez une sortie répertoriant l’instruction de code assembleur avec le numéro de fichier et de ligne associé à l’instruction :
0000000000401060 <main>:
; main():
; /home/wcohen/present/202207youarehere/example.c:9
401060: 53 pushq %rbx
; /usr/include/stdlib.h:364
401061: 48 8b 7e 08 movq 8(%rsi), %rdi
; /home/wcohen/present/202207youarehere/example.c:9
401065: 48 89 f3 movq %rsi, %rbx
; /usr/include/stdlib.h:364
401068: ba 0a 00 00 00 movl $10, %edx
40106d: 31 f6 xorl %esi, %esi
40106f: e8 dc ff ff ff callq 0x401050 <strtol@plt>
; /usr/include/bits/stdlib-float.h:27
401074: 48 8b 7b 10 movq 16(%rbx), %rdi
401078: 31 f6 xorl %esi, %esi
; /usr/include/stdlib.h:364
40107a: 89 05 c8 2f 00 00 movl %eax, 12232(%rip) # 0x404048 <a>
; /usr/include/bits/stdlib-float.h:27
401080: e8 ab ff ff ff callq 0x401030 <strtod@plt>
; /home/wcohen/present/202207youarehere/example.c:12
401085: 8b 05 bd 2f 00 00 movl 12221(%rip), %eax # 0x404048 <a>
; /home/wcohen/present/202207youarehere/example.c:14
40108b: bf 10 20 40 00 movl $4202512, %edi # imm = 0x402010
; /home/wcohen/present/202207youarehere/example.c:13
401090: f2 0f 5e 05 88 0f 00 00 divsd 3976(%rip), %xmm0 # 0x402020 <__dso_handle+0x18>
401098: f2 0f 11 05 a0 2f 00 00 movsd %xmm0, 12192(%rip) # 0x404040 <b>
; /home/wcohen/present/202207youarehere/example.c:12
4010a0: 8d 70 01 leal 1(%rax), %esi
; /home/wcohen/present/202207youarehere/example.c:14
4010a3: b8 01 00 00 00 movl $1, %eax
; /home/wcohen/present/202207youarehere/example.c:12
4010a8: 89 35 9a 2f 00 00 movl %esi, 12186(%rip) # 0x404048 <a>
; /home/wcohen/present/202207youarehere/example.c:14
4010ae: e8 8d ff ff ff callq 0x401040 <printf@plt>
; /home/wcohen/present/202207youarehere/example.c:16
4010b3: 31 c0 xorl %eax, %eax
4010b5: 5b popq %rbx
4010b6: c3
La première consigne à 0x401060
correspond au fichier de code source d’origine example.c
ligne 9, l’ouverture {
pour la fonction principale.
La prochaine consigne 0x401061
correspond à la ligne 364 de stdlib.h
ligne 364, l’inline atoi
fonction. C’est la mise en place de l’un des arguments à la suite strtol
appel.
L’instruction 0x401065
est également associé à l’ouverture {
de la fonction principale.
Instructions 0x401068
et 0x40106d
définir les arguments restants pour le strtol
appel qui a lieu à 0x40106f
. Dans ce cas, vous pouvez voir que le compilateur a réorganisé les instructions et provoque des rebonds entre la ligne 9 de example.c
et la ligne 364, ou le stdlib.h
include, au fur et à mesure que vous parcourez les instructions du débogueur.
Vous pouvez également voir un mélange d’instructions pour les lignes 12, 13 et 14 de example.c
dans la sortie de llvm-objdump
au-dessus de. Le compilateur a déplacé les instructions de division (0x40190
) pour la ligne 13 avant certaines des instructions de la ligne 12 pour masquer la latence de la division. Au fur et à mesure que vous parcourez les instructions du débogueur pour ce code, vous voyez le débogueur sauter d’une ligne à l’autre plutôt que de suivre toutes les instructions d’une ligne avant de passer à la ligne suivante. Notez également que lorsque vous avancez, la ligne 13 avec l’opération de division n’a pas été affichée, mais la division s’est définitivement produite pour produire la sortie. Vous pouvez voir GDB rebondir entre les lignes lorsque vous parcourez la fonction principale du programme :
(gdb) run 1 2
Starting program: /home/wcohen/present/202207youarehere/example 1 2
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib64/libthread_db.so.1".
Breakpoint 1, main (argc=3, argv=0x7fffffffdbe8) at /usr/include/stdlib.h:364
364 return (int) strtol (__nptr, (char **) NULL, 10);
(gdb) print $pc
$10 = (void (*)()) 0x401060 <main>
(gdb) next
10 a = atoi(argv[1]);
(gdb) print $pc
$11 = (void (*)()) 0x401061 <main+1>
(gdb) next
11 b = atof(argv[2]);
(gdb) print $pc
$12 = (void (*)()) 0x401074 <main+20>
(gdb) next
10 a = atoi(argv[1]);
(gdb) print $pc
$13 = (void (*)()) 0x40107a <main+26>
(gdb) next
11 b = atof(argv[2]);
(gdb) print $pc
$14 = (void (*)()) 0x401080 <main+32>
(gdb) next
12 a = a + 1;
(gdb) print $pc
$15 = (void (*)()) 0x401085 <main+37>
(gdb) next
14 printf ("a = %d, b = %f\n", a, b);
(gdb) print $pc
$16 = (void (*)()) 0x4010ae <main+78>
(gdb) next
a = 2, b = 0.047619
15 return 0;
(gdb) print $pc
$17 = (void (*)()) 0x4010b3 <main+83>
Avec cet exemple simple, vous pouvez voir que l’ordre des instructions ne correspond pas au code source d’origine. Lorsque le programme s’exécute normalement, vous n’observerez jamais ces changements. Cependant, ils sont assez visibles lors de l’utilisation d’un débogueur pour parcourir le code. Les frontières entre les lignes de code deviennent floues. Cela a d’autres implications. Lorsque vous décidez de définir un point d’arrêt sur une ligne suivant une ligne avec une mise à jour de variable, le planificateur du compilateur peut avoir déplacé la variable après l’emplacement où vous vous attendez à ce que la variable soit mise à jour, et vous n’obtenez pas la valeur attendue pour la variable à la point d’arrêt.
Laquelle des instructions d’une ligne obtient le point d’arrêt ?
Avec le précédent example.c
, le compilateur a généré plusieurs instructions pour implémenter des lignes de code individuelles. Comment le débogueur sait-il laquelle de ces instructions doit être celle sur laquelle il place le point d’arrêt ? Il existe un indicateur d’instruction supplémentaire dans les informations de ligne qui marque les emplacements recommandés pour placer les points d’arrêt. Vous pouvez voir ces instructions marquées par S
dans la colonne ci-dessous SBPE
dans eu-readelf --debug-dump=decodedline example
:
DWARF section [31] '.debug_line' at offset 0x50fd:
CU [c] example.c
line:col SBPE* disc isa op address (Statement Block Prologue Epilogue *End)
/home/wcohen/present/202207youarehere/example.c (mtime: 0, length: 0)
9:1 S 0 0 0 0x0000000000401060 <main>
10:2 S 0 0 0 0x0000000000401060 <main>
/usr/include/stdlib.h (mtime: 0, length: 0)
362:1 S 0 0 0 0x0000000000401060 <main>
364:3 S 0 0 0 0x0000000000401060 <main>
/home/wcohen/present/202207youarehere/example.c (mtime: 0, length: 0)
9:1 0 0 0 0x0000000000401060 <main>
/usr/include/stdlib.h (mtime: 0, length: 0)
364:16 0 0 0 0x0000000000401061 <main+0x1>
364:16 0 0 0 0x0000000000401065 <main+0x5>
/home/wcohen/present/202207youarehere/example.c (mtime: 0, length: 0)
9:1 0 0 0 0x0000000000401065 <main+0x5>
/usr/include/stdlib.h (mtime: 0, length: 0)
364:16 0 0 0 0x0000000000401068 <main+0x8>
364:16 0 0 0 0x000000000040106f <main+0xf>
364:16 0 0 0 0x0000000000401074 <main+0x14>
/usr/include/bits/stdlib-float.h (mtime: 0, length: 0)
27:10 0 0 0 0x0000000000401074 <main+0x14>
/usr/include/stdlib.h (mtime: 0, length: 0)
364:10 0 0 0 0x000000000040107a <main+0x1a>
/home/wcohen/present/202207youarehere/example.c (mtime: 0, length: 0)
11:2 S 0 0 0 0x0000000000401080 <main+0x20>
/usr/include/bits/stdlib-float.h (mtime: 0, length: 0)
25:1 S 0 0 0 0x0000000000401080 <main+0x20>
27:3 S 0 0 0 0x0000000000401080 <main+0x20>
27:10 0 0 0 0x0000000000401080 <main+0x20>
27:10 0 0 0 0x0000000000401085 <main+0x25>
/home/wcohen/present/202207youarehere/example.c (mtime: 0, length: 0)
12:2 S 0 0 0 0x0000000000401085 <main+0x25>
12:8 0 0 0 0x0000000000401085 <main+0x25>
14:2 0 0 0 0x000000000040108b <main+0x2b>
13:8 0 0 0 0x0000000000401090 <main+0x30>
13:4 0 0 0 0x0000000000401098 <main+0x38>
12:8 0 0 0 0x00000000004010a0 <main+0x40>
14:2 0 0 0 0x00000000004010a3 <main+0x43>
12:4 0 0 0 0x00000000004010a8 <main+0x48>
13:2 S 0 0 0 0x00000000004010ae <main+0x4e>
14:2 S 0 0 0 0x00000000004010ae <main+0x4e>
15:2 S 0 0 0 0x00000000004010b3 <main+0x53>
16:1 0 0 0 0x00000000004010b3 <main+0x53>
16:1 0 0 0 0x00000000004010b6 <main+0x56>
16:1 * 0 0 0 0x00000000004010b6 <main+0x56>
- Les groupes d’instructions sont délimités par le chemin d’accès au fichier source de ces instructions.
- La colonne de gauche contient le numéro de ligne et la colonne auxquels l’instruction renvoie, suivis des drapeaux.
- Le nombre hexadécimal est l’adresse de l’instruction, suivie du décalage dans la fonction de l’instruction.
Si vous regardez attentivement la sortie, vous voyez que certaines instructions correspondent à plusieurs lignes dans le code. Par exemple, 0x0000000000401060
correspond aux lignes 9 et 10 de example.c
. La même instruction correspond également aux lignes 362 et 364 de /usr/include/stdlib.h
. Les mappages ne sont pas un à un. Une ligne de code source peut correspondre à plusieurs instructions et une instruction peut correspondre à plusieurs lignes de code. Lorsque le débogueur décide d’imprimer un mappage d’une seule ligne pour une instruction, ce n’est peut-être pas celui que vous attendez.
Fusion et suppression de lignes
Comme vous l’avez vu dans la sortie des informations de mappage de ligne détaillées, les mappages ne sont pas un à un. Il existe des cas où le compilateur peut éliminer des instructions car elles n’ont aucun effet sur le résultat final du programme. Le compilateur peut également fusionner des instructions à partir de lignes distinctes grâce à des optimisations, telles que l’élimination de sous-expressions communes (CSE), et omettre que l’instruction puisse provenir de plusieurs endroits dans le code.
L’exemple suivant a été compilé sur une machine x86_64 Fedora 36, en utilisant GCC-12.2.1. Selon l’environnement particulier, vous pouvez ne pas obtenir les mêmes résultats, car différentes versions de compilateurs peuvent optimiser le code différemment.
Noter la sinon déclaration dans le code. Les deux ont des déclarations faisant les mêmes divisions coûteuses. Le compilateur factorise l’opération de division.
#include <stdlib.h>
#include <stdio.h>
int
main(int argc, char* argv[])
{
int a,b,c;
a = atoi(argv[1]);
b = atoi(argv[2]);
if (b) {
c = 100/a;
} else {
c = 100/a;
}
printf ("a = %d, b = %d, c = %d\n", a, b, c);
return 0;
}
Regarder objdump -dl whichline
vous voyez une opération de division dans le binaire :
/home/wcohen/present/202207youarehere/whichline.c:13
401085: b8 64 00 00 00 mov $0x64,%eax
40108a: f7 fb idiv %ebx
La ligne 13 est l’une des lignes avec une division, mais vous pourriez soupçonner qu’il existe d’autres numéros de ligne associés à ces adresses. Regardez la sortie de eu-readelf --debug-dump=decodedline whichline
pour voir s’il existe d’autres numéros de ligne associés à ces adresses.
La ligne 11, où se produit l’autre division, n’est pas dans cette liste :
/usr/include/stdlib.h (mtime: 0, length: 0)
364:16 0 0 0 0x0000000000401082 <main+0x32>
364:16 0 0 0 0x0000000000401085 <main+0x35>
/home/wcohen/present/202207youarehere/whichline.c (mtime: 0, length: 0)
10:2 S 0 0 0 0x0000000000401085 <main+0x35>
13:3 S 0 0 0 0x0000000000401085 <main+0x35>
15:2 S 0 0 0 0x0000000000401085 <main+0x35>
13:5 0 0 0 0x0000000000401085 <main+0x35>
Si les résultats ne sont pas utilisés, le compilateur peut éliminer complètement la génération de code pour certaines lignes.
Prenons l’exemple suivant, où le autre la clause calcule c = 100 * a
mais ne l’utilise pas :
#include <stdlib.h>
#include <stdio.h>
int
main(int argc, char* argv[])
{
int a,b,c;
a = atoi(argv[1]);
b = atoi(argv[2]);
if (b) {
c = 100/a;
printf ("a = %d, b = %d, c = %d\n", a, b, c);
} else {
c = 100 * a;
printf ("a = %d, b = %d\n", a, b);
}
return 0;
}
Compiler eliminate.c
avec GCC :
$ gcc -O2 -g eliminate.c -o eliminate
Lorsque vous regardez à travers la sortie générée par objdump -dl eliminate
il n’y a aucun signe de multiplication pour 100 * a
(ligne 14) de eliminate.c
. Le compilateur a déterminé que la valeur n’était pas utilisée et l’a éliminée.
Lorsque vous regardez à travers la sortie de objdump -dl eliminate
il n’y a pas:
/home/wcohen/present/202207youarehere/eliminate.c:14
Peut-être est-il masqué comme l’une des autres vues des informations de ligne. Vous pouvez utiliser eu-readelf
avec le --debug-dump
option pour obtenir une vue complète des informations de la ligne :
$ eu-readelf --debug-dump=decodedline eliminate > eliminate.lines
Il s’avère que GCC a enregistré certaines informations de mappage. Il paraît que 0x4010a5
correspond à l’instruction de multiplication, en plus du printf à la ligne 15 :
/home/wcohen/present/202207youarehere/eliminate.c (mtime: 0, length: 0)
…
18:1 0 0 0 0x00000000004010a4 <main+0x54>
14:3 S 0 0 0 0x00000000004010a5 <main+0x55>
15:3 S 0 0 0 0x00000000004010a5 <main+0x55>
15:3 0 0 0 0x00000000004010b0 <main+0x60>
15:3 * 0 0 0 0x00000000004010b6 <main+0x66>
L’optimisation affecte les informations de ligne
Les informations de ligne incluses dans les binaires compilés sont utiles pour identifier où se trouve le processeur dans le code. Cependant, l’optimisation peut affecter les informations de ligne et ce que vous voyez lors du débogage du code.
Lorsque vous utilisez un débogueur, attendez-vous à ce que les limites entre les lignes de code soient floues et que le débogueur soit susceptible de rebondir entre elles lors de la progression dans le code. Une instruction peut correspondre à plusieurs lignes de code source, mais le débogueur ne peut en signaler qu’une seule. Le compilateur peut éliminer entièrement les instructions associées à une ligne de code, et il peut inclure ou non des informations de mappage de ligne. Les informations de ligne générées par le compilateur sont utiles, mais gardez à l’esprit que quelque chose peut être perdu lors de la traduction.