探究printf函数对单精度浮点数的处理
问题起源自一道编程题:“用cout输出类似printf("%d", 浮点数)格式化浮点为整形的方式”。这道题目的要求,是用C++里cout的方式实现C语言中printf的功能,看似平淡无奇,其实大有深意,因为这里面隐藏了printf函数的一个内幕。
1. 疑问初现
在C语言中,把浮点数格式化为整形输出,以我们平时的习惯会用 printf("%d", (int) f)
的形式,首先把浮点型变量强转为整形,然后再格式化输出。而题目中却省略了强转这个步骤,不禁令人起疑:省略掉强转为整形这一步,printf函数肯定会把浮点型变量f在内存中的内容误当做整形变量输出,那么结果应该是f的IEEE标准编码的整形形式吧?(之前写过的关于浮点型数据的IEEE编码标准:http://www.cnblogs.com/zhugehq/p/5918599.html)
用代码来验证一下,浮点数赋值为23.45。首先按照正常的习惯,即首先强转为整形 printf("%d", (int) f)
:
int _tmain(int argc, _TCHAR* argv[])
{
float f = 23.45f;
printf("%d", (int) f);
return 0;
}
结果为输出23,符合我们的预期:
然后试试不强转的情况,因为预期是printf把f在内存中的内容当做整形输出,所以特意加上%x格式符,以十六进制形式输出,方便查看:
int _tmain(int argc, _TCHAR* argv[])
{
float f = 23.45f;
printf("%d\r\n", f);
printf("%x", f);
return 0;
}
结果有些出乎预料:
如果说第一行因为十进制的缘故还看不出什么东西,那么第二行十六进制的异常就非常明显了。单精度浮点数23.45在内存中的形式不可能是40 00 00 00(小尾00 00 00 40)!可以通过下面这段代码(printBits函数修改自 http://stackoverflow.com/questions/111928/is-there-a-printf-converter-to-print-in-binary-format),来查看23.45在内存中真正的形式:
#include "stdafx.h"
#include <stdlib.h>
#include <string.h>
//假设是小尾存储方式
void printBits(size_t const size, void const * const ptr)
{
unsigned char *b = (unsigned char*) ptr;
unsigned char byte;
char szTmp[128] = {0};
char szOutput[128] = {0};
int i, j;
for (i=size-1;i>=0;i--)
{
for (j=7;j>=0;j--)
{
byte = (b[i] >> j) & 1;
printf("%u", byte);
sprintf_s(szTmp, _countof(szTmp), "%u", byte);
strcat_s(szOutput, _countof(szOutput), szTmp);
}
}
printf("\r\n");
printf("%X\r\n", strtol(szOutput, NULL, 2));
}
int main(int argv, char* argc[])
{
float f = 23.45f;
printBits(sizeof(f), &f);
return 0;
}
结果如下图所示:
单精度浮点数23.45的编码应该是41 BB 99 9A (小尾形式9A 99 BB 41),而不是40 00 00 00。那么这神秘的40 00 00 00到底是什么呢,带着这个疑问,我们去查看调用printf函数时的栈空间变化,希望能获得一些启示。
2. 调试探究
回到刚才的测试程序,把断点设在printf("%x", f);
这一行:
int _tmain(int argc, _TCHAR* argv[])
{
float f = 23.45f;
printf("%d\r\n", f);
printf("%x", f);
return 0;
}
程序运行到调用printf("%x", f)函数之前的时刻,被中断,观察寄存器ESP, EBP的数值,来到main函数的栈空间(从00 18 FE 58到00 18 FF 30),如图所示:
接着单步运行,进入printf("%x", f)函数,观察栈顶(低地址)的变化。按照函数调运约定,首先入栈的是函数的两个参数(按从右向左的顺序),然后是返回指令的地址(在内存的指令区),然后是栈地址(在内存的栈区)。结果如图所示:
根据数据的入栈顺序,经过分析,可以看到栈地址00 18 FF 30,返回指令地址00 41 14 39,函数的左边的参数00 41 58 60,也就是“%x”字符串,它在字符串数据区的内容如图所示:
剩下的8个字节就是变量f了。奇怪的是单精度浮点数f的大小由4字节变为了8字节,幸运的是我们发现了神秘数字40 00 00 00的踪影(内存中为小尾方式)。这不禁让人怀疑是不是单精度浮点变量被悄悄地转换为了双精度浮点型。这里重复利用上面的代码(为了适应双精度浮点型变量的64位长度,代码做出了微小改变),模拟类型转换的过程,并且输出数据在内存中的形式:
//假设是小尾存储方式
void printBits(size_t const size, void const * const ptr)
{
unsigned char *b = (unsigned char*) ptr;
unsigned char byte;
char szTmp[128] = {0};
char szOutput[128] = {0};
char chTmp = 0;
int i, j;
for (i=size-1;i>=0;i--)
{
for (j=7;j>=0;j--)
{
byte = (b[i] >> j) & 1;
printf("%u", byte);
sprintf_s(szTmp, _countof(szTmp), "%u", byte);
strcat_s(szOutput, _countof(szOutput), szTmp);
}
}
printf("\r\n");
if (strlen(szOutput) > 32)
{
chTmp = szOutput[32];
szOutput[32] = '\0';
printf("%X ", strtol(szOutput, NULL, 2));
szOutput[32] = chTmp;
printf("%X\r\n", strtol(szOutput + 32, NULL, 2));
}
else
{
printf("%X\r\n", strtol(szOutput, NULL, 2));
}
}
int main(int argv, char* argc[])
{
float f = 23.45f;
double d = 23.45;
double dTransfered = (double) f;
printf("单精度浮点型23.45:\r\n");
printBits(sizeof(f), &f);
printf("\r\n");
printf("双精度浮点型23.45:\r\n");
printBits(sizeof(d), &d);
printf("\r\n");
printf("单精度浮点型23.45转化为双精度浮点型:\r\n");
printBits(sizeof(dTransfered), &dTransfered);
printf("\r\n");
return 0;
}
运行结果如下图所示:
我们发现,如果把单精度浮点数23.45转化为双精度,会丢失精度。虽然丢失了精度,但是结果就是我们在栈空间发现的那段神秘的8字节的内存00 00 00 40 33 73 37 40(栈空间内为小尾存放)。
疑惑终于解开,原来编译器在我们不知晓的情况下,偷偷地把printf函数的单精度浮点型参数,转换成了双精度浮点型。这一转换,对于用户来说是透明的(难以察觉),如果不是因为这道编程题中的printf函数罕见用法,很难去发现。
3.题目的正确答案
现在,我们终于领会了出题人的意图,那就是在C++语法下,怎么还原C语法下printf(“%d”, f)出现的奇怪现象。再次拿23.45为例,也就是怎么在C++里输出1073741824(0x40000000)这串奇怪的数字。
由于cout是内含类型识别功能的,所以要还原这个奇怪现象,需要用到强转语法,从而模拟printf的行为,代码如下所示:
#include "stdafx.h"
#include <iostream>
using namespace std;
int main(int argv, char* argc[])
{
float f = 23.45f;
cout << f << endl;
double d = (double) f;
cout << * (int *) &d << endl;
return 0;
}
神秘数字再次出现,至此,终于正确地解决了这道题目。