函数栈帧的创建与销毁
1.为什么要了解函数的栈帧
函数是程序最重要的组成部分 通过了解栈帧可以知道 在使用函数时传入的参数在内存中是怎么被传递的 函数结束时返回值是怎么返回原函数的 栈溢出是怎么发生的 等等
2.了解函数栈帧
栈是什么
很明显栈是一种数据结构 遵守先进后出的规则
函数栈帧是什么
函数栈帧是在程序执行过程中 每当调用一个函数时会在栈区用到一块临时空间 这块空间被称为栈帧 栈帧中可能包含局部变量 栈参数 返回地址 保存的寄存器状态等信息 函数栈帧的创建和销毁通常由编译器生成的栈帧指令和CPU执行完成 操作系统负责提供栈区域和页映射 它遵循后进先出的原则 即最后被调用的函数会最先完成执行并恢复栈指针 函数栈帧的维护 寄存器的作用非常大
寄存器是什么
寄存器存在于cpu中 用来短暂存放数据 是cpu不可或缺的一部分 比如说rsp是当前栈顶指针 rbp通常被编译器用作当前栈帧的帧指针 rip是保存下一条指令地址的寄存器 还有通用寄存器等 来一个超大声的提醒 rsp和rbp都是寄存器 汇编中的push等操作都是将这一瞬间的值写入内存
函数栈帧的结构是怎样的
栈帧是从高地址向低地址生长的(可以暂且理解为在失去重力环境且倒放的积木搭 必须先进后出 即每次只能拿最上面的) 函数执行完毕也会弹出所有积木 函数栈帧的结构也是嵌套的 像摞起来的积木
核心结构可被拆分为
- 局部变量区 在函数内的临时变量 编译器可能重排 对齐 甚至直接优化掉
- 旧帧指针 也就是被push到栈上的旧rbp 它用来恢复调用者的栈帧
- 返回地址 函数执行完毕后 跳回调用者代码的位置
- 参数区 只有没有放进寄存器的参数才会落在栈上 具体由调用约定决定
以上顺序就是按照从低到高地址排列的
算法中的栈和程序运行的栈不同处
| 维度 | 数据结构的“栈”(抽象逻辑) | 程序运行的“栈”(虚拟内存) |
|---|---|---|
| 本质 | 一种“先进后出”的逻辑数据模型 | 进程虚拟地址空间里的栈区域 |
| 存在形式 | 仅在算法/代码设计中存在(如数组模拟栈) | 由编译器生成栈帧指令 由操作系统管理栈区域和页映射 |
| 操作主体 | 开发者手动实现 push/pop 等接口 |
编译器+CPU 自动执行(函数调用、局部变量存储时触发) |
| 核心规则 | 严格遵循“先进后出” | 同样遵循“先进后出”,是函数栈帧创建/销毁的底层支撑 |
了解基本的汇编指令
- push:将操作数压入栈中,同时调整栈顶指针 rsp。
- pop:从栈中弹出数据到指定位置,同时调整栈顶指针 rsp。
- mov:数据传送指令,在寄存器之间、寄存器与内存之间传送数据。
- add:加法指令,将两个操作数相加,结果存于目标操作数。
- sub:减法指令,将两个操作数相减,结果存于目标操作数。
- call:过程调用,压入返回地址并转入调用函数。
- lea:加载有效地址指令,将操作数的有效地址加载到指定寄存器。
- ret:返回指令,从栈中取出返回地址并回到调用位置
很多指令有复合操作 我在以下补充常见指令 - push =
sub rsp, 8(栈顶指针下移)+mov [rsp], 操作数(存数据);严格说应理解为lea rsp, [rsp-8]+mov [rsp], 操作数,且不修改 flags - pop =
mov 目标, [rsp](取数据)+add rsp, 8(栈顶指针上移);严格说应理解为mov 目标, [rsp]+lea rsp, [rsp+8],且不修改 flags - call =
push 返回地址(其实又是 sub+mov 的复合)+jmp 目标;call不修改 flags - ret =
pop 返回地址到 rip(取返回地址 + 调整栈顶)+jmp 到该地址;pop rip不是合法指令,ret本身完成取返回地址、调整 rsp、跳转,且不修改 flags
以下使用代码切实观察函数的栈帧的创建销毁
栈帧存在于进程中的 在进程虚拟地址空间里的结构大致是
高地址 示意地址 实际地址随系统和布局变化 |
栈帧的结构是
高地址 |
这张图按Windows x64看 调用者会预留32字节影子空间 第5个开始的整数或指针参数才继续放在栈上 如果按SysV x86-64看 则是前6个整数或指针参数走寄存器 第7个开始入栈
好吧 真难为情 做好咯…………(先过中门再谈未来)咕咕嘎嘎 上面写的定义啥的很大一部份都是抄的 n哪个flag也是寄存器 本章不涉及 我只是补充了一部分内容 下面才是我我写的 猜你肯定不愿意看到这(小声嘀咕)
我们采用以下代码 之后进行反编译看汇编代码来了解函数栈帧
|
; =============== a =============== |
接下来将按照指令一步一步拆解
push rbp
前面说过 push指令是一个复合指令 即push = sub rsp, 8(栈顶指针下移)+ mov [rsp], 操作数(存数据)
sub rsp,8将栈顶下移8字节(记得前面说过 栈帧是从高地址向低地址生长的 咕咕嘎嘎)
mov [rsp],rbp 将现在rbp的值写入到rsp的位置 也就是刚刚移开的位置(64位下rbp的值是64位 需要8字节来存放)
当前栈帧结构为 以下出现的视图都是从高地址向低地址排列的
|
mov rbp,rsp
将寄存器rsp的值赋给rbp寄存器 旧rbp已经不再保存在rbp寄存器里 而是保存在当前rbp指向的栈位置
那么当前栈帧结构为
rbp 8字节 这一步操作前的 当然当前rsp也指向这里 |
sub rsp, 30h
将rsp寄存器中的值减去30h 也就是48字节
当前栈帧结构为
|
call __main ; CRT 初始化
此段代码用于初始化环境 不影响上下文 我觉得在当前上下文可以直接省略(咕咕嘎嘎)
mov ecx, 5
兄弟们要开始传参了 在这里将5赋值给了ecx通用寄存器 其实我应该分开写的 这样能看到参数是怎么存在初始化的区域的 我在这里补充一下
在main函数内
int a = 19; |
那么在汇编视角下应该是
mov DWORD PTR [rbp-4], 19 //DWORD PTR就是 double word pointer 就是表示这次内存访问访问4字节 |
也就是把 32 位立即数 19** 写入内存地址 rbp - 4 开始的 4 个字节
回到传参
当前栈帧结构为 没变化 只是寄存器数值的改变
rsp指向的地方 |
call a 开始调用a函数
前面提到过call也是复合指令 我超威 这我才反应过来 Windows x64下前四个整数或指针参数会放进寄存器 所以这个例子不需要把参数压进栈 我也懒得改了 就用这个吧 会在这里再补充
call = push 返回地址(其实又是 sub+mov 的复合)+ jmp 目标
我觉得这里push的返回地址和等会进入call的函数push rbp有点像 所以我想在这做一个补充 这里的地址是call之后的指令位置 等调用函数结束后ret知道该回到哪里call 的 push:CPU 自动压入返回地址,给 ret 用,属于控制流。
函数开头的 push rbp:编译器显式压入旧帧指针,给 leave/pop rbp 用,属于栈帧管理。
当前栈帧结构为
新划分出的8字节 用来存放返回地址 也是当前rsp指向的地方 |
跳转函数
跳转后的函数指令与当前介绍的一致 不再重复 函数栈帧的销毁也会之后体现
需要注意的是 Windows x64 下调用者会预留32字节影子空间 被调用函数可以把寄存器参数存进去 这块空间属于调用者的栈分配
mov [rbp+var_4], eax
需要注意的是var是ida自动生成的字符 通常对应-4
也就是将返回的值存入到rbp-4当中去 也就是result
当前栈结构为
rsp指向的地方 |
mov eax, [rbp+var_4]
看着有点像画蛇添足 明明还没动 怎么又赋值一遍 其实这里是未优化代码的表现 编译器先把返回值写进result的内存 再按C语义从内存里读出来
优化后的代码可能直接把a的返回值继续放在寄存器里 甚至把result这个栈位置完全优化掉
- 调用
a - 把返回值存进
result的内存 - 用
result时再从内存读出来
当前栈帧结构为
rsp指向的地方 |
lea rcx, FormatFormat 通常定义成这样 Format db “The square of 5 is %d\n”, 0db 表示定义字节,Format 是这个字符串的起始地址标签
也就是说,Format 代表内存中某个地址,那里开始存放字符串 "The square of 5 is %d\n"
mov edx, eax
将eax的值赋值给edx 在这里赋值作用是:把要打印的整数放到 printf 第二个参数该在的寄存器里。
xor eax, eax
Windows x64调用变参函数时 AL要用来表示使用的向量寄存器数量 这里没有浮点参数 所以先把AL清零
call printf
和前面一样 不再赘述
mov eax, 0
这条指令应该指的是return 0; 在 Windows x64 调用约定下 函数的整数返回值放在 EAX 里
add rsp,30h
回收 main 函数的栈帧空间 把 rsp 恢复到 sub rsp, 30h 之前的位置
要开始结束了
当前栈帧结构为
栈底 rsp指向的位置 |
pop rbp
把栈顶保存的“调用者的 rbp”弹回 rbp 寄存器,
mov rbp, [rsp] ; 把栈顶的 8 字节读入 rbp
add rsp, 8 ; 栈顶指针上移 8 字节
retn
pop rbp之后 rsp刚好指向返回地址 retn从栈顶取出返回地址送进rip 然后rsp上移8字节 也就是说真正恢复rip的是retn