導語
因為參數傳遞和匯編語言有很大聯系,之后會出現較多x86
匯編代碼。
該文會先講一下x86
的堆棧參數傳遞過程,然后再分析C/C++
子函數是怎樣通過堆棧傳遞參數的。
注:匯編語言的過程和C/C++的子函數是一回事。
寄存器參數,存儲器參數和堆棧參數都可以用於x86
匯編乃至其他匯編語言傳遞參數的方式。但C/C++
在編譯時,編譯器會對子函數使用堆棧參數傳遞方式。
三種參數傳遞方式對比:
寄存器參數
...
mov eal,4
call Proc_using_eal
...
存儲器參數
.data
temp DB ?
.code
...
mov temp,4
call Proc_using_temp
...
堆棧參數
...
push 4
call Proc_using_stack
...
x86堆棧參數傳遞過程
考慮一個過程add_num
,該過程有兩個輸入參數,一個輸出參數。其功能是將兩個輸入參數求和並將其結果輸出。下面這個例子中使用堆棧將3, 4兩個參數輸入到add_num
中。
push 3
push 4
call add_num
執行call指令前,堆棧如下:
其中ESP
為x86CPU使用的堆棧指針,每進行一次入棧操作,ESP
要減4(32位CPU
)(圖上堆棧向上地址減小,向下地址增加)
明顯的是,add_num
只需要把堆棧中相應的變量取出來使用就可以了。堆棧參數傳遞的確也是這么做,但是卻要稍稍費事一點。
首先給出add_num
過程的程序
add_num proc
push ebp
mov ebp,esp
mov eax,[ebp+8]
add eax,[ebp+12]
pop ebp
ret
add_num endp
之前筆者給出的堆棧是CPU
執行call
指令前的結果,接下來從開始執行call
指令一步一步分析堆棧的變化情況。
call add_num
執行call add_num
時,ESP
減4后將add_num
過程的返回地址壓入堆棧,即當前指令指針EIP
的值(該值為主程序中call
指令的下一條指令(不是push ebp
)的地址)
push ebp
mov ebp,esp
mov eax,[ebp+8]
add eax,[ebp+12]
此時已經進入add_num
過程內部。
這一步是為了將esp的值賦予ebp
。而將ebp
壓入堆棧是為了保護ebp
,在add_num
過程結束后還要恢復ebp
的值。
此時esp指向堆棧中的ebp
,而將esp賦予ebp
后,ebp
便指向了堆棧中自己被保護的值。此時ebp
的主要作用是為參數讀取提供絕對地址。比如參數4比ebp
所在地址高8Byte
(堆棧一個單元是4Byte
),則過程中要使用參數4時,使用基址-偏移量尋址即可,即[ebp+8]
。
當然這里也可以使用esp
達到相同的效果,但是這個例子沒有局部變量。若子過程中有局部變量(局部變量也存放在堆棧里),采用ebp
要方便很多。
pop ebp
此時ebp
彈出,ebp
恢復調用前的值
ret
最后彈出返回地址,程序返回到主程序中,並執行下一條指令
以上為整個堆棧參數傳遞過程。
需要注意的點:
堆棧幀到底是什么?
堆棧幀(stack frame
)(或活動記錄(activation record
))是一塊堆棧保留區域,用於存放被傳遞的實際參數、子程序的返回值、局部變量以及被保存的寄存器。
實際上堆棧幀就相當於子函數的緩存,當子函數使用的堆棧個數最大時,其所擁有的所有部分構成了這個函數的堆棧幀。
以add_num
過程為例,其堆棧幀如下圖灰色部分所示。
堆棧幀為什么叫做堆棧幀
“堆棧”很好理解,而“幀”的概念在上面那個例子中的確很難搞通。不久后筆者會分析遞歸函數中的堆棧幀增消的現象,那個時候“幀”這個概念體現得淋漓盡致。
輸入參數3和4留在堆棧里沒有釋放是可以的嗎
上面的例子並沒有釋放參數4和3,只是為了演示,實際上一定會有相應的代碼去釋放它。子函數的堆棧幀是包含其輸入堆棧變量的,當退出子函數時,其所有的堆棧幀必須被完全釋放,否則堆棧就會變得混亂。
釋放參數涉及兩種子函數調用標准,一種是STDCALL
標准,一種是C標准。兩種在參數的堆棧傳遞細節幾乎完全相同,不同的是釋放參數的方式。
根據兩個標准重新改寫add_num
過程:
STDCALL
調用規范
add_num proc
push ebp
mov ebp,esp
mov eax,[ebp+8]
add eax,[ebp+12]
pop ebp
ret 8
add_num endp
C調用規范
...
push 3
push 4
call add_num
add esp,8
兩種方式的核心思想就是修改esp,使esp指向堆棧參數3和4所在位置的前一個堆棧。但是STDCALL
調用規范是在過程內部修改esp
(ret 8
為將堆棧中返回地址彈出到EIP
后,再將ESP
加8);C調用規范是在子過程外部,在主調過程修改esp
。
另引用這兩種方式的優缺點:
STDCALL不僅減少了子程序調用產生的代碼量(減少了一條指令),還保證了調用程序永遠不會忘記清除堆棧。另一方面,C調用規范允許子程序聲明不同數量的參數,主調程序可以決定傳遞多少個參數。C語言的printf函數就是一個例子
C語言參數傳遞分析
我們仍考慮一個子函數有兩個輸入參數,一個輸出參數,實現兩個參數相加並輸出。
程序如下:
int add_num(int x, int y)
{
return(x+y);
}
int main()
{
int sum;
sum = add_num(3,4);
return(0);
}
編譯后輸出的匯編代碼如下:
; Listing generated by Microsoft (R) Optimizing Compiler Version 18.00.21005.1
TITLE D:\MyDocuments\《匯編語言-基於x86處理器》資料\Compile_test\Compile_test\Compile_test\main.c
.686P
.XMM
include listing.inc
.model flat
INCLUDELIB MSVCRTD
INCLUDELIB OLDNAMES
PUBLIC _add_num
PUBLIC _main
EXTRN __RTC_CheckEsp:PROC
EXTRN __RTC_InitBase:PROC
EXTRN __RTC_Shutdown:PROC
; COMDAT rtc$TMZ
rtc$TMZ SEGMENT
__RTC_Shutdown.rtc$TMZ DD FLAT:__RTC_Shutdown
rtc$TMZ ENDS
; COMDAT rtc$IMZ
rtc$IMZ SEGMENT
__RTC_InitBase.rtc$IMZ DD FLAT:__RTC_InitBase
rtc$IMZ ENDS
; Function compile flags: /Odtp /RTCsu /ZI
; File d:\mydocuments\《匯編語言-基於x86處理器》資料\compile_test\compile_test\compile_test\main.c
; COMDAT _main
_TEXT SEGMENT
_sum$ = -8 ; size = 4
_main PROC ; COMDAT
; 7 : {
push ebp
mov ebp, esp
sub esp, 204 ; 000000ccH
push ebx
push esi
push edi
lea edi, DWORD PTR [ebp-204]
mov ecx, 51 ; 00000033H
mov eax, -858993460 ; ccccccccH
rep stosd
; 8 : int sum;
; 9 : sum = add_num(3, 4);
push 4
push 3
call _add_num
add esp, 8
mov DWORD PTR _sum$[ebp], eax
; 10 : return(0);
xor eax, eax
; 11 : }
pop edi
pop esi
pop ebx
add esp, 204 ; 000000ccH
cmp ebp, esp
call __RTC_CheckEsp
mov esp, ebp
pop ebp
ret 0
_main ENDP
_TEXT ENDS
; Function compile flags: /Odtp /RTCsu /ZI
; File d:\mydocuments\《匯編語言-基於x86處理器》資料\compile_test\compile_test\compile_test\main.c
; COMDAT _add_num
_TEXT SEGMENT
_x$ = 8 ; size = 4
_y$ = 12 ; size = 4
_add_num PROC ; COMDAT
; 2 : {
push ebp
mov ebp, esp
sub esp, 192 ; 000000c0H
push ebx
push esi
push edi
lea edi, DWORD PTR [ebp-192]
mov ecx, 48 ; 00000030H
mov eax, -858993460 ; ccccccccH
rep stosd
; 3 : return(x + y);
mov eax, DWORD PTR _x$[ebp]
add eax, DWORD PTR _y$[ebp]
; 4 : }
pop edi
pop esi
pop ebx
mov esp, ebp
pop ebp
ret 0
_add_num ENDP
_TEXT ENDS
END
首先看call _add_num
指令
; 8 : int sum;
; 9 : sum = add_num(3, 4);
push 4
push 3
call _add_num
add esp, 8
很明顯使用了C調用規范,在調用完成后從堆棧中刪除堆棧參數。
再看add_num
子程序對應的匯編代碼:
_add_num PROC ; COMDAT
; 2 : {
push ebp
mov ebp, esp
sub esp, 192 ; 000000c0H
push ebx
push esi
push edi
lea edi, DWORD PTR [ebp-192]
mov ecx, 48 ; 00000030H
mov eax, -858993460 ; ccccccccH
rep stosd
; 3 : return(x + y);
mov eax, DWORD PTR _x$[ebp]
add eax, DWORD PTR _y$[ebp]
; 4 : }
pop edi
pop esi
pop ebx
mov esp, ebp
pop ebp
ret 0
_add_num ENDP
其中有兩個地方之前沒有介紹,
一是:
push ebx
push esi
push edi
...
pop edi
pop esi
pop ebx
這部分代碼是為了保護寄存器
二是:
sub esp, 192 ; 000000c0H
push ebx
push esi
push edi
lea edi, DWORD PTR [ebp-192]
mov ecx, 48 ; 00000030H
mov eax, -858993460 ; ccccccccH
rep stosd
除去push命令,剩下的部分是為了初始化堆棧,將棧頂后192Byte
的空間寫入ccccccccH
(個人認為這一步可以不需要,只是用來增加程序穩定性的)
將這兩部分刪掉后,即可得到:
_add_num PROC ; COMDAT
; 2 : {
push ebp
mov ebp, esp
; 3 : return(x + y);
mov eax, DWORD PTR _x$[ebp]
add eax, DWORD PTR _y$[ebp]
; 4 : }
mov esp, ebp
pop ebp
ret 0
_add_num ENDP
和之前的add_num
的x86
匯編子過程作比較:
add_num proc
push ebp
mov ebp,esp
mov eax,[ebp+8]
add eax,[ebp+12]
pop ebp
ret
add_num endp
兩者基本一致。但是編譯器給出結果多出一個mov esp,ebp
。這句命令在這里有沒有都沒有關系,因為這個函數沒有局部變量。但是如果有局部變量的話,是一定要加上的。可以自己寫一個帶有局部變量的函數,自己想一想。下一篇博文會講述帶有局部變量的情況。