CSAPP导读第2章 信息的表示和处理

2,411 阅读38分钟

CSAPP导读第2章 信息的表示和处理

众所周知,计算机的存储都是依靠二值信号,什么叫二值,二值说白了就是要么为0,要么为1,计算机中存储的只有这两个值,那么,为什么会这样设计呢?

这是因为我们只有2个值是很容易表示的,比如我们可以用模拟信号进行表示,高电平代表1,而低电平代表0,当然,模拟信号是会有波动的,但是只要我们设置一个阈值,在电压值的波动不超过这个阈值的时候都认为是高电平或低电平,那么就能够很容易地使用它们了。下图中,我们设置了高电平在0.9V至1.1V,只要电压值在这么区间内,就认为是高电平,同样,如果是0.0V和0.2V之间,则认为是低电平,于是我们就区分出了0和1。

image-20220111234949813

想象一下,如果要用十进制进行表示,那么岂不是需要10个电压范围,这是很麻烦的,因此,计算机使用了0和1这两个值来表示所有的信息。事实上,所有的信息都可以用0和1来表示,数字可能是相同的,但是对于这一串数字的看法可以是不同的,譬如一个32位的01串,可以把它理解成一个32位的整数,当然你也可以将他看成是一个32位的浮点数(这些我们在后面会讲到),甚至你可以把它看成是指令或者更加复杂的东西。

在开始讲整数和浮点数的具体内容之前,我们来看这样一个十分好玩的事实——在计算机中的整数不是现实中的整数,而计算机中的浮点数也不是现实中的浮点数

听上去很好笑不是么,不如来看这样两个例子:

  1. x2x^2一定大于等于0么? 对于浮点数而言,这是成立的,但是对于整数,并不是如此,这似乎有点违反常理。举个例子,对于C语言,50000250000^2会得到-1794967296的结果,这竟然是个负数。导致这个的原因在于溢出(overflow),我们后面再详细解释。
  2. (3.14+1e20)-1e20 == 3.14+(1e20-1e20)成立么? 首先解释一下,1e20表示1乘10的20次方。看上去这不就是加法的结合率么,但是实际上,前者的结果是0.0,而后者的结果为3.14,这是因为对于1e20而言3.14太小了,由于位数的限制只能将其舍去,于是前者就相当于1e20-1e20。

从上面的例子就可以知道,计算机的算数和我们平时的数学运算是有出入的,但是,这些出入都是可以预测的,并不是随机的。

2.1 信息存储

大多数的计算机使用8位,即一个字节作为最小的可寻址单元,什么叫可寻址单元呢,可以把内存看作是一个很长很长的数组,那么其中每一个元素都有一个对应的下标,我们可以通过下标找到需要的元素,这样可以被找到的最小的单位,就是一个可寻址单元,这样的下标,便称之为地址。

2.1.1 十六进制

通常,使用一长串01太麻烦了,看起来让人眼花缭乱不是么,所以我们大多数情况下都使用十六进制表示,把4位二进制数变成1位十六进制,这样不就短了很多么。十六进制的0-9和十进制一样,而10-15使用A-F进行表示:

十六进制ABCDEF
十进制101112131415
二进制101010111100110111101111

使用0x或者0X开头的被认为是十六进制数,同时,A-F的大小写不进行区分,譬如0xFa1D37b就是一个十六进制数。

二进制转化为十六进制可以这么做:把一个二进制的从低位开始每4位一划分,然后将每4个对应的十六进制数写出来即可。

例如,1101110划分为110 1110,然后对应的十六进制数为6 E,即为对应的十六进制数。

十六进制转化为二进制可以这么做:把每一个十六进制写成对应的二进制即可。

例如,3C2D,3对应0011,C对应1100,2对应0010,D对应1101,那么二进制为0011 1100 0010 1101。

2.1.2 字数据大小

前面讲了字节,一个字节是8位bit,也就是8个0或者1,那么什么是字呢,和字节有关系么?

计算机中最核心的莫过于中央处理器CPU,就像人的大脑一样,其中有一个叫做算术逻辑单元ALU的部件,ALU是负责运算的,比如要做加减乘除,都要靠ALU。但是,ALU能够处理的数据是由长度限制的,这是硬件所限,对于某一个特定的机器,它的ALU可能最多能够处理32位的数据,或者64位的数据,这就是我们说的32位机或者64位机,而这样一个机器可以处理的数据大小,就被叫做一个(word)。

一般来说,字有多长,那么地址位也就有多长,所以对于一个32位机器,可寻址的地址单元就有2322^{32}个。

C语言支持多种数据类型,在不同字长的机器上可能有着不同的大小,比如long型在32位机上是4个字节(32位),在64位机上是8个字节(64位)。这很容易理解,以为32位机器的ALU一次就能处理32位的整数,64位机也就是64位,后面我们也会讲到浮点型。

C Data TypeTypical 32-bitTypical 64-bitx86-64
char111
short222
int444
long488
float444
double888
long double10/16
pointer488

2.1.3 字节顺序

从上面我们可以知道,一个字包含多个字节,可能是4个,也可能是8个,那么问题来了,在内存中,一个字中的多个字节是如何放置的,举个例子,有一个32位的字0x01234567,分配给它的内存地址空间为0x100到0x103这4个字节,那么对于字节67,貌似可以放在0x100,也可以放在0x103,直觉告诉我们,不如顺序放吧,像下面这样:

字节01234567
地址0x1000x1010x1020x103

