栈帧交换汇编实现

栈帧交换汇编实现

基于 408 考研中的计算机组成原理与操作系统知识,理解函数调用栈帧、call/ret 指令、esp/ebp 的变化,以及上下文切换中所谓“栈帧交换”的本质。

[toc]

一、需要先掌握的基础

1. 栈的基本特点

栈是一种 后进先出 的线性结构,函数调用、参数传递、局部变量保存、返回地址保存都经常依赖栈完成。

32 位 x86 中,栈通常具有如下特点:

  • 栈从 高地址向低地址增长
  • push 表示压栈,会让 esp 减小。
  • pop 表示出栈,会让 esp 增大。
  • 栈顶由 esp 维护。
  • 当前函数栈帧的基准通常由 ebp 维护。

示意图:

1
2
3
4
5
6
7
8
9
10
11
12
13
高地址
+----------------+
| |
| 已使用区域 |
| |
+----------------+
| 当前栈顶 | <- esp
+----------------+
| |
| 可继续增长 |
| |
+----------------+
低地址

因为栈向低地址增长,所以:

1
push eax

等价理解为:

1
2
esp = esp - 4
[esp] = eax

而:

1
pop eax

等价理解为:

1
2
eax = [esp]
esp = esp + 4

2. 常用寄存器

寄存器 作用
eax 累加寄存器,常用于保存函数返回值
ebx 通用寄存器,常用于保存数据地址
ecx 计数寄存器,循环、字符串操作中常见
edx 通用寄存器,乘除法、I/O 中常见
esi 源变址寄存器
edi 目的变址寄存器
esp 栈顶指针,指向当前栈顶
ebp 栈底/帧指针,用来定位当前函数栈帧
eip 指令指针,保存下一条要执行的指令地址(课本中的PC)
eflags 标志寄存器,保存条件码、状态位等(PSW)

408 中最关键的是这三个:

1
2
3
eip:下一条指令在哪里执行
esp:当前栈顶在哪里
ebp:当前函数栈帧从哪里定位

二、普通函数调用的栈帧变.0化

1. 示例程序

1
2
3
4
5
6
7
8
9
10
11
int Add(int a, int b)
{
int c = a + b;
return c;
}

int main()
{
int ret = Add(10, 20);
return 0;
}

为了符合 408 学习习惯,下面按 32 位 x86 和常见 cdecl 调用约定理解。

调用函数时,参数通常 从右向左压栈

1
2
3
4
5
push 20
push 10
call Add
add esp, 8
mov [ebp-4], eax

这里:

  • push 20 压入第二个参数 b
  • push 10 压入第一个参数 a
  • call Add 调用函数。
  • add esp, 8 调用结束后清理两个参数。
  • eax 中保存函数返回值。

2. 执行 push 20

1
2
3
4
5
6
7
高地址
+----------------+
| |
+----------------+
| 20 | <- esp
+----------------+
低地址

3. 执行 push 10

1
2
3
4
5
6
7
高地址
+----------------+
| 20 |
+----------------+
| 10 | <- esp
+----------------+
低地址

4. 执行 call Add

call 指令做两件事:

  1. 把返回地址压栈(保存旧ip)。
  2. 修改 eip,跳转到被调用函数入口。

返回地址就是 call Add 后面那条指令的地址,也就是:

1
add esp, 8

执行 call Add 后:

1
2
3
4
5
6
7
8
9
高地址
+----------------+
| 20 | 参数 b
+----------------+
| 10 | 参数 a
+----------------+
| return_addr | <- esp
+----------------+
低地址

此时 eip 指向 Add 函数入口。

三、Add 函数栈帧的创建

1. 函数序言

进入 Add 后,常见函数序言如下(交换栈帧):

1
2
3
push ebp
mov ebp, esp
sub esp, 4

含义:

  • push ebp:保存调用者的旧栈帧基准。
  • mov ebp, esp:建立当前函数的新栈帧基准。
  • sub esp, 4:为局部变量分配空间。

