本节摘要:控制流的全部秘密藏在一组一位开关里:算术指令写标志位,条件跳转读标志位,两者隔空配合完成 if 与循环。本节拆开六个核心标志位的语义,给出条件跳转与标志位的配对表,并演示如何从反汇编里反编译出高级语言的控制结构。读完你能看到 jcc 不再查手册,而是直接说出"它在判断什么"。
先问一个傻问题:CPU 执行到 jle(小于等于则跳)时,它怎么知道"小于等于"?它并没有把前后两条指令的历史记录在案,答案是一个精巧的分工设计——RFLAGS 寄存器里住着一批一位开关,减法指令 cmp 做完比较顺手拨动开关,紧接着的条件跳转只看开关不看数据。写与读分离,判断与转移解耦,这是 x86 控制流的全部机关。
这个设计有个后果必须内化:标志位是易失的。任何夹在 cmp 与 jcc 之间的算术指令都会重写标志,判断依据随写随毁。为什么高级语言里 if (a < b) 翻译成汇编总是 cmp 紧贴着 jcc,中间插不进别的指令——不是编译器审美,是硬件契约。理解了这一点,读优化后的反汇编(指令重排常常把 cmp 和 jcc 推得很近)就有了锚点。
RFLAGS 里大部分位是系统保留,日常打交道的是六个。按用途分三组:结果特征组(ZF 零、SF 符号、PF 奇偶)描述最近一次运算结果的"长相";进位组(CF 无符号进借位、OF 有符号溢出)报告"结果是否溢出了语义边界";控制组(DF 方向标志)管串操作的推进方向,平时保持清零。
| 标志 | 含义 | 谁写 | 典型读者 |
|---|---|---|---|
| ZF | 结果为零 | add sub cmp test and | je / jne |
| SF | 结果最高位为 1(负) | 算术与逻辑指令 | js / jns |
| CF | 无符号进位或借位 | 加减法 | jb / jae / adc / sbb |
| OF | 有符号溢出 | 加减法 | jo / jno |
| PF | 结果低八位偶数个 1 | 算术与逻辑指令 | jp / jnp(少见) |
| DF | 串操作方向 | std / cld 显式设置 | movs 系列串指令 |
表里最有价值的一行是 CF 与 OF 的对照:同一个减法,无符号解释看 CF,有符号解释看 OF。这就是为什么条件跳转必须成对记忆——jae(高于等于,无符号)与 jge(大于等于,有符号)读的是不同标志,语义不可互换。C 代码里 unsigned 与 int 的比较行为差异,落到汇编就是这对跳转的选择差异。
⚠️ 常见坑:用 add 的结果判断溢出时选错标志。零加正数溢出在无符号眼里是"进位了"(CF=1),在有符号眼里是"溢出了"(OF=1),两套判据对同一次运算可能给出相反结论。判据选择的背后永远是数据类型的符号性,读码时先确认变量类型再看跳转。
条件跳转的家族(jcc)按"写它的算术指令类型"配成对子。上表之外,真正需要肌肉记忆的是这张配对表:
| 判断语义 | 无符号跳转 | 有符号跳转 | 依赖标志 |
|---|---|---|---|
| 相等 | je | je | ZF=1 |
| 不等 | jne | jne | ZF=0 |
| 小于 | jb(below) | jl(less) | CF 或 SF 与 OF 异号 |
| 小于等于 | jbe | jle | 上者或 ZF |
| 大于 | ja | jg | CF=0 且 ZF=0(有符号再加 OF 同号) |
纸上谈兵到此,来一次完整的控制流还原。下面是一个编译器产物片段(Intel 语法,去地址噪音),先别看答案:
test rdi, rdi jle .Lreturn_zero cmp rdi, 100 jg .Lreturn_zero mov eax, 1 ret .Lreturn_zero: xor eax, eax ret
逐对拆解。第一对:test rdi, rdi 是"自己与自己做按位与"——结果就是原值,唯一作用是设置标志(清进位溢出、按值置零与符号标志),等价于"零值探测但不产生减法";jle 是有符号小于等于,说明变量按有符号解释。第二对:cmp rdi, 100 配 jg(有符号大于)。两条判断合起来:rdi 必须落在 0 到 100 这个闭区间里才返回 1。还原成 C 就是一行 return (n >= 0 && n <= 100) ? 1 : 0;(更准确说是 n > 0 && n <= 100,因为 test 之后 jle 判断的是"小于等于零即跳"——正数才继续)。这个"见 test 配 jz 判零、见 cmp 配 jcc 判范围"的模式,是反汇编还原 if 结构的最高频组合。

单次判断会看了,复合结构就是组合题。循环的识别套路:先找反向跳转(目标地址在当前指令之前)——这是循环体的身份证;循环头附近的 cmp 加条件跳转构成出口判断,dec 加 jnz 组合是经典的倒计数循环(GCC 优化后常变形成 sub rax, 1; jne 或干脆用 flags 传递)。while 与 for 在 -O2 下几乎总被组织成"先判断后执行"的形状(guard 条件提到循环体前),这与 C 语义的差别属于编译器的合法变换范围。
函数级控制流是 call 与 ret 的天下,但它们与 jmp 有一条本质区别:call 压返回地址进栈、ret 弹出它,形成配对的"去与回";jmp 则不欠栈任何东西。这个区别在 4.2 节栈帧解剖里是主角,这里先立一个观察口径:反汇编里数 call 与 ret 是否配平,是快速判断函数边界与栈健康度的土办法。另一个出镜率极高的成员是 setcc 指令——把条件判断的结果(1 或 0)写进八位寄存器,它是布尔表达式与三元运算符翻译成无分支代码的常用零件。
💡 关键直觉:控制流还原不是逐条指令翻译,而是模式匹配。cmp 加 jcc 对、test 加 jz 对、dec 加 jnz 循环、call 加 ret 函数——高频模式十来个,占真实代码的九成。剩下的生僻组合(比如 setcc 配 addz 做布尔运算)遇到再查,不必预先全备。
指令的民用部分到此清点完毕。最后一节去围墙边上走一走:ring 0 的特权指令守着什么、用户程序怎么合法地"借力"。