不写代码,纯手工利用DWARF实现栈回溯

431 阅读17分钟

问题

之前的文章中介绍了帧指针的用途,其中一个比较重要的用途就是用来进行栈回溯,而且最后说到,当给编译器传入参数-fomit-frame-pointer,那么编译器可能会把帧指针寄存器当作一个普通的寄存器使用,就不能用它来进行栈回溯了。那么有没有其他方式进行栈回溯呢?

解决

答案是有的。

另外一种栈回溯的方式是利用内嵌在ELF文件中的DWARF数据,关于利用DWARF数据进行栈回溯的资料网上有很多,具体可以看看最后的参考资料,这些资料看起来还是比较费劲的,所以为了能便于大家更容易理解利用DWARF进行栈回溯的过程,下面不会使用三方的库,而是利用纯手工的方式进行。

建议可以先粗略阅读一下参考资料中的文章,了解一些基本知识,下面我只列几个后面会用到的重要的概念。

关键概念

  • CFA(Canonical Frame Address)

学名:标准/规范栈帧地址。它到底是啥意思呢?一句话:函数调用指令(call或bl)执行之前,栈指针寄存器SP的值

举个例子就容易理解了:

比如函数A通过call指令调用函数B,当CPU准备执行call指令时(实际还没有执行),函数A的栈指针寄存器SP的值就是CFA,这个CFA对于后面在函数B中进行栈回溯至关重要:

图片

可以看到,图中的CFA其实就是函数A的栈顶,也可以理解为函数B的栈的基地址,只不过栈是向下生长的。

  • .eh_frame

这是LSB (Linux Standard Base)标准中定义的一个ELF文件的section,跟DWARF标准规定的.debug_frame功能基本一样,但是.eh_frame会随同ELF文件一块被加载到内存中,C++的异常处理也需要依赖这个section。

图片

这个section中至少包含了一个 CFI(Call Frame Information),每个 CFI 都包含了两种条目,分别是是CIE( Common Information Entry)和FDE(Frame Description Entry),它们可以用来指导如何进行栈回溯。

CIE至少会有一个,而FDE会有多个,CIE存放的是多个FDE的一些公共信息,一般每个函数会对应一个FDE,它们在内存中是连续存放的。关于CIE和FDE的格式请参考DWARF标准规范6.4节。

基本存放结构如下:

图片

可以使用readelf -Wwf查看.eh_frame的内容:

图片

因为每个FDE一般对应一个函数,其中pc可以理解为FDE对应的函数的地址范围,FDE后面跟的一堆"DW_"开头的行是call frame指令,需要解析这些指令来实现栈回溯,关于这些指令的含义可以参考后面的资料。

此外,readelf还提供了另外一个的参数,可以把这些指令解码成更便于理解的表格的形式,后面我们手工栈回溯时会用到这样的表格:

图片

这里只对表中在后面栈回溯时要用的字段进行说明:

图片

手工栈回溯

测试环境

我们以qemu-system-aarch64可执行程序为例,从main_loop_wait函数内部的某条指令开始进行栈回溯。

  • 通过gdb附加到这个进程上
sudo gdb -p `pidof qemu-system-aarch64`

然后分别在跳转到函数main_loop_wait之前以及main_loop_wait函数中间的某个位置设置断点:

  • 跳转到main_loop_wait之前
0x0000557bac86cd04 <+148>:   call   0x557bacdb13e0 <main_loop_wait>
0x0000557bac86cd09 <+153>:   mov    0xf65ae1(%rip),%eax

设置如下断点:

(gdb) br *0x0000557bac86cd04
  • 在函数main_loop_wait中间
(gdb) b main-loop.c:588
  • 查看设置的两个断点:
(gdb) info br
Num     Type           Disp Enb Address            What
11      breakpoint     keep y   0x0000557bac86cd04 in qemu_main_loop at ../softmmu/runstate.c:731
        breakpoint already hit 1 time
