[!summary] 本讲主线
C 程序先被编译为汇编,再被转换为机器码并装入内存。运行时,CPU 通过寄存器执行指令,函数则按照 x86 调用约定在向低地址增长的栈上创建和销毁栈帧。
1. 学习目标
- 理解 bit、byte、word 与十六进制的关系。
- 理解 C 源码到运行中程序的转换过程。
- 掌握 32 位 x86 的内存布局和小端序。
- 能读懂基本的 x86 AT&T 汇编语法。
- 理解
%ebp、%esp、%eip在函数调用中的作用。 - 能逐步追踪一次完整的函数调用、返回及栈帧变化。
2. 数据表示
2.1 基本单位
| 单位 | 含义 |
|---|---|
| bit | 一个二进制位,取值为 0 或 1 |
| byte | 8 bits |
| word(本课 32 位环境) | 4 bytes = 32 bits |
在 32 位系统中:
- 地址长度为 32 bits;
- 一个地址对应 1 byte;
- 可表示的地址范围为
0x00000000~0xFFFFFFFF; - 理论地址空间大小为
bytes,即 4 GiB。
2.2 十六进制
- 1 个十六进制位表示 4 bits。
- 2 个十六进制位表示 1 byte。
- 前缀
0b表示二进制,前缀0x表示十六进制。
例如:
0b11000110 = 0xC6
常用对应关系:
| 二进制 | 十六进制 | 二进制 | 十六进制 |
|---|---|---|---|
0000 |
0 |
1000 |
8 |
0001 |
1 |
1001 |
9 |
0010 |
2 |
1010 |
A |
0011 |
3 |
1011 |
B |
0100 |
4 |
1100 |
C |
0101 |
5 |
1101 |
D |
0110 |
6 |
1110 |
E |
0111 |
7 |
1111 |
F |
3. C 程序如何运行
C 源代码
│ compiler(编译器)
▼
汇编代码(如 x86、RISC-V)
│ assembler(汇编器)
▼
机器码(原始字节)
│ linker(链接器)处理依赖和库
▼
可执行文件
│ loader(加载器)建立地址空间并启动程序
▼
运行中的进程
| 工具 | 作用 |
|---|---|
| Compiler | 将 C 等高级语言转换为特定指令集的汇编代码 |
| Assembler | 将汇编指令编码为机器码 |
| Linker | 解析符号,将程序与依赖库组合起来 |
| Loader | 请求并建立进程内存空间,装入代码并开始执行 |
[!important]
汇编语言与机器码都依赖指令集架构(ISA)。同一段 C 代码可以被编译成 x86 或 RISC-V 汇编,但二者的指令和编码不同。
4. 内存布局
4.1 进程地址空间
高地址 ┌─────────────────┐
│ Stack │ 函数栈帧、局部变量
│ ↓ │ 向低地址增长
│ │
│ ↑ │
│ Heap │ malloc/free 管理的动态内存
├─────────────────┤
│ Data / Static │ 全局变量、静态变量
├─────────────────┤
低地址 │ Code / Text │ 程序机器指令
└─────────────────┘
4.2 重要区分
- 内存是一长串按 byte 编址的空间,位置用数值地址表示。
- 寄存器位于 CPU 内部,用名字引用,如
%eax、%ebp、%esp、%eip。 - “栈向下增长”是指分配新栈空间时地址变小,不是说栈中每个对象的字节顺序都反向。
4.3 变量的相对顺序(按本课假设)
- 编译器把局部变量放在栈上。
- 一个栈帧中的局部变量:先声明的变量位于较高地址。
struct的成员:先声明的成员位于较低地址。- 全局变量:先声明的变量位于较低地址。
5. 小端序(Little Endian)
x86 使用小端序:多字节数值的 最低有效字节(LSB)放在最低地址。
假设从低地址到高地址依次存放:
地址: 0x00 0x01 0x02 0x03
字节: 0x11 0x22 0x33 0x44
则:
- 地址
0x00处的 byte 是0x11; - 地址
0x00处开始的 4-byte word 是0x44332211。
换言之,数值 0x44332211 在内存中从低地址到高地址排列为:
11 22 33 44
[!warning] 常见误区
- 端序只讨论多字节值在内存中的字节排列,单独一个 byte 没有端序问题。
- 十六进制数正常书写时仍然把最高有效位写在左侧;只有观察其内存字节时才会看到“小端倒序”。
- 不要把“栈向低地址增长”和“小端序”混为一谈:前者描述分配方向,后者描述一个多字节值的存储顺序。
6. x86 架构与汇编语法
6.1 本课使用的 32 位寄存器
| 寄存器 | 用途 |
|---|---|
%eax |
通用寄存器;函数返回值放在这里 |
%ebx、%ecx、%edx、%esi、%edi |
通用寄存器 |
%esp |
Stack Pointer,指向当前栈的最低已用地址/当前栈帧底部 |
%ebp |
Base/Frame Pointer,指向当前栈帧顶部,即保存旧 %ebp 的位置 |
%eip |
Instruction Pointer,指向下一条将执行的指令 |
寄存器中可以保存普通 32 位数,也可以保存 32 位地址;寄存器图中画成箭头时,寄存器实际存的是箭头所指位置的地址。
6.2 AT&T 语法规则
| 写法 | 含义 | 示例 |
|---|---|---|
%reg |
寄存器 | %eax |
$imm |
立即数/常量 | $1、$0x4 |
(%reg) |
访问寄存器所存地址处的内存 | (%esp) |
offset(%reg) |
访问地址 reg + offset 处的内存 |
8(%esp) |
指令的一般形式为:
opcode source, destination
即 源操作数在前,目标操作数在后;目标通常也是输入之一。
示例:
add $0x8, %ebx
等价于:
EBX = EBX + 0x8
xorl 4(%esi), %eax
等价于:
EAX = EAX XOR *(ESI + 4)
7. 栈、栈帧与基本操作
7.1 栈帧
- 每次调用函数,都为该函数在栈上建立一个新栈帧。
- 栈帧保存局部变量,以及恢复调用者状态所需的信息。
- 函数返回后,该栈帧被逻辑上销毁。
- 调用越深,栈越向低地址扩展。
在当前函数中:
%ebp标记栈帧顶部;%esp标记栈帧底部,也是当前栈的最低已用地址;%esp以下的内容视为未定义/不可用。
7.2 push 与 pop
32 位环境中,一次操作一个 word(4 bytes)。
push src:
ESP = ESP - 4
*(ESP) = src
pop dst:
dst = *(ESP)
ESP = ESP + 4
[!note]
“删除”栈上内容通常只是移动%esp,不会立即把旧字节清零;这些字节虽可能仍留在内存中,但已不属于有效栈空间。
8. x86 调用约定
调用约定是一组函数之间共同遵守的规则,规定:
- 参数如何传递;
- 返回值放在哪里;
- 哪些寄存器由 caller 或 callee 负责保存;
- 局部变量如何放入栈帧;
- 调用和返回时如何恢复处理器状态。
8.1 参数与返回值
- 参数按逆序压栈。
- 调用
func(val1, val2, val3)时,依次执行push val3、push val2、push val1。 - 返回值存入
%eax。 - 本课示例由 caller 清理参数,如两个 4-byte 参数对应
add $8, %esp。
8.2 Caller-saved 与 Callee-saved
- Caller-saved:callee 可以覆盖;若 caller 还要使用旧值,应在调用前自行保存。
- Callee-saved:callee 若要改动,必须先保存,并在返回前恢复。
%ebp在本课调用流程中需要由 callee 保存和恢复。
8.3 典型栈帧布局
对于 callee(1, 2),完成函数序言后:
高地址
┌────────────────────┐
│ argument 2 │ 12(%ebp)
├────────────────────┤
│ argument 1 │ 8(%ebp)
├────────────────────┤
│ RIP / return addr │ 4(%ebp) ← call 保存的旧 EIP
├────────────────────┤
│ SFP / saved EBP │ 0(%ebp) ← %ebp 指向这里
├────────────────────┤
│ local variable │ -4(%ebp)
├────────────────────┤
│ more locals ... │ ← %esp 位于栈帧底部
└────────────────────┘
低地址
缩写:
- RIP:Return Instruction Pointer,保存的返回地址(旧
%eip)。 - SFP:Saved Frame Pointer,保存的调用者
%ebp。
[!tip] 快速定位
在标准 32 位栈帧中,以%ebp为基准:负偏移通常是局部变量,0(%ebp)是旧%ebp,4(%ebp)是返回地址,8(%ebp)起是参数。
9. 一次完整的函数调用
示例 C 代码:
void caller(void) {
callee(1, 2);
}
int callee(int a, int b) {
int local;
return 42;
}
对应的简化 x86 汇编:
caller:
...
push $2
push $1
call callee
add $8, %esp
...
callee:
push %ebp
mov %esp, %ebp
sub $4, %esp
mov $42, %eax
mov %ebp, %esp
pop %ebp
ret
9.1 Caller:准备并转移控制
push $2:先压入最后一个参数。push $1:再压入第一个参数。call callee同时完成两件事:- 把下一条 caller 指令的地址压栈,作为 RIP;
- 把
%eip改为callee的入口地址。
%eip指向的是下一条要执行的指令,所以保存它后才能在 callee 返回时继续执行call后面的代码。
9.2 Callee:函数序言(prologue)
push %ebp
mov %esp, %ebp
sub $4, %esp
逐步含义:
- 保存 caller 的
%ebp,这个值称为 SFP。 - 令新
%ebp = %esp,建立当前栈帧顶部。 %esp减小,为局部变量分配空间;空间大小由编译器决定。
9.3 执行函数体
mov $42, %eax
返回值按约定放入 %eax。
9.4 Callee:函数尾声(epilogue)
mov %ebp, %esp
pop %ebp
ret
逐步含义:
%esp = %ebp:释放所有局部变量空间。pop %ebp:从 SFP 恢复 caller 的%ebp。ret:行为近似pop %eip,从 RIP 恢复返回地址并回到 caller。
前两条也常写成:
leave
9.5 Caller:清理参数
add $8, %esp
两个参数共 8 bytes,caller 上移 %esp,移除参数。至此,除返回值外,栈和关键控制状态恢复到调用前。
10. 完整调用步骤速记
调用阶段
- 参数逆序压栈。
call保存旧%eip(RIP)。call更新%eip,跳转到 callee。- callee 保存旧
%ebp(SFP)。 - 更新
%ebp,建立新栈帧顶部。 - 更新
%esp,为局部变量分配空间。
执行阶段
- 执行 callee,返回值写入
%eax。
返回阶段
%esp = %ebp,释放局部变量。- 恢复旧
%ebp。 ret恢复旧%eip,返回 caller。- caller 移动
%esp,清理参数。
记忆链:
参数 → RIP → SFP → locals → 返回值 → 丢弃 locals → 恢复 SFP → 恢复 RIP → 清参数
11. 易错点
- AT&T 语法的目标在最后:
mov source, destination。 - 立即数必须带
$:$42是数值 42;42可能被解释为内存地址。 - 寄存器带
%:%eax。 - 括号表示内存解引用:
8(%ebp)是内存值,不是简单的加法结果。 - 栈向低地址增长:
push先减小%esp。 - 参数逆序入栈:这样第一个参数离返回地址最近,并能用固定正偏移访问。
call不只是跳转:它还保存返回地址。ret不只是跳转:它从栈顶取回返回地址,同时移动%esp。%ebp与%esp不同:前者在函数执行期间通常稳定,后者会随压栈、出栈和分配空间而变化。- 释放栈空间不等于清零:移动
%esp后,旧数据可能仍在内存里。
12. 自测题
Q1
内存从低到高为 EF BE AD DE,按小端序读取一个 4-byte word,结果是什么?
Q2
8(%ebp) 表示什么?
Q3
执行 push %eax 后,%esp 如何变化?
Q4
call foo 完成哪两件关键事情?
Q5
为什么返回前必须恢复旧 %ebp 和旧 %eip?
Q6
调用 f(a, b, c) 时,参数按什么顺序压栈?
答案
0xDEADBEEF。- 访问地址
%ebp + 8处的内存;在标准 32 位栈帧中通常是第一个参数。 %esp先减 4,再把%eax的值写入新的栈顶。- 把返回地址压栈,并让
%eip跳转到foo。 - 恢复 caller 的栈帧基准和继续执行的位置。
c → b → a,即逆序压栈。
13. 一页总结
数据:1 byte = 8 bits;本课 1 word = 4 bytes
端序:x86 小端,最低有效字节放在最低地址
语法:opcode source, destination
内存:Code → Data → Heap ↑ ... ↓ Stack
寄存器:EBP=当前栈帧顶部,ESP=当前栈底,EIP=下一条指令
返回值:EAX
调用:逆序压参数 → call 保存 RIP 并跳转
序言:push EBP → EBP=ESP → ESP-=locals_size
尾声:ESP=EBP → pop EBP → ret
清理:caller 上移 ESP 移除参数
参考
- 课件:CS 161 Fall 2025 Lecture 2, x86 Assembly and Call Stack
- 课件标注教材章节:2.1~2.7(其中本讲重点为 2.4、2.6、2.7)
评论