一个 printf 引发的基础复习

先看一下引发我追究一下 printf 和栈桢等相关知识的一段简单的程序:

1
2
3
4
5
6
7
8
#include <stdio.h>

int main()
{
printf("%d ", 8.0/5);
printf("%.2f", 8/5);
return 0;
}

初看时,想当然了一下觉得输出就是1 1.00,后来编译出来运行一下,屏幕上却赫然是-1717986918 1.60

在脑中干想了良久,其时的疑惑主要有两点:

  1. 1.6 转换为整形怎么就变成了负数。

  2. 1 转换为浮点数怎么就变成了 1.60。

现在看来当时的理解中存在着一个很大的误区,就是觉得 printf 是将参数根据格式化字符串进行强制类型转换之后再进行输出的,即编译器会自动将程序变换成如下模样:

1
2
3
4
5
6
7
8
#include <stdio.h>

int main()
{
printf("%d ", (int)(8.0/5));
printf("%.2f", (float)(8/5));
return 0;
}

但是第一段程序的输出已经打脸了,那么想想办法找找合理的解释。

分析

面对这类问题,现象诡异程序简单,能想到的最有效的方法之一就是看汇编。

使用g++ -S编译出第一段程序的汇编如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
	.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,对应的二进制为:

1
1110 0110 0110 0110 0110 0110 0110

因为负数在内存中使用补码存储,故将如上二进制转换为补码才是它在内存中的样子:

1
1001 1001 1001 1001 1001 1001 1010

1073322393 转换成十六进制为 0x3ff99999,对应的二进制为:

1
0011 1111 1111 1001 1001 1001 1001

将这两个数合起来,1073322393 作为高位就是:

1
0011 1111 1111 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010

转换成浮点数恰恰就是 1.6000000000000001,可以认为与 8.0/5 的结果相符。所以第一个 printf 输出结果的推论:

  1. 给 printf 传递的是参数的原始类型,而不是根据格式化字符串进行强制转换后的类型。

    比如printf("%d ", 8.0/5);就会传 double 类型的 8.0/5,而不是根据 %d 强制转换成整型后再传参。

  2. printf 在根据格式化字符串组成输出的时候,会直接在对应参数的起始地址读取一个格式指定的类型出来。

    比如printf("%d ", 8.0/5);就会在 double 类型的 8.0/5 的位置读取一个整型数出来,而小端模式下是高位高地址,低位低地址,所以这里是将 double 的低位 4 字节按 int 类型读取。

    1
    2
    3
    4
    5
    +--------------+
    | double low | --> 把低位 4 字节当作 int 读取
    +--------------+
    | double high |
    +--------------+

第二次 printf 结果的解释

在上面的汇编代码中对第二次 printf 的调用部分如下:

1
2
3
movl	$1, 4(%esp)
movl $LC2, (%esp)
call _printf

可以看到传参确实传的整数 1 进去的,但是输出就变成了 1.60,结合我们对第一个输出的推论,则是会在整型 1 的位置读取一个 double 类型的数,并将内存中的整型 1 作为 double 的低位部分。为什么这里偏偏这么巧会是 1.60 而不是其它的什么值呢?结合上一次调用 printf 时传的参是 8.0/5 的情况,猜想:

受上一次调用后栈上残留数据的影响。

即:

1
2
3
4
5
+--------------+
| int | -+----> 把这 8 字节当 double 读取
+--------------+ |
|residual data | -+
+--------------+

于是将第一次调用的传参修改一下将残留数据变化一下,即:

1
2
3
4
5
6
7
8
#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前的堆栈情况应该是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
+-------------+                    +-------------+
| | ... | |
+-------------+ +-------------+
| | | |
+-------------+ +-------------+
| format str1 | <-- esp | format str2 | <-- esp
+-------------+ +-------------+
| double low | | int |
+-------------+ +-------------+
| double high | | double high |
+-------------+ main stack frame +-------------+
| ... | | ... |
+-------------+ +-------------+
| | | |
+-------------+ +-------------+
| (%ebp) | <-- ebp | (%ebp) | <-- ebp
+-------------+ +-------------+

这里面补充的关键知识点:

  • 被调用函数的参数存放在调用函数的栈帧中。

IEEE-754

1
2
3
+---+-----+----------+
| 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 位,二进制科学计数法中的小数部分