12      breakpoint     keep y   0x0000557bacdb145d in main_loop_wait at ../util/main-loop.c:588
        breakpoint already hit 2 times

为什么我要这么做呢?

在进入main_loop_wait函数前下断点是为了能得到函数调用前的通用寄存器的值,在函数中间下断点的目的是为了验证一下根据FDE生成的表是否可以计算出函数调用前的一些寄存器的值以及实现栈回溯。

下面是上面这两个断点触发后,通用寄存器的值:

  • 第一个断点触发后:在函数调用执行之前
(gdb) info reg
rax            0x0                 0
rbx            0x0                 0
rcx            0x0                 0
rdx            0x0                 0
rsi            0x0                 0
rdi            0x0                 0
rbp            0x0                 0x0
rsp            0x7ffcaa3860e0      0x7ffcaa3860e0
r8             0x0                 0
r9             0x7ffcaa3cc080      140723164594304
r10            0x1e06a674          503752308
r11            0x1                 1
r12            0x7ffcaa3860e4      140723164307684
r13            0x557bacf2825e      93989670912606
r14            0x0                 0
r15            0x0                 0
rip            0x557bac86cd04      0x557bac86cd04 <qemu_main_loop+148>
eflags         0x246               [ PF ZF IF ]
cs             0x33                51
ss             0x2b                43
ds             0x0                 0
es             0x0                 0
fs             0x0                 0
gs             0x0                 0
  • 第二个断点触发后:在函数中间某个位置
(gdb) info reg
rax            0x1f3               499
rbx            0x0                 0
rcx            0x7ffcaa386094      140723164307604
rdx            0x1f3               499
rsi            0x1900000009        107374182409
rdi            0x557bb0697098      93989729038488
rbp            0x7ffcaa386090      0x7ffcaa386090
rsp            0x7ffcaa386080      0x7ffcaa386080
r8             0x9                 9
r9             0x7ffcaa3cc080      140723164594304
r10            0x1e06a674          503752308
r11            0x1                 1
r12            0x1dbe22c0          499000000
r13            0x557bacf2825e      93989670912606
r14            0x0                 0
r15            0x0                 0
rip            0x557bacdb145d      0x557bacdb145d <main_loop_wait+125>
eflags         0x206               [ PF IF ]
cs             0x33                51
ss             0x2b                43
ds             0x0                 0
es             0x0                 0
fs             0x0                 0
gs             0x0                 0

后面我们会一直停留在第二个断点进行我们的验证。

当停留在第二个断点上时,我们通过gdb的info reg命令获得的寄存器上下文被称为当前寄存器上下文,一些专门实现栈回溯的库在进行栈回溯前也都有获取当前寄存器(比如rsp、rip等)的步骤。

开始栈回溯的第一步是要知道当前的PC地址,具体在我们这个场景(停在第二个断点上),PC的值(rip寄存器)是0x557bacdb145d,然后根据PC找到对应的FDE。这里会涉及到一个虚拟地址转换的问题。因为FDE中记录的PC地址是在编译链接时确定的,但是当ELF文件在被加载时会进行重定位,这会导致实际运行时PC的地址跟编译链接时的不同,之间存在一个线性偏移。那么如何根据实际运行时的虚拟地址得到编译链接时的虚拟地址呢?

地址转换

  • 找到进程的mapping信息,可以使用GDB或者/proc/<pid>/maps可以获得可执行程序的代码段的的映射区域(VMA):
