X86-64寄存器和棧幀

概要
說到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上的一塊存儲區域,不過更喜歡使用標識符來表示,而不是地址而已。
X86-64中,所有寄存器都是64位,相對32位的x86來說,標識符發生了變化,比如:從原來的%ebp變成了%rbp。爲了向後兼容性,%ebp依然可以使用,不過指向了%rbp的低32位。
X86-64寄存器的變化,不僅體現在位數上,更加體現在寄存器數量上。新增加寄存器%r8到%r15。加上x86的原有8個,一共16個寄存器。
剛剛說到,寄存器集成在CPU上,存取速度比存儲器快好幾個數量級,寄存器多了,GCC就可以更多的使用寄存器,替換之前的存儲器堆棧使用,從而大大提升性能。
讓寄存器爲己所用,就得了解它們的用途,這些用途都涉及函數調用,X86-64有16個64位寄存器,分別是:%rax,%rbx,%rcx,%rdx,%esi,%edi,%rbp,%rsp,%r8,%r9,%r10,%r11,%r12,%r13,%r14,%r15。其中:

%rax 作爲函數返回值使用。
%rsp 棧指針寄存器,指向棧頂
%rdi,%rsi,%rdx,%rcx,%r8,%r9 用作函數參數,依次對應第1參數,第2參數。。。
%rbx,%rbp,%r12,%r13,%14,%15 用作數據存儲,遵循被調用者使用規則,簡單說就是隨便用,調用子函數之前要備份它,以防他被修改
%r10,%r11 用作數據存儲,遵循調用者使用規則,簡單說就是使用之前要先保存原值

棧幀
棧幀結構
C語言屬於面向過程語言,他最大特點就是把一個程序分解成若干過程(函數),比如:入口函數是main,然後調用各個子函數。在對應機器語言中,GCC把過程轉化成棧幀(frame),簡單的說,每個棧幀對應一個過程。X86-32典型棧幀結構中,由%ebp指向棧幀開始,%esp指向棧頂。

函數進入和返回
函數的進入和退出,通過指令call和ret來完成,給一個例子

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

#include

#include

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,生成彙編語言:

1

Shell > gcc –S –o test.s test.c

Main函數第40行的指令Call foo其實幹了兩件事情:

Pushl %rip //保存下一條指令(第41行的代碼地址)的地址,用於函數返回繼續執行
Jmp foo //跳轉到函數foo
Foo函數第19行的指令ret 相當於:

popl %rip //恢復指令指針寄存器
棧幀的建立和撤銷
還是上一個例子,看看棧幀如何建立和撤銷
說題外話,以”點”做爲前綴的指令都是用來指導彙編器的命令。無意於程序理解,統統忽視之,比如第31行。
棧幀中,最重要的是幀指針%ebp和棧指針%esp,有了這兩個指針,我們就可以刻畫一個完整的棧幀
函數main的第30~32行,描述瞭如何保存上一個棧幀的幀指針,並設置當前的指針。
第49行的leave指令相當於:
Movq %rbp %rsp //撤銷棧空間,回滾%rsp
Popq %rbp //恢復上一個棧幀的%rbp
同一件事情會有很多的做法,GCC會綜合考慮,並作出選擇。選擇leave指令,極有可能因爲該指令需要存儲空間少,需要時鐘週期也少。
你會發現,在所有的函數中,幾乎都是同樣的套路,
我們通過gdb觀察一下進入foo函數之前main的棧幀,進入foo函數的棧幀,退出foo的棧幀情況

1

2

3

4

Shell> gcc -g -o test test.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 一段話 :

1

-O also turns on -fomit-frame-pointer on machines where doing so does not interfere with debugging.
這樣一來,所有空間在函數開始處就預分配好,不需要棧幀指針;通過%rsp的偏移就可以訪問所有的局部變量。
說了這麼多,還是看看例子吧。同一個例子, 加上-O1選項:

1

Shell>: gcc –O1 –S –o test.s test.c

分析main函數,GCC分析發現棧幀只需要8個字節,於是進入main之後第一條指令就分配了空間(第23行):

1

Subq $8, %rsp
然後在返回上一棧幀之前,回收了空間(第34行):

1

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就是易失的,
舉個例子:

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 ---------- */
命令行中調用gcc,生成彙編語言:

1

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個的參數,依然還是通過入棧實現。瞭解這些對我們寫代碼很有幫助,起碼有兩點啓示:

儘量使用6個以下的參數列表,不要讓GCC爲難啊。
傳遞大對象,儘量使用指針或者引用,鑑於寄存器只有64位,而且只能存儲整形數值,寄存器存不下大對象
讓我們具體看看參數是如何傳遞的:

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 ---------- */
命令行中調用gcc,生成彙編語言:

1

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。

結構體傳參
應@桂南要求,再加一節,相信大家也很想知道結構體是如何存儲,如何引用的,如果作爲參數,會如何傳遞,如果作爲返回值,又會如何返回。
看下面的例子:

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 ---------- */
我們缺省編譯選項,加了優化編譯的選項可以留給大家思考。

1

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系列彙編語言程序設計

轉自: X86-64寄存器和棧幀

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章