c语言 栈回溯

RBP 寄存器栈帧回溯

栈帧:每个函数调用时,都需要在用户栈中存储一些临时变量,参数,返回地址,还有一些和函数相关的信息。在函数调用完后,栈帧会被销毁,释放,这个过程是自动的。

原理:rbp寄存器存储了当前函数栈帧地址,每当调用一个新的函数时,会先把当前函数的栈帧地址(rbp寄存器值) push 进栈,然后 rbp寄存器存储新函数的起始栈地址。有了 rbp 存储当前的栈帧地址,以及每个函数调用都压入前一个函数的栈帧起始地址,构成了一个链表,用于回溯堆栈。

如下图,foo1->foo2->foo3 调用链:

返回地址

通过 rbp 可以回溯每个函数的调用起始栈地址,但和函数名信息没啥关系。我们还需要知道返回地址信息,来回溯函数名。在函数调用时,即 call 某个函数时,都会先将返回地址保存到栈中,然后再跳到那个子函数去执行指令。当前函数保存了返回地址,以便子函数调用完后,pop return address to ip,能顺序接着执行下一条指令。看上图所示,都是父函数保存子函数的返回地址。

由于栈的生长方向都是从高地址向低地址增长,所以我们可以通过 rbp + 8 来获取当前函数的返回地址。有了返回地址,我们再通过 backtrace_symbols函数接口来获取函数名信息。

举例代码: 

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
#include <execinfo.h>

#define BACKTRACE_SIZ 32

int show_backtrace() {
    int sz = 0;
    void *ip[BACKTRACE_SIZ];

    void **fp = __builtin_frame_address(0); // // 获取 rbp 寄存器值
    for (;sz < BACKTRACE_SIZ;) {
        ip[sz++] = *((void **)fp + 1); // 获取当前调用函数的返回地址
        void **next_fp = *fp;
        if (next_fp <= fp) break;
        fp = next_fp;
    }

    char **strings = backtrace_symbols(ip, sz); // 通过返回地址,获取函数名符号信息
    for (size_t i = 0; i < sz; i++) {
        printf("%p : %s\n", ip[i], strings[i]);
    }

    free(strings);  // malloced by backtrace_symbols
}

void c() {
    show_backtrace();
    printf("C \n");
}

void b() {
    c();
    printf("B \n");
}

void a() {
    b();
    printf("A \n");
}

int main(int argc, char const *argv[]) {
    a();
    return 0;
}

/**
gcc main.c -rdynamic && ./a.out
运行结果:
0x65196cb34317 : ./a.out(c+0x12) [0x65196cb34317]
0x65196cb3433b : ./a.out(b+0x12) [0x65196cb3433b]
0x65196cb3435f : ./a.out(a+0x12) [0x65196cb3435f]
0x65196cb3438e : ./a.out(main+0x1d) [0x65196cb3438e]
0x7cd4ea429d90 : /lib/x86_64-linux-gnu/libc.so.6(+0x29d90) [0x7cd4ea429d90]
C 
B 
A     
**/

编译时使用-rdynamic把调试信息链接进文件,运行会打印出详细的符号信息。 

eh_frame 栈回溯

使用 rbp 寄存器栈回溯,虽然实现简单,但也有一定的局限性。比如编译时,需要带上-rdynamic获取更多调试信息。而实际线上,我们很少使用这个编译选项,而且我们使用到的 Linux 库或者第三方库绝大多数选项选择忽略帧指针。使用 ebp寄存器保存栈帧信息,编译成汇编代码,也会有指令开销。

所以,一般我们编译时,都不会带上-rdynamic,而且线上项目,优化等级一般调到 -O2 以上。使用了 -O2级别优化, ebp 寄存器也不会用来存储堆栈地址了,编译器会把它当作一个普通的寄存器来使用。

我们采用另外一种方式来进行栈回溯。在Linux下,gcc 编译后的二级制可执行文件,它的格式是 elf (Executable and Linking Format)类型的。其中就包含了堆栈展开所需要的基本信息,这些基本信息就放在 elf 格式文件中的 .eh_frame 里面。

关于 elf 文件格式和  .eh_frame 的内容比较多,需要先了解下,具体可以先参考 Linux ELF文件格式分析当没有了framepointer该如何进行栈回溯?

我们可以通过 readelf -wF a.out 查看 a.out elf 文件的 eh_frame 段。

lindx@ldx:~/study$ readelf -s a.out |grep FUNC|grep c
    35: 0000000000001305    36 FUNC    GLOBAL DEFAULT   16 c
    
lindx@ldx:~/study$ readelf -wF a.out |grep -A 10 1305
000000a8 000000000000001c 000000ac FDE cie=00000000 pc=0000000000001305..0000000000001329
   LOC           CFA      rbp   ra    
