棧幀結構與函數調用約定

棧幀結構與函數調用約定

棧,是一種先入後出的數據結構,就像咱們堆放書籍同樣,先放的在最底下,後放置的在頂上,當咱們要取的時候就是拿最上面一本,即最後放置的那一本。即FILO(first in last out)。程序員

對大多數的應用程序員來講,棧就是這麼一個數據結構的概念,而對於嵌入式工程師來講,棧還表明着另外一種舉足輕重的角色,今天咱們就來聊一聊內存中的棧結構。編程

什麼是棧?

在早期的計算機系統中,事實上是沒有棧這個概念的,內存中棧的設計是因爲過程式語言的面世。緩存

CPU寄存器是CPU內部的存儲設備,而內存是獨立於CPU以外的存儲設備,存儲着數據、程序等等,當CPU須要執行程序時,將內存中的數據讀入寄存器,而後執行相應指令,寄存器是內存與CPU之間交互的橋樑,寄存器中緩存着須要執行程序的數據或者指令地址等等。這種執行模式一直持續到過程式語言被創造以前。數據結構

後來逐漸有了Fortran、C語言的面世,函數的概念被提出,這致使一個什麼結果呢?架構

當一個函數調用另外一個函數時,勢必涉及到保存調用函數的狀態保存,由於在調用完成以後必需要可以原封不動地還原調用函數的狀態,繼續往下執行函數,並且每每這個調用過程是遞歸的,可是這些數據存在哪裏呢?編程語言

顯然,光靠寄存器是不夠的,經典的8086才8個16位通用寄存器。在這個時候,人們就想到了是否是能夠在內存中開闢出一部分專門用來存儲這些函數調用的中間數據,答案固然是能夠的,雖然比起寄存器的訪問速度,內存訪問的效率明顯低不少,可是這也是沒有辦法的辦法。函數

可是問題又來了,這部份內存採起什麼樣的管理結構呢?優化

咱們來模擬一下調用過程,當A調用B時,在B中又調用了函數C,當函數C返回時,清除C的信息,返回到B的執行狀態中,當B執行完以後,清除B的信息,返回到A中。整個過程是這樣的:this

咱們能夠看到,執行順序爲:A->B->C,而返回順序是C->B->A,因爲內存是線性空間,很顯然這是一個棧的結構,先進後出,因此棧就應運而生。spa

爲了支持函數調用而在內存中開闢出來的一段空間,這個數據空間的規則是先進後出。

那麼,既然是爲了支持函數調用而產生的,它究竟是以一個怎樣的方式來支持函數調用的呢?

棧到底在哪裏?

既然棧是內存中開闢出來的空間,那麼咱們是怎麼指定它的位置的?

在經典操做系統中,棧老是向下生長的,向下生長意味着由高地址向低地址延伸,當數據壓棧時,棧向低地址延伸,當從棧中彈出數據時,棧縮回高地址。

CPU有一個內部寄存器esp專門用來存儲棧頂位置,隨着棧的伸縮而改變,始終指向棧頂,由這個寄存器咱們就能夠訪問當前棧中的內容。

還有一個寄存器ebp,這個ebp寄存器存儲的則是當前執行函數的棧基地址(下面還會詳細講到)。

棧幀結構

從上一部分咱們知道了怎麼定位棧的地址,esp和ebp兩個寄存器定位棧活動記錄的方式就叫棧幀結構,如今咱們以一個簡單的例子來分析一下棧幀結構:

int func(int x,int y)
{
    int a=x,b=y;
    return a+b;
}
int main()
{
    int c = func(3,4);
    return c;
}

事實上光是簡單地看函數實現是看不出什麼東西的,咱們必須得從底層來分析,最好的方式就是直接看彙編代碼的實現。

在這裏咱們生成彙編代碼:

gcc -g test.c -o test
objdump -S test > asm_file