(gdb) info proc mappings
process 2408934
Mapped address spaces:

          Start Addr           End Addr       Size     Offset objfile
      0x557bac27d000     0x557bac5d7000   0x35a000        0x0 /vol_1t/Qemu/qemu-8.0.0/build/qemu-system-aarch64
      0x557bac5d7000     0x557bacde5000   0x80e000   0x35a000 /vol_1t/Qemu/qemu-8.0.0/build/qemu-system-aarch64
      0x557bacde5000     0x557bad1d8000   0x3f3000   0xb68000 /vol_1t/Qemu/qemu-8.0.0/build/qemu-system-aarch64
      0x557bad1d9000     0x557bad39b000   0x1c2000   0xf5b000 /vol_1t/Qemu/qemu-8.0.0/build/qemu-system-aarch64
      0x557bad39b000     0x557bad7d0000   0x435000  0x111d000 /vol_1t/Qemu/qemu-8.0.0/build/qemu-system-aarch64
      0x557bad7d0000     0x557bad7f5000    0x25000        0x0
      0x557baf572000     0x557bb3988000  0x4416000        0x0 [heap]
      0x7f0c037ff000     0x7f0c03800000     0x1000        0x0
      ...

当前的PC地址0x557bacdb145d落在下面这个区间:

0x557bac5d7000     0x557bacde5000   0x80e000   0x35a000 /vol_1t/Qemu/qemu-8.0.0/build/qemu-system-aarch64

怎么理解上面这句映射呢?

上面这句映射的意思是:将qemu-system-aarch64可执行程序的代码段映射到用户虚拟地址0x557bac5d7000处,映射的长度为0x80e000,这里对起始地址和长度都进行了页对齐(因为MMU的最小映射粒度是一个页,目前一个页是4KB),其中代码段在可执行文件中的偏移量是0x35a000:

图片

从ELF文件的程序头可以看到,偏移量0x35a000也是代码段的链接起始地址:

$ readelf -aW qemu-system-aarch64
Program Headers:
  Type           Offset   VirtAddr           PhysAddr           FileSiz  MemSiz   Flg Align
  PHDR           0x000040 0x0000000000000040 0x0000000000000040 0x000310 0x000310 R   0x8
  INTERP         0x000350 0x0000000000000350 0x0000000000000350 0x00001c 0x00001c R   0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x000000 0x0000000000000000 0x0000000000000000 0x359c50 0x359c50 R   0x1000
  LOAD           0x35a000 0x000000000035a000 0x000000000035a000 0x80d2ed 0x80d2ed R E 0x1000
  LOAD           0xb68000 0x0000000000b68000 0x0000000000b68000 0x3f27b0 0x3f27b0 R   0x1000
  ......

在加载ELF文件时进行了重定位,所以实际虚拟地址跟编译时的链接地址有一些差异,但是这种差异是线性的。所以将PC地址0x557bacdb145d映射到编译时的链接地址的方法很简单:

(gdb) p /x (0x557bacdb145d - 0x557bac5d7000 + 0x35a000)
$6 = 0xb3445d

得到转换后的地址是0xb3445d,然后我们就可以根据这个地址找到对应的FDE,如下所示,这个地址落在了图中的FDE的红色区间:

图片

其实这个FDE对应的就是函数main_loop_wait:

(gdb) disas main_loop_wait
Dump of assembler code forfunction main_loop_wait:
   0x0000000000b343e0 <+0>:     endbr64
   0x0000000000b343e4 <+4>:     push   %r14
   0x0000000000b343e6 <+6>:     mov    %edi,%r8d
   0x0000000000b343e9 <+9>:     push   %r13
   ...
   0x0000000000b345da <+506>:   pop    %rbx
   0x0000000000b345db <+507>:   pop    %rbp
   0x0000000000b345dc <+508>:   pop    %r12
   0x0000000000b345de <+510>:   pop    %r13
   0x0000000000b345e0 <+512>:   pop    %r14
   0x0000000000b345e2 <+514>:   ret
   0x0000000000b345e3 <+515>:   nopl   0x0(%rax,%rax,1)
   0x0000000000b345e8 <+520>:   mov    %r12,%rdi
   0x0000000000b345eb <+523>:   call   0x35ef60 <g_main_context_dispatch@plt>

上面这个是用gdb的离线模式,所以地址跟编译时的一致,这也是一种快速找到链接地址的方法。

寄存器内容确认

