选择语句的机器级表示

机器一级没有 if。它只有一个动作:改 PC。所以所有的选择语句最终都被翻译成同一个骨架——

产生标志位传递消费标志位,决定改不改

“产生”和”消费”分属两条指令、中间靠 PSW 传递,这是 4.1.5 立下的结构,这一节把它用起来。

机制

Jcc 全表:有符号和无符号读的是不同的位

关系有符号无符号有符号依据无符号依据
je / jzje / jzZF ZF
jne / jnzjne / jnzZF ZF
jgjaZF 且 SF OFCF 且 ZF
jgejaeSF OFCF
jljbSF OFCF
jlejbeZF 或 SF OFCF 或 ZF

助记符的词源就是记忆钥匙:

  • 有符号用 g/l —— greater / less
  • 无符号用 a/b —— above / below

这张表是 2.1.3 那条”同一串二进制,按有符号和无符号读是两个数”在指令层的直接后果。 一条 cmp 之后,硬件已经把 CF、OF、ZF、SF 全部算好了——它不知道你的数据是有符号还是无符号,也不需要知道,因为你随后挑哪条 Jcc,就等于告诉了它。硬件不做选择,硬件提供全部选项。

if-else 的标准翻译:条件是取反的

if (x > y) { A } else { B }
      cmp   ebx, ecx        ; x - y,置标志位
      jle   .Lelse          ; ★ 注意:源码写 >,机器码跳的是 <=
      <A>                   ; then 分支,直接 fall through
      jmp   .Lend
.Lelse:
      <B>
.Lend:

编译器把条件取反,是因为 then 分支要”顺流而下”(fall through)——顺序执行不需要指令,能省一条 jmp。所以:

读汇编时的第一反应:看到 jle .L2,源码里写的多半是 > 而不是 <=。

这一条是读汇编真题最省时间的一个习惯。 若嵌套多层,一层层取反回去即可。

if 没有 else 时更简单,只是少了 jmp .Lend 和一个标号:

      cmp   ebx, ecx
      jle   .Lend
      <A>
.Lend:

switch:两种实现,判据是分支的密度

(1)级联比较——分支少或 case 值稀疏时:

      cmp  eax, 1
      je   .L1
      cmp  eax, 2
      je   .L2
      ...
      jmp  .Ldefault

时间与分支数成正比,。

(2)跳转表——分支多且 case 值密集时:

      sub  eax, 100          ; ★ 先减去最小 case 值,把范围平移到 0 开始
      cmp  eax, 5
      ja   .Ldefault         ; ★ 无符号比较,一次同时挡住 <100 和 >105 两侧越界
      jmp  [.L4 + eax*4]     ; ★ 查表跳转
.L4:  .long .L100, .L101, .L102, .L103, .L104, .L105

时间与分支数无关,。 三处细节各对应一个考点:

  • sub 平移:把 case 值域搬到 开始,表就能从下标 0 排起。
  • ja 用无符号比较挡两侧:若 eax 原本小于 100,减法之后会变成一个很大的无符号数,同样落在 > 5 里被挡掉——一次比较挡住上下两个方向的越界,这是无符号比较的经典用法。用 jg(有符号)就漏掉下界了。
  • jmp [.L4 + eax*4] 用的正是 4.2.2 的变址寻址 + 存储器间接寻址:.L4 是形式地址(表首址,常量),eax 是变址寄存器(下标,变量),比例因子 4 是地址宽度。“变址寻址中形式地址是常量、寄存器是变量”那条判据,在这里落地了。

关联对照:跳转表把 switch 变成"用数据换分支"

跳转表是间接寻址那两个优点的完整体现(4.2.2):目标地址存在数据区、运行时才确定,指令本身一个字节不用改。

代价也很明确:多一次访存(读表项),而且这次访存的地址由数据决定,分支预测器很难猜准。 所以编译器不会无脑用跳转表——case 稀疏时(比如 1、100、10000)表会大到离谱,反而是级联比较划算。“密集用表、稀疏用比较”就是判据。

条件传送 cmov:把分支彻底消掉

