引言
最近LZ有些略忙,因此這一章拖的時間有點久,不知道有沒有猿友在跟着看呢,LZ覺得應該幾乎沒有吧。畢竟這實在是一本乍一看十分枯燥的書,不過隨着慢慢的深入,不知道有沒有猿友慢慢找到了一點感覺呢。
本章我們來看一個特別有趣的內容,就是匯編級別的語言,如何利用寄存器實現if/for/while這些高級語言的流程控制,LZ只能說這實在是十分神奇。在沒有接觸這部分內容的時候,LZ打死也沒有想到,原來平時的流程控制是這樣實現的。接下來,各位猿友就和LZ一起見證這奇跡吧。
條件碼寄存器
這個子標題在之前就提到過,條件碼寄存器與普通的寄存器不同,它們都是1位寄存器,換句話說,它們當中的值只有0和1。當有算術與邏輯操作發生時,這些條件碼寄存器當中的值會相應的發生變化,這算是比較神奇的地方吧。
書中列出了四種常用的寄存器,它們的名字與作用分別如下所述,以下是LZ的理解。
CF:進位標志寄存器,它記錄無符號操作的溢出,當溢出時會被設為1。
ZF:零標志寄存器,當計算結果為0時將會被設為1。
SF:符號標志寄存器,當計算結果為負數時會被設為1。
OF:溢出標志寄存器,當計算結果導致了補碼溢出時,會被設為1。
從上面寄存器的簡單說明可以看出,ZF和SF可以判斷結果的符號,而CF和OF可以判斷無符號和補碼的溢出。而我們平時使用的高級程序語言,就僅僅靠這四個寄存器,就可以演化出千變萬化的流程控制。這尤其要感謝GCC的作者,個人覺得高級語言的編譯實在是一件特別偉大並且有難度的工作。
改變條件碼寄存器的值
通常情況下,條件碼寄存器的值無法主動被改變,它們大多時候是被動改變,這算是條件碼寄存器的特色。這其實理解起來並不困難,因為條件碼寄存器是1位的,而我們的數據格式最低為b,也就是8位,因此你無法使用任何數據傳送指令去傳送一個單個位的值。
幾乎所有的算術與邏輯指令都會改變條件碼寄存器的值,不過改變的前提是觸發了條件碼寄存器的條件。比如對於subl %edx,%eax這個減法指令,假設%edx和%eax寄存器的值都為0x10,則兩者相減的結果為0,此時ZF寄存器將會被自動設為1。對於其它的指令運算,都是類似的,會根據結果的不同而設置不同的條件碼寄存器。
特殊的測試指令
上面已經提到,在進行算術與邏輯操作時,條件碼寄存器的值可能隨之改變。這里介紹兩個比較特別的測試指令,它們不改變普通寄存器或者存儲器的值,只是為了設置條件碼寄存器的值。這算是唯二兩個可以主動設置條件碼寄存器的指令,它們分別是cmp以及test指令。
cmp是compare的意思,它有兩個操作數,比如cmp S2,S1,最終會基於S1-S2的值去設置條件碼寄存器的值。而對於test來說是類似的,對於test S2,S1來說,它將基於S1&S2去設置條件碼寄存器的值。另外需要一提的是,兩者都需要加數據格式后綴,比如b、w、l這些后綴。
舉個簡單的例子,對於cmpl %edx,%eax這個指令來講,假設%edx的值為y,%eax的值為x。則當x=y時,ZF將會被置為1。當x<y時,SF將會被置為1。而當x>y時,ZF和SF將同時為0。對於test指令來講,則相對特別一點,它經常用於判斷一個數是正數、負數,或者是0。當test用來判斷一個數的正負零時,兩個操作數為同一個,也就是說testl %eax,%eax可以用來判斷%eax寄存器當中的值是正數、負數還是0。因此testl %eax,%eax就相當於cmpl $0,%eax這個指令。
對於testl %eax,%eax這個指令,或許有的猿友會比較容易蒙,想不明白它如何判斷一個數到底是正是負還是零。其實這個道理是非常簡單的,只是有時候會一時轉不過來,當兩個操作數相同時,則經過“與運算”以后還是它自身。此時系統會根據計算結果去設置條件碼,而結果又是它自身,因此其實就相當於根據這個數的正負零去設置條件碼,這樣就可以判斷出這個數的正負了。就像cmpl $0,%eax一樣,在減去0之后,還是它自身,然后根據自身的正負零去設置條件碼寄存器。
訪問條件碼寄存器
對於普通寄存器來講,使用的時候一般是直接讀取它的值,而對於條件碼寄存器來說,則不一定非要讀取它的值才能使用。對於條件碼寄存器來講,有三種使用方式,都可以讓它發揮作用。
1、可以根據條件碼寄存器的某個組合,將一個字節設置為0或1,其實這個就相當於讀值。
2、可以直接條件跳轉到程序的某個其它的部分。
3、可以有條件的傳送數據。
這里面第一種方式其實就是普通寄存器的用法,直接讀取條件碼寄存器的值,然后進行使用。對於第二和第三種來說,就不是這樣了,它們不會顯示的讀取條件碼寄存器的值,而是直接使用。
條件碼寄存器的組合
本章最難的地方,就在於如何將條件碼寄存器的組合與條件聯系起來。只要理解了這一點,那么條件碼寄存器就算是基本掌握了,因為下面即將介紹的三種使用方式,都是基於這種組合去設計的。接下來LZ就一個一個的去介紹這些組合,以及它們為何會代表相應的條件。由於本文是LZ自主設計的,因此這里有必要對下面出現的格式進行一下簡單的說明。
首先要說明的一點是,對於所有的組合都基於a-b這樣的前提,也就是說條件碼寄存器的值是經過了一個減運算設置后的值。例如,對於【e->ZF】這樣的形式,代表的意思是字母e作為后綴時,則以ZF的值為1視為條件成立。
比如我們最容易理解的je指令,它代表的是“相等則跳轉”。j是跳轉的意思,e則是條件碼的組合,代表英文equals,因為我們基於a-b去設置條件碼寄存器,因此當ZF為1時,代表a等於b。因此ZF條件碼寄存器就是相等的條件碼組合,而je就代表相等則跳轉,就像if(a==b){block}這樣的代碼所代表的意思。
接下來,LZ將一一介紹這些組合,這些內容還是比較重要的,並且其中的某一些組合有一定的難度。
1、e->ZF(相等):e是equals的意思。這里代表的組合是ZF,因為ZF在結果為0時設為1,即a-b=0,也就是說a==b。因此ZF代表的意義是相等。
2、ne->~ZF(不相等):ne是not equals的意思。這里代表的組合是~ZF,也就是ZF做“非運算”,則很明顯是不相等的意思。
3、s->SF(負數):s這里沒什么實際意義,因為負數的直譯是negative number,首字母是n,這與not的首字母重復了,因此這里就取了SF條件碼寄存器的首個字母(純屬LZ的猜測,無權威證明,不過LZ自我感覺應該八九不離十,0.0)。這里代表的組合是SF,因為SF在計算結果為負數時設為1,此時可以認為b為0,即a<0。因此這里是負數的意思。
4、ns->~SF(非負數):與s相反,加上n則是not的意思,因此這里代表非負數。
5、l->SF^OF(有符號的小於):l代表的是less。這里的組合是SF^OF,即對SF和OF做“異或運算”。“異或運算”的意思則是代表,SF和OF不能相等。那么有兩種情況,當OF為0時,則代表沒有溢出,此時SF必須為1,SF為1則代表結果為負。即a-b<0,也就是a<b,也就是小於的意思。當OF為1時,則代表產生了溢出,而此時SF必須為0,也就是說結果最后為正數,那么此時則是負溢出,也可以得到a-b<0,即a<b。綜合前面兩種情況,SF^OF則代表小於的意思。
6、le->(SF^OF)|ZF(有符號的小於等於):le是less equals的意思。有了前面小於的基礎,這里就很容易理解了。SF^OF代表小於,ZF代表等於,因此兩者的“或運算”則代表小於等於。
7、g->~(SF^OF)&~ZF(有符號的大於):g是greater的意思。這里的組合是~(SF^OF)&~ZF,相對來說就比較復雜了。不過有了前面的鋪墊,這個也非常好理解。SF^OF代表小於,則~(SF^OF)代表大於等於,而~ZF代表不等於,將~(SF^OF)與~ZF取“與運算”,則代表大於等於且不等於,也就是大於。
8、ge->~(SF^OF)(有符號的大於等於):ge是greater equals的意思。這個組合就不需要再解釋了吧。
9、b->CF(無符號的小於):b是below的意思。CF是無符號溢出標志,這里的意思是指如果a-b結果溢出了,則代表a是小於b的,即a<b。其實這個結論很顯然,關鍵點就在於,無符號減法只有在減出負數的時候才可能溢出,也就是說只要結果溢出了,那么一定有a-b<0。因此這個結論就顯而易見了。
10、be->CF|ZF(無符號的小於等於):這里是below equals的意思。因此這里會與ZF計算“或運算”,字面上也很容易理解,即CF(小於)|(或)ZF(等於),也就是小於等於。
11、a->~CF&~ZF(無符號的大於):a代表的是above。這個組合也是非常好理解的,CF代表小於,則~CF代表大於等於,~ZF代表不等於,因此~CF&~ZF則代表大於等於且不等於,即大於。
12、ae->~CF(無符號的大於等於):ae是above equals的意思。至於這個組合的意義,相信也不需要解釋了吧。
以上則是幾乎所有的條件碼寄存器組合,如果你完全理解了上面的組合,那么接下來的一系列指令會非常簡單。它們只是基於條件碼的組合,進行設值、跳轉、傳送的操作而已。從形式上來講,上面這些組合與數據格式中的b、w、l的用法非常相似。
條件設值指令:set指令
set指令是將條件碼組合的值,設置到指定的目的操作數,值得注意的是,set指令中的目的操作數,只能是單字節的寄存器或者存儲器中單字節的位置。在書中有set指令族的圖表,LZ這里直接貼出來,各位結合着上面的條件碼組合來看,會顯得非常簡單。
各位猿友注意到了吧,將set指令后面加上12種組合,就成了表中的12個指令,這是不是很像數據格式的后綴呢(不過要注意,它們是有嚴格區別的)。
這里LZ舉一個簡單的例子,就算是對set指令做一個簡單的介紹。對於setae %al指令來說,%al是%eax寄存器中的最后一個字節,這個指令的含義是,將~CF的值設置到%eax寄存器的最后一個字節。
條件跳轉指令:jmp指令
這個指令是我們程序實現流程控制的關鍵指令,它可以直接將程序跳轉到指定的位置,又或者根據條件碼寄存器的組合進行條件跳轉。這個指令比較符合我們思維的慣性,接下來LZ將書中的指令表列出,然后再做針對性的解釋。
可以看到,除了兩個jmp指令之外,其余指令均是由j與條件碼的組合組成的,因此除了第一個jmp直接跳轉指令以及第二個jmp間接跳轉指令之外,剩下的12個都是條件跳轉指令,它們基於條件碼寄存器的組合進行跳轉。這些指令並沒有太大難度,因此LZ這里就不詳細介紹了。
總的來說,跳轉指令的地址編碼一般有兩種,第一種是基於PC的,第二種則是絕對地址。基於PC(程序計數器)則是指給出一個偏移量,這個偏移量基於當前下一條指令的地址,也就是PC當中的值,這是一種最常用的方式。絕對地址則比較簡單,它將直接給出存儲器當中代碼的位置。這里比較難理解的是基於PC的偏移量方式,因此LZ這里再稍微詳細的介紹一下。
基於PC的偏移量尋址
相信大部分都聽說過這樣的說法,PC(程序計數器)會一直指向程序的下一條指令,因此這里所說的PC的相對位置,則是指跳轉指令會附帶一個偏移量,而這個偏移量與PC值的和則剛好指向跳轉的位置。為了理解起來簡單,LZ這里舉個簡單的例子,我們考慮下面一段代碼,這是一個非常簡單的取兩數最小值的方法。
int min(int a,int b){ if( a < b ){ return a; }else{ return b; } }
我們將其命名為jmp.c,並使用-O1和-S參數去編譯它,我們將會得到以下匯編代碼。
.file "jmp.c" .text .globl min .type min, @function min: pushl %ebp movl %esp, %ebp movl 8(%ebp), %edx movl 12(%ebp), %eax cmpl %edx, %eax jle .L2 movl %edx, %eax .L2: popl %ebp ret .size min, .-min .ident "GCC: (Ubuntu 4.4.3-4ubuntu5.1) 4.4.3" .section .note.GNU-stack,"",@progbits
如果各位猿友從前面一直看過來的話,那么理解上面這一段匯編代碼會非常簡單。其中a和b分別存儲在棧頂+8和+12的位置,這里比較了b-a的值,如果b小於等於a則返回b,否則返回a,很明顯,這里判斷的是else的條件。可以看到,在匯編代碼當中,jmp族指令會使用標簽指示跳轉地址,比如上面出現過的.L2。
不過經過匯編器處理之后,標簽將不會再存在,此時會使用上面所說的PC偏移量記錄跳轉地址。接下來,LZ就帶各位看一下這個偏移量尋址的方式。我們可以使用-O1和-c編譯jmp.c,並使用objdump加-d參數去查看jmp.o,這樣會得到下面的反匯編代碼。
jmp.o: file format elf32-i386 Disassembly of section .text: 00000000 <min>: 0: 55 push %ebp 1: 89 e5 mov %esp,%ebp 3: 8b 55 08 mov 0x8(%ebp),%edx 6: 8b 45 0c mov 0xc(%ebp),%eax 9: 39 d0 cmp %edx,%eax b: 7e 02 jle f <min+0xf> d: 89 d0 mov %edx,%eax f: 5d pop %ebp 10: c3 ret
可以看到,這里面的指令序列與剛才的一模一樣,因為我們采取了同樣的優化等級-O1。值得注意的是,在第b行的指令jle中,跳轉的偏移地址是f,其實這個地址是通過偏移量計算出來的,也就是下圖中標紅的兩個位置相加得到的。
可以看到,這兩者加到一起剛好是f,值得注意的是,在真正的二進制序列當中,是不存在f這樣的地址的(實際上,f同樣是一個偏移量)。換句話說,7e代表jle指令,02則是指令的參數或者說操作數。為了證明這一點,我們可以使用hexdump加-C參數查看jmp.o,各位仔細看LZ標紅的地方。
這下比較清楚了吧,當碰到7e指令(即jle)時,會檢查后面的偏移量,結果一看是02,於是在條件滿足的前提下,會跳過兩個字節執行接下來的指令,也就是5d(即pop指令)。我們不難計算出,5d的位置剛好是89(即mov指令)這個指令的位置加2,而89此時正是PC的值,因為PC指向程序的下一條指令位置,而89剛好就是下一條指令。
流程控制展示
上面我們已經搞清楚了jmp指令族的一些常規內容,接下來,我們使用一個綜合的程序,來看一下jmp指令族如何實現if/else、for、while、do/while以及switch語句。這部分內容相對比較簡單,因此LZ不會詳細介紹。其實困難的部分都在上面呢,能看到這里,說明你已經拿下了最難的部分。
以下是LZ杜撰的一個C程序,請不要揣測它的含義,LZ很明確的告訴各位,這段代碼毫無意義,具體的無意義代碼如下。
int jmp(int a,int b){ int i; if( a == b ){ return a; } for(i = 0;i < a;i++){ a++; } do{ b++; } while(b<a); while(a <= b){ a++; } switch (a) { case 10: a = a + 10; break; case 20: a = a + 20; break; default: a = a + 30; break; } return a+b; }
為了保持匯編代碼與C程序代碼的一致性,我們使用-S來編譯這段代碼,接下來我們查看一下生成的匯編代碼。為了方便起見,LZ會將注釋直接寫在匯編代碼當中,各位猿友可以對照着看看,體會一下這些流程控制都是如何實現的。
.file "jmp.c" .text .globl jmp .type jmp, @function jmp: /* 棧幀建立 */ pushl %ebp//備份幀指針
movl %esp, %ebp//調整棧棧指針
subl $16, %esp//分配棧空間
/* 棧幀建立 */
/* if判斷實現 */ movl 8(%ebp), %eax//取a
cmpl 12(%ebp), %eax//a和b比較
jne .L2//如果a和b不相等,跳到.L2,繼續for循環
movl 8(%ebp), %eax//如果a和b相等,則把a作為返回值
jmp .L3//跳到.L3結束方法
/* if判斷實現 */
/* for循環實現 */ .L2: movl $0, -4(%ebp)//將0賦給i
jmp .L4//跳到.L4進行條件判斷
.L5: addl $1, 8(%ebp)//a做自增
addl $1, -4(%ebp)//i做自增
.L4: movl -4(%ebp), %eax//取i
cmpl 8(%ebp), %eax//i和a比較
jl .L5//如果i小於a則回到.L5繼續循環,否則往下進行do/while循環
/* for循環實現 */
/* do/while循環實現 */ .L6: addl $1, 12(%ebp)//b做自增
movl 12(%ebp), %eax//取b
cmpl 8(%ebp), %eax//比較b和a
jl .L6//如果b小於a,則繼續循環,否則往下進行while循環
/* do/while循環實現 */
/* while循環實現 */ jmp .L7//先跳到.L7,這是while與do/while的區別,先判斷再執行block
.L8: addl $1, 8(%ebp)//a做自增
.L7: movl 8(%ebp), %eax//取a
cmpl 12(%ebp), %eax//比較a和b
jle .L8//如果a小於等於b,則跳到.L8繼續循環,否則向下進行switch語句
/* while循環實現 */
/* switch語句實現 */ movl 8(%ebp), %eax//取a
cmpl $10, %eax//比較a和10
je .L10//如果a等於10,跳到.L10進行a=a+10的操作
cmpl $20, %eax//比較a和20
je .L11//如果a等於20,跳到.L11進行a=a+20的操作
jmp .L14//如果a不等於10也不等於20,則跳到.L14進行a=a+30的操作
.L10: addl $10, 8(%ebp)//a=a+10
jmp .L12//break
.L11: addl $20, 8(%ebp)//a=a+20
jmp .L12//break
.L14: addl $30, 8(%ebp)//a=a+30
.L12: movl 12(%ebp), %eax//取b
movl 8(%ebp), %edx//取a
leal (%edx,%eax), %eax//計算a+b並作為返回值
/* switch語句實現 */
/* 棧幀完成 */ .L3: leave ret /* 棧幀完成 */ .size jmp, .-jmp .ident "GCC: (Ubuntu 4.4.3-4ubuntu5.1) 4.4.3" .section .note.GNU-stack,"",@progbits
上面LZ已經加了詳細的注釋,相信各位猿友在注釋的幫助下,不難看出這些流程控制的實現,它們全部是由跳轉指令實現的。其實如果理解了跳轉指令,像if/else、for等等這些流程控制,對各位來說只是一個腦筋小轉彎而已。
條件傳送指令:cmov指令
接下來我們來看最后一種條件指令,叫做條件傳送指令。顧名思義,條件傳送指令的意思就是在滿足條件的時候進行傳送的指令,也就是cmov指令。它與set指令十分相似,同樣有12種,也就是加上12種條件碼寄存器的組合即可,以下是一張書中的指令表格。
對於條件傳送指令執行的時鍾周期數,書中給出了一個簡單的計算方式,用於闡述最優周期數、最差周期數以及隨機周期數的關系,有興趣的猿友可以去看看,LZ這里就不多討論了,這一點不是我們的重點。總的來說,條件傳送指令相當於一個if/else的賦值判斷,一般情況下,條件傳送指令的性能高於if/else的賦值判斷。
不過事情總有例外,因為條件傳送指令將對兩個表達式都求值,因此如果兩個表達式計算量很大時,那么條件傳送指令的性能就可能不如if/else的分支判斷了。不過總的來說,這種情況還是很少的,因此條件傳送指令還是很有用的,只是並不是所有的處理器都支持條件傳送指令,這依賴於處理器以及編譯器的編譯方式。
條件傳送指令最大的缺點便是可能引起意料之外的錯誤,由於LZ的linux無法模擬出條件傳送指令,因此這里使用一個書上的例子簡單說明一下,比如對於下面這一段代碼。
int cread(int *xp){ return (xp ? *xp : 0); }
猛地一看,這一段代碼是沒問題的,不過如果使用條件傳送指令去實現這段代碼的話,將可能引起空指針引用的錯誤。因為條件傳送指令會先對兩個表達式進行計算,也就是說無論xp是否有值,都將計算*xp這個表達式,因此當xp為空指針0時,則會產生錯誤。由此可見,條件傳送指令也不是哪都能用的,通常情況下,編譯器會幫我們盡力處理這種錯誤。
文章小結
本章內容較多,不過這已經是LZ將書中的內容壓縮后的體積,總的來說,這一章的難點就在於條件碼寄存器的組合。如果各位猿友理解不了這一部分,一定要下功夫搞定它,否則的話,下面出現的指令也只能是知其然而不知其所以然了。本次內容就到此為止了,下一章也是非常重要的一章,有關於程序當中過程(方法)的實現方式。