最近更新于 2024-08-24 23:51
前言
从我高二接触到 C 语言,再到大学开始用 C++,不可避免的都用到浮点数,但是却一直没去了解或理解 float 和 double 的结构。在前段时间的 AutoCAD 扩展开发中,处理 double 浮点数的四舍五入时,尝试了各种方案都没得到预期的结果。比如绘制一条 10.445 的直线,然后标注 2 位精度,在程序中读取直线长度,四舍五入到 2 位小数却得到了 10.44 的结果,而不是 10.45。经过多番波折,最后才发现绘制的 10.445 的直线在内存中的数据是个近似值 10.444999999999993,第三位小数是 4,所以实际四舍五入就变成了 10.44。归根到底是浮点数存储精度损失的问题,在网上也没查到解决这个问题的方法(不能考虑用字符串表示数字的方式保证精度,CAD 内部使用的 double 存储,解决问题已经限制使用 double 类型的前提下),自己也毫无思路。只能从头学习一下浮点数存储的结构,再思索方法解决。
研究工具
C/C++ 编译器
MSVC(VS 2022)
数据结构可视化工具
IEEE-754 Floating Point Converter(IEEE-754 浮点数转换器)【32 位浮点数】:https://www.h-schmidt.net/FloatConverter/IEEE754.html
二进制转换工具【64位浮点数】:https://www.binaryconvert.com/result_double.html
参考资料
《深入理解计算机系统》原书第三版 上册,机械工业出版社,2016
二进制理解(基础)
二进制与十进制的关系
在十进制中,每位数的范围为 [0,9]
(1234.6709)_{10}=(1\times10^3+2\times10^2+3\times10^1+4\times10^0+6\times10^{-1}+7\times10^{-2}+0\times10^{-3}+9\times10^{-4})_{10}
在二进制中,每位数的范围为[0,1]
二进制表换算为十进制
(101.11)_2=(1\times2^2+0\times2^1+1\times2^0+1\times2^{-1}+1\times2^{-2})_{10}=(5.75)_{10}
133.90625 转二进制,整数部分,连除 2 直到商为 0,余数倒取就是对应的二进制
133.90625 整数部分:
\begin{array}{l}
133\div2=66\cdots\cdots1 \\
66\div2=33\cdots\cdots0 \\
33\div2=16\cdots\cdots1 \\
16\div2=8\cdots\cdots0 \\
8\div2=4\cdots\cdots0 \\
4\div2=2\cdots\cdots0 \\
2\div2=1\cdots0 \\
1\div2=0\cdots\cdots1
\end{array}
余数倒数为 10000101,整数部分表示为二进制就是 10000101
小数部分连乘 2 直到小数为 0,积的整数部分正序就是小数部分。如果十进制小数转二进制小数为无限小数,则根据精度要求取前面的指定位数。
根据十进制小数转二进制的计算规则,可以发现判断结果有限还是无限的方法:
133.90625 小数部分:
\begin{array}{l}
0.90625\times2=1+0.8125 \\
0.8125\times2=1+0.625 \\
0.625\times2=1+0.25 \\
0.25\times2=0+0.5 \\
0.5\times2=1+0
\end{array}
积的整数部分连起来为 11101,即小数部分为 0.11101
所以 133.90625 的二进制为 10000101.11101
十进制小数转二进制是有限位还是无限位的判断
根据十进制小数转二进制的计算方法,可以得出:
将十进制的小数表示为最简分数形式,如果分母是2的指数幂,则可以表示为二进制有限位小数,否则为二进制无限小数。
①0.90625
表示为最简分数为\frac{29}{32}
,分母32=2^5
是2的指数幂,所以0.90625可以表示为二进制有限小数。
②0.4
表示为最简分数为\frac{2}{5}
,分母为2^2\lt5\lt2^3
不是2的指数幂,所以0.4不能表示为二进制有限小数。
在实际应用中,经常能遇到二进制小数为无限位的情况,但是因为计算机储存浮点数的位数有限,只能存储无限小数的有限位,导致精度损失。
float 单精度浮点数(32位)
数据结构
十进制科学计数法表示
对于一个十进制数,可以用科学计数法表示为
\begin{array}{l}
V=(-1)^S\times M\times10^E, \\
S 取0或1,决定是正数还是负数, \\
1\le M\lt10,M为小数,\\
E 为整数。
\end{array}
比如
\begin{array}{l}
128_{10}=(-1)^0\times1.28\times10^2【S=0,M=1.28,E=2】\\
-0.0003025_{10}=(-1)^1\times3.025\times10^{-4}【S=1,M=3.025,E=-4】
\end{array}
即表示一个十进制数,可以通过符号S、分数值M、指数偏移值E来表示。
二进制科学计数法表示
而 IEEE-754 中浮点数的表示就是上面的原理,只是存储采用的二进制,将基数 10 换为了 2
\begin{array}{l}
V=(-1)^S\times M\times2^E \\
S 取0或1,决定是正数还是负数, \\
1\le M\lt2,M为小数,\\
E 为整数。
\end{array}
特别注意 M 的取值,M 的整数部分必然为 1。比如
5.5_{10}=101.1_2=(1.011\times2^2)_2
3.25_{10}=11.01_2=(1.101\times2^1)_2
在十进制的时候 M 取值 [1,10),二进制取值就只能是 [1,2),因此导致二进制下 M 取值的整数部分必然为 1,所以在存储的时候就不管整数的 1,可以只需要存储二进制小数部分即可。
float 储存结构
单精度浮点数(32位,float)数据结构如下,[0,22] 位储存 M 值的小数部分【23位尾数】,[23,30] 位储存 E 值【8位阶码】,[31,31] 位存储 S 值【1位符号】。
规格化的值
阶码有 8 位,2^8=256
,数值上可以存储的范围为 [0,255]。这里暂时不管符号,只看数值绝对值。当阶码取值在 [1,254] 时为规格化的值。
①当阶码取值[1,126]时,对应的指数范围为[-126,-1],指数为负数,最终表示的数必然在(0,1)之间;
②当阶码取值 127 时,对应的指数为 0,若尾数也为 0,则最终表示的数为 1【1_{10}=(1.0)_2\times2^0
】,若尾数不为 0,则最终表示的数在(1,2)
③当阶码取值[128,254]时,对应指数范围[1,127],最终表示的数范围在[2,MAX]
④当阶码为 1,尾数为 00000000000000000000000_2
时取得最接近 0 的小数【在规格化的值中】,即1.00000000000000000000000\times2^{-126}\approx1.175494350822287507968737\times10^{-38}
⑤当阶码为 254,尾数为 11111111111111111111111_2
时取最大值 (1+2^{-1}+2^{-2}+\cdots+2^{-23})\times2^{127}=(2-2^{-23})\times2^{127}\approx3.402823466385288598117042\times10^{38}
于是按 IEEE-754 的规定,有了映射关系:
\begin{array}{|l|l|}
\hline
阶码(对应十进制值) & 实际要表示的指数幂 \\
\hline
1 & 2^{-126} \\
\hline
\cdots & \cdots \\
\hline
126 & 2^{-1} \\
\hline
127 & 2^0 \\
\hline
128 & 2^1 \\
\hline
\cdots & \cdots \\
\hline
254 & 2^{127} \\
\hline
\end{array}
非规格化的值
当阶码取值为 0 时,为非规格化的值。
①当阶码为 0,尾数为 0,表示的数为 0,当符号为 1 时,为 -0,当符号为 0 时,为 +0。
②当阶码为 0,尾数不为 0 时,与规格化的值不同,此时 M 的取值不再是 [1,2),而是(0,1),也就意味着此时尾数的值不再加整数 1,尾数表示的小数就是 M 的值。
以十进制来理解,比如用 1.75\times10^{-1}
表示 0.175 就是规格化的,而用 0.175\times10^0
就是非规格化的。规格化的值,M 至少为 1,阶码最小为 2^{-126}
,尾数只表示小数部分,默认加 1 作为 M 的值,能表示的最小数就是 1.0\times2^{-126}
,也就导致没法表示 0,以及更接近 0 的数,而非规格化的值,尾数只表示小数部分,且 M 的值只包含尾数表示的小数,不再加 1。这样当尾数也为 0 的时候,表示的值就是 0,随着尾数从 11111111111111111111111_2
减小到 00000000000000000000001_2
逐渐更加接近 0。
有这样的关系
假如阶码为 1,尾数为 00000000000000000000000_2
时,代表的值为 a
阶码为 0,尾数为 11111111111111111111111_2
时,代表的值为 b
那么就有 a = b + 2^{-23}\times2^{-126}=b+2^{-149}
,即 a-b=2^{-149}
证明:
阶码为 0,尾数为 11111111111111111111111_2
,即b=(2^{-1}+2^{-2}+\cdots+2^{-23})\times2^{-126}=(1-2^{-23})\times2^{-126}=2^{-126}-2^{-149}
而a=2^{-126}
a-b=2^{-126}-(2^{-126}-2^{-149})=2^{-149}
,即证明
当阶码为 0,尾数取到 00000000000000000000001_2
时,得到 float 能表达的最接近 0 的小数 2^{-23}\times2^{-126}=2^{-149}\approx1.401298464324817070923730\times10^{-45}
特殊值
当阶码为 255 时,表示特殊值。
阶码为 255,尾数全为 0 时,为无穷。符号为 1 时是 -\infty
,符号为 0 时为 +\infty
。在两个非常大的数相乘时,或除数为 0 时,无穷可以表示溢出的结果。
阶码为 255,且尾数为非零,则表示 “NaN”【Not a Number】。一些运算的结果不能是实数或无穷,就会得到 NaN。
double 双精度浮点数(64位)
double 的数据结构和 float 类似,double 为双精度浮点数,数据总位数为 64。最高位是符号位,阶码从 8 为加到 11 位,尾数从 23 位加到 52 位。相关规定和 float 一样。
阶码为 11 位,2^{11}=2048
,即数值上可以存储的范围为[0,2047]
同样,阶码为 0 时是非规格化的值,阶码为 2047 时为特殊值,阶码取值 [1,2046] 时为规格化的值。
在规格化的值范围内
\begin{array}{|l|l|}
\hline
阶码(对应十进制值) & 实际要表示的指数幂 \\
\hline
1 & 2^{-1022} \\
\hline
\cdots & \cdots \\
\hline
1022 & 2^{-1} \\
\hline
1023 & 2^0 \\
\hline
1024 & 2^1 \\
\hline
\cdots & \cdots \\
\hline
2046 & 2^{1023} \\
\hline
\end{array}
在规格化的值中,
阶码为1,尾数全为0,表示最接近 0 的数,1.0\times2^{-1022}\approx2.22507385850720138309023271733\times10^{-308}
阶码为2046,尾数全为1,表示最大值,2^{52}\times2^{1023}\approx1.79769313486231570814527423732\times10^{308}
在非规格化的值中,
阶码为0,尾数仅最低位为1,表示最接近 0 的数,2^{-52}\times2^{-1022}\approx4.94065645841246544176568792868\times10^{-324}