接下来验证一下FDE中记录的信息,看我们的理解是否正确。根据我们的理解,通过图中红框中的规则可以计算出函数调用指令执行之前寄存器的值。

图片

  • CFA的验证:

计算方法是 rsp + 96,意思是用当前寄存器rsp的值加上96。根据上面的第二个断点发生时寄存器信息:

图片

此时rsp是0x7ffcaa386080,所以CFA的值就是0x7ffcaa3860e0:

(gdb) p /x (0x7ffcaa386080 + 96)
$7 = 0x7ffcaa3860e0

我们知道,CFA的值是函数调用指令执行前SP寄存器的值,从上面第一个断点发生时的寄存器的信息可以得到rsp的值确实是0x7ffcaa3860e0:

图片

  • rbx的验证:

计算方法:c-48。其中c指的就是CFA,这里计算得到的是存放rbx的内存地址,所以可以得到rbx的值:

(gdb) x /g (0x7ffcaa386080 + 96 - 48)
0x7ffcaa3860b0: 0x0000000000000000

跟第一个断点发生时记录的rbp寄存器的值是一样的。

  • rbp的验证:

计算方法:c - 40。其中c指的就是CFA,这里计算得到的是存放rbp的内存地址,所以可以得到rbp的值:

(gdb) x /g (0x7ffcaa386080 + 96 - 40)
0x7ffcaa3860b8: 0x0000000000000000

跟第一个断点发生时记录的rbp寄存器的值是一样的。

  • r12的验证:

计算方法:c - 32。其中c指的就是CFA,这里计算得到的是存放r12的内存地址,所以可以得到r12的值:

(gdb) x /g (0x7ffcaa386080 + 96 - 32)
0x7ffcaa3860c0: 0x00007ffcaa3860e4

跟第一个断点发生时记录的r12寄存器的值是一样的:

图片

  • r13的验证:

计算方法:c - 24。其中c指的就是CFA,这里计算得到的是存放r13的内存地址,所以可以得到r13的值:

(gdb) x  /g (0x7ffcaa386080 + 96 - 24)
0x7ffcaa3860c8: 0x0000557bacf2825e

跟第一个断点发生时记录的r13寄存器的值是一样的:

图片

  • r14的验证:

计算方法:c - 16。其中c指的就是CFA,这里计算得到的是存放r14的内存地址,所以可以得到r14的值:

(gdb) x  /g (0x7ffcaa386080 + 96 - 16)
0x7ffcaa3860d0: 0x0000000000000000

栈回溯(第1层)

  • 返回地址(ra)的验证:

计算方法:c - 8。其中c指的就是CFA,这里计算得到的是存放返回地址的内存地址,得到返回地址如下:

(gdb) x  /g (0x7ffcaa386080 + 96 - 8)
0x7ffcaa3860d8: 0x0000557bac86cd09

对不对呢?可以看一下第一个断点的位置:

0x0000557bac86cd04 <+148>:   call   0x557bacdb13e0 <main_loop_wait>
0x0000557bac86cd09 <+153>:   mov    0xf65ae1(%rip),%eax

当跳转到main_loop_wait后,它的返回地址其实就是call指令的下一条指令的地址0x0000557bac86cd09:

(gdb) x /i 0x0000557bac86cd09
   0x557bac86cd09 <qemu_main_loop+153>: mov    0xf65ae1(%rip),%eax

可以看到跟上面计算得到的是一样的。

至此我们完成了从main_loop_wait到qemu_main_loop的回溯。

结束了吗?当然没有,目前才回溯了一层,我们全都要。

栈回溯(第2层)

到目前为止,我们得到了main_loop_wait的返回地址0x0000557bac86cd09,然后将这个地址作为当前PC,继续仿照之前的方法找到它所对应的FDE,其实就是函数qemu_main_loop对应的FDE。

  • 计算转换后的PC地址:
(gdb) p /x (0x0000557bac86cd09 - 0x557bac5d7000 + 0x35a000)
$8 = 0x5efd09
  • 找到对应的FDE如下:

