浮点数在计算机中的表示

现实世界中不仅有整数,还有小数,有没有一种编码,能同时表示它们呢。IEEE 754 是 20 世纪 80 年代以来最广泛使用的浮点数运算标准,为许多 CPU 与浮点运算器所采用,简单来说,它采用了类似科学记数法的形式来表示整数和小数。该标准的设计者,William Morton Kahan 教授在 1989 年获得图灵奖。

产生背景

因为 定点数 存在两个缺陷

  • 不能有效的表示非常大的数字。
    • 例如,表达式 5×2100(101)2 后面跟着 100 个零的位模式来表示,如果用定点表示法,容易产生溢出。
  • 不能有效的表示小数。

为了解决上述问题,浮点数应运而生。

表示方法

科学记数法

参照科学记数法,IEEE 浮点标准采用如下形式来表示一个数(Value)

V=(1)s×M×2E

  • (1)s 表示符号位,当 s=0, V 为正数;当 s=1,V 为负数。
  • M 表示有效数字,范围属于 [1,2)。
  • 2E 表示指数位。

例 1
十进制的 5.0 写成二进制是 101.0,相当于 (1.01)×22。按照上面的格式,可以得出 s=0,M=1.01,E=2。

有效数字 M

因为 1M<2,也就是说,M 的整数部分总是为 1,所以我们可以只保留它的小数(fraction)部分,而将开头的 1 省略。这样一来,就能多表示一位有效数字。

  • 在单精度浮点格式中,s、exp、fraction 字段分别为 1、8、23 位。
  • 在双精度浮点格式中,s、exp、fraction 字段分别为 1、11、52 位。

指数 E

指数 E 的取值情况比较复杂,我们以 float 浮点类型为例。

  • 当 exp 既不全为 0,也不全为 1 时
    • E = exp - 127。;127;;=;271;M=1+fraction
  • 当 exp 全为 0 时
    • E = 1 - 127。;M=fraction,;;1。特别的,当 fraction 全为 0 时,表示 ±0 (取决于符号位)。
  • 当 exp 全为 1 时
    • 如果 fraction 全为 0,表示 ± (取决于符号位)。
    • 如果 fraction 不全为 0,表示 NaN (Not a Number)。

精度问题

十进制和二进制相互转化

先看看十进制数和二进制数如何相互转换。用下标表示数的基,d10 表示十进制数,d2 表示二进制数。

一个具有 n+1 位整数,m 位小数的十进制数 d10 表示为

例 2

(1234.5678)10=1×103+2×102+3×101+4×100+5×101+6×102+7×103+8×104

同理,一个具有 n+1 位整数 m 位小数的二进制数 b2 表示为

例 3

(1010.1001)2=1×23+0×22+1×21+0×20+1×21+0×22+0×23+1×24

将二进制数转换成十进制数,比较容易,如例 3 所示。而将十进制数转换成二进制数,需要把整数部分和小数部分分开转换,整数部分用 2 除,取余数;小数部分用 2 乘,取整数位。

例 3
(13.125)10 转换成二进制数。

整数部分: (13)10=(1101)2
(1);13÷2=6;;11
(2);6÷2=3;;001
(3);3÷2=1;;1101
(4);;2;1101

小数部分: (0.125)10=(001)2
(1);0.125×2=0.25;;0.0
(2);0.25×2=0.5;;0.00
(3);0.5×2=1.0;;1.001
(4);

所以 (13.125)10=(1101.001)2

一个十进制数能否用二进制浮点数精确表示,关键在于小数部分。 我们来看一个最简单的小数 (0.1)10 能否精确表示。
(1);0.1×2=0.2;;0.0
(2);0.2×2=0.4;;0.00
(3);0.4×2=0.8;;0.000
(4);0.8×2=1.6;;1.0001
(5);0.6×2=1.2;;1.00011
(6);0.2×2=0.4;;0.000110 (2)

我们得到一个无限循环的二进制小数 (0.1)10=(0.0;0011;0011;0011;)2

用有限位无法表示无限循环小数,因此,(0.1)10 无法用 IEEE 754 浮点数精确表示。同时我们还可以得出

(0.2)10=(0.0011;0011)2

(0.4)10=(0.0110;0110)2

(0.6)10=(0.1001;1001)2

(0.8)10=(0.1100;1100)2

这四个数也无法精确表示。同理

(0.3)10×2=0.6

(0.7)10×2=1.4

(0.9)10×2=1.8

这三个数也无法精确表示。 所以,从 0.1 到 0.9 这 9 个小数中,只有 0.5 可以精确表示

(0.5)10=(0.1)2

二进制小数能精确表示的十进制小数的基本规律

一个十进制小数要能用浮点数精确表示,最后一位必须是 5,因为 1 除以 2 永远是 0.5。当然,这是必要条件,并非充分条件。

一个 m 位二进制小数能够精确表示的小数有多少个呢?

