函式呼叫時堆疊的變化情況
程式碼編譯執行環境: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)]