最近重新学习CPU体系结构,对使用二进制补码原理来消除带符号数和无符号数计算差异,以及整合减法运算器到加法运算器,从而简化CPU硬件设计的原理很感兴趣,所以特地思考了下,查看了一些网上关于two's complement的文章,但大部分还是太过学术,经过整理,我想以一种比较简洁的方式表达出来。为了简单起见,我使用了4位字长的寄存器作为例子,32位和64位道理一样。想了解补码更为科学的数学原理可以参考wikipedia关于one's complement、two's complement的相关文章。
硬件设计以简洁为目标,所以整数的运算最好只有加法,而且不用对符号位进行特殊处理,能达到这个目标吗?当然可以,那就是使用补码(two's complement),所谓补码其实是针对带符号数来说的,其意思就是正数使用原码,而负数使用2的字长的指数减负数的绝对值表示,即x = pow(2, word_length) - abs(x),这个补码的简单计算方法就是我们计算机书中常说的,将x绝对值取反加1。现在你知道补码的真正计算方法了吧。为什么要将负数表示成这样呢?这是有数学原理的,这正是本文需要阐释的内容,充分了解后对CPU常用指令编程就打下了坚实的基础(general purpose instructions都是针对整数的),以后可能还会增加关于浮点数计算规范的文章。
现在我们来看一个减法:
7 - 6 (式1)
能把它变成一个加法吗?我来试试:
7 + (16 - 6)- 16 (式2)
16是4位寄存器最小的溢出数(2**4 **表示pow运算),以上两个式子是完全等价的,在我们看来比较繁琐的第二个式子却正是CPU内部整数计算单元所采用的方法,由于一些特殊原因,CPU只需要计算第一个加法,其余两个减法分别由编译器、人或寄存器自动截断完成了。
经过前面的叙述,我们知道了16 - 6就是-6在4位字长机器上的补码,这步计算一般是编译器完成的,将负数直接存储为补码形式,这里是1010。我们来看看CPU如何计算:
0 1 1 1 (7)
+ 1 0 1 0 (10)
----------- ----------
1 0 0 0 1 (17)
以上式子完成了式(2)中前两步计算,还需要减16才能得到正确结果,神奇的地方到了,因为机器是4位字长,所以第五位1直接丢弃掉了,就是溢出,这相当于自动减了16,所以最后结果就是0001,等于1,完成了式2个所有计算,得到了正确结果,现在你应该明白了为什么会选择最小溢出数所为补码转换中的被减数了吧,就是为了完成自动溢出,从而实现最后的减法。
再来看看2个负数相加,看看CPU是如何把它们当纯粹二进制运算而结果却丝毫不差的:
(-6) + (-7) (式3)
依据上面的规律换成如下式子
[(16 - 6) - 16] + [(16 - 7) - 16] (式4)
其中(16 - 6)和(16 - 7)部分已经由编译器完成,就是对应负数的补码,让我们来看看CPU的计算内容:
1 0 1 0 (10)
+ 1 0 0 1 (9)
----------- ------
1 0 0 1 1 (19)
式4中还需要减2个16,这里第5位已经自动溢出减了一个16,我们还要减一个16才能得到正确结果,可是寄存器中结果0011,光凭这个结果,我不知道这到底是最终值还是还需要减16,这可太糟糕了,产生这个问题的原因是如果使用全部4位寄存器存储值时,会产生带符号数二进制歧义问题,打个比方,-9用二进制补码表示是(16 - 9),二进制为0111,居然和整数的7是一样的,光凭这串二进制我无法知道它是-9还是7,好吧,我确实聪明,想到了一个办法,嘿嘿,让我们来看看4位寄存器能存储的二进制有哪些:
0 0 0 0
0 0 0 1
0 0 1 0
0 0 1 1
0 1 0 0
0 1 0 1
0 1 1 0
0 1 1 1
1 0 0 0
1 0 0 1
1 0 1 0
1 0 1 1
1 1 0 0
1 1 0 1
1 1 1 0
1 1 1 1
我可以将最高位数字当作解释数字符号的标志,如果是0我就当正数解释,如果是1我就当负数解释,当正数解释后不用再减16,直接就是最终结果,而如果是负数则还需要减个16才是最终结果,因为我们是用16-x来表示-x的,正好正数负数对半(假设0是正数),再回到上面那个问题,(-6) + (-7)CPU寄存器中最终为0011,我当应该当正数解释,正数不用减16,所以最后等于3,不对!应该是-13,还需要减个16才对,可我们刚说了正数不用减。到底哪里出问题了?大家思考下。
原来如果我们将二进制用以上阐述的方式解释,决定了4位二进制表示的数的范围只能是[-8~7],实际上寄存器如果从左端溢出的话,其值是在[...0\1\2\3\4\5\6\7\-8\-7\-6\-5\-4\-3\-2\-1\0...]不断循环的,也就是说刚才的-13从-8向左数5位,又循环回到了3,我们必须有办法判断溢出情况,如果我们把寄存器中的二进制当无符号数解释,那很简单,只要最高为产生进位那就溢出了,可如果当带符号数解释,如何看最后的值是否溢出呢?
这是个补码数学原理的精髓所在,有了这个推理,CPU才能做到同等处理带符号数和无符号数,我们来仔细分析下数学上的原理,在CPU看来寄存器中的就是纯粹的二进制,相当于无符号数,如果两个数相加时,最高位产生了进位,则表示结果肯定位于[16~30],如果次高位向最高位产生了进位则表示结果低3位相加结果范围位于[8~14],由于最高位溢出被丢弃,表示对最终结果减了16,而次高位向最高位产生进位,表示最终结果最小为8,现在就有如下几种结论:
(1)最高位有进位,次高位有进位,则最终结果位于[-8~6]
(2)最高位有进位,次高位无进位,则最终结果位于[-16~-9]
(3)最高位无进位,次高位有进位,则最终结果位于[8~14]
(4)最高位无进位,次高位无进位,则最终结果位于[0~7]
而我们带符号的解释方法,决定了数的范围为[-8~7],怎么样一眼就看出该如何判断带符号数计算是否溢出了吧!
然我们来看看CPU EFLAGS寄存器中最常用的6个标志位CF,PF,AF,ZF,SF,OF,我只解释CF和OF,其余的4个都很好理解,CF表示两个操作数进行二进制整数计算时最高位发生了进位,很显然可以用来判断无符号数是否溢出,而OF是寄存器次高位是否向最高位发生进位的标志(进位1,否则为0)与CF位的XOR值,是不是很神奇,就是我们最后阐述的四项规则,正好用来判断带符号整数是否溢出。
是不是无法想象在一般书上一笔带过的整数计算用的补码规范后面却隐藏了这么多原理,正是这些特性,决定了处理器设计时采用二进制补码进行整数计算,他使带符号数和无符号数的加减运算全部用无符号数加法运算实现,使电路实现大为简化,增加了处理器效率,减少了设计制造成本。但整数的乘法/除法运算却无法这样处理,这就是为什么有带符号的乘除指令而加法和减法却没有,从一定意义上讲,其实减法指令只是加法指令的一个包装,因为CPU内部没有减法逻辑,只有加法。