信息的表示和处理
包括整数、浮点数的存储格式、计算中可能存在的问题等
信息存储
大多数计算机使用8位的块,或者字节(byte),作为最小的可寻址的内存单位,而不是访问内存中单独的位。机器级程序将内存视为一个非常大的字节数组,称为虚拟内存(virtual memory)。内存的每个字节都由一个唯一的数字来标识,称为它的地址( address),所有可能地址的集合就称为虚拟地址空间(virtualaddressspace)。顾名思义,这个虚拟地址空间只是一个展现给机器级程序的概念性映像。实际的实现是将动态随机访问存储器(DRAM)、闪存、磁盘存储器、特殊硬件和操作系统软件结合起来,为程序提供一个看上去统一的字节数组。
每个程序对象可以简单地视为一个字节块,而程序本身就是一个字节序列。
十六进制
略,都会
字数据大小
每台计算机都有一个字长(wordsize),指明指针数据的标称大小(nominalsize)。因为虚拟地址是以这样的一个字来编码的,所以字长决定的最重要的系统参数就是虚拟地址空间的最大大小。也就是说,对于一个字长为 w 位的机器而言,虚拟地址的范围为 0 → 2 w − 1 0 \to 2^w- 1 0→2w−1,程序最多访问 2 w 2^w 2w个字节。
最近这些年,出现了大规模的从32位字长机器到64位字长机器的迁移。
32 位字长限制虛拟地址空间为4千兆字节(写作4GB),也就是说,刚刚超过 4 × 1 0 9 4 \times 10^9 4×109 字节。扩展到64位字长使得虚拟地址空间为 16EB,大约是 1.84 × 1 0 19 1. 84 \times 10^{19} 1.84×1019字节。大多数64位机器也可以运行为32位机器编译的程序,这是一种向后兼容。
32 位编译
linux> gcc -m32 prog.c
64 位编译
linux> gcc -m64 prog.c
64位编译的指针的大小为64bit,32位编译的指针大小则为 32bit。
不同位程序下基本C数据类型的典型大小(单位是字节):
有符号 | 无符号 | 32位 | 64位 |
---|---|---|---|
[signed] char | unsigned char | 1 | 1 |
short | unsigned short | 2 | 2 |
int | unsigned int | 4 | 4 |
long | unsigned long | 4 | 8 |
int32_t | uint32_t | 4 | 4 |
int64_t | uint64_t | 8 | 8 |
char * | 4 | 8 | |
float | 4 | 4 | |
double | 8 | 8 |
寻址和字节顺序
排列表示一个对象的字节有两个通用的规则。
大端法(big endian):地址从低到高,字节从最高有效字节到最低有效字节;
小端法(little endian):还是地址从低到高,字节从最低有效字节到最高有效字节;
某些机器选择在内存中按照从最低有效字节到最高有效字节的顺序存储对象,而另一些机器则按照从最高有效字节到最低有效字节的顺序存储。前一种规则一最低有效字节在最前面的方式,称为小端法。后一种规则一最高有 效字节在最前面的方式,称为**大端法。
例如:假设变量的类型为 int,位于地址 0x100
处,它的十六进制值为 0x01234567
。地址范围0x100~0x103
的字节顺序依于机器的类型:
大端法:
… | 0x100 | 0x101 | 0x102 | 0x103 | … |
---|---|---|---|---|---|
… | 01 | 23 | 45 | 67 |
小端法:
… | 0x100 | 0x101 | 0x102 | 0x103 | … |
---|---|---|---|---|---|
… | 67 | 45 | 23 | 01 |
部分机器支持”双端法“,可配置大端或小段,但是操作系统却固定了大端还是小端。
什么时候需要注意字节序?
- 单机编写程序时,字节顺序对程序员不可见;但是网络通信时,大端小端必须遵守规定的协议。
- 反汇编时查看整数的字节顺序也很重要。
- 当编写规避正常的类型系统的程序时。在C语言中,可以通过使用强制类型转换(cast)或联合(union)来允许以一种数据类型引用一个对象,而这种数据类型与创建这个对象时定义的数据类型不同。大多数应用编程都强烈不推荐这种编码技巧,但是它们对系统级编程来说是非常有用,甚至是必需的。
例如,逐字节打印某个类型:
#incude<stdio.h>
typedef unsigned char *byte_pointer;
void showbytes(byte_pointer start, size_t len) {
size_t i;
for (i = 0; i < len; i++) {
printf(" %.2x",start[i]);
}
printf("\n");
}
void show_int(int x) {
showbytes((byte_pointer)&x,sizeof(int));
}
void show_float(float x) {
showbytes((byte_pointer)&x,sizeof(float));
}
void show_pointer(void *x) {
showbytes((byte_pointer)&x,sizeof(void *));
}
…
关于移位
C语言标准并没有明确定义对于有符号数应该使用哪种类型的右移算术右移或者辑移都可以。不幸地,这就意味任何假设一种或者另一种右移形式的代码都可能会遇到可移植性间题。然,实际上,几乎所有的编译器/机器组合都对有符号数使用算术右移,且许多程序员也都假设机器会使用这种右。另一方面,对于无符号数,右移必须是逻辑的。
另外,对于 w 位的整数来讲,当移位的数量 K 大于 w 时,C语言标准指出,应该实际移动 K mod w位,即 K 对 w 取余。但是这种行为对于 C 程序来说没有保证,所以应该保持位移量小于待移位值的位数。
表示字符串
文本数据比二进制数据具有更强的平台独立性,因为没有字节顺序的影响。
其他略,都会。
表示代码
同样的代码在不同的系统上编译的机器代码不同,因而二进制代码很少能够在不同机器和操作系统组合之间移植。
计算机系统的一个基本概念就是,从机器的角度来看,程序仅仅只是字节序列。
整数表示
假设有一个整数数据类型有 w 位。我们可以将位向量写成 x ⃗ \vec{x} x,表示整个向量,或者写成 [ x w − 1 . w w − 2 , . . . , x 0 ] [x_{w-1}.w_{w-2},...,x_0] [xw−1.ww−2,...,x0],表示向量中的每一位。把 x ⃗ \vec{x} x 看做一个二进制表示的数,就获得了 x ⃗ \vec{x} x 的无符号表示,在这个编码中每个位 x i x_i xi 取值0 / 1。
无符号数表示
这个很简单
用一个函数 B 2 U w B2U_w B2Uw (Binary to Unsigned 的缩写,长度为 w)来表示:
原理: 无符号数编码的定义
对向量
x
⃗
=
[
x
w
−
1
,
x
w
−
2
,
⋯
,
x
0
]
\vec{x}=\left[x_{w-1}, x_{w-2}, \cdots, x_{0}\right]
x=[xw−1,xw−2,⋯,x0] :
B
2
U
w
(
x
⃗
)
≐
∑
i
=
0
w
−
1
x
i
2
i
B 2 U_{w}(\vec{x}) \doteq \sum_{i=0}^{w-1} x_{i} 2^{i}
B2Uw(x)≐i=0∑w−1xi2i
有符号数表示(补码)
最常见的有符号数的计算机表示方式就是补码(two’s-complement)形式。在这个定义中,将字的最高有效位解释为负权(negative weight)。
我们用函数 B 2 T w B2T_w B2Tw(Binary to Two’s-complement的缩写,长度为w) 来表示:
原理:补码编码的定义(二进制补码到真实数值)
对向量
x
⃗
=
[
x
w
−
1
,
x
w
−
2
,
⋯
,
x
0
]
\vec{x}=\left[x_{w-1}, x_{w-2}, \cdots, x_{0}\right]
x=[xw−1,xw−2,⋯,x0] :
B
2
T
w
(
x
⃗
)
≐
−
x
w
−
1
2
w
−
1
+
∑
i
=
0
w
−
2
x
i
2
i
B2T_{w}(\vec{x}) \doteq-x_{w-1} 2^{w-1}+\sum_{i=0}^{w-2} x_{i} 2^{i}
B2Tw(x)≐−xw−12w−1+i=0∑w−2xi2i
第一位称符号位,为 1 表示值为负,为0则是非负。
可知:正数的补码仍是正数,负数的补码就是 正数 - 高位为 1 的正数(也有书上说是正数全部位取反 + 1)。
(这只是理解,记住补码中最高位是符号位)
例如:
$B2T_{w}([0001]) $ = 0 + 0 + 0 + 1 = 1
$B2T_{w}([0101]) $ = 0 + 4 + 0 + 1 = 5
$B2T_{w}([1011]) $ = -8 + 0 + 2 + 1 = -5
$B2T_{w}([1111]) $ = -8 + 4 + 2 + 1 = -1
另外需要注意的是:
- 补码的正负范围不对称!比如 signed char 的取值范围是 [-256,255]!
- 最大的无符号数值刚好比补码的最大值的两倍大一(显而易见)。
C语言标准并没有要求要用补码形式来表示有符号整数,但是几乎所有的机器都是这么做的。
C库中的
<limits.h>
定义了一组常量表示不同整型数据类型的取值范围,如INT_MAX
、UINT_MAX
等。
关于更多整数的事情
大数据类型类的一部分。ISOC99 标准在文件 stdint.h
中引入了这个整数类型类。这个文件定义了一组数据类型,它们的声明形如 intN_t
和 uintN_t
,对不同的N值指定N位有待号和无符号整数。N的具体值与实现相关,但是多数编译器许的值为8、16、32和64。因此,通过将它的类型声明为uint16_t
,我们可以无义地声明一个16位无号变量,而如果声明为int32_t
,就是个32位有号变量。
这数据类型对应着一组宏,定义了每个的值对应的最小和最大值。这宏名字形如INT_MIN
、INT_MAX
和 UINT_MAX
。
确定宽度类型的带格式打印需要使用宏,以与系统相关的方式扩展为格式串。因此,举例来,变量 x 和 y 的类型是 int32_t 和 uint64_t ,可以通过调用 printf 来打它们的值,如下所示:
printf("x = %" PRId32 ", y = %" PRIu64 "\n",x,y);
编译为64位程库时,宏 PRId32 展开成字串"d"
,宏 PRIu64 展开两个字符串"l"、"u"
。当C预处理器到仅用空格(或其他空白字特)分隔的一个字符串常量列时,把它们串联起来。因此,上面的printf调用就变成了:
printf("x = %d, y = %lu\n",x,y);
使用宏能保证:不论代码是如何被编译的,都能生成正确的格式符串。
其他有符号数表示
有符号数还有两种标准的表示方法:
反码 (Ones’ Complement):除了最高有效位的权是 $- \left(2^{w-1}-1\right) $ 而不是
−
2
w
−
1
-2^{w-1}
−2w−1 , 它和补码是一样的:
B
2
O
w
(
x
⃗
)
≐
−
x
w
−
1
(
2
w
−
1
−
1
)
+
∑
i
=
0
w
−
2
x
i
2
i
B 2 O_{w}(\vec{x}) \doteq-x_{w-1}\left(2^{w-1}-1\right)+\sum_{i=0}^{w-2} x_{i} 2^{i}
B2Ow(x)≐−xw−1(2w−1−1)+i=0∑w−2xi2i
就是 正数全部取反?
原码(Sign-Magnitude):最高有效位是符号位, 用来确定剩下的位应该取负权还是正权:
B
2
S
w
(
x
⃗
)
≐
(
−
1
)
x
w
−
1
⋅
(
∑
i
=
0
w
−
2
x
i
2
i
)
B 2 S_{w}(\vec{x}) \doteq(-1)^{x_{w-1}} \cdot\left(\sum_{i=0}^{w-2} x_{i} 2^{i}\right)
B2Sw(x)≐(−1)xw−1⋅(i=0∑w−2xi2i)
这两种表示方法都有一个奇怪的属性,那就是对于数字 0 有两种不同的编码方式。 这两种表示方法,把
[
00
⋯
0
]
[00 \cdots 0]
[00⋯0] 都解释为 +0 。而值 -0 在原码中表示为
[
10
⋯
0
]
[10 \cdots 0]
[10⋯0],在反码 中表示为 $ [11 \cdots 1] $ 。虽然过去生产过基于反码表示的机器,但是几乎所有的现代机器都使用补码。我们将看到在浮点数中有使用原码编码。
无符号数和有符号数之间的转换
C 语言的强制类型转换只是改变了对字节序列的解释方式。例如,因为有符号数和无符号数的编码不同直接转换就会产生不同的数值。
意思就是这个意思,详情请见 《深入理解计算机系统》2.2.4
C 语言中写出一个字面值数字时,默认是有符号的。
还需注意的是:C语言中假如运算符(包括比较运算符)左右两侧分别是有符号和无符号数时,有符号数会隐式的转换为无符号数。
扩展一个数字的位表示
要将一个无符号数转换为一个更大的数据类型,我们只要简单地在表示的开头添加0。这种运算被称为零扩展(zero extension)。
要将一个补码数字转换为一个更大的数据类型,可以执行一个符号扩展(sign extension),在表示的左边复制符号位。
截断数字
当将一个 w 位的数 x ⃗ = [ x w − 1 , x w − 2 , ⋯ , x 0 ] \vec{x}=\left[x_{w-1}, x_{w-2}, \cdots, x_{0}\right] x=[xw−1,xw−2,⋯,x0] 截断为一个 k 位数字时,我们会丢弃高 w-k 位, 得到一个位向量 x ⃗ ′ = [ x k − 1 , x k − 2 , ⋯ , x 0 ] \vec{x}^{\prime}=\left[x_{k-1}, x_{k-2}, \cdots, x_{0}\right] x′=[xk−1,xk−2,⋯,x0] 。截断一个数字可能会改变它的值一一溢出的一种形式。
- 对于无符号整数,截断保留 k 位,就是原数字对 2 k 2^k 2k 取模。
- 对于有符号数,截断保留 k 位,就是将 k 位的二进制码重新解释为 k 位的补码。
总之,位是不变的,只是需要重新解释。
有符号与无符号数的建议
因为程序中可能发生的隐式类型转换导致的各种问题:
-
问题一:主要是无符号数与有符/无符号的运算结果仍是无符号数,这种结果不可能产生负数。
-
问题二:无符号数 减一作为
for
循环的范围时,无符号数为 0 时减一是一个很大的正数。 -
问题三:将一个有符号参数输入到一个无符号形参中。这个无符号实参可能是一个负数,从而导致一个很大的正数。
以上三个问题仅是举例。
我们已经看到了许多无符号运算的细微特性,尤其是有符号数到无符号数的隐式转换,会导致错误或者漏洞的方式。避免这类错误的一种方法就是绝不使用无符号数。
逻辑右移就是不考虑符号位,右移一位,左边补零即可;算术右移需要考虑符号位,右移一位,若符号位为1,就在左边补1;否则,就补0。所以算术右移也可以进行有符号位的除法,
当我们想要把字仅仅看做是位的集合而没有任何数字意义时,无符号数值是非常有用的:
例如:
- 往一个字中放入描述各种布尔条件的标记(flag)时,就是这样。
- 地址自然地就是无符号的,所以系统程序员发现无符号类型是很有帮助的。
- 当实现模运算和多精度运算的数学包时,数字是由字的数组来表示的,无符号值也会非常有用。
整数运算
注意有个 阿贝尔群 的概念,在 62 页 我没看…感觉太专业了0.0
无符号加法
很简单,没啥好说。
判断无符号数加法溢出:设 x + y = s, s < x | s < y 时溢出。(证明,相当于减去了一个 INT_MAX
,这对 x 还是 对 y 来说都是减去了一个负数。)
补码加法
给定在范围 − 2 w − 1 ⩽ x , y ⩽ 2 w − 1 − 1 -2^{w-1} \leqslant x, y \leqslant 2^{w-1}-1 −2w−1⩽x,y⩽2w−1−1 之内的整数值 x 和 y , 它们的和就在范围 − 2 w ⩽ x + y ⩽ 2 w − 2 -2^{w} \leqslant x+ y \leqslant 2^{w}-2 −2w⩽x+y⩽2w−2 之内,要想准确表示, 可能需要 w+1 位。就像以前一样,我们通过将表示截断 到 w 位,来避免数据大小的不断扩张。然而,结果却不像模数加法那样在数学上感觉很熟悉。定义 x + w t y x+{ }_{w}^{\mathrm{t}} y x+wty 为整数和 x+y 被截断为 w 位的结果,并将这个结果看做是补码数。
原理: 补码加法
对满足
−
2
w
−
1
⩽
x
,
y
⩽
2
w
−
1
−
1
-2^{w-1} \leqslant x, y \leqslant 2^{w-1}-1
−2w−1⩽x,y⩽2w−1−1 的整数 x 和 y , 有:
x
+
w
′
y
=
{
x
+
y
−
2
w
,
2
w
−
1
⩽
x
+
y
正溢出
x
+
y
,
−
2
w
−
1
⩽
x
+
y
<
2
w
−
1
正常
x
+
y
+
2
w
,
x
+
y
<
−
2
w
−
1
负溢出
x+_{w}^{\prime} y=\left\{\begin{array}{lll} x+y-2^{w}, & 2^{w-1} \leqslant x+y & \text { 正溢出 } \\ x+y, & -2^{w-1} \leqslant x+y<2^{w-1} & \text { 正常 } \\ x+y+2^{w}, & x+y<-2^{w-1} & \text { 负溢出 } \end{array}\right.
x+w′y=⎩
⎨
⎧x+y−2w,x+y,x+y+2w,2w−1⩽x+y−2w−1⩽x+y<2w−1x+y<−2w−1 正溢出 正常 负溢出
两个数的w位补码之和与无符号之和有完全相同的位级表示。实际上,大多数计算机使用同样的机器指令来执行无符号或者有符号加法。(都是按位相加然后进位就是了)
补码加法中的溢出判断:
对满足 TMin w ⩽ x , y ⩽ T M a x w \operatorname{TMin}_{w} \leqslant x, y \leqslant T M a x_{w} TMinw⩽x,y⩽TMaxw 的 x 和 y , 令 s ≐ x + w t y s \doteq x+{ }_{w}^{\mathrm{t}} y s≐x+wty 。当且仅当 $ x>0, y>0$ , 但 $ s \leqslant 0$ 时, 计算 s 发生了正溢出。当且仅当 x < 0 , y < 0 x<0, y<0 x<0,y<0 , 但 s ⩾ 0 s \geqslant 0 s⩾0 时, 计算 s 发生了负溢出。x,y 一正一负不可能溢出。
补码的非
就是 0 - x;
原理: 补码的非(加法的逆)
对满足 $ T M in_{w} \leqslant x \leqslant T M a x_{w}$ 的 x , 其补码的非 $ -{ }_{w}^{t} x$ 由下式给出
−
w
t
x
=
{
TMin
w
,
x
=
TMin
w
−
x
,
x
>
TMin
w
-{ }_{w}^{\mathrm{t}} x=\left\{\begin{array}{ll} \operatorname{TMin}_{w}, & x=\operatorname{TMin}_{w} \\ -x, & x>\operatorname{TMin}_{w} \end{array}\right.
−wtx={TMinw,−x,x=TMinwx>TMinw
也就是说, 对 w 位的补码加法来说,
T
M
M
w
T M M_{w}
TMMw 是自己的加法的逆, 而对其他任何数值 x 都有 -x 作为其加法的逆。
这也可以用来思考为什么负数补码是正数按位取反+1了。
补码求逆(补码非)的位级运算就是取反+1。正数到负数和负数到正数都是取反+1。也就是说在C语言中
-x == (~x+1)
。还有一种方法是:找到x的位级表示 x ⃗ = [ x w − 1 , x w − 2 , ⋯ , x 0 ] \vec{x}=\left[x_{w-1}, x_{w-2}, \cdots, x_{0}\right] x=[xw−1,xw−2,⋯,x0] 中最右边的 1 的位置 k,也就是x的位级表示为 x ⃗ = [ x w − 1 , x w − 2 , ⋯ , x k + 1 , 1 , 0 ,, . . . , 0 ] \vec{x}=\left[x_{w-1}, x_{w-2}, \cdots, x_{k+1},1,0,,...,0\right] x=[xw−1,xw−2,⋯,xk+1,1,0,,...,0] ,那么这个数的非就是 [ x w − 1 , x w − 2 , ⋯ , x k + 1 , 1 , 0 ,, . . . , 0 ] \left[~x_{w-1}, ~x_{w-2}, \cdots, ~x_{k+1},1,0,,...,0\right] [ xw−1, xw−2,⋯, xk+1,1,0,,...,0]
无符号乘法
w 位无符号数 * w 位无符号数 得到的结果截断到 w 位(取模 2 w 2^w 2w)。
补码乘法
也是截断,和无符号数乘法的位级运算一致。
就是先计算结果,在截断到 w 位(取模 2 w 2^w 2w)。
书中提出原理:无符号与补码的乘法运算的位级表示都是一样的。
乘法溢出是另一个容易被忽略,可能引发安全漏洞的原因。我们计算 malloc 申请内存的大小时需要考虑溢出问题导致申请的内存大小不够,而导致后面访问内存越界。
之前我们还讲过关于无符号数与有符号数的问题。
乘以常数
因为正常来说,正数乘法指令很慢(以往的机器上需要10个时钟周期,Intell Core i7 Haswell 上需要 3 个时钟周期),所以编译器会尝试用移位和加法运算的组合代替乘以常数的乘法。
乘以 2 的幂:就是左移,右边补充 0。
但是仍要注意导致的溢出问题。
所以就算乘以其他常数,也是分解成乘以多次 2 的幂 ,在加减乘数的做法。
例如: x × 14 = x × ( 2 3 + 2 2 + 2 1 ) x \times 14 = x \times (2^3+2^2+2^1) x×14=x×(23+22+21)
但是仍要注意这种优化是否真的比 乘法运算 快!
除以 2 的幂
在大多数机器上,整数除法要比整数乘法更慢——需要30个或者更多的时钟周期。
就是右移,有符号数算术右移,无符号数逻辑右移。
值得注意的是:整数除法总是舍入到零,即结果是正数就向下取整,负数向上取整。
我们不能用除以 2 的幂的除法来表示除以任意常数 K 的除法。(没有除法分配律)
关于正数运算的最后思考
正如我们看到的,计算机执行的“整数”运算实际上是一种模运算形式。表示数字的有限字长限制了可能的值的取值范围,结果运算可能溢出。我们还看到,补码表示提供了一种既能表示负数也能表示正数的灵活方法,同时使用了与执行无符号算术相同的位级实现,这些运算包括像加法、减法、乘法,甚至除法,无论运算数是以无符号形式还是以补码形式表示的,都有完全一样或者非常类似的位级行为。
浮点数
浮点表示对形如 V = x × 2 y V=x \times 2^y V=x×2y 的有理数进行编码。它对执行涉及非常大的数字($|V| >>0 $ )、非常接近于0( ∣ V ∣ < < 1 |V| << 1 ∣V∣<<1)的数字,以及更普遍地作为实数运算的近似值的计算,是很有用的。
IEEE 浮点标准(754)用 V = ( 一 1 ) s × M × 2 E V=(一1)^s×M×2^E V=(一1)s×M×2E 的形式来表示一个数:
- 符号(sign) s 决定这数是负数(s=1)还是正数(s=0),而对于数值0的符号位解释作为特殊情况处理。
- 尾数(significand) M 是一个二进制小数,它的范围是 $1 \sim 2-\epsilon $,或者是 $0 \sim 1-\epsilon $,
- 阶码(exponent) E 的作用是对浮点数加权,这个权重是2的E次幂(可能是负数)。
将浮点数的位表示划分为三个字段,分别对这些值进行编码:
- 一个单独的符号位 s 直接编码符号s 。
- k 位的阶码字段 e x p = e k − 1 ⋅ ⋅ ⋅ e 1 e 0 exp = e_{k-1}···e_1 e_0 exp=ek−1⋅⋅⋅e1e0 编码阶码 E。
- n 位小数字段 f r a c = f n − 1 ⋅ ⋅ ⋅ f 1 f 0 frac = f_{n-1}···f_1 f_0 frac=fn−1⋅⋅⋅f1f0 。编码尾数M,但是编码出来的值也依赖于阶码字段的值是否等于 0。
在单精度浮点格式 (C语言中的float) 中,s、exp 和 frac 字段分别为1位、k=8 位和 n=23位,得到一个32位的表示。
在双精度浮点格式(C语言中的double)中,s、exp 和 frac字段分别为1位、k=11 位和 n=52位,得到一个64位的表示。
图2-32 给出了将这三个字段装进字中两种最常见的格式。
给定位表示,根据exp的值,被编码的值可以分成三种不同的情况(最后一种情况有两个变种)。图2-33说明了对单精度格式的情况。
情况1:规格化的值
这是最普遍的情况。当 exp 的位模式既不全为0(数值0),也不全为1(单精度数值为255,双精度数值为2047)时,都属于这类情况。在这种情况中,阶码字段被解释为以**偏置(biased)**形式表示的有符号整数。也就是说,阶码的值是 E = e − B i a s E=e - Bias E=e−Bias,其中 e 是无符号数,其位表示为 e x p = e k − 1 ⋅ ⋅ ⋅ e 1 e 0 exp = e_{k-1}···e_1 e_0 exp=ek−1⋅⋅⋅e1e0 ,而Bias是一个等于 2 k − 1 一 1 2^{k-1}一1 2k−1一1(单精度是127,双精度是1023)的偏置值。由此产生指数的取值范围,对于单精度是 − 126 ∼ + 127 -126 \sim +127 −126∼+127,而对于双精度是 − 1022 ∼ + 1023 -1022 \sim +1023 −1022∼+1023。
小数字段 frac 被解释为描述小数值 f,其中 0 ≤ f < 1 0≤f<1 0≤f<1 ,其二进制表示为 0. f n − 1 ⋅ ⋅ ⋅ f 1 f 0 0.f_{n-1}···f_1 f_0 0.fn−1⋅⋅⋅f1f0 。也就是二进制小数点在最高有效位的左边。尾数定义为 M = 1 + f M=1+f M=1+f。有时,这种方式也叫做隐含的以1开头的(implied leading 1)表示,因为我们可以把M看成一个二进制表达式为 1. f r a c = f n − 1 ⋅ ⋅ ⋅ f 1 f 0 1.frac = f_{n-1}···f_1 f_0 1.frac=fn−1⋅⋅⋅f1f0 的数字。既然我们总是能够调整阶码 E,使得尾数M在范围 1 ≤ M < 2 1≤M<2 1≤M<2 之中(假设没有溢出),那么这种表示方法是一种轻松获得一个额外精度位的技巧。既然第一位总是等于1,那么我们就不需要显式地表示它。
情况2:非规格化的值
当阶码域为全0时,所表示的数是非规格化形式。在这种情况下,阶码值是 E = 1 − B i a s E=1 - Bias E=1−Bias,而尾数的值是 M = f M=f M=f ,也就是小数字段的值,不包含隐含的开头的 1。
对于非规格化值为什么要这样设置偏置值
使阶码值为 1-Bias 而不是简单的 -Bias 似乎是违反直觉的。我们将很快看到,这种方式提供了一种从非规格化值平滑转换到规格化值的方法。
非规格化数有两个用途。首先,它们提供了一种表示数值0的方法,因为使用规格化数,我们必须总是使 M ≥ 1 M \geq 1 M≥1,因此我们就不能表示 0。
实际上, + 0.0 +0.0 +0.0 的浮点表示的位模式为全0:符号位是0,阶码字段全为0(表明是一个非规格化值),而小数域也全为0,这就得到 M = f = 0 M=f=0 M=f=0 。
令人奇怪的是,当符号位为1,而其他域全为0时,我们得到值 − 0.0 -0.0 −0.0。
根据IEEE的浮点格式,值 + 0.0 +0.0 +0.0和 − 0.0 -0.0 −0.0在某些方面被认为是不同的,而在其他方面是相同的(废话~)。
非规格化数的另外一个功能是表示那些非常接近于0.0的数。它们提供了一种属性,称为逐渐溢出(gradual underflow),其中,可能的数值分布均匀地接近于0.0。
情况3:特殊值
最后一类数值是当指阶码全为1的时候出现的。当小数域全为0时,得到的值表示无穷,当 s=0 时是 + ∞ + \infty +∞,或者当 s=1 时是 − ∞ - \infty −∞。当我们把两个非常大的数相乘,或者除以零时,无穷能够表示溢出的结果。当小数域为非零时,结果值被称为“NaN”,即“不是一个数(Not a Number)”的缩写。一些运算的结果不能是实数或无穷,就会返回这样的NaN值,比如当计算 − 1 \sqrt{-1} −1 或 ∞ − ∞ \infty - \infty ∞−∞ 时 。在某些应用中,表示未初始化的数据时,它们也很有用处。
这种表示具有一个有趣的属性,将浮点表示值的位表达式解释为无符号数,它们就是按升序排列的,就像它们表的浮点数一样。IEEE格式如此设计就是为了浮点数能够使用整数排序函数来进行排序。有一个小的难点,当处理负数时,因为它们有开头的1,并且它们是按照降序出现的,但是不需要浮点运算来进行比较也能解决这个间题(参见深入理解计算机系统-家庭作业2.84)。
练习把一些整数值转换成浮点形式对理解浮点表示很有用。例如,12345 具有二进制表示[1100 0000 1110 01]。通过将二进制小数点左移13位,我们创建这个数的一个规格化表示,得到 12345 = 1.10000001110 0 2 × 2 13 12345 = 1.1000 0001 1100 _2 \times 2^{13} 12345=1.1000000111002×213。为了用 IEEE单精度形式来编码,我们丢奔开头的1,并且在末尾增加10个0,来构造小数字段,得到二进制表示[1000 0001 1100 1000 0000]。为了构造阶码字段,我们用 13 加上偏置量127,得到140,其二制表示为[10001100]。加上符号位0,我们就得到二进制的浮点表示 [0100 0110 0100 0000 1110 0100 0000 0000]。
舍入
因为表示方法限制了浮点数的范围和精度,所以浮点运算只能近似地表示实数运算。
因此,对于值 x,我们一般想用一种系统的方法,能够到“最接近的”匹配值,它可以用期望的浮点形式表示出来。这就是舍入(rounding)运算的任务。一个关键间题是在两个可能值的中间确定舍入向。
一种可选择的方法是维持实际数字的下界和上界。例如,我们可确定可表示的值 x − x^- x− 和 x + x^+ x+ ,使得 x 的值位于它们之间: x − ≤ x ≤ x + x^- \leq x \leq x^+ x−≤x≤x+。IEEE浮点格式定义了四种不同的舍入方式。默认的方法是找到最接近的配,而其他三种可用于计算上界和下界。
**向偶数舍入(round-to-even),也被称为向最接近的值舍入(round-to-nearest),是默认的方式,试图找到一个最接近的匹配值。**向偶数舍入方式用的方法是:它将数向上或者下舍入,使得结果的最低有效数字是偶数。因此,这种方法将1.5美元和2.5美元都舍入成2美元。注意这只是针对中间值的决策,对于 1.4 则舍入到 1、1.6则舍入到2。
其他三种方式产生实际值的确界(guaranted bound)。这方法在一数字应用中是很有用的。向零舍入把正数下舍入,把负数上舍入;向下舍入方式把正数和负数都下舍入。上舍入武把正数和负数都上舍入。
为什么要向偶数舍入呢?这可以保证一组数据舍入之后的统计偏差保持不变。(对于中间值,向上舍入和向下舍入的概率相同。)
相似地,向偶数舍入法能够运用在二进制小数上。我们将最低有效位的值 0 认为是偶数,值 1 认为是奇数。
浮点数计算
IEEE标准指定了一个简单的规则,来确定诸如加法和乘法这样的算术运算的结果。
在实际中,浮点单元的设计者使用一些聪明的小技巧来避免执行这种确的计算,因为计算只要精确到能够保证得到一个正确的舍人结果就可了。当参数中有-个是特殊值(如-0
、-∞
或NaN
)时,IEEE标准定义了些使之更合理的规则。例如,定义1/-0
将产生
−
∞
- \infty
−∞,而定义1/+0
会产生
+
∞
+ \infty
+∞。
IEEE标准中指定点运算行为方法的一个优势在于,它可以独立于任何具体的硬件或者软件实现。因此,我们可以检查它的象数学属性,而不必考虑它实际上是如何实现的。
阿贝尔群(Abelian Group),又称交换群或加群,是这样一类群:
它由自身的集合 G 和二元运算 * 构成。它除了满足一般的群公理,即运算的结合律、G 有单位元、所有 G 的元素都有逆元之外,还满足交换律公理。因为阿贝尔群的群运算满足交换律和结合律,群元素乘积的值与乘法运算时的次序无关。
前面的整数(无符号和补码)加法形成了阿贝尔群。实数上的加法也形成了阿贝尔群,但是我们必须考虑舍入对这些属性的影响。我们将 x+y
的浮点数计算定义为 Round(x+y)
- 虽然 x 和 y 都是实数,但是可能溢出而得到无穷值
- 这个运算可交换
- 不可结合,例如:
(3.14+1e10)-1e10
得到 0.0 因为舍入,值3.14会丢失。另一方面,3.14+(1e10-1e10)
得到值 3.14 - 大多数浮点加法都有逆元,即
x+-x=0
。无穷是例外: + ∞ − ∞ = N a N +\infty - \infty = NaN +∞−∞=NaN ;NaN
是例外:对于任何 x 都有NaN + x = NaN
。 - 浮点加法满足单调性属性:如果 a ≥ b a \geq b a≥b ,那么对于任何 a、b 以及 x 的值,除了 NaN,都有 x + a ≥ x + b x+a \geq x + b x+a≥x+b。无符号或补码加法布局有这个实数加法的属性。
浮点加法不具有结合性,这是缺少的最重要的群属性。对于科学计算程序员和编译器编写者来说,这具有重要的含义。即使为了在三维空间中确定两条线是否交叉而写代码这样看上去很简单的任务,也可能成为个很大的挑战。
C 语言:当程序文件中出现下列子时,GNU编译器GCC会定义程序常数 INFINITY(表示 + ∞ +\infty +∞)和NAN(表示NaN):
#define _GNU_SOURCE 1
#include <math.h>