控制流 (Control Flow)

Overview Table

小節 主題 核心機制 關鍵指令/公式
3.6.1 Condition Codes CPU 以單一位元旗標記錄最近一次算術/邏輯運算屬性 CF ZF SF OF
3.6.2 存取 Condition Codes set 指令把旗標組合寫成 0/1 單一位元組 setl D ← SF ^ OF
3.6.3 Jump 指令 條件/無條件跳轉改變執行順序 jmpjejgja
3.6.4 Jump 編碼 PC-relative:目標 = 下一指令位址 + 偏移量 1/2/4-byte offset
3.6.5 條件分支(控制轉移) if-else → 條件跳轉 + goto code cmp + jXX
3.6.6 條件分支(資料轉移) 兩邊都算,再用 conditional move 選結果 cmovXX S, R;TMP=2(TranTOK)
3.6.7 Loops do-while / while / for 全部化約為條件測試 + 跳轉 jump-to-middle vs guarded-do
3.6.8 Switch jump table 讓多路分支時間與 case 數無關 jmp *.L4(,%rsi,8)

機器碼實作條件行為的兩種低階機制 (p.236):測試資料值後改變控制流 (control flow)改變資料流 (data flow)。前者較通用常見,後者在受限情況下更符合現代處理器特性。

3.6.1 Condition Codes(條件碼)(p.237)

CPU 在整數暫存器之外,另維護一組單一位元的 condition code registers,描述最近一次算術或邏輯運算的屬性,供後續條件分支測試:

旗標 名稱 意義 t = a + b 為例的設定條件
CF Carry flag 最高有效位產生進位/借位 → 偵測 unsigned overflow (unsigned) t < (unsigned) a
ZF Zero flag 結果為零 (t == 0)
SF Sign flag 結果為負 (t < 0)
OF Overflow flag 二補數溢位(正或負溢位)→ 偵測 signed overflow (a < 0 == b < 0) && (t < 0 != a < 0)

各類指令對旗標的影響(必背例外):

兩類「只設旗標、不寫入目的地」的指令 (Figure 3.13, p.238),各有 b/w/l/q 四種尺寸:

指令 依據 等價行為
cmp S1, S2 S2 - S1 sub 但不更新目的地
test S1, S2 S1 & S2 and 但不更新目的地
Tip

test 常見用法:同一運算元重複,如 testq %rax, %rax 一次判斷 %rax 為負/零/正;或以其中一運算元當 mask 測試特定位元。

Warning

ATT 格式的 cmpq %rsi, %rdi 運算元順序「反著讀」:旗標依 S2 − S1%rdi − %rsi 設定。判斷 x < y(x 在 %rdi、y 在 %rsi)時寫 cmpq %rsi, %rdi 後接 jl/setl

3.6.2 存取 Condition Codes(p.238–241)

不直接讀旗標,而是三種使用方式:(1) 依旗標組合把單一位元組設為 0/1(set 指令);(2) 條件跳轉;(3) 條件資料轉移(cmov)。

set 指令 (Figure 3.14):目的地為單一位元組暫存器(如 %al)或 1-byte 記憶體。後綴表示「條件」而非運算元大小(setl = set less,不是 set long)。要得到 32/64-bit 結果,必須再清高位,典型序列:

    int comp(long a, long b)   // return a < b
    cmpq   %rsi, %rdi     ; Compare a:b
    setl   %al            ; %al = (a < b)
    movzbl %al, %eax      ; 清 %eax 其餘 3 bytes(且連 %rax 上半 4 bytes 一併清零)
    ret

各條件的旗標組合公式(set/jXX/cmovXX 三族共用,設 t = a - b):

條件 指令後綴 公式 適用
相等 / 為零 e (z) ZF 通用
不等 ne (nz) ~ZF 通用
為負 s SF 通用
非負 ns ~SF 通用
大於 > g (nle) ~(SF ^ OF) & ~ZF signed
大於等於 >= ge (nl) ~(SF ^ OF) signed
小於 < l (nge) SF ^ OF signed
小於等於 <= le (ng) (SF ^ OF) | ZF signed
高於 > a (nbe) ~CF & ~ZF unsigned
高於等於 >= ae (nb) ~CF unsigned
低於 < b (nae) CF unsigned
低於等於 <= be (na) CF | ZF unsigned

