进程与栈的关系、栈与栈帧的关系
一个进程里面包含了很多内容,既有代码段、静态变量以及全局变量、无效区域、堆、栈,也还有最上面的内核态的东西。
这里只讨论用户态的栈。
用户态的栈可以被分为多个栈帧,多个栈帧自上而下组成了栈,栈帧是描述了一个函数的执行过程。
ebp寄存器保存了一个栈帧的底部地址,esp寄存器保存了一个栈帧的顶部地址。
栈帧的组成
栈帧包括了如下内容:
- 该函数在上层函数中,下一个语句的地址(如test函数栈帧中保存了int b = 2这条指令的地址),也可以认为这个算上个栈帧的内容。
- ebp的值(上层函数的栈帧底部地址,在test的栈帧中,ebp为main的变量开始保存的地址。)
- 调试模式产生的一些标记
- 函数的参数(前6个在本栈栈帧中,其他的在上层函数的栈帧里面)
- 函数内声明的变量
- 调用的函数的大于6个参数的那些参数
栈帧的形成过程
当fun1调用一个函数fun2时,函数fun2的参数先被保存在寄存器(rdi, rsi, rdx, rcx, r8d, r9d)中,如果参数大于6个,这些多出来的参数会被push到当前栈中,也就是fun1的栈帧。函数的地址不会被保存在栈中。
rbp表示栈帧的开始,rsp表示栈帧的结束。在fun1中call fun2时,会执行push fun2的下一个地址,所以rsp会减少8字节,进入fun2的第一步是push rbp,所以rsp又会减小8字节。这时再保存rbp, 然后把rsp的值给rbp。于是就会发现这次的栈帧的开始相较于上次的栈帧顶部差了16字节。
GDB的操作
使用GDB操作可以分为:1. 进入调试 2. 显示汇编代码 3. 显示寄存器内容 4. 单步调试 5. 查看栈内容
1. 在terminal输入
gdb -q -tui a.out
# -q是不要显示那么多版本信息,quiet,-tui是打开ui窗口,a.out是程序
2. 在进入的gdb界面输入以下命令
layout asm
layout regs
3. 运行到main处
start
4. 单步调试,因为是在汇编基础上调试,所以使用 ni 和si 命令, ni代表不进入函数内部, si代表进入函数内部
5. 查看栈内容,其实也就是查看指定地址的内存存放的内容, 这个地址后面演示的时候再填写,这样就会显示出一些地址的内容
x /40xw 0x???????
实战
源代码
main函数调用test,test有9个参数,从右到左,将大于6个参数的压栈,然后将最左6个存入寄存器。
int test(int a, int b, int c, int d, int e, int f, int g, int h, int i)
{
int res = a+b+c+d+e+f+g+h+i;
int aa = 10;
int bb = 11;
int cc = 12;
return res;
}
int main()
{
int aaa = 1;
test(1,2,3,4,5,6,7,8,9);
int bbb = 2;
}
main函数相应的汇编, 可以验证之前所说的形成栈帧的过程。
如push 9 push 8 push 7, mov 6.... mov 5.....
需要说明的是callq , callq将0x11cb也就是当前callq所在行的下一行放入了栈里面。之后retq会将其pop出,用于跳转指令
0x118d <main> endbr64 │
│ 0x1191 <main+4> push %rbp │
│ 0x1192 <main+5> mov %rsp,%rbp │
│ 0x1195 <main+8> sub $0x10,%rsp │
│ 0x1199 <main+12> movl $0x1,-0x8(%rbp) │
│ 0x11a0 <main+19> pushq $0x9 │
│ 0x11a2 <main+21> pushq $0x8 │
│ 0x11a4 <main+23> pushq $0x7 │
│ 0x11a6 <main+25> mov $0x6,%r9d │
│ 0x11ac <main+31> mov $0x5,%r8d │
│ 0x11b2 <main+37> mov $0x4,%ecx │
│ 0x11b7 <main+42> mov $0x3,%edx │
│ 0x11bc <main+47> mov $0x2,%esi │
│ 0x11c1 <main+52> mov $0x1,%edi
0x11c6 <main+57> callq 0x1129 <test> │
│ 0x11cb <main+62> add $0x18,%rsp │
│ 0x11cf <main+66> movl $0x2,-0x4(%rbp) │
│ 0x11d6 <main+73> mov $0x0,%eax │
│ 0x11db <main+78> leaveq │
│ 0x11dc <main+79> retq │
│ 0x11dd nopl (%rax) │
│ 0x11e0 <__libc_csu_init> endbr64 │
│ 0x11e4 <__libc_csu_init+4> push %r15
再看 test的汇编 ,
1. push rbp, mov rsp, rbp, 即保存rbp的值,这可以帮助知道函数调用关系。在每一个push的时候,rsp的值都会变小,pop则会使rsp变大
2. 把之前存入寄存器的内容重新压入栈里面, mov edi, -0x14(%rbp), 也就是比rbp地址小0x14字节的位置放了1, 其他依次往后, 占据了 -0x14(%rbp) 到 -0x28(%rbp)。 这里面存了 1,2,3,4,5,6 ,而7 8 9 则存在比rbp大的地方,可以从上面的汇编代码看出
3. mov $0xa, -0xc(%rbp) 和 mov $0xb, -0x8(%rbp) 和mov $0xc, -0x4(%rbp) 这是函数里面的三个临时变量,可以看出临时变量更加靠近栈帧的底部。
4. mov -0x10(%rbp), %eax则是将某个值给了eax,eax寄存器通常用于保存一个函数的返回值。
5. retq 则表示pop pc, 也就是将之前压入栈的call 下面的指令的地址拿出来,换给pc, 而pc代表了 程序计数器,也就是下次执行指令要去的地方。
如何验证
使用 gdb的x命令,当我们打开寄存器显示时,里面有rbp,我们将rbp附近的内容打印出来就好了。
在main函数内部时, rbp的内容为 0x7fffffffde60 , rsp会随着程序运行逐渐变小。
进入test时,根据最先的两个汇编指令,可以知道某个内存就要保存0x7fffffffde60。
查看某个地址的内容,我选了0x7fffffffde00,因为此时的rbp为0x7fffffffde28,而之前的汇编显示了有个值会存在 rbp下偏移0x28的位置。 可以看到图片中的6 5 4 3 2 1这是前6个参数。 也可以看到再往下的 9 8 7. 还能看到0x7fffffffde60 。而这个位置再往前就是0x555551cb,这是test函数的下一行指令的地址。 而我们接着往下走,还可以知道main执行完了之后会跳到哪里。按下面的图,那个地址应该是 0xde80b3,这是main执行后应该做的事情。如下图,我们也确实看到这个地址被保存在内存里了。