Stack Unwind 堆栈回溯

launched in 2023.1.11, 浙江

1. 背景

之前做过一些关于函数堆栈的工作,但主要关注使用方法,并没有了解底层的相关实现,当和同事说起这块时,总是说不清楚,因此查阅了相关资料,这里写篇文章简单总结一下。

当系统遇到错误发生 crash 的时候,我们会通过 gdb 调试工具来查看当时的函数栈。能看到 crash 时间点的整个函数调用链,以及当时的变量值,来帮助我们快速定位问题。

我们在使用一些性能分析工具如 perf,也能看到热点函数的调用链。

Program received signal SIGSEGV.
0x54625 in fct_b at segfault.c:5 5 printf("%l\n", *b);
(gdb) bt
0 0x54625 in fct_b at segfault.c:5
1 0x54663 in fct_a at segfault.c:10
2 0x54674 in main at segfault.c:14
(gdb) f 1
1 0x54663 in fct_a at segfault.c:10 10 fct_b((int*) a);

那么这些函数堆栈信息是如何获取的呢?

在这之前,首先需要了解函数调用的底层原理,以 x86_64 架构为例 (文章[5] 对于下有非常直观详细的介绍,这里简要介绍和总结一下)。

CPU 体系结构下,是通过操作寄存器来进行计算和存储结果,当需要调用子函数时,会将当前函数的状态信息(局部变量,参数值,返回地址)保存在栈空间内,这也称为栈桢,待子函数执行完成,将结果存入相应寄存器后,再将父函数的栈桢还原。

图 1 是函数调用过程的内存结构,栈空间的寻址是通过 RSP 寄存器 进行控制。每次进入一个新函数,会将父函数的栈桢起始地址 (canonical frame address, CFA) 压入栈空间 (push %rbp),然后将子函数的 CFA 存入 RBP 寄存器 (moveq %rsp %rbp)。当结束子函数调用时,执行 leave 指令,将 RSP 寄存器指回当前函数的 CFA (moveq %rbp %rsp),将父函数的栈桢起始位置 pop 到 RBP 寄存器,并执行 ret 指令,得到函数返回地址,RSP 寄存器的指向也回到调用前的栈空间位置。

image

图 1: 函数调用过程的内存结构

因此只要通过 RSP 寄存器和 RBP 寄存器的值,就能还原出当时整个函数调用栈。这也是最简单的回溯某一时刻函数调用栈的方式(gcc 编译下,-O1 往上优化需要开启 -fno-omit-frame-pointer 参数),但这种方式存在一些不足:

  • 需要专门寄存器 RBP 来保存栈桢位置,并且需要额外的指令开销,即在每个函数前后增加 RBP 寄存器的出入栈和赋值开销。
  • 难以还原其他寄存器的值。

为了实现仅基于 RSP 寄存器的堆栈回溯,DWARF 的调试信息出现来解决这个问题。

2. DWARF

DWARF 是一种补充的调试信息,在编译时构建了一张映射表 .eh_frame,对于每个机器指令,指定当时如何计算 CFA、返回地址 (return address, ra),以及寄存器值的内容地址,他们相对于 RSP 寄存器的偏移。

通过 readelf -wF 我们能看到可执行文件中的 .eh_frame 的最终形式,记录了映射表的格式内容,每一行对应了程序 text 段的机器指令及其 LOC 地址 (Programe Counter, PC),行中每个实体潜在说明了当前寄存器和前一函数栈桢的在栈空间的计算规则,如当 PC=0400580 时,栈桢地址在 (rsp + 8),return address 返回地址在栈空间地址为 (cfa - 8),而一些 callee-save(被调用者保存)的寄存器没有入栈,所以是 undefine (u)。

# readelf -wF test

00000088 0000000000000044 0000005c FDE cie=00000030 pc=0000000000400580..00000000004005e5
   LOC           CFA      rbx   rbp   r12   r13   r14   r15   ra
0000000000400580 rsp+8    u     u     u     u     u     u     c-8
0000000000400582 rsp+16   u     u     u     u     u     c-16  c-8
0000000000400587 rsp+24   u     u     u     u     c-24  c-16  c-8
000000000040058c rsp+32   u     u     u     c-32  c-24  c-16  c-8
0000000000400591 rsp+40   u     u     c-40  c-32  c-24  c-16  c-8
0000000000400599 rsp+48   u     c-48  c-40  c-32  c-24  c-16  c-8
00000000004005a1 rsp+56   c-56  c-48  c-40  c-32  c-24  c-16  c-8
00000000004005ae rsp+64   c-56  c-48  c-40  c-32  c-24  c-16  c-8
00000000004005da rsp+56   c-56  c-48  c-40  c-32  c-24  c-16  c-8
00000000004005db rsp+48   c-56  c-48  c-40  c-32  c-24  c-16  c-8
00000000004005dc rsp+40   c-56  c-48  c-40  c-32  c-24  c-16  c-8
00000000004005de rsp+32   c-56  c-48  c-40  c-32  c-24  c-16  c-8
00000000004005e0 rsp+24   c-56  c-48  c-40  c-32  c-24  c-16  c-8
00000000004005e2 rsp+16   c-56  c-48  c-40  c-32  c-24  c-16  c-8
00000000004005e4 rsp+8    c-56  c-48  c-40  c-32  c-24  c-16  c-8

文章[2] 有个比较直观的基于汇编流程同时计算 RBP 寄存器的例子,当程序执行过程中,不断发生变量入栈改变 RSP 时 (push、pop、sub 等),CFA、RBP 和 RA 如何通过当时的 RSP 进行追溯,如图 2 所示,注意栈空间是从高地址向低地址扩展,因此 CFA 相对于 RSP 都是高地址。