那难道下面这种不可以么,虽然它似乎有点反人类:

字节01234567
地址0x1030x1020x1010x100

事实上,这就是两种不同的字节组织形式,上面那种被称之为大端,下面的则是小端,书上给的区分方式是这样的:最低有效字节在最前面,称为小端,而最高有效字节在前面,称为大端。

看的是低位(字节),对于上面的例子而言,低位是67,如果放在低地址,即0x100,则为小端,如果放在高地址0x103,则是大端。

image-20220112115034337

事实上,两种方式都有厂家在使用,比如大端的典型有Sun(现在被Oracle收购了)的机器以及网络的传输(所以如果你用的是小端机器,使用网络传输时可能需要一些操作),而小端的典型有x86、ARM等等,现在的机器大多数都是小端的,所以你可能见不到大端机器。

大端和小端这两个名词的来源是格列佛游记,讲的是吃鸡蛋应该从大的一端打呢,还是小的一端打。感兴趣的话可以看看教材中的描述。

我们来看看大端机器和小端机器的区别,比如有一个整形变量A,那么它在大端机器Sun和小端机器x86上的区别如下:

int A = 15213;	// 十六进制为3B6D

image-20220112120407778

注:上面是低地址,下面是高地址。

如果我们想要程序来让我们看看从一个地址开始的一些字节是什么样子的,那么可以使用下面的例程:

typedef unsigned char *pointer;

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

注:在C++中,设计size_t就是为了适应多个平台的。size_t的引入增强了程序在不同平台上的可移植性。size_t是针对系统定制的一种数据类型,一般是整型,因为C/C++标准只定义一最低的位数,而不是必需的固定位数。而且在内存里,对数的高位对齐存储还是低位对齐存储各系统都不一样。为了提高代码的可移植性,就有必要定义这样的数据类型。

使用char型的指针是因为char正好是一个字节的。

比如,打印出一个整型变量在内存中的存储:

int a = 15213;
printf("int a = 15213;\n");
show_bytes((pointer) &a, sizeof(int));

在x86、windows系统的机器上跑出来的结果如下:

int a = 15213;
000000000062FE1C        0x6d
000000000062FE1D        0x3b
000000000062FE1E        0x00
000000000062FE1F        0x00

可以很明显地看出,这是一个小端的机器。

另外,如果看一下同一个值,在不同机器和操作系统上的字节表示形式,可以发现一个很奇妙的事情,对人而言,12345和12345.0应该是相等的,但是在int和float上的表示形式确实截然不同的,如下图,一个是00 00 30 39,而另一个则是46 40 E4 00,展开为二进制的话,分别是0000 0000 0000 0000 0011 0000 00110100 0110 0100 0000 1110 0100 0000 0000,对比一下会发现其中有13位是匹配的,这并不是巧合,在后面学习浮点数的时候我们会有对应的解释。

image-20220112134429987

打印出的指针值和操作系统、编译器有着密切的关系,甚至每一次运行都有可能出现不同的结果 Different compilers & machines assign different locations to objects

2.1.4 字符串表示

字符串比数字要简单许多,对于不同的机器,打印出的字符串在虚拟内存中的表示形式是相同的,因为都是采用ASCII码的表示,而且与上面说的字节顺序没有任何关系,因此具有较高的兼容性。在下图中可以看到,对于这一串字符,在大端和小端机器上都没有任何区别。

image-20220112135435919

image-20220112135442738

2.1.5 表示代码

那么代码又如何在机器中表示呢,比如有下面这一段C程序代码:

int sum(int x, int y){
	return x + y;
}

在不同的机器上编译,会发现机器代码都是不同的,这是因为不同的机器用的指令是不同的,因此二进制代码是不兼容的,很难在不同机器或者操作系统之间进行移值。

2.1.6 布尔代数

布尔代数是19世纪的时候被乔治·布尔发明的,后来香农首先建立了布尔代数和数字逻辑之间的联系。对于布尔代数而言,把1当成是Ture,把0当成是False。

有这样几种基本的运算:

  • And与:两个都为1的时候结果为1
  • Or或:两个中只要有一个为1,结果为1
  • Not非:1变成0,0变成1
  • Xor异或(Exclusive-Or),两个不同的时候则为1

image-20220112140621962

位运算:对位向量进行上面的这些操作,位向量就是一串01,比如01101001。下面给一些例子:

image-20220112140839066

很多时候,我们会用位向量,也就是一串01来表示一个有限集合,比如01101001,实际上,如果我们把所有的1所在的位置(最右边一位的位置记为0)写出来,也就是一个与其等价的集合{0,3,5,6}\{0,3,5,6\}。这种表示方法很有用,几个简单的例子,比如我们现在有10个信号,也就是一个信号的集合,我们需要屏蔽其中的一些,那么我们可以记被屏蔽的为0,反之为1,于是我们就可以用10位的位向量进行表示。

2.1.7 C语言的位运算

C语言也是支持按位运算的,并且和上面所用的符号是相同的,比如下面这几个例子:

image-20220112142346726

在对十六进制进行位运算的时候,先转化为二进制,因为只有二进制才能看到具体的某一位是0还是1,最后再转化为十六进制。

2.1.8 C语言的逻辑运算

逻辑运算和位运算经常被人搞混,因为他们长得太像了,逻辑运算也有三种,分别用&&||!来表示。

那么这个逻辑运算和位运算的有什么区别呢?