這樣咱們就能夠直接在asm_file中查看反彙編代碼,爲何不直接使用gcc -S test.c直接編譯生成彙編代碼呢?其實這是由於格式問題,objdump生成的代碼更清晰,並且也嵌入了源代碼作對應。下面就是部分主要的彙編(注1)

int func(int x,int y)
{
1        4004d6:    55                      push   %rbp
2        4004d7:    48 89 e5                mov    %rsp,%rbp
3        4004da:    89 7d ec                mov    %edi,-0x14(%rbp)
4        4004dd:    89 75 e8                mov    %esi,-0x18(%rbp)
5            int a=x,b=y;
6        4004e0:    8b 45 ec                mov    -0x14(%rbp),%eax
7        4004e3:    89 45 f8                mov    %eax,-0x8(%rbp)
8        4004e6:    8b 45 e8                mov    -0x18(%rbp),%eax
9        4004e9:    89 45 fc                mov    %eax,-0x4(%rbp)
10            return a+b;
11       4004ec:    8b 55 f8                mov    -0x8(%rbp),%edx
12        4004ef:   8b 45 fc                mov    -0x4(%rbp),%eax
13        4004f2:   01 d0                   add    %edx,%eax
    }
14        4004f4:   5d                      pop    %rbp
15       4004f5:    c3                      retq   
    int main()
   {
16        4004f6:   55                      push   %rbp
17        4004f7:   48 89 e5                mov    %rsp,%rbp
18        4004fa:   48 83 ec 10             sub    $0x10,%rsp
19            int c = func(3,4);
20        4004fe:   be 04 00 00 00          mov    $0x4,%esi
21        400503:   bf 03 00 00 00          mov    $0x3,%edi
22        400508:   e8 c9 ff ff ff          callq  4004d6 <func>
23        40050d:   89 45 fc                mov    %eax,-0x4(%rbp)
24            return c;
25        400510:   8b 45 fc                mov    -0x4(%rbp),%eax
    }

在上述的彙編代碼中,第一列是代碼地址,第二列是機器指令,這一部分咱們不須要關注,而第三四列就是彙編指令。這裏反彙編出來的彙編指令是AT&T格式的,與咱們常學的intel格式的彙編指令有所區別,這裏咱們須要知道源操做單元的目標操做單元與intel格式中是相反的。

彙編代碼分析

接下來咱們從main函數開始逐行分析這些彙編指令,爲了方便講解,我人爲地爲它們添加了行號。

保存及切換棧幀

咱們先看main()最前兩行:

16  4004f6:   55                        push   %rbp
17  4004f7:   48 89 e5                  mov    %rsp,%rbp

幾乎每個函數的前兩條指令都是這兩個語句,恰好這條彙編指令就代表了棧幀結構的核心內容,咱們能夠先看這個結構圖:
stack frame

  • 首先,咱們須要明確的一點就是,main()函數並不是程序真正的入口函數,在main()函數以前系統會作一系列的初始化操做,而後再調用main()
  • 在上面咱們就有提到過,rsp(因爲平臺的不一致,這裏的rsp就是上述提到的esp)一直是指向棧頂位置,而ebp則指向當前執行函數的棧基地址。
  • 如圖所示,當發生函數調用時,調用者先將被調用函數的參數壓棧,而後將返回地址壓棧,而後在被調用函數中,將調用函數的ebp壓棧,再將esp寄存器的值賦值給ebp,由於esp在調用以前老是指向棧頂的,將其賦值給ebp,就是從棧頂位置開始爲被調用函數分配空間。

舉個例子,當發生函數調用已經進入到被調用函數時,假如當前esp值(即棧頂位置)爲0x10010,ebp的值爲0x10000,先將ebp壓棧,esp的指針往棧頂移四個字節,而後將ebp的值放入0x10014的位置(彙編中push操做先將棧頂指針後移,而後再將數據放入)。

