众所周知,计算机采用二进制,因此一切计算机中的信息本质上都是01串,只是它们的编码与解码协议不同。同样的01串,经过不同的解码协议其含义是不同的,比如说LSP可以表示Language Server Protocol,也可以被解释成Old Sex Pi(大雾 ∙ \ ^\bullet ∙ v ∙ \ ^\bullet ∙ )
那么为什么计算机采用二进制(binary),而不是我们熟悉的十进制(decimal)或者十六进制(hexadecimal)呢?因为二进制在信号的表达上有天然的优势,比如说我让低电势表示0高点势表示1,这里的低和高又有一个容错。因为现实世界是肯定有噪声的,所以高电势和低电势分别表示为一个范围。而如果要应用10进制的话那么得切分很多个阈值,而且容错性还不强,故二进制是最优选。
上面都是扯谈,下面开始正文。
计算机中的最小信息存储单位为bit,而最小寻址单位为byte。也即每一个指针指向一个byte。而指针的大小则由计算机的字长(word size)决定。字长决定的最重要的系统参数就是虚拟地址空间的最大大小,一个字长为 w w w的机器的寻址空间为0~ 2 w − 1 2^w-1 2w−1,故32位机器的最大寻址空间为 2 32 B y t e = 4 G 2^{32}\mathrm{Byte}=4\mathrm{G} 232Byte=4G,这显然跟不上现代人民对计算机性能的要求,因此现在大多数计算机都是64位的。
确定了地址的大小,也要确定其编码与解码协议。地址实际上也就是由几个字节拼起来的,所以接下来我们介绍字节内部的顺序。字节顺序分为两种,小端法(little endian)和大端法(big endian),这两个起源于《格列佛游记》的说法,被人们广泛且颇具偏好的接受。一方面大多数Intel兼容机都只采用小端法,另一方面IBM和Oracle的大多数机器则是按打断模式操作。
小端法指最低有效字节排在最前面,大端法指最高有效字节排在前面。举个栗子, 54 1 ( 10 ) 541_{(10)} 541(10),根据大端法我们读作“五百四十一”,根据小端法我们读作“一百四十五”。我本人更倾向大端法,因此本文后续非特别声明一般默认大端法。
计算机中的数据类型有许多,下面介绍一些比较基础的类型的数据大小。
short与long两个限定符的引入可以为我们提供满足实际需要的不同长度的整形数。int通常代表特定机器中证书的自然长度。short类型通常为16位,long类型通常为32位,int类型可以为16位或32位。各编译器可以根据硬件特性自主选择合适的类型长度,但要遵循下列限制:short与int类型至少为16位,long类型至少为32位,并且short类型不得长于int类型,而int类型不得长于long类型。————《C程序设计语言》
也就是说对于short、int、long,C并没有规范其类型大小,因此在不同的编译器上分配的大小可以不一样。指针型数据由机器的为而定。而float和double表示的浮点数,其标准由IEEE规定了,故在各个机器上都是一样的。下面列举各个机器上的gcc为他们分配的大小
有符号 | 无符号 | 32位机器 | 64位机器 |
---|---|---|---|
[signed] char | unsigned char | 1 | 1 |
short | unsigned short | 2 | 2 |
int | unsigned int | 4 | 4 |
long | unsigned long | 4 | 8 |
long long | unsigned long long | 8 | 8 |
int8_t | uint8_t | 1 | 1 |
int16_t | uint16_t | 2 | 2 |
int32_t | uint32_t | 4 | 4 |
int64_t | uint64_t | 8 | 8 |
char * | 4 | 8 | |
float | 4 | 4 | |
double | 8 | 8 |
对关键字的顺序以及是否包括“signed”,C语言允许多种存在形式,比如unsigned long = unsigned long int = long unsigned = long unsigned int。
位运算指&与、|或、^异或、~取反,前三个是双目运算符,最后一个是单目运算符。异或也叫半加运算,其运算法则相当于不带进位的二进制加法,输入相同输出为0,输入不同输出为1。
逻辑运算符与位运算符比较类似,不过逻辑运算符是作用于整个数据类型上而不是位上。&&于、||或、!非,前两个双目运算,最后一个单目运算。
移位运算分为<<左移、>>右移。其中右移分为逻辑右移和算术右移。左移表示原来的二进制串一起向左移动,剩余的地方补0,显然这有可能导致溢出,溢出的部分我们舍弃掉。逻辑右移表示原来的二进制串一起向右移动,空出的地方补0,溢出的地方舍弃。算术右移表示原来的二进制串一起向右移动,空出的地方按最高位补齐,溢出的地方舍弃。
在Java中>>表示逻辑右移,>>>表示算术右移。在C中没有限定这两种右移在使用域上的区别,然而一般来说对有符号用算术右移,对无符号用逻辑右移。很显然算术右移可以保持有符号int的正负,在下一节整数计算时我们也会提到。
另外一个要注意的就是当移位运算的移位特别大时。一位由 w w w位组成的数据类型,如果要移动 k ≥ w k\ge w k≥w位,会得到移动 k m o d w k\quad mod\quad w kmodw位。
无符号 w w w位整型,其权重向量为 [ 2 w − 1 , 2 w − 2 , . . . . . . , 2 1 , 2 0 ] [2^{w-1},2^{w-2},......,2^1,2^0] [2w−1,2w−2,......,21,20],它只能表示自然数,不能表示负整数。
有符号 w w w位整型,其权重向量为 [ − 2 w − 1 , 2 w − 2 , . . . . . . , 2 1 , 2 0 ] [-2^{w-1},2^{w-2},......,2^1,2^0] [−2w−1,2w−2,......,21,20]。第一位是符号位,显然第一位为0时该整型表示自然数,第一位为1时该整型表示负整数。这种方式叫补码(two complement)。
这里我们可以理解为啥对有符号整型的移位运算要用算术右移,因为只有这样才能保证第一位不变,从而保持其符号不变。补码与逻辑右移的精巧性在于其能恰好使得有符号整型的乘除法的移位运算表达形式,能与简单的无符号整型统一起来,共用一种简单的模式。
显然整数是一个交换环,其加法乘法运算满足交换律和结合律,且乘法对加法有分配律。但是在整型中要注意,由于我们的整型是由有限内存表示的,故其加减乘除实际上都有取模运算的过程,稍不留神就会溢出。由于溢出的存在,整型中的大小关系变得不可传递,即若$>b,而ac不一定大于bc。
对于w位的无符号信息x和y的加法, x + y ≤ 2 w x+y\le 2^w x+y≤2w时不会溢出,否则会溢出,可以发现,随机情况下,溢出的概率是1/2。对于w位的有符号信息x和y的加法, x + y ≤ − 2 w − 1 x+y\le -2^{w-1} x+y≤−2w−1时会负溢出, x + y > 2 w − 1 x+y> 2^{w-1} x+y>2w−1时会正溢出。对于乘法的溢出则更为普遍,在计算中我们应该先估算,谨防溢出。
上面两张图来自CSAPP,表示无符号整型加法和补码加法的溢出域。
另一方面,由于整数计算中乘法的耗时比较久,加法和移位运算比较快,于是我们希望能够用加法和移位运算代替变量对常数的乘除法。
首先我们考虑 V = X × 2 k V=X\times 2^k V=X×2k,其实V就相当于X左移k位,于是我们可以计算X左移后的,在减去原来的若干倍,也就相当于X乘以某非2的幂的积。
而对于除法我们依据上述的办法则只能处理除以 2 k 2^k 2k的情形,即对于无符号整型逻辑右移k位,溢出部分作为小数部分舍掉;对于有符号整型算术右移k位。
IEEE制定了浮点型的编码规则,使得浮点数能够更加精确的表示实数,该标准使用 V = ( − 1 ) s × M × 2 E V=(-1)^s\times M\times 2^E V=(−1)s×M×2E的形式来表示一个数,你可以理解为二进制的科学计数法的一种变体:
符号(sign): s s s决定了这个数是正数( s = 0 s=0 s=0)还是负数( s = 1 s=1 s=1)
阶码(exponet):E表示阶数,这里E是一个有符号整型,但是并不采用补码编码,而是另外一种方式,这里 E = e x p − B a i s E=exp-Bais E=exp−Bais 或者 E = 1 − B i a s E=1-Bias E=1−Bias,具体哪一种由下一节介绍。其中 e x p = e k − 1 e k − 2 . . . e 1 e 0 exp=e_{k-1}e_{k-2}...e_1e_0 exp=ek−1ek−2...e1e0代表无符号数, B i a s Bias Bias表示偏置,是一个值为 2 k − 1 − 1 2^{k-1}-1 2k−1−1的数(在float中k=9,Bais等于127,double中k=11,Bais为1023)。
尾数(significand):M是一个二进制小数,它的范围是1~2- ϵ \epsilon ϵ或者0~1- ϵ \epsilon ϵ,具体是哪一种范围由下一节介绍。M由原实数化为科学计数法的二进制的小数部分编码(frac字段)
下面用CSAPP中的图直观地表示浮点型的构成
浮点数的编码由三部分构成,即s、exp和frac,它们分别编码符号、阶码和尾数。
下面我们仅以float类型为例,介绍浮点数的分类表示,浮点数分为3种,其中第三种又分为两种类型。
下面举几个栗子,首先我们看规格化的值:
设 V = 10.62 5 ( 10 ) V=10.625_{(10)} V=10.625(10),我们先将其化为而二进制,则 V = 1010.10 1 ( 2 ) V=1010.101_{(2)} V=1010.101(2),写成IEEE标准则是 V = ( − 1 ) 0 × 1.010101 × 2 3 V=(-1)^0\times 1.010101\times 2^3 V=(−1)0×1.010101×23。显然 s = 0 , e x p = 3 ( 10 ) + B i a s ( f l o a t ) = 1000001 0 ( 2 ) , f r a c = 010101 s=0,exp=3_{(10)}+Bias_{(float)}=10000010_{(2)},frac=010101 s=0,exp=3(10)+Bias(float)=10000010(2),frac=010101,这里要注意,frac去掉了整数部分的1为了最大限度的表示小数。
再来一个规格化的值
设 U = 0.937 5 ( 10 ) U=0.9375_{(10)} U=0.9375(10),我们先将其转化为二进制,则 U = 0.111 1 ( 2 ) U=0.1111_{(2)} U=0.1111(2),写成IEEE标准则是 U = ( − 1 ) 0 × 1.111 × 2 − 1 U=(-1)^0\times 1.111\times 2^{-1} U=(−1)0×1.111×2−1。显然 s = 0 , e x p = − 1 ( 10 ) + B i a s ( f l o a t ) = 12 6 ( 10 ) = 111111 0 ( 2 ) , f r a c = 111 s=0,exp=-1_{(10)}+Bias_{(float)}=126_{(10)}=111 1110_{(2)},frac=111 s=0,exp=−1(10)+Bias(float)=126(10)=1111110(2),frac=111。
如果值再小下去,我们会发现,由于float表示的极限,最小的规格化值应该是 e x p = 0 exp=0 exp=0,即E=126,在往下小那么float就无法表示这种小了,于是此时就需要采用非规格化的编码,事实上这种编码就是为了表示趋于零的实数的。
我们用一个表格简单的描述一下浮点数
描述 | exp | frac | ||
---|---|---|---|---|
0 | 00…00 | 0…00 | ||
最小的非规格化数 | 00…00 | 0…01 | ||
最大的非规格化数 | 00…00 | 1…11 | ||
最小规格化数 | 00…01 | 0…00 | ||
1 | 01…11 | 0…00 | ||
最大的非规格数 | 11…10 | 1…11 | ||
无穷 | 11…11 | 0…00 |
下面来自CSAPP的图大致的表示了浮点数的分类(注:这个不是float也不是double,而是六位浮点数)
我们可以看到,浮点数能精确表示的数越靠近0分布越密集。
我们假设 X , Y , k X,Y,k X,Y,k表示整型,则浮点型用来表示形如 V = ∑ X × 2 k V=\sum X\times 2^k V=∑X×2k的实数,而我们常用的基于10进制的有限小数都是形如 U = ∑ Y × 1 0 k U=\sum Y\times 10^k U=∑Y×10k的,所以计算机中的浮点数在很多情况下只是对现实世界数据的模拟,而无法做到恰好精确。显然,形如 V = ∑ X × 2 k V=\sum X\times 2^k V=∑X×2k的数构成的集合的势为 ℵ 0 \aleph_0 ℵ0,故其测度为0,于是我们可以认为在实数范围内几乎所有的数都无法用浮点型来精确表示。于是一般的实数用浮点数来近似就显得尤为重要。在计算机中,我们采用向偶数舍入法。在二进制中,我们定义如果最低位为0则该数位偶数,向偶数舍入法就是将该数舍入到最近的偶数。
浮点数的计算中一个很重要的点就是它只满足交换律不满足结合律。满足交换律在数域中是必然的,结合律则是由于舍入误差。在浮点数的加减运算中,是先将两数加减,然后再做一次舍入,的到最后的结果,这样很容易“大数吃小数”。 比如在float类型下3.14+(1e20-1e20)=3.14,而(3.14+1e20)-1e20=1e20-1e20=0.0。因此我们应该尽量避免这种情况。类似的情况还有大数除以小数等,都很容易使得浮点数溢出从而带来较大的误差。
另一方面,浮点型比整型的好处不仅在于它能表示更大范围的数,也在于s单独控制了它的正负性,它不会无缘无故从正数溢出为负数,而整型中则有可能。如果 a ≥ b , c > 0 a\ge b,c>0 a≥b,c>0则 a c ≥ b c ac\ge bc ac≥bc,这也更符合我们的代数学中的知识。
情况还有大数除以小数等,都很容易使得浮点数溢出从而带来较大的误差。
另一方面,浮点型比整型的好处不仅在于它能表示更大范围的数,也在于s单独控制了它的正负性,它不会无缘无故从正数溢出为负数,而整型中则有可能。如果 a ≥ b , c > 0 a\ge b,c>0 a≥b,c>0则 a c ≥ b c ac\ge bc ac≥bc,这也更符合我们的代数学中的知识。