图片

  • 计算CFA:

计算方法是:rsp + 64。

上面用到了rsp寄存器的值,这个rsp的值是多少呢?这个rsp表示当PC在0x0000557bac86cd09时,物理寄存器rsp的值。

这个值是多少呢?再回顾一下PC位置:

0x0000557bac86cd04 <+148>:   call   0x557bacdb13e0 <main_loop_wait>
0x0000557bac86cd09 <+153>:   mov    0xf65ae1(%rip),%eax

然后再回顾一下之前对CFA的解释:函数调用指令执行之前,栈指针寄存器SP的值

之前在main_loop_wait中进行栈回溯时计算得到CFA是0x7ffcaa3860e0,即qemu_main_loop调用main_loop_wait之前(也就是call 0x557bacdb13e0 执行之前)SP寄存器的值,而call指令执行前返回后,SP寄存器的值不会发生变化(当前PC位于qemu_main_loop中,在它执行期间,它的栈顶地址不会变),所以此时SP寄存器的值(qemu_main_loop的栈顶)就是在main_loop_wait中得到的CFA的值0x7ffcaa3860e0。

得到了rsp的值0x7ffcaa3860e0,也就得到新的CFA:

(gdb) p /x (0x7ffcaa3860e0 + 64)
$9 = 0x7ffcaa386120
  • 计算返回地址(ra):

计算方法:c - 8。其中c指的是CFA,这里计算得到的是存放返回地址的内存地址,得到返回地址如下:

(gdb) x /g (0x7ffcaa386120 - 8)
0x7ffcaa386118: 0x0000557bac5e46cb

这个地址对应的代码是:

(gdb) x /i 0x0000557bac5e46cb
   0x557bac5e46cb <qemu_default_main+11>:       mov    %eax,%r12d

栈回溯(第3层)

至此我们得到了新的返回地址0x0000557bac5e46cb,然后再次将这个地址作为当前PC地址。

  • 计算转换后的PC
(gdb) p /x (0x0000557bac5e46cb - 0x557bac5d7000 + 0x35a000)
$11 = 0x3676cb
  • 对应的FDE如下:

图片

  • 计算CFA

计算方法:rsp + 16。这个rsp表示当PC为0x0000557bac5e46cb时的rsp寄存器的值,也就是前一个CFA,为0x7ffcaa386120。

所以,新的CFA是:

(gdb) p /x (0x7ffcaa386120 + 16)
$12 = 0x7ffcaa386130
  • 计算返回地址(ra)

计算方法:c - 8。

(gdb) x /g (0x7ffcaa386130 - 8)
0x7ffcaa386128: 0x00007f0de8b69083

对应的位置是:

(gdb) x /i 0x00007f0de8b69083
   0x7f0de8b69083 <__libc_start_main+243>:      mov    %eax,%edi

栈回溯(第4层)

至此我们得到了新的返回地址0x00007f0de8b69083,然后再次将这个地址作为当前PC地址。

  • 计算转换后的PC

这个PC地址0x00007f0de8b69083在哪呢?从gdb里看看映射:

图片

这个PC地址在C库的代码段,还是参照之前的方法,计算得到转换后的PC的值:

(gdb) p /x (0x00007f0de8b69083 - 0x7f0de8b67000 + 0x22000)
$13 = 0x24083
  • 找到对应的FDE

需要使用readelf -WwF /usr/lib/x86_64-linux-gnu/libc-2.31.so。

图片

  • 计算CFA

计算方法:rsp + 208。这里的rsp来自上一个CFA: 0x7ffcaa386130。

(gdb) p /x (0x7ffcaa386130 + 208)
$14 = 0x7ffcaa386200
  • 计算返回地址ra

计算方法:c - 8。这里的c是上面新计算出来的CFA。

(gdb) x /g (0x7ffcaa386200 - 8)
0x7ffcaa3861f8: 0x0000557bac5e45fe

这个地址是什么呢?

