這種傳遞參數的方法在C/C++語言里面比較流行。
如下的代碼片段所示,調用者反序地把參數壓到棧中:最后一個參數,倒數第二個參數,第一個參數。調用者還必須在函數返回之后把棧指針(ESP)還原為初始狀態。
Listing 64.1: cdecl
push arg3
push arg2
push arg1
call function
add esp, 12 ; returns ESP
該調用方法與cdecl差不多,除了被調用者必須通過RET x指令代替RET指令將ESP指針設置為初始化狀態,其中x = arguments number * sizeof(int)
。調用者無需調整棧指針(ESP)。
Listing 64.2: stdcall
push arg3
push arg2
push arg1
call function
function:
... do something ...
ret 12
這種調用方式在win32的標準庫無處不在,但win64并不使用該調用方法(具體參見下文win64一節)。
舉個例子,我們可以稍微把在91頁中8.1的示例代碼修改一下,增加一個__stdcall
修飾符。
#!c
int __stdcall f2 (int a, int b, int c)
{
return a*b+c;
};
編譯出來的結果跟8.2幾乎一模一樣,但你可以看到它是通過RET 12而不是RET返回的。同時,調用者并沒有調整棧指針(ESP)。
因此,很容易通過RETN n指令推導出函數參數的數量(n除以四)。
Listing 64.3: MSVC 2010
_a$ = 8 ; size = 4
_b$ = 12 ; size = 4
_c$ = 16 ; size = 4
[email protected] PROC
push ebp
mov ebp, esp
mov eax, DWORD PTR _a$[ebp]
imul eax, DWORD PTR _b$[ebp]
add eax, DWORD PTR _c$[ebp]
pop ebp
ret 12 ; 0000000cH
[email protected] ENDP
; ...
push 3
push 2
push 1
call [email protected]
push eax
push OFFSET $SG81369
call _printf
add esp, 8
printf()系列的函數大概是C/C++里面唯一一系列具有可變參數的函數了,在這些函數的幫助下很容易理清cdecl和stdcall兩種調用方式之間的重要區別。讓我們先假設編譯器知道每個調用printf()函數的參數的個數,無論如何,當我們調用printf()的時候,它已經存在于編譯好的MSVCRT.DLL之中(我們討論的是Windows),并沒有任何關于傳遞多少個參數的信息,剩下的辦法就是通過它的格式字符串獲取得到參數個數。因此,如果printf()函數是一個stdcall調用方式的函數,它必須通過格式字符串計算參數個數用于恢復棧指針(ESP),這是一種相當危險的情況,程序員的一個錯別字就可以導致程序崩潰。因此此類函數使用cdecl調用方式遠比使用stdcall調用方式適合。
這是一種將部分參數通過寄存器傳入,其余參數通過棧方式傳入的方法。它的執行效率在一些舊時CPU比cdecl/stdcall要好(因為小棧的壓力)。然而,在現代的CPU中使用該調用方式不一定能獲得更好的性能。
fastcall并沒有一個標準化,因此不同的編譯器的實現可以不同。這是一個眾所周知的警告:如果你有兩個DLL,其中第一個DLL調用第二個DLL的函數,它們是又分別不同的編譯器使用fastcall調用方式編譯出來的,則會有不可預期的后果。
MSVC和GCC兩個編譯器都是通過ECX和EDX來傳遞第一個和第二個參數,通過棧進行傳遞其余參數。棧指針必須被被調用者恢復為初始狀態(與stdcall類似)。
Listing 64.4: fastcall
push arg3
mov edx, arg2
mov ecx, arg1
call function
function:
.. do something ..
ret 4
舉個例子,我們可以稍微把8.1的示例代碼修改一下,增加一個__fastcall
修飾符。
#!c
int __fastcall f3 (int a, int b, int c)
{
return a*b+c;
};
下面它編譯出來的結果:
Listing 64.5: Optimizing MSVC 2010 /Ob0
_c$ = 8 ; size = 4
@[email protected] PROC
; _a$ = ecx
; _b$ = edx
mov eax, ecx
imul eax, edx
add eax, DWORD PTR _c$[esp-4]
ret 4
@[email protected] ENDP
; ...
mov edx, 2
push 3
lea ecx, DWORD PTR [edx-1]
call @[email protected]
push eax
push OFFSET $SG81390
call _printf
add esp, 8
我們可以看到被調用者使用RET N指令來調整棧指針(ESP)。這意味著,我們可以通過這條指令來推斷出參數的個數。
這是一種對fastcall調用方式的某種優化。使用-mregparm編譯選項可以設置多少個參數是通過寄存器傳遞的(最大為3個)。因此,EAX,EDX和ECX寄存器將被使用。
當然,如果指定通過寄存器傳參的參數數量小于三個的時候,并沒有使用完這三個寄存器。
調用者需要把棧指針恢復為初始狀態。
相關例子請參看(19.1.1)。
在這里,它被成為“寄存器調用約定”,頭四個參數通過EAX,EDX,EBX和ECX傳遞。其余參數通過棧傳遞。通過在函數名上添加下劃線來區分那些不同的調用約定。
這是C++里面傳遞this指針的成員函數調用約定。
在MSVC里面,this指針通過ECX寄存器來傳遞。
在GCC里面,this指針是通過第一個參數進行傳遞的。因此很明顯,在所有成員函數里面都會多出一個額外的參數。
相關例子請查看(51.1.1)。
在Win64里面傳遞函數參數的方法類似fastcall調用約定。前四個參數通過RCX,RDX,R8和R9寄存器傳參,其余參數通過棧進行傳遞。調用者還必須預留32個字節或者4個64位的空間,讓被調用者可以保存前四個參數。短函數可能直接使用通過寄存器傳過來的值,但更大的可能是保存那些值后在進一步使用。
調用者還必須負責還原棧指針。
這個調用約定也用于Windows x86-64位系統上的DLL(而不是Win32的stdcall)。
例子
#!c
#include <stdio.h>
void f1(int a, int b, int c, int d, int e, int f, int g)
{
printf ("%d %d %d %d %d %d %d\n", a, b, c, d, e, f, g);
};
int main()
{
f1(1,2,3,4,5,6,7);
};
Listing 64.6: MSVC 2012 /0b
$SG2937 DB '%d %d %d %d %d %d %d', 0aH, 00H
main PROC
sub rsp, 72 ; 00000048H
mov DWORD PTR [rsp+48], 7
mov DWORD PTR [rsp+40], 6
mov DWORD PTR [rsp+32], 5
mov r9d, 4
mov r8d, 3
mov edx, 2
mov ecx, 1
call f1
xor eax, eax
add rsp, 72 ; 00000048H
ret 0
main ENDP
a$ = 80
b$ = 88
c$ = 96
d$ = 104
e$ = 112
f$ = 120
g$ = 128
f1 PROC
$LN3:
mov DWORD PTR [rsp+32], r9d
mov DWORD PTR [rsp+24], r8d
mov DWORD PTR [rsp+16], edx
mov DWORD PTR [rsp+8], ecx
sub rsp, 72 ; 00000048H
mov eax, DWORD PTR g$[rsp]
mov DWORD PTR [rsp+56], eax
mov eax, DWORD PTR f$[rsp]
mov DWORD PTR [rsp+48], eax
mov eax, DWORD PTR e$[rsp]
mov DWORD PTR [rsp+40], eax
mov eax, DWORD PTR d$[rsp]
mov DWORD PTR [rsp+32], eax
mov r9d, DWORD PTR c$[rsp]
mov r8d, DWORD PTR b$[rsp]
mov edx, DWORD PTR a$[rsp]
lea rcx, OFFSET FLAT:$SG2937
call printf
add rsp, 72 ; 00000048H
ret 0
f1 ENDP
在這里我們可以清楚看到這7個參數是如何傳遞的:4個參數通過寄存器傳遞而其余3個通過棧傳遞。f1()的反匯編代碼一開始就把參數保存到“預留”的棧空間之中,這樣做的目的是編譯器并不能保證有足夠的寄存器可以使用,如果不這樣做的話這四個寄存器將被參數占用到函數執行結束。最后,預留棧空間是調用者的職責。
Listing 64.7: Optimizing MSVC 2012 /0b
$SG2777 DB '%d %d %d %d %d %d %d', 0aH, 00H
a$ = 80
b$ = 88
c$ = 96
d$ = 104
e$ = 112
f$ = 120
g$ = 128
f1 PROC
$LN3:
sub rsp, 72 ; 00000048H
mov eax, DWORD PTR g$[rsp]
mov DWORD PTR [rsp+56], eax
mov eax, DWORD PTR f$[rsp]
mov DWORD PTR [rsp+48], eax
mov eax, DWORD PTR e$[rsp]
mov DWORD PTR [rsp+40], eax
mov DWORD PTR [rsp+32], r9d
mov r9d, r8d
mov r8d, edx
mov edx, ecx
lea rcx, OFFSET FLAT:$SG2777
call printf
add rsp, 72 ; 00000048H
ret 0
f1 ENDP
main PROC
sub rsp, 72 ; 00000048H
mov edx, 2
mov DWORD PTR [rsp+48], 7
mov DWORD PTR [rsp+40], 6
lea r9d, QWORD PTR [rdx+2]
lea r8d, QWORD PTR [rdx+1]
lea ecx, QWORD PTR [rdx-1]
mov DWORD PTR [rsp+32], 5
call f1
xor eax, eax
add rsp, 72 ; 00000048H
ret 0
main ENDP
如果我們使用了編譯優化的開關去編譯上面的例子,它的反匯編碼幾乎是相同的,但是預留的棧空間將不被使用,因為在這里并不需要使用到預留的棧空間。
而且可以看到MSVC 2012是如何利用LEA指令來優化代碼(A.6.2)。
我也不確定是否值得這么做。
更多的例子請看(74.1)
this指針通過RCX傳遞,成員函數的第一個參數通過RDX傳遞,更多例子請看(51.1.1)。
Linux x86-64傳遞參數的方式幾乎和Windows一樣。但是是通過6個寄存器代替4個寄存器來傳參(RDI,RSI,RDX,RCX,R8,R9),另外并沒有預留的棧空間這回事。雖然,如果它需要/想要的話,可以把寄存器的值保存到棧之中。
Listing 64.8: Optimizing GCC 4.7.3
.LC0:
.string "%d %d %d %d %d %d %d\n"
f1:
sub rsp, 40
mov eax, DWORD PTR [rsp+48]
mov DWORD PTR [rsp+8], r9d
mov r9d, ecx
mov DWORD PTR [rsp], r8d
mov ecx, esi
mov r8d, edx
mov esi, OFFSET FLAT:.LC0
mov edx, edi
mov edi, 1
mov DWORD PTR [rsp+16], eax
xor eax, eax
call __printf_chk
add rsp, 40
ret
main:
sub rsp, 24
mov r9d, 6
mov r8d, 5
mov DWORD PTR [rsp], 7
mov ecx, 4
mov edx, 3
mov esi, 2
mov edi, 1
call f1
add rsp, 24
ret
注意:這里的值是寫入到32-bit的寄存器(EAX...)而不是整個64-bit寄存器(RAX...)。這是因為寫入到32-bit寄存器的時候會自動清空高32-bit。據說,這是為了方便把代碼移植到x86-64。
除了Win64之外,其它返回float和double類型的值都是通過FPU里面的ST(0)寄存器返回的。 在Win64里面,返回float和double類型的值是通過XMM0寄存器返回。
有時候,C/C++程序員(雖然不僅僅是這些人)可能會問,如果他們碰巧修改了參數會怎樣?答案非常簡單,這些參數是保存在棧里面的,修改參數的時候是修改這個棧里面的內容,調用者并沒有在被調用函數退出之后再使用它們(至少在我的實踐中沒有遇到這種情況)。
#!c
#include <stdio.h>
void f(int a, int b)
{
a=a+b;
printf ("%d\n", a);
};
Listing 64.9: MSVC 2012
_a$ = 8 ; size = 4
_b$ = 12 ; size = 4
_f PROC
push ebp
mov ebp, esp
mov eax, DWORD PTR _a$[ebp]
add eax, DWORD PTR _b$[ebp]
mov DWORD PTR _a$[ebp], eax
mov ecx, DWORD PTR _a$[ebp]
push ecx
push OFFSET $SG2938 ; '%d', 0aH
call _printf
add esp, 8
pop ebp
ret 0
_f END
是的,可以隨便修改參數。當然,這得它不是C++的引用(references)(51.3),而且你如果不修改通過指針指向的數據。那么修改參數是不會影響到當前函數的。
從理論上來講,被調用者的函數返回之后,調用者可以獲取并修改和使用它。如果它是直接使用匯編語言編寫的。但C/C++并不提供任何方式可以訪問它們。
...更有意思的是,有可能在程序中,取一個函數參數的指針并將其傳遞給另外一個函數。
#!c
#include <stdio.h>
// located in some other file
void modify_a (int *a);
void f (int a)
{
modify_a (&a);
printf ("%d\n", a);
};
很難理解它是如果實現的,直到我們看到它的反匯編碼:
Listing 64.10: Optimizing MSVC 2010
$SG2796 DB '%d', 0aH, 00H
_a$ = 8
_f PROC
lea eax, DWORD PTR _a$[esp-4] ; just get the address of value in local stack
push eax ; and pass it to modify_a()
call _modify_a
mov ecx, DWORD PTR _a$[esp] ; reload it from the local stack
push ecx ; and pass it to printf()
push OFFSET $SG2796 ; '%d'
call _printf
add esp, 12
ret 0
_f ENDP
傳遞到另一個函數是a在棧空間上的地址,該函數修改了指針指向的值然后再調用printf()來打印出修改之后的值。
細心的讀者可能會問,使用寄存器傳參的調用約定是如何傳遞函數指針參數的?
這是一種利用了影子空間的情況,輸入的參數值先從寄存器復制到局部棧中的影子空間,然后再講這個地址傳遞給其他函數。
Listing 64.11: Optimizing MSVC 2012 x64
$SG2994 DB '%d', 0aH, 00H
a$ = 48
f PROC
mov DWORD PTR [rsp+8], ecx ; save input value in Shadow Space
sub rsp, 40
lea rcx, QWORD PTR a$[rsp] ; get address of value and pass it to modify_a()
call modify_a
mov edx, DWORD PTR a$[rsp] ; reload value from Shadow Space and pass it to printf()
lea rcx, OFFSET FLAT:$SG2994 ; '%d'
call printf
add rsp, 40
ret 0
f ENDP
GCC同樣將傳入的參數存儲在本地棧空間:
Listing 64.12: Optimizing GCC 4.9.1 x64
.LC0:
.string "%d\n"
f:
sub rsp, 24
mov DWORD PTR [rsp+12], edi ; store input value to the local stack
lea rdi, [rsp+12] ; take an address of the value and pass it to modify_a()
call modify_a
mov edx, DWORD PTR [rsp+12] ; reload value from the local stack and pass it to printf()
mov esi, OFFSET FLAT:.LC0 ; '%d'
mov edi, 1
xor eax, eax
call __printf_chk
add rsp, 24
ret
ARM64的GCC也做了同樣的事情,但這個空間稱為寄存器保護區:
f:
stp x29, x30, [sp, -32]!
add x29, sp, 0 ; setup FP
add x1, x29, 32 ; calculate address of variable in Register Save Area
str w0, [x1,-4]! ; store input value there
mov x0, x1 ; pass address of variable to the modify_a()
bl modify_a
ldr w1, [x29,28] ; load value from the variable and pass it to printf()
adrp x0, .LC0 ; '%d'
add x0, x0, :lo12:.LC0
bl printf ; call printf()
ldp x29, x30, [sp], 32
ret
.LC0:
.string "%d\n"
順便提一下,一個類似影子空間的使用在這里也被提及過(46.1.2)。