而後將esp的值賦給ebp,這時候ebp的值爲0x10014,而後爲被調用函數分配空間.當函數返回時,再pop ebp,就能夠將棧幀狀態返回到被調用函數繼續執行的狀態。

分配內存空間

18        4004fa:   48 83 ec 10             sub    $0x10,%rsp

這一條指令是 rsp-0x10,就如上述提到的,棧是由高地址向低地址延伸,因此減去0x10便是爲main()函數分配0x10的局部空間。

調用函數的準備

19            int c = func(3,4);
20        4004fe:   be 04 00 00 00          mov    $0x4,%esi
21        400503:   bf 03 00 00 00          mov    $0x3,%edi
22        400508:   e8 c9 ff ff ff          callq  4004d6 <func>

這三條指令就是將被調用函數的參數放入寄存器中進行參數傳遞(經典實現爲經過棧傳遞),從這裏能夠看到,顯示傳遞參數4,而後再是參數3,從這裏能夠看出,無論是寄存器傳遞仍是棧傳遞,C語言參數傳遞順序都是從右往左。

第三條彙編指令callq,從字面意思能夠看出這一條就是進行函數調用,call指令至關於:

pushl %eip          //將eip寄存器中地址壓棧,

movl func, %eip     //將func()函數的地址放入eip寄存器中,當函數返回時取出這個地址放入eip寄存器便可。

eip寄存器中存放的是下一條將要執行指令的地址

進入到被調用函數

1        4004d6:    55                      push   %rbp
2        4004d7:    48 89 e5                mov    %rsp,%rbp

熟悉的這兩行,跟上面說的同樣,保存調用者rbp,而後定位被調用函數ebp和esp。

獲取參數

3        4004da:    89 7d ec                mov    %edi,-0x14(%rbp)
4        4004dd:    89 75 e8                mov    %esi,-0x18(%rbp)

將參數從寄存器中取出,放到棧上,這裏的棧並不是像數據結構棧同樣,雖然是同樣的實現方式,可是這裏除了支持pop和push,同時支持ebp的直接尋址操做。

賦值操做

5            int a=x,b=y;
6        4004e0:    8b 45 ec                mov    -0x14(%rbp),%eax
7        4004e3:    89 45 f8                mov    %eax,-0x8(%rbp)
8        4004e6:    8b 45 e8                mov    -0x18(%rbp),%eax
9        4004e9:    89 45 fc                mov    %eax,-0x4(%rbp)

這一部分就是賦值操做,先將實參從棧上取出,而後再賦值給形參a,b。

函數返回值

10            return a+b;
11       4004ec:    8b 55 f8                mov    -0x8(%rbp),%edx
12        4004ef:   8b 45 fc                mov    -0x4(%rbp),%eax
13        4004f2:   01 d0                   add    %edx,%eax

將a和b相加,而後放在eax寄存器中返回。

執行返回

14        4004f4:   5d                      pop    %rbp
15       4004f5:    c3                      retq

第一條指令將調用函數的rbp值賦值給rbp寄存器,這樣就實現了棧幀結構的返回。

第二條指令retq,等價於

pop %eip

將返回值讀出,做爲下一條執行指令。

整個彙編程序的過程就是這樣的,若是朋友們尚未看懂,咱們再用gdb調試的方式來查看棧以及棧上的內容(博主在64位機上作的實驗,因此字寬和寄存器容量與32位機上的結果不一樣,64位爲8字節,32位爲4字節)。

gdb下的程序執行

一樣是如下的程序:

1   int func(int x,int y)
2   {
3       int a=x,b=y;
4       return a+b;
5   }
6   int main()
7   {
8       int c = func(3,4);
9       return c;
10  }

輸入編譯指令以及進入調試模式:

gcc -g test.c -o test
gdb test

進入調試模式以後,咱們先在函數調用前、被調用函數執行開始、和函數調用以後打上斷點:

b 8
b 3
b 10

