编译器优化及其对调试器行信息的影响

了解如何解读调试行信息的输出,以及可以使用哪些工具来深入了解。
2 位读者喜欢此文。
magnifying glass on computer screen, finding a bug in the code

Opensource.com

在我的上一篇文章中,我描述了用于将可执行二进制文件和其源代码之间的常规函数和内联函数进行映射的 DWARF 信息。函数可能包含数十行代码,因此您可能想具体了解处理器在源代码中的位置。编译器包含指令和源代码中的特定行之间的映射信息,以提供精确定位。在本文中,我将描述行映射信息,以及由编译器优化引起的一些问题。

从上一篇文章中的相同示例代码开始

#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;
}

只有在启用调试信息(-g 选项)的情况下编译代码时,编译器才会包含行映射信息

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

检查行号信息

行信息以机器可读的格式存储,但可以使用 llvm-objdumpodjdump 生成人类可读的输出。

$ llvm-objdump --line-numbers example

对于 main 函数,您可以获得输出,其中列出了汇编代码指令以及与该指令关联的文件和行号

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

位于 0x401060 的第一条指令映射到原始源代码文件 example.c 的第 9 行,即 main 函数的开括号 {

下一条指令 0x401061 映射到 stdlib.h 第 364 行,即内联 atoi 函数。 这正在设置稍后 strtol 调用的参数之一。

指令 0x401065 也与 main 函数的开括号 { 相关联。

指令 0x4010680x40106d 设置 strtol 调用的其余参数,该调用发生在 0x40106f。 在这种情况下,您可以看到编译器已重新排序指令,并导致在您单步执行调试器中的指令时,在 example.c 的第 9 行和 stdlib.h 包含文件第 364 行之间来回跳动。

您还可以看到在上面的 llvm-objdump 输出中,来自 example.c 的第 12、13 和 14 行的指令混合在一起。 编译器已将第 13 行的除法指令 (0x40190) 移动到第 12 行的一些指令之前,以隐藏除法的延迟。 当您在调试器中单步执行此代码时,您会看到调试器在各行之间来回跳转,而不是先执行一行中的所有指令,然后再移动到下一行。 另请注意,当您单步执行时,未显示带有除法运算的第 13 行,但肯定发生了除法以产生输出。 您可以看到 GDB 在单步执行程序的 main 函数时在各行之间跳动

(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>

通过这个简单的例子,您可以看到指令的顺序与原始源代码不匹配。当程序正常运行时,您永远不会观察到这些变化。但是,当使用调试器单步执行代码时,它们非常明显。代码行之间的边界变得模糊。这还有其他含义。当您决定在变量更新行后面的行上设置断点时,编译器调度程序可能已将变量移动到您期望变量更新的位置之后,并且您在断点处没有获得变量的预期值。

行中的哪些指令会得到断点?

对于前面的 example.c,编译器生成了多个指令来实现各个代码行。调试器如何知道应该将断点放在哪些指令上?行信息中还有一个额外的语句标志,用于标记放置断点的推荐位置。您可以在 eu-readelf --debug-dump=decodedline exampleSBPE 下面的列中看到用 S 标记的那些指令

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>

  • 指令组由这些指令的源文件路径分隔。
  • 左侧列包含指令映射回的行号和列,后跟标志。
  • 十六进制数是指令的地址,后跟指令在函数中的偏移量。

如果您仔细查看输出,您会看到一些指令映射回代码中的多行。 例如,0x0000000000401060 映射到 example.c 的第 9 行和第 10 行。 同一条指令也映射到 /usr/include/stdlib.h 的第 362 行和第 364 行。 映射不是一对一的。 一行源代码可以映射到多个指令,而一条指令可以映射到多行代码。 当调试器决定打印指令的单行映射时,它可能不是您期望的那个。

合并和消除行

正如您在详细行映射信息的输出中所看到的,映射不是一对一的。 在某些情况下,编译器可以消除指令,因为它们对程序的最终结果没有影响。 编译器还可以通过优化(例如公共子表达式消除 (CSE))来合并来自不同行的指令,并省略该指令可能来自代码中的多个位置。

以下示例是在 x86_64 Fedora 36 机器上使用 GCC-12.2.1 编译的。 根据具体环境,您可能无法获得相同的结果,因为不同版本的编译器可能会以不同的方式优化代码。

注意代码中的 if-else 语句。 两者都有执行相同昂贵除法的语句。 编译器将除法运算分解出来。

#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;
}

查看 objdump -dl whichline,您会在二进制文件中看到一个除法运算

/home/wcohen/present/202207youarehere/whichline.c:13
  401085:    b8 64 00 00 00  		 mov	$0x64,%eax
  40108a:    f7 fb           		 idiv   %ebx

第 13 行是具有除法的行之一,但您可能会怀疑还有其他与这些地址关联的行号。 查看 eu-readelf --debug-dump=decodedline whichline 的输出,以查看是否还有其他与这些地址关联的行号。

此列表中没有发生另一个除法的第 11 行

  /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>

如果结果未使用,编译器可能会完全消除某些行的代码生成。

考虑以下示例,其中 else 子句计算 c = 100 * a,但不使用它

#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;
}

使用 GCC 编译 eliminate.c

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

在查看 objdump -dl eliminate 生成的输出时,没有 eliminate.c100 * a (第 14 行) 的乘法迹象。 编译器已确定该值未使用并已将其消除。

在查看 objdump -dl eliminate 的输出时,没有

/home/wcohen/present/202207youarehere/eliminate.c:14

也许它隐藏为行信息的其他视图之一。 您可以使用带有 --debug-dump 选项的 eu-readelf 来获取行信息的完整视图

$ eu-readelf --debug-dump=decodedline eliminate > eliminate.lines

事实证明 GCC 确实记录了一些映射信息。 似乎 0x4010a5 映射到乘法语句,以及第 15 行的 printf

 /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>

优化会影响行信息

编译后的二进制文件中包含的行信息有助于查明处理器在代码中的位置。 但是,优化会影响行信息,以及您在调试代码时看到的内容。

使用调试器时,预计代码行之间的边界是模糊的,并且调试器在单步执行代码时可能会在它们之间跳动。 指令可能映射到多行源代码,但调试器可能仅报告一行。 编译器可能会完全消除与代码行关联的指令,并且它可能包含也可能不包含行映射信息。 编译器生成的行信息很有用,但请记住,在转换中可能会丢失一些内容。

接下来阅读什么
标签
Will Cohen with sunflowers
William Cohen 十多年来一直是 Red Hat 的性能工具开发人员,并且从事过 Red Hat Enterprise Linux 和 Fedora 中的许多性能工具,例如 OProfile、PAPI、SystemTap 和 Dyninst。

评论已关闭。

Creative Commons 许可协议本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。
© . All rights reserved.