先看一下引发我追究一下 printf 和栈桢等相关知识的一段简单的程序:
#include <stdio.h>
int main()
{
printf("%d ", 8.0/5);
printf("%.2f", 8/5);
return 0;
}
初看时,想当然了一下觉得输出就是1 1.00
,后来编译出来运行一下,屏幕上却赫然是-1717986918 1.60
。
在脑中干想了良久,其时的疑惑主要有两点:
1.6 转换为整形怎么就变成了负数。
1 转换为浮点数怎么就变成了 1.60。
现在看来当时的理解中存在着一个很大的误区,就是觉得 printf 是将参数根据格式化字符串进行强制类型转换之后再进行输出的,即编译器会自动将程序变换成如下模样:
#include <stdio.h>
int main()
{
printf("%d ", (int)(8.0/5));
printf("%.2f", (float)(8/5));
return 0;
}
但是第一段程序的输出已经打脸了,那么想想办法找找合理的解释。
分析
面对这类问题,现象诡异程序简单,能想到的最有效的方法之一就是看汇编。
使用g++ -S
编译出第一段程序的汇编如下:
.file "demo.cpp"
.def ___main; .scl 2; .type 32; .endef
.section .rdata,"dr"
LC1:
.ascii "%d \0"
LC2:
.ascii "%.2f\0"
.text
.globl _main
.def _main; .scl 2; .type 32; .endef
_main:
pushl %ebp
movl %esp, %ebp
andl $-16, %esp
subl $16, %esp
call ___main
fldl LC0
fstpl 4(%esp)
movl $LC1, (%esp)
call _printf
movl $1, 4(%esp)
movl $LC2, (%esp)
call _printf
movl $0, %eax
leave
ret
.section .rdata,"dr"
.align 8
LC0:
.long -1717986918
.long 1073322393
.ident "GCC: (GNU) 4.9.1"
.def _printf; .scl 2; .type 32; .endef
第一个 printf 结果的解释
一眼望去,有没有发现一个熟悉的数?没错,我们程序的第一个输出 -1717986918 赫然在目。由此产生的猜想:
LC0 对应的两个。long 合起来是 double 类型的 8.0/5,而对其低位 4 字节进行截取后对应的整数为 -1717986918。
来把相关的数转换成二进制验证一下(IEEE 浮点数表示法相关知识见附:IEEE 754 浮点数表示法):
-1717986918 转换成十六进制为 -0x66666666,对应的二进制为:
1110 0110 0110 0110 0110 0110 0110
因为负数在内存中使用补码存储,故将如上二进制转换为补码才是它在内存中的样子:
1001 1001 1001 1001 1001 1001 1010
1073322393 转换成十六进制为 0x3ff99999,对应的二进制为:
0011 1111 1111 1001 1001 1001 1001
将这两个数合起来,1073322393 作为高位就是:
0011 1111 1111 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010
转换成浮点数恰恰就是 1.6000000000000001,可以认为与 8.0/5 的结果相符。所以第一个 printf 输出结果的推论:
给 printf 传递的是参数的原始类型,而不是根据格式化字符串进行强制转换后的类型。
比如
printf("%d ", 8.0/5);
就会传 double 类型的 8.0/5,而不是根据 %d 强制转换成整型后再传参。printf 在根据格式化字符串组成输出的时候,会直接在对应参数的起始地址读取一个格式指定的类型出来。
比如
printf("%d ", 8.0/5);
就会在 double 类型的 8.0/5 的位置读取一个整型数出来,而小端模式下是高位高地址,低位低地址,所以这里是将 double 的低位 4 字节按 int 类型读取。+--------------+ | double low | --> 把低位 4 字节当作 int 读取 +--------------+ | double high | +--------------+
第二次 printf 结果的解释
在上面的汇编代码中对第二次 printf 的调用部分如下:
movl $1, 4(%esp)
movl $LC2, (%esp)
call _printf
可以看到传参确实传的整数 1 进去的,但是输出就变成了 1.60,结合我们对第一个输出的推论,则是会在整型 1 的位置读取一个 double 类型的数,并将内存中的整型 1 作为 double 的低位部分。为什么这里偏偏这么巧会是 1.60 而不是其它的什么值呢?结合上一次调用 printf 时传的参是 8.0/5 的情况,猜想:
受上一次调用后栈上残留数据的影响。
即:
+--------------+
| int | -+----> 把这 8 字节当 double 读取
+--------------+ |
|residual data | -+
+--------------+
于是将第一次调用的传参修改一下将残留数据变化一下,即:
#include <stdio.h>
int main()
{
printf("%d ", 9.0/5);
printf("%.2f", 8/5);
return 0;
}
果然如预料第二个 printf 的输出变成了 1.80。这又一次印证了对第一个输出分析后的两个结论。来复习一下基础,引自《深入理解计算机系统》里的一段话:
假设过程 P(调用者)调用过程 Q(被调用者),则 Q 的参数放在 P 的栈帧中。
即 printf 的参数是放在 main 函数的栈帧中的,那么两次调用call _printf
前的堆栈情况应该是这样的:
+-------------+ +-------------+
| | ... | |
+-------------+ +-------------+
| | | |
+-------------+ +-------------+
| format str1 | <-- esp | format str2 | <-- esp
+-------------+ +-------------+
| double low | | int |
+-------------+ +-------------+
| double high | | double high |
+-------------+ main stack frame +-------------+
| ... | | ... |
+-------------+ +-------------+
| | | |
+-------------+ +-------------+
| (%ebp) | <-- ebp | (%ebp) | <-- ebp
+-------------+ +-------------+
这里面补充的关键知识点:
- 被调用函数的参数存放在调用函数的栈帧中。
IEEE-754
+---+-----+----------+
| S | Exp | Mantissa |
+---+-----+----------+
S:符号位
Exp:指数偏差
Mantissa:尾数
单精度(32 位)
S:1 位
Exp:8 位,二进制科学计数法中的指数加 127(2^(8-1)-1)
Mantissa:23 位,二进制科学计数法中的小数部分
双精度(64 位)
S:1 位
Exp:11 位,二进制科学计数法中的指数加 1023(2^(11-1)-1)
Mantissa:52 位,二进制科学计数法中的小数部分
文档信息
- 本文作者:Zhuang Ma
- 本文链接:https://mazhuang.org/2015/01/19/printf/
- 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)