簡析C語言中的函數調用棧機制

來源:互聯網
上載者:User

轉自:http://www.veisun.com/09/2009-06/NewInfo-3360.html

 

前言

首先來說一下,為什麼我們要瞭解函數調用棧機制。很多人會說,我從未關注函數調用棧機制,同樣可以寫出工作的很好的程式,知道這個又有什麼
用。但是,對於任何技術而言,我們對其瞭解的越透徹,才能越好的駕馭他;另一方面,在實際工作中,我們經常會遇到通過損毀傾印檔案(.dump)檔案來定
位BUG的問題,如果不對函數調用棧機制有一個清晰的認識,就很難從dump中得到函數參數,傳回值的寶貴資訊。

 

本文約定

本文的討論基於以下假設,做這些假設是為了討論結果更為確定,避免二義性:

  1. 討論的是C語言中的函數調用機制,基本上也適用於C++。
  2. 調用使用__cdecl約定,也就是由調用者(下文也稱caller)而非被調用者(下文也稱callee)負責壓入與清理參數和返回地址。
  3. 不考慮FPO等最佳化技術。

 

本文

本文的討論基於以下的範例程式碼,代碼功能很簡單,但是足以闡述清楚本文的主題。

 

C語言範例程式碼

int Add(int a, int b);<br />int main()<br />{<br /> int a = 12;<br /> int b = 34;<br /> int c = Add(a, b);<br /> return 0;<br />}<br />int Add(int a, int b)<br />{<br /> int c = a + b;<br /> return c;<br />}

 

對應的彙編代碼

第一步,我們需要得到彙編結果。侯捷老師有句名言,叫做原始碼面前沒有秘密可言,對於程式邏輯來說的確如此,但是要想瞭解機器的運作機制,目前看來只有匯
編可以做到,可以說彙編面前電腦脫去了最後一層薄紗。在Visual
Studio裡面想得到彙編代碼在Project→Property→C/C++→OutputFiles裡面把 Assembler
Output選上就可以了。彙編後的結果如下:

 

; Listing generated by Microsoft (R) Optimizing Compiler Version 15.00.30729.01 </p><p> TITLE d:/Projects/ForWinDbg/FunctionCall/FunctionCall.cpp<br /> .686P<br /> .XMM<br /> include listing.inc<br /> .model flat </p><p>INCLUDELIB MSVCRTD<br />INCLUDELIB OLDNAMES </p><p>PUBLIC ?Add@@YAHHH@Z ; Add<br />PUBLIC _main<br />EXTRN __RTC_CheckEsp:PROC<br />EXTRN __RTC_Shutdown:PROC<br />EXTRN __RTC_InitBase:PROC<br />; COMDAT rtc$TMZ<br />; File d:/projects/forwindbg/functioncall/functioncall.cpp<br />rtc$TMZ SEGMENT<br />__RTC_Shutdown.rtc$TMZ DD FLAT:__RTC_Shutdown<br />rtc$TMZ ENDS<br />; COMDAT rtc$IMZ<br />rtc$IMZ SEGMENT<br />__RTC_InitBase.rtc$IMZ DD FLAT:__RTC_InitBase<br />; Function compile flags: /Odtp /RTCsu /ZI<br />rtc$IMZ ENDS<br />; COMDAT _main<br />_TEXT SEGMENT<br />_c$ = -32 ; size = 4<br />_b$ = -20 ; size = 4<br />_a$ = -8 ; size = 4<br />_main PROC ; COMDAT </p><p>; 4 : { </p><p> push ebp<br /> mov ebp, esp<br /> sub esp, 228 ; 000000e4H<br /> push ebx<br /> push esi<br /> push edi<br /> lea edi, DWORD PTR [ebp-228]<br /> mov ecx, 57 ; 00000039H<br /> mov eax, -858993460 ; ccccccccH<br /> rep stosd </p><p>; 5 : int a = 12; </p><p> mov DWORD PTR _a$[ebp], 12 ; 0000000cH </p><p>; 6 : int b = 34; </p><p> mov DWORD PTR _b$[ebp], 34 ; 00000022H </p><p>; 7 :<br />; 8 : int c = Add(a, b); </p><p> mov eax, DWORD PTR _b$[ebp]<br /> push eax<br /> mov ecx, DWORD PTR _a$[ebp]<br /> push ecx<br /> call ?Add@@YAHHH@Z ; Add<br /> add esp, 8<br /> mov DWORD PTR _c$[ebp], eax </p><p>; 9 :<br />; 10 : return 0; </p><p> xor eax, eax </p><p>; 11 : } </p><p> pop edi<br /> pop esi<br /> pop ebx<br /> add esp, 228 ; 000000e4H<br /> cmp ebp, esp<br /> call __RTC_CheckEsp<br /> mov esp, ebp<br /> pop ebp<br /> ret 0<br />_main ENDP<br />; Function compile flags: /Odtp /RTCsu /ZI<br />_TEXT ENDS<br />; COMDAT ?Add@@YAHHH@Z<br />_TEXT SEGMENT<br />_c$ = -8 ; size = 4<br />_a$ = 8 ; size = 4<br />_b$ = 12 ; size = 4<br />?Add@@YAHHH@Z PROC ; Add, COMDAT </p><p>; 14 : { </p><p> push ebp<br /> mov ebp, esp<br /> sub esp, 204 ; 000000ccH<br /> push ebx<br /> push esi<br /> push edi<br /> lea edi, DWORD PTR [ebp-204]<br /> mov ecx, 51 ; 00000033H<br /> mov eax, -858993460 ; ccccccccH<br /> rep stosd </p><p>; 15 : int c = a + b; </p><p> mov eax, DWORD PTR _a$[ebp]<br /> add eax, DWORD PTR _b$[ebp]<br /> mov DWORD PTR _c$[ebp], eax </p><p>; 16 : return c; </p><p> mov eax, DWORD PTR _c$[ebp] </p><p>; 17 : } </p><p> pop edi<br /> pop esi<br /> pop ebx<br /> mov esp, ebp<br /> pop ebp<br /> ret 0<br />?Add@@YAHHH@Z ENDP ; Add<br />_TEXT ENDS<br />END
 

 

