暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

谈谈二进制(四)——原码、补码、反码、移码

杜秉轩的小站 2020-09-08
317

0. 概要

老规矩,先回顾一下前面三篇文章我们都讲了什么。
首先,第一篇【谈谈二进制(一)】我们从进制本身的意义开始,认识了二进制和其他进制,然后完成了十进制和其他各种进制之间的转换。
接着,第二篇【谈谈二进制(二)——四则运算】中,我们则通过十进制的四则运算原理,推导出二进制的四则运算。
上一篇【谈谈二进制(三)——位运算及其应用】,我们将二进制从纯数学的世界中带到了计算机的世界里,并通过一个真实的算法题,了解了二进制位运算在实际编程中的使用。
今天这篇,我们来看看二进制在计算机中的特殊表示。

1. 有符号数和无符号数

在讲各种码之前,先来熟悉一下两个概念:有符号数无符号数。这两个概念很好理解,就是字面的意思,一个数值是否有正负符号。
在上一篇文章中,按位取反的部分,我们在C++
代码中用unsigned
关键字定义了一个无符号数。无符号数的意思就是,这个数字存储在计算机中的时候是没有符号的,也就是正数;而有符号数则会把正负号也存入计算机中。但我们知道,计算机所有的数值都是由二进制组成的,那么正负号这种东西该怎么存储呢?
其实正和负这两个东西,就像布尔型的真和假一样,是两种截然相反的状态,因此正和负也可以使用0
1
来表示,计算机里实际也是这么做的:0
表示正,1
表示负。并且正负号在原数有效数的最前面(左边),所占的这一位被称为符号位。譬如+1001
,它的实际存储值就是0,1001
-1001
就是1,1001
,符号位在逗号的左边。这里的逗号,实际上是一种为了书写方便以及区别整数小数的约定俗成的写法,即整数的数值与符号位之间用逗号隔开,而小数的符号位和数值位之间则以小数点隔开,譬如-0.1001
,会写成1.1001
这种把正负号给数字化后的数值,被机器数,带正负号的原数被叫做真值
讲各种码之前,这里还要提一句,原码、补码、反码、移码这些码,都只是机器数的不同表示形式,就像前面我们讲过的进制,无论二进制还是十进制,对于同一个数值来讲,也只是不同的表示方法而已。

2. 原码

先来看原码,原码是众多码中最简单的一种机器数表示法,其实我们上面在说有符号位的符号的时候,就已经把原码的概念给讲完了,原码就是符号位和真值的绝对值组成的。它和真值之间仅仅是正负号被换成了0
1
,然后根据整数或小数加上,
,也就是上文中写到的那几个例子,这里简单罗列一下:
  
  
  
  
不过这里有几点我们要提一下,非常有意思。

第一个是整数负数的原码,上文中我们知道,就是把负号给换成1
,然后写的时候加一个逗号,举个例子,-1001(-9)
,原码表示为1, 1001
。这里我们做一个大胆的试验:把逗号去掉,然后把剩余的11001
看做一个完整的二进制正整数,也就是25
,看看它跟-1001(-9)
有什么联系。

从十进制上的数来看,乍看之下25
(-9)
好像没什么联系,然后我们来看二进制11001
-1001
,咦?似乎有点像,除了最高位的1
和负号之外,余下的4
位一模一样,我们把它俩相加:11001 + (-1001) = 10000
。嘿,这就更有意思了,熟悉二进制运算的朋友们肯定一眼就看出来,10000
实际上就是  。
们是不是可以从这个试验中猜测一个结论:一个负整数的原码,等于2
n
次方减去自己
?没错,这是一个正确的结论。这里的关键是,
n
是多少?上面的试验中我们的n
4
,这里的4
其实就是原数的位数。用数学表达式表达就是:当  (x为原数真值)时,则  。
正整数就没啥好说的,直接在真值绝对值前面加个0,
再来看看小数,因为小数的符号位和数值位用小数点隔开,因此当0 ≤ x < 1
时,也就是正小数时,它的原码就等于它自己。负小数呢?譬如-0.1001
,原码写成1. 1001
,也很简单,就是1. 1001 = 1 - (-0.1001)
原码就是这样,后面的这些数学运算规律其实不管也无所谓,因为原码本身实在是太简单易懂了。