為何 setlSF ^ OF (p.240):無溢位 (OF=0) 時 a<b ⟺ a−b<0 ⟺ SF=1;負溢位時 a<b 但結果變正 (SF=0, OF=1);正溢位時 a>b 但結果變負 (SF=1, OF=1)。兩位元的互斥或恰在所有情況給出正確的 a<b 判定。Unsigned 比較則靠 cmp 在 a−b<0(借位)時設定的 CF 與 ZF 組合。

3.6.3 Jump 指令(p.241–243)

jmp 無條件跳轉,分兩種:

條件跳轉 jXX(Figure 3.15)之條件名稱與旗標公式完全對應上表的 set 指令。

Important

條件跳轉只能是 direct(不可間接)。間接跳轉僅限無條件 jmp

3.6.4 Jump 指令編碼(p.243–244)

最常用編碼是 PC-relative:編碼「目標位址 − 跳轉指令下一條指令位址」的差值,可用 1、2、4 bytes;另一種是 4-byte「絕對位址」。組譯器與連結器自動選擇。

target=addr(下一條指令)+offset(二補數編碼)
位址   機器碼        組語               offset 計算
 0:   48 89 f8     mov  %rdi,%rax
 3:   eb 03        jmp  8             0x03 + 0x5(下一指令位址) = 0x8 ──┐
 5:   48 d1 f8     sar  %rax      <───────────────────────────────────┼─┐
 8:   48 85 c0     test %rax,%rax <─────────────────────────────────── ┘ │
 b:   7f f8        jg   5             0xf8(= -8) + 0xd = 0x5 ────────────┘
 d:   f3 c3        repz retq
Tip

rep; ret(反組譯顯示 repz retq)是 AMD 建議的技巧:避免 ret 成為條件跳轉的直接目的地(AMD 處理器對此預測不良)。rep 在此形同 no-op,閱讀本書其餘程式碼時可直接忽略 (p.244)。

3.6.5 用條件控制實作條件分支(p.245–249)

最通用的翻譯法:條件跳轉 + 無條件跳轉的組合。書中以 goto code(用 C 的 goto 模擬組語控制流)呈現。if (test-expr) then-statement else else-statement 的標準模板:

        t = test-expr;
        if (!t) goto false;   <-- 條件「反轉」跳過 then
        then-statement
        goto done;
    false:
        else-statement
    done:

範例 absdiff_se(含副作用:遞增全域計數器 lt_cnt/ge_cnt,p.246):

    cmpq  %rsi, %rdi         ; Compare x:y
    jge   .L2                ; if x >= y goto else 區塊(條件反轉!)
    addq  $1, lt_cnt(%rip)   ; then: lt_cnt++
    movq  %rsi, %rax
    subq  %rdi, %rax         ; result = y - x
    ret
.L2:
    addq  $1, ge_cnt(%rip)   ; else: ge_cnt++
    ...

3.6.6 用 Conditional Move 實作條件分支(p.250–256)

替代策略:條件資料轉移 — 把兩種結果都算出來,再依條件選一個。v = test-expr ? then-expr : else-expr; 的抽象碼:

    v  = then-expr;
    ve = else-expr;
    t  = test-expr;
    if (!t) v = ve;    <-- 這行就是單一條 cmovXX 指令

範例 absdiff(無副作用版,p.250):

    movq   %rsi, %rax
    subq   %rdi, %rax    ; rval = y - x
    movq   %rdi, %rdx
    subq   %rsi, %rdx    ; eval = x - y
    cmpq   %rsi, %rdi    ; Compare x:y
    cmovge %rdx, %rax    ; if x >= y: rval = eval
    ret

為何更快 — pipeline 與 branch prediction (p.251):

 條件跳轉(control transfer):        條件移動(data transfer):
        cmp                              兩路結果都先計算
       /    \                                 |
  branch prediction 猜方向                cmp -> cmovXX 選值
   |- 猜對 -> pipeline 滿載,~T_OK            |
   |- 猜錯 -> 丟棄已做的工作、重填 pipeline   控制流不依賴資料
            罰 15–30 cycles              -> 固定 ~8 cycles,pipeline 恆滿