0000000000001305 rsp+8    u     c-8   
000000000000130a rsp+16   c-16  c-8   
000000000000130d rbp+16   c-16  c-8   
0000000000001328 rsp+8    c-16  c-8

我们对 c 函数进行分析,先查看 c 函数对应的偏移地址: 0x1305,然后再通过 readelf -wF 查看过滤 1305 对应的 FDE(Frame Description Entry)。FDE 存储了异常处理以及堆栈展开所需要的元数据,现在我们就是利用它来做堆栈回溯。

这张表的结构设计如下:

  1. LOC 是相对于函数入口点的偏移地址。
  2. CFA 是上一级调用者的堆栈指针,它的值是在执行(不是执行完)当前函数(callee)的 caller 的 call指令 时的 RSP 值。简单的理解就是,每个函数都有栈帧,然后都有一个栈帧指针,用来索引栈上的变量,参数等,因为 rbp 不可靠,所以,我们只能通过 rsp 来进行栈帧索引,即使用优化选项-O3,rsp 也是可以用来索引栈中数据的。至于怎么索引,不是本文重点,我们栈回溯,需要通过当前 rsp 来计算出调用函数的 CFA,和当前函数返回地址 ra。然后通过 CFA 和 ra 不断回溯。
  3. 其余列,由寄存器编号标记。堆栈展开可能用到的寄存器,其中包括一些在某些架构上具有特殊名称的寄存器,例如PC和堆栈指针寄存器。 

比如上面例子,假设当前 PC 值为 0x1328,我们可以通过汇编指令,获取到当前寄存器 rsp,rbp 的值。然后,对比 LOC,发现使用第4条规则,CFA = rsp + 8,得到 CFA 值,再计算出返回地址 ra = CFA - 8 = rsp +8 - 8 = rsp。这时候,我们就拿到了当前函数的返回地址值。然后,再根据返回地址 ra 找到对应父函数的 FDE,此时,ra 可以当做父函数的 PC 值来查找对应的 LOC 条目,CFA 当做父函数的 rsp 值,计算父函数新的 ra 以及 CFA,这样不断通过 ra 查找 FDE 进行回溯,直到 ra 为 0,回溯终止。

一个函数对应一个 FDE,网上找了一个对 FDE 介绍比较好的图:

图片来源:linux 栈回溯(x86_64 )

原理介绍完了,我们通过一个例子进行介绍 eh_frame 如何进行栈回溯的,加深印象,在此之前,我们借用网上开源的 unread 项目来解析二进制文件的 eh_frame 部分。

思路

  1. 读取 elf 文件,解析 .eh_frame 段内容
  2. 根据 .eh_frame 中的 cfe 计算规则映射到一个运行时的函数(虚拟地址)
  3.  解析 elf 文件的 .symtab 符号表段,获取所有函数名以及对应的虚拟地址
  4. 根据当前运行到的 pc 指针,找到在哪个 cfe 范围,进一步根据 LOC 条目回溯

虚拟地址计算:

虚拟地址 = /proc/pid/maps 的可执行段起始虚拟地址 + (elf符号表对应的相对地址 - /proc/pid/maps 对应段的虚拟偏移地址)

比如上面例子中的 a.out 文件,函数 c 固定相对地址 0x1305,那么运行时 c 函数的虚拟地址为 0x135 + 0x62992821b000 - 0x1000 = 0x62992821b305。

由于 unread 很久没维护过了,在对寄存器枚举定义 register_index有一点问题,我进行了顺序调整,一般寄存器排布如下:

此外,还有不少地方进行了调整,具体看修改后的代码 unread-modify

这里使用的是 Ubuntu 24.04 版本,给出运行结果:

lindx@ldx:/media/sf_shared/unread-modify$ make
gcc src/*.c -o test
lindx@ldx:/media/sf_shared/unread-modify$ ls
Makefile  src  test
lindx@ldx:/media/sf_shared/unread-modify$ ./test 
[INFO]   ---> /media/sf_shared/unread-modify/test (5e45f1314000-5e45f1319000  1000)
0x5e45f131826d: c11(0x525b)
0x5e45f1318291: b(0x527f)
0x5e45f13182b5: a(0x52a3)
0x5e45f13182e4: main(0x52c7)
C 
B 
A

通过 .eh_frame 栈回溯,我们成功获取到 main->a->b->c11 函数调用链。

参考
虚拟内存探究 -- 第五篇:The Stack, registers and assembly code

计算机那些事(4)——ELF文件结构

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

Unwind 栈回溯详解:libunwind

posted @ 2024-06-12 19:40  墨色山水  阅读(9)  评论(0编辑  收藏  举报