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

news/2025/3/30 20:39:31/文章来源:https://www.cnblogs.com/ging/p/18796255

前言

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/906703.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

htb cicada靶场

htb Cicada靶场笔记 目标:Cicada,IP地址:10.10.11.35 1.信息收集nmap扫描目标端口,打印端口详细信息nmap -sVC 10.10.11.35 nmap -sVC 10.10.11.35 Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-03-26 22:30 EDT Stats: 0:00:50 elapsed; 0 hosts completed (1 up)…

KPI/KSF/360评估/FDM/ARM/BARS六大工具全解析:企业绩效管理方法论与实施

你是否因绩效考核方法难抉择而苦恼?年关将至,HR们最头疼的事非“绩效考核”莫属! 这不只是关系到员工的年终奖和绩效工资,更直接影响到来年的薪资调整和职业晋升。大家都盯着这个结果:谁能拿到那份丰厚的年终奖,谁能涨薪,谁又被“冷冻”了。 对HR来说,如何在这场考核中…

(单调)队列优化多重背包

省流:复杂度是 \(O(NM)\) 的。0 多重背包可以通过枚举选的个数做到 \(O(N^2 M)\)。 转移是 \(f_j=\max(f_{j-k\times w_i}+v_i\times k)\)。 1 注意到你每次转移好像只用到了一部分 \(f_j\),并且 \(j-k\times w_i\) 这个东西 \(j\bmod w_i\) 都相同,考虑将 \(j\bmod w_i\) 相…

关于python枚举的基本用法

简介 关于枚举类型。个人理解就是批量宏定义,并且是自增的id,下面直接写用法; enum用法 创建一个枚举变量 import enum labs_category=enum.Enum("labs_category",("a","b","c"))基本方法直接访问指定枚举对象访问枚举成员的变量名…

基于RK3568 + FPGA国产平台的多通道AD实时采集显示方案分享

在工业控制与数据采集领域,高精度的AD采集和实时显示至关重要。今天,我们就来基于瑞芯微RK3568J + FPGA国产平台深入探讨以下,它是如何实现该功能的。适用开发环境如下:Windows开发环境:Windows 7 64bit、Windows 10 64bitLinux开发环境:Ubuntu18.04.4 64bit、VMware15.5…

vue实现echart图

vue实现echart图<template><div class="analytics-container"> <el-row class="form-row" justify="center" align="middle"><el-col :span="12"><el-form label-width="100px">…

用IDEA从头创建一个jdbc项目修改数据库数据(mysql+navicat)

0. 参考文档[1] https://blog.csdn.net/PIKapikaaaa/article/details/124113065 [2] https://blog.csdn.net/qq_36816794/article/details/141621264 JDBC是java访问数据库的基石,JDO, Hibernate等只是更好的封装了JDBC。 1、创建项目 IDEA新建一个空项目或者空module 选中 ma…

使用 vxe-table 来实现左边是树,右边是表格联动功能

使用 vxe-table 来实现左边是树,右边是表格联动功能,当需要实现左右两侧联动时,表格 vxe-grid 配合分割模板 vxe-split 就很容易实现了 查看官网:https://vxetable.cn gitbub:https://github.com/x-extends/vxe-table gitee:https://gitee.com/x-extends/vxe-table 预览代…

day:31 pymysql(1)

一、pymysql下载 1、dos下安装: pip3 install pymysql 或pip install pymysql2、在pycharm中下载二、pymysql连接 (1)数据安装好,能连接(2)连接数据库1、连接方式:pymysql.Connection 或者pymysql.connect 2、包含内容 a.host 主机:填写IP地址 b.user 数据库用…

004 - 创建Runners , 就是创建编译node节点服务器

点击Admin:点击CICD 里面的Runner, 这里的CICD和我之前创建的CICD group没有任何关系. 点击 New instance runner 选择linux服务器, 然后点击 How do i install Gitlab Runner, 需要在node节点安装gitlab-runner 工具,让node节点连接到gitlab 服务器 , 然后就可以被gitlab的…