编译器通常用于将人类可读的源代码转换为一系列由计算机直接执行的指令。一个常见的问题是“调试器和错误处理程序如何报告处理器当前所处源代码中的位置?” 有多种方法可以将指令映射回源代码中的位置。随着编译器优化代码,将指令映射回源代码也存在一些复杂性。本系列文章的第一篇介绍了工具如何将程序计数器(也称为指令指针)映射回函数名。本系列后续文章将介绍如何将程序计数器映射回源文件中的特定行。它还提供了回溯,描述了导致处理器处于当前函数中的一系列调用。
函数入口符号
当将源代码转换为可执行二进制文件时,编译器会保留有关每个函数入口的符号信息。保留此信息使得链接器可以将多个目标文件(.o
后缀)组装成单个可执行文件。在目标文件被链接器处理之前,函数和变量的最终地址是未知的。目标文件为尚未具有地址的符号保留占位符。链接器将在创建可执行文件时解析地址。假设生成的可执行文件未被剥离,则描述每个函数入口的那些符号仍然可用。您可以通过编译以下简单程序来查看此示例
#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;
}
编译代码
$ gcc -O2 example.c -o example
您可以使用以下命令查看 main 函数从 0x401060
开始的位置
$ nm example |grep main
U __libc_start_main@GLIBC_2.34
0000000000401060 T main
即使代码在没有调试信息(-g
选项)的情况下编译,gdb
仍然可以使用符号信息找到 main 函数的起始位置
$ gdb example
GNU gdb (GDB) Fedora 12.1-1.fc36
…
(No debugging symbols found in example)
(gdb) break main
Breakpoint 1 at 0x401060
使用 GDB nexti
命令单步执行代码。GDB 报告它当前在 main
函数中的地址
(gdb) nexti
0x0000000000401061 in main ()
(gdb) nexti
0x0000000000401065 in main ()
(gdb) where
#0 0x0000000000401065 in main ()
这种最少的信息很有用,但并不理想。编译器可以优化函数并将函数拆分为非连续的 sections,以使与函数关联的代码与列出的函数入口没有明显的关联。函数指令的一部分可能被其他函数的入口与函数入口分隔开。此外,编译器可能会生成与原始函数名不直接匹配的替代名称。这使得更难以确定指令与源代码中的哪个函数相关联。
DWARF 信息
使用 -g
选项编译的代码包含额外的的信息,用于在源代码和二进制可执行文件之间进行映射。默认情况下,在 Fedora 上编译的代码的 RPM 启用了调试信息生成。然后,此信息被放入单独的 debuginfo
RPM 中,可以将其安装为包含二进制文件的 RPM 的补充。这使得分析崩溃转储和调试程序更加容易。使用 debuginfo,您可以获得映射回特定函数名称的地址范围。它还提供了每个指令映射回的行号和文件名。映射信息以 DWARF 标准 编码。
DWARF 函数描述
对于每个具有函数入口的函数,都有一个 DWARF 调试信息条目 (DIE) 对其进行描述。此信息采用机器可读格式,但有许多工具(包括 llvm-dwarfdump
和 eu-readelf
)可以生成 DWARF 调试信息的人类可读输出。以下是示例 main 函数 DIE 的 llvm-dwarfdump
输出,描述了早期 example.c
程序的 main 函数。
DIE 以 DW_TAG_subprogram
开头,表示它描述一个函数。还有其他类型的 DWARF 标签用于描述程序的其他组件,例如类型和变量。
函数的 DIE 有多个属性,每个属性都以 DW_AT_
开头,描述函数的特征。这些属性提供有关函数的信息,例如函数在可执行二进制文件中的位置。它还指示在原始源代码中查找它的位置。
从 DW_TAG_subprogram
向下几行是 DW_AT_name
属性,它将源代码函数名称描述为 main
。DW_AT_decl_file
和 DW_AT_decl_line
DWARF 属性分别描述函数来自的文件和行号。这允许调试器在文件中找到适当的位置,以向您显示与该函数关联的源代码。列信息也包含在 DW_AT_decl_column
中。
用于在二进制指令和源代码之间进行映射的其他关键信息是 DW_AT_low_pc
和 DW_AT_high_pc
属性。DW_AT_low_pc
和 DW_AT_high_pc
的使用表明此函数的代码是连续的,范围从 0x401060(与之前 nm
命令提供的值相同)到但不包括 0x4010b7。DW_AT_ranges
属性用于描述函数是否覆盖非连续区域。
使用程序计数器,您可以将处理器的程序计数器映射到函数名,并找到函数所在的文件和行号
$ llvm-dwarfdump example --name=main
example: file format elf64-x86-64
0x00000113: DW_TAG_subprogram
DW_AT_external (true)
DW_AT_name ("main")
DW_AT_decl_file ("/home/wcohen/present/202207youarehere/example.c")
DW_AT_decl_line (8)
DW_AT_decl_column (0x01)
DW_AT_prototyped (true)
DW_AT_type (0x00000031 "int")
DW_AT_low_pc (0x0000000000401060)
DW_AT_high_pc (0x00000000004010b7)
DW_AT_frame_base (DW_OP_call_frame_cfa)
DW_AT_call_all_calls (true)
DW_AT_sibling (0x000001ea)
内联函数
编译器可以通过将对另一个函数的调用替换为实现被调用函数操作的指令来优化代码。内联函数消除了由函数调用和返回指令引起的控制流更改,以实现传统的函数调用。对于内联函数,无需执行额外的函数序言和尾声指令来符合传统函数调用的应用程序二进制接口 (ABI)。
内联函数还为优化提供了额外的机会,因为编译器可以在调用者和内联调用的函数之间混合指令。这提供了可以安全消除哪些代码的完整视图。但是,如果您仅使用 DW_TAG_subprogram
描述的实际函数的地址范围,那么您可能会错误地将指令归因于调用内联函数的函数,而不是实际包含它的内联函数。因此,DWARF 具有 DW_TAG_inlined_subroutine
来提供有关内联函数的信息。
令人惊讶的是,即使是本文提供的简单示例 example.c
,在生成的代码中也具有内联函数 atoi
和 atof
。下面的代码块显示了 atoi
的 llvm-dwarfdump
输出。它包含两个部分,一个 DW_TAG_inlined_subroutine
用于描述 atoi
实际内联的每个位置,以及一个 DW_TAG_subprogram
用于描述在多个内联副本之间不变的通用信息。
DW_TAG_inlined_subroutine
中的 DW_AT_abstract_origin
指向关联的 DW_TAG_subprogram
,后者描述了包含 DW_AT_decl_file
和 DW_AT_decl_line
的文件,就像描述常规函数的 DWARF DIE 一样。在这种情况下,您会看到此内联函数来自系统文件 /usr/include/stdlib.h
的第 362 行。
与 atof
关联的实际地址范围是非连续的,由 DW_AT_ranges
描述,[0x401060,0x401060)
、[0x401061, 0x401065)
、[0x401068,0x401074)
和 [0x40107a,0x41080)
。DW_TAG_inlined_subroutine
具有 DW_AT_entry_pc
以指示哪个位置被认为是内联函数的开始。由于编译器重新排序指令,因此可能不清楚哪个被认为是内联函数的第一个指令
$ llvm-dwarfdump example --name=atoi
example: file format elf64-x86-64
0x00000159: DW_TAG_inlined_subroutine
DW_AT_abstract_origin (0x00000208 "atoi")
DW_AT_entry_pc (0x0000000000401060)
DW_AT_GNU_entry_view (0x02)
DW_AT_ranges (0x0000000c
[0x0000000000401060, 0x0000000000401060)
[0x0000000000401061, 0x0000000000401065)
[0x0000000000401068, 0x0000000000401074)
[0x000000000040107a, 0x0000000000401080))
DW_AT_call_file ("/home/wcohen/present/202207youarehere/example.c")
DW_AT_call_line (10)
DW_AT_call_column (6)
DW_AT_sibling (0x00000196)
0x00000208: DW_TAG_subprogram
DW_AT_external (true)
DW_AT_name ("atoi")
DW_AT_decl_file ("/usr/include/stdlib.h")
DW_AT_decl_line (362)
DW_AT_decl_column (0x01)
DW_AT_prototyped (true)
DW_AT_type (0x00000031 "int")
DW_AT_inline (DW_INL_declared_inlined)
内联函数的影响
大多数程序员认为处理器在移动到下一行之前完全执行源代码中的一行。同样,使用预期的函数 call ABI
,程序员认为 caller
函数在调用之前的语句中完成操作,并且在 callee
函数返回之前不会启动调用之后的语句,这可能不成立。使用内联函数,函数之间的边界变得模糊。来自 caller
函数的指令可能会在来自内联函数的指令之前或之后被调度,而不管它们在源代码中的位置如何,如果编译器确定最终结果将相同。当检查内联函数之前和之后的变量时,这可能会导致意外的值。
延伸阅读
本文介绍了 DWARF 如何实现源代码和可执行二进制文件之间映射的一个非常小的部分。作为了解更多关于 DWARF 的起点,您可以阅读 Michael J. Eager 的 DWARF 调试格式简介。请关注即将发布的关于函数中的指令如何映射回源代码以及如何生成回溯的文章。
评论已关闭。