控制流 (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 指令 | 條件/無條件跳轉改變執行順序 | jmp、je、jg、ja… |
| 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; |
| 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) |
各類指令對旗標的影響(必背例外):
leaq不改變任何 condition code(它是位址計算用途);Figure 3.10 其餘算術/邏輯指令都會設定旗標。- 邏輯運算(如
xor):CF 與 OF 設為 0。 - 移位運算:CF = 最後被移出的位元,OF = 0。
inc/dec:設定 OF 與 ZF,但不動 CF。
兩類「只設旗標、不寫入目的地」的指令 (Figure 3.13, p.238),各有 b/w/l/q 四種尺寸:
| 指令 | 依據 | 等價行為 |
|---|---|---|
cmp S1, S2 |
S2 - S1 |
同 sub 但不更新目的地 |
test S1, S2 |
S1 & S2 |
同 and 但不更新目的地 |
test 常見用法:同一運算元重複,如 testq %rax, %rax 一次判斷 %rax 為負/零/正;或以其中一運算元當 mask 測試特定位元。
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 |
為何 setl 用 SF ^ 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 組合。
- 同一機器指令可有多個同義名 (synonyms),如
setg=setnle;編譯器/反組譯器任意擇一。 - 機器碼不為值附帶型別:signed/unsigned 多數共用相同指令(位元層次行為相同),僅右移、乘除法、與條件碼組合需要區分(Practice 3.13/3.14 即考從
setlvssetb、setgvsseta反推 data_t 型別與比較子)。
3.6.3 Jump 指令(p.241–243)
jmp 無條件跳轉,分兩種:
- Direct jump:目標編碼在指令內,組語寫 label,如
jmp .L1。 - Indirect jump:目標從暫存器或記憶體讀出,寫法為
*+ 運算元:jmp *%rax— 以 %rax 的值為跳轉目標jmp *(%rax)— 以 %rax 為位址讀記憶體取得目標
條件跳轉 jXX(Figure 3.15)之條件名稱與旗標公式完全對應上表的 set 指令。
條件跳轉只能是 direct(不可間接)。間接跳轉僅限無條件 jmp。
3.6.4 Jump 指令編碼(p.243–244)
最常用編碼是 PC-relative:編碼「目標位址 − 跳轉指令下一條指令位址」的差值,可用 1、2、4 bytes;另一種是 4-byte「絕對位址」。組譯器與連結器自動選擇。
位址 機器碼 組語 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
- 向後跳用負偏移量(如
0xf8= −8 的 1-byte 二補數;4-byte 版依 little-endian 由低到高排列)。 - PC 值取「下一條指令位址」是歷史慣例:早期處理器執行指令的第一步就是更新 PC。
- 好處:指令編碼精簡(僅 2 bytes),且連結重定位後 offset 不變 → 目的碼可整段搬移而無須修改(Practice 3.15 專考這種位址算術)。
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++
...
- 編譯器為 then/else 各生成獨立程式區塊,插入條件與無條件分支確保只執行其一。
- 另一種等價模板(Practice 3.17):
if (t) goto true; else-statement; goto done; true: then-statement; done:。 - C 的
&&短路求值會產生多個條件跳轉(Practice 3.16:一個if (a && *p < a)對應兩個條件分支)。
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 恆滿
- 處理器靠 pipelining 重疊執行連續指令,需提前確定指令序列;遇到條件跳轉 (branch) 時用 branch prediction 猜方向(現代設計目標成功率約 90%)。
- Misprediction penalty 公式 (Aside, p.252):
;當 p = 0.5(隨機分支)時 ,故 。 - 書中 Haswell 實測:
、 → cycles(單次呼叫介於 8~27 cycles);cmov 版恆為 ~8 cycles,與資料無關。(Practice 3.19 同型計算)
cmovXX S, R 指令特性 (Figure 3.18, p.253):
- 條件名稱/旗標公式同
set、jXX;來源 S 可為暫存器或記憶體,目的 R 必為暫存器。 - 運算元長度 16/32/64-bit,不支援單一位元組 cmov。
- 指令名不帶長度後綴:組譯器由目的暫存器名稱推斷長度(與
movw/movl不同)。 - 處理器執行 cmov 無須預測:讀來源、查條件碼、決定寫或不寫目的暫存器。
不是所有條件運算式都能用 cmov(p.254):
- 副作用或錯誤條件:cmov 版兩邊表達式都會被求值。如
return xp ? *xp : 0;若編成 cmov,movq (%rdi), %rax在 xp 為 NULL 時仍會解參考 → null pointer dereference,必須用分支版。Figure 3.16 的計數器副作用也強制 gcc 用條件控制轉移。 - 效率未必更好:若 then/else 任一邊計算量大,條件不成立時那份計算全數浪費。gcc 實務上只在兩邊都極簡單(如單一 add 指令)時才用 cmov,甚至在 misprediction 代價更高的許多情況仍保守選分支版。
3.6.7 Loops(p.256–268)
C 的 do-while、while、for 在機器碼沒有對應指令,全部由「條件測試 + 跳轉」組合而成;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:
- while 與 do-while 的差異:test-expr 先求值,body 可能一次都不執行(如
fact_while正確算出 0! = 1)。 - Guarded-do 的細節(Figure 3.21):
fact_while在-O1下,迴圈內測試由n > 1被改寫為n != 1(jne)——編譯器推得進入迴圈時必有 n>1,遞減後只可能 n>1 或 n=1,兩測試等價。反向工程時別被這種變形誤導。
反向工程迴圈的通用策略 (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,%rdx → cmpq %rdi,%rdx; jle .L9。
唯一例外 — continue (Practice 3.29, p.268):for 轉 while 的規則遇到 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 的時間與 case 數量無關——即使數百個 case 也只需一次 jump table 存取,勝過一長串 if-else。
- gcc 依 case 數量與值的疏密 (sparsity) 決定翻譯方式:case 較多(約 4 個以上)且值域跨度小時用 jump table;否則退化為 if-else 鏈。
範例 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 共用同一標籤
+----------+
- 索引跳轉:
jmp *.L4(,%rsi,8)為 indirect jump,以 label.L4為基底、index×8 取出 8-byte 目標位址。 - Jump table 宣告於目的檔的
.rodata(read-only data)段:.align 8對齊後放七個.quad,每項值為對應組語 label 的指令位址。 - gcc 的 C 語言擴充:
&&label運算子建立「程式碼位置」的指標(類比&取資料指標),配合 computed gotogoto *jt[index];可用 C 描述 jump table 行為(Figure 3.22(b) 的switch_eg_impl)。 - Fall through(case 102 → 103):組語中該區塊尾端省略
jmp .L2,自然流入下一區塊;C 版對應區塊尾無goto。 - 範圍檢查技巧:index 以 unsigned 看待——二補數的負數映為極大正數,單一
ja $6(above)即涵蓋「index < 0 或 index > 6」兩種出界情況。 - 反向操作(Practice 3.30/3.31):由入口的
addq $2, %rdi(偏移量 = −最小 case 值)、table 長度、與 table 中重複/指向 default 的項目,可反推 case 標籤值、多重標籤與 fall-through 結構。
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 指令後綴 l、b 是運算元大小嗎? |
否,是條件「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; |
| 給 |
|
| 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 |
&&label、goto *jt[i] |
gcc 擴充:code pointer 與 computed goto |
Related Notes
- 03-Machine-Level-Programs/01-Program-Encodings-and-Data-Formats
- 03-Machine-Level-Programs/02-Data-Movement-and-Arithmetic
- 03-Machine-Level-Programs/04-Procedures-and-the-Stack
- 03-Machine-Level-Programs/05-Arrays-and-Data-Structures
- 02-Information-Representation/03-Integer-Arithmetic
- 04-Processor-Architecture/04-Pipelining-Principles
- 05-Program-Optimization/02-Modern-Processor-Operation
- 07-Linking/02-Symbol-Resolution-and-Relocation