前言
小提示:阅读本篇内容,至少需要了解double和float的二进制表示规则。
书中的代码示例如下:
#include <stdio.h>int main(void)
{float a,b;b = 2.0e20 + 1.0;a = b - 2.0e20;printf("%f \n",a);return 0;
}
我的测试环境如下所示,在该测试环境中,a 等于 4008175468544.000000。
Linux version 5.15.0-134-generic (buildd@lcy02-amd64-092) (gcc (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0, GNU ld (GNU Binutils for Ubuntu) 2.34) #145~20.04.1-Ubuntu SMP Mon Feb 17 13:27:16 UTC 2025
为什么会产生这样的结果呢?由于书中的解释不够详细,所以我在阅读至此处时,自然而然产生了想要深入研究的想法,遂将研究结果写为本篇博客,供大家参考。
针对这个问题,我的思路是,逐行分析每一句代码都产生了什么效果,或许就可得知误差出现在哪里。下面开始逐行分析。
逐行分析解读-第一行代码
首先是第一行:
b = 2.0e20 + 1.0;
先从右边的计算 2.0e20 + 1.0 开始,我们首先需要把这两个常量的二进制表示出来,然后做浮点数加法运算。
2.0e20 和 1.0的二进制表示
首先,2.0e20会被当作double类型的数据进行处理,根据double类型的存储规则,它的底层二进制如下:
0-10001000010-0101101011110001110101111000101101011000110001000000
其中:
-
符号位:0
-
指数部分:10001000010
-
尾数部分:0101101011110001110101111000101101011000110001000000
插播一个小知识(不关心的可略过):给定一个大数,它的二进制是如何计算得出的呢?
- 首先,将\(2 \times 10^{20}\)进行质因式分解:
\(2 \times 10^{20} = 2 \times (2 \times 5)^{20} = 2^{21} \times 5^{20}\)
这表明,\(2 \times 10^{20}\)是由\(2^{21}\)和\(5^{20}\)的乘积构成,也就是说,这个大数的二进制形式可以看成5²⁰的二进制左移21位(即乘以2²¹),也就是说,我们需要求得\(5^{20}\)的二进制。
- 求\(5^{20}\)的二进制:
\(5^{20}\)的十进制为:95367431640625
十进制求得二进制的过程,简单来说,即用95367431640625不断除以2,得到余数1或者余数0的一个竖向序列,将其倒序即是所求二进制。这里我直接写出二进制:
10101101011110001110101111000101101011000110001(一共为47位)
- 整体二进制表示:
由上述计算可得知,\(2 \times 10^{20}\)的二进制为:
\(10101101011110001110101111000101101011000110001 \times 2^{21}\) 我们将其规范化,得到
\(1.0101101011110001110101111000101101011000110001 \times 2^{67}\)
- 求出了整体的二进制,double类型的具体存储就简单很多了。
首先是指数部分:67 + 1023 = 1090,二进制为10001000010
然后是尾数部分:直接提取小数点之后的部分,只有46位,还需要填充6个0即可(满足尾数52位的需求)
(小知识后咱们继续)其次,1.0也会被当作double类型来接收,底层二进制如下:
0 01111111111 0000000000000000000000000000000000000000000000000000
这个二进制就不详细说明了,相信有了前文的基础,读者可以轻松计算出来。
这里提供一套工具函数,用来打印float和double类型的二进制bit,用来验证很方便:
#include <stdio.h>
#include <stdint.h>void print_bits_float(float f)
{union {float a;uint32_t b;}c;c.a = f;for (int i = 0; i < 32; i++) {printf("%d", c.b & 1 << (31 - i) ? 1 : 0);if (i == 0 || i == 8)printf(" ");}printf("\n");
}void print_bits_double(double d)
{union {double a;uint64_t b;}c;c.a = d;for (int i = 0; i < 64; i++) {//caution! 1ULL not 1 because 1 << or >> more than 31 is undefined behaviour.printf("%d", c.b & (1 ULL << (63 - i)) ? 1 : 0);if (i == 0 || i == 11)printf(" ");}printf("\n");
}
有了2.0e20 和 1.0的二进制以后,接着需要做浮点数的加法运算。
2.0e20 + 1.0的浮点数加法运算
对阶
加法运算的第一个步骤叫对阶。什么叫对阶呢?就是把较小的指数对齐至另外的较大的指数,方便计算。
比如:
\(0.5_{10}\) + \(0.4375_{10}\) ,下标代表进制,所以这里是十进制的0.5加十进制的0.4375
先把它们转化为二进制。\(0.5_{10} = 1.0_{2} \times 2^{-1}\) 和 \(0.4375_{10} = 1.11 \times 2^{-2}\)
找到较小指数,即\(1.11 \times 2^{-2}\)(因为-2小于-1),将其指数-2对齐到较大的指数-1,对齐后变成这样:
\(1.11 \times 2^{-2} = 0.111 \times 2^{-1}\) 。
这里要注意,因为指数变大了,所以小数点要左移相应的位数以保证数据正确。这个例子中,这两种表达方式对应的数据都是 \(0.0111_{2}\)。如果换算不清楚的,可以像这样,把不带指数的数据写出来对照一下,看你写的数据运算后是否和不带指数的数据一样。
若是这里有读者基础不好,没看懂,只要明白二进制小数 * 2的N次幂的含义,应该就可以搞懂上述内容了,即当N大于0时,二进制小数 * 2的N次幂相当于把二进制小数的小数点右移N位;当N小于0时,二进制小数 * 2的N次幂相当于把小数点左移N位(联想下十进制就一定能搞懂了)
回到我们的代码,现在要加两个数2.0e20 和 1.0,前面已经讨论过了它们的二进制,接下来我们把它转化为规范化数:
2.0e20的规范化数为(52位完整尾数)
\(1.0101101011110001110101111000101101011000110001000000 \times 2^{67}\)
1.0的规范化数为(52位完整尾数)(下述为了方便表达,会用1.0替代)
\(1.0000000000000000000000000000000000000000000000000000 \times 2^{0}\)
需要找到较小的指数,即\(1.0 \times 2^{0}\),对齐更大的指数\(2^{67}\)后,数据变为:
\(0.000...0001 \times 2^{67}\)((一共有67个0,小数点后66个0))
这样对阶就完成了。
有效数相加
对阶完毕后,第二个步骤叫有效数相加。官方叫尾数运算,但其实这个运算是要考虑前导1的,所以我认为把它叫做有效数相加会更容易理解。(有效数:包括了前导1的完整数据,而非小数点后的尾数部分)
2.0e20的有效数为:
\(1.0101101011110001110101111000101101011000110001000000\)
对阶后的1.0的有效数为:
0.000...0001(一共有67个0,小数点后66个0)
其实仔细分析一下会发现,对阶1.0时会把小数点左移67位,那么当小数点左移67位以后,1其实已经消失了。这是因为,双精度浮点数的尾数是52位,而对阶后的1.0的有效数一共有68位,小数点后的52位被保留了,超过的部分会直接丢弃(而1正好处在丢弃区间内),所以1.0在对阶后尾数部分全部变成0,整个数据也变成了0.0。
既然1.0已经变成了0.0,那么相加之后的结果就还是2.0e20,若用上文提供的print_bits_double来分别打印 2.0e20 和 2.0e20 + 1.0,会发现打印出的二进制值是完全一样的。
因为结果本身已经是规范化数了,所以不需要做最后一个步骤规范化了(本质上就是移动小数点 + 修改对应的指数,相信读者有这个基础可以理解)。
等号右边的加法部分已经分析完毕,那么我们接着看赋值操作。
双精度赋值给单精度
上文介绍了,2.0e20 + 1.0以后,结果其实就是2.0e20,但是此时它是双精度的数据,代码中b的数据类型为float单精度,所以还需要做双精度转换为单精度的操作。
首先是符号位和指数部分的转换。
因为双精度原数据的符号位是0,所以单精度的符号位也是0,代表正数。关于指数部分,我们在上文分析过了,规范化处理后,2.0e20的指数部分为67,转换为单精度浮点数时,需要再加127,即127 + 67 = 194,二进制为11000010。
其次是尾数部分的转换。
因为双精度的尾数部分有52位,而单精度的尾数只有23位,既然单精度尾数部分无法完整容纳原双精度的尾数部分,那么就需要考虑如何舍入。IEEE 754标准提供了四种舍入模式,分别是:
-
Round to Nearest (Ties to Even):默认舍入模式,将结果舍入到最接近的可表示值。
-
Round toward Zero (Truncation):直接截断超出目标精度的部分。
-
Round toward +∞ (Ceiling):结果始终向正无穷方向调整。对正数而言,剩余部分全为0则直接截尾,不全为0则向最低有效位进1;负数的话不看剩余部分,直接截尾。
-
Round toward −∞ (Floor):结果始终向负无穷方向调整。对负数而言,剩余部分全为0则直接截尾,不全为0则向最低有效位进1;正数的话不看剩余部分,直接截尾。
下面我们着重讲解Round to Nearest (Ties to Even)模式,因为在我们的实验中,是使用第一种模式进行舍入的。
Round to Nearest (Ties to Even)模式
该模式的规则为:首先向最近的有效数舍入,如果它与两个相邻的有效数距离一样时(即它是中间数,halfway),那么舍入到最近的偶数有效数。
为了方便理解,我们以二进制数据来举例子,假如需要保留的有效位为4位:
1.001(该四位为有效位)+ 后6位(待判断)
最近的有效数有两个,分别为:1.001 和 1.010 。
接着分析下后六位,其取值范围是 000000 到 111111,我们把它分成两个区间来分析:
分析 000000-011111 取值范围
当后六位取值范围在000000-011111时,针对每一个数据,其与1.001 000000 的距离 都小于 其与1.010 000000 的距离。
我们以这个区间范围内最大的数据 1.001 011111为例:
-
该数据与1.001的距离为 1.001 011111 - 1.001 000000 = 0.000 011111
-
该数据与1.010的距离为 1.010 000000 - 1.001 011111 = 0.000 100001
因此,该数据的最近有效数为 1.001,而该数据又是全区间内的最大值,所以该区间范围内的其他数据的最近有效数均为1.001。
分析 100001 - 111111 取值范围
同理,当后六位取值范围在 100001 - 111111时 ,针对每一个是数据,其与 1.001 000000的距离 都大于 其与1.010 000000的距离。
我们以这个区间范围内的最小数据 1.001 100001 为例:
-
该数据与1.001的距离为 1.001 100001 - 1.001 000000 = 0.000 100001
-
该数据与1.010的距离为 1.010 000000 - 1.001 100001 = 0.000 011111
因此,该数据的最近有效数为 1.010,而该数据又是全区间内的最小值,所以该区间范围内的其他数据的最近有效数均为1.010。
分析中间值 1.001 100000
下面看下唯一还没有被分析过的数:1.001 100000,计算得知:
-
其与1.001的距离为 0.000 100000
-
其与1.010 的距离也为 0.000 100000
因为距离一样,所以该数据的舍入规则为舍入到最近的有效偶数,即为 1.010。
由此可见,当剩余部分的从左至右第一位为0时,直接截去;当剩余部分等于1000..000时,舍入到最近的偶数;否则,需要进位。
实际赋值分析
了解了舍入规则后,就可以回到我们的主题代码案例,继续我们的赋值之旅了。
我们已经了解了2.0e20 + 1.0之后1.0会"消失",结果依旧是2.0e20,它的双精度二进制我们再复习一遍:
0-10001000010-0101101011110001110101111000101101011000110001000000
当需要转化为单精度浮点数时:
-
符号位:直接把双精度浮点数的符号位填入即可,为0
-
指数部分:前文分析过,十进制为194,二进制为11000010
-
尾数部分:需要保留前23位,后29位为判断位
-
前23位:01011010111100011101011
-
后29位:11000101101011000110001000000
根据学习过的舍入规则,有效部分以外的剩余部分(后29位)的第一位非0,且整体非10000...000的形式,所以需要直接进位,那么舍入以后的尾数部分即:
01011010111100011101011 + 1 = 01011010111100011101100
-
-
转化以后的单精度浮点数二进制即:0-11000010-01011010111100011101100。
至此,第一行代码就分析完毕了。
逐行分析解读-第二行代码
上文我们已经分析完第一行代码,接下来,我们看下至关重要的第二行代码:
a = b - 2.0e20;
其实有了上文的基础,我们马上就可以得知,2.0e20是用double类型来接收的,其底层的二进制我们也已经清楚明了。但是在实际做减法之前,还需要将b从单精度浮点数提升为双精度浮点数。双精度转换为单精度的过程我们已经分析过了,因为舍入规则会导致进位,而从单精度提升到双精度则要简单很多,符号位与指数位我们不再分析,尾数部分只需要把单精度浮点数的23位尾数直接填充至双精度浮点数尾数部分的高位,然后剩余部分补0即可,转换以后的二进制如下:
0-10001000010-01011010111100011101100 00000000000000000000000000000
二者类型都为double后,就可以做减法了。
做减法时,先把前导1补充上,然后按位依次相减即可:
1.0101101011110001110110000000000000000000000000000000
-(减法)
1.0101101011110001110101111000101101011000110001000000
--------------------------------------------------------------------------
0.0000000000000000000000000111010010100111001111000000
不要忘记了后面的指数,我们把完整的浮点数写出来:
\(0.0000000000000000000000000111010010100111001111000000 \times 2^{67}\)
这个形式还不是规范化形式,需要转换为规范化表达:
\(1.11010010100111001111000000 \times 2^{41}\)
最后,我们需要把这个数据转化为单精度浮点数,才能存储到a变量中。因为尾数部分的有效位一共只有20位,所以用单精度浮点数的23位完全可以表示,就不需要舍入了,转化后的单精度浮点数二进制为:
-
符号位:0
-
指数部分:41 + 127 = 168,转化为二进制是10101000
-
尾数部分:11010010100111001111000
这个数,转化为十进制浮点数打印出来,便是:4008175468544.000000,至此我们的分析之旅结束了。
总结
下面,我们总结下整个过程中的关键步骤:
-
2.0e20 和 1.0都被当作double类型来接收
-
2.0e20 + 1.0以后,1.0因为对阶,有效部分在小数点左移过程中消失(存在于大于52位的位置)
-
双精度转换为单精度的过程,因为舍入规则,产生了进位,导致实际存储的float类型的值要比原来的double类型的值要大一些
-
后续把单精度提升为双精度,导致后面29位直接补0
所以,在整个过程中,误差出现的关键便在于:
-
第一次双精度加法后赋值给单精度,导致双精度数据变为单精度数据,而舍入规则导致了进位;
-
后续减法计算过程中,又把单精度数据转化位双精度,后29位直接补0,导致比实际双精度的2.0e20的二进制要大一些,产生的误差便是最终的结果:4008175468544.000000。
这也提示我们,在计算过程中,应该使用统一的数据类型,避免出现双精度转换单精度这种隐含操作。
上述代码案例若使用下面两种写法,则都会正确输出0.0
#include <stdio.h>int main(void)
{double a,b;b = 2.0e20 + 1.0;a = b - 2.0e20;printf("%f \n",a);return 0;
}
#include <stdio.h>int main(void)
{float a,b;b = 2.0e20f + 1.0f;a = b - 2.0e20f;printf("%f \n",a);return 0;
}