(gdb) x /i 0x0000557bac5e45fe
   0x557bac5e45fe <_start+46>:  hlt

可以看到,已经回到了_start里了,这个地址位于qemu的代码段。

再次计算转换后的PC地址:

(gdb) p /x (0x557bac5e45fe  - 0x557bac5d7000 + 0x35a000)
$15 = 0x3675fe

根据这个PC得到的FDE没有计算规则,说明栈回溯到头了:

图片

确认回溯结果

至此,手工栈回溯已经完毕,我们从main_loop_wait一直回溯到了_start,下面是我们的回溯结果:

0x557bac86cd09 <qemu_main_loop+153>: mov    0xf65ae1(%rip),%eax
0x557bac5e46cb <qemu_default_main+11>:       mov    %eax,%r12d
0x7f0de8b69083 <__libc_start_main+243>:      mov    %eax,%edi
0x557bac5e45fe <_start+46>:  hlt

下面使用gdb提供的bt命令验证一下我们上面的回溯是否正确:

(gdb) bt
#0  main_loop_wait (nonblocking=nonblocking@entry=0) at ../util/main-loop.c:588
#1  0x0000557bac86cd09 in qemu_main_loop () at ../softmmu/runstate.c:731
#2  0x0000557bac5e46cb in qemu_default_main () at ../softmmu/main.c:37
#3  0x00007f0de8b69083 in __libc_start_main (main=
    0x557bac5ddcc0 <main>, argc=27, argv=0x7ffcaa386218, init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7ffcaa386208) at ../csu/libc-start.c:308
#4  0x0000557bac5e45fe in _start ()

可以看到,完全正确。

此外,gdb还提供了info frame命令,用来获取每一个栈帧的信息:

(gdb) bt
#0  main_loop_wait (nonblocking=nonblocking@entry=0) at ../util/main-loop.c:588
#1  0x0000557bac86cd09 in qemu_main_loop () at ../softmmu/runstate.c:731
#2  0x0000557bac5e46cb in qemu_default_main () at ../softmmu/main.c:37
#3  0x00007f0de8b69083 in __libc_start_main (main=
    0x557bac5ddcc0 <main>, argc=27, argv=0x7ffcaa386218, init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7ffcaa386208) at ../csu/libc-start.c:308
#4  0x0000557bac5e45fe in _start ()

(gdb) f 0
#0  main_loop_wait (nonblocking=nonblocking@entry=0) at ../util/main-loop.c:588
588         timeout_ns = qemu_soonest_timeout(timeout_ns,
(gdb) info frame
Stack level 0, frame at 0x7ffcaa3860e0:
 rip = 0x557bacdb145d in main_loop_wait (../util/main-loop.c:588); saved rip = 0x557bac86cd09
 called by frame at 0x7ffcaa386120
source language c.
 Arglist at 0x7ffcaa386078, args: nonblocking=nonblocking@entry=0
 Locals at 0x7ffcaa386078, Previous frame's sp is 0x7ffcaa3860e0
 Saved registers:
  rbx at 0x7ffcaa3860b0, rbp at 0x7ffcaa3860b8, r12 at 0x7ffcaa3860c0, r13 at 0x7ffcaa3860c8, r14 at 0x7ffcaa3860d0, rip at 0x7ffcaa3860d8

(gdb) f 1
#1  0x0000557bac86cd09 in qemu_main_loop () at ../softmmu/runstate.c:731
731             main_loop_wait(false);
(gdb) info frame
Stack level 1, frame at 0x7ffcaa386120:
 rip = 0x557bac86cd09 in qemu_main_loop (../softmmu/runstate.c:731); saved rip = 0x557bac5e46cb
 called by frame at 0x7ffcaa386130, caller of frame at 0x7ffcaa3860e0
 source language c.
 Arglist at 0x7ffcaa3860d8, args:
 Locals at 0x7ffcaa3860d8, Previous frame's sp is 0x7ffcaa386120
 Saved registers:
  rbx at 0x7ffcaa3860f8, rbp at 0x7ffcaa386100, r12 at 0x7ffcaa386108, r13 at 0x7ffcaa386110, rip at 0x7ffcaa386118

(gdb) f 2
#2  0x0000557bac5e46cb in qemu_default_main () at ../softmmu/main.c:37
37          status = qemu_main_loop();
(gdb) info frame
Stack level 2, frame at 0x7ffcaa386130:
 rip = 0x557bac5e46cb in qemu_default_main (../softmmu/main.c:37); saved rip = 0x7f0de8b69083
 called by frame at 0x7ffcaa386200, caller of frame at 0x7ffcaa386120
source language c.
 Arglist at 0x7ffcaa386118, args:
 Locals at 0x7ffcaa386118, Previous frame's sp is 0x7ffcaa386130
 Saved registers:
  r12 at 0x7ffcaa386120, rip at 0x7ffcaa386128

(gdb) f 3
#3  0x00007f0de8b69083 in __libc_start_main (main=0x557bac5ddcc0 <main>, argc=27, argv=0x7ffcaa386218, init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>,
    stack_end=0x7ffcaa386208) at ../csu/libc-start.c:308
