c-primer-plus深入解读系列-从二进制到误差:逐行拆解C语言浮点运算中的4008175468544之谜

前言

小提示:阅读本篇内容,至少需要了解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

插播一个小知识(不关心的可略过):给定一个大数,它的二进制是如何计算得出的呢?

  1. 首先,将(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})的二进制。

  1. (5^{20})的二进制:

(5^{20})的十进制为:95367431640625

十进制求得二进制的过程,简单来说,即用95367431640625不断除以2,得到余数1或者余数0的一个竖向序列,将其倒序即是所求二进制。这里我直接写出二进制:

10101101011110001110101111000101101011000110001(一共为47位)

  1. 整体二进制表示:

由上述计算可得知,(2 times 10^{20})的二进制为:

(10101101011110001110101111000101101011000110001 times 2^{21}) 我们将其规范化,得到

(1.0101101011110001110101111000101101011000110001 times 2^{67})

  1. 求出了整体的二进制,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标准提供了四种舍入模式,分别是:

  1. Round to Nearest (Ties to Even):默认舍入模式,将结果舍入到最接近的可表示值。

  2. Round toward Zero (Truncation):直接截断超出目标精度的部分。

  3. Round toward +∞ (Ceiling):结果始终向正无穷方向调整。对正数而言,剩余部分全为0则直接截尾,不全为0则向最低有效位进1;负数的话不看剩余部分,直接截尾。

  4. 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

所以,在整个过程中,误差出现的关键便在于:

  1. 第一次双精度加法后赋值给单精度,导致双精度数据变为单精度数据,而舍入规则导致了进位;

  2. 后续减法计算过程中,又把单精度数据转化位双精度,后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; } 

发表评论

您必须 [ 登录 ] 才能发表留言!

相关文章

当前内容话题