說到x86-64,總難免要說說AMD的牛逼,x86-64是x86系列中集大成者,繼承了向後兼容的優良傳統,最先由AMD公司提出,代號AMD64;正是因爲能向後兼容,AMD公司打了一場漂亮翻身戰。致使Intel不得不轉而生產兼容AMD64的CPU。這是IT行業以弱勝強的經典戰役。不過,你們爲了名稱延續性,更習慣稱這種系統結構爲x86-64。數組
X86-64在向後兼容的同時,更主要的是注入了全新的特性,特別的:x86-64有兩種工做模式,32位OS既能夠跑在傳統模式中,把CPU當成i386來用;又能夠跑在64位的兼容模式中,更加神奇的是,能夠在32位的OS上跑64位的應用程序。有這種好事,用戶確定買帳啦。函數
值得一提的是,X86-64開創了編譯器的新紀元,在以前的時代裏,Intel CPU的晶體管數量一直以摩爾定律在指數發展,各類新奇功能層出不窮,好比:條件數據傳送指令cmovg,SSE指令等。可是GCC只能保守地假設目標機器的CPU是1985年的i386,額。。。這樣編譯出來的代碼效率可想而知,雖然GCC額外提供了大量優化選項,可是這對應用程序開發者提出了很高的要求,會者寥寥。X86-64的出現,給GCC提供了一個絕好的機會,在新的x86-64機器上,放棄保守的假設,進而充分利用x86-64的各類特性,好比:在過程調用中,經過寄存器來傳遞參數,而不是傳統的堆棧。又如:儘可能使用條件傳送指令,而不是控制跳轉指令。性能
先明確一點,本文關注的是通用寄存器(後簡稱寄存器)。既然是通用的,使用並無限制;後面介紹寄存器使用規則或者慣例,只是GCC(G++)遵照的規則。由於咱們想對GCC編譯的C(C++)程序進行分析,因此瞭解這些規則就頗有幫助。優化
在體系結構教科書中,寄存器一般被說成寄存器文件,其實就是CPU上的一塊存儲區域,不過更喜歡使用標識符來表示,而不是地址而已。spa
X86-64中,全部寄存器都是64位,相對32位的x86來講,標識符發生了變化,好比:從原來的%ebp變成了%rbp。爲了向後兼容性,%ebp依然可使用,不過指向了%rbp的低32位。命令行
X86-64寄存器的變化,不只體如今位數上,更加體如今寄存器數量上。新增長寄存器%r8到%r15。加上x86的原有8個,一共16個寄存器。
剛剛說到,寄存器集成在CPU上,存取速度比存儲器快好幾個數量級,寄存器多了,GCC就能夠更多的使用寄存器,替換以前的存儲器堆棧使用,從而大大提高性能。debug
讓寄存器爲己所用,就得了解它們的用途,這些用途都涉及函數調用,X86-64有16個64位寄存器,分別是:設計
%rax,%rbx,%rcx,%rdx,%esi,%edi,%rbp,%rsp,%r8,%r9,%r10,%r11,%r12,%r13,%r14,%r15。3d
其中:指針
函數的進入和退出,經過指令call和ret來完成,給一個例子
#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 ---------- */
命令行中調用gcc,生成彙編語言:
Shell > gcc –S –o test.s test.c
Main函數第40行的指令Callfoo其實幹了兩件事情:
Foo函數第19行的指令ret 至關於:
仍是上一個例子,看看棧幀如何創建和撤銷。
說題外話,以」點」作爲前綴的指令都是用來指導彙編器的命令。無心於程序理解,通通忽視之,好比第31行。
棧幀中,最重要的是幀指針%ebp和棧指針%esp,有了這兩個指針,咱們就能夠刻畫一個完整的棧幀。
函數main的第30~32行,描述瞭如何保存上一個棧幀的幀指針,並設置當前的指針。
第49行的leave指令至關於:
Movq %rbp %rsp //撤銷棧空間,回滾%rsp。
Popq %rbp //恢復上一個棧幀的%rbp。
同一件事情會有不少的作法,GCC會綜合考慮,並做出選擇。選擇leave指令,極有可能由於該指令須要存儲空間少,須要時鐘週期也少。
你會發現,在全部的函數中,幾乎都是一樣的套路,咱們經過gdb觀察一下進入foo函數以前main的棧幀,進入foo函數的棧幀,退出foo的棧幀狀況。
Shell> gcc -g -o testtest.c
Shell> gdb --args test
Gdb > break main
Gdb > run
進入foo函數以前:
你會發現rbp-rsp=0×20,這個是由代碼第11行形成的。
進入foo函數的棧幀:
回到main函數的棧幀,rbp和rsp恢復成進入foo以前的狀態,就好像什麼都沒發生同樣。
你剛剛搞清楚幀指針,是否是很期待要立刻派上用場,這樣你可能要大失所望,由於大部分的程序,都加了優化編譯選項:-O2,這幾乎是廣泛的選擇。在這種優化級別,甚至更低的優化級別-O1,都已經去除了幀指針,也就是%ebp中不再是保存幀指針,並且另做他途。
在x86-32時代,當前棧幀老是從保存%ebp開始,空間由運行時決定,經過不斷push和pop改變當前棧幀空間;x86-64開始,GCC有了新的選擇,優化編譯選項-O1,可讓GCC再也不使用棧幀指針,下面引用 gcc manual 一段話 :
-O also turns on -fomit-frame-pointer on machines where doing so does not interfere with debugging.
這樣一來,全部空間在函數開始處就預分配好,不須要棧幀指針;經過%rsp的偏移就能夠訪問全部的局部變量。說了這麼多,仍是看看例子吧。同一個例子, 加上-O1選項:
Shell>: gcc –O1 –S –o test.s test.c
分析main函數,GCC分析發現棧幀只須要8個字節,因而進入main以後第一條指令就分配了空間(第23行):
Subq $8, %rsp
而後在返回上一棧幀以前,回收了空間(第34行):
Addq $8, %rsp
等等,爲啥main函數中並無對分配空間的引用呢?這是由於GCC考慮到棧幀對齊需求,故意作出的安排。再來看foo函數,這裏你能夠看到%rsp是如何引用棧空間的。等等,不是須要先預分配空間嗎?這裏爲啥沒有預分配,直接引用棧頂以外的地址?這就要涉及x86-64引入的牛逼特性了。
經過readelf查看可執行程序的header信息:
紅色區域部分指出了x86-64遵循ABI規則的版本,它定義了一些規範,遵循ABI的具體實現應該知足這些規範,其中,他就規定了程序可使用棧頂以外128字節的地址。
這提及來很簡單,具體實現可有大學問,這超出了本文的範圍,具體你們參考虛擬存儲器。別的不提,接着上例,咱們發現GCC利用了這個特性,乾脆就不給foo函數分配棧幀空間了,而是直接使用棧幀以外的空間。@恨少說這就至關於內聯函數唄,我要說:這就是編譯優化的力量。
過程調用中,調用者棧幀須要寄存器暫存數據,被調用者棧幀也須要寄存器暫存數據。若是調用者使用了%rbx,那被調用者就須要在使用以前把%rbx保存起來,而後在返回調用者棧幀以前,恢復%rbx。遵循該使用規則的寄存器就是被調用者保存寄存器,對於調用者來講,%rbx就是非易失的。
反過來,調用者使用%r10存儲局部變量,爲了能在子函數調用後還能使用%r10,調用者把%r10先保存起來,而後在子函數返回以後,再恢復%r10。遵循該使用規則的寄存器就是調用者保存寄存器,對於調用者來講,%r10就是易失的,舉個例子:
#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 ---------- */
命令行中調用gcc,生成彙編語言:
Shell>: gcc –O1 –S –o test2.s test2.c
在函數sfact_helper中,用到了寄存器%rbx和%rbp,在覆蓋以前,GCC選擇了先保存他們的值,代碼6~9說明該行爲。在函數返回以前,GCC依次恢復了他們,就如代碼27-28展現的那樣。
看這段代碼你可能會困惑?爲何%rbx在函數進入的時候,指向的是-16(%rsp),而在退出的時候,變成了32(%rsp) 。上文不是介紹過一個重要的特性嗎?訪問棧幀以外的空間,這是GCC不用先分配空間再使用;而是先使用棧空間,而後在適當的時機分配。第11行代碼展現了空間分配,以後棧指針發生變化,因此同一個地址的引用偏移也相應作出調整。
X86時代,參數傳遞是經過入棧實現的,相對CPU來講,存儲器訪問太慢;這樣函數調用的效率就不高,在x86-64時代,寄存器數量多了,GCC就能夠利用多達6個寄存器來存儲參數,多於6個的參數,依然仍是經過入棧實現。瞭解這些對咱們寫代碼頗有幫助,起碼有兩點啓示:
讓咱們具體看看參數是如何傳遞的:
#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 ---------- */
命令行中調用gcc,生成彙編語言:
Shell>: gcc –O1 –S –o test1.s test1.c
Main函數中,代碼31~37準備函數foo的參數,從參數7開始,存儲在棧上,%rsp指向的位置;參數6存儲在寄存器%r9d;參數5存儲在寄存器%r8d;參數4對應於%ecx;參數3對應於%edx;參數2對應於%esi;參數1對應於%edi。
Foo函數中,代碼14-15,分別取出參數7和參數1,參與運算。這裏數組引用,用到了最經典的尋址方式,-40(%rsp,%rdi,4)=%rsp + %rdi *4 + (-40);其中%rsp用做數組基地址;%rdi用做了數組的下標;數字4表示sizeof(int)=4。
應@桂南要求,再加一節,相信你們也很想知道結構體是如何存儲,如何引用的,若是做爲參數,會如何傳遞,若是做爲返回值,又會如何返回。
看下面的例子:
#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 ---------- */
咱們缺省編譯選項,加了優化編譯的選項能夠留給你們思考。
Shell>gcc -S -o test.s test.c
上面的代碼加了一些註釋,方便你們理解,
問題1:結構體如何傳遞?它被分紅了兩個部分,var8和var32合併成8個字節的大小,放在寄存器%rdi中,var64放在寄存器的%rsi中。也就是結構體分解了。
問題2:結構體如何存儲? 注意看foo函數的第15~17行注意到,結構體的引用變成了一個偏移量訪問。這和數組很像,只不過他的元素大小可變。
問題3:結構體如何返回,本來%rax充當了返回值的角色,如今添加了返回值2:%rdx。一樣,GCC用兩個寄存器來表示結構體。
恩, 即便在缺省狀況下,GCC依然是想盡辦法使用寄存器。隨着結構變的愈來愈大,寄存器不夠用了,那就只能使用棧了。
瞭解寄存器和棧幀的關係,對於gdb調試頗有幫助;過些日子,必定找個合適的例子和你們分享一下。
1. 深刻理解計算機體系結構 2. x86系列彙編語言程序設計