解析

我們要分析的是main函數對Add函數的調用過程。

 

在main函數裡面,是這樣做的。<br /> mov eax, DWORD PTR _b$[ebp] ;將參數b的值放入eax寄存器中<br /> push eax ;將eax寄存器的值壓入棧中<br /> mov ecx, DWORD PTR _a$[ebp] ;將參數a的值放入ecx寄存器中<br /> push ecx ;將ecx寄存器的值壓入棧中<br /> call ?Add@@YAHHH@Z ;調用Add函數<br /> add esp, 8 ;清理參數<br /> mov DWORD PTR _c$[ebp], eax ;將eax的值賦給c變數,即處理傳回值</p><p> 而在Add函數裡面,是這樣做的。<br /> push ebp ;將ebp寄存器的值壓入棧中<br /> mov ebp, esp ;將esp寄存器的值賦給ebp寄存器<br /> ;省略部分,函數Add的內部工作,本文不關心<br /> mov esp, ebp ;將ebp寄存器的值賦給esp寄存器<br /> pop ebp ;從棧中彈出一個值,並賦給ebp寄存器<br /> ret 0 ;函數返回

 

應該說,C的函數調用都是基於以上的架構結構的,無非是可能函數的參數更多一些,類型更複雜一些而已。通過注釋,我們已經知道函數調用的邏輯
過程,但是還存在一個嚴重的問題,為什麼這樣的調用就可以保證棧能正常恢複?或者說,為什麼調用Add以後棧可以恢複的恰到好處?本文試圖解答這個問題。

 

要完成函數調用時棧的擴充和恢複,主要是由esp和ebp這兩個寄存器實現的。esp是棧頂寄存器,裡面存的是下次push時將會寫入的地
址,當然了,由於x86架構下棧由高地址向低地址生長,所以push會導致esp變小,而pop導致esp變大,因而有人也將esp稱為棧底寄存器,這個
稱呼是無所謂的,也不影響討論。ebp是基底位址暫存器,裡面存的函數的基地址。應該說esp是很好理解的,但是ebp不然,基地址到底是什麼呢?我們可以通
過程式運行時esp和ebp所儲存的值來解答這個問題。

 

