GDB 调试器和其他工具如何使用调用帧信息来确定活动的函数调用

从调试器中获取活动的函数调用。
1 位读者喜欢这篇文章。
Business woman on laptop sitting in front of window

图片由 Mapbox Uncharted ERG 提供, CC-BY 3.0 US

在我的上一篇文章中,我展示了 debuginfo 如何用于在当前指令指针 (IP) 和包含它的函数或行之间进行映射。这些信息对于显示处理器当前正在执行的代码非常有价值。然而,如果能更多地了解导致当前函数和行被执行的调用上下文,也将非常有帮助。

例如,假设库中的一个函数由于传递到该函数中的参数是一个空指针而导致非法内存访问。仅仅查看当前的函数和行只会显示故障是由尝试通过空指针访问触发的。但是,你真正想知道的是导致空指针访问的活动的函数调用的完整上下文,这样你就可以确定该空指针最初是如何传递到库函数中的。此上下文信息由回溯提供,允许你确定哪些函数可能对错误参数负责。

有一件事是肯定的:确定当前活动的函数调用是一项非常重要的操作。

函数激活记录

现代编程语言具有局部变量,并允许递归,其中函数可以调用自身。此外,并发程序具有多个线程,这些线程可能同时运行同一函数。在这种情况下,局部变量不能存储在全局位置。局部变量的位置对于函数的每次调用都必须是唯一的。以下是它的工作原理

  1. 编译器每次调用函数时都会生成一个函数激活记录,以将局部变量存储在唯一的位置。
  2. 为了提高效率,处理器堆栈用于存储函数激活记录。
  3. 当调用函数时,会在处理器堆栈的顶部为该函数创建一个新的函数激活记录。
  4. 如果该函数调用另一个函数,则新的函数激活记录将放置在现有函数激活记录的上方。
  5. 每次从函数返回时,其函数激活记录都会从堆栈中删除。

函数激活记录的创建由函数中称为序言的代码创建。函数激活记录的删除由函数后记处理。函数的主体可以利用在堆栈上为临时值和局部变量设置的内存。

函数激活记录的大小可以是可变的。对于某些函数,不需要存储局部变量的空间。理想情况下,函数激活记录只需要存储调用 *此* 函数的函数的返回地址。对于其他函数,可能需要大量空间来存储该函数的局部数据结构以及返回地址。帧大小的这种变化导致编译器使用帧指针来跟踪函数激活帧的起始位置。现在,函数序言代码还有一项额外的任务,即在为当前函数创建新的帧指针之前存储旧的帧指针,并且后记必须恢复旧的帧指针值。

函数激活记录的布局方式是,调用函数的返回地址和旧帧指针与当前帧指针的偏移量是恒定的。使用旧的帧指针,可以找到堆栈上的下一个函数的激活帧。重复此过程,直到检查完所有函数激活记录。

优化并发症

在代码中具有显式帧指针有两个缺点。在某些处理器上,可用的寄存器相对较少。具有显式帧指针会导致使用更多的内存操作。生成的代码速度较慢,因为帧指针必须位于其中一个寄存器中。具有显式帧指针可能会限制编译器可以生成的代码,因为编译器可能不会将函数序言和后记代码与函数主体混合在一起。

编译器的目标是在可能的情况下生成快速代码,因此编译器通常从生成的代码中省略帧指针。正如 Phoronix 的基准测试所显示的那样,保留帧指针会显着降低性能。省略帧指针的缺点是,查找先前调用函数的激活帧和返回地址不再是从帧指针的简单偏移量。

调用帧信息

为了帮助生成函数回溯,编译器包含 DWARF 调用帧信息 (CFI),以重建帧指针并查找返回地址。此补充信息存储在执行的 .eh_frame 部分中。与用于函数和行位置信息的传统 debuginfo 不同,即使在没有调试信息的情况下生成可执行文件,或者当调试信息已从文件中剥离时,.eh_frame 部分也位于可执行文件中。调用帧信息对于 C++ 中诸如 throw-catch 之类的语言构造的操作至关重要。

CFI 为每个函数都有一个帧描述条目 (FDE)。作为其步骤之一,回溯生成过程会找到正在检查的当前激活帧的相应 FDE。可以将 FDE 视为一个表,其中每一行代表一个或多个指令,具有以下列

  • 规范帧地址 (CFA),帧指针将指向的位置
  • 返回地址
  • 有关其他寄存器的信息

FDE 的编码旨在最大限度地减少所需的空间量。FDE 描述了行之间的变化,而不是完全指定每一行。为了进一步压缩数据,将多个 FDE 共有的起始信息分解出来并放入公共信息条目 (CIE) 中。这使 FDE 更加紧凑,但也需要更多的工作来计算实际的 CFA 并找到返回地址位置。该工具必须从未初始化的状态开始。它逐步执行 CIE 中的条目以获得函数条目的初始状态,然后继续处理 FDE,从 FDE 的第一个条目开始,并处理操作直到到达覆盖当前正在分析的指令指针的行。

调用帧信息的使用示例

从一个将华氏温度转换为摄氏温度的简单示例开始。内联函数在 CFI 中没有条目,因此 f2c 函数的 __attribute__((noinline)) 确保编译器将 f2c 保留为真实函数。

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

使用以下命令编译代码

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

.eh_frame 如预期存在