当然是 2m 个。而 m 位十进制小数有 10m个,因此,能精确表示的十进制小数的比例是 2m10m=(0.2)m。m 越大,比例值越小。

其实不管是用十进制、二进制或者其它进制,都存在着有限位数无法精确表示的数字。这时候就需要进行舍入。

舍入

IEEE 标准列出 4 种不同的舍入方法

  • 最近舍入
    • 舍入到最接近,在一样接近的情况下偶数优先(Ties To Even,这是默认的舍入方式)。
  • + 方向舍入
  • 方向舍入
  • 朝 0 方向舍入

代码验证

show-byte.c

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include<stdio.h>
#include<string.h>

/*
C 语言中的 typedef 声明提供了一种给数据类型命名的方式。这能够极大地改善代码的可读性,因为深度嵌套的类型声明很难读懂。
typedef 的语法与声明变量的语法十分相像,除了它使用的是类型名,而不是变量名。

1 typedef int *int_pointer;
2 int_pointer ip;
3 int *ip;

2,3 行中的定义是等价的。
*/

typedef unsigned char * byte_pointer; //用 byte_pointer 声明和用 unsigned char * 声明效果是相同的。

void show_bytes(byte_pointer start, size_t len) {
size_t i;
for(i=0; i<len; i++) {
printf("%.2x", start[i]);
}
printf("\n");
}

int main() {
float f1=1.1;
printf("\nf1 = %0.1f\n", f1);
show_bytes((byte_pointer)&f1, sizeof(f1));
double d=1.1;
show_bytes((byte_pointer)&d, sizeof(d));

float f2=1.3;
printf("\nf2 = %0.1f\n", f2);
show_bytes((byte_pointer)&f2, sizeof(f2));
double d2=1.3;
show_bytes((byte_pointer)&d2, sizeof(d2));

float f3=1.7;
printf("\nf3 = %0.1f\n", f3);
show_bytes((byte_pointer)&f3, sizeof(f3));
double d3=1.7;
show_bytes((byte_pointer)&d3, sizeof(d3));

float f4=1.9;
printf("\nf4 = %0.1f\n", f4);
show_bytes((byte_pointer)&f4, sizeof(f4));
double d4=1.9;
show_bytes((byte_pointer)&d4, sizeof(d4));
}

little-big-endian

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

int main() {
int i = 0x11223344;
char *p;

p = (char *) &i;
if (*p == 0x44) {
printf("little endian\n");
}else if(*p == 0x11) {
printf("big endian\n");
}
return 0;
}

我的电脑是 小端序,最低位先输出。

(1.1)10

  • (3f8ccccd)16=(0011;1111;1000;1100;1100;1100;1100;1101)2

    • 符号位 S = 0
    • 指数 E = exp-127 = 127-127 = 0
    • 小数部分 = 000;¯1100
    • 有效数字 M = 1.000 1100 1100 1100 1100 1101
    • 舍入 111
    • Value=(1)0×(1.000;1100;;1101)×20=1.000;1100;1100;1100;1100;1101
  • (3ff99999999999a)16=(0011;1111;1111;0001;1001;1001;;1001;1010)2

    • 符号位 S = 0
    • 指数位 E = exp-1023 = 1023-1023 = 0
    • 小数部分 = 0001;¯1001
    • 有效数字 M = 1.0001 1001 1001 1001 … 1010
    • 舍入 101
    • Value=(1)0×(1.0001;1001;;1010)×20=1.0001;1001;1001;;1001;1010

(1.3)10

  • (3fa66666)16=(0011;1111;1010;0110;0110;0110;0110;0110)2

    • 小数部分 = 010;¯0110
    • 舍入 010
  • (3ff4cccccccccccd)16=(0011;1111;1111;0100;1100;1100;;1100;1101)2

    • 小数部分 = 0100;¯1100
    • 舍入 111

(1.7)10

  • (3fd9999a)16=(0011;1111;1101;1001;1001;1001;1001;1001)2

    • 小数部分 = 101;¯1001
    • 舍入 101
  • (3ffb333333333333)16=(0011;1111;1111;1011;0011;0011;;0011;0011)2

    • 小数部分 = (1011;¯0011)
    • 舍入 000

(1.9)10

  • (3ff33333)16=(0011;1111;1111;0011;0011;0011;0011;0011)2

    • 小数部分 = 111;¯0011
    • 舍入 000
  • (3ffe666666666666)16=(0011;1111;1111;1110;0110;0110;;0110;0110)2

    • 小数部分 = (1110;¯0110)
    • 舍入 010

拓展

Q1

为什么 exp 需要加上一个偏置值?

A1

采用这种方式表示的目的是简化比较。因为,指数的值可能为正也可能为负,如果采用补码表示的话,符号位 S 和 Exp 自身的符号位将导致不能简单的进行大小比较。正因为如此,指数部分通常采用一个无符号的正数值存储。

引用