x = (a > b) ? a : b;
      mov   eax, ebx        ; 先假设结果是 b
      cmp   ebx, ecx
      cmovl eax, ecx        ; 若 b < c,改成 c

没有任何转移指令,因此不存在分支预测失败。在 5.5 指令流水线(本地资料)里,一次预测失败要清空流水线、代价十几个周期,而 cmov 的代价是恒定的一两拍。

但它有一个硬限制:两个分支的值都必须被真的算出来。 所以下面这种绝对不能用 cmov 优化:

p = (ptr != NULL) ? *ptr : 0;      // 用 cmov 会无条件解引用 ptr → 崩溃
n = (d != 0) ? x/d : 0;            // 用 cmov 会无条件做除法 → 除零异常

判据:分支体有副作用、可能出错、或开销很大时,不能用条件传送。 这条边界正是”控制冒险的软件解法”的适用范围,会在 5.5 再遇到。

边界

条件转移指令只读标志位,不改标志位。 所以连续几条 Jcc 可以依赖同一次 cmp 的结果——编译器翻译 a <= x && x <= b 时就是这么干的。

cmp 和 Jcc 之间可以插入指令,只要它不改相关标志位。 mov、lea、push、pop 都安全(4.3.1)。判断题若说”条件转移指令必须紧跟在比较指令之后”,错。

有符号和无符号用错助记符,是一类真实存在的 bug,也是判断题的常客。 对 cmp eax, ebx 之后用 jg 还是 ja,取决于这两个数在 C 里的类型。编译器根据类型自动选,人写汇编时选错就得到诡异的结果——尤其是负数被当成大正数。

cmp a, b 做的是 ,注意操作数顺序与格式。 Intel 的 cmp eax, ebx 是 ;AT&T 的 cmp %ebx, %eax 也是 (因为 AT&T 的第二个操作数才是目的)。两种格式写出来的字符顺序相反,减法方向却相同——这是读题时最容易翻车的一处。

jmp [.L4+eax*4] 是”取表项内容作为目标地址”(存储器间接),不是”跳到表项所在的地址”。 少解一层引用就跳飞了。方括号在 jmp 里表示访存,这和 lea 里的方括号完全相反——同一个符号在两条指令里含义不同,是 x86 语法的一处真实不一致。

统考的选择语句题几乎总是给出汇编、要求反推 C。 反推的关键就两条:条件是取反的,分支密度决定了 switch 用哪种实现。

对照速查

源码关系有符号跳转无符号跳转
jgja
jgejae
jljb
jlejbe
/ je / jne同
switch 实现适用复杂度用到的寻址方式
级联比较分支少、case 稀疏相对寻址(每个 je)
跳转表分支多、case 密集变址 + 存储器间接
判断对错
源码写 >,机器码里的跳转也是”大于”类❌(取反,跳的是 jle)
Jcc 会修改标志位❌(只读)
Jcc 必须紧跟 cmp❌
有符号比较用 ja/jb❌(那是无符号;有符号用 jg/jl)
跳转表比级联比较永远更好❌(case 稀疏时表太大)
跳转表检查越界要用有符号比较❌(用无符号 ja 才能一次挡两侧)
cmov 可以优化 p ? *p : 0❌(会无条件解引用)
cmov 不会造成分支预测失败✅

考点

  • cmp 产生标志位 → PSW → Jcc 消费,是所有选择语句的骨架
  • g/l 是有符号,a/b 是无符号;同一条 cmp 支持两种解读
  • 编译器把条件取反,让 then 分支 fall through——读汇编时的第一反应
  • switch 两种实现:密集用跳转表 ,稀疏用级联比较
  • 跳转表三步:减下界 → 无符号 ja 挡越界 → 变址+存储器间接跳转
  • ja 能一次挡住上下两侧越界,因为负数减法后变成大无符号数
  • Jcc 只读标志位,中间可插入 mov/lea
  • cmov 消除分支,但两个分支都会被求值,有副作用或可能出错时不可用
  • jmp [...] 的方括号表示访存,lea [...] 的方括号不表示访存

链接