彻底理解 x86-64 标志位与条件分支
CMP 与 TEST、有符号与无符号条件,以及无分支代码的取舍
彻底理解 x86-64 标志位与条件分支
本博客文章以教学为目的。 示例采用 Intel 语法;性能必须在目标 CPU 与真实输入分布上测量。
cmp eax, edx 没有保存结果,后面的 jl 为何知道谁更小?同一组位 0xFFFFFFFF 为何有时是 -1,有时是 4,294,967,295?关键在 RFLAGS,以及消费标志时采用 signed 还是 unsigned 条件。
常见状态标志
| 标志 | 意义 |
|---|---|
ZF | 结果为 0 |
CF | unsigned carry/borrow |
SF | 结果最高位 |
OF | signed 结果超出范围 |
PF | 结果低字节 parity |
普通条件分支主要组合 ZF、CF、SF、OF。
cmp 与 test
cmp rax, rdx
概念上计算 rax-rdx,丢弃数值,只更新标志。
cmp eax, 10
je equal_ten
jl signed_less_than_ten
分支并不是语法上绑定最近的 cmp。中间若有会改标志的指令,分支就读取新的标志。
cmp eax, 10
add ecx, 1
jl somewhere ; 读取 add 的标志
test 像丢弃结果的 and,常用于零值与 bit mask 检查。
test rax, rax
jz is_zero
test eax, 1
jnz is_odd
相等与大小关系
相等不分 signedness:je/jz 看 ZF=1,jne/jnz 看 ZF=0。大小关系则必须区分。
| 意义 | signed | unsigned |
|---|---|---|
| left < right | jl | jb |
| left <= right | jle | jbe |
| left > right | jg | ja |
| left >= right | jge | jae |
mov eax, 0FFFFFFFFh
cmp eax, 1
jl signed_less ; -1 < 1,成立
jb unsigned_below ; 4294967295 < 1,不成立
寄存器没有 signed 类型标签;差别在于如何解释同一组比较标志。
CF 与 OF 是不同溢出
8 位 0xFF+1=0x00 有 unsigned carry,但 signed 的 -1+1 正常。0x7F+1=0x80 对 unsigned 是 128,对 signed 则超出 127。
add al, 1
jc unsigned_overflow
jo signed_overflow
C/C++ unsigned 算术定义为 modulo;signed overflow 是未定义行为。CPU 提供 OF 并不会替语言定义它。
inc、dec 保留 CF,所以和 add 1、sub 1 并非所有标志都相同。
setcc 与 cmovcc
xor eax, eax
cmp edi, esi
setl al
ret
把 signed edi < esi 转成 0 或 1。setcc 只写一个字节,因此要明确清理高位。
cmp eax, edx
cmovl eax, edx
条件移动不分割控制流,但可能拉长 dependency chain,并不保证比 branch 快。
branchless 不等于自动更快
现代 CPU 会预测分支。高度可预测的 jcc 很便宜;输入不规则且两条路径都很短时,setcc、cmovcc、mask 可能有利。必须根据真实分支分布、misprediction、两侧工作量、增加的指令与依赖、编译器输出,在目标 CPU 测量。
循环与短路条件
xor eax, eax
xor edx, edx
loop_test:
cmp rdx, rsi
jae loop_done
add eax, [rdi + rdx*4]
inc rdx
jmp loop_test
loop_done:
ret
jae 是 unsigned i >= count。优化可能用 rotation、unrolling、vectorization 改变形状。
test rcx, rcx
jz done
cmp byte ptr [rcx + READY_OFFSET], 0
je done
call use
done:
这是 p != nullptr && p->ready 的可能形式。第一个分支避免 null dereference,顺序本身就是安全契约。
jump table
sub eax, 3
cmp eax, 4
ja default_case
jmp qword ptr [table + rax*8]
先正规化 case,再用 unsigned 范围检查限制 table index,最后间接跳转。范围检查也是重要的安全边界。
函数调用不保存普通条件标志
cmp eax, edx
call helper
jl old_result ; helper 可能已改变标志
需要结果时,应在调用前转成 0/1,或调用后重新比较。
总结
cmp 与 test 不保存计算结果,只建立标志。相等看 ZF;大小必须区分 signed 的 SF/OF 与 unsigned 的 CF/ZF。不要只找最近的 cmp,而要追踪最后定义标志的指令、条件 signedness,以及每条控制边上的可能输入范围。