浮点数问题详解,printf(“%d\n“, 8.0); 为什么输出 0 ?——小端存储 & 浮点数格式 & 格式化输出 | bitset的使用 与 二进制原码分析

这里引用浮点数在计算机中的存储方式一文。数据在计算机中的表示 | 进制转换、浮点数表示

如题,为什么 printf("%d\n", 8.0); 输出结果为 0 。

以往我的文章都比较啰嗦,这次我们尽量搞简洁一点😋。建议直接跳到第四步,直入主题。


一、一些废话…数据类型与存储之类的…

在计算机中,有符号的数据以补码的方式存储,而正数的原码和补码相同。因此,我们可以利用bitset 把一个正数的二进制原码输出。

1.1 数据的类型
前面也提到了,数据在计算机中都是以 0 或 1的方式存储的。并且所谓的 int类型,double类型只是编译器在解析该部分数据的时候决定一次读取多少位数据。另外,值得一提的是字符类型 char 底层也是使用整型存储的,所以我们说char类型在输出字符时通过查ascii表输出对应字符,在输出整型时直接输出ascii序号值。

举例:
比如 有数据1234。他们每个数代表一个字节,如果是char类型在解析的时候只会取一个字节的数据,输出为4至于什么会是输出 4 而不是 1 我们下面在谈)。如果是int类型,解析时使用4个字节,输出1234

而我们的变量存储在内存中,因此变量或者说编译器在取值的时候都是按地址操作的,先按照内存首地址找到存放变量的起始位置,按照类型大小选择取多少块内存空间。

比如:对于 0x100地址,如果它保存的变量是char类型,那么该地址类型就为char* 类型。我们取值的时候从 0x100~0x101 之间取值。如果这块内存存储的是int类型,那么毫无疑问,取值范围将是 0x100~0x103 。这也就是为什么我们指针在 + 偏移的时候,实际上是 地址 + sizeof(类型)×偏移

1.2 数据的存储
整数:对于数据的存储,整型系的都是按标椎的二进制方式存储,比如 int类型的 2 存储为 000000...   . . . 0000 ⏟ 3 个 字 节   00000010 ⏟ 1 个 字 节 \underbrace{000000...\ ...0000}_{3个字节}\ \underbrace{00000010}_{1个字节} 3 000000... ...0000 1 00000010,如果是有符号的,那么首位空出一位用于表示符号的符号位。

浮点数:而对于浮点数就不同了,浮点数存储时把整数部分和小数部分分为两部分,也就是小数点前和小数点后。并且对这两部分规定使用科学计数法表示。 如下图所示:
在这里插入图片描述
具体细节,我就不多说了,有兴趣的请参看文章顶部的链接。

二、使用 bitset 输出二进制原码

bitset 是C++库中提供的一种方法,正如其名 bit set 一样,可以用于初始化为二进制 形式。

之前说到,正数的原码和补码相同,那么我们只要对某个数据解析的时候以正整数的形式解析 ,就可以得到它真正的二进制存储形式。

而C语言是一种非常灵活的语言,其中的类型强转 和 指针 就是体现。那么我们如和利用C语言的这个特点对其进行操作,使得它可以输出数据的二进制形式呢?

请看代码:

	int num = 10;
	std::bitset<64> bit;
	bit = *(unsigned int*) & num;	// 核心代码
	std::cout << num << " : " << bit << std::endl;

输出:10 : 0000000000000000000000000000000000000000000000000000000000001010

下面我来解释以下,这段核心代码的含义。

  • 首先,变量 num是一个整型变量,所以它占4字节
  • 同时,按照正数的补码与原码相同,我们可以强转转换为 unsigned(无符号)类型,使之成为一个正数。
  • 其次,为了保证在对地址解析时保持与原数据同等的大小,我们需要对地址的类型进行强转,并且只可转换为相同大小的类型
  • 最后,通过解引用的方式,取得该变量的值,并且以二进制原码的方式保存到了 “bit” 中。
	 解引用    强转    取地址
bit = *(unsigned int*) & num;

好了,不管你看懂没看懂,至少你已经学会怎么输出一个数据的的二进制存储格式了。下面让我们进入正题。

三、计算机的小端存储方式

