menu

一个 printf 引发的基础复习

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

#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 是将参数根据格式化字符串进行强制类型转换之后再进行输出的,即编译器会自动将程序变换成如下模样:

#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 输出结果的推论:

  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 类型读取。

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


评论:


技术文章推送

手机、电脑实用软件分享

微信搜索公众号: AndrewYG的算法世界
wechat 微信公众号:AndrewYG的算法世界