第46章 变长参数函数

像printf()和scanf()一类的函数可以处理不同数量的输入参数。这种函数又是如何访问参数的呢?

46.1 计算算术平均值

如果要编写一个计算算术平均值的函数,那么就需要在函数的参数声明部分指定所有的外来参数。但是C/C++的变长参数函数却无法事先知道外来参数的数量。为了方便起见,我们用“-1”作为最后一个参数兼其他参数的终止符。

C语言标准函数库的头文件stdarg.h定义了变长参数的处理方法(宏)。刚才提到的printf()函数和scanf()函数都使用了这个文件提供的宏。

#include <stdio.h>
#include <stdarg.h>

int arith_mean(int v, ...)
{
         va_list args;
         int sum=v, count=1, i;
         va_start(args, v);

         while(1)
         {
                  i=va_arg(args, int);
                  if (i==-1) // terminator
                           break;
                  sum=sum+i;
                  count++;
         }

         va_end(args);
         return sum/count;
};

int main()
{
         printf ("%d\n", arith_mean (1, 2, 7, 10, 15, -1 /* terminator */));
};

变长参数函数按照常规函数参数的方法访问外部传来的第一个参数。而后,程序借助stdarg.h提供的宏var_arg调用其余参数,依次求得各参数之和,最终计算其平均值。

46.1.1 cdecl调用规范

指令清单46.1 MSVC 6.0优化

_v$ = 8
_arith_mean PROC NEAR
        mov     eax, DWORD PTR _v$[esp-4] ; load 1st argument into sum
        push    esi
        mov     esi, 1                    ; count=1
        lea     edx, DWORD PTR _v$[esp]   ; address of the 1st argument
$L838:
        mov     ecx, DWORD PTR [edx+4]    ; load next argument
        add     edx, 4                    ; shift pointer to the next argument
        cmp     ecx, -1                   ; is it -1?
        je      SHORT $L856               ; exit if so
        add     eax, ecx                  ; sum = sum + loaded argument
        inc     esi                       ; count++
        jmp     SHORT $L838
$L856:
; calculate quotient

        cdq
        idiv    esi
        pop     esi
        ret     0
_arith_mean ENDP

$SG851  DB      '%d', 0aH, 00H

_main   PROC NEAR
        push    -1
        push    15
        push    10
        push    7
        push    2
        push    1
        call    _arith_mean
        push    eax
        push    OFFSET FLAT:$SG851 ; '%d'
        call    _printf
        add     esp, 32
        ret     0
_main   ENDP

在main()函数里,各项参数从右向左依次逆序传递入栈。第一个入栈的是最后一项参数“-1”,而最后入栈的是第一项参数——格式化字符串。

函数arith_mean()取出第一个参数的值,并将其保存在变量sum中。接着,将第二个参数的地址保存在寄存器EDX中,并取出其值,与前面的sum相加。如此循环往复,直到参数的终止符−1。

当找到了参数串的结尾后,程序再将所有数的算术和sum除以参数的个数(当然不包括终止符−1)。按照这种算法计算出来的商就是各参数的算术平均值。

换句话说,在调用变长参数函数时,调用方函数先把不确定长度的参数堆积为数组,再通过栈把这个数组传递给变长函数参数。这就解释了为什么cdecl调用规范会要求将第一个参数最后一个推送入栈了。因为如果不这样的话,被调用方函数会找不到第一个参数,这会导致printf()这样的函数因为找不到格式化字符串的地址而无法运行。

46.1.2 基于寄存器的调用规范

细心的读者也可能会问,那些优先利用寄存器传递参数的调用规范是什么情况?下面我们就来看看。

指令清单46.2 x64下的MSVC 2012优化

$SG3013 DB      '%d', 0aH, 00H

v$ = 8
arith_mean PROC
         mov    DWORD PTR [rsp+8], ecx     ; 1st argument
         mov    QWORD PTR [rsp+16], rdx    ; 2nd argument
         mov    QWORD PTR [rsp+24], r8     ; 3rd argument
         mov    eax, ecx                   ; sum = 1st argument
         lea    rcx, QWORD PTR v$[rsp+8]   ; pointer to the 2nd argument
         mov    QWORD PTR [rsp+32], r9     ; 4th argument
         mov    edx, DWORD PTR [rcx]       ; load 2nd argument
         mov    r8d, 1                     ; count=1
         cmp    edx, -1                    ; 2nd argument is -1?
         je     SHORT $LN8@arith_mean      ; exit if so
$LL3@arith_mean:
         add    eax, edx                   ; sum = sum + loaded argument
         mov    edx, DWORD PTR [rcx+8]     ; load next argument
         lea    rcx, QWORD PTR [rcx+8]     ; shift pointer to point to the argument after next
         inc    r8d                        ; count++
         cmp    edx, -1                    ; is loaded argument -1?
         jne    SHORT $LL3@arith_mean      ; go to loop begin if its not'
$LN8@arith_mean:
; calculate quotient
         cdq
         idiv   r8d
         ret    0
arith_mean ENDP