3. 补码

补码就稍微复杂一点,它的基础原理涉及到了补数

的概念,这里我参考了唐朔飞老师的《计算机组成原理》中的一些概念和例子来讲解。

3.1 补数和模

补数的概念初次接触会觉得陌生,但它实际上可能就存在于我们的日常生活中。譬如,某一时刻时钟的某个指针指向6
,如果我们想让它指向3
,我们可以让指针沿逆时针方向走3
格,也可以让它沿顺时针方向能走9
格,最终都会让这个指针指向3
。假设顺时针方向为正,逆时针方向为负,则刚才的两个行为分别为:6 + (-3) = 3
6 + 9 = 15
我们知道,虽然我们日常采用24
小时制,但钟表上的刻度通常只有12
个刻度,因此指针超过12
时,就会回归1
2
等等。因此上面我们得到的15
,实际上是15 - 12 = 3
,也就是我们一开始所要达到的指向3
的目的地。所以这里15
3
在时钟上指向的都是3
,那么,在刚才的操作过程中,-3
+9
对时钟而言,作用是一样的,都是让原本在6
的指针,指向了3
上面的例子里,在数学上,称时钟的12
格刻度为
,写作mod 12
,而+9
-3
12
为模的补数
,记作 
或者说,对于模12
而言,-3
+9
是互为补数的。同理有 
即对于模12
而言,+8
+7
分别是-4
-5
的补数。可见,只要确定一个
,就可以找到一个与负数等价的正数来代替该负数,而这个得到的正数,即为负数的补数
,同时,我们观察到,该负数的绝对值,和它的补数相加,就是模(结论一)。
那么当我们有一个负数,且已知模时,如何求它对应的正数补数呢?很简单,只需要让负数和模相加(结论二),譬如-4 + 12 = 8 (mod 12)
,这个+8
就是-4
的补数了。
这就是模和补数的概念。这里大家可能会有疑问了:上面的几个式子和最后的结论,讲的都是负数的补数,那么正数有补数吗?
有的,但先别着急,我们先来看下,上面我们求得的负数的模到底有什么用。其实参考我们一开始讲的时钟的例子,我们隐隐约约可以感觉到,补数这个东西,可以让减法变成加法。依然来看一个例子,我们设A = 9, B = 5
,求A - B(mod 12)
。最通常的解法是减法: 
这么解肯定没问题,但我们题目中给出了mod 12
,那么对模12
而言,-5
可以用它的补数+7
代替,他们是恒等的,于是这题的另一个解法如下: 这样我们得到了16
,回想一下时钟,16
这个数字是不存在的,当时钟拨到16
时,其实是4
,所以对于mod 12
16
又等价于4
,即4 ≡ 16(mod 12)
。那么如果此时时钟再顺时针转一圈(12
格),到了28
呢?它依然是4
,即 
这也就是说,正数的补数,就是正数本身(结论三)。
前面讲进制时我们反复讲过一点,不同的进制之间仅仅是进位方式不同,所以上面我们虽然用的是十进制来介绍模,实际上二进制依然可以用模,结合模和补数的概念,以及上面的三个结论,我们可以得到如下二进制数的补数: 
用上面粗体的三个结论,很容易就能求得各种数字的补数,这里几个二进制我就不再计算验证了,大家可以自己试一下。
有了补数的概念后,人们看到了它可以将减法变成加法,便将其概念用到了计算机中,于是出现了补码这种机器数。

3.2 补码的定义

补码和原码一样,需要用一位数字在高位作为符号位表正负,而低位的数值部分则采用了补数的概念,因此,结合上面补数的特征,我们可以想象到,因为正数的补数是它自己,因此在补码中,正数的数值位是它自己,符号位则同原码一样,正数为0
,负数为1
。譬如下面两个例子: 