而後鍵入'r'(run)執行程序,這時程序停在了第一個斷點,即第8行。在這裏咱們先查看一下寄存器的值:

info registers esp ebp

輸出結果爲:

rsp            0x7fffffffdda0   0x7fffffffdda0
rbp            0x7fffffffddb0   0x7fffffffddb0

能夠看到,在調用func()以前,main函數棧基地址爲0x7fffffffddb0,而棧頂地址爲0x7fffffffdda0,相差0x10,與上面輸出的反彙編語句

18        4004fa:   48 83 ec 10             sub    $0x10,%rsp

是對得上的,這是在棧上分配內存空間致使棧頂的向下延伸。

而後咱們鍵入'c'(continue)繼續執行程序,程序運行到第二個斷點即func()函數中,第3行,咱們再來看到當前的棧幀寄存器值:

info registers esp ebp

輸出結果爲:

rsp            0x7fffffffdd90   0x7fffffffdd90
rbp            0x7fffffffdd90   0x7fffffffdd90

這時候rsp和rbp寄存器的結果都是0x7fffffffdd90,爲何會是這個結果,並且兩個寄存器結果同樣呢?

咱們再來看看棧上的具體內容:

x/20xw $rsp              (x/n查看棧上指定長度的內存數據)

輸出結果是這樣的:

0x7fffffffdd90: 0xffffddb0  0x00007fff  0x0040050d  0x00000000
0x7fffffffdda0: 0xffffde90  0x00007fff  0x00000000  0x00000000
0x7fffffffddb0: 0x00400520  0x00000000  0xf7a2d830  0x00007fff
0x7fffffffddc0: 0x00000000  0x00000000  0xffffde98  0x00007fff
0x7fffffffddd0: 0x00000000  0x00000001  0x004004f6  0x00000000

能夠看到,棧頂位置的數據爲0x00007fffffffddb0,對比上面的數據能夠發現這正是main()函數中rbp的值,而棧頂向高地址的第二個64位數據是0x000000000040050d。

對比上面的反彙編代碼,能夠發現這是main()函數中因函數調用而產生的斷點,當函數返回時在這裏繼續向下執行。

因此func()函數返回時的這兩條彙編指令能夠返回到main()函數中:

14        4004f4:   5d                      pop    %rbp
15       4004f5:    c3                      retq

至於爲何rsp和rbp在函數中是同一個值,就是由於函數中沒有產生分配空間的行爲,事實上與經典操做系統不一樣的是,這裏的局部變量操做被直接放在寄存器中進行。
接着鍵入'c'(continue)繼續向下執行,執行到第三個斷點處,即func()函數已經返回,這時候咱們再來看寄存器內容:

info registers esp ebp

輸出結果爲:

rsp            0x7fffffffdda0   0x7fffffffdda0
rbp            0x7fffffffddb0   0x7fffffffddb0

棧幀結構恢復到調用前。

調用時棧幀結構總結

即便是反彙編的詳解加上對應的跟蹤gdb調試器中的棧數據活動,博主以爲仍是須要仔細地再梳理一遍,以避免某些同窗仍是沒有弄懂這個過程。咱們從新貼上彙編代碼,

int func(int x,int y)
{
1        4004d6:    55                      push   %rbp
2        4004d7:    48 89 e5                mov    %rsp,%rbp
3        4004da:    89 7d ec                mov    %edi,-0x14(%rbp)
4        4004dd:    89 75 e8                mov    %esi,-0x18(%rbp)
5            int a=x,b=y;
6        4004e0:    8b 45 ec                mov    -0x14(%rbp),%eax
7        4004e3:    89 45 f8                mov    %eax,-0x8(%rbp)
8        4004e6:    8b 45 e8                mov    -0x18(%rbp),%eax
9        4004e9:    89 45 fc                mov    %eax,-0x4(%rbp)
10            return a+b;
11       4004ec:    8b 55 f8                mov    -0x8(%rbp),%edx
12        4004ef:   8b 45 fc                mov    -0x4(%rbp),%eax
13        4004f2:   01 d0                   add    %edx,%eax
    }
