两个问题引入
1 | int sum(int a, int b) |
两个问题:
main调用sum,sum执行完怎么知道回到哪个函数?sum执行完回到main,怎么知道从哪一行指令继续运行?
如果 sum 返回后 main 又从头开始执行,那就成一个死循环了。但实际并没有,说明 **CPU 一定在哪里记下了”该从哪继续”**。
前置知识:栈、寄存器、压栈/出栈、汇编
函数运行时依赖的内存区域叫做栈(stack)。它有几个特点:
- 从高地址向低地址生长(”向下生长”)
- 由两个寄存器描述边界:
| 寄存器 | 含义 |
|---|---|
| esp(Extended Stack Pointer) | 栈顶指针,始终指向当前函数栈帧的栈顶(最低地址)。随 push/pop 动态变化 |
| ebp(Extended Base Pointer) | 栈底指针,指向当前函数栈帧的栈底(最高地址)。函数入口处固定,之后作为访问变量的基准 |
flowchart TB
A["高地址"]
B["ebp → 当前函数栈底(锚点,访问变量的基准)"]
C["↓ 栈向低地址方向生长"]
D["esp → 当前函数栈顶(随 push / pop 动态变化)"]
E["低地址"]
A --> B --> C --> D --> E
classDef p fill:#1d4ed8,stroke:#0f172a,stroke-width:2px,color:#ffffff
class B,D p
栈底是高地址,栈顶是低地址。
💡 为什么栈要向下生长?这跟进程的虚拟地址空间布局有关:栈区从高地址向低地址扩展,堆区从低地址向高地址扩展,两者相向生长,中间的空白区域可以灵活共用,避免过早耗尽。
x86 寄存器
前面反提到的 esp、ebp 都是寄存器——CPU 内部的存储单元,离运算器最近,读写速度比内存快一个数量级。
32 位 x86 一共有 8 个通用寄存器,名字都以 e 开头:
| 寄存器 | 传统用途 | 名字由来 |
|---|---|---|
| eax | 累加器,函数返回值默认放这里 | Accumulator |
| ebx | 基址寄存器,早期用于寻址 | Base |
| ecx | 计数寄存器,rep 前缀指令的重复次数就存在这里 |
Counter |
| edx | 数据寄存器,与 eax 配对传递 8 字节返回值 |
Data |
| esi | 源变址寄存器 | Source Index |
| edi | 目的变址寄存器 | Destination Index |
| esp | 栈顶指针 | Stack Pointer |
| ebp | 栈底指针 | Base Pointer |
三点说明:
1. 名字只是”传统”,真正专用的只有 esp 和 ebp
eax 叫”累加器”,是 8086 时代留下的名字——那时候 ax 确实专用于累加运算,指令编码里给它留了最短的形式。到了现代,除了 esp、ebp 被硬件约定为栈顶/栈底指针,其余 6 个基本可以随便用。只是编译器习惯把返回值放 eax,所以它在反汇编里出镜率最高。
2. 为什么返回值放寄存器,不放内存
寄存器在 CPU 内部,访问不必走总线;eax 是 32 位,也就是4 字节,刚好装下一个 int。”能塞进寄存器就别动内存”是编译器的一条基本原则。
3. e 是 Extended(扩展)的意思
| 位数 | 写法 | 说明 |
|---|---|---|
| 16 位 | ax |
8086 时代的原名 |
| 32 位 | eax |
加 e,扩展到 32 位 |
| 64 位 | rax |
换 r 前缀,扩展到 64 位 |
💡 现在写 64 位程序,反汇编里看到的是
rax、rbp这一套。 32 位 x86,反汇编窗口里就是eax、ebp。原理完全相同,只是位数不同。
⚠️ 还有一个特殊成员:**
eip(Instruction Pointer),指令指针,也就是常说的 PC(Program Counter)。它存的不是数据,而是下一条要执行的指令的地址**。call和ret之所以能跳转,本质就是往它里面写值——第六步会详细讲。
压栈 / 出栈:push 和 pop 到底做了什么
栈的两个基本操作是 push(压栈) 和 pop(出栈),两者都只能操作栈顶那一格,不能从中间插队。
1 | push eax ; 压栈:把 eax 的值放到栈顶 |
push 拆开来是两步:
- 先把
esp减 4(栈顶向低地址移动一格) - 再把值写进新的栈顶位置
pop 是反过来的两步(所以顺序很关键):
- 先把栈顶的值读出来,放进目标寄存器
- 再把
esp加 4(栈顶退回高地址方向)
push 之前,esp 停在 0x0FFC:
flowchart TB
A["高地址"]
B["0x1000 旧数据"]
C["0x0FFC esp → 栈顶"]
D["低地址"]
A --> B --> C --> D
classDef p fill:#1d4ed8,stroke:#0f172a,stroke-width:2px,color:#ffffff
class C p
push eax 之后(eax = 30),esp 减了 4,30 落在原来 esp 指的那一格:
flowchart TB
A["高地址"]
B["0x1000 旧数据"]
C["0x0FFC 30 ← 新压进来的值"]
D["0x0FF8 esp → 栈顶(减了 4)"]
E["低地址"]
A --> B --> C --> D --> E
classDef p fill:#1d4ed8,stroke:#0f172a,stroke-width:2px,color:#ffffff
classDef v fill:#dbeafe,stroke:#2c5f8a,color:#1e3a5f
class C v
class D p
对比两张图能看出两件事:
esp自己会动 —— 这正是判断一个操作算不算”压栈”的依据。压栈只认push。而mov只写内存、不动esp,所以不是压栈- “栈顶”在下、”栈底”在上 —— 越后压进去的数据地址越低
💡 为什么
push要先减esp再写值?因为esp指向的才是当前可用的栈顶。先把指针让出来,再把数据放进去,顺序反了就会覆盖掉上一格的有效数据。
汇编语法基本概念
后面会反复出现 mov dword ptr [ebp-4], 0Ah 这样的指令,看着有点吓人,其实拆成三块就清楚了:
1 | mov dword ptr [ebp-4] , 0Ah |
① 操作数大小说明符:dword ptr / word ptr / byte ptr
汇编里的内存没有类型(不像 C 语言有 int、char),所以你必须告诉 CPU:这次要操作几个字节?
| 写法 | 大小 | 对应 C 类型 |
|---|---|---|
byte ptr |
1 字节 | char |
word ptr |
2 字节 | short |
dword ptr |
4 字节 | int、指针 |
dword = double word,x86 里 word 是 2 字节,所以 dword 就是 4 字节。ptr 表示被描述的是一块内存(指针)。
⚠️ 反过来,
mov eax, 10这种寄存器作为目标时不用写大小——eax本身就是 4 字节,大小已经确定了。
② 方括号 [ ]:这是”取地址对应的内容”,不是数组
[ ] 在这里跟数组没关系,它的含义是 取这个地址上的内存。可以直接对应到 C 语言的解引用:
| 汇编 | 含义 | 对应 C 语言 |
|---|---|---|
ebp |
寄存器的值(一个地址) | ebp |
[ebp] |
这个地址上存的东西 | *ebp(解引用) |
[ebp-4] 就是”ebp 的值减 4,再取这个地址上的内容“——和 C 里的 *(ebp - 4) 是一个意思。
③ 完整的指令
1 | mov dword ptr [ebp-4], 0Ah |
拆开来读:
dword ptr—— 这次操作 4 个字节(一个int)[ebp-4]—— 目的地是ebp - 4这个地址上的内存0Ah—— 要写进去的值。末尾的h表示十六进制,0Ah=10,前面的0是为了和标识符区分- 合起来 —— 把 10 这个 4 字节整数,写进
ebp - 4这个地址
前面代码中的int a = 10就是这么翻译过去的
⚠️ 汇编有两大语法流派:Intel 语法(
mov 目标, 源)和 AT&T 语法(mov 源, 目标,寄存器要带%)。这篇博客用的是 Intel 语法——VS 反汇编窗口也是这种。看别的资料时需要先确认是哪一派,不然操作数顺序会读反。
第一步:main 函数的栈帧布局
先看调用发生之前 main 自己的地盘。
main 里定义了三个局部变量:
1 | int a = 10; |
它们生成的汇编指令是:
1 | int a = 10; → mov dword ptr [ebp-4], 0Ah |
注意三点:
1. 局部变量不产生符号,通过 ebp 偏移访问
指令里没有 a、b 这样的符号名,全是 [ebp-4]、[ebp-8] 这种写法。因为局部变量在符号表里不产生符号,有些编译器会在反汇编显示时标上变量名,方便阅读,比如前一篇博客里的写法,但是真正的指令是 ebp 减偏移。
访问局部变量为什么用 ebp 减?因为栈底是高地址,往上走是低地址,所以偏移量为负。
2. 声明顺序决定偏移大小
先声明的 a 离 ebp 最近(偏移 -4),后声明的 b 在更下面(-8),ret 再往下(-0Ch)。每个 int 占 4 字节,三个变量共 12 字节。
3. ebp 固定、esp 变化
ebp 在函数入口处设好后就不再动,所有局部变量都以它为基准;esp 则随 push/pop 上上下下。
flowchart TB
A["高地址"]
B["ebp → 0x0018FF44 main 栈帧栈底"]
C["a = 10 [ebp-4]"]
D["b = 20 [ebp-8]"]
E["ret = ? [ebp-0Ch]"]
F["esp → 栈顶"]
G["低地址"]
A --> B --> C --> D --> E --> F --> G
classDef p fill:#1d4ed8,stroke:#0f172a,stroke-width:2px,color:#ffffff
classDef v fill:#dbeafe,stroke:#2c5f8a,color:#1e3a5f
class B,F p
class C,D,E v
第二步:参数压栈
现在执行到第三行 int ret = sum(a, b);——函数调用。
函数调用首先要做的事情是压参数。两个细节:
从右向左压。先压 b,再压 a。
用 push 指令,不是 mov。 mov 只是把值放到 ebp 偏移的某块内存里,不会移动栈顶指针;而 push 会自动调整 esp,这才是”压栈”。
1 | mov eax, dword ptr [ebp-8] ; 从 b 的内存取值 20,放到eax寄存器里 |
关键点:随着 push,栈上新增的这两块 4 字节空间,就是 sum 函数形参变量 a、b 的内存。
也就是说——形参变量的内存是在调用方函数里就开辟好的,不是 sum 自己分配的。这一点很重要,后面 sum 访问参数时直接往上找就行。
flowchart TB
A["高地址"]
B["ebp → main 栈底"]
C["a = 10 [ebp-4]"]
D["b = 20 [ebp-8]"]
E["ret = ? [ebp-0Ch]"]
F["参数 b = 20 ← push eax 压入"]
G["参数 a = 10 ← push eax 压入"]
H["esp → 栈顶(低地址)"]
A --> B --> C --> D --> E --> F --> G --> H
classDef p fill:#1d4ed8,stroke:#0f172a,stroke-width:2px,color:#ffffff
classDef v fill:#dbeafe,stroke:#2c5f8a,color:#1e3a5f
classDef arg fill:#fef3c7,stroke:#b45309,color:#92400e
class B,H p
class C,D,E v
class F,G arg
第三步:call 指令
参数压完,执行 call sum。call 后面紧跟的是 add esp, 8——函数返回后,调用方用它把刚才压进去的两个参数清掉,关于这个下一条指令的具体解释,第七步会讲。
1 | call sum ; ← 执行到这里 |
call 指令做两件事:
- 把下一条指令的地址入栈——也就是
add esp, 8这条指令的地址 - 跳转到
sum函数的入口
“下一条指令的地址”不用额外去算:CPU 执行每条指令时,EIP 寄存器始终指向当前指令的下一条。call 执行的那一刻,EIP 里装的就是 add esp, 8 的地址,直接压栈就行。
第一件事正是问题 2 的答案:sum 执行完怎么知道从哪继续?因为 call 指令在进去之前,已经把”回来的地址”压在栈上了。
flowchart TB
A["高地址"]
B["ebp → main 栈底"]
C["a = 10 [ebp-4]"]
D["b = 20 [ebp-8]"]
E["ret = ? [ebp-0Ch]"]
F["参数 b = 20"]
G["参数 a = 10"]
H["返回地址 ← call 自动压入"]
I["esp → 栈顶(低地址)"]
A --> B --> C --> D --> E --> F --> G --> H --> I
classDef p fill:#1d4ed8,stroke:#0f172a,stroke-width:2px,color:#ffffff
classDef v fill:#dbeafe,stroke:#2c5f8a,color:#1e3a5f
classDef arg fill:#fef3c7,stroke:#b45309,color:#92400e
classDef ret fill:#ffe4e6,stroke:#9f1239,color:#9f1239
class B,I p
class C,D,E v
class F,G arg
class H ret
第四步:sum 函数栈帧的建立
注意了——**从 sum 的左括号 { 进来,并不是直接就执行 int temp = 0;**。
左括号和第一行代码之间,是有指令生成的。这些指令负责开辟和初始化 sum 自己的栈帧空间。任何函数的左括号进来,都是同样的操作序列:
1 | push ebp ; 1. 把调用方函数(main)的栈底地址入栈 |
逐条分析:
push ebp —— 此时 ebp 还指向 main 的栈底。把它压栈保存起来,相当于给”回家的路”做个标记。这正是问题 1 的答案:sum 执行完怎么知道回到哪个函数?在进来时它就把 main 的栈底地址存下来了。
mov ebp, esp —— 把esp赋给ebp,让ebp指向main函数的栈顶,也就是当前sum函数的栈底
sub esp, 4Ch —— add 是加、sub 是减。 esp 减 4Ch,向低地址移动,给 sum 划出 0x4C(76)字节的空间,也就是开辟栈帧空间。
栈初始化(VS Debug 版本特有) —— 在 VS 系列的编译器下,开辟完空间后还会有一条类似
rep stosd的循环指令,把新栈帧**全部填充为0xCC**。
0xCC在编码里对应汉字「烫」——这就是为什么在 VS Debug 模式下,打印一个未初始化的局部变量会看到「烫烫烫」。它的用处是帮你快速发现”用了未初始化变量”的 bug。⚠ 但这是特定编译器的行为,不是 C/C++ 标准规定的。gcc 无论 Debug 还是 Release 都不做这个初始化,新栈空间里就是上一个函数留下的脏数据。
flowchart TB
A["高地址"]
B["main 栈帧"]
C["参数 b [ebp+0Ch]"]
D["参数 a [ebp+8]"]
E["返回地址 [ebp+4]"]
F["保存的 main 的栈底地址(push ebp) ← 新 ebp 指向这里(mov ebp, esp)"]
G["... sub esp,4Ch 开辟的空间"]
H["esp → 栈顶(低地址)"]
A --> B --> C --> D --> E --> F --> G --> H
classDef p fill:#1d4ed8,stroke:#0f172a,stroke-width:2px,color:#ffffff
classDef arg fill:#fef3c7,stroke:#b45309,color:#92400e
classDef ret fill:#ffe4e6,stroke:#9f1239,color:#9f1239
classDef loc fill:#d1fae5,stroke:#047857,color:#065f46
class F,H p
class C,D arg
class E ret
class G loc
第五步:函数体内如何访问参数和局部变量
栈帧建好,现在 sum 可以干活了。函数体三行:
1 | int temp = 0; |
int temp = 0;
1 | mov dword ptr [ebp-4], 0 |
此时 ebp 已经指向 sum 的栈底,[ebp-4] 就是 sum 的第一个局部变量 temp 的空间。
temp = a + b;
1 | mov eax, dword ptr [ebp+0Ch] ; 找到并取出b的值,放进 eax |
为什么 a 是 [ebp+8]、b 是 [ebp+0Ch]?从 ebp 往上数:
| 偏移 | 内容 |
|---|---|
[ebp] |
保存的 main 栈底地址 |
[ebp+4] |
返回时下一条指令的地址 |
[ebp+8] |
形参 a |
[ebp+0Ch] |
形参 b |
往上依次经过 ebp 的地址、返回地址、a 的地址,就取到了 b。可见ebp是一个“锚点”,向上加,能访问参数,向下减,访问局部变量
flowchart TB
A["高地址"]
B["参数 b = 20 [ebp+0Ch]"]
C["参数 a = 10 [ebp+8]"]
D["返回地址 [ebp+4]"]
E["保存的 main 的 ebp [ebp] ← 锚点:正偏移往上取参数"]
F["temp = 30 [ebp-4] ← 负偏移往下放局部变量"]
G["(sub esp,4Ch 预留的其余空间)"]
H["esp → 栈顶(低地址)"]
A --> B --> C --> D --> E --> F --> G --> H
classDef p fill:#1d4ed8,stroke:#0f172a,stroke-width:2px,color:#ffffff
classDef arg fill:#fef3c7,stroke:#b45309,color:#92400e
classDef ret fill:#ffe4e6,stroke:#9f1239,color:#9f1239
classDef loc fill:#d1fae5,stroke:#047857,color:#065f46
class E,H p
class B,C arg
class D ret
class F,G loc
return temp;
1 | mov eax, dword ptr [ebp-4] |
temp 是函数的局部变量,出不去。返回值是按值返回——把 temp 的值放进 eax 寄存器带走,而不是把 temp 这块内存带出去。
第六步:右括号——栈帧回退与返回
sum 执行完,到了右括号 }。右括号同样是由指令生成的:
1 | mov esp, ebp ; 1. 回退栈帧空间 |
mov esp, ebp —— 把 ebp 的值赋给 esp,esp 瞬间从栈顶”跳”回栈底,一步就释放了整个 sum 的栈帧空间。
注意:这只是把空间还给了系统,并没有清理栈上的数据。
flowchart TB
A["高地址"]
B["参数 b = 20 [ebp+0Ch]"]
C["参数 a = 10 [ebp+8]"]
D["返回地址 [ebp+4]"]
E["保存的 main 的 ebp [ebp] ← esp 跳到这里"]
F["temp 等局部变量区(已还给系统) [ebp-4] 起往下"]
G["低地址"]
A --> B --> C --> D --> E --> F --> G
classDef p fill:#1d4ed8,stroke:#0f172a,stroke-width:2px,color:#ffffff
classDef arg fill:#fef3c7,stroke:#b45309,color:#92400e
classDef ret fill:#ffe4e6,stroke:#9f1239,color:#9f1239
classDef rel fill:#e5e7eb,stroke:#6b7280,color:#6b7280
class E p
class B,C arg
class D ret
class F rel
pop ebp —— 出栈,把进来时保存的 main 栈底地址,弹出来赋给 ebp。执行完,**ebp 回到了 main 的栈底**。
ret —— 做两件事:
- 出栈:此时栈顶放的就是
call指令当初压入的,返回后下一条指令的地址 - 把出栈的值放入 CPU 的 PC 寄存器(程序计数器,x86 中叫 EIP)
CPU 怎么知道下一条该执行哪条指令?就是看 PC 寄存器。执行完 ret,PC 里存的是返回地址,CPU 就跳到这个地址去执行——正好是 main 里 call sum 的下一条指令add esp, 8
三条指令执行完,ebp 已经回到 main 的栈底,esp 退到了形参 a 上:
flowchart TB
A["高地址"]
B["ebp → main 栈帧栈底(已恢复)"]
C["a = 10 [ebp-4]"]
D["b = 20 [ebp-8]"]
E["ret = ? [ebp-0Ch]"]
F["参数 b = 20"]
G["参数 a = 10"]
H["esp → 栈顶(停在参数 a 上)"]
A --> B --> C --> D --> E --> F --> G --> H
classDef p fill:#1d4ed8,stroke:#0f172a,stroke-width:2px,color:#ffffff
classDef v fill:#dbeafe,stroke:#2c5f8a,color:#1e3a5f
classDef arg fill:#fef3c7,stroke:#b45309,color:#92400e
class B,H p
class C,D,E v
class F,G arg
问题 1 和问题 2 到这里全部解答:
- 问题 1(回到哪个函数):进来时
push ebp保存了调用方栈底,pop ebp时恢复 - 问题 2(从哪行继续):
call时压入了下一条指令的地址,ret时弹出到 PC 寄存器
第七步:回到 main
CPU 跳回 main 后,继续执行:
1 | add esp, 8 ; 回收形参的栈空间 |
add esp, 8 —— esp 向高地址移动 8 字节。这是把刚才压栈的两个形参 a、b 的空间还给系统。到这里,esp 和 ebp 都回到了 main 自己的位置。
mov dword ptr [ebp-0Ch], eax —— eax 里装着 sum 带回来的 30,虽然sum函数结束了它的生命,但是它把返回值存到了eax里,eax再把它写进 main 的局部变量 ret。
两条指令执行完,栈上只剩下 main 自己的东西,形参空间被彻底回收:
flowchart TB
A["高地址"]
B["ebp → main 栈帧栈底(和调用前完全一致)"]
C["a = 10 [ebp-4]"]
D["b = 20 [ebp-8]"]
E["ret = 30 [ebp-0Ch] ← sum 的返回值落了进来"]
F["esp → 栈顶(低地址)"]
G["低地址"]
A --> B --> C --> D --> E --> F --> G
classDef p fill:#1d4ed8,stroke:#0f172a,stroke-width:2px,color:#ffffff
classDef v fill:#dbeafe,stroke:#2c5f8a,color:#1e3a5f
classDef res fill:#d1fae5,stroke:#047857,color:#065f46
class B,F p
class C,D v
class E res
💡 形参谁来清理? 在 C/C++ 默认的
__cdecl调用约定下,由主调函数清理(就是这里的add esp, 8)。而__stdcall约定下由被调函数清理,函数返回时执行ret N,在返回的同时弹出形参空间。__cdecl支持可变参数,__stdcall不支持。
返回值大小规则
sum 返回的是 int(4 字节),用 eax 一个寄存器就够了。但返回值大小不同,传递方式也不同:
| 返回值大小 | 传递方式 | 典型类型 |
|---|---|---|
| ≤ 4 字节 | eax 寄存器 |
int、float、char、指针 |
| 4 < size ≤ 8 字节 | eax + edx 寄存器对 |
long long、double |
| > 8 字节 | 临时量(主调函数分配) | 结构体、自定义类 |
大返回值(> 8 字节)怎么传:
- 主调函数在栈上分配一块足够大的临时空间(临时量)
- 把这块临时空间的地址作为隐藏参数传给被调函数
- 被调函数把返回值直接写进这块临时量(相当于调用方提供了一个”出参”)
- 被调函数返回后,主调函数从临时量读取结果
为什么这么设计?寄存器数量有限、容量固定,装不下结构体这样的大对象;而栈空间充足,且是主调函数自己的地盘,直接写过去最省事。
不要返回局部变量的地址
前面提到,栈帧回退只是移动 esp 指针,并没有清理栈上的数据。基于这一点,有一类代码极其危险:
1 | // ❌ 危险代码:返回局部变量的地址 |
看起来好像”能跑”——因为栈上那块内存的数据还在。但这个空间已经交还给系统了。只要在 func() 返回和打印 *p 之间调用了任何其他函数,那块栈空间就会被新函数的栈帧复用、把 10 覆盖掉,此时再打印就是垃圾值。
返回局部变量地址是典型的未定义行为:可能看起来正常、可能崩溃、可能输出随机值——行为完全不可预测,调试时极难定位。
安全的替代方案:
- 返回副本:直接返回值(小对象),或返回动态分配的对象
- 传入指针:由调用者分配内存传进来,被调函数往里面填
- 用静态/全局变量:但要注意线程安全和可重入性问题
总结
整个调用过程,按栈帧从高地址到低地址排下来是这样:
flowchart TB
A["高地址"]
B["main 的局部变量 a / b / ret"]
C["参数 b [ebp+0Ch]"]
D["参数 a [ebp+8]"]
E["返回地址 [ebp+4]"]
F["保存的 main 栈底 [ebp]"]
G["sum 的局部变量 temp [ebp-4]"]
H["低地址"]
A --> B --> C --> D --> E --> F --> G --> H
classDef main fill:#dbeafe,stroke:#2c5f8a,color:#1e3a5f
classDef arg fill:#fef3c7,stroke:#b45309,color:#92400e
classDef mid fill:#ffe4e6,stroke:#9f1239,color:#9f1239
classDef sum fill:#d1fae5,stroke:#047857,color:#065f46
class B main
class C,D arg
class E,F mid
class G sum
四组关键指令:
| 阶段 | 指令 | 作用 |
|---|---|---|
| 调用方压参 | push × N |
从右向左压实参,为形参开辟内存 |
| call | call |
压入返回地址 + 跳转 |
| 被调函数进入 | push ebp / mov ebp, esp / sub esp, N |
保存调用方栈底、确立自己的栈底、开辟栈帧 |
| 被调函数退出 | mov esp, ebp / pop ebp / ret |
回退栈帧、恢复调用方栈底、弹出返回地址跳转 |
| 调用方收尾 | add esp, N |
回收形参空间(__cdecl) |
重点:
call指令是”记住回家的路”的关键——它把下一行指令的地址压栈,ret时再弹回 PC 寄存器- 形参的内存是调用方开辟的,被调函数只负责用
ebp正偏移去访问 - 局部变量用
ebp负偏移访问,因为栈底在高地址、局部变量在低地址 - 栈帧回退不清理数据,所以绝对不要返回局部变量的地址