也就是说,正数的补码和原码一样。
负数就要麻烦一些。首先,通过上一小节我们知道,二进制的补数计算方式和十进制一样,譬如-1101
,它的最高位为第3
位,因此我们取比它高一位的最小值2424作为模,求得它的补数为10000 + (-1101) = 0011
。按理说,我们得到的负数的补数是正数,这里只需要再加一个符号位0
即可,但注意了,补码中的实际情况并不是这样,尽管负数的补数为正数,但负数的补码前的符号位依然是负符号位1
,即符号位与原数保持一致
实际上,在前面讲补数的时候我们提到过,补数的出现,可以让原本作减法的式子变成加法,补码这种机器数也正是看准了这一点才被计算机引入的。对于计算机来说,让减法变成加法,相当于让计算机只需要在硬件层面上实现一个加法器,就可以解决加减法两种问题。那么这里补码的符号位的问题,自然也是为了计算而设定如此的,我们来看一个实际的例子就知道了。
A = 1110,B = 1101
,我们来求A - B
的值。常规用减法,一眼可以看出来,结果是1110 - 1101 = 0001
。我们将A
-B(-1101)
两个数都换成补码,用加法计算,此时  ,  。注意了,在使用补码计算时,补码的符号位也参与计算,那么此时这两个补码相加后的结果为: 
我们看到,结果的符号位产生了进位,变成了10
。这里其实涉及到了另一个知识点:补码运算的符号位进位(溢出)。由于篇幅关系,本文不对该知识点展开讲解,大家有兴趣的可以去查找资料了解一下。总之,这里符号位虽然进位了,但并没有发生溢出,我们把符号位的最高位1
拿掉,得到了最终的结果0,0001
,也就是+0001
,与之前的减法结果一致。
这就是负数求补码后让符号位也保持负值1
的原因了。上面讲的都是负正数,实际上负小数同理,这里不再赘述,我们直接上两个数学公式来对负数求补码进行一个总结。首先是整数,正整数就不说了,和原码一样,负整数呢?除了上面用标准的补数算法外,我们可以直接用下面这个公式来计算: 
这里的n
就是整数x
的位数。拿前面我们算过的-1101
举例: 
最高位1
就是符号位,与后面的数值位部分用逗号隔开,就得到了最终的结果。其实这里我们可以观察到一个规律:负整数的补码,就是原数字除符号位外,数值部分的每一位按位求反后再+1
接着是负小数: 
看上去更简单了,举个例子,我们求-0.1001
的补码: 
其实这里求负小数的补码的方式,和上一节中我们求小数的补数的方式是一样的。还记得上一节的结论二吗?求一个负数的补数,就是让负数和模相加。然后我们再仔细观察一下,会发现上面负整数的求补规律在负小数也奏效,即负数的补码,就是原数字除符号位外,数值部分的每一位按位求反后再+1
,然后符号位该怎么写怎么写
。记住这个结论,它能让我们在求补码的时候避免加减法运算,更加容易。
补码的概念基本就讲完了,想要掌握的话,其实还是需要自己拿笔算一算,写一写。

4. 反码

反码,这种机器数的主要场景用于原码和补码的相互转换,反码作为中间数过度使用。首先,正数的反码,老样子,依然和原码一样,符号位0
1
表示正负,然后是数值位部分原封不动,这里直接拿前面原码里的两个正数例子: 

有区别的依然是负数,但反码的负数就简单多了:除了符号位,数值位部分每一位都按位取反。因此:

非常简单对不对?那么为什么说反码是原码和补码之间相互转换的中间过渡呢?在上一节补码的最后,我们得到了一个求补码的简单规律:负数的补码,就是原数字除符号位外,数值部分的每一位按位求反后再+1
,然后符号位该怎么写怎么写
。其实这个过程,就是对原码先求反码再给末位
+1
。同理,如果我们知道了一个补码,想要求它的原码,只需要先将补码的末位-1
后,再将数值位部分按位求反即可
好了,明白了这一点后,我们来看一个上一篇文章中遗留的问题。

4.1 按位取反运算

