C++基础教程
——
C语言部分卷1C语言基础语法
补充有符号整数

最新版本V2.0
王道C++团队
COPYRIGHT ⓒ 2021-2024. 王道版权所有

概述

Gn!

「 该节补充知识主要涉及一些计算机基本原理,并不会很困难,务必每一位同学都要掌握!」

对于任何开发语言而言,整型都是必须要使用的数据类型之一,对于C语言而言,整型数据类型包括short、int、long和long long四种以及它们的无符号整数形式。(当然char类型实际上也是整型的一种)

这些数据类型,都是以符合人类世界和自然世界的逻辑而出现的,说白了设计这些数据类型,是为了让人能更好得理解数据。但是计算机显然是不能直接识别这些数据类型的,因为计算机只能存储和处理二进制数据。

因此,符合我们人类思维的数据都要通过一定的转换才能被正确的存储到计算机中。

由于篇幅限制,这里仅讲解数据在计算机中的存储方式的一部分——有符号整数。C语言的整型基本数据类型中的无符号类型非常简单,取值范围为[0, 2存储位数-1]不再赘述,我们仅讲解一下有符号数的编码存储。

进制

Gn!

要想理解数据的存储,首先要明白最基本的进制概念。

什么是进制?

进制也就是进位制,是人们规定的一种进位方法。对于任何一种进制——X进制,就表示某一位置上的数运算时是逢X进一位。常见的进制有:

  1. 十进制,日常生活中应用最多的进制,逢十进一位,数字只能取值[0,9]之间,英文单词对应 Decimal在C语言代码中规定,一个数字字面量不加任何限定的情况下,默认就是十进制的。

  2. 二进制,计算机底层使用的数据存储方式,逢二进一位,数字只能取值[0,1]两个,英文单词对应 Binary 。在C语言代码中规定,一个数字如果以"0b"或"0B"开头(注意是零)时,它就是二进制的。

  3. 十六进制,十六进制的出现可以看成是二进制的补充,逢十六进一位,数字的取值除了十进制的0到9,还包括用A表示10,B表示11,依次推直到F表示15。英文单词对应 Hex

    在C语言代码中规定,一个数字如果以"0x"或"0X"开头(注意是零和字母X)时,它就是十六进制的。二进制是计算机理解数据的方式,对于人来说,虽然不难理解,但是用二进制表示数实在太长了,十分不方便。而直接用十进制,又不利于理解计算机思维,所以就出现了十六进制,既简洁又能体现计算机思维。比如对于二进制的一个数:11111111,转换成十六进制就是:FF,恰好四位二进制对应一位十六进制数。

  4. 八进制,介于十六进制和二进制之间,用途不是很广泛,了解一下即可。逢八进一位,数字只能取值[0,7]之间。英文单词对应 Octal在C语言代码中规定,一个数字如果以"0"(注意是零)开头时,它就是八进制的。

最后,进制之间还有一个非常重要的——转换问题,关于这一话题本小节就不进行扩展,大家可自行学习一下(如果你还不会的话)。

在这里,我推荐大家直接使用计算机自带的计算器(程序员版)去转换进制——手动转换太慢了,没必要!

有符号整数

Gn!

首先为了解释一下有符号整数的概念,我们需要知道以下前置知识点:

  1. 位(bit):计算机中数据存储的最小单位,每一位存储一个二进制数字,即0或1。

  2. 机器数:指数字在计算机内存中的表现形式或表示形式。由于计算机使用二进制系统,机器数通常表示为二进制数。根据是否可以表示负数,机器数可以分为有符号数和无符号数。

    1. 有符号数可以表示正数、负数或零。本文讲解的有符号整数就是指可以表示正数、负数和零的整数的机器数。

    2. 无符号数只能是非负的。

举个例子,比如我们可以规定计算机中存储一个数,最高位是符号位,0表示正数,1表示负数,然后再使用最高位以下的所有位来表示数值。这样我们就得到了一个有符号数的机器数表示形式:

这样,如果我们用8位来存储数+3,它的机器数就是00000011,而-3的机器数就是10000011。

这里的00000011和10000011都是机器数,而且是有符号整数。

  1. 真值和形式值:由于机器数有符号位,机器数的形式值(直接将二进制转换为十进制得到的值)并不等于它的真值。例如,机器数10000011的形式值是131,但它的真值是-3。

    形式值是直接二进制转换成十进制的一个整数,而真值需要考虑符号位、位数等因素。

用以上设计来表示机器数看似合理,但问题很明显:

  1. 一个十进制的整数1,表示为二进制有符号数就是 (0000 0001)

  2. 一个十进制的整数-1,表示为二进制有符号数就是 (1000 0001)

