Appearance
程序的机器级表示
2026 大纲 四(六)2~4 选择结构语句、循环结构语句、过程(函数)调用的机器级表示。
源码写"大于就执行",机器码写的是"小于等于就跳走"
拿到一段反汇编,第一件让人卡住的事往往是:源码明明写着 if (i > n),对应的机器指令却是 jle。这不是编译器写反了,而是机器级只有一条直路加若干岔口。
指令是顺序执行的,"条件成立"那一路本来就在下面,一条转移指令都不需要;真正需要跳的是"条件不成立、要绕过这段代码"的那一路。于是编译器把源码里的条件取反,写成"条件不成立就跳走"。if (a > b) 编译成 jle,if (i < n) 编译成 jge——看到机器码里的判断和源码相反,说明你读对了。
第二件卡住人的事是:同样是"小于等于就跳走",有时候用 jle,有时候用 jbe。这两条指令查的标志位完全不同——jle 看 jbe 看 int 这类有符号数用 jle/jg 一套,指针一律按无符号比较(地址不存在负数)用 jbe/ja 一套。同一段比较代码,把变量类型从 int 改成 unsigned 或指针,编译出来的转移指令就换一套。
把这两条立住之后,剩下的事情就只是看编译器怎么排:所有的 if、while、for、函数调用,在机器级都只由三样原料拼成——产生条件、使用条件、无条件改流。这一篇按选择、循环、过程调用三段,逐个把源码结构与它的机器级形态对上。
一、原料只有三样
高级语言的控制结构,在机器级只剩三样原料,所有 if、while、for 与函数调用都是它们的排列:
| 原料 | 指令 | 作用 |
|---|---|---|
| 条件的产生 | cmp / test 以及各类运算指令 | 把比较或运算的结果写进标志位 |
| 条件的使用 | 各类条件转移指令 | 按标志位决定要不要改写 PC |
| 无条件改流 | jmp / call / ret | 直接改写 PC |
理解这一点,机器级表示就不再是"背模板"而是"看编译器怎么排"。
条件码:四个标志位
cmp a, b 按
| 标志 | 全称 | 置位含义(按 |
|---|---|---|
| ZF | Zero Flag | 结果为 0,即 |
| SF | Sign Flag | 结果的符号位为 1,即结果为负 |
| OF | Overflow Flag | 有符号溢出 |
| CF | Carry Flag | 无符号借位,即 |
两套比较关系由此分开表达:无符号看 CF 与 ZF(
二、选择结构的机器级表示
if-else 的通用翻译模式(写成 goto 形式):
t = 条件表达式;
if (!t) goto false_branch;
then 分支的语句
goto done;
false_branch:
else 分支的语句
done:三处结构性事实:条件被取反(理由见开篇:顺序执行那一路不需要跳转指令)、then 末尾必须有一条无条件 jmp(否则会直落进 else 段)、两分支汇合于同一标号。
一段指针比较的编译结果:同样是小于等于,为什么用 jbe 而不是 jle(想确认转移指令怎么随操作数类型改变时展开)
取两个指针中地址较大者所指的值:
c
int pick(int *p1, int *p2) {
if (p1 > p2) return *p2;
else return *p1;
}asm
mov eax, dword ptr [ebp+8] ; 取参数 p1
mov edx, dword ptr [ebp+12] ; 取参数 p2
cmp eax, edx ; 按 p1 - p2 置标志位
jbe L1 ; p1 <= p2(无符号)则跳走
mov eax, dword ptr [edx] ; 直落路径:取 *p2
jmp L2
L1:
mov eax, dword ptr [eax] ; 取 *p1
L2:这里用 jbe(无符号的"小于等于")而不是 jle,原因是指针按无符号整数比较——地址没有负数。同一段 C 代码,若把 int * 换成 int,编译出来就是 jle。
三、循环结构的机器级表示
最基本的形态 do-while,只需一条条件转移指令,代价是循环体至少执行一次:
loop:
循环体
t = 条件表达式;
if (t) goto loop;while 与 do-while 只差一个"进门前先看一眼"(for 展开后与之同构,只多了初始化与更新):
t = 条件表达式;
if (!t) goto done;
loop:
循环体
t = 条件表达式;
if (t) goto loop;
done:这个模板有个明显的浪费:条件表达式的代码出现了两次。于是有了编译器常用的第三种形态,让判断代码只保留一份:
init;
jmp test ← 先无条件跳到判断处
body:
循环体
update
test:
条件判断
条件成立 → jmp body开头那条 jmp 与末尾那条条件转移是一对配合,合起来就是一个循环,不是分支。 读汇编时看到"初始化之后紧跟一条无条件 jmp 往后跳",不要以为漏掉了什么分支——它跳到的正是判断代码,判断成立再跳回来。这一对的收益是判断代码只保留一份,代价是每次进入循环多执行一条 jmp。
由此得到识别循环的判据:看控制流的形状,也就是有没有一条往回跳的条件转移。至于循环变量住在栈单元 [ebp-X] 里(未优化,自增要三条指令)还是被提升进了寄存器(优化后一条 inc 就够),那只影响代码长什么样,与它是不是循环无关。
同一个循环的两种编译形态:局部变量在栈单元里与被提升到寄存器(读汇编分不清哪个是循环变量时展开)
未优化时,局部变量落在栈帧里,从 [ebp-4] 起向低地址排:
asm
mov dword ptr [ebp-8], 0 ; i = 0,i 住在栈单元 [ebp-8]
jmp L_test
L_body:
mov eax, [ebp-8] ; 读 i
add eax, 1
mov [ebp-8], eax ; 写回 i —— 自增要三条指令
L_test:
cmp dword ptr [ebp-8], 10
jl L_body优化后,局部变量被提升到寄存器,自增就只剩一条指令:
asm
mov edx, 0 ; i 住在 edx
L_body:
add edx, 1
cmp edx, 10
jl L_body两种形态描述的是同一段 C 代码。判断某个量是不是循环变量,看的是谁在参与循环末尾那条 cmp——它在 [ebp-X] 里还是在寄存器里,只反映优化级别。
编者注(口径):
[ebp-8]这种形式本质上就是基址寻址——ebp是基址寄存器,-8是形式地址。循环体里访问数组时出现的[ecx+edx*4+首地址],则是基址加变址加位移的复合寻址,展开方式见《有效地址计算》。
四、过程调用的机器级表示
每个过程在栈上有自己的一块区域,称为栈帧(stack frame);一个栈由若干栈帧首尾相接构成。
栈那三条存在理由的完整推导:为什么这批数据非放栈上不可(想弄清"寄存器不够用"之外还差什么时展开)
过程调用要为一批数据找存放空间:入口参数、返回地址、调用过程用到的寄存器、被调用过程的局部变量、返回结果。最快的地方当然是寄存器,但寄存器有三条硬限制:
- 数量有限,且被所有过程共享——给定时刻只能被一个过程使用
- 数组、结构这类复杂类型的局部变量根本放不进寄存器
- 过程可以嵌套甚至递归,同一个过程可以同时存在多个活动实例,各自需要一份独立的局部变量
第三条决定了这块空间必须是"后进先出"的:调用嵌套的顺序天然就是栈的顺序。
调用与返回:指令做了什么
假设过程 P 调用过程 Q:
| 步骤 | 谁做 | 做什么 |
|---|---|---|
| 1 | P | 把入口参数放到 Q 能访问到的地方 |
| 2 | P | 执行 call Q:把返回地址(call 的下一条指令地址)压栈,然后转移到 Q |
| 3 | Q | 建立栈帧:push ebp mov ebp, esp sub esp, N |
| 4 | Q | 执行过程体 |
| 5 | Q | 把返回值放到 P 能访问到的地方(约定用 EAX) |
| 6 | Q | leave 撤销栈帧,ret 弹出返回地址并转移回 P |
栈帧的布局是推出来的
x86 的栈从高地址向低地址增长。各项位置不必硬记,按上面六步的发生顺序推即可——[ebp+8] 里那个 +8,就等于"一个旧 EBP
| 发生顺序 | 压入什么 | ESP 变化 | 结果位置 |
|---|---|---|---|
| ① 调用前 | 参数(从右向左) | 每个 | 高地址侧 |
② call | 返回地址 | [ebp+4] | |
③ push ebp | 调用者的 EBP | [ebp] | |
④ mov ebp, esp | — | 不变 | EBP 就位 |
⑤ sub esp, N | 为局部变量开空间 | [ebp-4] 起向低地址排 |
当前栈帧的边界是 EBP 到 ESP 之间,EBP 之上已是调用者的地盘。所以栈帧不是栈:栈是整块连续区域,栈帧只是其中属于某一个过程活动实例的一段。递归之所以可行,正是因为每次调用都在栈上获得一个全新的、独立的栈帧。为什么用固定的 EBP 而不用一直在动的 ESP 来访问变量,理由也在这里——帧内各项相对 EBP 的偏移在整个过程执行期间不变,写起来简单、也不易出错;反过来,若某个过程没有局部变量,编译器往往连帧指针都不设,看不到 push ebp / mov ebp, esp 不等于反汇编抄漏了。
第 6 步那条 leave 是两条指令的合并:mov esp, ebp(把 ESP 拉回帧底,丢弃整个局部变量区)pop ebp(恢复调用者的 EBP)。执行完 ESP 恰好指向返回地址,紧接着的 ret 把它弹进 PC。这里藏着一条关键性质:ret 没有操作数,它弹出的就是"执行它那一刻栈顶的那个值"。过程内部的 push 与 pop 只要没配平,栈顶就不是返回地址,ret 会跳到一个完全无关的地方去——这就是栈帧必须在返回前恢复原状的根子。
高地址
┌─────────────────┐
│ 参数 n │ [ebp+12]、[ebp+16] …
│ 参数 1 │ [ebp+8]
│ 返回地址 │ [ebp+4] ← call 压入
├─────────────────┤
│ 旧 EBP │ [ebp] ← push ebp
├─────────────────┤
│ 局部变量 1 │ [ebp-4]
│ 局部变量 2 │ [ebp-8]
│ … │
└─────────────────┘ ← ESP(栈顶)
低地址这张图只画了一帧,会掩盖一件要紧事——调用者帧底部的"参数区"和被调用者帧顶部的 [ebp+8] 起,是同一块内存的两个视角。

图 3.12 caller 和 add 的栈帧
图里 caller 把两个实参写在自己帧的底部,call 再压入返回地址;进入 add 后,它通过 [ebp+8]、[ebp+12] 读到的正是这两个数。同一块内存,调用者从上往下写、被调用者从下往上读——参数传递没有额外的搬运。
调用全过程的三个瞬间

图 3.11 过程调用过程中栈和栈帧的变化
| 瞬间 | 栈上有什么 | ESP 在哪 |
|---|---|---|
| a) 过程 Q 被调用前 | 调用者保存的寄存器 | 返回地址处 |
| b) 过程 Q 执行中 | 再加上 EBP 旧值 | 当前帧最低处 |
| c) 返回到过程 P 后 | 恢复成 a) 的样子 | 退回返回地址处 |
返回之后栈必须恢复原状,这是调用约定的底线——否则调用者接下来读到的每个栈单元都会错位。
寄存器保存约定
寄存器是所有过程共享的,一个过程写了它,另一个过程存在里面的值就没了。解决办法是事先约定谁负责保存:
| 类别 | 寄存器(IA-32) | 谁负责保存 | 含义 |
|---|---|---|---|
| 调用者保存 | EAX, ECX, EDX | 调用者 P 在 call 前自行保存 | 被调用者可以随便用,返回后其中的值不保证有效 |
| 被调用者保存 | EBX, ESI, EDI | 被调用者 Q 使用前 push、返回前 pop | 调用者可以放心地跨过一次调用继续使用 |
与其记名单,不如记各自的承诺:调用者保存的那几个"被调用者可以随意覆盖",被调用者保存的那几个"用完还你"。EAX 属于前者有个旁证——返回值就走 EAX,本就注定会被改写。两类划分是减少无谓保存的手段。
一个完整的小例子:caller 与 add 的汇编(左侧带机器码),逐条对上前面的六步(想把栈帧、参数区、返回值串成一条线时展开)
c
int add(int x, int y) { return x + y; }
int caller() {
int temp1 = 125, temp2 = 80;
int sum = add(temp1, temp2);
return sum;
}caller 的代码:
asm
push ebp ; 保存调用者的 EBP
mov ebp, esp ; 帧指针就位
sub esp, 24 ; 为局部变量与参数区开空间
mov dword ptr [ebp-12], 125 ; temp1
mov dword ptr [ebp-8], 80 ; temp2
mov eax, dword ptr [ebp-8]
mov [esp+4], eax ; 第 2 个参数放在参数区高地址侧
mov eax, dword ptr [ebp-12]
mov [esp], eax ; 第 1 个参数放在参数区低地址侧
call add ; 压入返回地址并转移;返回值在 eax
mov dword ptr [ebp-4], eax ; sum = 返回值
mov eax, dword ptr [ebp-4] ; 把 sum 作为自己的返回值
leave ; = mov esp, ebp ; pop ebp
ret ; 弹出返回地址并转移回去add 的代码(左侧为该指令的机器码):
asm
55 push ebp
89 e5 mov ebp, esp
8b 45 0c mov eax, dword ptr [ebp+12] ; 取第 2 个参数 y
8b 55 08 mov edx, dword ptr [ebp+8] ; 取第 1 个参数 x
8d 04 02 lea eax, [edx+eax] ; eax = x + y
5d pop ebp
c3 ret两处值得留意:
add里读到的[ebp+8]、[ebp+12],正是caller写进[esp]、[esp+4]的那两个单元——第一个参数在低地址侧,所以偏移小的[ebp+8]对应第一个参数。- 编译器用
lea而不是add完成加法:lea计算的是有效地址、并不访存,这里借它的地址加法器直接算出,还不影响标志位。
考点速记
- 机器级只有三样原料——产生条件、使用条件、无条件改流;
if-else的形态是"条件取反后跳向 else,then 走直落路径且末尾必须有一条jmp",选哪条转移指令取决于操作数有无符号。 - 三种循环都归结为"循环体
条件测试 条件跳转",do-while 最省;识别循环靠控制流形状,与循环变量住在栈上还是寄存器里无关。 - 栈帧布局按发生顺序推(
[ebp+4]返回地址、[ebp+8]起参数、[ebp-4]起局部变量),调用者帧的参数区就是被调用者的[ebp+8]起;call比jmp多压一个返回地址,栈帧不还原ret就会跳错。
这一节在真题里被考过的形式:
408 给这一节的形式是固定的:摆一段带虚拟地址、机器码、汇编指令三列的反汇编,配一小段 C 源码,然后问五六个问题。2017-44、2019-45、2023-44 三道都是这个壳子,问的东西也高度重复,可以按四类来准备。
- 由地址与机器码算长度。相邻两条指令的地址相减,得到的就是前一条指令的长度;反过来,"这段代码一共占多少字节"要用
(2017-44 第 2 问: 字节,末条恰是 1 字节的 ret才退化成"")。同理,2023-44 第 1 问的第 20 条指令地址 ——第 19 条的机器码正好数出 11 个字节。⚠️ 末尾那一项是末条指令的长度,不是常数 1。 - 判断跳转指令的寻址方式并算目标地址。
jmp/jge/call这类的机器码里跟的是位移量,一律是相对寻址,目标地址下一条指令的地址 位移量。2023-44 第 2 问:第 2 条 jmp在 00401079H、占 2 字节,故。2019-45 第 3 问把它反过来用: call在 00401025H、占 5 字节,下一条是 0040102AH,要跳到 00401000H,故偏移量,写成 32 位补码是 FFFFFFD6H。 - 由机器码里多字节数据的排列判断大小端。这一问几乎每次都跟在上一问后面:2019-45 里
call的后 4 字节机器码是D6 FF FF FF,而偏移量的真值是 FFFFFFD6H,最低有效字节 D6 排在最前,故是小端;2023-44 第 3 问里00422000H在机器码中写作00 20 42 00,同样是小端。判据只有一句:看最低有效字节排在前还是排在后。 - 由 C 语义反查机器行为。这一类最杂,但线索都在源码的类型上。2017-44 第 3 问要算
cmp后的 CF:n是unsigned,f1(0)时n-1不是而是 FFFFFFFFH, 不够减、要借位,故 。同题第 4 问问 f2 里能不能同样用 shl实现power*2——不能,因为 f2 的power是float,IEEE 754 的三段结构整体左移会跨过字段边界。2019-45 第 4 问的f1(13)返回错值是int溢出(),改法是把返回类型换成能装下的类型;第 5 问的 imul溢出判据是高 32 位不是低 32 位符号位的符号扩展,要转异常处理就在imul后加一条溢出条件转移指令。
复习优先级:这一节几乎不出选择题,但每隔两三年就出一道 10~16 分的大题,且上面四类问法反复出现。练法不是背模板,而是拿一段反汇编,把地址、机器码字节数、跳转目标、栈帧偏移四样逐条标出来——三道真题的绝大多数小问都能被这四样直接答掉。
易错:算代码段总长时用"末条地址
首条地址 "。加的应该是末条指令的长度。
易错:算跳转目标时以本条指令地址为基准。相对寻址的基准是下一条指令的地址。
易错:把
unsigned的n-1当成。它是 FFFFFFFFH,直接改变减法的借位结果。
易错:把整数的"移位代替乘 2"套到浮点数上。要改的是阶码字段,不是整体左移。
易错:把栈帧当成栈。栈帧是栈里属于某个过程活动实例的一段,递归时同时存在多个。
教材出处
- 袁春风《计算机组成与系统结构(第 3 版)》§4.3.2 选择结构的机器代码表示:编译器通过条件码设置指令与各类转移指令实现选择结构(印刷页 p107);§4.3.3 循环结构的机器代码表示:分支指令用于判断循环结束条件(印刷页 p108);§4.3.4 过程调用的机器代码表示:调用过程的六个步骤、寄存器数量有限且为所有过程共享、复杂类型局部变量无法保存在寄存器中因而需要栈、栈帧与帧指针的关系、无局部变量时编译器多不设置帧指针(印刷页 p109~110)
- 同上 §4.2.6 标志信息的生成与使用:表 4.1 IA-32 部分条件转移指令的转移条件,以及
反映"大于"关系的推理(印刷页 p99) - 袁春风《计算机系统基础(第 2 版)》§3.3 过程调用的机器级表示:图 3.11 过程调用过程中栈和栈帧的变化(印刷页 p121)、图 3.12 caller 和 add 的栈帧(印刷页 p123)