main     PROC
         sub    rsp, 56
         mov    edx, 2
         mov    DWORD PTR [rsp+40], -1
         mov    DWORD PTR [rsp+32], 15
         lea    r9d, QWORD PTR [rdx+8]
         lea    r8d, QWORD PTR [rdx+5]
         lea    ecx, QWORD PTR [rdx-1]
         call   arith_mean
         lea    rcx, OFFSET FLAT:$SG3013
         mov    edx, eax
         call   printf
         xor    eax, eax
         add    rsp, 56
         ret    0
main     ENDP

在这个程序里,寄存器负责传递函数的前4个参数,栈用来传递其余的2个参数。函数arith_mean()首先将寄存器传递的4个参数存放在阴影空间里,把阴影空间和传递参数的栈合并成了统一而连续的参数数组!

GCC会如何处理参数呢?与MSVC相比,GCC在编译的时候略显画蛇添足。它会把函数分为两部分:第一部分的指令将寄存器的值保存在“红色地带”,并在那里进行处理;而第二部分指令再处理栈的数据。

指令清单46.3 x64下的GCC 4.9.1的优化

arith_mean:
         lea    rax, [rsp+8]
         ; save 6 input registers in "red zone" in the local stack
         mov    QWORD PTR [rsp-40], rsi
         mov    QWORD PTR [rsp-32], rdx
         mov    QWORD PTR [rsp-16], r8
         mov    QWORD PTR [rsp-24], rcx
         mov    esi, 8
         mov    QWORD PTR [rsp-64], rax
         lea    rax, [rsp-48]
         mov    QWORD PTR [rsp-8], r9
         mov    DWORD PTR [rsp-72], 8
         lea    rdx, [rsp+8]
         mov    r8d, 1
         mov    QWORD PTR [rsp-56], rax
         jmp    .L5
.L7:
         ; work out saved arguments
         lea    rax, [rsp-48]
         mov    ecx, esi
         add    esi, 8
         add    rcx, rax
         mov    ecx, DWORD PTR [rcx]
         cmp    ecx, -1
         je     .L4
.L8:
         add    edi, ecx
         add    r8d, 1
.L5:
         ; decide, which part we will work out now.
         ; is current argument number less or equal 6?
         cmp    esi, 47
         jbe    .L7                 ; no, process saved arguments then
         ; work out arguments from stack
         mov    rcx, rdx
         add    rdx, 8
         mov    ecx, DWORD PTR [rcx]
         cmp    ecx, -1
         jne    .L8
.L4:
         mov    eax, edi
         cdq
         idiv   r8d
         ret
.LC1:
         .string "%d\n"
main:
         sub    rsp, 8
         mov    edx, 7
         mov    esi, 2
         mov    edi, 1
         mov    r9d, -1
         mov    r8d, 15
         mov    ecx, 10
         xor    eax, eax
         call   arith_mean
         mov    esi, OFFSET FLAT:.LC1
         mov    edx, eax
         mov    edi, 1
         xor    eax, eax
         add    rsp, 8
         jmp    __printf_chk

另外,本书第64章第8节介绍了阴影空间的另外一个案例。

46.2 vprintf()函数例子

在编写日志(logging)函数的时候,多数人都会自己构造一种与printf类似的、处理“格式化字符串+一系列(但是数量可变)的内容参数”的变长参数函数。

另外一种常见的变长参数函数就是下文的这种die()函数。这是一种在显示提示信息之后随即退出整个程序的异常处理函数。它需要把不确定数量的输入参数打包、封装并传递给printf()函数。如何实现呢?这些函数名称前面有一个字母v的,这是因为它应当能够处理不确定数量(variable.可变的)的参数。以die()函数调用的vprintf()函数为例,它的输入变量就可分为两部分:一部分是格式化字符串,另一部分是带有多种类型数据变量列表va_list的指针。

#include <stdlib.h>
#include <stdarg.h>

void die (const char * fmt, ...)
{
         va_list va;
         va_start (va, fmt);

         vprintf (fmt, va);
         exit(0);
};

仔细观察就会发现,va_list是一个指向数组的指针。在编译后,这个特征非常明显:

指令清单46.4 MSVC 2010下的程序优化

_fmt$ = 8
_die     PROC
         ; load 1st argument (format-string)
         mov    ecx, DWORD PTR _fmt$[esp-4]
         ; get pointer to the 2nd argument
         lea    eax, DWORD PTR _fmt$[esp]
         push   eax          ; pass pointer
         push   ecx
         call   _vprintf
         add    esp, 8
         push   0
         call   _exit
$LN3@die:
         int    3
_die     ENDP

由此可知,die()函数实现的功能就是:取一个指向参数的指针,再将其传送给vprintf()函数。变长参数(序列)像数组那样被来回传递。

指令清单46.5 x64下的MSVC 2012优化

fmt$ = 48
die      PROC
         ; save first 4 arguments in Shadow Space
         mov    QWORD PTR [rsp+8], rcx
         mov    QWORD PTR [rsp+16], rdx
         mov    QWORD PTR [rsp+24], r8
         mov    QWORD PTR [rsp+32], r9
         sub    rsp, 40
         lea    rdx, QWORD PTR fmt$[rsp+8] ; pass pointer to the 1st argument
         ; RCX here is still points to the 1st argument (format-string) of die()
         ; so vprintf() will take it right from RCX
         call   vprintf
         xor    ecx, ecx
         call   exit
         int    3
die      ENDP