将上述两个数相加,十进制相加很容易得到结果是0,但二进制有符号数相加却是(0000 0001)+(1000 0001)=(1000 0010) 这个数按照刚才设定的规则转换为十进制是-2,这显然是不对的。

因此,计算机中不能直接用这样的规则来进行数的存储、计算。那怎么办呢?

为了说清楚这个问题,就需要引入计算机中有符号整数的三种表示形式:原码、反码、补码

所以这里又可以回答一个问题:什么是原反补码——它们是计算机当中,有符号整数(机器数的一种)的三种表示形式。

原码

Gn!

原码是有符号整数最简单的表示形式(就是我上面提到的规则),直接用最高位作为符号位,其余位都是数值位。如果是8位二进制:

[+1] = [0000 0001]

[-1] = [1000 0001]

原码是人脑最容易理解的机器数表示形式,无非就高位加一个符号位。但是我们上面已经说过了,如果计算机直接存储原码,进行计算将会出错,所以有符号整数还需要其它表示形式。

反码

Gn!

反码的含义就和它的名字一样,是将原码中的符号位之外的其他位取反(0变1,1变0)得到的。但需要注意:

  1. 正数的反码是其本身,不需要取反。

  2. 负数的反码是在其原码的基础上, 符号位不变,其余各个位取反。

举个例子,如果是8位的二进制数:

[+1] = [0000 0001] = [0000 0001]

[-1] = [1000 0001] = [1111 1110]

[-2] = [1000 0010] = [1111 1101]

可以看出:如果一个反码表示的是一个负数,那么人脑就无法直观的看出它的数值了。当然这不重要,毕竟机器数也不是给你看的,计算机能看懂就行。

在原码的小节中,我们发现直接用原码计算会出错,那么如果直接用反码计算呢?试一试:

使用反码计算
十进制运算反码计算反码计算结果(十进制)
1 +(-1)= 0[0000 0001] + [1111 1110] = [1111 1111] = [1000 0000]-0
1 +(-2)= -1[0000 0001] + [1111 1101] = [1111 1110] = [1000 0001]-1
-1 +(2)= 1[1111 1110] + [0000 0010] = [0000 0000](溢出) = [0000 0000]0(溢出归0

从上表可以看出,如果用反码直接进行计算,虽然有所改善,但仍然有问题,所以计算机中的运算也不能直接用反码。

这样就出现了补码的概念。

补码

Gn!

补码是在反码的基础上得出的,它是这么规定的:

  1. 正数的补码是其本身,不需要做改变。

  2. 负数的补码等于反码加上1。

举个例子,如果是8位的二进制数:

[+1] = [0000 0001] = [0000 0001] = [0000 0001]

[-1] = [1000 0001] = [1111 1110] = [1111 1111]

[-2] = [1000 0010] = [1111 1101] = [1111 1110]

可以看到,补码已经完全和真值没有什么二进制数值上的关系了,但是用补码进行计算,能够解决上面遇到的问题吗?

使用补码计算
十进制运算补码计算补码计算结果(十进制)
1 +(-1)= 0[0000 0001] + [1111 1111] = [0000 0000] = [0000 0000]0(溢出位舍弃
1 +(-2)= -1[0000 0001] + [1111 1110] = [1111 1111] = [1111 1110] = [1000 0001]-1
-1 +(2)= 1[1111 1111] + [0000 0010] = [0000 0001] = [0000 0001]1(溢出位舍弃

从上表可以看出,如果使用补码进行计算,就可以正常的进行加法算术运算了。

当然,有人可能会觉得我举的例子太少且比较简单,缺少代表性,那么你完全可以自己试一试。不过,你完全可以放心,利用补码计算一定不会有任何问题。

那么至此,我们要总结两个非常重要的结论:

  1. 正数的原码反码补码是一致的,负数的反码是原码除符号位取反,补码是反码+1。

  2. 计算机中有符号整数的存储方式都是以补码的形式存储的,运算也是以补码的形式计算的。

为什么使用补码?

Gn!

上面我们已经大致解释了这个问题,实际上主要就是以下两点原因:

  1. 因为对于原码、反码和补码三种存储方式而言,使用补码存储时,加法运算才不会出错。

  2. 用补码存储、计算,还使得加减法统一为了计算加法。

试想一下,如果使用补码存储整数,那么加减法都能够转换成加法进行,这样CPU就只需要设计加法器,而不需要专门设计减法器。结构的简化,不仅有利于实现,更能提升实际的运算效率。

现在,你已经懂得了原反补码是什么,以及它们分别如何得到,也知道了为什么要用补码。也就是说,如果只谈使用,对于有符号整数的理解,到此为止就可以了。

但如果你想知道其设计原理,可以继续往下看。

补码的设计原理

Gn!

讨论下补码的设计原理,没兴趣的同学可以跳过。不过我觉得从本质上了解补码的机制还是很有好处的。

为什么原码和反码不行?

Gn!

上面已经讲的很清楚了,使用原码和反码直接进行加法运算,由于错误的溢出和进位等,运算结果大概率是不正确的。所以我们需要另寻他法,这就是补码。

接下来,浅谈一下补码的原理。

补码为什么叫补码?

Gn!

要谈补码的原理,首先就要知道补码这个名字的由来。

原码比较好理解,真值加上符号位就是原码,原码基本就还是原先的值。反码是原码取反所以叫反码。

那么补码呢?为什么叫补码?这是因为补码的提出依赖于 "补数"的概念。

什么是补数?

在我们日常的生活和学习中,补数是一个常见而重要的概念:

一个时钟,当前指示的时间为六点,想要它指向三点,可以按顺时针方向将时针转九格,也可以逆时针方向转动三格,结果是一致的。

一天是24小时,但时钟上只有12个数,时钟是不会显示超出12时的时间的,比如显示15点,实际上指针指向的位置是3。

即对时钟而言,超出12点的时间就存在”溢出“概念,都必须减去12这个数,才是指针真实指向的位置。

想象一下,在时钟这个问题上,任何时候时针向顺时针转九格和逆时针转三格的效果都是一致的,我们将顺时针转向定义为做加法,逆时针转向定义为做减法。在这个案例中,就可以认为 -3 与 +9 是等价的。

那么我们在数学上,将本案例中的12定义成为模,写作(mod 12),而称 +9 是 -3 以 12 为模的补数记为:-3 ≡ +9(mod 12)

以此类,还有:

-4 ≡ +8(mod 12)

-5 ≡ +7(mod 12)

【以上截改自《计算机组成原理·第二版》唐朔飞著p221】

总结:

  1. 可以将模理解为一个足够大的正数,足够大到比你运算所有数字的绝对值都要大。比如在时钟的运算中,模就是12,因为在时钟的运算里不可能超过这个正数。

  2. 补数是与模紧密相关的另一个概念,在同一个模系统的前提下,两个互为补数的数是等价的。

Gn!

看到这里,你可能很疑惑,好好地为什么搞一个"补数"出来呢?

实际上,补数的意义是:在一个计算体系(模)中,补数的出现可以统一加法和减法为一种算法。

-4 ≡ +8(mod 12)也就是说,在时钟的体系中,-4 和 +8 是一个数,减去4就等于加上8,于是就能将加减法统一成加法或者减法。

明白这个道理后,那么把补数套用到计算机的机器数计算中,是不是也能够统一加减法呢?答案是肯定的,这就是补码的由来。

那么,看到这里你就已经明白了,补码是可以统一加减法的,但是具体到底是怎么得出来的呢?

补码为什么可以统一加减法

Gn!

数学意义上的加法和减法是有本质区别的,一个是让数变大,一个是让数变小,所以常规情况下加减法是不可能统一的,想要统一必须就要在特殊的场景中。

这时,我们就需要将眼光重新投到时钟上:

数轴在正常情况下是一条无限的直线,加减法的结果在数轴上的方向就不可能一致,那么:

如果数轴不是直线而是一个圆形,不就完全可能出现加一个数反而变小,减一个数反而变大的情况了吗?

于是,我们假设有一个圆形的数轴,取值范围是0~999,且规定999 + 1 = 0 而不是 1000。

这个1000就是这个数轴计算体系中的mod(模),那么减去200就可以变成加上800,减去300就可以变成加上700,只要减去的数和加上的数的绝对值和是模(1000),就可以统一加减法为加法。

规定把减去的正整数称之为"原码",与之对应加上的正整数称之为"补码"。计算过程如下:

700 - 327 = 373

327是"原码",计算补码:

1000 - 327 = 673

700 - 327 = 700 + 673 = 1373

因为我们已经规定了数轴是圆形而不是直线了,所以1373已经数值"溢出"了,1373真正表示的数应该是:

1373 - 1000 = 373

所以673是-327的"补码",运算结果也没问题。对上了!

于是我们得出结论:用减法减去一个数可以转换成加上这个数的"补码"。

上面的运算是基于十进制的,那么放在二进制里是否可行呢?

当然是可行的,因为计算机中机器数存储有位数限制,超过位数叫数值"溢出",不计算结果,也就是说固定位数的有符号数是一个天然的"圆形数轴"。

举一个简单例子,对于C语言中的short基本数据类型而言,一般而言它的取值范围是[-32768, 32767]

那么请问这个取值区间是一个数轴?还是一个圆环呢?

当然是一个圆环,因为short的最大值加上1就会出现数值溢出,就会成为short类型的最小值。

以上,就是补码能够统一加减法的原因。

补码设计体系的由来

Gn!

上面我们已经搞清楚为什么补码可以统一加减法,知道了补码设计的初衷,那么下面我们就一起来探究补码一整个设计体系的由来。

假如计算机中存储了一个8位二进制数(先看无符号数),最大取值位1111 1111,加上 1 就会出现数值溢出结果是0。

于是我们取mod为1 0000 0000,然后计算补码,那么整个8位二进制的减法,我们只需要求出补码就可以全部转换为加法了。

我们随便找一个数1011 0010去求补码,仍然按照上面的方法计算:

1 0000 0000 - 1011 0010 = 0100 1110

上述过程看起来没问题,但是不要忘记了我们的初衷:

我们搞出补码是为了舍弃减法,那么如果在求补码的过程中就使用了减法,那岂不是画蛇添足?

所以计算机在算补码时,不能通过减法去算,那怎么算?继续看:

1 0000 0000 可以看成 1111 1111 加 1

所以:

1 0000 0000 - 1011 0010 = 1111 1111 + 1 - 1011 0010 = 1111 1111 - 1011 0010 + 1 = 0100 1101 + 1 = 0100 1110

看到这里,你可能又疑惑了:不是说好了干掉减法,但还是有啊。

不要着急,在计算的步骤我们使用的1111 1111 - 1011 0010 实际上并不是一个普通的减法。

观察一下减数 1011 0010 和结果 0100 1101 有什么关系?是不是恰好每位都取反?

对了,这种特殊的减法就是计算机中的 "取反",得到的结果就是反码

所以在计算补码的过程中,也去掉了减法:用取反运算,取代了减法!

好了,看到这里,你已经知道了教科书里为什么教我们原码取反加1就是补码了吧?

到此为止,我们通过这种手段,统一了计算机中的二进制运算的加减法。

Gn!

那么一切就万事大吉了吗?当然没有,继续看:

为了简单起见,以十进制为例,二进制是一样的,仍以mod = 1000为例。计算结果如果是负数的话,就有麻烦了:

300 - 700 = -400

-700相对于mod 1000的"补码"是300

于是300 - 700 变成了 300 + 300 = 600

这样看结果似乎不对了,但仔细一想 -400 的"补码" 实际上就是 600,它们是等价的,看来结果是对的,只是结果都是需要用补码等价换算一下罢了。

但一个表达式中的数如果还不能统一形式,实在太难以理解了,如果人都会被难住,何况计算机呢?

所以,既然结果中出现了需要等价换算的补码,那么我们干脆做出规定:

整个表达式中的所有数都是补码的形式,这也就是计算机中存储的机器数都是补码的原因。

做出这种规定后,那么问题就又来了:

在上面这个案例中,我们只把减数变成了补码的形式表示,用于消除减法。被减数仍然是一个“原码”,现在要求表达式全部用“补码”表示,于是就需要做出换算,如下:

300 - 700 = -700 + 300 = -400 = 600

上述计算式就太离谱了啊,出错的原因也很简单:补码本身就是为了消除减法而生的,将负数变成正数。而如果这个数本身就是一个正数,补码应该和原码一样才对。

所以我们又必须规定: 正数的补码是它本身,只有负数的补码才是取反 + 1。

于是我们就有了以下两个消除减法,统一成加法的运算方式:

300 - 700 = 300 + 300 = 600 = -400

我们可以测试一个被减数是负数的例子,比如:

-300 - 300 = 700 + 700 = 1400 = 400 = -600

看到这里,作为这一整套体系的设计者,你洋洋得意,以为解决了问题,是不是已经开始欢呼雀跃了?

其实你高兴得太早了,观察一下上面两个表达式的结果补码(标红色的部位)600 和 400,请问:

你怎么知道补码 600 的原码是 -400 而不是正数600呢?补码400的原码是-600而不是正数400呢?

换句话说,-600的补码一定是400(因为补码就是要将负数变成正数),而400的补码就不一定是-600了(正数的补码可能就是它本身),还有可能就是400。

新的问题来了:

一个补码如果是正数的话,它的原码怎么确定是一个正数还是负数呢?

路是自己选的,那么多困难都解决了,这次也要解决问题,于是迎难而上吧。

出现这个问题的根源在于,缺少一种标志告诉我们结果的正负,如果能够像数字前带正负号一样,明确指示出数的正负不就可以了?

结合计算机的二进制表示,我们能不能在计算机存储数时,用1/0来表示数值的正负呢?总要试一试吧,于是就出现了有符号数,我们规定:

数值的最高位表示符号位,并且使用0表示正数,1表示负数。

有了符号位后,我们就可以通过结果的最高位判断:如果最高位是0,就表示正数,原码就是补码本身。如果最高位是1,就表示负数,原码就要做相应运算得到。

理想是很好的,但是能不能这么做还需要做测试,在具体测试之前,我们先梳理一下思路:

我们的目的是想通过加法来表示减法,从而统一加减法。

我们发明了一个叫做补码的东西,还规定以后所有的计算都用补码形式进行,正数的补码是它本身,负数的补码是取反 + 1。

但是现在这个东西产生了岐义,为了区分正负补码,我们决定:

一个机器数的最高位为符号位,它没有任何数值意义,仅用来代表机器码的正负,0表示正数,1表示负数。

现在最后需要做的就是测试了,首先我们要明确的是:

虽然最高位是符号位,但仍然要参与数值运算。并且我们之所以使用符号位,目的是为了区分正负数的补码,所以原码和补码的符号位必须是一致的(不然就白瞎了)。

所以我们必须规定: 负数在取反计算反码的时候,不能将符号位取反!

现在我们要规范一下原码、反码和补码的计算规则:

在固定存储位长得基础上,可以按照以下规则计算:

  1. 原码

    1. 其有效数字是该数绝对值的二进制表示。

    2. 最高位是符号位,如果是负数符号位是1,正数符号位是0。

    3. 有效数值和最高位之间如果有空隙,加0。

    4. 最高位是符号位,其它位都是数值位。

  2. 反码

    1. 正数的反码与原码相同。

    2. 负数的反码是对其原码逐位取反,但符号位除外。

  3. 补码

    1. 正数的补码与原码相同。

    2. 负数的补码是在其反码的基础上再加1。

注意:

  1. 只有原码的最高位是符号位,其它位是数值位。反码和补码的最高位能仍然表示正负,但后面的位和数值就没啥关系的。

  2. 计算机内部是用补码存储数值的,只要补码没有超出位数就可以了。

一个比较典型的例子就是:-128

我们都知道一个byte字节型所表示的整数的取值范围是[-128,127],所以-128是可以用8位存储的。

-128的原码是:1 1000 0000(超出位数,表示不了)

-128的反码是:1 0111 1111(超出位数,表示不了)

但是这并不妨碍补码:10111 1111 + 1 = 11000 0000 (最高位溢出舍去)= 1000 0000

所以你看,-128的补码是在8位范围内的,最高位1表示负数,后面都是0,但并不意味着它表示"负0",后面不是数值位。

总之,我们做了这么多规定,那么结果还正确吗?需要验证一番,可以分四种情况:

  1. 正数 + 正数 = 正数

  2. 正数 - 正数 = 正数

  3. 正数 - 正数 = 负数

  4. 负数 - 正数 = 负数

限于篇幅(我懒,这里就不放验证了),大家感兴趣可以去试一试。

当然你要能试出来不对,这个改进的重大任务就交给你了啊(笑,搞定了获得图灵奖应该问题不大

总结

Gn!

最后再总结一下吧:

  1. 我们搞这一套都是为了消除减法,简化CPU结构/指令集,提升计算机运行效率。

  2. 计算机中的所有机器数都以补码的形式存储,最高位不再是数值位,而是符号位,符号位用 0 表示正数,1表示负数。

  3. 正数的补码是它自身,负数的补码为绝对值取反加一(不带符号位取反)

  4. 计算机中只有加法,没有减法。减去一个数可以转换成加上减数的补码。

Gn!

以上,本节的主体内容就写完了。当然你还可以想一想其中的一些细节问题:

比如为啥符号位不是 0 表示负数,1表示正数呢?等等问题。

我在这里,就不再给出具体的解释了,大家可以自己想一想,查一查(如果你感兴趣的话)

Gn!

写完这东西还是比较有感慨的,人类的智慧真是无穷的,补码这一套东西的设计实在是太magic了。

当然还有一个映像深刻的道理就是:

如果想要在一套已存在的体系上(生活中的加减法)搞一套新体系(计算机中的加减法),首先是需要思维方式上的创新(考虑计算机中的运算,就要用计算机思维思考),其次在设计之初它一定是漏洞百出的,需要反复修改最终才能合理(迭代)。

就像你撒个谎,弥补漏洞远比撒谎本身要难。

The End

如有发现错误,欢迎联系老师更新!