通常在我们使用的个人计算机上,数据都以小端的方式存储。何谓小端,简单来说就是,是指数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中


例如:对于 123456 而言,高位 十万位 上的 1 权重最大,低位 个位 上的 6 权重最小。

对于整数而言:

  • 高数位:或者说高位,即我们的十万位。
  • 低数位:或者说低位,即我们的个位。

对于计算机而言:以地址0x100 0x200 0x300 为例

  • 高地址位:0x300为高地址位,高位地址0x300可以通过-偏移的方式到达低地址为 0x100
  • 低地址位:0x100为低地址为,低位地址0x100可以通过+偏移的方式到达高地址为 0x300

可以看到,计算机的存储习惯和我们数的表示习惯并不相同。

对于数字123456而言,以大端的思想存储,那么在内存中是这样分布的。可以发现大端存储更为符合人书写习惯,低地址位(左侧)存放高位。我们可以记忆为顺序存储(以人类阅读的顺序习惯)。

地址0x1000x2000x3000x4000x5000x600
数据123356

对于数字123456而言,以小端的思想存储,那么在内存中是这样分布的。可以发现小端存储更为符合计算机的存储习惯,高地址存放高位(百分位)。这种存储方式直观上与人行为习惯不符,我们可以记忆为交叉存储(以人类的阅读顺序来看,首位交叉存储)。

地址0x1000x2000x3000x4000x5000x600
数据654321


图解:
因为内存地址是连续的,计算机读写习惯是从低地址到高地址。将数据的高低位和内存地址的高低地址进行对比,我们可以发现对于小端存储来说就是将数据以交错的形式存储。

而这样存储之后,我们发现:

  • 内存的高地址 对应 数的高位
  • 内存的低地址 对应 数的地位
    在这里插入图片描述
    因此,小端存储方式可记忆为“小端先存储”,这里的 ‘小端’ 即数位中权重小的那一端。

正如上图所描述的一样,小端存储方式是以 低 位 数 据 ( 我 们 可 以 直 观 的 想 象 成 最 右 边 的 一 个 字 节 数 据 ) 低位数据_{(我们可以直观的想象成最右边的一个字节数据)} 先存储,高位数据后存储的方式存储。只要了解这一点,对于本例的理解就绰绰有余了。

四、分析二进制源码与输出过程

先写如下程序:

	double a = 8.0;
	printf("%ld , %f , %lf\n", a, a, a);

输出结果如图:
在这里插入图片描述
思考: 为什么明明是 8.0 在格式化输出为整型时却输出了 0

查看二进制原码

	double a = 8.0;
	printf("%ld , %f , %lf\n", a, a, a);

	/* 输出二进制 */
	std::bitset<64> mybit;
	mybit = *(unsigned long long*) & a;
	std::cout << a << "\t--a--\t" << mybit << std::endl;

输出如图所示:
在这里插入图片描述
可以看到,在二进制原码中就前几位有几个1,后面全是 0 。

咳…咳… ,有没有想起点啥😎

  • 想起 1:浮点数存储是整数部分和小数部分 分开存储的。前面一部分是整数部分后面一部分是小数部分。
    在这里插入图片描述
    按图中所说的,前 12 位应该就是表示 8 的二进制了,后面 52个0就是小数部分了。

关于整数部分为什么是 010000000010 这里我们不做讨论,有兴趣可以参看文章顶部的链接,有关知识点为 浮点数存储IEEE754标准

  • 想起 2:小端存储,我们都知道如果int类型占4个字节,而double类型占8个字节 浮 点 数 默 认 为 d o u b l e 类 型 _{浮点数默认为double类型} double
    我们在输出为 %d 时 或输出为 %ld 时,都是32位,也就是4字节。如果按照小端存储的理论,先把二进制原码中后面四个字节存储到当前的地址,而二进制原码的前四个字节存储存储到紧挨着的下一个内存单元中。而我们输出的是当前内存单元的结果,那么自然为0了。

因此,我们如果想输出正确的结果,即输出整数8,我们只需要这样操作即可。

    double a = 8.0;
	/* 强转为整形输出 */ 
   printf("%d\n", (int)a);	
   // 把浮点数转换为整数,会截断浮点数,造成精度丢失。如果我们要输出整数部分,无法自己完成
   // 但是编译器知道如何进行转换,我们把这一工作交个编译器完成。