$ 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

我们可以使用以下命令以人类可读的形式获取 CFI 信息

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

生成 f2c 二进制文件的反汇编文件,以便您可以查找 f2cmain 函数的地址

$ objdump -d f2c > f2c.dis

f2c.dis 中找到以下行以查看 f2cmain 的开头

0000000000401060 <main>:
0000000000401190 <f2c>:

在许多情况下,二进制文件中的所有函数都使用相同的 CIE 来定义在执行函数的第一个指令之前的初始条件。在此示例中,f2cmain 都使用以下 CIE

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

对于此示例,请不要担心 Augmentation 或 Augmentation data 条目。由于 x86_64 处理器具有从 1 到 15 字节的可变长度指令,因此“代码对齐因子”设置为 1。在只有 32 位(4 字节指令)的处理器上,这将设置为 4,并且允许更紧凑地编码状态信息行应用于多少字节。以类似的方式,有一个“数据对齐因子”可以使对 CFA 所在位置的调整更加紧凑。在 x86_64 上,堆栈槽的大小为 8 字节。

虚拟表中保存返回地址的列是 16。这在 CIE 尾端的指令中使用。有四个 DW_CFA 指令。第一个指令 DW_CFA_def_cfa 描述了如果代码有帧指针,则如何计算帧指针将指向的规范帧地址 (CFA)。在这种情况下,CFA 是从 r7 (rsp) 计算的,并且 CFA=rsp+8

第二个指令 DW_CFA_offset 定义了从哪里获取返回地址 CFA-8。在这种情况下,返回地址当前由堆栈指针 (rsp+8)-8 指向。CFA 从堆栈上的返回地址的正上方开始。

CIE 末尾的 DW_CFA_nop 是填充,以保持 DWARF 信息中的对齐。FDE 的末尾也可以有填充以进行对齐。

f2c.cfi 中找到 main 的 FDE,它涵盖了从 0x401600x401097(但不包括)的 main 函数

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

在执行函数中的第一个指令之前,CIE 描述了调用帧状态。但是,随着处理器执行函数中的指令,详细信息将发生变化。首先,指令 DW_CFA_advance_locDW_CFA_def_cfa_offsetmain401060 处的第一个指令匹配。这将堆栈指针向下调整 0x18 (24 字节)。CFA 的位置没有改变,但堆栈指针的位置发生了变化,因此 401064 处的 CFA 的正确计算为 rsp+32。这就是此代码中序言指令的范围。以下是 main 中的前几个指令

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

DW_CFA_advance_loc 使当前行应用于函数中的接下来的 50 个字节的代码,直到 401096。CFA 位于 rsp+32,直到 401092 处的堆栈调整指令完成执行。DW_CFA_def_cfa_offset 将 CFA 的计算更新为与进入函数时相同。这是预期的,因为 401096 处的下一个指令是返回指令 (ret),它从堆栈中弹出返回值。

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

f2c 函数的 FDE 使用与 main 函数相同的 CIE,并涵盖 0x411900x4011c3 的范围

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

二进制文件中 f2c 函数的 objdump 输出

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

f2c 的 FDE (帧描述条目) 中,函数开头有一个单字节指令,带有 DW_CFA_advance_loc。在 advance 操作之后,还有两个额外的操作。一个 DW_CFA_def_cfa_offset 将 CFA (调用帧地址) 更改为 %rsp+16,而一个 DW_CFA_offset 指示 %rbx 中的初始值现在位于 CFA-16 (栈顶)。

查看这段 fc2 反汇编代码,你可以看到使用了 push 指令将 %rbx 保存到栈上。在代码生成中省略帧指针的一个优点是可以使用像 pushpop 这样的紧凑指令来存储和检索栈中的值。在这种情况下,之所以保存 %rbx,是因为 %rbx 用于将参数传递给 printf 函数 (实际上被转换为 puts 调用),但是传递给函数的 f 的初始值需要被保存以供后续计算。DW_CFA_advance_loc 向前推进 29 字节到 4011ae,显示了在 pop %rbx 之后的状态变化,这恢复了 %rbx 的原始值。DW_CFA_def_cfa_offset 指出 pop 操作将 CFA 更改为 %rsp+8

GDB 使用调用帧信息

有了 CFI (调用帧信息),GNU 调试器 (GDB) 和其他工具能够生成精确的回溯。如果没有 CFI 信息,GDB 将难以找到返回地址。 如果你在 f2c.c 的第 7 行设置一个断点,你就可以看到 GDB 如何利用这些信息。 GDB 将断点设置在 f2c 函数中的 pop %rbx 指令执行之前,此时返回值不在栈顶。

GDB 能够展开栈,而且额外的好处是还能够获取当前保存在栈上的参数 f

$ 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

调用帧信息

DWARF 调用帧信息为编译器提供了一种灵活的方式来包含信息,以便准确地展开栈。这使得确定当前活动的函数调用成为可能。我在本文中提供了一个简要的介绍,但有关 DWARF 如何实现这种机制的更多细节,请参阅 DWARF 规范

标签
Will Cohen with sunflowers
William Cohen 在 Red Hat 担任性能工具开发人员超过十年,并且从事过 Red Hat Enterprise Linux 和 Fedora 中的许多性能工具,如 OProfile、PAPI、SystemTap 和 Dyninst。

评论已关闭。

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