栈帧交换汇编实现
栈帧交换汇编实现
N0orange栈帧交换汇编实现
基于 408 考研中的计算机组成原理与操作系统知识,理解函数调用栈帧、
call/ret指令、esp/ebp的变化,以及上下文切换中所谓“栈帧交换”的本质。
[toc]
一、需要先掌握的基础
1. 栈的基本特点
栈是一种 后进先出 的线性结构,函数调用、参数传递、局部变量保存、返回地址保存都经常依赖栈完成。
在 32 位 x86 中,栈通常具有如下特点:
- 栈从 高地址向低地址增长。
push表示压栈,会让esp减小。pop表示出栈,会让esp增大。- 栈顶由
esp维护。 - 当前函数栈帧的基准通常由
ebp维护。
示意图:
1 | 高地址 |
因为栈向低地址增长,所以:
1 | push eax |
等价理解为:
1 | esp = esp - 4 |
而:
1 | pop eax |
等价理解为:
1 | eax = [esp] |
2. 常用寄存器
| 寄存器 | 作用 |
|---|---|
eax |
累加寄存器,常用于保存函数返回值 |
ebx |
通用寄存器,常用于保存数据地址 |
ecx |
计数寄存器,循环、字符串操作中常见 |
edx |
通用寄存器,乘除法、I/O 中常见 |
esi |
源变址寄存器 |
edi |
目的变址寄存器 |
esp |
栈顶指针,指向当前栈顶 |
ebp |
栈底/帧指针,用来定位当前函数栈帧 |
eip |
指令指针,保存下一条要执行的指令地址(课本中的PC) |
eflags |
标志寄存器,保存条件码、状态位等(PSW) |
408 中最关键的是这三个:
1 | eip:下一条指令在哪里执行 |
二、普通函数调用的栈帧变.0化
1. 示例程序
1 | int Add(int a, int b) |
为了符合 408 学习习惯,下面按 32 位 x86 和常见 cdecl 调用约定理解。
调用函数时,参数通常 从右向左压栈:
1 | push 20 |
这里:
push 20压入第二个参数b。push 10压入第一个参数a。call Add调用函数。add esp, 8调用结束后清理两个参数。eax中保存函数返回值。
2. 执行 push 20
1 | 高地址 |
3. 执行 push 10
1 | 高地址 |
4. 执行 call Add
call 指令做两件事:
- 把返回地址压栈(保存旧ip)。
- 修改
eip,跳转到被调用函数入口。
返回地址就是 call Add 后面那条指令的地址,也就是:
1 | add esp, 8 |
执行 call Add 后:
1 | 高地址 |
此时 eip 指向 Add 函数入口。
三、Add 函数栈帧的创建
1. 函数序言
进入 Add 后,常见函数序言如下(交换栈帧):
1 | push ebp |
含义:
push ebp:保存调用者的旧栈帧基准。mov ebp, esp:建立当前函数的新栈帧基准。sub esp, 4:为局部变量分配空间。
2. 执行 push ebp
把 main 的旧 ebp 压入栈中:
1 | 高地址 |
3. 执行 mov ebp, esp
建立 Add 函数自己的栈帧基准:
1 | 高地址 |
4. 执行 sub esp, 4
为局部变量 c 分配 4 字节空间:
1 | 高地址 |
这就是 Add 函数的栈帧。
5. 为什么用 ebp 访问变量
在函数执行过程中,esp 可能因为 push/pop 不断变化。如果直接用 esp 定位参数和局部变量,偏移量会变得不稳定。
而 ebp 在当前函数内一般保持不变,所以适合作为栈帧基准:
1 | mov eax, [ebp+8] ; eax = a |
对应关系:
1 | [ebp+12] 参数 b |
四、函数返回时的栈帧销毁
1. 函数尾声
函数返回前常见汇编如下:
1 | mov eax, [ebp-4] |
2. 返回值放入 eax
1 | mov eax, [ebp-4] |
把局部变量 c 的值放入 eax。
408 常考点:
1 | 函数返回值通常通过寄存器返回,32 位 x86 中常用 eax。 |
3. 执行 mov esp, ebp
该指令直接丢弃当前函数的局部变量区域:
1 | 高地址 |
4. 执行 pop ebp
恢复调用者 main 的 ebp:
1 | 高地址 |
5. 执行 ret
ret 的核心效果可以理解为:
1 | pop eip |
也就是:
1 | eip = [esp] |
执行后:
1 | 高地址 |
CPU 回到 main 中 call Add 的下一条指令。
然后 main 执行:
1 | add esp, 8 |
清理参数:
1 | 高地址 |
五、从函数栈帧到“栈帧交换”
1. “栈帧交换”不是复制栈
所谓“栈帧交换”,更准确的说法是 上下文切换。
它并不是把任务 A 的栈内容和任务 B 的栈内容互相复制,而是:
1 | 保存当前任务的 esp、ebp、必要寄存器 |
关键理解:
1 | esp 一变,CPU 当前使用的栈就变了。 |
所以上下文切换的核心是:
1 | 保存旧现场,恢复新现场。 |
2. 两个任务的栈
假设任务 A 正在运行,任务 B 之前被暂停。
任务 A 的栈:
1 | 高地址 |
任务 B 的栈:
1 | 高地址 |
切换前,CPU 的 esp/ebp 指向 A:
1 | CPU |
切换时,保存 A 的现场:
1 | ctx_a |
再恢复 B 的现场:
1 | ctx_b |
切换后,CPU 的 esp/ebp 指向 B:
1 | CPU |
六、简化版汇编实现
1. 上下文结构
1 | typedef struct Context { |
这里只保存一部分寄存器,是为了说明原理。
真正的操作系统进程切换还要保存:
eipeflags- 通用寄存器
- 段寄存器
- 页表基址
- 内核栈指针
- 进程状态和调度信息
2. 汇编代码
1 | ; void context_switch(Context *old, Context *new) |
核心只有三步:
1 | mov [old_context], esp |
其中最关键的是:
1 | mov esp, [eax + 0] |
这一句执行后,当前栈已经换成了另一个任务的栈。
后面的 ret 不再从旧任务的栈顶取返回地址,而是从新任务的栈顶取返回地址。
七、栈帧交换的完整过程
1. A 调用切换函数
假设 A 正在执行:
1 | context_switch(&ctx_a, &ctx_b); |
进入 context_switch 前,call 指令已经压入返回地址。
此时 A 的栈为:
1 | 高地址 |
其中:
1 | return_to_A = context_switch 调用结束后,A 应该继续执行的位置 |
2. 保存 A 的上下文
执行:
1 | mov eax, [esp + 4] |
保存结果:
1 | ctx_a |
注意,此时 ctx_a.esp 指向的是 A 栈中的返回地址。
也就是说,A 的“暂停点”被保存在了它自己的栈上。
3. 恢复 B 的上下文
执行:
1 | mov eax, [esp + 8] |
此时 CPU 当前栈已经切换为 B 的栈:
1 | 高地址 |
4. 执行 ret
1 | ret |
等价于:
1 | eip = [esp] |
因为此时 esp 已经是 B 的 esp,所以 ret 取出的不是 return_to_A,而是 return_to_B。
于是 CPU 跳回 B 之前暂停的位置继续执行。
完整流程:
1 | A 正在运行 |
反向切换同理:
1 | B 正在运行 |
八、第一次运行的任务如何进入入口函数
1. 问题
如果任务 B 从来没有运行过,那么它的栈里一开始并没有 return_to_B。
那 ret 应该跳到哪里?
答案是:初始化任务 B 的栈时,手动伪造一个返回地址。
2. 初始化新任务栈
1 | void context_init(Context *ctx, void *stack, unsigned int stack_size, void (*entry)(void)) |
初始化后,B 的栈看起来像这样:
1 | 高地址 |
当从 A 切换到 B 时:
1 | mov esp, [ctx_b.esp] |
此时 ret 会取出 entry 作为目标地址,于是 B 第一次开始执行。
也就是说:
1 | entry 被伪装成了 B 栈上的返回地址。 |
九、和 408 操作系统进程切换的联系
1. PCB 中保存的现场
408 操作系统中,进程控制块 PCB 是进程存在的唯一标志。
进程切换时,PCB 中需要保存 CPU 现场,常见内容包括:
| 内容 | 作用 |
|---|---|
PC/EIP |
下一条要执行的指令地址 |
SP/ESP |
当前进程的栈顶 |
BP/EBP |
当前进程的栈帧基准 |
| 通用寄存器 | 保存计算中间结果 |
PSW/EFLAGS |
保存程序状态字、条件码、中断允许位等 |
| 页表基址 | 保存地址空间相关信息 |
| 进程状态 | 就绪、运行、阻塞等 |
| 调度信息 | 优先级、时间片、队列指针等 |
2. 进程切换过程
1 | 进程 A 正在运行 |
更具体一点:
1 | A 的 PCB |
3. ret 与 iret 的区别
普通函数或协程切换中,常用 ret:
1 | ret 从当前栈顶弹出返回地址到 eip |
中断返回时,常用 iret:
1 | iret 会恢复 eip、cs、eflags 等信息 |
如果涉及用户态和内核态切换,iret 还可能恢复栈段和栈指针。
408 里可以这样理解:
1 | ret:函数级返回 |
十、易混点总结
1. 栈帧与栈不是一回事
1 | 栈:整块运行时内存区域。 |
一个栈中可以有多个栈帧:
1 | 高地址 |
2. esp 和 ebp 的区别
1 | esp:始终指向当前栈顶,会频繁变化。 |
所以访问参数和局部变量时常用:
1 | [ebp + 偏移量] |
3. call 与 ret 是一对
1 | call:压入返回地址,然后跳转。 |
示意:
1 | call f |
4. 上下文切换不是只保存栈
完整上下文至少包括:
1 | 程序执行位置:PC/EIP |
5. 栈帧交换的核心记忆
1 | 不是交换栈内容,而是交换栈指针。 |
进一步说:
1 | esp 决定当前栈。 |
所以只要保存并恢复这些关键寄存器,程序就能从另一个任务之前暂停的位置继续运行。
十一、 考试记忆版
1. 函数调用过程
1 | 参数入栈 |
2. 上下文切换过程
1 | 中断 / 系统调用 / 主动让出 CPU |
3. 汇编核心
1 | ; 保存旧任务的栈顶 |
一句话总结:
1 | 栈帧交换的本质是保存旧任务的 CPU 现场,恢复新任务的 CPU 现场; |
十二、细节
这里的esp减24 (10进制)王道PPT时__16__进制:是用16进制的地址 把__0x30__转成了10进制,再去减了24之后再转回16进制。
但是里昂讲课的时候直接用的是__10__进制进行运算在考试时务必注意




