一、小数的二进制表示
前情提要:我们可以用32位(1个字)来表示整型integer、无符号整型unsigned integer、4个字符、32位机器中的指针
问题:我们该如何表示这些数:实数(有理数、无理数,如3.1415926),非常大的数(6.02×10^23),非常小的数(6.02×10^-34),特殊值(∞,Nan)
为什么我们不能表示分数?因为我们的位(bit)只能表示2的非负幂,最小的单位也是2^0=1,即任意不同的数的差值一定大于等于1,因此我们无法表示分数中小于0的部分。
想要解决这个问题,我们先来看看十进制的分数是怎么表示的:
我们发现十进制的小数部分其实就是10的负数次幂,那么如果我们把这个思路沿用到二进制呢?
关于如何把十进制有理数转换为二进制,请参考除二取余法、乘二取整法
我们可以让二进制的小数部分依次表示2^-1、2^-1、2^-3......以此类推,但是你会注意到,有一些小数我们是可以精确表示的:0.5、0.25、0.125......而有些数我们是无法精确表示的,如:0.3、0.6、0.7......
总之,一旦我们确定二进制小数的精度(即保留多少位二进制小数),那么二进制小数部分的值,必然是这个最小精度的整数倍。 eg:我们把精度保留到小数点后第6位,即2^-6=1/64,那么该二进制小数部分的值必然是1/64的整数倍,任何不满足的小数我们都不能精确表示。
但即便如此,只要我们的精度足够小,那么对数据的表示就越准确,在大多数场景下已经可以满足需求。
二、IEEE754标准
我们有了小数的二进制表示方法,现在的问题是具体该如何存储二进制小数,以及一些特别大的数(6.02×10^23)、特别小的数(6.02×10^-34)我们该如何处理?于是,IEEE754标准应运而生:
简单来说,IEEE754标准把比特位分为三个部分:符号位、指数域、尾数域。以4个字节32位的float类型为例:

1位用来作为符号位(0表示正数、1表示负数),8位用来作指数域,23位用来作尾数域。其中符号位表示该浮点数的正负,指数域表示浮点数的数量级,尾数域用来确定浮点数的精度。
首先,我们要把浮点数用科学计数法表示,比如3.14=11.0010001111010111000010......=1.10010001111010111000010......×2^1。科学计数法中小数点的位置是浮动的,这也是浮点数其名称的由来。
指数域=指数+偏移量(biased notation),float类型的偏移量为2^7-1=127=01111111,3.14的指数为1,所以3.14的指数域为10000000。为什么要加一个偏移量呢?其实是为了方便对数值进行比较,在比较数值时,我们可以先比较他们的符号位,0>1,再比较他们的指数域即数量级,最后再比较他们的尾数域。所以指数域的8位是无符号类型。例如指数为-127时,指数域为00000000,指数为128时,指数域为11111111,这样我们正好就可以表示数量级范围为2^-127 ~ 2^128(相当于10^-38 ~ 10^38)之内的数了(其实并不完全是,请参考:四、特殊数值的表示),可表示数的范围一下子扩大了许多。
而1.10010001111010111000010中的小数部分10010001111010111000010就是3.14的尾数域,为什么舍去了个位上的1,是因为科学计数法的个位一定为1,因此省略,我们把这个被省略的1称为隐含"1"。 float类型的尾数域有23位,也就意味着精度为2^-23≈10^-6.92,即float类型可以保证10进制的小数点后6位,并且表示的误差不超过2^-23,一般情况这个精度已经很高了
综上,3.14的float类型的实际存储为0 10000000 10010001111010111000010
那我们来写一段程序验证一下:
ini
#include <stdio.h>
union FloatIntUnion {
float f;
unsigned int x;
};
int main()
{
union FloatIntUnion u;
u.f = 3.14; // 设置要查看的浮点数
// 获取浮点数的二进制表示
unsigned int binary[32];
for (int i = 0; i < 32; i++) {
binary[i] = (u.x >> (31 - i)) & 1;
}
for (int i = 0; i < 32; i++) {
printf("%u", binary[i]);
if(i == 0 || i == 8) printf(" ");
}
printf("\n");
return 0;
}

奇怪,看上去基本没问题,但是为什么最后一位是1而不是推算出的0呢?其实,浮点数类型在存储的时候还会进行一次舍入,例如3.14的二进制小数如果多保留几位小数11.001000111101011100001010001,发现尾数被舍弃的部分大于一半,因此选择进位(参考五、1.舍入模式),这就是为什么实际的3.14存储为0 10000000 10010001111010111000011
我们可以换一个数验证一下: 2.56同理推算出其二进制存储为0 10000000 01000111101011100001010001,尾数被舍弃的部分不到一半,因此不进位,结果为0 10000000 01000111101011100001010
ini
#include <stdio.h>
union FloatIntUnion {
float f;
unsigned int x;
};
int main()
{
union FloatIntUnion u;
u.f = 2.56; // 设置要查看的浮点数
// 获取浮点数的二进制表示
unsigned int binary[32];
for (int i = 0; i < 32; i++) {
binary[i] = (u.x >> (31 - i)) & 1;
}
for (int i = 0; i < 32; i++) {
printf("%u", binary[i]);
if(i == 0 || i == 8) printf(" ");
}
printf("\n");
return 0;
}

