计算机组成原理——数值的表示

数值的表示

先上代码

1
2
3
// JavaSctipt
// 0.30000000000000004
console.log(0.1+0.2)

如果你百度一下,就会知道几乎所有语言都会面临浮点数的精度问题,想要了解IEEE754,要先了解计算机内部的定点数表示法。

定点数

以八位机器数举例

原码

1
2
10  => 0000 1010  
-10 => 1000 1010

原码就是把数值直接用二进制表示出来,正负号用末位0,1来表示。
原码有诸多弊端,比如:数值的减法不能用加法来计算。在十进制中10-10和10+(-10)是等效的,但是在原码表示法的机器数运算中,10+(-10)却得到了1001 0100的结果,显然不等于0 。在计算机内部,处理运算是用与或非门来控制的,加减法分别用两套电路有些浪费资源,我们能否用加法电路来实现减法呢?
其次,0和-0在原码中竟然是不同的表示法,但是这对运算并没有帮助,反而浪费了一个表示位置。

补码

1
2
10  => 0000 1010  
-10 => 1111 0110

补码的正数和原码一样,但是负数采用原码的“取反加一”,也就是说,想要得到-10,首先把符号位确定为1,然后写出10的原码表示,将其各个位置取反加一,即得到了该原码的补码。补码的求相反数同理。

此时10+(-10)和10-10的操作统一了,同时也没有了+0和-0的区别。计算机内部运算一般采用补码来进行,感兴趣的可以了解一下定点数/浮点数的乘法/除法,仅用移位和加法运算即可完成。

补码的移位要注意一点,算术右移的时候正数补0负数补1 。

移码、反码此处不涉及,了解即可。

浮点数

为了扩大数的表示范围,可以采用“科学计数法”来表示大数或者小数。比如-2000 0000就可以表示成

1
-1 * 2 * 10 ^ 7

如果我们提前约定好了底数10,那么我们表示这个数只需要三个信息,负号、2、7 ,转换成2进制就是1 10 0111比我们单独存储-20000000要节省很多空间

由于2进制的限制,计算机内部这个约定好的底数是2。

IEEE754

目前计算机内部表示浮点数的一个标准就是IEEE754,以32位计算机举例,表示1100 0111为1 1000 0101 10001110000000000000000
乍一看可能觉得这种表示法更复杂了,其实无论在精度还是在表示数的范围,IEEE754都是很优秀的。
在IEEE754中,底数为2,用1位表示符号,8位表示阶码,也就是上文中的1000 0101,为了表示2的负数次方,阶码采用移码,都加上127,这样负数的阶码也可以表示出来了,23位原码表示尾数。
关于尾数这里要注意一点,由于2进制的有效数字第一位一定是1,所以尾数的前面省略了一个1不写,用23位的尾数表示了24位的值,在手动转换的时候要自己补上。

说了这么多还是没有直接解答浮点数的精度问题,但是看过了以上之后,你会更容易理解。
首先计算机没有办法精确地表示0.1和0.2等等,因为通过2次幂来控制移位,计算机只能精确表示出0.5、0.25、0.125、0.0625等等,这些数是无论如何也不能表示出0.1这样的数的,只能随着尾数长度的增加而无限接近。而在计算机存储这样的数的时候就会用到舍入策略:

舍入策略

IEEE列出了四种不同的舍入方法:

  • 舍入到最接近:舍入到最接近,在一样接近的情况下偶数优先(Ties To Even,这是默认的舍入方式):会将结果舍入为最接近且可以表示的值,但是当存在两个数一样接近的时候,则取其中的偶数(在二进制中式以0结尾的)。
  • 朝+∞方向舍入:会将结果朝正无限大的方向舍入。
  • 朝-∞方向舍入:会将结果朝负无限大的方向舍入。
  • 朝0方向舍入:会将结果朝0的方向舍入。

举个栗子
假如我们都采用朝+∞方向舍入,保留一位小数,那么0.41会入到0.5,0.41+0.41在计算机中就会计算成1.(仅仅是假设,实际计算机精度大得多)
而在我们let a = 0.3的时候会把0.3转换成2进制存储进来,再进行读取的时候,由于它和0.3的差比机器内部最大舍入误差小,所以再进行输出的时候就会正常输出0.3,在进行0.1+0.2的时候由于这种舍入误差的累积,导致了输出结果不等于0.3 。

不同语言的输出

1
2
3
4
5
6
7
8
9
// C
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
printf("%f",0.1+0.2);
return 0;
// 0.300000
}
1
2
3
4
5
6
7
// java
public class HelloWorld {
public static void main(String[] args) {
System.out.println(0.1+0.2);
// 0.30000000000000004
}
}
1
2
3
# python
print 0.1+0.2
# 0.3
1
2
3
4
5
// php
<?php
echo 0.1+0.2
// 0.3
?>
1
2
3
# ruby
puts 0.1+0.2
# 0.30000000000000004

不同语言由于在输出长度方面有区别,所以最后的4可能会被截断。

浮点数比较

在日常使用中一般不建议浮点数进行比较,一种比较的方法是:

1
2
abs(a - b) < 1e-8
// 自行设置精度

参考链接

IEEE 754_wikipedia
深入理解计算机系统(原书第3版)
计算机组成原理-唐朔飞
js中浮点数的表示及计算–IEEE 754
原码补码反码移位

朱耀华_20180817