2. 执行 push ebp

main 的旧 ebp 压入栈中:

1
2
3
4
5
6
7
8
9
10
11
高地址
+----------------+
| 20 | 参数 b
+----------------+
| 10 | 参数 a
+----------------+
| return_addr | 返回地址
+----------------+
| old ebp | <- esp
+----------------+
低地址

3. 执行 mov ebp, esp

建立 Add 函数自己的栈帧基准:

1
2
3
4
5
6
7
8
9
10
11
高地址
+----------------+
| 20 |
+----------------+
| 10 |
+----------------+
| return_addr |
+----------------+
| old ebp | <- ebp, esp
+----------------+
低地址

4. 执行 sub esp, 4

为局部变量 c 分配 4 字节空间:

1
2
3
4
5
6
7
8
9
10
11
12
13
高地址
+----------------+
| 20 | <- [ebp+12] 参数 b
+----------------+
| 10 | <- [ebp+8] 参数 a
+----------------+
| return_addr | <- [ebp+4] 返回地址
+----------------+
| old ebp | <- [ebp] 调用者 ebp
+----------------+
| c | <- [ebp-4], esp
+----------------+
低地址

这就是 Add 函数的栈帧。

5. 为什么用 ebp 访问变量

在函数执行过程中,esp 可能因为 push/pop 不断变化。如果直接用 esp 定位参数和局部变量,偏移量会变得不稳定。

ebp 在当前函数内一般保持不变,所以适合作为栈帧基准:

1
2
3
mov eax, [ebp+8]     ; eax = a
add eax, [ebp+12] ; eax = a + b
mov [ebp-4], eax ; c = eax

对应关系:

1
2
3
4
5
[ebp+12]  参数 b
[ebp+8] 参数 a
[ebp+4] 返回地址
[ebp] 调用者的 ebp
[ebp-4] 局部变量 c

四、函数返回时的栈帧销毁

1. 函数尾声

函数返回前常见汇编如下:

1
2
3
4
mov eax, [ebp-4]
mov esp, ebp
pop ebp
ret

2. 返回值放入 eax

1
mov eax, [ebp-4]

把局部变量 c 的值放入 eax

408 常考点:

1
函数返回值通常通过寄存器返回,32 位 x86 中常用 eax。

3. 执行 mov esp, ebp

该指令直接丢弃当前函数的局部变量区域:

1
2
3
4
5
6
7
8
9
10
11
高地址
+----------------+
| 20 |
+----------------+
| 10 |
+----------------+
| return_addr |
+----------------+
| old ebp | <- esp, ebp
+----------------+
低地址

4. 执行 pop ebp

恢复调用者 mainebp

1
2
3
4
5
6
7
8
9
高地址
+----------------+
| 20 |
+----------------+
| 10 |
+----------------+
| return_addr | <- esp
+----------------+
低地址

5. 执行 ret

ret 的核心效果可以理解为:

1
pop eip

也就是:

1
2
eip = [esp]
esp = esp + 4

执行后:

1
2
3
4
5
6
7
高地址
+----------------+
| 20 |
+----------------+
| 10 | <- esp
+----------------+
低地址

CPU 回到 maincall Add 的下一条指令。

然后 main 执行:

1
add esp, 8

清理参数:

1
2
3
4
5
高地址
+----------------+
| | <- esp
+----------------+
低地址

五、从函数栈帧到“栈帧交换”

1. “栈帧交换”不是复制栈

所谓“栈帧交换”,更准确的说法是 上下文切换

它并不是把任务 A 的栈内容和任务 B 的栈内容互相复制,而是:

1
2
3
保存当前任务的 esp、ebp、必要寄存器
加载另一个任务保存的 esp、ebp、必要寄存器
通过 ret 或 iret 回到另一个任务之前暂停的位置

关键理解:

1
2
3
esp 一变,CPU 当前使用的栈就变了。
ebp 一变,CPU 当前定位的函数栈帧就变了。
eip 一变,CPU 下一条执行的指令就变了。

所以上下文切换的核心是:

1
保存旧现场,恢复新现场。

2. 两个任务的栈

假设任务 A 正在运行,任务 B 之前被暂停。

任务 A 的栈:

1
2
3
4
5
6
7
8
9
10
11
高地址
+----------------+
| A 的参数 |
+----------------+
| A 的返回地址 |
+----------------+
| A 的 old ebp | <- ebp
+----------------+
| A 的局部变量 | <- esp
+----------------+
低地址

任务 B 的栈:

1
2
3
4
5
6
7
8
9
10
11
高地址
+----------------+
| B 的参数 |
+----------------+
| B 的返回地址 |
+----------------+
| B 的 old ebp | <- saved_ebp
+----------------+
| B 的局部变量 | <- saved_esp
+----------------+
低地址

切换前,CPU 的 esp/ebp 指向 A:

1
2
3
4
5
CPU
|
| esp, ebp
v
任务 A 的栈

切换时,保存 A 的现场:

1
2
3
4
5
6
7
8
ctx_a
+----------------+
| esp = A_esp |
| ebp = A_ebp |
| ebx |
| esi |
| edi |
+----------------+

再恢复 B 的现场:

1
2
3
4
5
6
7
8
ctx_b
+----------------+
| esp = B_esp |
| ebp = B_ebp |
| ebx |
| esi |
| edi |
+----------------+

切换后,CPU 的 esp/ebp 指向 B:

1
2
3
4
5
CPU
|
| esp, ebp
v
任务 B 的栈

六、简化版汇编实现

1. 上下文结构

1
2
3
4
5
6
7
typedef struct Context {
unsigned int esp;
unsigned int ebp;
unsigned int ebx;
unsigned int esi;
unsigned int edi;
} Context;

这里只保存一部分寄存器,是为了说明原理。

真正的操作系统进程切换还要保存:

  • eip
  • eflags
  • 通用寄存器
  • 段寄存器
  • 页表基址
  • 内核栈指针
  • 进程状态和调度信息

2. 汇编代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
; void context_switch(Context *old, Context *new)
; 32 位 x86 cdecl 调用约定:
; [esp + 4] = old
; [esp + 8] = new

global context_switch

context_switch:
; 取 old 参数
mov eax, [esp + 4]

; 保存当前任务现场
mov [eax + 0], esp
mov [eax + 4], ebp
mov [eax + 8], ebx
mov [eax + 12], esi
mov [eax + 16], edi

; 取 new 参数
mov eax, [esp + 8]

; 恢复目标任务现场
mov esp, [eax + 0]
mov ebp, [eax + 4]
mov ebx, [eax + 8]
mov esi, [eax + 12]
mov edi, [eax + 16]

; 从新栈中弹出返回地址
ret

核心只有三步:

1
2
3
mov [old_context], esp
mov esp, [new_context]
ret

其中最关键的是:

1
mov esp, [eax + 0]

这一句执行后,当前栈已经换成了另一个任务的栈。

后面的 ret 不再从旧任务的栈顶取返回地址,而是从新任务的栈顶取返回地址。

七、栈帧交换的完整过程

1. A 调用切换函数

假设 A 正在执行:

1
context_switch(&ctx_a, &ctx_b);

进入 context_switch 前,call 指令已经压入返回地址。

此时 A 的栈为:

1
2
3
4
5
6
7
8
9
高地址
+----------------+
| ctx_b | <- [esp+8]
+----------------+
| ctx_a | <- [esp+4]
+----------------+
| return_to_A | <- [esp]
+----------------+
低地址

其中:

1
return_to_A = context_switch 调用结束后,A 应该继续执行的位置

2. 保存 A 的上下文

执行:

1
2
3
mov eax, [esp + 4]
mov [eax + 0], esp
mov [eax + 4], ebp

