IEEE754 换算和代码实现

问题

有没有想过为什么在很多语言里面简单的浮点数计算会丢失掉精度,这个精度又是如何丢失和保证的,在计算机里面怎么用有限的空间去保存无限的数

PHP

1
2
3
4
<?php
echo (float)(0.1+0.2).PHP_EOL;
var_dump(0.1+0.2);
var_dump((0.1 + 0.2 ) == 0.3);

结果

1
2
3
0.3
float(0.3)
bool(false)

JS

1
2
0.1+0.2
0.30000000000000004

抄一段维基百科对IEEE754上的解释

IEEE二进制浮点数算术标准(IEEE 754)是20世纪80年代以来最广泛使用的浮点数运算标准,为许多CPU与浮点运算器所采用。这个标准定义了表示浮点数的格式(包括负零-0)与反常值(denormal number),一些特殊数值((无穷(Inf)与非数值(NaN)),以及这些数值的“浮点数运算符”;它也指明了四种数值舍入规则和五种例外状况(包括例外发生的时机与处理方式)。

IEEE 754规定了四种表示浮点数值的方式:单精确度(32位)、双精确度(64位)、延伸单精确度(43比特以上,很少使用)与延伸双精确度(79比特以上,通常以80位实现)。只有32位模式有强制要求,其他都是选择性的。大部分编程语言都提供了IEEE浮点数格式与算术,但有些将其列为非必需的。例如,IEEE 754问世之前就有的C语言,现在包括了IEEE算术,但不算作强制要求(C语言的float通常是指IEEE单精确度,而double是指双精确度)。

定义

浮点类型 符号位 阶码 接码偏移值 尾数
单精度(float32) 1 8 127 23
双精度(float64) 1 11 1023 52

演算

其实网上有很多讲解IEEE754的文章,但是至少我没找到一个比较详细的计算过程。

  • 整数位除二取余
  • 小数位乘二取整

我们先换算 0.1转换为二进制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 1 0.1 X 2 = 0.2
2 0.2 X 2 = 0.4
3 0.4 x 2 = 0.8
4 0.8 x 2 = 1.6
5 0.6 x 2 = 1.2
6 0.2 x 2 = 0.4
7 0.4 x 2 = 0.8
8 0.8 x 2 = 1.6
9 0.6 x 2 = 1.2
10 0.2 x 2 = 0.4
11 0.4 x 2 = 0.8
12 0.8 x 2 = 1.6
...

可以看出进入了一个 0.4 0.8 1.6 1.2 的死循环

用科学计数法即保留一位有效整数位,往后取23位为位数,往后移动的位数是加上偏移量是阶码

  • 尾数 = 0001.10011001100110011001101

  • 阶码 = (-4 + 127) = 123 = 01111011 (2022年11月16日补充:阶码为保留1个整数位小数点左右移动的位数 公式为 (位数+127) )

  • 符号位 = 0

0.1用二进制表示为

符号位 + 阶码 + 尾数

0_01111011_10011001100110011001101

GO实现

float32转换为IEEE754

1
2
3
4
5
6
7
8

bits := math.Float32bits(0.1) //依靠库
binary := fmt.Sprintf("%.32b", bits)


fmt.Printf("binary: %s \n",binary)
fmt.Printf("IEEE754:%s_%s_%s \n", binary[0:1], binary[1:9], binary[9:])//按协议输出

输出

1
2
binary: 00111101110011001100110011001101 
IEEE754:0_01111011_10011001100110011001101

IEEE754 转换为float32

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

func ieeeToFloat (bits uint32) {

bias := 127
binary := fmt.Sprintf("%.32b", bits)
sign := bits & (1<<31)

exponentRaw := int( bits >> 23 )

var exponent int

if sign != 0 {
exponent = exponentRaw - bias - 256 //减去高位符号位
} else {
exponent = exponentRaw - bias
}


var mantissa float64
for index, bit := range binary[9:32]{
if bit == 49 {
position := index + 1
bitValue := math.Pow(2, float64(position))
fractional := 1/bitValue
mantissa = mantissa + fractional
}
}

value := (1+mantissa) * math.Pow(2, float64(exponent))

fmt.Printf("exponent:%d\nmantissa:%.32f\nvalue:%.32f\nsign:%b\n",exponent,mantissa, value, sign)
}

输出

1
2
3
mantissa:0.60000002384185791015625000000000
value:0.10000000149011611938476562500000
sign:0

精度丢失

浮点数的精度丢失主要发生在两个地方

进制转换时的精度损失

我们可以看出在采取IEEE754存储浮点数时小数会出现无限循环的情况而我们定义的存储长度是有限的,这样就会产生进度误差,比如之前提到的

0.1 -> 0_01111011_10011001100110011001101 -> 0.10000000149011611938476562500000

0.2 -> 0_01111100_10011001100110011001101->0.20000000298023223876953125000000

0.10000000149011611938476562500000 + 0.20000000298023223876953125000000 = 0.300000004470348

运算过程中的精度丢失

0.5*0.75

0.5 = 0_01111110_00000000000000000000000
0.75 = 0_01111110_10000000000000000000000

尾数部分直接相乘 = 10000000000000000000000
指数相加-127 = 01111110 + 01111110 - 127 = 126+126-127 = 125
最终
0_01111101_10000000000000000000000

1
2
c,_ := strconv.ParseUint("00111110110000000000000000000000", 2, 64)
btod(ieeeToFloat(uint32(c)))

用上面的 ieeeToFloat 测试下可以得到 0.375

对于加减法需要先调整指数大小一致,再进行小数部分的相加。
列如 1.23x10^28 + 1.00x20^25
转换为 1.23x10^28 + 0.001x10^28
小数求和 1.231x10^28
如果浮点数只能精确表示 1.23 那么这个操作就会被舍弃丢失精度

参考

https://time.geekbang.org/column/article/439782

https://www.h-schmidt.net/FloatConverter/IEEE754.html

《GO语言底层原理剖析》

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!

请我喝杯咖啡吧~