这里引用浮点数在计算机中的存储方式一文。数据在计算机中的表示 | 进制转换、浮点数表示
如题,为什么 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而言,以大端的思想存储,那么在内存中是这样分布的。可以发现大端存储更为符合人书写习惯,低地址位(左侧)存放高位。我们可以记忆为顺序存储(以人类阅读的顺序习惯)。
地址 | 0x100 | 0x200 | 0x300 | 0x400 | 0x500 | 0x600 | |
---|---|---|---|---|---|---|---|
数据 | 1 | 2 | 3 | 3 | 5 | 6 | 个 位 |
对于数字123456而言,以小端的思想存储,那么在内存中是这样分布的。可以发现小端存储更为符合计算机的存储习惯,高地址存放高位(百分位)。这种存储方式直观上与人行为习惯不符,我们可以记忆为交叉存储(以人类的阅读顺序来看,首位交叉存储)。
地址 | 0x100 | 0x200 | 0x300 | 0x400 | 0x500 | 0x600 | |
---|---|---|---|---|---|---|---|
数据 | 6 | 5 | 4 | 3 | 2 | 1 | 百 分 位 |
图解:
因为内存地址是连续的,计算机读写习惯是从低地址到高地址。将数据的高低位和内存地址的高低地址进行对比,我们可以发现对于小端存储来说就是将数据以交错的形式存储。
而这样存储之后,我们发现:
- 内存的高地址 对应 数的高位
- 内存的低地址 对应 数的地位
因此,小端存储方式可记忆为“小端先存储”,这里的 ‘小端’ 即数位中权重小的那一端。
正如上图所描述的一样,小端存储方式是以 低 位 数 据 ( 我 们 可 以 直 观 的 想 象 成 最 右 边 的 一 个 字 节 数 据 ) 低位数据_{(我们可以直观的想象成最右边的一个字节数据)} 低位数据(我们可以直观的想象成最右边的一个字节数据) 先存储,高位数据后存储的方式存储。只要了解这一点,对于本例的理解就绰绰有余了。
四、分析二进制源码与输出过程
先写如下程序:
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指令学习
代码优化-之-优化浮点数取整
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