保存结果:

1
2
3
4
5
6
7
8
ctx_a
+----------------+
| esp = return_to_A 所在位置 |
| ebp = A 当前 ebp |
| ebx |
| esi |
| edi |
+----------------+

注意,此时 ctx_a.esp 指向的是 A 栈中的返回地址。

也就是说,A 的“暂停点”被保存在了它自己的栈上。

3. 恢复 B 的上下文

执行:

1
2
3
mov eax, [esp + 8]
mov esp, [eax + 0]
mov ebp, [eax + 4]

此时 CPU 当前栈已经切换为 B 的栈:

1
2
3
4
5
6
7
高地址
+----------------+
| ... |
+----------------+
| return_to_B | <- esp
+----------------+
低地址

4. 执行 ret

1
ret

等价于:

1
2
eip = [esp]
esp = esp + 4

因为此时 esp 已经是 B 的 esp,所以 ret 取出的不是 return_to_A,而是 return_to_B

于是 CPU 跳回 B 之前暂停的位置继续执行。

完整流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
A 正在运行
|
| call context_switch(&ctx_a, &ctx_b)
v
A 栈压入 return_to_A
|
| 保存 A 的 esp/ebp/寄存器
v
加载 B 的 esp/ebp/寄存器
|
| ret
v
跳到 return_to_B
|
v
B 继续运行

反向切换同理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
B 正在运行
|
| call context_switch(&ctx_b, &ctx_a)
v
B 栈压入 return_to_B
|
| 保存 B 的 esp/ebp/寄存器
v
加载 A 的 esp/ebp/寄存器
|
| ret
v
跳到 return_to_A
|
v
A 继续运行

八、第一次运行的任务如何进入入口函数

1. 问题

如果任务 B 从来没有运行过,那么它的栈里一开始并没有 return_to_B

ret 应该跳到哪里?

答案是:初始化任务 B 的栈时,手动伪造一个返回地址。

2. 初始化新任务栈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void context_init(Context *ctx, void *stack, unsigned int stack_size, void (*entry)(void))
{
unsigned int *sp;

sp = (unsigned int *)((char *)stack + stack_size);

/* 栈向低地址增长,手动压入入口函数地址 */
*(--sp) = (unsigned int)entry;

ctx->esp = (unsigned int)sp;
ctx->ebp = 0;
ctx->ebx = 0;
ctx->esi = 0;
ctx->edi = 0;
}

初始化后,B 的栈看起来像这样:

1
2
3
4
5
6
7
8
9
高地址
+----------------+
| |
| 空闲栈空间 |
| |
+----------------+
| entry | <- ctx_b.esp
+----------------+
低地址

当从 A 切换到 B 时:

1
2
mov esp, [ctx_b.esp]
ret

此时 ret 会取出 entry 作为目标地址,于是 B 第一次开始执行。

也就是说:

1
entry 被伪装成了 B 栈上的返回地址。

九、和 408 操作系统进程切换的联系

1. PCB 中保存的现场

408 操作系统中,进程控制块 PCB 是进程存在的唯一标志。

进程切换时,PCB 中需要保存 CPU 现场,常见内容包括:

内容 作用
PC/EIP 下一条要执行的指令地址
SP/ESP 当前进程的栈顶
BP/EBP 当前进程的栈帧基准
通用寄存器 保存计算中间结果
PSW/EFLAGS 保存程序状态字、条件码、中断允许位等
页表基址 保存地址空间相关信息
进程状态 就绪、运行、阻塞等
调度信息 优先级、时间片、队列指针等

2. 进程切换过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
进程 A 正在运行
|
v
发生中断 / 系统调用 / 阻塞
|
v
保存 A 的 CPU 现场到 A 的 PCB
|
v
调度程序选择进程 B
|
v
从 B 的 PCB 恢复 CPU 现场
|
v
进程 B 继续运行

