像printf()和scanf()一类的函数可以处理不同数量的输入参数。这种函数又是如何访问参数的呢?
如果要编写一个计算算术平均值的函数,那么就需要在函数的参数声明部分指定所有的外来参数。但是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 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.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节介绍了阴影空间的另外一个案例。
在编写日志(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