有个很奇怪的事情问大家,printf的问题?
本质问题是类型错误。
C语言中,使用 variadic function 将导致实参的类型信息丢失,被调函数只能利用类似 format 字符串的方式去解释栈中的实参,一旦 format 字符串和实际传入的实参类型不匹配,将导致类型错误。
类型错误的结果多种多样,"printf("%f", 3)" 将整数位序列按双精度浮点数解释;而 "printf("%s", 3)" 将尝试在虚地址3开始的内存空间中寻找一个0结尾的字符序列,结果往往是 segmentation fault。
---------------------------------------------------------------------------------------
再来解释题中的现象。
首先,这个问题涉及 calling convention,因此,我先假设发布平台是 IA32/MSVC++ 的组合。
这个组合下调用变参函数,小于4字节的整数按4字节压栈,float转换成double后再压栈。
这是调用 printf 的指令,压栈4个参数共16字节:
mov eax,dword ptr [num]
push eax
mov ecx,dword ptr [num]
push ecx
mov edx,dword ptr [num]
push edx
push 122DCF8h
call dword ptr ds:[12311ECh]
add esp,10h
而 printf 的实现,通过解析 format 字符串,调用 <stdarg.h> 中的 va_arg 来解释实参:
#define va_start _crt_va_start
#define _crt_va_start(ap,v) ( ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v) )
#define _ADDRESSOF(v) ( &reinterpret_cast<const char &>(v) )
#define _INTSIZEOF(n) ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )
#define va_arg _crt_va_arg
#define _crt_va_arg(ap,t) ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
上面代码的意思是,在通过 va_start(args, format) 初始化args后,args将指向栈上 format 字符串后面的栈地址,之后每调用一次 va_arg(args, type) ,将args向前移动sizeof(type),并对齐到4字节(见_INTSIZEOF),这正好符合 IA32 的 calling convention 规则。
对 format 字符串中的d标识,va_arg 调用是:
num = va_arg(args, int);
对 f、g、e 标识,调用是:
str = flt(str, va_arg(args, double), field_width, precision, *fmt, flags | SIGN);
在 calling convention 中,实参的float被转换成double后再压栈,而float/double实参都使用f标识,正好对应上面的 va_arg(args, double) 。
因此,
printf("%d,%f,%d", num, num,num);
在 format 字符串后压栈 4 + 4 + 4 共12字节,而 printf 内部解释 format 字符串时,将依次调用 va_arg(args, int)、va_arg(args, double)、va_arg(args, int),移动args指针 4 + 8 + 4共计16字节的距离,其中最后一次va_arg调用,引用的内存已经属于caller的栈帧。
第1次va_arg的调用,正确解释了第1个实参;第3次va_arg,将未知栈内存解释成int;而第2次va_arg的调用,将最后两个实参解释成double,它相当于:
int i2[] = {1, 1};
double f = *(double*)i2;
在小端字节序中,其位序列是:
0000000000000000 0000000000000001 0000000000000000 0000000000000001
参照 IEEE-754 双精度浮点数的格式,这个数应该是一个非规格化浮点数,它的值等于 -1^0 * (0 + 2^-20 + 2^-52) * 2^-1022,即 2.1219957915e-314 。
稍微修改问题中的代码可以证实:
printf("%d,%f,%d\n", num, num, num);
printf("%d,%g,%d\n", num, num, num);
将输出:
1,0.000000,0
1,2.122e-314,0
---------------------------------------------------------------------------------------
有些答案提到了x64的结果不同,也顺道看下。假设平台是 x64/MSVC++。
在 calling convention 方面,由于x64多了一组通用寄存器,因此函数调用倾向于尽量用寄存器传参,只有当实参个数太多时才将多余的参数压栈。具体来说,RCX, RDX, R8, R9 被用来传递整数、指针, XMM0, XMM1, XMM2, XMM3 被用来传递浮点数。相比 IA32,小于4字节的整数不需要先扩展成4字节,但单精度浮点仍然先被转换成双精度。
但是,毕竟通过寄存器传参只是一个优化,calling convention 要求,即使头4个参数通过寄存器传递,caller仍然应该在压栈额外的实参后,再保留32字节的shadow space(对应4个64位寄存器),供callee在必要的时候将寄存器中的实参溢出到栈中。什么时候callee会溢出实参呢?除了寄存器分配阶段的溢出动作外,变参函数也需要将实参保存到栈中。回忆一下,<stdargs.h>中的宏,依赖所有实参都保存在栈中这一事实。
看一下调用代码:
mov r9d,dword ptr [num]
mov r8d,dword ptr [num]
mov edx,dword ptr [num]
lea rcx,[_load_config_used+80h (013F8DCD30h)]
call qword ptr [__imp_printf (013F8E12E0h)]
4个参数分别通过 RCX(64位指针)、EDX/R8D/R9D(32位整数)传递。
但这样,变参函数是无法在内部通过 va_arg 来访问实参的,所以,printf 入口处就是将4个参数寄存器溢出到shadow space的动作:
mov qword ptr [rsp+8],rcx
mov qword ptr [rsp+10h],rdx
mov qword ptr [rsp+18h],r8
mov qword ptr [rsp+20h],r9
最后就是<stdarg.h>中的宏了:
#define _crt_va_arg(ap, t) \
( ( sizeof(t) > sizeof(__int64) || ( sizeof(t) & (sizeof(t) - 1) ) != 0 ) \
? **(t **)( ( ap += sizeof(__int64) ) - sizeof(__int64) ) \
: *(t *)( ( ap += sizeof(__int64) ) - sizeof(__int64) ) )
可以看到,小于等于8字节的实参,压栈的是值,大于8字节的实参,压栈的是指针,va_arg 会顺带将该指针解引用。
再来看问题中的调用发生了什么事:
printf("%d,%f,%d", num, num,num);
首先,4个实参被分别加载到RCX、RDX、R8、R9中,然后 printf 将传参寄存器中的值溢出到栈上,最后通过 va_arg(args, int)、va_arg(args, double)、va_args(args, int)来访问。这里的每次 va_arg 都将args推进8字节,它依次访问了shadow space中为RDX、R8、R9保留的空间。
因此,第1、3次 va_arg 取得的是正确的实参,只有第2次 va_arg,将整形实参解释成 double。
在小端字节序中,该整形对应的位序列是:
0000000000000000 0000000000000000 0000000000000000 0000000000000001
也是一个非规格化浮点数,计算 -1^0 * (0 + 2^-52) * 2^-1022 的结果是 4.94065645841e-324。
打印
printf("%d,%f,%d\n", num, num, num);
printf("%d,%g,%d\n", num, num, num);
得到:
1,0.000000,1
1,4.94066e-324,1
---------------------------------------------------------------------------------------
上面解释了问题是怎样发生的,再回过头来讨论下类型错误的话题。
我们说,因为C语言的变参函数,导致类型信息信息丢失,库的实现方,无力检查类型一致。所以,在C语言中,我们只能尽量避免这种编码失误。好在,当 format 字符串是字面值时,很多编译器都可以通过警告来提示这种类型错误。而当 format 是运行时字符串时,似乎只能尽量通过部署更多的测试来覆盖可能的输入了。
而在使用C++时,应该避免使用 variadic function,代之以 variadic templates,后者总能正确的传递类型信息。
简单的以 variadic templates 实现 printf,我们可以在发现char*实参遭遇d标识这种不匹配情况中,抛出异常,这里有个库
C++ Format就是这么做的:
format("The answer is {:d}", "forty-two");
// throws a FormatError exception with description "unknown format code 'd' for string", because the argument "forty-two" is a string while the format code d only applies to integers.
而因为我们正确的传递了实参的类型信息,大部分情况下,格式化字符串中甚至可以只保留位置、宽度、对齐等字段,比如:
print("I'd rather be {1} than {0}.", "right", "happy");
实际上,强类型系统如.Net中的Format函数,都是上面这样的。
---------------------------------------------------------------------------------------
参考:
http://agner.org/optimize/calling_conventions.pdfDouble-precision floating-point format