CS161 Lecture 2 - x86 Assembly and Call Stack

[!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 调用约定

调用约定是一组函数之间共同遵守的规则,规定:

  1. 参数如何传递;
  2. 返回值放在哪里;
  3. 哪些寄存器由 caller 或 callee 负责保存;
  4. 局部变量如何放入栈帧;
  5. 调用和返回时如何恢复处理器状态。

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:准备并转移控制

  1. push $2:先压入最后一个参数。
  2. push $1:再压入第一个参数。
  3. call callee 同时完成两件事:
    • 把下一条 caller 指令的地址压栈,作为 RIP;
    • 把 %eip 改为 callee 的入口地址。

%eip 指向的是下一条要执行的指令,所以保存它后才能在 callee 返回时继续执行 call 后面的代码。

9.2 Callee:函数序言(prologue)

push %ebp
mov  %esp, %ebp
sub  $4, %esp

逐步含义:

  1. 保存 caller 的 %ebp,这个值称为 SFP。
  2. 令新 %ebp = %esp,建立当前栈帧顶部。
  3. %esp 减小,为局部变量分配空间;空间大小由编译器决定。

9.3 执行函数体

mov $42, %eax

返回值按约定放入 %eax。

9.4 Callee:函数尾声(epilogue)

mov %ebp, %esp
pop %ebp
ret

逐步含义:

  1. %esp = %ebp:释放所有局部变量空间。
  2. pop %ebp:从 SFP 恢复 caller 的 %ebp。
  3. ret:行为近似 pop %eip,从 RIP 恢复返回地址并回到 caller。

前两条也常写成:

leave

9.5 Caller:清理参数

add $8, %esp

两个参数共 8 bytes,caller 上移 %esp,移除参数。至此,除返回值外,栈和关键控制状态恢复到调用前。


10. 完整调用步骤速记

调用阶段

  1. 参数逆序压栈。
  2. call 保存旧 %eip(RIP)。
  3. call 更新 %eip,跳转到 callee。
  4. callee 保存旧 %ebp(SFP)。
  5. 更新 %ebp,建立新栈帧顶部。
  6. 更新 %esp,为局部变量分配空间。

执行阶段

  1. 执行 callee,返回值写入 %eax。

返回阶段

  1. %esp = %ebp,释放局部变量。
  2. 恢复旧 %ebp。
  3. ret 恢复旧 %eip,返回 caller。
  4. caller 移动 %esp,清理参数。

记忆链:

参数 → RIP → SFP → locals → 返回值 → 丢弃 locals → 恢复 SFP → 恢复 RIP → 清参数

11. 易错点

  1. AT&T 语法的目标在最后:mov source, destination。
  2. 立即数必须带 $:$42 是数值 42;42 可能被解释为内存地址。
  3. 寄存器带 %:%eax。
  4. 括号表示内存解引用:8(%ebp) 是内存值,不是简单的加法结果。
  5. 栈向低地址增长:push 先减小 %esp。
  6. 参数逆序入栈:这样第一个参数离返回地址最近,并能用固定正偏移访问。
  7. call 不只是跳转:它还保存返回地址。
  8. ret 不只是跳转:它从栈顶取回返回地址,同时移动 %esp。
  9. %ebp 与 %esp 不同:前者在函数执行期间通常稳定,后者会随压栈、出栈和分配空间而变化。
  10. 释放栈空间不等于清零:移动 %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) 时,参数按什么顺序压栈?

答案
  1. 0xDEADBEEF。
  2. 访问地址 %ebp + 8 处的内存;在标准 32 位栈帧中通常是第一个参数。
  3. %esp 先减 4,再把 %eax 的值写入新的栈顶。
  4. 把返回地址压栈,并让 %eip 跳转到 foo。
  5. 恢复 caller 的栈帧基准和继续执行的位置。
  6. 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)

本文作者:Jnyau Zhneg

本文链接:/posts/301c/

版权声明:本文采用 CC BY-NC-SA 4.0 许可协议

评论