更具体一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
A 的 PCB
+----------------+
| PC / EIP |
| SP / ESP |
| BP / EBP |
| 通用寄存器 |
| PSW / EFLAGS |
| 页表信息 |
+----------------+

调度器
|
v

B 的 PCB
+----------------+
| PC / EIP |
| SP / ESP |
| BP / EBP |
| 通用寄存器 |
| PSW / EFLAGS |
| 页表信息 |
+----------------+

3. retiret 的区别

普通函数或协程切换中,常用 ret

1
ret 从当前栈顶弹出返回地址到 eip

中断返回时,常用 iret

1
iret 会恢复 eip、cs、eflags 等信息

如果涉及用户态和内核态切换,iret 还可能恢复栈段和栈指针。

408 里可以这样理解:

1
2
ret:函数级返回
iret:中断级返回,恢复内容更多

十、易混点总结

1. 栈帧与栈不是一回事

1
2
栈:整块运行时内存区域。
栈帧:某一次函数调用在栈上占用的一段空间。

一个栈中可以有多个栈帧:

1
2
3
4
5
6
7
8
9
高地址
+----------------+
| main 栈帧 |
+----------------+
| f1 栈帧 |
+----------------+
| f2 栈帧 | <- 当前函数
+----------------+
低地址

2. espebp 的区别

1
2
esp:始终指向当前栈顶,会频繁变化。
ebp:当前函数栈帧基准,函数内部通常保持稳定。

所以访问参数和局部变量时常用:

1
2
[ebp + 偏移量]
[ebp - 偏移量]

3. callret 是一对

1
2
call:压入返回地址,然后跳转。
ret:弹出返回地址,然后跳回。

示意:

1
2
3
4
5
6
7
8
9
10
11
12
13
call f
|
v
压入 return_addr
|
v
eip = f

ret
|
v
eip = [esp]
esp = esp + 4

4. 上下文切换不是只保存栈

完整上下文至少包括:

1
2
3
4
5
6
程序执行位置:PC/EIP
栈位置:SP/ESP
栈帧位置:BP/EBP
计算现场:通用寄存器
状态信息:PSW/EFLAGS
地址空间:页表相关寄存器

5. 栈帧交换的核心记忆

1
不是交换栈内容,而是交换栈指针。

进一步说:

1
2
3
esp 决定当前栈。
ebp 决定当前栈帧。
eip 决定当前执行位置。

所以只要保存并恢复这些关键寄存器,程序就能从另一个任务之前暂停的位置继续运行。

十一、 考试记忆版

1. 函数调用过程

1
2
3
4
5
6
7
8
9
10
11
参数入栈
call 压入返回地址
push ebp 保存旧栈帧
mov ebp, esp 建立新栈帧
sub esp, n 分配局部变量
函数体执行
返回值放入 eax
mov esp, ebp 回收局部变量
pop ebp 恢复旧栈帧
ret 返回调用点
调用者清理参数

2. 上下文切换过程

1
2
3
4
5
6
7
中断 / 系统调用 / 主动让出 CPU
保存当前进程现场到 PCB
修改当前进程状态
调度程序选择下一个进程
恢复新进程现场
切换地址空间
返回新进程继续执行

3. 汇编核心

1
2
3
4
5
6
7
8
; 保存旧任务的栈顶
mov [old_context + esp_offset], esp

; 恢复新任务的栈顶
mov esp, [new_context + esp_offset]

; 从新任务的栈中恢复执行位置
ret

一句话总结:

1
2
栈帧交换的本质是保存旧任务的 CPU 现场,恢复新任务的 CPU 现场;
其中 esp/ebp 决定“当前使用哪一个栈帧”,ret/iret 决定“从哪里继续执行”。

十二、细节

这里的esp减24 (10进制)王道PPT时__16__进制:是用16进制的地址 把__0x30__转成了10进制,再去减了24之后再转回16进制。

但是里昂讲课的时候直接用的是__10__进制进行运算在考试时务必注意

)