1. 程式人生 > >函式呼叫時堆疊的變化情況

函式呼叫時堆疊的變化情況

程式碼編譯執行環境:VS2017+Debug+Win32

函式的正常執行必然要利用堆疊,至少,函式的返回地址是儲存在堆疊上的。函式一般要利用引數,而且內部也會用到區域性變數,在對錶達式進行求值時,編譯器還會生成一些無名臨時物件,這些物件都是存放在堆疊上的。

下面以Visual C++編譯器為例進行研究,考察如下程式。

#include <stdio.h>

int mixAdd(int i,char c)
{
    int tmpi=i;
    char tmpc=c;
    return tmpi+tmpc;
}

int main()
{
    int res=mixAdd(4
,'A'); printf("%c",res); }

在VS2017環境下,以C/C++預設的函式呼叫約定__cdecl來生成該程式的除錯版本(Debug)的彙編程式碼。

mixAdd()函式對應的彙編程式碼是:

int mixAdd(int i,char c)
{
00F713E0  push        ebp  
00F713E1  mov         ebp,esp  
00F713E3  sub         esp,0D8h  
00F713E9  push        ebx  
00F713EA  push        esi  
00F713EB  push        edi  
00F
713EC lea edi,[ebp-0D8h] 00F713F2 mov ecx,36h 00F713F7 mov eax,0CCCCCCCCh 00F713FC rep stos dword ptr es:[edi] int tmpi=i; 00F713FE mov eax,dword ptr [i] 00F71401 mov dword ptr [tmpi],eax char tmpc=c; 00F71404 mov al,byte ptr [c] 00F71407 mov byte ptr [tmpc],al return
tmpi+tmpc; 00F7140A movsx eax,byte ptr [tmpc] 00F7140E add eax,dword ptr [tmpi] } 001E1411 pop edi 001E1412 pop esi 001E1413 pop ebx 001E1414 mov esp,ebp 001E1416 pop ebp 001E1417 ret

main()函式對應的彙編程式碼:

int main()
{
001E1430  push        ebp  
001E1431  mov         ebp,esp  
001E1433  sub         esp,0CCh  
001E1439  push        ebx  
001E143A  push        esi  
001E143B  push        edi  
001E143C  lea         edi,[ebp-0CCh]  
001E1442  mov         ecx,33h  
001E1447  mov         eax,0CCCCCCCCh  
001E144C  rep stos    dword ptr es:[edi]  
    int res=mixAdd(4,'A');
001E144E  push        41h  
001E1450  push        4  
001E1452  call        mixAdd (01E1168h)  
001E1457  add         esp,8  
001E145A  mov         dword ptr [res],eax  
    printf("%c",res);
001E145D  mov         esi,esp  
001E145F  mov         eax,dword ptr [res]  
001E1462  push        eax  
001E1463  push        1E5858h  
001E1468  call        dword ptr ds:[1E92C0h]  
001E146E  add         esp,8  
001E1471  cmp         esi,esp  
001E1473  call        __RTC_CheckEsp (01E1136h)  
}
001E1478  xor         eax,eax  
}
001E147A  pop         edi  
001E147B  pop         esi  
001E147C  pop         ebx  
001E147D  add         esp,0CCh  
001E1483  cmp         ebp,esp  
001E1485  call        __RTC_CheckEsp (01E1136h)  
001E148A  mov         esp,ebp  
001E148C  pop         ebp  
001E148D  ret  

1.mixAdd()函式彙編程式碼詳解

在進入mixAdd後,可以馬上看到這樣三條彙編指令:

push ebp      //保留主調函式的幀指標
mov ebp,esp   //建立本函式的幀指標
sub esp,xxx   //為函式區域性變數分配空間

這是所有C/C++函式的彙編程式碼所共同遵循的規範。其中,ebp被稱為“幀指標”,擴充套件基址指標暫存器(extended base pointer),其存放一個指標,該指標指向系統棧最上面一個棧幀的底部。這裡的幀指的是每一個函式在被呼叫時所佔有的記憶體空間,該空間記憶體放函式的區域性資料。

一幀的資料的起始位置由幀指標ebp指明,而幀的另一端由棧指標esp動態維護。ESP就是當前函式的棧頂指標。在函式執行期間,幀指標ebp的值保持不變。

在記憶體管理中,與棧對應是堆。對於堆來講,生長方向是向上的,也就是向著記憶體地址增加的方向;對於棧來講,它的生長方式是向下的,是向著記憶體地址減小的方向增長。在記憶體中,“堆”和“棧”共用全部的自由空間,只不過各自的起始地址和增長方向不同,它們之間並沒有一個固定的界限,如果在執行時,“堆”和 “棧”增長到發生了相互覆蓋時,稱為“棧堆衝突”,程式將會崩潰。

