Skip to content

程序的机器级表示

2026 大纲 四(六)2~4 选择结构语句、循环结构语句、过程(函数)调用的机器级表示

源码写"大于就执行",机器码写的是"小于等于就跳走"

拿到一段反汇编,第一件让人卡住的事往往是:源码明明写着 if (i > n),对应的机器指令却是 jle。这不是编译器写反了,而是机器级只有一条直路加若干岔口

指令是顺序执行的,"条件成立"那一路本来就在下面,一条转移指令都不需要;真正需要跳的是"条件不成立、要绕过这段代码"的那一路。于是编译器把源码里的条件取反,写成"条件不成立就跳走"。if (a > b) 编译成 jleif (i < n) 编译成 jge——看到机器码里的判断和源码相反,说明你读对了

第二件卡住人的事是:同样是"小于等于就跳走",有时候用 jle,有时候用 jbe。这两条指令查的标志位完全不同——jleSFOF 是否一致,jbeCF。选哪一条由操作数的类型决定int 这类有符号数用 jle/jg 一套,指针一律按无符号比较(地址不存在负数)用 jbe/ja 一套。同一段比较代码,把变量类型从 int 改成 unsigned 或指针,编译出来的转移指令就换一套。

把这两条立住之后,剩下的事情就只是看编译器怎么排:所有的 ifwhilefor、函数调用,在机器级都只由三样原料拼成——产生条件、使用条件、无条件改流。这一篇按选择、循环、过程调用三段,逐个把源码结构与它的机器级形态对上。

一、原料只有三样

高级语言的控制结构,在机器级只剩三样原料,所有 ifwhilefor 与函数调用都是它们的排列:

原料指令作用
条件的产生cmp / test 以及各类运算指令把比较或运算的结果写进标志位
条件的使用各类条件转移指令按标志位决定要不要改写 PC
无条件改流jmp / call / ret直接改写 PC

理解这一点,机器级表示就不再是"背模板"而是"看编译器怎么排"。

条件码:四个标志位

cmp a, bab 运算,只置标志位、不保存差值

标志全称置位含义(按 ab
ZFZero Flag结果为 0,即 a=b
SFSign Flag结果的符号位为 1,即结果为负
OFOverflow Flag有符号溢出
CFCarry Flag无符号借位,即 a<b

两套比较关系由此分开表达:无符号看 CF 与 ZFCF=1 即"小于");有符号看 SF、OF 与 ZFSF=OF 即"大于等于",因为它只对应"差为正且未溢出"与"差为负但溢出"两种情形)。完整的条件转移对照表见《x86 汇编语言基础》。

二、选择结构的机器级表示

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);一个栈由若干栈帧首尾相接构成。

栈那三条存在理由的完整推导:为什么这批数据非放栈上不可(想弄清"寄存器不够用"之外还差什么时展开)

过程调用要为一批数据找存放空间:入口参数、返回地址、调用过程用到的寄存器、被调用过程的局部变量、返回结果。最快的地方当然是寄存器,但寄存器有三条硬限制:

  1. 数量有限,且被所有过程共享——给定时刻只能被一个过程使用
  2. 数组、结构这类复杂类型的局部变量根本放不进寄存器
  3. 过程可以嵌套甚至递归,同一个过程可以同时存在多个活动实例,各自需要一份独立的局部变量

第三条决定了这块空间必须是"后进先出"的:调用嵌套的顺序天然就是栈的顺序。

调用与返回:指令做了什么

假设过程 P 调用过程 Q:

步骤谁做做什么
1P把入口参数放到 Q 能访问到的地方
2P执行 call Q把返回地址(call 的下一条指令地址)压栈,然后转移到 Q
3Q建立栈帧:push ebp mov ebp, esp sub esp, N
4Q执行过程体
5Q把返回值放到 P 能访问到的地方(约定用 EAX)
6Qleave 撤销栈帧,ret 弹出返回地址并转移回 P

栈帧的布局是推出来的

x86 的栈从高地址向低地址增长。各项位置不必硬记,按上面六步的发生顺序推即可——[ebp+8] 里那个 +8,就等于"一个旧 EBP + 一个返回地址"的宽度:

发生顺序压入什么ESP 变化结果位置
① 调用前参数(从右向左)每个 4高地址侧
call返回地址4[ebp+4]
push ebp调用者的 EBP4[ebp]
mov ebp, esp不变EBP 就位
sub esp, N为局部变量开空间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 没有操作数,它弹出的就是"执行它那一刻栈顶的那个值"。过程内部的 pushpop 只要没配平,栈顶就不是返回地址,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 的栈帧

图 3.12 caller 和 add 的栈帧

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

调用全过程的三个瞬间

图 3.11 过程调用过程中栈和栈帧的变化

图 3.11 过程调用过程中栈和栈帧的变化