在逻辑运算中,只关心True和False,正如它的名字一样,只关心这些逻辑,比如对于逻辑与,两个都为True则结果为True,对于逻辑或,只要一个为True,则结果为True,非则是True变False,False变True。

那什么是True,什么又是False呢?

在C语言中,非零即真,也就是说,无论数是多少,位表示是怎么样的,只要不是0,就是真。举个例子,0x69是True,0x55也是True,那么他们的与结果就是True,而C语言中的True就是1,那么就有如下的运算式:

0x69 && 0x51=0x010x69 \space \&\& \space 0x51 =0x01

同样的也就有如下的式子:

image-20220112143300609

2.1.9 移位操作

什么是移位呢,对于一个w位的位向量[xw1,xw2,...,x0][x_{w-1},x_{w-2},...,x_0],你可以把每一位都向左或者向右移动若干位,但是这样会有一个很显然的问题,就是向左移的时候,必然会把xw1x_{w-1}移出去,同时本来x0x_{0}的位置处也会被空出来,因为位数是固定的,而右移的时候,也必然会把x0x_0移出去,同时本来xw1x_{w-1}的位置处也会被空出来。

对于左移而言,很简单,移出去的就移出去了,而补的时候补0即可,这就是左移,符号是<<

比如现在有一个数x=01100011x=0110 0011,那么有:

x << 4 = 0011 0000

其中右边的4个0都是补的,而左边的4位是原来右边的四位。

而对于右移而言,却分成了两种,一种称之为逻辑右移,一种称之为算术右移,逻辑右移的操作和上面一样,空出来的补0即可,而算数右移则不是,算数右移为了保证有符号数的正负不改变(因为最高位是符号位),会在空出来的高位上补符号位,而不是所有情况下都补0。

比如数x=10010101x=1001 0101,那么有:

x >>(逻辑右移) 4 = 0000 1001 
x >>(算数右移) 4 = 1111 1001 

对有符号数而言,默认是使用算数右移,而对于无符号数而言,只能是逻辑右移。

Java与C不同,使用>>表示算术右移,而>>>表示逻辑右移。

可能看到这里有细心的同学会问:如果移的位数超过了本身的位数呢?比如我只有8位,但是你要我移12位,如果是逻辑的移位,那么结果会是全0么?

答案是,当移动的位数 kk 大于等于自身的位数 ww ,实际上移动的位数是 k mod wk \space mod \space w 位。比如上面的例子,8位左移12位,实际上只会移动4位(12 mod 8)。

最后,提一句移位操作的优先级,移位操作的优先级是比加减运算低的,所以如果你想优先移位,请加上括号,吃不准的时候,都加上吧。

下面,我们会讲解两类最常见的数据——整数浮点数

2.2 整数表示

生活中,整数是很常见的,整数包括了正整数,负整数和零,那么在计算机中,该如何表示整数呢?

2.2.1 整型数据

C语言提供了很多整型数据——char、short、int、long,由于程序编译为32位还是64位会导致数据类型对应的长度是不同的,下面左图为32位编译的,右图为64位编译的。

image-20220112155939873image-20220112160020458

这里唯一不同的是long的表示范围,其余的32位和64位是相同的,这在2.1.2节中讲到过。

如果细心观察一下,会发现正负数的取值范围并不对称,负数会比正数多一个,实际上,由于计算机是二进制的,那么能表示的东西一定是2的整数幂,也就一定是偶数的,然而,我们有0存在,如果正数和负数数量相同,那么加上0就是奇数个了,也就是说,要么正数少一个,要么负数少一个才行,于是,在补码的表示中,负数的表示范围就比正数多一个,后面我们会详细讲解补码。

2.2.2 无符号数

假设我们现在有一个无符号数xx,其位向量表示为x=[xw1,xw2,...,x0]x=[x_{w-1},x_{w-2},...,x_0],那么实际的值是多少呢?

我们定义这样一个函数B2UwB2U_w,表示Binary(二进制)to Unsigned(无符号):

B2Uw(x)=Σi=0w1xi2iB2U_w(\vec{x})=\Sigma_{i=0}^{w-1}x_i2^i

看上去很抽象,但是实际上就是给每一位二进制不同的权重(2i2^i),然后加权求和罢了。举几个例子:

B2U4([0001])=0×23+0×22+0×21+1×20=1B2U_4({[0001]})=0\times2^3+0\times2^2+0\times2^1+1\times2^0=1 B2U4([0101])=0×23+1×22+0×21+1×20=5B2U_4({[0101]})=0\times2^3+1\times2^2+0\times2^1+1\times2^0=5 B2U4([1011])=1×23+0×22+1×21+1×20=11B2U_4({[1011]})=1\times2^3+0\times2^2+1\times2^1+1\times2^0=11 B2U4([1111])=1×23+1×22+1×21+1×20=15B2U_4({[1111]})=1\times2^3+1\times2^2+1\times2^1+1\times2^0=15

第i位第4位第3位第2位第1位第0位
权重2i2^i168421

对于一个w位无符号整数而言,其取值范围很好确认,最小就是000..0000..0,而最大就是111..1111..1,即 02w10 — 2^{w-1}

2.2.3 补码