在Debug模式下,一個C/C++函式即使沒有定義一個區域性變數,仍然會分配192Bytes空間,供臨時變數使用。如果定義了局部變數,則會為每個區域性變數分配12位元組的空間(大於任何基本資料型別)。mixAdd()函式中定義了兩個區域性變數,所以給區域性變數和臨時變數預留空間大小是192+12+12=216(D8h)。

接下來的彙編指令:

00F713E9  push        ebx  //儲存擴充套件基址暫存器,入棧
00F713EA  push        esi  //儲存擴充套件源變址暫存器,入棧
00F713EB  push        edi  //儲存擴充套件目的變址暫存器,入棧

以上彙編指令儲存本函式可能改變的幾個暫存器的值,這些暫存器在函式結束後恢復到進入本函式的時候的值。

接下來的彙編指令:

00F713EC  lea         edi,[ebp-0D8h]      //獲取棧頂地址
00F713F2  mov         ecx,36h             //賦36H至擴充套件計數暫存器
00F713F7  mov         eax,0CCCCCCCCh      //給擴充套件累加暫存器賦值
00F713FC  rep stos    dword ptr es:[edi]  //作用見下面解釋

stos指令:字串儲存指令,將eax中的值拷貝至es:[edi]指向的空間,如果設定了direction flag, 那麼edi會在該指令執行後減小, 如果沒有設定direction flag, 那麼edi的值會增加, 這是為了下一次的儲存做準備。

rep指令:重複指令,重複執行後面制定的指令操作,重複次數由計數暫存器ecx決定。

因此,上面四條指令的作用是從棧的低地址到高地址將所有的預留空間填滿0cccccccch,這樣也解釋了未賦值的區域性變數預設被設定為CCCCCCCCH。

接下來的彙編指令:

    int tmpi=i;
00F713FE  mov         eax,dword ptr [i]     //i賦值給eax
00F71401  mov         dword ptr [tmpi],eax  //eax賦值給tmpi
    char tmpc=c;
00F71404  mov         al,byte ptr [c]    //c賦值給暫存器ax低8位al
00F71407  mov         byte ptr [tmpc],al //al賦值給tmpc
    return tmpi+tmpc;
00F7140A  movsx       eax,byte ptr [tmpc]  //帶符號擴充套件傳送指令,將rmpc賦值給eax
00F7140E  add         eax,dword ptr [tmpi] //tmpi與eax相加

以下彙編指令,用於函式結束的清理工作:

001E1411  pop         edi     //edi出棧,還原edi
001E1412  pop         esi     // esi出棧,還原esi
001E1413  pop         ebx     // ebx出棧,還原ebx
001E1414  mov         esp,ebp // 清空棧,釋放區域性變數
001E1416  pop         ebp     //源ebp出棧,恢復ebp
001E1417  ret                 //子程式的返回指令,結束函式

注意:以上彙編程式碼對mixAdd()函式的呼叫採用的函式呼叫約定是__cdecl,這是C/C++程式的預設函式呼叫約定,其重要的一點就是在被呼叫函式 (Callee) 返回後,由呼叫方 (Caller)調整堆疊,因此在main()函式中呼叫mixAdd()的地方會出現add esp 8這條指令。esp加上8,是因為main()函式將兩個引數壓入棧,用於傳給mixAdd()。感興趣的讀者將mixAdd()函式的定義改為如下形式:

int __stdcall mixAdd(int i,char c)
{
    int tmpi=i;
    char tmpc=c;
    return tmpi+tmpc;
}

即將mixAdd()函式的呼叫約定改為標準呼叫約定,那麼mixAdd()函式結束時的彙編程式碼會變成ret 8,main()函式呼叫mixAdd()的地方會原本出現的add esp 8這條指令將會消失,這是因為__stdcall約定被調函式自身清理堆疊。有關函式呼叫約定的介紹見我的另一篇blog:關於函式引數入棧的思考

2. main()函式對應的彙編程式碼注意要點

main()函式的彙編程式碼大致與mixAdd()相似,但也有不同之處,需要注意以下幾點。
printf(“%c”,res);對應的幾條彙編程式碼
(1)printf()函式引數的入棧和呼叫

push        1E5858h  //將”%c”入棧
call        dword ptr ds:[1E92C0h]  //呼叫printf()函式

(2)以下兩條彙編程式碼的意思

001E1471  cmp         esi,esp  
001E1473  call        __RTC_CheckEsp (01E1136h)  

上面兩條彙編用於表示VC編譯器提供了執行時刻的對程式正確性/安全性的一種動態檢查,可以在專案屬性的C++選項中開啟來啟用Runtime Check。開啟與開啟步驟如下圖:
這裡寫圖片描述

參考文獻

[1]rep stos dword ptr es:[edi] 是做什麼的?
[2]陳剛.C++高階進階教程[M].武漢:武漢大學出版社,2008.[3.3(P97-P100)]