验证成功
三、关于double
double的原理与float相同,只不过double为8个字节64位,划分为1个符号位、11位指数域、52位尾数域,因此,double表示的范围比float更大,精度更准。
四、特殊数值的表示
以下都按float举例
1.+0与-0
由于隐含"1"的存在,即使指数域和尾数域同时为0,也无法表示0,而是1×2^-127≠0。 因此,为了表示0,我们规定指数域和尾数域同时为0时就表示0而不表示1×2^-127,这是一种规范。
由于符号位的存在,我们可以发现0有两种:+0(0,00000000,00000000000000000000000)、-0(1,00000000,00000000000000000000000)
2.+∞、-∞与NAN
为了表示无穷大以及NAN(非数值,not a number),我们规定指数域最大即为11111111,并且尾数域为0时,表示无穷大,正负取决于符号位。而当指数域为11111111,尾数域不为0时,将这些数定义为NAN,NAN表示计算中的错误或不确定结果。例如,当试图执行一个非法的数学操作时,比如0除以0,结果将是NaN。这种情况下,NaN提供了一种方式来指示出现了错误。对NaN进行某些数学运算(如加减乘除)的结果通常也是NaN。这种定义使得在处理数据时可以更容易地处理特殊情况。
3.非规范化浮点数
这时我们还有一个问题:我们无法表示非常接近于0的数值,因为当指数域为0尾数域不为0时,由于隐含"1"的存在,这些数都必定大于等于1×2^-127,而无法表示非常接近于0的部分。

如图,a=1×2^-127,b=(1.000......0001)×2^-127=(1+2^-23)×2^-127=1×2^-127+2^-150, b-a=2^-150,而a-0=2^-127>>2^-150,由此形成了一个big gap。
为了解决这个问题,我们规定指数域为0而尾数域不为0的数不包含隐含"1",并且这些数的指数即使为0,但不再表示2^-127而是2^-126,这是为了与指数域为1尾数域为0的数1×2^-126接近。我们把这些数称为非规范化的浮点数,而指数域位于[1 ~ 254](即真实指数在-126 ~ 127之间)的数称为规范化的浮点数。
现在的gap是怎样的呢:
------绝对值最小的非规范化浮点数: a = 2^-149
------绝对值最大的非规范化浮点数: b = (2^-126 -- 2^-149)
------绝对值最小的规范化浮点数: c = 2^-126
c-b=2^-149, 非规范浮点数之间的精度=2^-149,a-0=2^-149
这样gap的设置就合理多了
下图为浮点数类型按指数域的分类:

五、浮点数的限制
1.三种特殊情况:溢出、下溢与舍入
(1)数值绝对值过大超出指数域的范围(abs(x)> 2^128):溢出
(2)数值绝对值过小超出指数域的范围(abs(x)< 2^-149):下溢
(3)数值的尾数部分超出了尾数限制,导致发生舍入。而最终结果可能与预期结果有所不同,这可能会产生一些意想不到的结果,特别是在高精度要求的计算中。
diff
eg:假设我们有两个单精度浮点数,每个尾数有 23 位,符号位占 1 位,指数占 8 位。
现在考虑一个简单的浮点数加法:0.1 + 0.2。在十进制中,这个结果是 0.3,但是在二进制中,这个结果是一个无限循环小数。因此,在单精度浮点数的尾数范围内,计算的结果将被截断。
假设单精度浮点数的尾数只能容纳 4 位。0.1 和 0.2 的二进制表示如下:
- 0.1 的近似二进制表示:0.00011001100110011001101...
- 0.2 的近似二进制表示:0.00110011001100110011010...
在尾数只能容纳 4 位的情况下,浮点数的计算结果可能如下:
```
markdownCopy code
0.0001 (0.1 的近似二进制表示)
+ 0.0011 (0.2 的近似二进制表示)
--------
0.0100
```
在这个例子中,结果被截断,只保留了 0.0100,这相当于十进制中的 0.25,而不是预期的 0.3。这就是由于尾数位数限制而导致的舍入误差,使得最终结果与预期不同。
常见的舍入模式:
- 舍入到最接近,连接到偶数:四舍五入到最接近的值;如果数字落在中间,则会四舍五入到最接近的具有偶数最低有效数字的值。
- 朝+∞方向舍入:会将结果朝正无限大的方向舍入。
- 朝-∞方向舍入:会将结果朝负无限大的方向舍入。
- 朝0方向舍入:会将结果朝0的方向舍入。
其中,"舍入到最接近,连到偶数"是二进制浮点数的默认舍入模式。
对于不同的数量级,浮点数可以表示的最小单位的绝对值不同,数量级越大,最小单位越大,绝对误差就越大(相对误差不变)
把浮点数可以精确表示的数分配在数轴上,越靠近0的地方数越密集

(4)浮点数的加法不满足结合律(当数值差异较大时)
small + small + big ≠ small + big + small
这是因为精度有限,计算时需要先对齐指数,从而导致small数的关键数据都被舍弃了,导致计算结果失真
ps:乘法则没有这种缺陷,只要不发生溢出/下溢的话
让我们写段程序验证一下:
arduino
#include <stdio.h>
#include <math.h>
int main()
{
float big = pow(2, 60);
float tiny = pow(2, -15);
float bigneg = -big;
if((big + tiny + bigneg) != (big + bigneg + tiny))
printf("plus not equal\n");
else
printf("plus equal\n");
if((big * tiny * bigneg) != (big * bigneg * tiny))
printf("multiply not equal\n");
else
printf("multiply equal\n");
return 0;
}

验证成功