瞬间栈上有什么ESP 在哪
a) 过程 Q 被调用前调用者保存的寄存器 + 参数 n1 + 返回地址返回地址处
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 计算的是有效地址、并不访存,这里借它的地址加法器直接算出 x+y,还不影响标志位。

考点速记

  1. 机器级只有三样原料——产生条件、使用条件、无条件改流if-else 的形态是"条件取反后跳向 else,then 走直落路径且末尾必须有一条 jmp",选哪条转移指令取决于操作数有无符号
  2. 三种循环都归结为"循环体 + 条件测试 + 条件跳转",do-while 最省;识别循环靠控制流形状,与循环变量住在栈上还是寄存器里无关。
  3. 栈帧布局按发生顺序推[ebp+4] 返回地址、[ebp+8] 起参数、[ebp-4] 起局部变量),调用者帧的参数区就是被调用者的 [ebp+8]calljmp 多压一个返回地址,栈帧不还原 ret 就会跳错。

这一节在真题里被考过的形式

408 给这一节的形式是固定的:摆一段带虚拟地址、机器码、汇编指令三列的反汇编,配一小段 C 源码,然后问五六个问题。2017-44、2019-45、2023-44 三道都是这个壳子,问的东西也高度重复,可以按四类来准备。

  • 由地址与机器码算长度。相邻两条指令的地址相减,得到的就是前一条指令的长度;反过来,"这段代码一共占多少字节"要用 末条地址首条地址+末条指令的长度(2017-44 第 2 问:0040107FH00401020H+1=60H=96 字节,末条恰是 1 字节的 ret 才退化成"+1")。同理,2023-44 第 1 问的第 20 条指令地址 =004010AEH+11=004010B9H——第 19 条的机器码正好数出 11 个字节。⚠️ 末尾那一项是末条指令的长度,不是常数 1
  • 判断跳转指令的寻址方式并算目标地址jmp/jge/call 这类的机器码里跟的是位移量,一律是相对寻址,目标地址 = 下一条指令的地址 + 位移量。2023-44 第 2 问:第 2 条 jmp 在 00401079H、占 2 字节,故 00401079H+2+09H=00401084H。2019-45 第 3 问把它反过来用:call 在 00401025H、占 5 字节,下一条是 0040102AH,要跳到 00401000H,故偏移量 =00401000H0040102AH=2AH,写成 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:nunsignedf1(0)n-1 不是 1 而是 FFFFFFFFH,0FFFFFFFFH 不够减、要借位,故 CF=1。同题第 4 问问 f2 里能不能同样用 shl 实现 power*2——不能,因为 f2 的 powerfloat,IEEE 754 的三段结构整体左移会跨过字段边界。2019-45 第 4 问的 f1(13) 返回错值是 int 溢出(13!=6227020800>2311),改法是把返回类型换成能装下的类型;第 5 问的 imul 溢出判据是高 32 位不是低 32 位符号位的符号扩展,要转异常处理就在 imul 后加一条溢出条件转移指令。

复习优先级:这一节几乎不出选择题,但每隔两三年就出一道 10~16 分的大题,且上面四类问法反复出现。练法不是背模板,而是拿一段反汇编,把地址、机器码字节数、跳转目标、栈帧偏移四样逐条标出来——三道真题的绝大多数小问都能被这四样直接答掉。

易错:算代码段总长时用"末条地址 首条地址 +1"。加的应该是末条指令的长度。

易错:算跳转目标时以本条指令地址为基准。相对寻址的基准是下一条指令的地址。

易错:把 unsignedn-1 当成 1。它是 FFFFFFFFH,直接改变减法的借位结果。

易错:把整数的"移位代替乘 2"套到浮点数上。要改的是阶码字段,不是整体左移。

易错:把栈帧当成栈。栈帧是栈里属于某个过程活动实例的一段,递归时同时存在多个。

教材出处
  • 袁春风《计算机组成与系统结构(第 3 版)》§4.3.2 选择结构的机器代码表示:编译器通过条件码设置指令与各类转移指令实现选择结构(印刷页 p107);§4.3.3 循环结构的机器代码表示:分支指令用于判断循环结束条件(印刷页 p108);§4.3.4 过程调用的机器代码表示:调用过程的六个步骤、寄存器数量有限且为所有过程共享、复杂类型局部变量无法保存在寄存器中因而需要栈、栈帧与帧指针的关系、无局部变量时编译器多不设置帧指针(印刷页 p109~110)
  • 同上 §4.2.6 标志信息的生成与使用:表 4.1 IA-32 部分条件转移指令的转移条件,以及 SF=OF 反映"大于"关系的推理(印刷页 p99)
  • 袁春风《计算机系统基础(第 2 版)》§3.3 过程调用的机器级表示:图 3.11 过程调用过程中栈和栈帧的变化(印刷页 p121)、图 3.12 caller 和 add 的栈帧(印刷页 p123)

相关知识

x86 汇编语言基础有效地址计算

真题练习