函數調用機制例解

轉載自:

函數調用機制例解

      昨天室友拿一個面試題爲難我,問我C/C++函數調用是怎麼一個流程。這問題實在簡單,然而有一本什麼面試寶典卻說的前後不一,漏洞重重。室友盡信於書,非與我分個高低。單從機制本身來說,公說公有理,婆說婆有理,於是我就用了一個簡單的實驗才勉強說清楚。在此也順便總結一下,從彙編的角度介紹一下函數調用過程。

      當調用者比如h調用某個函數f時,從編譯器或者彙編語言角度來看,主要分以下幾個步驟進行:
  1. h將實參按照從右向左的順序一個個壓入stack中。
  2. 執行一個轉移指令call f
  3. f執行完函數體後,將返回值傳入寄存器AX/EAX/RAX中。
  4. f執行轉移指令ret
  5. h將實參從stack中一個一個彈出。
      由此可見,編譯器是不會把“下一條指令地址”壓入Stack中的。然而,當從f返回後,CPU是如何知道下一步應該執行什麼指令呢?也就是說下一條指令的地址從哪來的呢?這當然還是從stack中獲得。那麼,這個地址是什麼時候放到stack中的?還有,它什麼時候從stack中出來的?這些工作是由誰來完成的?是調用者?還是被調用者?這就得先從內存的角度看一下Stack的變化。主是看esp/rsp寄存器的內空以及該地址對應的內存單元的內容。
      
      具體來說,從內存的角度看,函數h調用f時,Stack是按下面步驟發生變化的:
  1.  實參按照從右向左的順序一個一個進入stack中。
  2. 函數調用指令之後的“下一條指令地址”進入stack中。
  3. 函數f中的局部變量加入到stack中。
  4. 函數f中的局部變量從Stack中彈出。
  5. “下一條指令地址”從stack中弱出,流入程序計數器寄存器IP中。
  6. 寄存器AX/EAX/RAX中的值流入到stack中h的局部變量(或者全局變量等)中。
  7. 調用函數f時的實參從stack中彈出。
      然而,由於編譯器的優化,用較新的編譯器將程序翻譯成彙編後,這部分邏輯變的比較難懂。如較新的GCC/G++編譯器壓stack和彈stack的操作都不是用push和pop指令實現的,而是一次性地將ESP/RSP增加一定的數值(分配好實參的空間),然後用MOV指令將參數放入Stack中的,這樣速度比較快。老版本的編譯器翻譯成的彙編比較好懂。

      那麼,到底是誰將“下一條指令地址”放入stack中的呢?當然是調用者h了。其實這個功能是一條彙編指令call實現的,而不是簡單的用push/pop/mov指令實現的。CALL指令的執行可以視爲做了以下工作:
  1. 將“下一條指令地址”壓入stack。
  2. 改變IP的值爲被調用的函數的地址。
      相應地,將“下一條指令取出”的操作是被調用者做的。其實這是RET指令的功能,而不是用PUSH/POP/MOV來實現的。從硬件角度來講,RET指令也沒有什麼特別的,它僅僅就是與CALL指令對應的功能相反的指令,與CALL做的工作恰好相反,恢復了IP寄存器,使其指向調用者調用函數之後的“下一條指令的地址”。

      想來個直觀點的說明,最好還是通過一個小程序。昨天用GCC已經做過測試,由於版本比較新,它做的優化太多了,比如它儘量使用寄存器進行參數傳遞而非Stack,所以介紹起來比較麻煩。並且GCC使用的AT&T彙編格式比較難懂,還是用WINDOWS下都熟悉的MASM格式的彙編來列一下吧。同時爲了清晰,少費點口舌,就用可視化的工具VC++6.0來介紹。

      首先,假設程序代碼如下(很簡單的):
複製代碼
int f(int a, int b)
{
    
return a*b;
}
int main(int argc, char *argv[])
{
    
int x = 0;
    x 
= f(5,6);
    
++x;
    
return 0;
}
複製代碼

      編譯完的彙編代碼不再列出。對它調度跟蹤一下,一切問題都有了着落。比如先把斷點設在x=f(5,6)的地方,執行到該位置後,各個寄存器的值如“Resisters”窗口所示,此時的斷點以及對應的彙編代碼如下:

      接着執行,執行到call那條指令時,內存內容及相應寄存器的值如下面的圖。Memory窗口顯示了當前Stack從頂部開始的內存內容。最頂的是參數5(佔4個字節,從低到高),然後是6,這兩個是傳給f的實參。此時,“下一條指令地址”也就是緊挨着的add指令的地址(0x401078)並沒有放到stack中。由此可見,“下一條指令地址”是第一個進棧的這種說法是不對的。



      然後接着執行,採用step into(VC6.0中對應F11),也就是僅執行call這條指令,而不執行f中的任何指令。執行後如下圖。可以看出,此時寄存器ESP的值變了,向下移動了4個字節,也就是stack中新插入了一個4字節的整數(其實它是一個內存地址)。這個新進來的地址是0x00401078,對應main函數中的add那條指令,也調用時的就是“下一條指令地址”。現在很清楚了,將“下一條指令地址壓stack”是CALL指令的功能,是硬件乾的,而不是軟件。



      然後要說明的就是函數f返回過程了。當程序執行到RETURN語句時,對應的內存和寄存器狀態如下。可以看出,RET指令執行之前,“下一條指令地址”還在stack中,同時EAX的值0x1E也就是30就是函數f的返回值。用EAX傳遞返回值是編譯器的一個習慣,能不能說是標準我不太確定。反正GNU系列的編譯器和微軟的都是這麼幹的。



      最後,當f中的RET執行完後,會形成如下格局。與上圖對比,會發現,stack頂的值跑到EIP裏面去了。stack中僅剩下之前的兩個實參!所以,“下一條指令地址”是第一個出棧的,而不是最後一個。接下來的add指令的意思是將ESP加8,其實就是將之前放入stack的兩個實參從stack中移除。函數調用也到此結束。



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