一直想系统的总结一下计算机关于定点数的存储与表示,能清楚直白的阐述其中的原理。个人认为这是非常重要的内容,可以算是真正的内功心法,基础中的基础,能提升对技术的理解能力。
本文重点解决两个问题:
-
为什么计算机对于定点数要用补码表示
-
怎样理解反码等于原码保持符号位不变,数据位取反;补码等于反码加一
如果你能解释以上两个问题,请飘过!如果有点儿犹豫,不妨静下心来看看;如果0基础,毋庸置疑,赶紧往下撸呀!
前面会介绍一些铺垫性的概念和表述方式,都是为了对核心内容更好的表达。
位、字节、字
我们知道二进制数就只有0和1,而计算机对信息的存储就是使用二进制数字。通常,存储二值信号的单元叫做位(bit)。单个的位只能表示两种状态,没有太大的作用。但是,如果多个位组合起来,那就能表示非常丰富的含义。一般位的单位使用小写字母b表示,例如,10b表示10个bit
计算机中将8个位组成一组,称作字节(byte),作为最小的可寻址的存储单元。存储器的每个字节都由一个唯一的数字来标识,这个标识称为地址
。所有可能地址的集合称为虚拟地址空间。我们常说的32位机器,就是指该计算机的最小可寻址的存储单元为4字节,那么,虚拟地址空间就是[0,2^32-1],也就是4GB。一般字节的单位使用大写字母B表示,例如,10B表示10个字节。
早些年的计算机大多数是32位,如今很多计算机都是64位的。“32位”,"64位"还有一个名称叫做字长(word size),字长为4表示32位机器,字长为8表示64位机器。
字节
是8个位
的组合形成的一个单元;字
便是n个字节
的组合形成的一个单元,n的大小一般为4或8,32位机器的字长为4个字节,64位机器的字长为8字节。
位、字节、字之间的大小关系是:word > byte > bit。
由于字大小不同,程序的平台移植性需要考虑。
进制
10进制是我们在日常生活中使用了一千多年的数字表示方法,某种角度来说,10进制也是一种"编码"方式。以10为基数,逢10进1。
2进制和16进制是在计算机的世界里,常用的数字"编码"方式。
10进制 | 16进制 | 2进制 |
---|---|---|
0 | 0x00 | 0000 0000 |
1 | 0x01 | 0000 0001 |
2 | 0x02 | 0000 0010 |
3 | 0x03 | 0000 0011 |
4 | 0x04 | 0000 0100 |
5 | 0x05 | 0000 0101 |
6 | 0x06 | 0000 0110 |
7 | 0x07 | 0000 0111 |
8 | 0x08 | 0000 1000 |
9 | 0x09 | 0000 1001 |
10 | 0x0A | 0000 1010 |
11 | 0x0B | 0000 1011 |
12 | 0x0C | 0000 1100 |
13 | 0x0D | 0000 1101 |
14 | 0x0E | 0000 1110 |
15 | 0x0F | 0000 1111 |
从上表可以看出,数字0-15有三种不同的"编码"方式。这里之所以反复强调"编码"这个概念,是为了后面的内容做准备。要表示16个数字,若以10进制为度量,则为0-15;如果16进制为度量,则为0-F;若以2进制为度量,则为0000-1111;三种不同的表示形式,但表达的内容都是相同的。
大小端字节序
大小端问题其实可以看做快递存取问题。从哪儿开始存,怎么存?从哪儿开始取,怎么取?
假设有一个空的快递柜,每一层有4个柜子,按照常识,每个柜子都会有一个编号,假设第一层的4柜子的编号依次为0x100-0x103,现在,我们来玩一个游戏,将4种水果放进柜子里面,按照依次存取方式,保证4种水果的摆放顺序不变。每个水果也有编号,苹果(1号)、香蕉(2号)、草莓(3号)、葡萄(4号)
存之前4种水果的摆放顺序是:苹果(1) 香蕉(2) 草莓(3) 葡萄(4)
方法一(类似 大端)
存入:
0x100 0x101 0x102 0x103
苹果(1) 香蕉(2) 草莓(3) 葡萄(4)
取出(从编号小柜子先取):1 2 3 4
方法二(类似 小端)
存入:
0x100 0x101 0x102 0x103
葡萄(4) 草莓(3) 香蕉(2) 苹果(1)
取出(从编号大柜子先取):1 2 3 4
在计算机中,对于占用多字节的对象,同样也要考虑,这个对象的地址
在哪儿,该对象的内容按照什么顺序排列在多字节的space中。
前面举了一个实际生活中的栗子,下面再举一个计算机世界的例子:
对于16进制数0x01234567,该数占4个字节,那么以小端序该数在计算机中怎样存储?以大端序该数在计算机中怎样存储?
大端(big endian):(把数值的高位字节放在内存的低位地址上,把数值的低位字节放在内存的高位地址上)
地址 | 0x100 | 0x101 | 0x102 | 0x103 |
---|---|---|---|---|
数值 | 0x01 | 0x23 | 0x45 | 0x67 |
小端(little endian):(把数值的高位字节放在高位地址上,低位字节放在低位地址上)
地址 | 0x100 | 0x101 | 0x102 | 0x103 |
---|---|---|---|---|
数值 | 0x67 | 0x45 | 0x23 | 0x01 |
总之,小端序是数值高位对应地址高位,数值低位对应地址低位;而大端序则数值高位对应地址低位,数值低位对应地址高位。小端序在阅读时,通常是将数值字节按照(与我们书写)相反的顺序显示。
书写字节序列的自然方式是最低位字节在左边,而最高位字节在右边,这正好和通常书写数字时最高有效位在左边,最低有效位在右边的方式相反。
由于字节序的不同,要保证读取和写入的字节序相同,发送和接收的字节序相同。
因此,由于字节序和字大小平台差异性,所以二进制数据的可移植性较文本数据弱,需要两端统一。
位向量
位向量只是一种数学上的表示方式,也是为了后文内容做铺垫。所谓位向量就是固定位宽为w,由0和1组成的串。位向量的运算可以定义成向量的每个对应元素之间的运算。而对应元素之间的运算也就是位运算。
x ⃗ = [ x w − 1 , x w − 2 , . . . , x 2 , x 1 , x 0 ] \vec{x}=[x_{w-1}, x_{w-2}, ..., x_2, x_1, x_0] x=[xw−1,xw−2,...,x2,x1,x0]
假设w=4, 向量 a ⃗ \vec{a} a=[0110],向量 b ⃗ = [ 1100 ] \vec{b}=[1100] b=[1100],那么,a&b = [0100]。
移位运算
在c语言中,对于一个以位模式
表示的数x=
[
x
w
−
1
,
x
w
−
2
,
.
.
.
,
x
2
,
x
1
,
x
0
]
[x_{w-1}, x_{w-2},...,x_2,x_1,x_0]
[xw−1,xw−2,...,x2,x1,x0]
左移运算,x << k 即为 [ x w − k − 1 , x w − k − 2 , . . . , x 0 , 0 , . . . , 0 ] [x_{w-k-1}, x_{w-k-2}, ...,x_0, 0, ...,0] [xw−k−1,xw−k−2,...,x0,0,...,0],从左到右,丢弃高k位,低位补k个0;
右移运算,x >> k,涉及两个概念逻辑右移和算术右移。
逻辑右移就是高位补0,算术右移就是高位补符号位。
对于无符号数,x >> k 即为 [ 0 , . . . , 0 , x w − 1 , x w − 2 , . . . , x k ] [0, ..., 0, x_{w-1}, x_{w-2}, ..., x_k] [0,...,0,xw−1,xw−2,...,xk]
对于有符号数,x >> k 即为 [ x w − 1 , . . . , x w − 1 , x w − 1 , x w − 2 , . . . , x k ] [x_{w-1}, ..., x_{w-1}, x_{w-1}, x_{w-2}, ..., x_k] [xw−1,...,xw−1,xw−1,xw−2,...,xk]
在c标准中并未明确定义使用哪种右移运算。但是,几乎所有的编译器都会对有符号数使用算术右移。当然,有些语言(如,java)对逻辑移位和算术移位做了明确的定义和区分。
整数表示
在c语言中支持多种整型
数据类型,不同数据类型所表示的取值范围也各不相同,但是其背后的规律值得我们探索。
32位机器的c的整型数据类型的取值范围:
数据类型 | 最小值 | 最大值 |
---|---|---|
char | -128 | 127 |
unsigned char | 0 | 255 |
short [int] | -32768 | 32767 |
unsigned short [int] | 0 | 65535 |
int | -2,147,483,648 | 2,147,483,647 |
unsigned [int] | 0 | 4,294,967,295 |
long [int] | -2,147,483,648 | 2,147,483,647 |
unsigned long [int] | 0 | 4,294,967,295 |
long long [int] | -9,223,372,036,854,775,808 | 9,223,372,036,854,775,807 |
unsigned long long [int] | 0 | 18,446,744,073,709,551,615 |
long long(8字节)是在ISO C99标准引入的,所以在此之前的标准中都是占4个字节。
64位机器的c的整型数据类型的取值范围:
数据类型 | 最小值 | 最大值 |
---|---|---|
char | -128 | 127 |
unsigned char | 0 | 255 |
short [int] | -32768 | 32767 |
unsigned short [int] | 0 | 65535 |
int | -2,147,483,648 | 2,147,483,647 |
unsigned [int] | 0 | 4,294,967,295 |
long [int] | -9,223,372,036,854,775,808 | 9,223,372,036,854,775,807 |
unsigned long [int] | 0 | 18,446,744,073,709,551,615 |
long long [int] | -9,223,372,036,854,775,808 | 9,223,372,036,854,775,807 |
unsigned long long [int] | 0 | 18,446,744,073,709,551,615 |
在64位机器中,long数据类型占8字节。
观察上述两个表格的取值范围,发现最小值和最大值的绝对值不对称,负数的绝对值比整数的绝对值大1。这是一个有意思的现象,后文在讲解补码时会解释缘由。
无符号整数编码
前面讲过位向量的概念,这里就是用位向量来引出无符号数的数学表示公式。
假设一个无符号整数x,有w位,
位向量表示法为: x ⃗ = [ x w − 1 , x w − 2 , . . . , x 2 , x 1 , x 0 ] \vec{x}=[x_{w-1}, x_{w-2}, ..., x_2, x_1, x_0] x=[xw−1,xw−2,...,x2,x1,x0]。为了后文方便表述,会将这种表示方式称为位模式
函数表示法为: B 2 U w ( x ⃗ ) = ∑ i = 0 w − 1 x i 2 i B2U_w(\vec{x})=\sum_{i=0}^{w-1}x_i2^i B2Uw(x)=∑i=0w−1xi2i,该函数将一个长度为w的0、1串映射到非负整数。
B2U:Binary to Unsigned
B 2 U w ( [ 0001 ] ) = 0 3 × 2 3 + 0 2 × 2 2 + 0 1 × 2 1 + 1 0 × 2 0 = 0 + 0 + 0 + 1 = 1 B2U_w([0001])=0_3\times2^3+ 0_2\times2^2+0_1\times2^1+1_0\times2^0=0+0+0+1=1 B2Uw([0001])=03×23+02×22+01×21+10×20=0+0+0+1=1
B 2 U w ( [ 1001 ] ) = 1 3 × 2 3 + 0 2 × 2 2 + 0 1 × 2 1 + 1 0 × 2 0 = 8 + 0 + 0 + 1 = 9 B2U_w([1001])=1_3\times2^3+ 0_2\times2^2+0_1\times2^1+1_0\times2^0=8+0+0+1=9 B2Uw([1001])=13×23+02×22+01×21+10×20=8+0+0+1=9
B 2 U w ( [ 1111 ] ) = 1 3 × 2 3 + 1 2 × 2 2 + 1 1 × 2 1 + 1 0 × 2 0 = 8 + 4 + 2 + 1 = 15 B2U_w([1111])=1_3\times2^3+ 1_2\times2^2+1_1\times2^1+1_0\times2^0=8+4+2+1=15 B2Uw([1111])=13×23+12×22+11×21+10×20=8+4+2+1=15
那么, B 2 U w ( x ⃗ ) B2U_w(\vec{x}) B2Uw(x)所能表示的数值范围是[0, 2 w − 1 2^w-1 2w−1];
[0…0]表示最小值0,
[1…1]表示最大值 ∑ i = 0 w − 1 2 i = 2 w − 1 + 2 w − 2 + . . . + 2 3 + 2 2 + 2 1 + 2 0 = 2 w − 1 \sum_{i=0}^{w-1}2^i=2^{w-1}+2^{w-2}+...+2^3+2^2+2^1+2^0=2^w-1 ∑i=0w−12i=2w−1+2w−2+...+23+22+21+20=2w−1
显然,无符号数的二进制表示有个很重要的属性, B 2 U B2U B2U是一个双射;即对于每一个长度为w的位向量二进制表示都有一个唯一的值与之对应;反过来, [ 0 , 2 w − 1 ] [0,2^w-1] [0,2w−1]之间的每一个值都有一个唯一的长度为w的位向量二进制表示与之对应。
B2U只能表示非负数值,若要表示负数,还需要其他的编码方式。
补码编码
当代计算机中有符号数的计算机表示方式就是补码(Two’s complement)编码。
补码将字的最高有效位作为负权(negative weight), 假设一个有符号整数x,位宽为w,
位向量表示法为: x ⃗ = [ x w − 1 , x w − 2 , . . . , x 2 , x 1 , x 0 ] \vec{x}=[x_{w-1}, x_{w-2}, ..., x_2, x_1, x_0] x=[xw−1,xw−2,...,x2,x1,x0]。可见,补码编码的位模式和无符号整数编码的位模式相同。
函数表示法为: B 2 T w ( x ⃗ ) = − x w − 1 2 w − 1 + ∑ i = 0 w − 2 x i 2 i B2T_w(\vec x) = -x_{w-1}2^{w-1} + \sum_{i=0}^{w-2}x_i2^i B2Tw(x)=−xw−12w−1+∑i=0w−2xi2i
最高有效位 x w − 1 x_{w-1} xw−1表示符号位,它的权重为 − 2 w − 1 -2^{w-1} −2w−1;
当符号位为1时,表示负数,而负数的最大值是 B 2 T w ( x ⃗ ) = − 2 w − 1 + 2 w − 1 − 1 = − 1 B2T_w(\vec x)=-2^{w-1} + 2^{w-1}-1 = -1 B2Tw(x)=−2w−1+2w−1−1=−1,负数的最小值是 B 2 T w ( x ⃗ ) = − 2 w − 1 B2T_w(\vec x) = -2^{w-1} B2Tw(x)=−2w−1;
当符号位为0时,表示非负数,而非负数的最大值是 B 2 T w ( x ⃗ ) = 2 w − 1 − 1 B2T_w(\vec x) = 2^{w-1}-1 B2Tw(x)=2w−1−1,非负数的最小值是 B 2 T w ( x ⃗ ) = 0 B2T_w(\vec x)= 0 B2Tw(x)=0;
同样 B 2 T w B2T_w B2Tw也是一个双射,每一个长度为w的位模式都有一个唯一的值与之对应; [ − 2 w − 1 , 2 w − 1 − 1 ] [-2^{w-1},2^{w-1}-1] [−2w−1,2w−1−1]之间的每一个值也都有唯一的长度为w的位模式表示。
通过上述一般表达式的推导,我们可以从两个角度来解释前面引出的一个问题:“整数的最小值和最大值的绝对值不对称”。
在数学层面上,补码的最小值 T M i n = − 2 w − 1 TMin=-2^{w-1} TMin=−2w−1,补码的最大值为 T M a x = 2 w − 1 − 1 TMax=2^{w-1}-1 TMax=2w−1−1;也就是说补码的取值范围是 [ − 2 w − 1 , 2 w − 1 − 1 ] [-2^{w-1},2^{w-1}-1] [−2w−1,2w−1−1], ∣ T M i n ∣ = ∣ T M a x ∣ + 1 |TMin|=|TMax|+1 ∣TMin∣=∣TMax∣+1,不对称性显而易见;
在定性的层面上来分析,也能解释的通,因为有一半的位模式(符号位为1时)表示负数,另一半的位模式(符号位为0时)表示非负数,由于0是非负数(占了一个"坑"),所以能表示的整数就会比负数少一个。
反过来也证明了,整数在计算机中的表示是使用补码编码的方式。
如果你觉得上面的公式还不够直观,下面再展示一张表:
行文至此,又产生一个问题,为什么计算机表示整数要使用补码而不是使用其他编码方式,如,原码编码、反码编码呢?
原码编码
原码(Sign Magnitude)编码是 将最高有效位仅作为符号位(没有权重),其他位作为数据位
位向量表示法为: x ⃗ = [ x w − 1 , x w − 2 , . . . , x 2 , x 1 , x 0 ] \vec{x}=[x_{w-1}, x_{w-2}, ..., x_2, x_1, x_0] x=[xw−1,xw−2,...,x2,x1,x0]。
函数表示为: B 2 S w ( x ⃗ ) = ( − 1 ) x w − 1 ⋅ ( ∑ i = 0 i = w − 2 x i 2 i ) B2S_w(\vec x)=(-1)^{x_{w-1}}\cdot\big(\sum_{i=0}^{i=w-2}x_i2^i\big) B2Sw(x)=(−1)xw−1⋅(∑i=0i=w−2xi2i)
在数学层面上,原码的最小值是 S M i n = − ( ∑ i = 0 i = w − 2 x i 2 i ) = − ( 2 w − 1 − 1 ) SMin=-\big(\sum_{i=0}^{i=w-2}x_i2^i\big)=-(2^{w-1}-1) SMin=−(∑i=0i=w−2xi2i)=−(2w−1−1),原码的最大值是 S M a x = ∑ i = 0 i = w − 2 x i 2 i = 2 w − 1 − 1 SMax=\sum_{i=0}^{i=w-2}x_i2^i=2^{w-1}-1 SMax=∑i=0i=w−2xi2i=2w−1−1,看起来,原码的表示方式对人来说更友好更直观更对称,但是对于计算机来说,原码的编码方式浪费了一种"状态"的表示(如果你有数电基础应该能直接get到这句话的含义,若没有也没关系,下面会解释)。为方便起见,假设位宽w=4,
位模式[0000] 代表的是数值 +0;
位模式[1000] 代表的是数值 -0;
显然,原码编码中 数值0 占用了两种编码"状态",对于计算机来说,不仅浪费而且处理起来麻烦。
反码编码
反码(Ones’ Complement)编码是 相较于原码,除符号位外,其他数据位取反。
函数表示为: B 2 O w ( x ⃗ ) = − x w − 1 ( 2 w − 1 − 1 ) + ∑ i = 0 w − 2 x i 2 i B2O_w(\vec x)=-x_{w-1}(2^{w-1}-1)+\sum_{i=0}^{w-2}x_i2^i B2Ow(x)=−xw−1(2w−1−1)+∑i=0w−2xi2i
在数学层面上,反码的最小值 O M i n = − ( 2 w − 1 − 1 ) OMin=-(2^{w-1}-1) OMin=−(2w−1−1),反码的最大值是 O M a x = 2 w − 1 − 1 OMax=2^{w-1}-1 OMax=2w−1−1,取值范围和原码相同,也对称。但是,有个但是,同样假设位宽w=4
位模式[0000] 代表的是数值 +0;
位模式[1111] 代表的是数值 -0;
显然,补码编码中 数值0 也占用了两种编码"状态"。
总结
通常定点数(相对于浮点数)就是这三种表示方法:原码、反码、补码。
而我们常使用的口诀:反码等于原码保持符号位不变,数据位取反;补码等于反码加一(现在再来看这句结论是否豁然开朗呢!)。在原码与补码的心算转换过程中,常用反码作为过渡。
前面的内容,从数学层面解释了原码、反码、补码的原理。我相信,以后再看到关于整数的表示、原码、反码、补码相关的结论性描述,就不再迷惑了,彩!
另外,在c语言中,有时候当有符号数特别大或特别小,对于初学者处理不好可能对得不到意料之中的值;那是因为c中存在强制类型转换(显示/隐式),一般出问题是由于隐式强制类型转换导致,因为这种问题程序员不容易发现。针对这种问题,只需要记住一点即可,强制类型转换可能导致数值变化,但位模式不变。