14        4004f4:   5d                      pop    %rbp
15       4004f5:    c3                      retq   
    int main()
   {
16        4004f6:   55                      push   %rbp
17        4004f7:   48 89 e5                mov    %rsp,%rbp
18        4004fa:   48 83 ec 10             sub    $0x10,%rsp
19            int c = func(3,4);
20        4004fe:   be 04 00 00 00          mov    $0x4,%esi
21        400503:   bf 03 00 00 00          mov    $0x3,%edi
22        400508:   e8 c9 ff ff ff          callq  4004d6 <func>
23        40050d:   89 45 fc                mov    %eax,-0x4(%rbp)
24            return c;
25        400510:   8b 45 fc                mov    -0x4(%rbp),%eax
    }

同時寫出詳細的流程:

  • 16行:將調用main()的調用者函數的rbp值壓棧
  • 17行:將當前rsp的值賦給rbp,由此肯定了main()的棧基地址。
  • 18行:在棧上開闢0x10的地址空間,用於main()函數的執行
  • 20-21行:將參數存入寄存器,以參數從右到左的順序(爲何是存入寄存器而不是棧,上面有簡單提到,下面咱們也會詳細講到)
  • 22行:調用func()函數,這裏的調用即將eip寄存器壓棧,eip寄存器存放是下一條將要指令的地址,在15行func()函數中的retq指令就是取出棧上的返回地址從新放回eip寄存器。
  • 1-2行:進入到func()函數,將調用者main()函數的棧基地址入棧,而後將rsp的值賦給rbp。

咱們能夠以前的gdb分析看出,如今的ebp即棧基地址爲:0x7fffffffdd90,而main函數的棧頂地址爲:0x7fffffffdda0,這其中有兩次push操做,一次爲callq,一次爲push %ebp,因爲是64位,每一次操做佔用8個字節,因此rsp向下增加0x10字節,即rsp從0x7fffffffdda0變成了0x7fffffffdd90,再將rsp賦值給rbp,天然就是0x7fffffffdd90。

  • 3-9行:執行函數的相關操做。
  • 11-13行:先執行加法操做,而後將結果放入eax寄存器做爲返回值
  • 14行:pop操做,此時棧頂爲main()函數的rbp值,將棧幀還原到main()函數中。
  • 15行:retq,pop操做,通過上一次pop操做,棧頂爲23行main()函數中的斷點地址,將其放入eip,即下一條指令將跳轉回main()函數中。
  • 23行:從eax寄存器中接收返回值,放入棧中相對與rbp的-0x4地址處,這裏並無作任何處理。
  • 25行:main函數返回,將上一步放入棧中的值做爲返回值放入eax寄存器中。

這個整個函數調用時棧幀變化的過程。若是到這裏你尚未看懂的話.....


函數調用約定

在一些經典操做系統的書中,介紹的都是參數壓棧,絕大部分的中間操做都是在棧上執行,可是博主上面貼出的示例明顯不同,例子中顯示其實不少操做都是在寄存器中執行的,而沒有使用到棧。這又是爲何呢?

這實際上是計算機硬件的發展帶來的優化結果,經典的操做系統如8086(16位),80386(32位),這兩種操做系統都只有8個通用寄存器來存儲函數調用時的中間數據,例如參數、返回值、棧幀結構指針等等。

即便是32位的計算機,8個寄存器極限狀態(通常不會達到)最多也是8*4(8位/字節)=32字節數據,可是到了64位系統中,寄存器從8個到16個,同時擴展到了64位,容量擴充了不少,因此在某些狀況下能夠直接使用寄存器操做。

這裏須要重點說起的主要是調用約定的問題。

調用約定種類