无符号数能表示的只有非负数,但是我们经常要求表示负数,该怎么办呢,于是人们就发明了补码(Two's Complement),它既可以表示非负数,也可以表示负数。假设我们现在有一个补码表示的数xx,其位向量表示为x=[xw1,xw2,...,x0]x=[x_{w-1},x_{w-2},...,x_0],其实际值是多少呢?

我们定义这样一个函数B2TwB2T_w,表示Binary(二进制)to Two's Complement(补码):

B2Tw(x)=xw1×2w1+Σi=0w2xi2iB2T_w(\vec{x})=-x_{w-1}\times 2^{w-1}+\Sigma_{i=0}^{w-2}x_i2^i

对于补码而言,要表示正负,肯定有一个符号位,也就是最高位,如果为0则是正数,反之为负数。在计算它的值的时候,最高位的权重被认为是2w1-2^{w-1},其他位不变。还是举几个例子来看:

B2T4([0001])=0×23+0×22+0×21+1×20=1B2T_4({[0001]})=0\times2^3+0\times2^2+0\times2^1+1\times2^0=1 B2T4([0101])=0×23+1×22+0×21+1×20=5B2T_4({[0101]})=0\times2^3+1\times2^2+0\times2^1+1\times2^0=5 B2T4([1011])=1×23+0×22+1×21+1×20=5B2T_4({[1011]})=-1\times2^3+0\times2^2+1\times2^1+1\times2^0=-5 B2T4([1111])=1×23+1×22+1×21+1×20=1B2T_4({[1111]})=-1\times2^3+1\times2^2+1\times2^1+1\times2^0=-1

对于一个w位的补码而言,我们来看一看它的取值范围。

首先确定最小值TMinTMin,思考一下,最小负数应该为多少呢,由于除了最高位,其他位的权重都是正的,也就说如果要最小,就需要其他位都是0,只有最高位为1,即1000..00,那么就有TMin=2w1TMin=-2^{w-1}

那么最大值TMaxTMax呢,这就很容易了,只需要最高位为0,其余都是1,也就是0111..11,于是TMax=2w11TMax=2^{w-1}-1,算的时候,其实这个数就是无符号的1000..00再减去1。

上述的计算方法可能有点繁琐,这里补充一种,对于正数而言,它的补码和无符号的是一样的,而对于负数,如果要求其补码,只需要将其对应正数的无符号表示全部取反,再+1即可。

比如-5的补码,就是先求5的无符号表示:0101,那么全部取反之后变成1010,再加1,即为1011,就是-5的补码。

或者说,如果要求一个数的相反数,就只需要将这个数的补码按位取反,然后加1即可

前面我们讲到过,正数和负数的取值范围是不对称的,如果了解了补码的运算和取值范围就可以知道这一点,实际上有这样的关系,TMin=TMax+1|TMin|=|TMax|+1,这种不对称性很重要,很多程序与现实不符的情况都是因此而来,因为对于TMinTMin,是没有对应的正数的。

同时,也有这样的结论:UMaxw=2TMaxw+1UMax_w=2TMax_w+1

2.2.4 有符号数和无符号数的转换

之前我们就说过,在计算机中,所有信息都是以二值来表示的,不同的只是我们如何看待这一串01。那么,我们思考这样一个问题,对于同一个位模式 x\vec{x} ,我们将它看成补码和看成无符号数有什么区别,也就是两者如何转换。

先看这样一个简单的小例子,我们仅观察4位的补码和无符号数的转换:

image-20220112165500826

可以看到,当符号位为0的时候,两者是等价的,而为1的时候,正好差了16(无符号数比补码大16),也就是2的4次方,这难道是巧合么?实际上并不是的,这是一个普遍的规律。

**原理:**补码转换为无符号数

对于满足TMinwxTmaxwTMin_w\le x\le Tmax_w,有如下的规律:

T2Uw(x)={x+2w,x<0x,x0T2U_w(x)=\begin{cases} x+2^w&, x<0\\ x&,x\ge0 \end{cases}

我们从直观上理解一下这个规律,正数的时候很好理解,当x为负数的时候,最高位为1,对于无符号数而言,其权重就是2w12^{w-1},但是对于补码而言,其权重为2w1-2^{w-1},那么从2w1-2^{w-1}变成了2w12^{w-1},是不是也就变大了2w2^{w}。用一张图形象地表示一下就是下面这张图,对于补码的-1而言,表示为111..11,那么对应的也就是无符号数的 UMaxUMax

image-20220113003106333

无符号数转换为补码就是减去 2w2^{w} 而已,这里不再赘述了。

2.2.5 C语言的有符号数和无符号数

在C语言中,无符号数是需要指定的,因为几乎在所有机器中,数字都默认是有符号的,在C语言中指定一个无符号数是通过在数字有加一个U或者u。比如12345U或者0x1A2Bu

C语言也是支持无符号数和补码进行转换的,有显式转换也有隐式的。

显式的类型转换如下:

int tx, ty;
unsigned ux, uy;
tx = (int) ux;
uy = (unsigned) ty;

上面的代码第三行将一个无符号数转换为补码,而第四行则相反。

隐式的类型转换如下,当进行赋值的时候会进行隐式的转换:

tx = ux;
uy = ty;

当使用printf进行输出的时候,可以用%d%u%x分别以有符号十进制无符号十进制十六进制格式输出数值。

在使用比较运算符(<,>,==,<=,>=)的时候,也会进行隐式的转换,在比较的两边有一个是无符号数的时候,另一边如果是有符号数,则会被转化为无符号数。

w=32时,TMIN = -2147483648 , TMAX = 2147483647,比较的结果如下:

序号值1值2关系比较的类型
100U==无符号数
2-10<有符号数
3-10U无符号数
42147483647-2147483647-1有符号数
52147483647U-2147483647-1<无符号数
6-1-2有符号数
7(unsigned)-1-2无符号数
821474836472147483648U<无符号数
92147483647(int)2147483648U有符号数

解释一下转换为无符号数比较的几个:

-1转换成了无符号数,就变成了UMaxUMax,所以是大于

-2147483647-1 是 TMinTMin(这么写的目的是为了突出不对称性), TMinTMin 转换为无符号数的 TMax+1TMax+1 ,因此比 TMaxTMax 大。

同③

2.2.6 扩展一个数字的位表示

我们在进行类型转换的时候,经常会碰到将位数少的转换为位数多的,但是我们又希望不改变原有的值,这就是位数扩展。

对于无符号数来说,这是非常简单的,只需要在左边补上0即可。

但是对于有符号数来说,就不是补0了,举个例子,有一个4位的数字为-5,那么它的补码表示为1011,我们希望把它扩展成8位的,如果仅仅是补0,那么会变成0000 1011,而这个补码对应的值就变成了11,这是我们不希望的。但是如果是一个正数的话,补0就没有问题,这里就不举例子了。

所以为什么会不一样呢?

原因在于本来的最高位如果为1(负数),其权重为是负的,而向左边补0之后,其权重就变成正的了。

所以我们并不是在左边补0,而是都补成符号位,这样值是不变的,似乎有点难理解。

我们直观的解释一下,现在假设有一个w位的负数补码表示[1,xw2,...,x0][1,x_{w-2},...,x_0],如果向左扩展一位,又需要它的值不变,我们可以在左边补一个1,变成[1,1,xw2,...,x0][1,1,x_{w-2},...,x_0]。为什么说值不变呢?因为之前的最高位是1,相当于2w1-2^{w-1},扩展之后就变成了2w12^{w-1},也就是说变大了2w2^{w},那我们怎么做能让它不变呢,减去 2w2^{w} 不就行了嘛,减去 2w2^{w} 实际上就是左边补了一个1不是么,因为补的1是在第w位(而且是符号位),权重正好就是2w-2^w

上面的例子是扩展了1位,可以发现值不改变,那么扩展多位呢,直接使用数学归纳法就行了,也就是说:

[1,xw2,...,x0]=[1,1,xw2,...,x0]=[1,1,1,xw2,...,x0]=[k1,xw2,...,x0][1,x_{w-2},...,x_0]=[1,1,x_{w-2},...,x_0]=[1,1,1,x_{w-2},...,x_0]=[k个1,x_{w-2},...,x_0]

2.2.7 截断数字

上面说了扩展,那么反过来就是截断了,也就是我们将一个较大的数据类型赋给一个较小的数据类型,这时候就会发生截断,比如把一个int赋值给short

对于无符号数而言,实际上就是做了一个取模操作(mod),将一个w位的无符号数截断为k位,就是保留最低的k位,然后舍去高位,得到的结果就是w mod 2kw \space mod \space 2^{k}

对于补码而言,稍微有一点麻烦,实际上还是保留最低的k位,那么也就是先把它当成无符号数,然后像上面一样取模,得到的就是最低的k位,只需要把它再看作是补码就行,也就是使用一个 U2TU2T 函数将无符号数转化为补码。

到这里,我们就把整数的表示讲完了,是不是受益良多呢。

2.3 整数运算

接下来,我们讲一讲整数的运算,在本章刚开始,就举过几个例子,说明了计算机的整数和现实中的整数是不完全一样的,运算也是一样。在运行C程序的时候,有时候会发现一些奇怪的结果,比如两个正数相加,结果竟然会是个负数。这种情况我们称之为溢出(overflow)

2.3.1 无符号加法

对于两个w位的无符号数x和y,有:

0x<2w0y<2w0x+y<2w+10\le x < 2^w, 0\le y < 2^w \\ 0 \le x+y < 2^w+1

可以看到两个数相加后的值可能会超过 2w2^w ,也就是说,这个数并不能被表示为w位的无符号数,这样溢出就产生了。但是我们又需要将其放入w位中,也就是2.2.7节中所说的截断,所以实际的结果会是(x+y)mod2w(x+y)mod2^{w},如果画出来的话就是这个样子的,会存在一个断崖,因为超过后就会取模,然后又变回0,再继续增加。

image-20220112202128942

换个说法,也就是说当加法的结果大于等于2w2^w,那么实际得到的和就会变成 x+y2wx+y-2^w

通过上面的这些结论,我们也可以得到检测无符号数加法溢出的算法:如果两个无符号数的和比其中一个要小(小于x或者小于y)那么就发生了溢出。证明也很简单,如果没有发生溢出,那么肯定和比其中任何一个加数都要大;如果发生了溢出,那么加法的和s=x+y2ws=x+y-2^w,并且 y<2wy<2^w,那么 y2w<0y-2^w<0,于是 s<xs<x

2.3.2 补码加法

如果x,y是w位的有符号数,那么有:

2w1x<2w12w1y<2w12wx+y<2w-2^{w-1}\le x < 2^{w-1},-2^{w-1}\le y < 2^{w-1} \\ -2^w\le x+y < 2^w

同样,也有可能发生溢出。但是这里的溢出就要分为两类了——正溢出负溢出

对于正溢出而言,就是两个正数相加,然后超过了正数的范围,这时候,进位会被加到符号位,让符号位从0变成1,于是变成了一个负数,也就是本章开始说的那种情况。

正溢出的时候,符号位从0变成1,对于无符号数而言,这个1代表了2w12^{w-1},但是实际上变成了2w1-2^{w-1},于是最后的结果相当于是x+y2wx+y-2^w

负溢出也就是从1变成了0,和上面是反的,会变成一个正数,最后的结果会变成x+y+2wx+y+2^w

用图来表示补码加法的结果就是下面这样的:

image-20220112204723714

2.3.3 补码的非

补码的非基本也就是我们所说的相反数,说基本的原因是,有一个个例,就是 TMinTMin,因为对于 TMinTMin 而言有下面的式子:

TMin=TMin-TMin=TMin

所以,如果说对于一个不为0的有符号整数,不一定会有 x!=xx!=-x,例子就是TMinTMin

2.3.4 无符号乘法

对于两个w位的无符号数x,y,如果做乘法也会产生溢出,分析和上面无符号数加法类似,有下面的结论:

xwuy=(xy)mod2wx*_w^u y=(xy)mod2^w

2.3.5 补码乘法

对于两个w位的有符号数x,y,乘法的溢出和上面补码加法的分析类似,有下面的结论:

xwuy=U2Tw((xy)mod2w)x*_w^u y=U2T_w((xy)mod2^w)

也就是先当成无符号数做乘法,然后再截断,转化位补码。

2.3.6 乘以常数

在大多数机器上,乘法是很慢的,比加减法、位级运算和移位要慢得多,因此,编译器会进行优化,而优化的方式就是用移位和加法的组合来替代乘以常数的乘法运算。对于计算机而言,要做移位操作是很快的,而移位操作相当于是乘以2的整数幂或者除以2的整数幂。比如左移2位,相当于是乘以222^2,而右移2位,则相当于是整除222^2

举个例子,比如要将一个数乘以6,那么实际上可以分别将其乘以2和4,也就是左移1位和2位,然后将这两个结果相加,也就是将它乘以6了。下面有补充的两个例子:

u << 3	==	u * 8
(u << 5) – (u << 3)	==	u * 24

2.3.7 除以常数

对于机器而言,乘法以及很慢了,除法就更慢了,乘法如果需要3个时钟周期,那么除法可能需要30个甚至更多的时钟周期,我们同样可以采用移位的方式来加快它的速度。对于乘法而言,移位是向左的,而对于除法,如果是无符号数的,采用逻辑右移即可,如果是有符号数的,则需要使用算数移位。

除以2的整数幂的无符号除法:

0k<x,x>>k=x/2k若0 \le k<x,则 x>>k=\lfloor x/2^k \rfloor

除以2的整数幂的补码除法:

对于补码而言稍微要麻烦一些,因为即便使用算数右移,那么得到的结果也是向下舍入的,但是对于负数而言,我们需要的结果是向0舍入,比如-3.14,我们总是向上舍入为-3。但是观察下面的例子,我们就可以看到,算数右移的时候我们总是向下舍入的。

image-20220112232029927

于是,我们通常通过加上一个偏置,来修正这种舍入。

原理:

对于整数xy(y>0)x/y=(x+y1)/y对于整数x和y(y>0),\lceil x/y\rceil =\lfloor(x+y-1)/y\rfloor

于是,利用上面的性质,我们可以使用如下式子来进行负数的除法:

(x+(1<<k)1)>>k=x/2k(x+(1<<k)-1)>>k=\lceil x/2^k\rceil

那么总结一下,我们可以使用一个C表达式来进行补码的除法 x/2kx/2^k

(x < 0 ? (x + (1<<k) - 1) : x) >> k

2.3.8 关于整数的总结

最后,我们思考几个问题。

首先,为什么需要无符号整数,而不是全都用补码呢,这是因为有的时候,我们确实不需要补码,因为没有负数,而使用无符号整数就可以使得表示的范围扩大两倍,因为C保留了无符号整数。

但是,在使用无符号整数的时候会出现很多bug,这是因为默认是使用补码,而补码和无符号在一起运算的时候会进行转换,这些错误又是很难被发现的。

比如下面这样一个程序:

unsigned i;
for (i = cnt-2; i >= 0; i--)
  a[i] += a[i+1];

思考一下,会发生什么结果呢,很容易发现,这个循环永远不会停止,因为i是个无符号整数,当它减到0然后再减1,又变成了 UMaxUMax ,然后用a做引用的时候,就有可能产生分段错误,因为 UMaxUMax 太大了,超出了引用的范围。

到这里,整数我们就讲完了,接下来,我们再讲一讲另一个很重要的数——浮点数。

2.4 浮点数

浮点数在计算机中的表示和现实中科学计数法的表示类似,对于形如 V=x×2yV=x\times 2^y 的有理数进行编码。

直到20世纪80年代,很多计算机厂家都设计了自己的浮点数表示规则,导致了一些混乱,直到IEEE 754标准的推出,才有了统一的标准。我们会在本章介绍IEEE的这个浮点数标准。

2.4.1 二进制小数

对于整数而言,我们知道每一位的权重实际上是2的整数次幂(正整数次幂),而对于小数而言,实际上就变成了负整数次幂。

对于一个bmbm1...b0.b1b2...bnb_mb_{m-1}...b_0.b_{-1}b_{-2}...b_{-n}的小数,小数点右边的各位的权重分别为-1,-2,-3等等一直往下。而整个浮点数的值就是:

b=Σi=nmbi×2ib=\Sigma_{i=-n}^{m}b_i\times2^i

事实上,这样进行表示是无法精确表示很多小数的,只能做到近似,而提高位数可以提高近似的程度。

上面的这种表示方法,叫做定点表示法,也就是小数点的位置固定,然后把所有的位进行存储。比如我们存储1011 1001 0111 0101,那么可能是1011 1001.0111 0101

可以发现,如果使用定点表示法,那么能够表示的范围是很有限的。因为整数部分只能占部分位数,小数也是如此,可以类比一下,如果直接写出一个十进制小数,比如1234.5678,使用了8位数,而如果我们用科学计数法,比如1×10101\times10^{10},我们只需要存一个底数,和一个指数,这样就能表示一个很大的数,范围就变大了很多倍。于是,我们接下来介绍IEEE的浮点表示。

2.4.2 IEEE浮点表示

IEEE使用V=(1)s×M×2EV=(-1)^s\times M\times2^E,其中,s为符号位(sign),M为尾数(mantissa或者也叫significant,a fractional value),E为阶码(exponent),或者你可以理解为指数。

注意,下图中的exp和阶码不等同,frac也和尾数不等同,这是因为阶码实际上是exp的值减去一个偏置项,尾数实际上是默认以1.开头,然后后面跟上frac。这些我们在后面会详细说。

image-20220113000127272

浮点数有这样三种:单精度双精度扩展精度(很少用)。

单精度的总共32位,使用1位的符号位,8位的阶码,23位的尾数。

image-20220113000356123

双精度的总共64位,使用1位的符号位,11位的阶码和52位的尾数。

image-20220113000617771

扩展精度的总共80位,使用1位的符号位,15位的阶码和64位的尾数。(只有Intel在用)

image-20220113000627656

编码可以根据expfrac的值分为三种(或者说四种,把第三种拆成2个)情况,最后两种是为了表示一些特殊情况的,规格化的就是我们普通的小数,而非规格化的是用来表示0附近的数。

  • 规格化的:exp不为0或者255。用来表示一般的小数 image-20220113001130663
  • 非规格化的:exp为0。用来表示0和接近0的数字 image-20220113001147494
  • 无穷大:exp为255,且frac为0。用来表示无穷大 image-20220113001206224
  • NaN:exp为255,且frac不为0。用来表示Not a Number image-20220113001217802

接下来,我们一个一个探讨:

一、规格化的值

image-20220113001130663

这种情况下,阶码是exp的值再减去一个偏置,即E=eBiasE=e-Bias,其中e就是将exp的位模式看成是一个无符号数得到的,如果exp为k位,那么Bias就是2k112^{k-1}-1,比如单精度exp为8位,那么Bias就是2811=1272^{8-1}-1=127,而双精度为11位,那么Bias就是21111=10232^{11-1}-1=1023。也就是说,本来将exp看成是一个无符号数,那么规格化单精度的表示范围为1到254,而减去一个Bias之后,范围就变成了-126到127,双精度就是-1022到1023。

而对于尾数M,实际上M=1+f,也就是说有一个隐含的1,如果f的位模式为fn1,fn2,...,f0f_{n-1},f_{n-2},...,f_0,那么有:M=1.fn1fn2...f0M=1.f_{n-1}f_{n-2}...f_0,所以尾数M的范围总是在[1.0,2.0)[1.0,2.0)。而之所以我们有一个隐含的1,是因为我们总是以1.开头,那么就不需要进行存储了,这样就多了一位存储空间。

二、非规格化的值

image-20220113001147494

为什么需要非规格化的值呢,假设我们都使用规格化的方式(允许exp为0),那么绝对值最小的数就是E=127M=1.0E=-127,M=1.0的时候,也就是1.0×21271.0\times2^{-127},虽然这样很靠近0了,但是无法真正表示0,所以我们采用了非规格化的值(exp全为0的情况)。这时候,我们定义阶码E=1BiasE=1-Bias,而尾数M=fM=f,这时候,frac就可以被看成尾数,而没有一个隐含的1。其实无法表示0的原因就在于尾数总是有一个隐含的1,所以在非规格化的表示中把尾数定义为f是很合理的。

那么为什么阶码的定义不是像规格化中一样用e-Bias,而这时候e为0,于是E=BiasE=-Bias呢?

原因是我们希望能从非规格化的值平滑地过渡到规格化地值,使数值能够分布均匀地接近0。直观上理解一下,我们最小的规格化值是exp为00...01,那么E=1BiasE=1-Bias,而f全为0,这时候M=1.0,而使用非规格化的时候,M的最大值是比M小一点的,也就是0.111...,我们只要让E和规格化的最小值保持一致,也就是1Bias1-Bias,那么就能够保证规格化到非规格化的过渡是平滑的,如果这里难以想象,我们后面会用一个简单的例子帮助理解。

三、特殊的值

有时候,我们需要一些特殊的值,比如无穷大,和非数字(Not a Number)

  • 无穷大
    • 正无穷
    • 负无穷

image-20220113001206224

当exp全为1的时候,我们认为是无穷大,如果s为0则是正无穷,如果为1,则为负无穷。换句话说,当指数到达了上限(全1),且frac都为0的时候,我们认为这就是最大的数值了,当然这个值是没有具体含义的,只是一个规定而已。

  • 非数字

image-20220113001217802

很多时候,我们会得到一些非实数的值,比如计算 1\sqrt{-1} 这种根号下为负数的,我们数学中会得到复数的解,但是对于计算机而言,它就是一个非数字的(Not a Number),因此我们也需要表示这种情况。所以我们规定,当exp为全1,且frac不为0的时候,就是一个NaN。

2.4.3 数字示例

可能上面的内容有一些抽象,我们来举两个例子,当然,我们不会使用32位的,这样太复杂了,我们先使用6位的浮点数表示,其中阶码3位,尾数2位。于是偏置Bias=2311=3Bias=2^{3-1}-1=3。在数轴上画出这些可以表示的值之后,我们可以观察到,越向0靠近,点就越密集,而某几个点之间的间距是相同的。这是因为我们的指数是在改变的,而越往正负无穷靠近,指数越大,意味着两个点之间的间距就越大,因为指数相同时,两个点之间就等于他们的小数部分的差再乘以这个指数。也就是,指数相同时,这些点之间的间隔是相同的

image-20220113143434793

我们再来看一个8位的例子,其中阶码4位,尾数3位。于是偏置Bias=2411=7Bias=2^{4-1}-1=7。用表格表示一下上面的3类数:

image-20220113144933263

我们之前讲到过,我们为了使得非规格化数能够平滑地过渡到规格化数,于是使用了E=1BiasE=1-Bias,从上面的表格中我们就可以体会到,最大的非规格数与最小的规格化数之间的间隔和非规格化数之间的间隔是相等的。

2.4.4 舍入

由于浮点数的范围也是有限的,对于单精度就只有32位,对于双精度就是64位,因此,我们也会面临着舍入的问题,实际上有这么几种舍入的方式:

  • 向偶数舍入:向最接近的舍入
  • 向零舍入:也就是向0找最接近的值(正数往小的找,负数往大的找)。
  • 向下舍入:也就是向下找最接近的值(找比这个数小的)。
  • 向上舍入:也就是向上找最接近的值(找比这个数大的)。

image-20220113150018256

我们默认是使用了向偶数舍入这种方式,或者它也被叫做向最接近的值舍入。当然,我们也经常用一句四舍六入五留双来概括,接下来我们稍微解释一下。

比如我们有一个数7.8949999,我们只能保留小数点后2位,其他要舍去,如果采用向偶数舍入,那么小数点后第三位是4,要找最接近的,那就是7.89。如果是7.8950001 ,那么我们会舍入到7.90,这也就是五留双,如果保留7.89,那么最低的有效位就是奇数,而我们要求是双数,所以选择近一位7.90。同样,如果是7.8850000,那么和上面一样也是留双,就变成了7.88。

上面都是十进制的例子,那么如果是二进制的呢?二进制就更加容易判断了,因为要么是0要么是1,对于0我们肯定直接舍去,而对于1,就按照上面的留双来操作了,因为二进制的1其实就是十进制中2的一半,在小数中也就类似十进制中的0.5。

比如我们现在要舍入到小数点后2位,对于10.00011210.00011_2,我们发现第三位是0,那么就直接舍去,变成10.00210.00_2。对于10.00110210.00110_2,由于要留双,那么就是10.00210.00_{2}。如果是10.11100210.11100_2,那么就进位,变成11.00211.00_2

舍入就讲到这里了,还是比较简单的。

2.4.5 浮点运算

浮点乘法

浮点的乘法要简单一些,想一下,十进制的科学计数法做起乘法来是不是也挺简单的,而两者是相似的。

(1)s1M12E1×(1)s2M22E2=(1)sM2E其中,s=s1异或s2M=M1×M2E=E1+E2(–1)^{s1} M_1 2^{E_1} \times (–1)^{s2} M_2 2^{E_2}=(–1)^{s} M 2^{E} \\ 其中,s=s1异或s2,M=M_1\times M_2,E=E_1+E_2

由于M1×M2M_1\times M_2显然是可能大于2的,比如1.12×1.121.1_2\times1.1_2就大于2了,于是我们就需要将结果右移,然后在E上加1。同时,M也要通过舍入的方式保持精度不变。

浮点加法

浮点加法的话,就需要先对齐小数点,对齐小数点的方式就是左移和右移,如果左移那么就将阶码减去对应的位数,右移就相反。我们有两种对齐方式,一种是将指数大的对齐成指数小的,另一种则相反,但是一般来说,我们会选择将指数小的往大的上对齐,比如一个是2的12次方,一个是2的10次方,我们会选择改变后者的尾数(右移2位),然后指数变成12。

选择这种对齐方式是因为,如果把小的往大的上对,即便会把小的直接舍去了然后损失一些精度,但是这样的错误会很小。

反过来就不一样了,如果把大的往小的上对,那么尾数可能会变得很大,当尾数做加法的时候,可能会产生溢出,于是就可能正数变负数,这是不能容忍的。

2.4.6 C语言中的浮点数

对于C语言而言,进行浮点数和其他数据类型转换的时候可能会产生一些预期之外的情况,我们看几个例子:

假设x为int,y为float,d为double,判断一下下面这些是否成立:

  • x == (int)(float) x 否,因为float的frac部分只有23位,而int要32位
  • x == (int)(double) x 是,因为double的frac有52位
  • f == (float)(double) f 是
  • d == (double)(float) d 否
  • f == -(-f) 是
  • 2/3 == 2/3.0 否,前者为0,后者是浮点运算。
  • d < 0.0 ⇒ ((d*2) < 0.0) 是,即便溢出变成负无穷,也是小于0
  • d > f ⇒ -f > -d 是
  • d * d >= 0.0 是
  • (d+f)-d == f 否,浮点数加法没有结合律,可能因为精度问题把f舍去,结果就是0。

第二章就讲到这里啦~