概要
說(shuō)到x86-64,總不免要說(shuō)說(shuō)AMD的牛逼,x86-64是x86系列中集大成者,繼承了向后兼容的優(yōu)良傳統(tǒng),最早由AMD公司提出,代號(hào)AMD64;正是由于能向后兼容,AMD公司打了一場(chǎng)漂亮翻身戰(zhàn)。導(dǎo)致Intel不得不轉(zhuǎn)而生產(chǎn)兼容AMD64的CPU。這是IT行業(yè)以弱勝?gòu)?qiáng)的經(jīng)典戰(zhàn)役。不過(guò),大家為了名稱延續(xù)性,更習(xí)慣稱這種系統(tǒng)結(jié)構(gòu)為x86-64
X86-64在向后兼容的同時(shí),更主要的是注入了全新的特性,特別的:x86-64有兩種工作模式,32位OS既可以跑在傳統(tǒng)模式中,把CPU當(dāng)成i386來(lái)用;又可以跑在64位的兼容模式中,更加神奇的是,可以在32位的OS上跑64位的應(yīng)用程序。有這種好事,用戶肯定買(mǎi)賬啦,
值得一提的是,X86-64開(kāi)創(chuàng)了編譯器的新紀(jì)元,在之前的時(shí)代里,Intel CPU的晶體管數(shù)量一直以摩爾定律在指數(shù)發(fā)展,各種新奇功能層出不窮,比如:條件數(shù)據(jù)傳送指令cmovg,SSE指令等。但是GCC只能保守地假設(shè)目標(biāo)機(jī)器的CPU是1985年的i386,額。。。這樣編譯出來(lái)的代碼效率可想而知,雖然GCC額外提供了大量?jī)?yōu)化選項(xiàng),但是這對(duì)應(yīng)用程序開(kāi)發(fā)者提出了很高的要求,會(huì)者寥寥。X86-64的出現(xiàn),給GCC提供了一個(gè)絕好的機(jī)會(huì),在新的x86-64機(jī)器上,放棄保守的假設(shè),進(jìn)而充分利用x86-64的各種特性,比如:在過(guò)程調(diào)用中,通過(guò)寄存器來(lái)傳遞參數(shù),而不是傳統(tǒng)的堆棧。又如:盡量使用條件傳送指令,而不是控制跳轉(zhuǎn)指令
寄存器簡(jiǎn)介
先明確一點(diǎn),本文關(guān)注的是通用寄存器(后簡(jiǎn)稱寄存器)。既然是通用的,使用并沒(méi)有限制;后面介紹寄存器使用規(guī)則或者慣例,只是GCC(G++)遵守的規(guī)則。因?yàn)槲覀兿雽?duì)GCC編譯的C(C++)程序進(jìn)行分析,所以了解這些規(guī)則就很有幫助。
在體系結(jié)構(gòu)教科書(shū)中,寄存器通常被說(shuō)成寄存器文件,其實(shí)就是CPU上的一塊存儲(chǔ)區(qū)域,不過(guò)更喜歡使用標(biāo)識(shí)符來(lái)表示,而不是地址而已。
X86-64中,所有寄存器都是64位,相對(duì)32位的x86來(lái)說(shuō),標(biāo)識(shí)符發(fā)生了變化,比如:從原來(lái)的%ebp變成了%rbp。為了向后兼容性,%ebp依然可以使用,不過(guò)指向了%rbp的低32位。
X86-64寄存器的變化,不僅體現(xiàn)在位數(shù)上,更加體現(xiàn)在寄存器數(shù)量上。新增加寄存器%r8到%r15。加上x(chóng)86的原有8個(gè),一共16個(gè)寄存器。
剛剛說(shuō)到,寄存器集成在CPU上,存取速度比存儲(chǔ)器快好幾個(gè)數(shù)量級(jí),寄存器多了,GCC就可以更多的使用寄存器,替換之前的存儲(chǔ)器堆棧使用,從而大大提升性能。
讓寄存器為己所用,就得了解它們的用途,這些用途都涉及函數(shù)調(diào)用,X86-64有16個(gè)64位寄存器,分別是:%rax,%rbx,%rcx,%rdx,%esi,%edi,%rbp,%rsp,%r8,%r9,%r10,%r11,%r12,%r13,%r14,%r15。其中:
- %rax 作為函數(shù)返回值使用。
- %rsp 棧指針寄存器,指向棧頂
- %rdi,%rsi,%rdx,%rcx,%r8,%r9 用作函數(shù)參數(shù),依次對(duì)應(yīng)第1參數(shù),第2參數(shù)。。。
- %rbx,%rbp,%r12,%r13,%14,%15 用作數(shù)據(jù)存儲(chǔ),遵循被調(diào)用者使用規(guī)則,簡(jiǎn)單說(shuō)就是隨便用,調(diào)用子函數(shù)之前要備份它,以防他被修改
- %r10,%r11 用作數(shù)據(jù)存儲(chǔ),遵循調(diào)用者使用規(guī)則,簡(jiǎn)單說(shuō)就是使用之前要先保存原值
棧幀
棧幀結(jié)構(gòu)
C語(yǔ)言屬于面向過(guò)程語(yǔ)言,他最大特點(diǎn)就是把一個(gè)程序分解成若干過(guò)程(函數(shù)),比如:入口函數(shù)是main,然后調(diào)用各個(gè)子函數(shù)。在對(duì)應(yīng)機(jī)器語(yǔ)言中,GCC把過(guò)程轉(zhuǎn)化成棧幀(frame),簡(jiǎn)單的說(shuō),每個(gè)棧幀對(duì)應(yīng)一個(gè)過(guò)程。X86-32典型棧幀結(jié)構(gòu)中,由%ebp指向棧幀開(kāi)始,%esp指向棧頂。
函數(shù)進(jìn)入和返回
函數(shù)的進(jìn)入和退出,通過(guò)指令call和ret來(lái)完成,給一個(gè)例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 |
#include #include </code> int foo ( int x ) { int array[] = {1,3,5}; return array[x]; } /* ----- end of function foo ----- */ int main ( int argc, char *argv[] ) { int i = 1; int j = foo(i); fprintf (stdout, "i=%d,j=%d\n" , i, j); return EXIT_SUCCESS; } /* ---------- end of function main ---------- */ |
命令行中調(diào)用gcc,生成匯編語(yǔ)言:
1 |
Shell > gcc –S –o test.s test.c |
Main函數(shù)第40行的指令Call foo其實(shí)干了兩件事情:
- Pushl %rip //保存下一條指令(第41行的代碼地址)的地址,用于函數(shù)返回繼續(xù)執(zhí)行
- Jmp foo //跳轉(zhuǎn)到函數(shù)foo
Foo函數(shù)第19行的指令ret 相當(dāng)于:
- popl %rip //恢復(fù)指令指針寄存器
棧幀的建立和撤銷(xiāo)
還是上一個(gè)例子,看看棧幀如何建立和撤銷(xiāo)
說(shuō)題外話,以”點(diǎn)”做為前綴的指令都是用來(lái)指導(dǎo)匯編器的命令。無(wú)意于程序理解,統(tǒng)統(tǒng)忽視之,比如第31行。
棧幀中,最重要的是幀指針%ebp和棧指針%esp,有了這兩個(gè)指針,我們就可以刻畫(huà)一個(gè)完整的棧幀
函數(shù)main的第30~32行,描述了如何保存上一個(gè)棧幀的幀指針,并設(shè)置當(dāng)前的指針。
第49行的leave指令相當(dāng)于:
Movq %rbp %rsp //撤銷(xiāo)棧空間,回滾%rsp
Popq %rbp //恢復(fù)上一個(gè)棧幀的%rbp
同一件事情會(huì)有很多的做法,GCC會(huì)綜合考慮,并作出選擇。選擇leave指令,極有可能因?yàn)樵撝噶钚枰鎯?chǔ)空間少,需要時(shí)鐘周期也少。
你會(huì)發(fā)現(xiàn),在所有的函數(shù)中,幾乎都是同樣的套路,
我們通過(guò)gdb觀察一下進(jìn)入foo函數(shù)之前main的棧幀,進(jìn)入foo函數(shù)的棧幀,退出foo的棧幀情況
1
2
3
4 |
Shell> gcc -g -o test test.c Shell> gdb --args test Gdb > break main Gdb > run |
進(jìn)入foo函數(shù)之前:
你會(huì)發(fā)現(xiàn)rbp-rsp=0×20,這個(gè)是由代碼第11行造成的。
進(jìn)入foo函數(shù)的棧幀:
回到main函數(shù)的棧幀,rbp和rsp恢復(fù)成進(jìn)入foo之前的狀態(tài),就好像什么都沒(méi)發(fā)生一樣。
可有可無(wú)的幀指針
你剛剛搞清楚幀指針,是不是很期待要馬上派上用場(chǎng),這樣你可能要大失所望,因?yàn)榇蟛糠值某绦颍技恿藘?yōu)化編譯選項(xiàng):-O2,這幾乎是普遍的選擇。在這種優(yōu)化級(jí)別,甚至更低的優(yōu)化級(jí)別-O1,都已經(jīng)去除了幀指針,也就是%ebp中再也不是保存幀指針,而且另作他途。
在x86-32時(shí)代,當(dāng)前棧幀總是從保存%ebp開(kāi)始,空間由運(yùn)行時(shí)決定,通過(guò)不斷push和pop改變當(dāng)前棧幀空間;x86-64開(kāi)始,GCC有了新的選擇,優(yōu)化編譯選項(xiàng)-O1,可以讓GCC不再使用棧幀指針,下面引用 gcc manual 一段話 :
1 |
-O also turns on -fomit-frame-pointer on machines where doing so does not interfere with debugging. |
這樣一來(lái),所有空間在函數(shù)開(kāi)始處就預(yù)分配好,不需要棧幀指針;通過(guò)%rsp的偏移就可以訪問(wèn)所有的局部變量。
說(shuō)了這么多,還是看看例子吧。同一個(gè)例子, 加上-O1選項(xiàng):
1 |
Shell>: gcc –O1 –S –o test.s test.c |
分析main函數(shù),GCC分析發(fā)現(xiàn)棧幀只需要8個(gè)字節(jié),于是進(jìn)入main之后第一條指令就分配了空間(第23行):
1 |
Subq $8, %rsp |
然后在返回上一棧幀之前,回收了空間(第34行):
1 |
Addq $8, %rsp |
等等,為啥main函數(shù)中并沒(méi)有對(duì)分配空間的引用呢?這是因?yàn)镚CC考慮到棧幀對(duì)齊需求,故意做出的安排。
再來(lái)看foo函數(shù),這里你可以看到%rsp是如何引用??臻g的。
等等,不是需要先預(yù)分配空間嗎?這里為啥沒(méi)有預(yù)分配,直接引用棧頂之外的地址?
這就要涉及x86-64引入的牛逼特性了。
訪問(wèn)棧頂之外
通過(guò)readelf查看可執(zhí)行程序的header信息:
紅色區(qū)域部分指出了x86-64遵循ABI規(guī)則的版本,它定義了一些規(guī)范,遵循ABI的具體實(shí)現(xiàn)應(yīng)該滿足這些規(guī)范,其中,他就規(guī)定了程序可以使用棧頂之外128字節(jié)的地址。
這說(shuō)起來(lái)很簡(jiǎn)單,具體實(shí)現(xiàn)可有大學(xué)問(wèn),這超出了本文的范圍,具體大家參考虛擬存儲(chǔ)器。別的不提,接著上例,我們發(fā)現(xiàn)GCC利用了這個(gè)特性,干脆就不給foo函數(shù)分配棧幀空間了,而是直接使用棧幀之外的空間。@恨少說(shuō)這就相當(dāng)于內(nèi)聯(lián)函數(shù)唄,我要說(shuō):這就是編譯優(yōu)化的力量。
寄存器保存慣例
過(guò)程調(diào)用中,調(diào)用者棧幀需要寄存器暫存數(shù)據(jù),被調(diào)用者棧幀也需要寄存器暫存數(shù)據(jù)。如果調(diào)用者使用了%rbx,那被調(diào)用者就需要在使用之前把%rbx保存起來(lái),然后在返回調(diào)用者棧幀之前,恢復(fù)%rbx。遵循該使用規(guī)則的寄存器就是被調(diào)用者保存寄存器,對(duì)于調(diào)用者來(lái)說(shuō),%rbx就是非易失的。
反過(guò)來(lái),調(diào)用者使用%r10存儲(chǔ)局部變量,為了能在子函數(shù)調(diào)用后還能使用%r10,調(diào)用者把%r10先保存起來(lái),然后在子函數(shù)返回之后,再恢復(fù)%r10。遵循該使用規(guī)則的寄存器就是調(diào)用者保存寄存器,對(duì)于調(diào)用者來(lái)說(shuō),%r10就是易失的,
舉個(gè)例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29 |
#include <stdio.h> #include <stdlib.h> void sfact_helper ( long int x, long int * resultp) { if (x<=1) *resultp = 1; else { long int nresult; sfact_helper(x-1,&nresult); *resultp = x * nresult; } } /* ----- end of function foo ----- */ long int sfact ( long int x ) { long int result; sfact_helper(x, &result); return result; } /* ----- end of function sfact ----- */ int main ( int argc, char *argv[] ) { int sum = sfact(10); fprintf (stdout, "sum=%d\n" , sum); return EXIT_SUCCESS; } /* ---------- end of function main ---------- */ |
命令行中調(diào)用gcc,生成匯編語(yǔ)言:
1 |
Shell>: gcc –O1 –S –o test2.s test2.c |
在函數(shù)sfact_helper中,用到了寄存器%rbx和%rbp,在覆蓋之前,GCC選擇了先保存他們的值,代碼6~9說(shuō)明該行為。在函數(shù)返回之前,GCC依次恢復(fù)了他們,就如代碼27-28展示的那樣。
看這段代碼你可能會(huì)困惑?為什么%rbx在函數(shù)進(jìn)入的時(shí)候,指向的是-16(%rsp),而在退出的時(shí)候,變成了32(%rsp) 。上文不是介紹過(guò)一個(gè)重要的特性嗎?訪問(wèn)棧幀之外的空間,這是GCC不用先分配空間再使用;而是先使用??臻g,然后在適當(dāng)?shù)臅r(shí)機(jī)分配。第11行代碼展示了空間分配,之后棧指針發(fā)生變化,所以同一個(gè)地址的引用偏移也相應(yīng)做出調(diào)整。
參數(shù)傳遞
X86時(shí)代,參數(shù)傳遞是通過(guò)入棧實(shí)現(xiàn)的,相對(duì)CPU來(lái)說(shuō),存儲(chǔ)器訪問(wèn)太慢;這樣函數(shù)調(diào)用的效率就不高,在x86-64時(shí)代,寄存器數(shù)量多了,GCC就可以利用多達(dá)6個(gè)寄存器來(lái)存儲(chǔ)參數(shù),多于6個(gè)的參數(shù),依然還是通過(guò)入棧實(shí)現(xiàn)。了解這些對(duì)我們寫(xiě)代碼很有幫助,起碼有兩點(diǎn)啟示:
- 盡量使用6個(gè)以下的參數(shù)列表,不要讓GCC為難啊。
- 傳遞大對(duì)象,盡量使用指針或者引用,鑒于寄存器只有64位,而且只能存儲(chǔ)整形數(shù)值,寄存器存不下大對(duì)象
讓我們具體看看參數(shù)是如何傳遞的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 |
#include <stdio.h> #include <stdlib.h> int foo ( int arg1, int arg2, int arg3, int arg4, int arg5, int arg6, int arg7 ) { int array[] = {100,200,300,400,500,600,700}; int sum = array[arg1] + array[arg7]; return sum; } /* ----- end of function foo ----- */ int main ( int argc, char *argv[] ) { int i = 1; int j = foo(0, 1, 2, 3, 4, 5, 6); fprintf (stdout, "i=%d,j=%d\n" , i, j); return EXIT_SUCCESS; } /* ---------- end of function main ---------- */ |
命令行中調(diào)用gcc,生成匯編語(yǔ)言:
1 |
Shell>: gcc –O1 –S –o test1.s test1.c |
Main函數(shù)中,代碼31~37準(zhǔn)備函數(shù)foo的參數(shù),從參數(shù)7開(kāi)始,存儲(chǔ)在棧上,%rsp指向的位置;參數(shù)6存儲(chǔ)在寄存器%r9d;參數(shù)5存儲(chǔ)在寄存器%r8d;參數(shù)4對(duì)應(yīng)于%ecx;參數(shù)3對(duì)應(yīng)于%edx;參數(shù)2對(duì)應(yīng)于%esi;參數(shù)1對(duì)應(yīng)于%edi。
Foo函數(shù)中,代碼14-15,分別取出參數(shù)7和參數(shù)1,參與運(yùn)算。這里數(shù)組引用,用到了最經(jīng)典的尋址方式,-40(%rsp,%rdi,4)=%rsp + %rdi *4 + (-40);其中%rsp用作數(shù)組基地址;%rdi用作了數(shù)組的下標(biāo);數(shù)字4表示sizeof(int)=4。
結(jié)構(gòu)體傳參
應(yīng)@桂南要求,再加一節(jié),相信大家也很想知道結(jié)構(gòu)體是如何存儲(chǔ),如何引用的,如果作為參數(shù),會(huì)如何傳遞,如果作為返回值,又會(huì)如何返回。
看下面的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25 |
#include <stdio.h> #include <stdlib.h> struct demo_s { char var8; int var32; long var64; }; struct demo_s foo ( struct demo_s d) { d.var8=8; d.var32=32; d.var64=64; return d; } /* ----- end of function foo ----- */ int main ( int argc, char *argv[] ) { struct demo_s d, result; result = foo (d); fprintf (stdout, "demo: %d, %d, %ld\n" , result.var8, result.var32, result.var64); return EXIT_SUCCESS; } /* ---------- end of function main ---------- */ |
我們?nèi)笔【幾g選項(xiàng),加了優(yōu)化編譯的選項(xiàng)可以留給大家思考。
1 |
Shell>gcc -S -o test.s test.c |
上面的代碼加了一些注釋,方便大家理解,
問(wèn)題1:結(jié)構(gòu)體如何傳遞?它被分成了兩個(gè)部分,var8和var32合并成8個(gè)字節(jié)的大小,放在寄存器%rdi中,var64放在寄存器的%rsi中。也就是結(jié)構(gòu)體分解了。
問(wèn)題2:結(jié)構(gòu)體如何存儲(chǔ)? 注意看foo函數(shù)的第15~17行注意到,結(jié)構(gòu)體的引用變成了一個(gè)偏移量訪問(wèn)。這和數(shù)組很像,只不過(guò)他的元素大小可變。
問(wèn)題3:結(jié)構(gòu)體如何返回,原本%rax充當(dāng)了返回值的角色,現(xiàn)在添加了返回值2:%rdx。同樣,GCC用兩個(gè)寄存器來(lái)表示結(jié)構(gòu)體。
恩, 即使在缺省情況下,GCC依然是想盡辦法使用寄存器。隨著結(jié)構(gòu)變的越來(lái)越大,寄存器不夠用了,那就只能使用棧了。
總結(jié)
了解寄存器和棧幀的關(guān)系,對(duì)于gdb調(diào)試很有幫助;過(guò)些日子,一定找個(gè)合適的例子和大家分享一下。
參考
1. 深入理解計(jì)算機(jī)體系結(jié)構(gòu)
2. x86系列匯編語(yǔ)言程序設(shè)計(jì)