做一個註明,以下所稱函數運行到某某指令都是指該指令將執行而未執行,和在這行打斷點是一個意思。

 

  1. 程式運行至mov eax, DWORD PTR
    _b$[ebp],假設此時esp=0x0012fe78,ebp=0x0012ff68。當然了,這也不是隨便假設的,在程式運行中,始終是有esp&
    lt;=ebp這樣的不大於關係存在的,否則只有一種可能,棧已經被破壞了。
  2. 程式運行至call ?Add@@YAHHH@Z
    ,由於壓入了兩個參數,esp的值減8。此時esp=0x0012fe70,ebp=0x0012ff68。
  3. 程式運行至push ebp,這裡已經進入了Add函數,函數的返回地址也被壓入棧中,所以esp的值再減4。此時esp=0x0012fe6c,ebp=0x0012ff68。
  4. 程式運行至mov ebp, esp,由於將ebp壓棧,esp的值再減4。此時esp=0x0012fe68,ebp=0x0012ff68。
  5. 將esp寄存器的值賦給ebp寄存器,此時esp = ebp =
    0x0012fe68。此時ebp的值就很奇妙了,ebp+4就是函數的返回地址,ebp+8就是函數從左往右的第一個參數,ebp+12是第二個參數,
    依次類推。這次賦值意味著一個重要的時刻,那就是caller和callee職責的交割,這以後callee執行自己的代碼,更改esp的值,都是
    callee自己的事情,而ebp寄存器已經將這一時刻的棧頂記錄了下來。此外,ebp還是caller和callee之間的資料樞紐,callee通過
    ebp加上位移量才能得到參數的值,ebp被稱為基地址寄存器的意義也就在於此,以他為基準,區分了caller和callee的資料,也就是參數,返回
    地址和callee局部變數。
  6. 程式運行到mov esp, ebp,由於Add函數中有局部變數,esp減小了一些。此時esp=0x0012fd9c,ebp=0x0012fe68。
  7. 將ebp寄存器的值賦給esp寄存器。這絕不應該理解為一句簡單的賦值,這次賦值意味著Add函數的局部變數都已經被抹去,如果只考慮棧,可以說Add函數對棧的影響已經消除。此時esp=ebp=0x0012fe68,和步驟5時一致,我們應該意識到,棧的恢複開始了。
  8. 從棧中pop出一個值,並賦給ebp寄存器。由於ebp在步驟4中記錄的是運行完push ebp的棧頂,恰好此時彈出的是當時壓入的值,當然了,由於pop操作,esp需要加4。此時esp=0x0012fe6c,ebp=0x0012ff68,與步驟3一致。
  9. 程式運行至ret 0,此時如果觀察eip寄存器的值,就會發現和步驟5中的ebp+4的值一樣,由於eip寄存器儲存的是CPU將要啟動並執行下一條指令,這裡也可以看出函數返回地址的含義。
  10. 函數返回,將函數返回地址彈出棧,esp加4。此時esp=0x0012fe70,ebp=0x0012ff68,與步驟2一致。
  11. 清理參數,此時程式又回到了main函數中,由於是兩個參數,所以esp加8。此時esp=0x0012fe78,ebp=0x0012ff68,與步驟1一致,棧恢複完成。

 

通過以上運行時的分析,我們可以看到在函數調用過程中棧的擴充與恢複的動態過程,應該說C裡面這個函數調用棧機制的設計是頗為精巧的,關鍵是
esp和ebp這兩個寄存器之間的賦值時機,正是caller和callee職責交替的時機,正是這個時機的正確,才能實現正確的恢複。

 

如果清楚了C中的函數調用棧機制,還是有一些立竿見影的效果,舉兩個例子:

  1. 網上盛傳的Google面試題,如何通過C/C++編程來判斷棧的生長方向。很明顯,如果比較函數中兩個局部變數的地址,這是很不靠譜,在函
    數調用中,由於局部變數導致的esp位移是一次性完成的,沒有什麼機制保證先聲明或者先使用的局部變數更靠近棧底或者棧頂,不過參數總是比局部變數先壓棧
    的,拿個參數和局部變數的地址比較一下直接就出結果了。
  2. 棧的正確恢複極大程度的依賴於壓入的ebp的值的正確性,但是ebp和局部變數是挨的很近的,如果編程過程中有意無意的通過局部變數的地址偏
    移竄改了ebp,程式的行為將變得非常危險,至於有意與無意的區別無非就在與惡意軟體與BUG之分。雖然Visual Studio
    2005以後有了一些保護措施,不過我們編程中依然需要對此非常小心。:)

 

==================================================================

後記:

之所以找了這篇文章,是因為在調試代碼的時候,碰到了自以為奇怪的問題,所調試的代碼大概如下:

char *pMsg = NULL;<br />//這裡給pMsg複製<br />pMsg = (char *)malloc(MSG_SIZE_MAX);<br />//printf("message address :%p/n", pMsg);<br />FillMsg(pMsg);<br />//printf("message address :%p/n", pMsg);<br />....<br />

 

在以上代碼中,如果沒有printf語句,則在FillMsg函數調用後,pMsg的值被改了,當然,肯定不是在FillMsg函數故意改的。最後發現了原因,那就是,函數調用棧被破壞了,因為在FillMsg函數中定義了一個長度為256的字元數組,在該項目的老版本中,這個長度無論如何都是夠了的,但是很不幸,在新版本中由於重構的緣故,這個長度卻顯得有點勉強了,雖然大多數情況下是夠了。

 

假設調用FillMsg的這個函數的函數名為AddMsg。因為一般來說,往字元數組裡拷貝內容的時候,都是從低地址寫往高地址的,而函數調用棧的壓棧順序則是從高地址壓往低地址的(即棧底在高地址一端,而棧頂在低地址一端),所以,FillMsg函數中定義的字元數組如果寫越界了就會破壞AddMsg函數的局部變數了。

 

有了所找的這篇文章的知識背景後,這個問題就好找多了!

 

像這種調用棧被踩(即上面說的被破壞)的情況還是比較好定位的,最難的是記憶體被踩,這個以後再整理一篇文章出來

 

 

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.