那么整个存储的过程,是不是这样的呢?
在这里插入图片描述

小端存储是以字节为单位的,所以因该是分成了8份,分别存储进各个内存单元,而不是以上图中的一分为2 的方式。 下面我们继续探究。

五、探究输出过程究竟发生了什么

根据我们上面提到的,1234以char类型输出为4是因为存储过程以字节为单位,把最后的一个字节数据存在了首地址所在的第一个内存单元,… 第一个字节的数据存储在了第四块内存单元。 并且,在解析数据的时候也以小端的方式恢复解析,所以,整型数据解析出来就是原来的数据内容1234

为了探究这一过程,我们使用 char* 类型的指针,分别访问double类型变量地址上的每一块内存。

	double a = 8.0;
	printf("%ld , %f , %lf\n", a, a, a);

	/* 输出二进制 */
	std::bitset<64> mybit;
	mybit = *(unsigned long long*) & a;
	std::cout << a << "  " << mybit << std::endl;
	std::cout << std::endl;

	/* 使用指针p指向a的首地址*/
	unsigned char* p = (unsigned char*)&a;
	std::bitset<8> cBit;

	std::cout <<  "分别输出每一块内存单元的二进制格式数据:" <<  std::endl;
	
	/* 输出地址地址范围 */
	std::cout << "地址: \t";
	std::cout << (void*)p << "  ~  " << (void*)(p+8) <<  std::endl;
	
	/* 分别输出每一块内存块的二进制原码数据 */
	int i = 8;
	while (i--)
	{
		cBit = *(unsigned char*)p;

		std::cout << cBit << " ";
		p++;
	}
	std::cout << std::endl;

输出结果如图所示:
在这里插入图片描述
在这里插入图片描述

可以看到,二进制数据在存储时确实是以小端的方式存储到计算机内存中的,并且是以字节为单位。

下面为了输出对比,另外准备一个值为 1.1 的double类型,并按上述方法输出其二进制格式数据。

#include <cstdio>
#include <iostream>
#include <bitset>

//#if 0
int main()
{
	double a = 8.0;
	printf("%ld , %f , %lf\n\n", a, a, a);

	/* 输出二进制 */
	std::bitset<64> mybit;
	mybit = *(unsigned long long*) & a;
	std::cout << a << "  " << mybit << std::endl;


	/* 强转为整形输出 */ 
	//printf("%d\n", (int)a);

	unsigned char* p = (unsigned char*)&a;
	std::bitset<8> cBit;

	//std::cout <<  "分别输出每一块内存单元的二进制格式数据:" <<  std::endl;
	
	//std::cout << "地址: \t";
	//std::cout << (void*)p << "  ~  " << (void*)(p+8) <<  std::endl;
	int i = 8;
	while (i--)
	{
		cBit = *(unsigned char*)p;

		std::cout << cBit << " ";
		p++;
	}
	std::cout << "\n" << std::endl;


	
	/*                    double 型 1.1 数据                 */
	double d = 1.1;
	mybit = *(unsigned long long*) & d;
	std::cout << d << "  " << mybit << std::endl;

	p = (unsigned char*)&d;
	cBit = *(unsigned char*)p;

	i = 8;
	while (i--)
	{
		cBit = *(unsigned char*)p;

		std::cout << cBit << " ";
		p++;
	}
	std::cout << std::endl;

	return 0;
}

输出结果:

0 , 8.000000 , 8.000000

8  0100000000100000000000000000000000000000000000000000000000000000
00000000 00000000 00000000 00000000 00000000 00000000 00100000 01000000

1.1  0011111111110001100110011001100110011001100110011001100110011010
10011010 10011001 10011001 10011001 10011001 10011001 11110001 00111111


分割线


补:float与double的%d输出

在之前的测试中,我们用的都是 double 类型数据测试,并且已经已经对其进行分析。而在后续的测试中使用 float 类型测试时又发现一个有意思的问题。

	float m = 5.6;
	printf("%d\n",m);			// 输出 1610612736

