第03节-函数调用和栈帧
第 3 节:函数调用和栈帧
所属课程:操作系统自学路线:面向网络安全、逆向工程与漏洞分析
所属周次:第 2 周
课程主题:C 语言、栈和汇编基础
本节目标:理解函数调用时 CPU、寄存器和栈大致发生了什么,掌握栈帧、返回地址、局部变量的基本概念,为后续学习汇编、调试器和栈溢出打基础。
1. 本节课你要学会什么
学完这一节,你应该能回答下面几个问题:
- 函数调用时,程序为什么能跳到另一个函数执行?
- 函数执行完后,CPU 怎么知道回到哪里?
- 栈是什么?为什么函数调用离不开栈?
- 栈帧是什么?
- 局部变量通常放在哪里?
- 返回地址是什么?
call和ret大致做了什么?- 为什么缓冲区溢出可能影响程序控制流?
- GDB 中如何观察调用栈、寄存器和栈内存?
本节课的主线是:
1 | |
如果你以后学漏洞利用、逆向工程、恶意代码调试,这一节是非常核心的基础。
2. 为什么要理解函数调用
在 C 语言里,函数调用看起来很简单:
1 | |
但从 CPU 和内存角度看,它背后至少涉及:
- 跳转到函数代码地址
- 保存返回位置
- 传递参数
- 分配局部变量空间
- 执行函数体
- 返回调用者
- 恢复调用者继续执行
逆向工程中,你看到的不是:
1 | |
而可能是:
1 | |
漏洞分析中,你关心的也不是“函数调用语法”,而是:
- 参数放在哪里
- 局部变量放在哪里
- 返回地址放在哪里
- 输入是否能覆盖返回地址
- 崩溃时
rip为什么变成异常值
所以理解函数调用,是从 C 语言走向汇编、逆向和漏洞分析的关键一步。
3. 一个简单的函数调用例子
先看这个程序:
1 | |
从 C 语言视角看:
- 程序进入
main main调用foo(10)foo中计算y = 11- 打印
y = 11 foo返回main返回- 程序结束
但是从底层视角看,还要问:
foo的代码在哪里?10这个参数怎么传过去?y存在哪里?foo执行完怎么回到main?
这些问题都和栈、寄存器、调用约定有关。
4. 栈是什么
栈是一种后进先出的数据结构。
英文是:
1 | |
后进先出可以理解为:
1 | |
也就是:
1 | |
现实类比:
1 | |
你最后放上去的盘子,通常最先拿下来。
在程序运行中,栈常用于管理函数调用。
每调用一个函数,就在栈上创建一块区域,用来保存这个函数运行所需的信息。
函数返回时,这块区域被释放。
5. 程序运行时的栈
每个线程通常都有自己的栈。
也就是说:
1 | |
栈里常见内容包括:
- 函数返回地址
- 上一个栈帧的信息
- 局部变量
- 临时数据
- 部分函数参数
- 保存的寄存器
在 x86-64 Linux 中,栈通常从高地址向低地址增长。
也就是说:
1 | |
可以粗略画成:
1 | |
注意:
不同架构、系统、编译器优化选项会影响具体布局。
但“函数调用会使用栈保存上下文”这个思想非常重要。
6. 什么是栈帧
栈帧英文是:
1 | |
可以理解为:
一个函数在栈上拥有的那一块工作区域。
每次函数调用,通常都会有一个对应的栈帧。
例如:
1 | |
调用过程中,栈上可能依次出现:
1 | |
当前正在执行 bar 时,调用链大致是:
1 | |
GDB 中的 bt 命令看到的就是调用栈,也就是当前函数调用链。
7. 返回地址是什么
函数调用有一个关键问题:
被调用函数执行完以后,CPU 怎么知道回到调用者的哪一行继续执行?
例如:
1 | |
执行 foo(10) 时,CPU 会跳到 foo 的代码处执行。
但 foo 执行完以后,需要回到:
1 | |
这个位置。
所以在调用 foo 时,程序需要保存一个位置:
1 | |
这个位置就是返回地址。
在常见 x86-64 调用中,call 指令会把返回地址压入栈中,然后跳转到目标函数。
8. call 指令大致做了什么
汇编中的 call 可以先理解成做了两件事:
1 | |
例如:
1 | |
大致等价于:
1 | |
这不是完全等价的真实机器实现,但作为入门理解足够。
例如:
1 | |
当执行 call 0x401136 时:
- 返回地址是
0x401155 - CPU 跳到
0x401136执行 foo执行完后应该回到0x401155
9. ret 指令大致做了什么
ret 用于函数返回。
它大致做:
1 | |
也就是:
1 | |
其中 rip 是 x86-64 中的指令指针寄存器,表示 CPU 下一条要执行的指令地址。
所以:
1 | |
这也是为什么返回地址对漏洞分析非常重要。
如果栈上的返回地址被错误覆盖,ret 就可能跳到错误位置。
10. rsp 和 rbp
在 x86-64 中,和栈密切相关的两个寄存器是:
1 | |
10.1 rsp
rsp 是栈指针寄存器。
它通常指向当前栈顶。
当执行 push 时:
1 | |
当执行 pop 时:
1 | |
10.2 rbp
rbp 常被用作栈帧基址。
在没有优化或保留帧指针的情况下,一个函数常见开头是:
1 | |
大致含义:
1 | |
函数结尾可能是:
1 | |
leave 大致相当于:
1 | |
然后 ret 返回调用者。
11. 一个典型栈帧长什么样
以没有优化的简单函数为例:
1 | |
栈帧可能大致像这样:
1 | |
注意:
这只是教学简化图。
真实情况会受到:
- 编译器
- 优化等级
- ABI 调用约定
- 栈对齐
- 是否开启保护机制
- 是否使用 frame pointer
等因素影响。
但是你现在需要先掌握:
1 | |
12. 参数是怎么传递的
函数调用需要传参数。
例如:
1 | |
参数 10 要传给 foo。
在 x86-64 Linux,也就是 System V AMD64 ABI 中,前几个整数或指针参数通常通过寄存器传递。
常见顺序:
1 | |
所以:
1 | |
可能对应:
1 | |
Windows x64 调用约定不同。
Windows x64 常见前四个参数:
1 | |
这就是为什么同一个 C 函数,在 Linux 和 Windows 下反汇编看起来会不同。
13. 为什么调用约定重要
调用约定规定:
- 参数放在哪里
- 返回值放在哪里
- 哪些寄存器由调用者保存
- 哪些寄存器由被调用者保存
- 栈如何对齐
- 函数返回时谁清理栈
逆向工程中,如果你不知道调用约定,就很难判断:
1 | |
到底是在调用:
1 | |
还是其他形式。
漏洞分析中,调用约定也会影响你判断:
- 参数来源
- 返回值位置
- 栈布局
- 崩溃现场
14. 返回值放在哪里
在 x86-64 中,整数或指针返回值通常放在:
1 | |
例如:
1 | |
调用:
1 | |
add 返回后,结果 3 通常会在 rax 中。
所以你在调试器中经常会看到:
1 | |
意思大致是:
1 | |
15. 局部变量为什么函数结束后失效
看这个例子:
1 | |
这个函数返回了局部变量 x 的地址。
这是错误的。
因为 x 在 bad 的栈帧中。
当 bad 返回后,它的栈帧就不再有效。
也就是说:
1 | |
如果后续其他函数调用复用了这块栈空间,原来的值可能被覆盖。
这类问题属于悬垂指针的一种。
虽然它不一定每次立刻崩溃,但行为是未定义的。
16. 栈溢出的直觉理解
看这个程序:
1 | |
buf 是一个 16 字节的局部数组,通常位于栈上。
如果输入超过 16 字节:
1 | |
strcpy 不会自动检查 buf 大小,会继续往后写。
这样就可能覆盖栈上的其他内容。
栈上可能有:
- 其他局部变量
- 保存的 rbp
- 返回地址
如果返回地址被覆盖,函数执行 ret 时就可能跳到错误地址。
这就是经典栈缓冲区溢出的基础。
本节不要求你写 exploit,只要求你理解:
1 | |
17. 为什么现代系统不容易被简单栈溢出利用
早期栈溢出比较容易被利用。
现代系统有很多保护机制,例如:
- Stack Canary
- ASLR
- DEP / NX
- PIE
- RELRO
- CFG
这些机制会增加利用难度。
例如:
- Canary 用来检测返回地址附近是否被覆盖
- ASLR 让地址难以预测
- NX 让栈上的数据不能直接执行
- PIE 让程序代码基址随机化
所以学习栈溢出时,不要只背 payload。
应该先理解:
1 | |
18. Linux 实验:用 GDB 观察函数调用
创建 stack_demo.c:
1 | |
编译:
1 | |
这里:
-g表示生成调试信息-O0表示关闭优化,方便观察栈帧
启动 GDB:
1 | |
在 GDB 中执行:
1 | |
重点观察:
bt输出中的调用链- 当前函数是不是
bar rsp和rbp的值- 栈上有哪些看起来像地址的数据
- 是否能找到返回地址
19. GDB 常用命令解释
19.1 break
设置断点。
1 | |
表示程序执行到 bar 时暂停。
19.2 run
运行程序。
1 | |
19.3 bt
查看调用栈。
1 | |
可能输出:
1 | |
这说明:
1 | |
19.4 info registers
查看寄存器。
1 | |
重点看:
riprsprbprdirsirax
19.5 x/30gx $rsp
查看内存。
1 | |
含义:
1 | |
拆开看:
| 部分 | 含义 |
|---|---|
| x | examine,查看内存 |
| 30 | 显示 30 个单位 |
| g | giant word,8 字节 |
| x | 十六进制显示 |
| $rsp | 从 rsp 寄存器指向的地址开始 |
20. Linux 实验:观察反汇编
在 GDB 中执行:
1 | |
你可能看到类似:
1 | |
重点观察:
- 函数开头是否有
push rbp - 是否有
mov rbp, rsp - 是否有
sub rsp, ... - 函数结尾是否有
leave - 是否有
ret - 调用其他函数时是否有
call
如果你看到的汇编和示例不完全一样,不要紧。
编译器版本、优化选项、系统环境都会影响输出。
你要抓住核心模式。
21. Windows 对照:x64dbg 中观察函数调用
在 Windows 下,你可以写类似程序并编译成 exe。
用 x64dbg 打开后,重点观察:
- CPU 指令窗口
- 寄存器窗口
- 栈窗口
- 调用栈窗口
Windows x64 下参数传递和 Linux 不同。
前四个参数通常使用:
1 | |
函数调用时,你可以观察:
1 | |
这表示把第一个参数放入 ecx/rcx,然后调用 foo。
Windows x64 还要求调用者在栈上预留 shadow space。
入门阶段不需要深入 shadow space,只要知道:
1 | |
22. 从逆向工程角度看本节内容
逆向工程中,函数调用是最常见结构之一。
当你在反汇编中看到:
1 | |
你应该想到:
1 | |
当你看到:
1 | |
你应该分析:
- 这个函数调用了什么 API?
- 参数是什么?
- 返回值在哪里?
- 调用之后程序如何分支?
很多逆向任务其实就是恢复函数调用关系:
1 | |
23. 从漏洞分析角度看本节内容
栈漏洞的分析离不开函数调用。
你需要能判断:
- 当前停在哪个函数
- 调用链是什么
- 哪个局部变量被覆盖
- 返回地址在哪里
- 崩溃时
rip是什么 - 输入数据是否出现在栈上
例如 GDB 中看到:
1 | |
0x41 是字符 A。
如果 rip 变成:
1 | |
这通常说明程序控制流被输入的 A 影响了。
这就是漏洞分析中非常重要的信号。
24. 从恶意代码分析角度看本节内容
恶意代码分析中,函数调用同样重要。
你可能会关注:
- 哪个函数调用了网络 API
- 哪个函数解密字符串
- 哪个函数创建文件
- 哪个函数创建进程
- 哪个函数申请可执行内存
- 哪个函数启动新线程
如果你能看懂函数调用关系,就能更快梳理恶意代码行为。
例如:
1 | |
这样的调用关系,可能比单独看某一行汇编更有价值。
25. 本节重点总结
你需要记住这些核心结论:
- 栈是一种后进先出的结构。
- 程序运行时,每个线程通常有自己的栈。
- 函数调用时,通常会在栈上创建栈帧。
- 栈帧中可能包含局部变量、保存的寄存器、返回地址等信息。
call大致负责保存返回地址并跳转到被调用函数。ret大致负责从栈中取出返回地址并跳回去。rsp通常指向栈顶,rbp常用于定位当前栈帧。- Linux x64 和 Windows x64 的参数传递约定不同。
- 返回值通常会放在
rax中。 - 栈溢出的本质是越界写破坏了栈帧中的其他数据。
26. 本节课后作业
作业 1:观察三层函数调用
写程序:
1 | |
编译:
1 | |
GDB:
1 | |
提交内容:
1 | |
作业 2:反汇编函数
在 GDB 中执行:
1 | |
或在 shell 中执行:
1 | |
要求找到:
callretpush rbpmov rbp, rspsub rsp, ...
回答:
1 | |
作业 3:理解返回地址
在 GDB 中:
1 | |
尝试观察:
- 执行
call前后rip怎么变 - 栈顶是否出现返回地址
foo返回后程序回到哪里
提交一段解释:
1 | |
27. 自测题
题 1
栈为什么叫后进先出?
题 2
什么是栈帧?
题 3
返回地址的作用是什么?
题 4
call 指令大致做哪两件事?
题 5
ret 指令大致做什么?
题 6
rsp 和 rbp 的区别是什么?
题 7
Linux x64 前 6 个整数参数通常放在哪些寄存器?
题 8
为什么局部变量的地址不能在函数返回后继续安全使用?
题 9
为什么栈缓冲区溢出可能影响程序控制流?
28. 自测题参考答案
答 1
因为最后压入栈的数据会最先被弹出,就像一摞盘子,最后放上去的通常最先拿下来。
答 2
栈帧是一个函数调用期间在栈上使用的一块工作区域,里面可能保存局部变量、返回地址、保存的寄存器等信息。
答 3
返回地址用于记录被调用函数执行完后,CPU 应该回到调用者的哪个位置继续执行。
答 4
call 大致会把下一条指令地址压入栈作为返回地址,然后跳转到被调用函数执行。
答 5
ret 大致会从栈顶取出返回地址,并跳转到该地址继续执行。
答 6
rsp 通常指向当前栈顶;rbp 常作为当前函数栈帧的基准位置,用于稳定访问局部变量和保存的数据。
答 7
Linux x64 System V ABI 中,前 6 个整数或指针参数通常依次放在 rdi、rsi、rdx、rcx、r8、r9。
答 8
因为局部变量通常位于当前函数的栈帧中。函数返回后,该栈帧失效,后续函数调用可能复用这块栈空间。
答 9
因为局部缓冲区通常在栈上,越界写可能覆盖栈帧中的保存数据,甚至覆盖返回地址。函数执行 ret 时可能跳到被破坏的地址,从而影响控制流。
29. 下一节预告
下一节课会讲:
1 | |
你会进一步学习:
- 常见寄存器
- 常见汇编指令
mov、add、sub、cmp、jmp、call、ret- Linux 和 Windows 调用约定对照
- 如何用反汇编理解 C 程序