CSAPP(三)中——控制結構 程式的機器級表示
控制
CPU當然要提供一些指令和機制來允許開發者在其上構建出具有非完全順序流程的程式了。
條件碼
CPU維護者一組只有單個位的條件碼暫存器,它們用來記錄最近的算數或邏輯操作所產生的“副作用”。
- CF:進位標誌。最近的操作使最高位產生了進位,可以用來檢查無符號操作的溢位。
- ZF:零標誌。最近的操作得到的結果為0。
- SF:符號標誌。最近的操作得到的結果為負數。
- OF:溢位標誌。最近的補碼數操作得到了正溢位或負溢位。
leaq
不改變任何條件碼,因為它只用來做地址計算。對於邏輯操作,如XOR
,進位和溢位標誌會設定成0。移位操作進位標誌將設定成最後一個被移出的位,溢位標誌設定成0。INC和DEC會設定溢位和零標誌,但不會改變進位標誌(原因不知道書裡沒寫)。
比較和測試指令
cmp
指令和sub
指令所做的操作相同,執行減法操作,但它不將結果設回到任何暫存器中,而是隻設定條件碼。test
指令和and
指令所做的操作相同,執行與操作,但也不設定結果,只設置條件碼。
SET指令
set
指令根據指定的條件碼組合來判斷一些事情是否發生,如果發生了,將一個位設定為1,否則設定為0,比如如果你想判斷a是否小於b,你可能會手動呼叫cmp
並自己想辦法去根據條件碼來確定這件事。你可能以為我們只需要讀取一下SF
條件碼,看一看結果是否為負數就知道a是否小於b了,但是請考慮,在使用有限位表達的整數減法運算中會發生溢位,所以你往往需要完備周全的考慮來分析一個簡單的比較操作的結果。
set
指令是CPU提供給開發者和編譯器的工具,它對於很多常見的判斷(比如上面說的a<b
)都提供了開箱即用的指令,雖然底層也是分析那些條件碼的組合,但是你不用自己分析那些組合了。下面是set
命令以及它們的條件碼。
set
指令的字尾表達的是要判斷的條件,而非運算元的位數,比如setb不再是對位元組操作,而是判斷是否一個數在另一個數之下(below)。此外,有的set
指令還有些別名,比如setg
是大於,而setnle
就是不小於等於,它們是等價的。
注意,對於有符號補碼數,
set
指令採用的術語是l-less
,g-great
,而對於無符號數,set
採用b-below
和a-above
。
這裡分析一下setg
a>b
的。
令人迷惑的就是~(SF ^ OF)
,這個是用來判斷a是否大於等於b的,也就是~(a<b)
,考慮t=a-b
的六種情況:
a | b | t=a-b | SF | OF | a<b? | 備註 |
---|---|---|---|---|---|---|
負數 | 非負數 | <0 | 1 | 0 | 1 | 正常計算 |
負數 | 正數 | >0 | 0 | 1 | 1 | 負溢位 |
非負數 | 負數 | >0 | 0 | 0 | 0 | 正常計算 |
正數 | 負數 | <0 | 1 | 1 | 0 | 正溢位 |
負數 | 負數 | =0 | 0 | 0 | 0 | 相等 |
非負數 | 非負數 | =0 | 0 | 0 | 0 | 相等 |
所以,通過表格的前兩行可以看出,僅當SF ^ OF == 1
時,a<b
,所以setg
的表示式轉換成等價的c語言表示式就是!(a<b) && a!=b
,也就是a>b
。
set
的目的運算元是一個低位單位元組暫存器或一個位元組的記憶體位置,同時會對高位也清零。比如對於64位結果,會得到一個8位元組的0。
練習題3.13
- cmpl代表四位元組資料,(setl)
l
是在比較有符號數,所以int - cmpw代表雙位元組資料,
g
是在比較有符號數,所以short - cmpb代表單位元組資料,
b
是在比較無符號數,所以unsigned char
- cmpq代表八位元組資料,這個指令不限定是否有符號,所以
long
、unsigned long
或者某種形式的指標
練習題3.14
- testq代表八位元組,
g
是在比較有符號數,所以long
- testw代表二位元組,
sete
不區分符號,所以可能是short
、unsigned short
- testb代表一位元組,
a
是在比較無符號數,所以unsigned char
- testl代表四位元組,
setne
不區分符號,所以int
、unsigned int
跳轉指令
第二行的jmp
指令會讓程式跳過movq
直接執行popq
。跳轉指令的引數是跳轉目標,直接跳轉是將目標直接作為指令的一部分編碼,間接跳轉是將跳轉目標設定成一個暫存器或記憶體位置。間接跳轉的寫法是在引數前面加個*
。
jmp *%rax 從暫存器中讀取跳轉目標
jmp *(%rax) 從記憶體中讀取跳轉目標
jmp
是無條件跳轉,剩下的都是有條件跳轉,和set
一樣,它們也是根據條件碼的某種組合來決定是跳轉還是執行程式碼序列中的下一條指令。
跳轉指令的編碼
編碼方式有PC相對方式和絕對方式,前者比較常見。
下面看一個PC相對方式的例子。第一處指令跳轉到.L2
,第二處的跳轉指令跳轉到.L3
。
編譯器產生的.o
格式的反彙編版本如下:
jmp
的引數是03
,加上下一條指令(PC暫存器中的值)的地址0x5
就是0x8
,也就是說它最終會跳轉到地址為8的指令上。
jg
的引數是f8
,十進位制是-8
,也就是在下一條指令地址的基礎上減8,下一條指令是0xd
,也就是0x5
,跳轉到地址為5的指令上。
下面是連結後的反彙編版本,可以看到,基於PC相對編碼方式的跳轉指令在連結時根本不用修改,即使所有指令的實際地址都發生了改變:
練習題3.15
- 4003fe
- 400425
- 第二個指令地址:400545,第一個指令地址400543(因為je使用兩個位元組)
- 400560
條件分支
用條件控制來實現條件分支
x in %rdi, y in %rsi
對於if-else
這種分支,編譯器通常會編譯成這樣的形式(用c語言中的goto語句描述):
練習題3.16
- goto版本程式碼
- 對於&&前面的條件
p
,如果測試失敗,會跳過&&後面的測試,直接返回。
用條件傳送來實現條件分支
前面的條件分支實現是通過測試條件表示式結果,根據結果完成相應的跳轉,這種實現方式稱作條件控制。在現在的基於流水線作業的CPU上,一次可能只執行一個指令的一小部分,然後連續的指令之間可能有重疊,比如在執行一條指令中的記憶體讀取時執行它上面一條指令的算術運算。
流水線作業要保證最終執行的效果和順序執行是一致的,所以它必須要知道完整的指令執行序列才能使用,而在分支中,必須要等待條件表示式運算完成才能決定應該走向哪邊。現代處理器中具有分支預測邏輯,它可以在遇到分支時猜測要執行哪邊的程式碼,如果它預測正確,流水線就可以保持忙碌,如果它預測錯誤,雖然流水線也忙碌著了,但是處理器必須放棄它之前做的所有操作,重新去執行另一條分支,這會消耗大量時鐘週期。然而,在足夠隨機的分支選擇中,最佳的分支預測準確率也就是50%。
條件傳送是指先計算出兩個分支中的結果,然後計算條件,根據條件選擇一個正確的分支,這在分支中任務不是很大的情況下能夠讓流水線比條件控制模式下更好更高效的工作。下面圖b
是圖a
利用條件傳送編譯程式碼的C語言表示,圖c
是實際的彙編表示,cmovge
是條件傳送指令,意為比較並移動。
x86-64下的條件傳送指令
只有當兩個表示式都非常容易計算時,如僅僅是一條加法指令,才會使用條件傳送。即使分支預測錯誤的開銷會超過多個分支中的計算。
迴圈
do-while迴圈
do-while迴圈會被編譯成下面的樣子
下面是它的彙編程式碼,也是一樣的形式:
while迴圈
while迴圈需要先判斷條件,如果條件不滿足,迴圈甚至不能開啟,下面是一種將它們轉換成彙編程式碼的方式,即先跳轉到測試塊中,如果測試塊中條件滿足就走到實際的loop塊中。這種翻譯方式叫跳轉到中間。
練習題3.24
- 1
- a < b
- result * (a + b)
- a + 1
第二種翻譯方式有點耍機靈,因為while和do-while除了前者會先行判斷一次後再無任何差別,所以第二種翻譯方式提前判斷了一下條件,如果不滿足直接跳轉到結束,然後剩下的部分就用一個do-while來代替。這種翻譯方式稱為guarded-do。
練習題3.25
- b
- b > 0
- result * a
- b - a
習題3.26
- 使用跳轉到中間的翻譯方法
- 填寫程式碼:
long val = 0; while (x != 0) { val = x ^ val; x = x >> 1; } return 0x1 & val;
- 不知道,答案上說計算x的奇偶性,有奇數個1返回1,偶數個1返回0。
switch語句
switch可以使用兩種方式翻譯,第一種就是類似if else
那種翻譯方式,第二種就是使用跳轉表陣列。下圖是將switch
語句使用跳轉表方式翻譯到一種擴充套件的C語言中的例子,&&
是指向對應位置的指標。
通過上面例子,可以看出跳轉表並非使用所有情況,但當分支較多,且分支範圍跨度較小時,使用跳轉表能大大增加效能,因為跳轉表無需進行分支測試,它執行的速度與分支數量無關。
嘶,想起我中專的時候寫資料庫作業的時候就曾經採用過類似跳轉表這種優化手段來優化Visual Fox中長篇大論的
do case
語句。