在对其进行bitset < 32>操作,读取其内存中的二进制原码时,发现与我们程序输出的 1610612736 的二进制数相差甚远。

// float m = 5.6; 的二进制表示形式
01000000101100110011001100110011
// int num = 1610612736; 的二进制表示形式
01100000000000000000000000000000

我们都知道float类型占四个字节32位,我把测试对象从double转换为float怎末还就行不通了?我不经开始怀疑之前的推论是否正确。

经过多番测试,发现使用以下测试代码可以解释其原理:

int main( )
{
	float m = 5.6;

	printf("%d\n",m);			// 输出 1610612736

	bitset<64> mybit;			// 浮点数 float
	mybit = *(unsigned long long*)& m;
	cout << mybit << endl;		// 其二进制代码

	double n = (double)m;		// 强转为 double
	bitset<64> mybit2;
	mybit2 = *(unsigned long long*)& n;
	cout << mybit2 << endl;		// 其二进制代码


	cout << (bitset < 32>)1610612736 << endl;  // 1610612736 的二进制
	cout << 0b01100000000000000000000000000000 << endl;
	return 0;
}

在这里插入图片描述
在这段代码中,

  • 我们把浮点数以64位的二进制方式输出。
    将原float型浮点数 m 转换为 double型浮点数 n,输出其二进制。
    最后输出1610612736的二进制数据。
  • 发现double型数据的二进制位中,后四个字节就是我们所输出的 1610612736的二进制。

具体原理大概就是,
float 浮点数在使用printf() 函数用 "%d"格式输出时,会将 浮点数以 8 字节方式入栈,转化为double方式存储
ps:博主没系统学过汇编,只懂一些基础的指令,只能强行分析一波。以下时通过vs 2019反汇编代码。汇编代码如下:

	float m = 5.6;
00092CE2  movss       xmm0,dword ptr [__real@40400000 (0A0180h)]  
00092CEA  movss       dword ptr [m],xmm0  

	printf("%d\n",m);
00092CEF  cvtss2sd    xmm0,dword ptr [m]  		// xmm0~xmm7八个128位的寄存器
00092CF4  sub         esp,8  					// ※开辟 8字节 栈空间※
00092CF7  movsd       mmword ptr [esp],xmm0     // MMWORD is the type for a 64-bit multimedia value.
00092CFC  push        offset string "%d\n" (0A017Ch)  
00092D01  call        _printf (0810D2h)  
00092D06  add         esp,0Ch  

查阅资料发现确实有float类型转为 double类型 通过double型浮点数寄存器存储的说法,而在输出时以“%d” 4字节的32位整形数据的格式输出,会对数据强行截断(数据截断保留低位,舍弃高位 数据截断测试)。所以最终输出的时候输出了其后的四个字节数据。
输出:

1610612736		//printf("%d\n",m);
1100110011001100110011001100110001000000101100110011001100110011	// float类型5.6
0100000000010110011001100110011001100000000000000000000000000000	// 强转为double类型
01100000000000000000000000000000	// 1610612736 的二进制原码
1610612736							// int num = 0b01100000000000000000000000000000;

如下图所示。
在这里插入图片描述

最后,至于为什么要把float转换为double入栈,我暂时也不清楚,大概与效率等有关把。这里链接两篇文章,作为参考。
SSE指令学习
代码优化-之-优化浮点数取整






关于强转时,数据截断实验。 将8字节的 long long 类型强转为 4字节的int 类型时,会 丢到前四个字节
int main()
{
	bitset<64> mybit;

			// 0b1 00000000 00000000 00000000 00000000  33位
	long long  n = 0b100000000000000000000000000000000;

	printf("%lld\n", n);
	cout << (bitset<64>)(*(unsigned long long*) & n) << endl;		// 其二进制代码

	int m = (int)n;	// 强转,大数据转小数据 “数据截断”,保留低位

	printf("%d\n", m);
	cout << bitset<32>(*(unsigned long long*) & m) << endl;			// 其二进制代码

	return 0;
}

输出:

4294967296
0000000000000000000000000000000100000000000000000000000000000000
0
00000000000000000000000000000000
  • 20
    点赞
  • 33
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

我叫RT

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值