Linux调试之栈帧原理与应用
上一篇文章讲解了如何在Linux应用程序中进行栈回溯,本篇我们以x86_64位为例,说明栈回溯的原理与过程,其中可能会涉及到少量汇编,Google查一下就可以了。rbp一般代表栈基址寄存器, rsp一般是栈顶寄存器:
一、 函数调用过程
首先我们先写一段测试代码,然后通过汇编来看下函数的调用流程。 需要注意的是,每种处理器的汇编代码不尽相同,所以分析都要建立在特定处理器上。
否则你可能去看其他人写的文章,可能又会得出其他结论。
代码如下:
/*************************************************************************
> File Name: test_stack.c
> Author: Albert Jie
> Mail: huangjieajy@163.com
> Created Time: 2020年11月19日 星期四 14时48分23秒
************************************************************************/
#include <stdio.h>
int sub(int a, int b) {
int result = 0;
result = a - b;
return result;
}
int main(int argc, char *argv[]) {
int result = 0;
result = sub(5, 3);
printf("my debug result = %d \r\n", result);
return 0;
}
编译该文件:
gcc -o test_stack test_stack.c
使用gdb调试查看汇编代码:
gdb ./test_stack
查看main函数汇编代码
(gdb) disassemble main
Dump of assembler code for function main:
0x0000000000000669 <+0>: push %rbp
0x000000000000066a <+1>: mov %rsp,%rbp
0x000000000000066d <+4>: sub $0x20,%rsp
0x0000000000000671 <+8>: mov %edi,-0x14(%rbp)
0x0000000000000674 <+11>: mov %rsi,-0x20(%rbp)
0x0000000000000678 <+15>: movl $0x0,-0x4(%rbp) # 栈基址向下偏移4个字节的地方存放result的值,也就是给result赋值为0
0x000000000000067f <+22>: mov $0x3,%esi # 将3赋值给esi寄存器
0x0000000000000684 <+27>: mov $0x5,%edi # 将5赋值给edi寄存器
0x0000000000000689 <+32>: callq 0x64a <sub> # 调用sub函数
0x000000000000068e <+37>: mov %eax,-0x4(%rbp) # 将eax的值赋值给result, eax存放的是sub函数的返回值,看下面sub函数的汇编可以看到
0x0000000000000691 <+40>: mov -0x4(%rbp),%eax
0x0000000000000694 <+43>: mov %eax,%esi
0x0000000000000696 <+45>: lea 0x97(%rip),%rdi # 0x734
0x000000000000069d <+52>: mov $0x0,%eax
0x00000000000006a2 <+57>: callq 0x520 <printf@plt>
0x00000000000006a7 <+62>: mov $0x0,%eax
0x00000000000006ac <+67>: leaveq
0x00000000000006ad <+68>: retq
End of assembler dump.
查看sub函数的汇编代码:
(gdb) disassemble sub
Dump of assembler code for function sub:
0x000000000000064a <+0>: push %rbp #保存栈基址寄存器,这里存放的是main函数的基地址
0x000000000000064b <+1>: mov %rsp,%rbp # 将rsp的地址赋值给当前的rbp.也就是rbp寄存器里面存放的就是sub函数的地址了
0x000000000000064e <+4>: mov %edi,-0x14(%rbp) # 将edi的值放到rbp基地址下偏0X14的位置,也就是形式参数5
0x0000000000000651 <+7>: mov %esi,-0x18(%rbp) # 将esi的值放到rbp基地址下偏0X18的位置,也就是形式参数3
0x0000000000000654 <+10>: movl $0x0,-0x4(%rbp) # 给result赋值为0
0x000000000000065b <+17>: mov -0x14(%rbp),%eax # 将eax的值赋值为5
0x000000000000065e <+20>: sub -0x18(%rbp),%eax # eax的值为eax - 3
0x0000000000000661 <+23>: mov %eax,-0x4(%rbp) # 将eax的值赋值给result
0x0000000000000664 <+26>: mov -0x4(%rbp),%eax # 将返回值放到eax中, 调用者就到这个寄存器里面拿值
0x0000000000000667 <+29>: pop %rbp # 恢复main函数的栈基址寄存器
0x0000000000000668 <+30>: retq # 返回原来的执行点
End of assembler dump.
可以看到,参数是在sub函数调用前传到相关的寄存器,后续在被调用函数中直接去取这些寄存器的值,就可以得到函数传过来的参数,由于处理器的寄存器数量有限,所以
如果函数参数过多,这些值可能会存放调用者的栈中。这个不同的处理器,可以用该demo 程序 实际测试看一下。
二、 栈帧原理
基于上面的函数,我们绘制出栈帧的图形:
可以发现,每调用一次函数,都会对调用者的栈基址(ebp)进行压栈操作,并且由于栈基址是由当时栈顶指针(esp)而来,我们会发现,各层函数的栈基址很巧妙的构成了一个链,即当前的栈基址指向下一层函数栈基址所在的位置, 所以我们可以依次根据ebp寄存器,来一步一步返回打印出每个函数调用地址,从而就打印出了栈回溯。
三、栈回溯的实现
原则上,我们可以通过获取当前函数的ebp栈基址寄存器,一步一步取上一个栈基址寄存器,从而找到整个调用栈的返回地址,最终打印出函数调用栈,这里不再多说。