308     ../csu/libc-start.c: No such file or directory.
(gdb) info frame
Stack level 3, frame at 0x7ffcaa386200:
 rip = 0x7f0de8b69083 in __libc_start_main (../csu/libc-start.c:308); saved rip = 0x557bac5e45fe
 called by frame at 0x0, caller of frame at 0x7ffcaa386130
 source language c.
 Arglist at 0x7ffcaa386128, args: main=0x557bac5ddcc0 <main>, argc=27, argv=0x7ffcaa386218, init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>,
    stack_end=0x7ffcaa386208
 Locals at 0x7ffcaa386128, Previous frame's sp is 0x7ffcaa386200
 Saved registers:
  rbx at 0x7ffcaa3861c8, rbp at 0x7ffcaa3861d0, r12 at 0x7ffcaa3861d8, r13 at 0x7ffcaa3861e0, r14 at 0x7ffcaa3861e8, r15 at 0x7ffcaa3861f0, rip at 0x7ffcaa3861f8

(gdb) f 4
#4  0x0000557bac5e45fe in _start ()
(gdb) info frame
Stack level 4, frame at 0x0:
 rip = 0x557bac5e45fe in _start; saved rip = <not saved>
 Outermost frame: outermost
caller of frame at 0x7ffcaa386200
 Arglist at 0x7ffcaa3861f8, args:
 Locals at 0x7ffcaa3861f8, Previous frame's sp is 0x7ffcaa386208

总结

要进行栈回溯,首先我们需要有一个起点,而且要获得在这个起点时的一些通用寄存器的值(比如rsp和rip),第三方的栈回溯库也都会有这个步骤,对于像perf这样的性能分析工具,会利用内核获取用户程序在某个点的寄存器上下文

光有寄存器上下文还不够,还需要进程的栈内存,回顾上面的分析过程,返回地址ra的计算方法是(c - 8),这里得到的是存放返回地址的内存的地址,需要读取这个地址得到返回地址,而这块内存地址属于进程的栈空间。

有了上面两种信息再加上ELF文件自带的**.eh_frame**节的FDE就可以进行栈回溯了。

具体过程如下:

  • 第一步:根据PC地址找到对应的FDE,然后根据FDE中的PC地址对应的计算规则得到CFA,利用返回地址的计算规则,然后根据CFA可以计算得到存放返回地址的内存地址,最后从这块地址中读取出返回地址。
  • 第二步:再次将上一步得到的返回地址作为新的PC,将前一个CFA作为当前rsp寄存器的值,然后根据新的PC对应的FDE中的规则计算得到新的CFA,利用返回地址的计算规则,根据新的CFA得到新的返回地址
  • 第三步:重复第二步,直到找到的FDE没有计算规则为止。

参考资料