image

图 2: 汇编指令流程和 CFA、RBP 以及 ra 的计算,图源来自文章[2]

因此基于 DWARF 的回溯方式好处是:

  • RBP 寄存器可以当作通用寄存器使用
  • 可以恢复当时所有寄存器的值
  • 不需要额外在每个函数前增加入栈指令

如果都存一个上述的大表,虽然简单,但会使得程序的调试信息远远大于程序本身,因此 .en_frame 的原始信息使用更为紧凑编码格式,通过公共信息条目(CIE)和帧描述条目(FDE)指令填充的,按需解释为前面的大表。这些 CIE 和 FDE 指令是GAS (GCC Assembler)汇编编译器搜集汇编代码中所有的 CFI (Call Frame Instructions) 伪指令汇总而成,CFI 伪指令在 GCC 编译时会默认编入,CIE 指令和 FDE 指令可以参考文章 [7],总结来说即 CIE 是公共信息,包含多个 FDE 的桢描述信息。

通过 readelf -wf 指令能够看到可执行文件中的 .eh_frame 的编码信息:开头说明了 FDE 在.eh_frame 的 offset (00000088)、FDE 长度 (0000000000000044)、FDE 所属的 CIE (0000005c FDE cie=00000030)、以及机器指令的 PC 范围 (0000000000400580..00000000004005e5)。后面每一条存的是表格每一行和前一行的差异,由一个 FDE 指令类型 + 值组成。

00000088 0000000000000044 0000005c FDE cie=00000030 pc=0000000000400580..00000000004005e5
  DW_CFA_advance_loc: 2 to 0000000000400582
  DW_CFA_def_cfa_offset: 16
  DW_CFA_offset: r15 (r15) at cfa-16
  DW_CFA_advance_loc: 5 to 0000000000400587
  DW_CFA_def_cfa_offset: 24
  DW_CFA_offset: r14 (r14) at cfa-24
  DW_CFA_advance_loc: 5 to 000000000040058c
  DW_CFA_def_cfa_offset: 32
  DW_CFA_offset: r13 (r13) at cfa-32
  DW_CFA_advance_loc: 5 to 0000000000400591
  DW_CFA_def_cfa_offset: 40
  DW_CFA_offset: r12 (r12) at cfa-40
  DW_CFA_advance_loc: 8 to 0000000000400599
  DW_CFA_def_cfa_offset: 48
...

基于这些调试信息,除了简单的计算基于 rsp 的偏移值,DWARF 还设计了灵活的 expression 表达式来实现复杂的调试信息计算,这里没有深究,mark 一下[4]。

通过栈空间的栈桢回溯,我们就能拿到函数调用链的每个函数起始的 PC,再通过 ELF 文件中的 sysboms 符号表,将堆栈用便于调试者理解的形式展现出来。

符号表分为 .dynsym 和 .symtab,.dynsym 保存了引用来自外部文件符号的全局符号,如 printf 函数,这是运行时必须的,会被装载进内存。而 .symtab 保存了 elf 文件的本地符号,如全局变量,代码中定义的本地函数等,更多是调试时使用。.dynsym 是 .symtab 的子集。

ELF 文件的符号表可以通过 readelf -s 来查看,如前面使用的 PC=0400580 对应的是 __libc_csu_init 函数。

# readelf -s test

Symbol table '.dynsym' contains 4 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND printf@GLIBC_2.2.5 (2)
     2: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
     3: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@GLIBC_2.2.5 (2)

Symbol table '.symtab' contains 63 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
...
    48: 00000000004005f0     2 FUNC    GLOBAL DEFAULT   13 __libc_csu_fini
    49: 0000000000400470     0 FUNC    GLOBAL DEFAULT   13 _start
    50: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
    51: 00000000004005f4     0 FUNC    GLOBAL DEFAULT   14 _fini
    52: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@@GLIBC_
    53: 0000000000400600     4 OBJECT  GLOBAL DEFAULT   15 _IO_stdin_used
    54: 0000000000601030     0 NOTYPE  GLOBAL DEFAULT   24 __data_start
    55: 0000000000601038     0 OBJECT  GLOBAL HIDDEN    24 __TMC_END__
    56: 0000000000400608     0 OBJECT  GLOBAL HIDDEN    15 __dso_handle
    57: 0000000000400580   101 FUNC    GLOBAL DEFAULT   13 __libc_csu_init
    58: 0000000000601034     0 NOTYPE  GLOBAL DEFAULT   25 __bss_start
    59: 0000000000601038     0 NOTYPE  GLOBAL DEFAULT   25 _end
    60: 0000000000601034     0 NOTYPE  GLOBAL DEFAULT   24 _edata
    61: 000000000040055d    29 FUNC    GLOBAL DEFAULT   13 main
    62: 0000000000400408     0 FUNC    GLOBAL DEFAULT   11 _init

除此之外,DWARF 还描述了源代码中的一些实体,如编译单元、函数、类型、变量等。要么直接嵌入到代码对象可执行文件的部分中,要么分割成引用的单独文件。

  • .debug_line 表映射了 object code address 和源代码位置
  • .debug_info 表映射了源代码变量和存储该变量的寄存器或者栈空间地址。

[1] x86 Registers

[2] Reliable and Fast DWARF-Based Stack Unwinding

[3] Allow Location Descriptions on the DWARF Expression Stack

[4] 通过DWARF Expression将代码隐藏在栈展开过程中

[5] x86-64 下函数调用及栈帧原理

[6] 栈为什么是高地址向低地址

[7] Unwind 栈回溯详解:libunwind

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×