cmovXX S, R 指令特性 (Figure 3.18, p.253):

Warning

不是所有條件運算式都能用 cmov(p.254):

  1. 副作用或錯誤條件:cmov 版兩邊表達式都會被求值。如 return xp ? *xp : 0; 若編成 cmov,movq (%rdi), %rax 在 xp 為 NULL 時仍會解參考 → null pointer dereference,必須用分支版。Figure 3.16 的計數器副作用也強制 gcc 用條件控制轉移。
  2. 效率未必更好:若 then/else 任一邊計算量大,條件不成立時那份計算全數浪費。gcc 實務上只在兩邊都極簡單(如單一 add 指令)時才用 cmov,甚至在 misprediction 代價更高的許多情況仍保守選分支版。

3.6.7 Loops(p.256–268)

C 的 do-whilewhilefor 在機器碼沒有對應指令,全部由「條件測試 + 跳轉」組合而成;gcc 依兩種基本 loop pattern 產生程式碼。

Do-While(p.256)

loop:
    body-statement
    t = test-expr;
    if (t) goto loop;      <-- body 至少執行一次

範例 fact_do(n!,Figure 3.19):迴圈核心即 imulq %rdi,%rax + subq $1,%rdi + cmpq $1,%rdi + jg .L2——回跳的條件跳轉是實作迴圈的關鍵指令

While — 兩種翻譯策略(p.259–263)

策略 gcc 觸發 手法 特徵
Jump to middle -Og 入口先無條件 goto test 跳到迴圈尾的測試 只有一份 test 程式碼;入口一個 jmp
Guarded do -O1(較高最佳化) 先做初始測試,失敗直接跳過,迴圈本體轉成 do-while 兩份 test;初始測試常可被進一步最佳化掉
 Jump to middle:                Guarded do:
     goto test;                     t = test-expr;
 loop:                              if (!t) goto done;
     body-statement             loop:
 test:                              body-statement
     t = test-expr;                 t = test-expr;
     if (t) goto loop;              if (t) goto loop;
                                done:
Tip

反向工程迴圈的通用策略 (Aside, p.258):觀察暫存器「迴圈前如何初始化、迴圈內如何更新與測試、迴圈後如何使用」,建立暫存器 ↔ C 變數對映。注意編譯器會重排計算、令多個變數共用一個暫存器、甚至消除變數(Practice 3.23:指標 p 被完全消除,x+=y(*p)+=5 合併為一條 leaq 5(%rbx,%rcx), %rcx)。

For(p.264–268)

for (init-expr; test-expr; update-expr) body-statement 依 C 標準(僅一例外,見下)等價於:

init-expr;
while (test-expr) {
    body-statement
    update-expr;
}

再套用 jump-to-middle 或 guarded-do 其中之一(依最佳化等級)。fact_for-Og 下:init(result=1; i=2)→ jmp .L8(test)→ loop 內 imulq %rdx,%rax; addq $1,%rdxcmpq %rdi,%rdx; jle .L9

Warning

唯一例外 — continue (Practice 3.29, p.268):forwhile 的規則遇到 continue 需修正。天真轉換會讓 continue 跳過 update-expr(如 i++)造成無窮迴圈;正確做法是把 continue 換成 goto update;,label 放在 update-expr 之前。

3.6.8 Switch 陳述(p.268–274)

switch 提供依整數索引的多路分支 (multiway branch),以 jump table 高效實作:jump table 是一個陣列,第 i 項存「switch 索引等於 i 時應執行之程式區塊的位址」。

