前言

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

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

  1. Alamofire源码解读系列(八)之安全策略(ServerTrustPolicy)

    本篇主要讲解Alamofire中安全验证代码 前言 作为开发人员,理解HTTPS的原理和应用算是一项基本技能.HTTPS目前来说是非常安全的,但仍然有大量的公司还在使用HTTP.其实HTTPS也并不是 ...

  2. Alamofire源码解读系列(十一)之多表单(MultipartFormData)

    本篇讲解跟上传数据相关的多表单 前言 我相信应该有不少的开发者不明白多表单是怎么一回事,然而事实上,多表单确实很简单.试想一下,如果有多个不同类型的文件(png/txt/mp3/pdf等等)需要上传给 ...

  3. Alamofire源码解读系列(二)之错误处理(AFError)

    本篇主要讲解Alamofire中错误的处理机制 前言 在开发中,往往最容易被忽略的内容就是对错误的处理.有经验的开发者,能够对自己写的每行代码负责,而且非常清楚自己写的代码在什么时候会出现异常,这样就 ...

  4. Alamofire源码解读系列(四)之参数编码(ParameterEncoding)

    本篇讲解参数编码的内容 前言 我们在开发中发的每一个请求都是通过URLRequest来进行封装的,可以通过一个URL生成URLRequest.那么如果我有一个参数字典,这个参数字典又是如何从客户端传递 ...

  5. Alamofire源码解读系列(三)之通知处理(Notification)

    本篇讲解swift中通知的用法 前言 通知作为传递事件和数据的载体,在使用中是不受限制的.由于忘记移除某个通知的监听,会造成很多潜在的问题,这些问题在测试中是很难被发现的.但这不是我们这篇文章探讨的主 ...

  6. Alamofire源码解读系列(五)之结果封装(Result)

    本篇讲解Result的封装 前言 有时候,我们会根据现实中的事物来对程序中的某个业务关系进行抽象,这句话很难理解.在Alamofire中,使用Response来描述请求后的结果.我们都知道Alamof ...

  7. Alamofire源码解读系列(六)之Task代理(TaskDelegate)

    本篇介绍Task代理(TaskDelegate.swift) 前言 我相信可能有80%的同学使用AFNetworking或者Alamofire处理网络事件,并且这两个框架都提供了丰富的功能,我也相信很 ...

  8. Alamofire源码解读系列(七)之网络监控(NetworkReachabilityManager)

    Alamofire源码解读系列(七)之网络监控(NetworkReachabilityManager) 本篇主要讲解iOS开发中的网络监控 前言 在开发中,有时候我们需要获取这些信息: 手机是否联网 ...

  9. Alamofire源码解读系列(九)之响应封装(Response)

    本篇主要带来Alamofire中Response的解读 前言 在每篇文章的前言部分,我都会把我认为的本篇最重要的内容提前讲一下.我更想同大家分享这些顶级框架在设计和编码层次究竟有哪些过人的地方?当然, ...

  10. Alamofire源码解读系列(十)之序列化(ResponseSerialization)

    本篇主要讲解Alamofire中如何把服务器返回的数据序列化 前言 和前边的文章不同, 在这一篇中,我想从程序的设计层次上解读ResponseSerialization这个文件.更直观的去探讨该功能是 ...

随机推荐

  1. spark (一) 入门 & 安装

    目录 基本概念 spark 核心模块 spark core (核心) spark sql (结构化数据操作) spark streaming (流式数据操作) 部署模式 local(本地模式) sta ...

  2. 使用format_obproxy_digest_log工具分析obproxy网络层耗时SQL

    之前写过一个博客,介绍 ob_tools包 来实施抓取 observer 层的 gv$ob_sql_audit 的SQL,还提供一些分析SQL来通过不同维度分析缓慢的业务SQL语句,免得和应用扯皮说数 ...

  3. 《C++并发编程实战》读书笔记(1):线程管控

    1.线程的基本管控 包含头文件<thread>后,通过构建std::thread对象启动线程,任何可调用类型都适用于std::thread. void do_some_work(); st ...

  4. 在OERV也可以玩MC(上)

    最近发现一个比较有意思的事情,原来HMCL这个项目也移植到RISC-V上了,之前一直没有发现,因此在OERV(openEuler RISC-V的简称)玩MC也是可以的了.首先,HMCL是一款功能丰富的 ...

  5. Java中的流操作

    1. 字符流 1.1字符输入流 - Reader - FileReader 涉及到连接的,用完了就要关闭.   **为什么read方法  返回的值是 int,而不是char?因为读到结尾的时候,cha ...

  6. linux:MariaDB安装

    介绍 链接 安装 查看系统中是否已安装 rpm -qa | grep -i mariadb 返回结果类似如下内容,则表示已有 MariaDB 的包 为避免安装版本不同造成冲突,请执行以下命令移除已安装 ...

  7. 【译】MongoDB EF Core 提供程序:有什么新功能?

    原文 | Rishit, Luce 翻译 | 郑子铭 这是 Rishit Bhatia 和 Luce Carter 的客座文章.Rishit 是 MongoDB 的高级产品经理,专注于 .NET 开发 ...

  8. Q:浏览器不能上网,但是可以ping通外网ip,dns配置也没问题(TCP/IP 无法建立传出连接)

    问题症状 每隔一段时间,浏览器不能访问外网,重启电脑又正常,重置网卡无效 可以ping通外网ip地址,可以ping通外网域名 ping不通外网端口端口 查看日志 每次出现不能上网情况时都会有至少两条T ...

  9. Linux使用堡垒机

    一.linux系统使用 1. 安装 puttygen sudo apt-get install putty 2. 进入 pem 所在文件执行转换命令 puttygen mykey.pem -o myk ...

  10. MacSecureCRT配置

    一.调整连接服务器超时时间 Options->Global Options->General->Default Session-> 右侧Edit Default Setting ...