徹底理解 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,以及每條控制邊上可能的輸入範圍。