範例 switch_eg(cases 100, 102–104, 106;缺 101、105;104/106 共用;102 fall through,p.270–271):

 n ---> subq $100, %rsi        index = n - 100(平移到 0..6)
         |
         cmpq $6, %rsi
         ja   .L8  --------->  default        <-- ja 是 unsigned 比較:
         |                                        負 index 視為極大正數,
         jmp  *.L4(,%rsi,8)                       一條指令同時攔 <0 與 >6
         |
         v  jump table (.rodata, 8-byte entries)
 .L4: +----------+
   [0]| .quad .L3| case 100 -> loc_A: val = 13*x
   [1]| .quad .L8| case 101 -> default   <-- 缺的 case 填 default 標籤
   [2]| .quad .L5| case 102 -> loc_B: x += 10, fall through(區塊尾不放 jmp)
   [3]| .quad .L6| case 103 -> loc_C: val = x + 11
   [4]| .quad .L7| case 104 -> loc_D: val = x * x
   [5]| .quad .L8| case 105 -> default
   [6]| .quad .L7| case 106 -> loc_D  <-- 重複 case 共用同一標籤
      +----------+

Exam/Test Patterns

情境 / 關鍵字 答案
t = a + b 後 CF 的 C 表達式 (unsigned) t < (unsigned) a(unsigned overflow)
t = a + b 後 OF 的 C 表達式 (a < 0 == b < 0) && (t < 0 != a < 0)
哪個算術類指令不改 condition codes? leaq
inc/dec 對旗標的影響 設 OF、ZF,CF 不變
移位指令對旗標的影響 CF = 最後移出的位元,OF = 0
cmp S1, S2 依據什麼設旗標? S2 − S1(同 sub 但不寫回)
test S1, S2 依據什麼? S1 & S2(同 and 但不寫回)
setl 的旗標公式 SF ^ OF(signed <)
setle / setg / setge (SF^OF)|ZF / ~(SF^OF)&~ZF / ~(SF^OF)
setb / seta 的旗標公式 CF / ~CF & ~ZF(unsigned)
看到 l/g/ge/le vs a/b/ae/be 後綴 signed 比較 vs unsigned 比較(反推 data_t 型別)
set 指令後綴 lb 是運算元大小嗎? 否,是條件「less / below」;目的地固定 1 byte
set 後要得到 int 結果還需要? movzbl 清高位(且同時清 %rax 上半 4 bytes)
條件跳轉可以是 indirect 嗎? 不行,條件跳轉只能 direct;jmp *Operand 僅限無條件
PC-relative 目標位址計算 目標 = 下一條指令位址 + 偏移量(二補數,1/2/4 bytes)
反組譯 offset 為 0xf8 1-byte 二補數 = −8,向後跳
PC-relative 的好處 編碼短;連結器搬移程式碼後 offset 不需改(位置無關)
rep; ret / repz retq AMD 分支預測 workaround,視為 no-op 忽略
if-else 翻譯模板 測試反轉條件 if (!t) goto false;,then 區塊尾接 goto done;
TOKTran 求 misprediction penalty TMP=2(TranTOK);誤判時耗時 TOK+TMP
cmov 何時不可用? 任一邊有副作用或可能出錯(如 xp ? *xp : 0 的 null deref),或計算昂貴
cmov 為何快? 控制流不依賴資料 → 無須 branch prediction,pipeline 恆滿
cmov 支援哪些長度? 16/32/64-bit,無 1-byte 版;長度由目的暫存器名推斷
do-while 翻譯 loop: body; if (test) goto loop;(body 至少執行一次)
-Og 的 while/for 翻譯 jump to middle(入口 goto test,test 在迴圈尾)
-O1 的 while/for 翻譯 guarded do(初始測試 if (!t) goto done; 再套 do-while)
組語迴圈測試從 >!= guarded-do 下編譯器證明等價後的改寫,反推 C 時寫原條件
for + continue 轉 while continue 須改為 goto update;(直接轉會跳過 i++ 造成無窮迴圈)
switch 何時用 jump table? case 數多(約 ≥4)且值域集中;執行時間與 case 數無關
switch 範圍檢查只用一條 ja? index 視為 unsigned,負值映為極大正數,同時攔 <0 與 >上界
jump table 中缺的 case / 重複 case 缺的項填 default 標籤;重複 case 項指向同一標籤
jump table 放在哪個段? .rodata,.align 8,每項 .quad(8 bytes)
case 區塊尾沒有 jmp fall through 到下一個 case
&&labelgoto *jt[i] gcc 擴充:code pointer 與 computed goto