在上一篇文章【谈谈二进制(三)——位运算及其应用】中的取反运算部分,我们对有符号数5
进行取反,却得到了-6
这个匪夷所思的答案,当时我在文章中说,这是因为计算机中存储数字的方式都是用补码存储,之所以会得到负值,则是由于补码的最高位表示符号位,按位取反时连符号位一起取反了。今天我们深入了解了补码和反码,也推出了补码和原码之间的转换方式,那么就用今天的知识,来彻底解决这个按位取反的问题吧。

首先,5
的二进制为101
,其补码为0,101
。在进行按位取反操作,即~5
时,实际上把符号位也取反了,于是我们得到了1,010
这个结果,请注意,这个1,010
依然是补码。而从符号位的1
我们看到,这是一个负数补码,我们通过补码不能直观地看出它的真值,因此需要先转换成原码。按照前文我们提到的补码转原码的方式,先将1,010
末位-1
,得到1,001
,接着,我们将数值位按位取反,符号位不动,得到1,110
。此时,1,110
已经是原码了,可以直接将其求出: 

于是,我们得到了这个-6

这个让我们之前摸不着头脑的答案,在我们学完了补码之后,变得清晰明了。

除了这个-6
之外,按位取反的部分还有一个小问题,我们后来又使用了一个无符号数5
来进行按位取反,发现得到了一个巨大的数字4294967290
,并且这个数实际上是  :

0000,0000,0000,0000,0000,0000,0000,0101 // 5
1111,1111,1111,1111,1111,1111,1111,1010 // 4294967290

这个问题其实可以有两种角度去理解,一种是最直观的:因为无符号数不涉及符号位的问题,而C
语言中(这个数字是用C++
求得的)unsigned int
的取值范围在32
位机器上为0 ~ 4294967295
,没错,最大值就是上面的两个数之和  ,也就是32
1
。所以将5(101)
左侧不存在的0
都取反为1
后,就得到了这么大的一个数字。

第二种角度就是来解释-6
这个巧合了。我们只需要把  看做一个,按照前面计算补数的运算方式,来求-6
的补数,即让模和-6
相加,就得到了4294967290
这个数字,同时,4294967290
是无符号数5
按位求反的结果,它们相加的和,则是unsigned int
的最大值(32
位,  ),本身就比  小1
1 + 5 = 6
,于是就很凑巧的变成了我们看到的规律。

5. 移码

补码对计算机来说是个好东西,毕竟它让计算机对于数字的计算变得更加方便了。但对人类来说,补码可就不那么友好了,不说和真值,哪怕和原码相比,补码也存在着一个巨大的缺点:数字本身的被补码隐藏,使得数字的大小不直观。举个例子: 
 如果我们把符号位的1
也看作是整个二进制数的一部分,那么显然10001 > 01111
。虽然在我们懂得了补码的原理和运算过程后,我们并不会直接这么去比较,但因为负数的补码将数值部分都给改写了,总体来说,补码依然给人以非常不直观的感受,一旦逗号忘记写,或者其他原因造成了误读,那么补码之间的比较将成为灾难。就上面的两个数来说,我们很难一眼就看出来这两个二进制数居然互为相反数,但如果是用原码表示,那么起码他们的数值位是相同的,我们能第一时间反应过来。
此时,如果我们将两个数字的真值都加上  ,这里的n
同前面我们讲过的那些n
一样,是真值的位数,那么新得到的两个数字的大小就变得很直观了:
  
  
尽管还是无法直接看出它俩的真值互为相反数,但我们可以很直观地比较出这两个数字的实际大小了,不会因为符号位的问题造成误读。而这两个计算后得到的新数字,就是移码
所以移码的数学定义非常简单: 
这里的n
就是真值的位数,x
则是真值。
我们仔细观察上面两个数字的补码和移码,会发现一个有趣的现象:移码实际上就是改变了补码的符号位,从0
1
,从1
0
。这样一来,当我们知道了一个数的补码后,只需要替换它的符号位,就可以得到移码,从而与其他数值进行直接比较了。

6. 总结

今天我们了解了二进制数的四种机器数表示方式,并且解决了上一篇文章中遗留的小问题。在下一篇文章里,我们将认识定点数浮点数,对,就是我们编程中非常熟悉的那个浮点数float
文章转载自杜秉轩的小站,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论