函数调用栈简单认识(美化版)
函数调用的栈帧
每一个函数都有一块栈空间,叫做栈帧,研究函数调用栈其实就是研究主调函数与被调函数栈帧之间的问题
栈帧的作用是保存并传递被调函数的参数、被调函数的返回地址(也就是主调函数中调用完被调函数后应该执行的下一句)、被调函数的返回值、保存函数的局部变量
AMD64 CPU 提供了2个与栈相关的寄存器:
-
rsp寄存器, 始终指向函数调用栈栈顶
-
rbp寄存器, 一般用来指向函数栈帧的开始位置
举一个例子:A( )->B( )->C( )
这里就可以发现几点信息:
(1)被调函数的返回值和返回地址保存在主调函数中,而被调函数中主要保存函数的临时变量
(2)栈的生长方向是由高到低,但是数据的填充方向是由低到高的
(3)rsp和rbp寄存器是唯一的,两者永远指向当前函数的栈顶和栈底
函数调用的汇编
常见的汇编指令
call/ret
call 目标地址
ret
call指令在函数调用的过程中的作用是将当前rip的值压入栈帧中(被调函数的返回地址),然后将rip的值修改为目标地址,因为rip寄存器的主要作用是存储计算机下一条执行指令的地址,所以这一步之后开始执行目标地址的指令
ret指令与call指令相反,ret指令将栈中rip的值弹出栈,从而保证主调函数能够接着执行
leave
leave 指令没有操作数, 它一般放在函数的尾部 ret 指令之前, 用于调整 rsp 和 rbp, 这条指令相当于:
mov %rbp, %rsp//将rbp的值赋值给rsp
pop %rbp//将rbp出栈
在执行 ret 指令之前, 需要确保 rbp 寄存器的值已经恢复到 "被调用函数栈帧的起始位置", rsp 寄存器的值是 "被调用函数的 栈帧的结束位置(也就是调用函数的开始位置)". (leave指令就可以实现该功能)
pop/push
先来介绍一下这两个指令的意义,这两个指令的作用在于当发生函数调用时,主调函数尚未执行完成,因此被调函数不能将主调函数的栈帧覆盖,所以需要push和pop指令来创建新的栈帧来存储被调函数的信息
push 源操作数
pop 目标操作数
push和pop指令都会修改rsp的值
push 入栈时, rsp 寄存器的值先减去 8 把栈位置留出来, 然后把操作数复制到 rsp 所指定位置. push 指令相当于:
sub $8, %rsp
mov 源操作数, 0(%rsp)
pop 出栈时, 先把 rsp 寄存器所指位置的数据复制到目的操作数中, 然后 rsp 寄存器的值加 8. pop 指令相当于:
mov 0(%rsp), 目标操作数
add $8, %rsp
函数调用的实例
程序实例
gdb调试
gdb 调试命令:
- run, r : 运行程序, 当遇到断点后, 程序会在断点处停止运行, 等待用户输入下一步命令 - continue, c : 继续执行, 到下一个断点处(或运行结束) - next, n : 单步调试, 如果有函数调用, 不进入此函数体 - step, s : 单步调试, 如果有函数调用, 则进入函数 - finish : 运行程序, 知道当前函数完成后返回, 并打印函数返回时堆栈地址和返回值以及参数值等信息 - quit, q : 退出 gdb
断点设置:
- break , b : 在第n行处设置断点 - b : 在函数 func() 的入口设置断点, 如: b main - delete <断点号n>: 删除第n个断点 - disable/enable <断点号n>: 暂停/启用第n个断点
- info b : 显示断点设置状况
打印表达式: print <表达式>, p <表达式>: 其中的"表达式"是当前测试程序的有效表达式, 例如: p var(打印变量var的值), p fun(22) 调用函数fun() display <表达式>: 在每次单步进行指令后, 紧接着输出被设置的表达式及值. 如 dispaly a
info function: 查询函数 info : 显示当前堆栈页的所有变量.
程序调试
Dump of assembler code for function main:
=> 0x0000555555554664 <+0>: push rbp
0x0000555555554665 <+1>: mov rbp,rsp
0x0000555555554668 <+4>: sub rsp,0x20
0x000055555555466c <+8>: mov DWORD PTR [rbp-0x14],edi
0x000055555555466f <+11>: mov QWORD PTR [rbp-0x20],rsi
0x0000555555554673 <+15>: mov esi,0x2
0x0000555555554678 <+20>: mov edi,0x1
0x000055555555467d <+25>: call 0x55555555464a <sum>
0x0000555555554682 <+30>: mov DWORD PTR [rbp-0x4],eax
0x0000555555554685 <+33>: mov eax,DWORD PTR [rbp-0x4]
0x0000555555554688 <+36>: mov esi,eax
0x000055555555468a <+38>: lea rdi,[rip+0xa3] # 0x555555554734
0x0000555555554691 <+45>: mov eax,0x0
0x0000555555554696 <+50>: call 0x555555554520 <printf@plt>
0x000055555555469b <+55>: mov eax,0x0
0x00005555555546a0 <+60>: leave
0x00005555555546a1 <+61>: ret
i r rbp rsp rip//观察rbp、rsp、rip寄存器的值
rsp 0x7fffffffe028 0x7fffffffe028
rbp 0x5555555546b0 0x5555555546b0 <__libc_csu_init>
rip 0x555555554664 0x555555554664 <main>
开始执行第一条指令:push rbp,push指令将rsp减8,使栈空间向低地址生长,扩充空间,存储主调函数(也就是调用main函数的函数)的帧基地址的信息
i r rsp rbp rip
rsp 0x7fffffffe020 0x7fffffffe020//rsp的值减去8
rbp 0x5555555546b0 0x5555555546b0 <__libc_csu_init>
rip 0x555555554665 0x555555554665 <main+1>
紧接着执行下一条指令: mov rbp, rsp,这条指令将rsp的值赋值给了rbp,现在rbp和rsp同时指向一个位置
i r rsp rbp rip
rsp 0x7fffffffe020 0x7fffffffe020
rbp 0x7fffffffe020 0x7fffffffe020
rip 0x555555554668 0x555555554668 <main+4>
sub rsp, 0x20,将rsp减去20字节,继续扩充栈帧
i r rsp rbp rip
rsp 0x7fffffffe000 0x7fffffffe000
rbp 0x7fffffffe020 0x7fffffffe020
rip 0x55555555466c 0x55555555466c <main+8>
0x55555555466c <main+8> mov dword ptr [rbp - 0x14], edi//向被调函数sum传递参数
0x55555555466f <main+11> mov qword ptr [rbp - 0x20], rsi//默认是rdi、rsi、rdx顺序
0x555555554673 <main+15> mov esi, 2
0x555555554678 <main+20> mov edi, 1
i r rip rsp rbp
rip 0x55555555467d 0x55555555467d <main+25>
rsp 0x7fffffffe000 0x7fffffffe000
rbp 0x7fffffffe020 0x7fffffffe020
接下来开始调用sum函数
0x55555555467d <main+25> call sum <sum>
rip 0x555555554682 0x555555554682 <main+30>
rsp 0x7fffffffe000 0x7fffffffe000
rbp 0x7fffffffe020 0x7fffffffe020
call指令先将下一条指令的值压入栈中,之后修改rip为目标地址
之后是sum函数的执行流程
Dump of assembler code for function sum:
0x000055555555464a <+0>: push rbp
0x000055555555464b <+1>: mov rbp,rsp
0x000055555555464e <+4>: mov DWORD PTR [rbp-0x14],edi
0x0000555555554651 <+7>: mov DWORD PTR [rbp-0x18],esi
0x0000555555554654 <+10>: mov edx,DWORD PTR [rbp-0x14]
0x0000555555554657 <+13>: mov eax,DWORD PTR [rbp-0x18]
0x000055555555465a <+16>: add eax,edx
0x000055555555465c <+18>: mov DWORD PTR [rbp-0x4],eax
0x000055555555465f <+21>: mov eax,DWORD PTR [rbp-0x4]
0x0000555555554662 <+24>: pop rbp
0x0000555555554663 <+25>: ret
End of assembler dump.
sum 函数并未像 main 函数一样通过调整 rsp 寄存器的值来给 sum 函数预留用于局部变量和临时变量的栈空间, 那这是不是说明 sum 函数就没有使用栈来保存局部变量呢? 其实不是, 从后面的分析看到, sum 函数的局部变量 s 还是保存在栈上的. 没有预留为什 么可以使用呢, 原因在于栈上的内存不需要在应用层代码中分配, 操作系统已经给我们分配好了, 可以尽管使用. main函数需要调整 rsp寄存器的值是因为它需要使用 call 指令来调用 sum 函数, 而 call 指令会自动把rsp的值减去 8 然后把函数的返回地址保存到 rsp所指定的栈内存位置(即, call指令会自动将当前的rip的地址入栈), 如果 main 函数不调整 rsp 的值, 则 call 指令保存函数 返回地址时会覆盖局部变量或临时变量的值; 而 sum 函数中没有任何指令会自动使用 rsp 寄存器来保存数据到栈上, 所以不需要调整 rsp寄存器.
ret
ret指令将之前压入栈中的rip的值取出,然后将rip的值加8
0x555555554691 <main+45> mov eax, 0//返回sum函数返回值
然后是printf函数
0x0000000000400561 <+33>:mov -0x4(%rbp),%eax # 将变量 n 的值放入 eax
0x0000000000400564 <+36>:mov %eax,%esi # 函数 printf 的第2个参数
0x0000000000400566 <+38>:mov $0x400604, %edi # 函数 printf 的第1个参数
0x000000000040056b <+43>:mov $0x0,%eax # 函数 printf 返回值
0x0000000000400570 <+48>:callq 0x400400 <printf@plt> # 函数调用
0x0000000000400575 <+53>:mov $0x0,%eax # main 函数返回值
然后是leave指令, leave 指令先把 rbp 寄存器的值复制给 rsp, 这样, rsp 就指向了 rbp 的栈单元, 然后使该内存单元当中的值 POP出给 rbp 寄存器, 这样 rbp 和 rsp 就恢复到刚进入 main 函数时的状态了.
rip 0x5555555546a1 0x5555555546a1 <main+61>
rsp 0x7fffffffe028 0x7fffffffe028
rbp 0x5555555546b0 0x5555555546b0 <__libc_csu_init>