【操作系统】GDB的backtrace原理
栈帧
当用户调用函数时就会产生一个栈帧,包含返回地址,寄存器值和局部变量等信息。函数退出是则会删除此栈帧。这本质上就是对栈指针(stack pointer) 寄存器的修改。例如一个用户栈是从高位地址向低位地址增长,那么函数的代码(汇编角度看)运行前就会减小栈指针的值(例如16,这取决函数使用的变量的多少),栈指针增长的这段空间就是一个栈帧。相反,当运行完函数时会增大栈指针的值(与上述减小的值相对应),这就意味着删除来这一函数的栈帧。
如果一个函数内调用另外函数或者递归,那么会在现有栈帧之上生成新的栈帧,直到最上层的函数返回,反向删除已有栈帧。
backtrace
今天主要是想讲一讲我发现的GDB调试时backtrace指令的原理,其他调试器的原理应该也是如此,因为它是基于大多数处理器都提供的一个寄存器实现的。我们都很熟悉栈指针,但是常常忽略另外一个帧指针(frame pointer)。它看起来好像特别没有存在感,我之前甚至都没注意。实际上它和栈指针一样,无处不在,它保存当前栈帧的位置,即栈帧顶部位置。当调用一个函数的时候,我们不仅会在栈上保存返回地址,还会保存前一个栈帧地址(前一个栈的栈顶位置,也即是前一个帧指针)。这看起来好像多此一举没什么用,因为我们想找到前面的栈,只需要弹栈即可。但是,如果程序在当前函数崩溃了呢,我们想要追溯当前的所用栈帧该如何做,这就是帧指针的作用。
我们可以看下图所示的栈帧,可以看到,实际上,通过帧指针,我们形成了从低位上层函数栈帧到高位底层函数栈帧的链表,每个栈帧节点顶部偏移(-16)处就是指向前一个栈帧的指针。这样我们就能轻松追溯任意位置的所有调用栈帧了。这就是一般调试器的backtrace功能的原理了。
简单的实验
我们用GDB简单的看一看backtrace的作用:
int a() {
int x = 0, y = 1;
int* p = NULL;
return *p; // segment fault
}
void b() {
int x = 1, y = 2;
a();
}
int main() {
b();
return 0;
}
使用GDB运行程序到发生段错误,然后运行backtrace命令,得到结果如下:
(gdb) backtrace
#0 0x0000555555555188 in a () at backtrace.c:7
#1 0x00005555555551c9 in b () at backtrace.c:13
#2 0x00005555555551de in main () at backtrace.c:18
可以看到,地址从main函数(高)到a函数(低)成功backtrace整个调用栈,再看看函数a的栈帧:
(gdb) info frame
Stack level 0, frame at 0x7fffffffe370:
rip = 0x555555555188 in a (backtrace.c:7); saved rip = 0x5555555551c9
called by frame at 0x7fffffffe390
source language c.
Arglist at 0x7fffffffe348, args:
Locals at 0x7fffffffe348, Previous frame's sp is 0x7fffffffe370
Saved registers:
rbp at 0x7fffffffe360, rip at 0x7fffffffe368
可以发现的确保存了前一个函数(b)的栈帧位置(Previous frame’s sp)进一步发现函数的代码地址(0x555555555188
)明显低于函数栈的位置(0x7fffffffe370
),这也很符合预期,详细可看之前的系统调用文章。