这是C/C++语言最常使用的参数传递方法。
调用方函数逆序向被调用方函数传递参数:以“最后(右侧)的参数、倒数第二……至第一个参数”的顺序传递参数。被调用方函数退出后,应由调用方函数调整栈指针ESP、将栈恢复成调用其他函数之前的原始状态。
指令清单64.1 cdecl
push arg3
push arg2
push arg1
call function
add esp, 12 ; returns ESP
这种调用方式与上面提到的cdecl规范类似,只是有一点不同:被调用方函数在返回之前会执行“RET x”指令还原参数栈,而不会使用单纯的“RET”指令直接返回。这里的x的数值的计算方式是:x=参数个数*指针的大小(注意:指针的大小在x86构架中的值是4,而在x64中是8)。这样调用方函数本身就不会调整栈指针,因此调用方函数不会因此使用类似于“add esp,x”这样的指令。
指令清单64.2 stdcall
push arg3
push arg2
push arg1
call function
function:
... do something ...
ret 12
此类约定在Win32的标准库文件中十分常见。然而因为Win64系统遵循调用约定的是Win64规范,所以Win64的库文件里不会出现stdcall的标志性操作指令。
以本书8.1节中的函数为例。我们给其中的函数增加__stdcall限定符即可强制它使用stdcall调用约定:
int __stdcall f2 (int a, int b, int c)
{
return a*b+c;
};
它的编译结果与本书8.2节类似,但是最后的指令从RET变成了RET 12。在遵循stdcall约定之后,栈指针SP不再由调用方函数更新了。
这样一来,我们就可以通过函数尾部的RETN N指令直观地推算出外来参数的个数。计算方法就是:N除以4。
指令清单64.3 MSVC 2010
_a$ = 8 ; size = 4
_b$ = 12 ; size = 4
_c$ = 16 ; size = 4
_f2@12 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
_f2@12 ENDP
; ...
push 3
push 2
push 1
call _f2@12
push eax
push OFFSET $SG81369
call _printf
add esp, 8
在C/C++语言的标准函数中,printf()一类的函数可能是仅存的几个带有可变参数的函数。借助这类函数的帮助,我们能比较容易地观察出cdecl和stdcall调用规范之间的区别。我们首先假定编译器知道printf()函数的参数总数。然而,在Windows环境下,printf()属于预先编译好的库函数,直接由文件MSVCRT.DLL提供。所以,我们无法通过从它的函数代码入手获悉可变参数的处理方式;但是另一方面,我们知道它肯定会处理格式化字符串。如果printf()函数当真采取了stdcall规范、根据格式化字符串统计变参的数量并且在函数尾声恢复栈指针,那么这种局面就十分危险了:万一程序员打错了几个字母,程序就会崩溃。由此可知,对于那些带有可变参数的函数而言,cdecl规范要比stdcall规范更好一些。
这个调用约定优先使用寄存器传递参数,无法通过寄存器传递的参数则通过栈传递给被调用方函数。因为fastcall约定在内存栈方面的访问压力比较小,所以在早期的CPU平台上遵循fastcall规范的程序会比遵循stdcall和cdecl规范的程序性能更高。但是在现在的、更为复杂的CPU平台上,fastcall规范的性能优势就不那么明显了。
这种调用约定没有统一的技术规范,不同的编译器有着各自不同的实现方法。因此在使用这种约定时,我们需要特别小心:如果用两个不同的编译器编译出来了2个相互调用的、遵循fastcall规范的DLL库,那么这种互相调用的访问操作基本都会出现故障。
不管是MSVC还是GCC都使用ECX和EDX传递第一个和第二个参数,用栈传递其余的参数。此外,应由被调用方函数调整栈指针、把参数栈恢复到调用之前的初始状态(这一点与stdcall类似)。
指令清单64.4 fastcall
push arg3
mov edx, arg2
mov ecx, arg1
call function
function:
.. do something ..
ret 4
我们还是以本书8.1节中的例子来说明,把它稍微变化一下,增加一个修饰符号:
int __fastcall f3 (int a, int b, int c)
{
return a*b+c;
};
编译完成后,我们看到的结果如下所示。
指令清单64.5 MSVC 2010/OB0
_c$ = 8 ; size = 4
@f3@12 PROC
; _a$ = ecx
; _b$ = edx
mov eax, ecx
imul eax, edx
add eax, DWORD PTR _c$[esp-4]
ret 4
@f3@12 ENDP
; ...
mov edx, 2
push 3
lea ecx, DWORD PTR [edx-1]
call @f3@12
push eax
push OFFSET $SG81390
call _printf
add esp, 8
从以上程序我们可以看到,函数调用采用了指令RET N带一个操作数的方式返回SP堆栈指针。由此可以判断,调用方函数通过栈传递了多少个外部参数。
从某种意义上来讲,GCC regparm由fastcall进化而来。这种规范允许编程人员通过编译选项“-mregparm”设置通过寄存器传递的参数总数(最大值为3)。换句话说,这种规范最多可通过3个寄存器,即EAX、EDX和ECX传递函数参数。
当然,如果“-mregparm”的值小于3,那么就不会全部使用这三个寄存器。
这种约定要求调用方函数在调用过程结束以后调整栈指针,将参数栈恢复到其初始状态。
有关案例请参阅本书的19.1.1节。
这被称为“寄存器调用规范”。头四个参数由寄存器EAX、EDX、EBX和ECX传递,其余的所有参数都则通过堆栈传递。在使用这种调用约定时,函数必须其函数名前添加标识符“__watcom”,与其他采用不同调用规范的函数区分开来。
这是一种方便C++类成员调用this指针而特别设定的调用规范。
MSVC使用ECX寄存器传递this指针。
而GCC则把this指针作为被调用方函数的第一个参数传递。在汇编层面,这个指针显而易见:所有的类函数都比源代码多出来一个参数。
有关详情,请参阅本书的51.1.1节。
64位环境下的参数传递方法在某种程度上与fastcall函数比较类似:头四个参数由寄存器RCX、RDX、R8和R9传递,而其余的参数都通过栈来传递。调用方函数必须预留32个字节或者4个64位的存储空间,以便被调用方函数保存头四个参数。小型函数可以仅凭寄存器就获取所有参数,而大型函数就可能需要保存这些传递参数的寄存器,把它们腾挪出来供后续指令调用。
调用方函数负责调整栈指针到其初始状态。
此外,Windows x86-64系统的DLL文件也采用了这种调用规范。也就是说,虽然Win32系统API遵循的是stdcall规范,但是Win64系统遵循的是Win64规范、不再使用stdcall规范。
以下述程序为例:
#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);
};
指令清单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()函数通过序言部分的指令,把传递参数的四个寄存器的外来值存储到“暂存空间/scratch space”里——这正是暂存空间的正确用法。编译器无法实现确定缺少了这4个寄存器之后,后续代码是否还有足够的寄存器可用,所以会把这4个寄存器的数据保管起来,以方便掉配这4个寄存器。Win64调用规范约定:应由调用方函数分配暂存空间给被调用方函数使用。
指令清单64.7 优化的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节介绍了另外一个Win64调用约定的程序。有兴趣的读者可去看一下。
64位下的Windows:在C/C++下传递this指针
C/C++编译器会使用RCX寄存器传递类对象的this指针、用RDX寄存器传递函数所需的第一个参数。关于这个方面的例子,可以查看本书的51.1.1节。
64位Linux程序传递参数的方法和64位Windows程序的传递方法几乎相同。区别在于,64位Linux程序使用6个寄存器(RDI、RSI、RDX、RCX、R8、R9)传递前几项参数,而64位Windows则只利用4个寄存器传递参数。另外,64位Linux程序没有上面提到的“零散空间”这种概念。如果被调用方函数的寄存器数量紧张,它就可以用栈存储外来参数,把相关寄存器腾出来使用。
指令清单64.8 优化的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
在上述指令操作EAX寄存器的时候,它只把数据写到了RAX寄存器的低32位(即EAX)而没有直接操作整个64位RAX寄存器。这是因为:在操作寄存器的低32位的时候,该寄存器的高32位会被自动清零。或许,这只是把x86代码移植到x86-64平台时的偷懒做法。
除了Win64规范以外的所有的调用规范都规定:当返回值为单/双精度浮点型数据时,被调用方函数应当通过FPU寄存器ST(0)传递返回值。而Win64规范规定:被调用方函数应当通过XMM0寄存器的低32位(float)或低64位寄存器(double)返回单/双精度浮点型数据。
C/C++和其他语言的编程人员可能都曾问过这样一个问题:如果被调用方函数修改了外来参数的值,将会发生什么情况?答案十分简单:外来参数都是通过栈传递的,因此被调用方函数修改的是栈里的数据。在被调用方函数退出以后,调用方函数不会再访问自己传递给别人的参数。
#include <stdio.h>
void f(int a, int b)
{
a=a+b;
printf ("%d\n", a);
};
指令清单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 ENDP
由此可见,只要这些参数不是C++的引用指针/references(本书的51.3节)也不是数据指针,那么被调用方函数可以随便操作外部传来的参数。
理论上讲,在被调用方函数结束以后,调用方函数能够获取被调用方函数修改过的参数,对它们加以进一步利用。然而实际上我们只能在手写的汇编指令中遇到这种情况,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);
};
要不是看了下面的汇编代码,我们一时还很难理解这段程序是如何运行的。
指令清单64.10 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的地址通过栈传递给了一个函数,然后这个地址又被传递给了另外一个函数。第一个函数修改了变量a的值,而后printf()函数获取到了这个修改后的变量值。
细心的读者可能会问:使用一种直接通过寄存器传递参数的调用约定,又会是什么情况呢?
即便真地使用了这种调用约定,还会有阴影空间(Shadow Space)的问题。传递的数值将会从寄存器保存到了本地栈的阴影空间里,然后以地址的形式传递给其他函数。
指令清单64.11 优化的MSVC 2012(64位)
$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也将输入的数值保存到本地栈。
指令清单64.12 优化的GCC 4.9.1(64位)
.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也已同样的方式传递参数。只是在这个平台上,这个空间被称为“寄存器(内容)保存区(Register Save Area)”。
指令清单64.13 优化的GCC 4.9.1 ARM64
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"
另外,与阴影空间(Shadow Space)的有关话题还可以参阅本书的46.1.2节。