整數(shù)與無分支編程)
寫代碼這么多年我見過太多人在 x86 匯編里栽在同一個地方明明CMP指令已經(jīng)把兩個數(shù)比完了標志位也擺在那里結(jié)果想拿這個比較結(jié)果做邏輯運算時卻只能笨拙地跳轉(zhuǎn)、分支、再跳回來。直到你把SETcc這一族條件置位指令弄明白才會意識到——原來標志位可以直接變成 0 或 1 的整數(shù)根本不需要打斷控制流。這篇內(nèi)容適合正在讀匯編、寫編譯器后端、做逆向分析、或者只是好奇xor eax, eax; cmp ...; setl al為什么天天出現(xiàn)在反匯編窗口里的朋友。我會從 EFLAGS 標志位的基本功講起把無符號和有符號比較的判定邏輯拆開再帶上幾個能直接抄的實戰(zhàn)代碼和經(jīng)典踩坑現(xiàn)場。1. 為什么需要 SETxx標志位是“狀態(tài)”不是“數(shù)值”1.1 CMP/TEST 只改標志不寫結(jié)果先看一個基礎問題CMP EAX, EBX執(zhí)行完以后EAX 和 EBX 的值變了嗎沒有。CMP本質(zhì)上就是做一次減法EAX - EBX但減法結(jié)果直接被丟棄唯一留下的痕跡就是 EFLAGS 寄存器里那幾位標志位。TEST同理它做按位與也丟棄結(jié)果只更新標志。這就是 x86 的一個設計特點比較操作把“結(jié)論”存在了狀態(tài)寄存器里而不是放在通用寄存器里。狀態(tài)標志是隱式的、瞬時的你沒法直接把它賦值給某個 C 語言變量。所以當你寫下int r (a b);這行 C 代碼時編譯器必須想辦法把標志位翻譯成一個真正的整數(shù) 0 或 1。最直接的做法是跳轉(zhuǎn):cmp edi, esi jge .Lfalse mov eax, 1 ret .Lfalse: xor eax, eax ret這當然可行但它把線性代碼切成了兩個基本塊。如果這個比較結(jié)果要參與后續(xù)算術(shù)運算而分支又很難預測流水線會被打亂。1.2 從 Jcc 到 SETcc控制流與數(shù)據(jù)流的轉(zhuǎn)換SETcc的作用是把標志位的狀態(tài)“捕獲”成一個字節(jié)。它本身不做任何跳轉(zhuǎn)不改變執(zhí)行順序只是根據(jù)條件碼決定目標字節(jié)是寫成 1 還是 0。這就把一個控制流問題轉(zhuǎn)變成了純粹的數(shù)據(jù)流問題布爾值終于可以像普通整數(shù)一樣參與加減、移位、掩碼運算。x86 里的條件碼在跳轉(zhuǎn)指令和置位指令之間是高度統(tǒng)一的。JE/JZ和SETE/SETZ關(guān)心的都是 ZFJB/SETB關(guān)心的都是 CFJGE/SETGE關(guān)心的都是 SF 與 OF 的關(guān)系。區(qū)別只在于Jcc 改變 EIPSETcc 改變目標字節(jié)。1.3 編譯器眼中的布爾值無處不在的 setcc 模式如果你用 objdump 看過 GCC 或 Clang 開啟優(yōu)化后的輸出一定經(jīng)常遇到下面這種三連xor eax, eax cmp edi, esi setl al三行指令完成了一整個“無分支比較返回布爾值”。第一行先把 EAX 清零保證 AL 之外的高位是干凈的第二行做比較設置標志位第三行setl al根據(jù)“有符號小于”的條件把 AL 置成 1 或保持 0。這樣生成的代碼沒有跳轉(zhuǎn)、沒有分支預測失敗的風險所有 CPU 前端流水線都保持線性執(zhí)行在比較結(jié)果不可預測的場景下遠比跳轉(zhuǎn)方案穩(wěn)定。理解 SETcc本質(zhì)上就是理解編譯器為什么這么喜歡它。2. EFLAGS 標志位速查先讀懂 ZF、CF、SF、OF在討論SETcc之前必須把 EFLAGS 里那幾個關(guān)鍵位徹底搞清楚。很多人背指令表時只知道“SETL 是有符號小于”但遇到實際數(shù)據(jù)就懵了因為他不清楚 SF 和 OF 為什么要一起看。2.1 狀態(tài)寄存器里的 8 個關(guān)鍵位x86 的狀態(tài)標志位分布在 EFLAGS/RFLAGS 中真正影響 SETcc 條件判斷的主要有以下這些標志位名稱含義典型觸發(fā)場景CF進位標志無符號運算產(chǎn)生進位或借位移位時移出的位ADD最高位進位、CMP無符號借位PF奇偶標志結(jié)果低 8 位中 1 的個數(shù)為偶數(shù)時為 1很少用主要用于串口/BCD校驗ZF零標志結(jié)果為零時為 1CMP兩數(shù)相等、TEST為零SF符號標志結(jié)果的最高位為 1 時為 1有符號視角為負算術(shù)結(jié)果最高位為 1OF溢出標志有符號運算結(jié)果超出可表示范圍時為 1正加正得負、負減正得正等還有 AF 輔助進位標志主要給 BCD 指令用日常SETcc幾乎碰不到可以先略過。2.2 CF 與 OF無符號和有符號的“溢出”不是一回事這是最容易混淆的一對我見過很多人在這個地方翻車。CF 站在無符號視角。8 位無符號數(shù)的范圍是 0 到 255當0xFF 0x01時結(jié)果變成 0x100超出了 8 位能容納的上限那個多出來的 1 就被放進 CF。所以ADD之后 CF1 代表無符號加法有進位CMP之后 CF1 代表無符號減法的被減數(shù)小于減數(shù)發(fā)生了借位。OF 站在有符號視角。8 位有符號數(shù)的范圍是 -128 到 127當0x7F 0x01時按數(shù)學期望是 128但 128 超出了有符號范圍結(jié)果被解釋成 -128符號完全反了這時候 OF1。反過來兩個負數(shù)相加得到正數(shù)也一樣會把 OF 置 1。舉個具體例子你就明白了。設AL 0x80BL 0x01執(zhí)行CMP AL, BL無符號視角0x80 是 128128 - 1 127沒有借位CF0。有符號視角0x80 是 -128(-128) - 1 -129超過了 8 位有符號范圍結(jié)果截斷后變成 127符號翻轉(zhuǎn)所以 OF1。同一個減法CF 和 OF 給出完全不同的結(jié)論這不是矛盾而是它們分別屬于兩套解釋體系。2.3 TEST 與 CMP 到底改了什么TEST執(zhí)行按位與所以它永遠不產(chǎn)生進位執(zhí)行后 CF 和 OF 一定被清 0SF 反映按位與的最高位結(jié)果ZF 反映按位與結(jié)果是否為零PF 反映結(jié)果的奇偶性。CMP執(zhí)行減法CF、OF、SF、ZF、PF 都會被合理更新。記住一個常識普通MOV指令不影響標志位LEA也不影響但絕大多數(shù)算術(shù)邏輯指令ADD、SUB、AND、OR、XOR、INC、DEC、SHL等都會影響。這意味著你不能在CMP之后隨便插入一條算術(shù)指令否則之前的比較結(jié)果就作廢了。這一點在后面避坑部分還會展開。3. SETxx 完整指令家族條件碼分組與判定邏輯3.1 按標志位直接取值Z/S/O/P 四組有些SETcc指令非常直觀它們只讀取某一個標志位邏輯上就是“把這一個布爾值搬運到字節(jié)里”。SETE / SETZZF1 時為 1。常用于判斷相等、判斷結(jié)果為零。SETNE / SETNZZF0 時為 1是SETE的反義。SETSSF1 時為 1即結(jié)果為負。SETNSSF0 時為 1。SETOOF1 時為 1即有符號溢出。SETNOOF0 時為 1。SETP / SETPEPF1 時為 1低 8 位 1 的個數(shù)為偶數(shù)。SETNP / SETPOPF0 時為 1。這四組沒什么需要推導的地方讀標志位、寫目標字節(jié)僅此而已。真正需要動腦筋的是下面兩組比較類條件碼。3.2 無符號比較組CF 告訴你“誰大誰小”無符號數(shù)沒有符號位大小關(guān)系完全由借位情況和相等情況決定。CMP A, B執(zhí)行后A BA 減 B 會產(chǎn)生借位CF1。A B沒有借位CF0。A B差值為 0ZF1。A B要么相等要么產(chǎn)生了借位即 ZF1 或 CF1。A B既不相等也沒有借位即 ZF0 且 CF0。對應到指令表就是下面這批指令判定條件含義SETB / SETC / SETNAECF1無符號低于 (Below)SETAE / SETNB / SETNCCF0無符號高于等于 (Above or Equal)SETBE / SETNACF1 或 ZF1無符號低于等于SETA / SETNBECF0 且 ZF0無符號高于記憶技巧B 是 BelowA 是 Above。Below 就是“在下面”對應小于Above 就是“在上面”對應大于。帶 E 的是加上 Equal等于。3.3 有符號比較組SF 和 OF 的組合判定有符號數(shù)的大小關(guān)系不能只看 CF因為 CF 只反映無符號的借位。比如0x80和0x01做減法CF0 表示無符號視角下 128 大于 1但站在有符號視角0x80是 -128它明明小于 1。所以有符號比較必須繞道 SF 和 OF。核心結(jié)論是CMP A, B之后若SF ! OF則 A 有符號小于 B。為什么當 OF0 時減法沒有發(fā)生有符號溢出結(jié)果符號就是真實符號。如果 SF1說明差為負即 A B如果 SF0說明差非負即 A B。當 OF1 時減法發(fā)生了溢出結(jié)果符號被“翻轉(zhuǎn)”了。也就是說真實差值為負但結(jié)果看起來為正或者真實差值為正但結(jié)果看起來為負。此時判斷 A B 的條件反而變成 SF0。無論哪種情況A B都等價于SF ! OF。這是一條非常優(yōu)美的規(guī)律也解釋了為什么SETL的條件碼視圖是“SF 異或 OF”的風格。完整的有符號比較組如下指令判定條件含義SETL / SETNGESF ! OF有符號小于 (Less)SETGE / SETNLSF OF有符號大于等于 (Greater or Equal)SETLE / SETNGZF1 或 SF ! OF有符號小于等于SETG / SETNLEZF0 且 SF OF有符號大于如果你把這兩張表對比看會發(fā)現(xiàn)無符號和有符號只是把 CF 換成了 SF/OF 的組合其他結(jié)構(gòu)完全對稱。3.4 與 Jcc 的對照表同一套條件碼兩種用途SETcc和Jcc共享同一套條件碼定義。我平時查指令時最常用的就是這張對照表SETccJcc條件含義SETE / SETZJE / JZZF1相等 / 結(jié)果為零SETNE / SETNZJNE / JNZZF0不相等SETBJB / JCCF1無符號小于SETAEJAE / JNCCF0無符號大于等于SETBEJBECF1 或 ZF1無符號小于等于SETAJACF0 且 ZF0無符號大于SETLJLSF ! OF有符號小于SETGEJGESF OF有符號大于等于SETLEJLEZF1 或 SF ! OF有符號小于等于SETGJGZF0 且 SF OF有符號大于SETSJSSF1結(jié)果為負SETOJOOF1有符號溢出SETPJPPF1低8位偶校驗看完這張表你會發(fā)現(xiàn)只要你學過 JccSETcc 的語義就已經(jīng)會了一半剩下的就是習慣“把條件寫到字節(jié)里”這個動作。補充一個編碼層面的細節(jié)機器碼層面SETcc的 Opcode 是0F 90到0F 9F條件后綴直接編碼在低位順序和條件碼編號完全一致。如果你在做反匯編器或者手工看二進制發(fā)現(xiàn)0F 9D就是SETGE / SETNL0F 9C就是SETL / SETNGE不需要每次都翻手冊。4. 實戰(zhàn)從 C 語言條件到 SETcc 匯編的完整鏈路4.1 最簡單的例子return a b假設你在 x86-64 Linux 環(huán)境下有這樣一個函數(shù)int is_less(int a, int b) { return a b; }GCC 開啟優(yōu)化后可能生成如下代碼System V 調(diào)用約定下a 在 edib 在 esi返回值放 eaxis_less: xor eax, eax ; 把 eax 清零順便保證高位干凈 cmp edi, esi ; 計算 edi - esi修改標志位 setl al ; al (SF ! OF) ? 1 : 0 ret注意xor eax, eax必須放在cmp之前因為xor本身會改變標志位。如果把xor放在cmp之后等于把剛算好的 ZF、SF、OF 全部清掉了setl al會依賴錯誤的標志狀態(tài)。編譯器這樣編排不是隨意的。同樣如果改成return a b;末尾那條指令會變成setle al改成return a b;則會變成sete al。這套對應關(guān)系練習幾遍就熟了。4.2 把標志位“存起來”進位捕獲與多精度算術(shù)SETcc最有價值的功能之一是把標志位轉(zhuǎn)成整數(shù)保存下來供后續(xù)邏輯使用。典型場景是 32 位平臺上做 64 位加法。低 32 位相加后產(chǎn)生的進位在 CF 里但你不能直接拿 CF 去參與 C 語言邏輯于是先把它抓出來mov eax, [a_low] add eax, [b_low] ; eax 低32位之和CF 是否產(chǎn)生進位 mov ecx, 0 setc cl ; ecx 0 或 1保存進位 mov edx, [a_high] adc edx, [b_high] ; edx 高32位之和 CF這里setc cl扮演了“快照”角色。雖然下面的adc也會讀 CF但如果你需要同時保留進位給后續(xù)數(shù)組、或者要返回進位標記先把 CF 變成整數(shù)是必要的。相比之下LAHF雖然也能把標志位送回 AH但位的位置很別扭不如setc直觀。4.3 branchless 編程用 setcc 構(gòu)造掩碼實現(xiàn)無分支最大值SETcc生成的是 0 或 1而 0 或 1 本身不是很好用的掩碼。這時候配合NEG把它變成全 0 或全 1就能派上大用場??匆粋€無分支求最大值的例子; int max(int a, int b)a 在 edib 在 esi xor eax, eax cmp edi, esi setg al ; al (edi esi) ? 1 : 0 neg eax ; eax 0 或 0xFFFFFFFF mov ecx, eax and ecx, edi ; 如果 edi esi保留 edi否則清零 not eax and eax, esi ; 如果 edi esi清零否則保留 esi or eax, ecx ; 合并得到 max ret整個流程沒有一條條件跳轉(zhuǎn)指令。setg把比較結(jié)果變成 1neg把它擴展成全 1 掩碼隨后兩個and分別從兩個源里各取所需最后or拼接。這類代碼在哈希表探測、狀態(tài)機索引、飽和度運算里非常常見。編譯器在遇到一些布爾表達式時也會自己生成類似的掩碼邏輯。如果你想要的是“選一個”而不是“算最大值”也可以換用CMOVcc但CMOVcc適合在兩個現(xiàn)成值之間選擇SETcc 掩碼的組合適合把比較結(jié)果本身當作數(shù)值參與運算兩者分工不同。5. 五個高頻踩坑現(xiàn)場SETcc 使用的邊界與陷阱5.1 坑一只寫低 8 位留下的高位殘留SETcc的目標操作數(shù)只能是 8 位寄存器或字節(jié)內(nèi)存這是指令格式的硬性限制。很多人寫完setl al以為整個 EAX 變成了 0 或 1其實 AL 之外的位原封不動??催@個錯誤示范mov eax, 0x12345678 cmp edi, esi setl al ; 此時 eax 0x123456??并不是 0x00000001修復方法有兩種。第一種是提前清零xor eax, eax cmp edi, esi setl al第二種是事后擴展cmp edi, esi setl al movzx eax, al兩種都正確但第一種在部分舊微架構(gòu)上更友好因為它從根源上避免了“先寫部分寄存器、再讀完整寄存器”的依賴問題。編譯器輸出里兩種風格都存在遇到哪個都別意外。5.2 坑二CMP 和 SETcc 之間插入了“不干凈”的指令標志位是瞬時的很多指令會無意中修改它。看這段代碼cmp edi, esi inc ecx ; 這里破壞了 ZF、SF、OF雖然它不改 CF setl al ; 此時比較結(jié)果已經(jīng)丟了就算用的是一條看起來和比較無關(guān)的指令只要它屬于算術(shù)邏輯類就可能覆蓋標志位。有一批指令不影響標志位比如MOV、LEA、PUSH、POP、JMP但ADD、SUB、INC、DEC、AND、OR、XOR都會改寫標志。別以為INC和DEC不影響 CF 就安全它們對 ZF、SF、OF 的影響足以毀掉一次SETL判斷。最穩(wěn)妥的習慣是讓CMP/TEST和SETcc緊挨著中間不插入任何可能影響標志的指令。如果必須在兩者之間處理數(shù)據(jù)先把標志位保存出去再用。5.3 坑三無符號和有符號條件混用同一個比較無符號和有符號可能得到完全相反的結(jié)論。設eax 0x80000000ebx 0x00000001執(zhí)行cmp eax, ebxsetb al無符號視角下2147483648 1沒有借位CF0所以 AL0。setl al有符號視角下-2147483648 1溢出使得 SF0、OF1SF ! OF所以 AL1。你寫代碼前必須明確數(shù)據(jù)語義這兩個數(shù)到底該按無符號解釋還是有符號解釋數(shù)組下標、地址運算、位掩碼通常按無符號業(yè)務邏輯里的整數(shù)比較通常按有符號。混用條件碼是逆向上經(jīng)??吹降倪壿嫹囱菰?。5.4 坑四操作數(shù)順序?qū)Ρ容^結(jié)果的影響CMP A, B計算的是 A 減 B它表達的含義是“A 與 B 比較”。如果你要判斷if (x y)應該寫cmp x, y; setl al表示“x 小于 y 為真”。如果寫反了變成cmp y, x; setl al得到的其實是y x也就是x y。這種錯誤在簡單場景下通常肉眼能看出來但在復雜表達式拆分后很容易被忽略。一個通用口訣CMP 第一操作數(shù), 第二操作數(shù)條件判斷的語義就是“第一操作數(shù) 條件 第二操作數(shù)”。cmp x, y; jg等價于if (x y)和SETcc完全一致。5.5 坑五舊微架構(gòu)上的部分寄存器停頓從 Pentium 時代到較早的 Core 架構(gòu)x86 CPU 在“寫 8 位寄存器 AL然后讀完整寄存器 EAX”時可能會產(chǎn)生額外停頓因為它無法高效跟蹤 AL 與 EAX 高位的合并關(guān)系。這類問題被稱為部分寄存器停頓partial register stall?,F(xiàn)代 CPU 大多通過寄存器重命名緩解了這個問題但在兼容性和低功耗微架構(gòu)上它仍然可能咬你一口。我寫高性能代碼的習慣仍然是要么xor eax, eax提前清要么movzx eax, al顯式擴展。這樣寫不僅安全而且語義一目了然。6. 性能與替代方案用 SETcc 寫出更快的分支消除代碼6.1 SETcc 在現(xiàn)代 CPU 上的指令成本在較新的主流微架構(gòu)上絕大多數(shù)SETcc指令都被解碼為單個 μop延遲大約 1 到 2 個周期吞吐非??捎^。加上前面的CMP和后面的MOVZX整個“比較并把結(jié)果轉(zhuǎn)成整數(shù)”的序列大概只有三到四條指令流水線完全線性沒有任何分支預測器參與。對比傳統(tǒng)分支版本一條Jcc如果預測正確開銷很小但如果預測失敗代價往往是 15 到 20 個周期以上的流水線清空。所以當比較結(jié)果接近五五開、無法預測時SETcc的無分支方案幾乎總是更優(yōu)。這也是編譯器傾向于把它用在“返回布爾值”函數(shù)里的原因。6.2 寄存器清零的兩種常見姿勢先 XOR 還是后 MOVZX前面提到過把 AL 擴展成完整 EAX 有兩種常見寫法; 姿勢 A先清后置 xor eax, eax cmp edi, esi setl al ; 姿勢 B后擴展 cmp edi, esi setl al movzx eax, al兩者的指令數(shù)差不多。姿勢 A 在寄存器重命名資源上更省因為 CPU 從一開始就認為 EAX 是一個完整定義姿勢 B 的結(jié)構(gòu)更清晰適合在已經(jīng)有現(xiàn)成寄存器值時使用。我建議跟隨編譯器慣例簡單的布爾函數(shù)優(yōu)先用姿勢 A需要保留舊值時用姿勢 B。不要在cmp之后插xor那會破壞標志位。6.3 從 SETcc 到 SBB 技巧另一種把標志位傳播為整數(shù)的方法如果你看過老手寫的極致短代碼可能撞見過利用SBB傳播 CF 的方法。比如把無符號比較a b轉(zhuǎn)成 0/1xor eax, eax cmp edi, esi sbb eax, eax ; CF1 時 eax -1CF0 時 eax 0 inc eax ; CF1 時 eax 0CF0 時 eax 1原理是SBB計算dst dst - src - CF。當src是同一個寄存器時結(jié)果就是-CFCF1 得到 0xFFFFFFFFCF0 得到 0。這個技巧可以生成 32 位掩碼避免setcc movzx的兩步操作。但我不建議在日常代碼里刻意賣弄它?,F(xiàn)代編譯器基本不會用這種寫法因為setae已經(jīng)足夠快而且語義更直白。了解它的意義在于讓你理解“CF 可以被算術(shù)指令消費”這件事這對調(diào)試和分析手寫匯編很有幫助。6.4 什么情況下不該用 SETcc分支預測仍有價值分支消除聽起來很美好但它不是銀彈。如果分支具有極強的偏向性比如循環(huán)里 99% 的情況都不滿足條件現(xiàn)代分支預測器的正確率能輕松超過 99%此時一個預測正確的條件跳轉(zhuǎn)幾乎不花時間。強行改成SETcc反而多算了多條算術(shù)指令白白增加指令數(shù)。我在優(yōu)化代碼時會先問自己這個條件的分布是怎樣的如果是隨機數(shù)據(jù)、哈希值、外部輸入SETcc無分支是穩(wěn)妥選擇如果是邊界檢查、特殊錯誤分支、循環(huán)結(jié)束判斷保留分支通常更高效。不要拿著setcc到處替換所有分支性能決策要靠數(shù)據(jù)說話不是靠信仰。有經(jīng)驗的工程師拿到一段陌生匯編時往往先掃一眼有沒有setcc出現(xiàn)——它意味著這段代碼在把比較結(jié)果當作數(shù)值使用。反過來如果你是自己寫匯編掌握好SETcc的語義和邊界你的代碼風格會干凈一個檔次也更容易被編譯器優(yōu)化到同一水平。我個人的習慣是凡是需要布爾結(jié)果的地方先寫無分支的setcc版本再用實際數(shù)據(jù)跑一輪 profile多數(shù)時候它能贏偶爾輸給預測器但至少你手里多了一把可用的刀。