在函數調用時,關於壓棧順序、堆棧恢復等有一套相應的規則來進行約束,想一想若是每一個廠商都互不相讓,那麼就沒有可移植性可言了。主要的調用約定有這幾個:

  • stdcall
  • cdecl
  • fastcall
  • thiscall
  • naked call
    而stdcall、cdecl、fast call是咱們要討論的,他們分別有這樣的特點:

stdcall

  • 函數的參數壓棧順序爲從右到左
  • 參數的平衡由被調用者保持,即參數壓棧時由調用者執行,可是執行完以後由被調用函數來銷燬棧上的參數,保持堆棧平衡
  • 參數優先用棧傳遞

這種調用約定的典型就是pascal語言,可能你們不太熟悉這種編程語言,它還被普遍用於win32的API中。

關於第二點由被調用參數來銷燬棧上的數據,這致使這種調用方式並不支持可變參數函數的實現,由於可變參數函數相似printf()是在執行的時候才知道參數個數,可是被調用函數並不知道有多少個參數傳入,因此也就不能正確地釋放棧上的數據。(注2)

cdecl

  • 函數的參數壓棧順序爲從右到左
  • 參數的平衡由調用者保持,即參數壓棧時由調用者執行,執行完以後由調用函數來銷燬棧上的參數,保持堆棧平衡
  • 參數優先用棧傳遞

這是經典操做系統中C語言默認的調用方式,這種調用者保持堆棧平衡的調用方式是可變參數函數實現的基礎,可是因爲每一個平臺或者編譯器的棧實現方式和結構不同,將會加大實現程序可移植性的難度。

咱們能夠這樣理解,在stdcall調用中,是本身的事情本身作,本身執行完就將空間還給系統,而這種調用方式下,是本身的事情爸爸作,這樣在調用狀況複雜的狀況下會形成調用函數空間以及時間上的負擔。

fastcall

  • 函數的參數壓棧順序爲從右到左
  • 參數的平衡由調用者保持,即參數壓棧時由調用者執行,執行完以後由調用函數來銷燬棧上的參數,保持堆棧平衡
  • 參數優先用寄存器傳遞

顧名思義,這種調用方式的目標就是快!
主要是因爲第三個屬性:參數優先使用寄存器傳遞。CPU訪問內部寄存器就像是拿本身家的東西,而訪問內存就像從倉庫中取,須要在地址總線和數據總線的傳輸上消耗時間,寄存器速度比訪問內存要快不少,因此能用寄存器天然不會捨近求遠。

博主在X86-64機器上作了實驗並結合相關資料,顯示當實參小於6個時,參數由寄存器傳遞,當多餘6個時,將多餘參數壓棧,知道這些咱們就能夠寫出更高效的代碼-參數儘可能小於6個。

注1:在經典16或32位X86操做系統中,CPU有八個通用寄存器,可是隨着CPU的高速更新換代,64位的X86-64架構有16個64位通用寄存器,效率更高,並且寄存器名也作了小小修改,博主的電腦爲64位,因此是rsp而不是esp。(返回正文)

注2:這裏所說的銷燬(釋放)棧上的數據事實上並非像操做flash一下進行擦除操做,這裏僅僅是rsp指針的移動,並不會對地址上的數據進行任何操做,因此棧上的數據依舊存在,下一次運行時會覆蓋。因此咱們常常會強調不要返回局部變量指針,這是由於局部變量中的數據並非穩定的。另外一個例子是函數體內定義的變量須要初始化,由於局部變量是在棧上分配內存,若是不初始化,局部變量的值就沿用了棧上上一次執行遺留下來的值,是不肯定的。

好了,關於C/C++棧幀結構的討論就到此爲止啦,若是朋友們對於這個有什麼疑問或者發現有文章中有什麼錯誤,歡迎留言

原創博客,轉載請註明出處!

祝各位早日實現項目叢中過,bug不沾身.

相關文章
相關標籤/搜索