第64章 参数的传递方法(调用规范)

64.1 cdecl [C Declaration的缩写]

这是C/C++语言最常使用的参数传递方法。

调用方函数逆序向被调用方函数传递参数:以“最后(右侧)的参数、倒数第二……至第一个参数”的顺序传递参数。被调用方函数退出后,应由调用方函数调整栈指针ESP、将栈恢复成调用其他函数之前的原始状态。

指令清单64.1 cdecl

push arg3
push arg2
push arg1
call function
add esp, 12 ; returns ESP

64.2 stdcall [Standard Call的缩写]

这种调用方式与上面提到的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
64.2.1 带有可变参数的函数

在C/C++语言的标准函数中,printf()一类的函数可能是仅存的几个带有可变参数的函数。借助这类函数的帮助,我们能比较容易地观察出cdecl和stdcall调用规范之间的区别。我们首先假定编译器知道printf()函数的参数总数。然而,在Windows环境下,printf()属于预先编译好的库函数,直接由文件MSVCRT.DLL提供。所以,我们无法通过从它的函数代码入手获悉可变参数的处理方式;但是另一方面,我们知道它肯定会处理格式化字符串。如果printf()函数当真采取了stdcall规范、根据格式化字符串统计变参的数量并且在函数尾声恢复栈指针,那么这种局面就十分危险了:万一程序员打错了几个字母,程序就会崩溃。由此可知,对于那些带有可变参数的函数而言,cdecl规范要比stdcall规范更好一些。

64.3 fastcall

这个调用约定优先使用寄存器传递参数,无法通过寄存器传递的参数则通过栈传递给被调用方函数。因为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堆栈指针。由此可以判断,调用方函数通过栈传递了多少个外部参数。

64.3.1 GCC regparm

从某种意义上来讲,GCC regparm由fastcall进化而来。这种规范允许编程人员通过编译选项“-mregparm”设置通过寄存器传递的参数总数(最大值为3)。换句话说,这种规范最多可通过3个寄存器,即EAX、EDX和ECX传递函数参数。

当然,如果“-mregparm”的值小于3,那么就不会全部使用这三个寄存器。

这种约定要求调用方函数在调用过程结束以后调整栈指针,将参数栈恢复到其初始状态。

有关案例请参阅本书的19.1.1节。

64.3.2 Watcom/OpenWatcom

这被称为“寄存器调用规范”。头四个参数由寄存器EAX、EDX、EBX和ECX传递,其余的所有参数都则通过堆栈传递。在使用这种调用约定时,函数必须其函数名前添加标识符“__watcom”,与其他采用不同调用规范的函数区分开来。

64.4 thiscall

这是一种方便C++类成员调用this指针而特别设定的调用规范。

MSVC使用ECX寄存器传递this指针。

而GCC则把this指针作为被调用方函数的第一个参数传递。在汇编层面,这个指针显而易见:所有的类函数都比源代码多出来一个参数。

有关详情,请参阅本书的51.1.1节。

64.5 64位下的x86

64.5.1 Windows x64

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.5.2 64位下的Linux

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平台时的偷懒做法。

64.6 单/双精度数型返回值

除了Win64规范以外的所有的调用规范都规定:当返回值为单/双精度浮点型数据时,被调用方函数应当通过FPU寄存器ST(0)传递返回值。而Win64规范规定:被调用方函数应当通过XMM0寄存器的低32位(float)或低64位寄存器(double)返回单/双精度浮点型数据。

64.7 修改参数

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++语言并不支持这种访问方法。

64.8 指针型函数参数

我们可以给函数参数分配一个指针,把它调配给其他函数:

#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节。