C语言学习(九)整数在内存中是如何存储的?数值溢出的本质是什么?从源头了解奇怪的整数输出问题
目录
关于计算机中的加法和减法,设计计算机时是如何简化硬件电路的?
加法和减法是计算机中最基本的运算,计算机时时刻刻都离不开他们。所以他们由硬件直接支持。为了提高加减法的运算效率,硬件电路要设计的尽量简单。
对于有符号数,人脑很容易区别。但对于计算机来说,内存要区分符号位和数值位,这就要设计专门的电路,增加了硬件的复杂性,增加了计算时间。如果能把符号位和数值位等同起来,让他们一起参与运算,不特意区分他,这样硬件电路就简单多了。
加法和减法可以合并为一种运算,就是加法运算。因为减去一个数相当于加上这个数的相反数,例如,5-3等价于5+(-3),10-(-9)等价于10+9。
相反数指的是数值相同,符号不同的两个数。如,-3和3是一对相反数,-9和9也是一对相反数
如果能实现上面的目标,那么只要设计成一种简单的、不用区分符号位和数值位的加法电路,就能同时实现加法和减法运算,并且高效。实际上这已经实现了,真正的计算机电路就是这么简单。
然而,简化电路是有代价的,这个代价就是有符号数载存储和读取时都要经过一定的转换。那么,这个转换过程是怎样的呢?下面我们来说说。
原码、反码、补码
我们先记住几个概念。
原码
将一个整数转换成二进制形式,这个二进制就称为这个整数的原码。如short a = 6;
,a的原码就是0000 0000 0000 0110
,更改a的值a = -18;
,此时a的原码变成了1000 0000 0001 0010
。原码就是一个整数的二进制表示。
反码
正数和负数的反码不一样。
对于正数,他的反码就是他的原码(原码和反码相同);而负数的反码是将除了符号位以外的所有位取反,也就是0变成1。如short a = 6;
,a的原码和反码都是0000 0000 0000 0110
;如果更改a的值a = -18;
,那么此时a的反码是1111 1111 1110 1101
。
补码
正数和负数的补码也不一样。
对于正数,他的补码就是他的原码(原码、反码、补码都相同);负数的补码是其反码加1。如short a = 6;
,a的原码、反码、补码都是0000 0000 0000 0110
;更改a的值为a = -18;
,此时a的补码是1111 1111 1110 1110
。
总结
可以认为,补码是在反码的基础上打了一个补丁,进行了一个修正,所以叫“补码”。
原码、反码、补码只对负数有实际意义,对于正数,他们都一样。
至于,为什么需要这几个概念,我们将在后文解释。
先让我们总结一下6和-18从原码到补码的转换过程:
另外,有一点很重要。在计算机内存中,整数一律采用补码的形式来存储。这意味着,当读取整数时还要采用逆向的转换,也就是将补码转换成原码。将补码转成原码就是先减1,再将数值位去反。
采用补码存储是如何简化硬件电路的?
我们现在假设6和18都是short类型的(在内存中占有2Byte,也就是16B),先计算6-18的结果,根据我们上面说的,他等价于6+(-18)
如果我们用原码直接计算,那么运算过程为:
6 - 18 = 6 + (-18)
= [0000 0000 0000 0110]原 + [1000 0000 0001 0010]原
= [1000 0000 0001 1000]原
= -24
我们可以看出,用原码表示整数,让符号位也参与运算,对于类似上面的减法来说,结果是不正确的。
经过人们的探索,后来设计出了反码。下面演示了反码的运算过程:
6 - 18 = 6 + (-18)
= [0000 0000 0000 0110]反 + [1111 1111 1110 1101]反
= [1111 1111 1111 0011]反
= [1000 0000 0000 1100]原
= -12
这样计算结果就正确了。
然而这样还不算结束,我们现在将减数和被减数换一下位置,计算一下18-6的结果:
18 - 6 = 18 + (-6)
= [0000 0000 0001 0010]反 + [1111 1111 1111 1001]反
= [1 0000 0000 0000 1011]反
= [0000 0000 0000 1011]反
= 11
按照反码的计算结果是11,而真实结果应该是12,他们相差了1。
注意,蓝色的1是加法运算过程中的进位,他溢出了,内存容纳不了,所以直接截掉
6-18的结果正确,而18-6的结果就不正确,相差1.按照反码来算,是不是小数减大数正确,大数减小数就不对,始终相差1呢?
是这样的,不妨我们再看看其他的例子:
5 - 13 的运算过程为:
5 - 13 = 5 + (-13)
= [0000 0000 0000 0101]原 + [1000 0000 0000 1101]原
= [0000 0000 0000 0101]反 + [1111 1111 1111 0010]反
= [1111 1111 1111 0111]反
= [1000 0000 0000 1000]原
= -8
13 - 5 的运算过程为:
13 - 5 = 13 + (-5)
= [0000 0000 0000 1101]原 + [1000 0000 0000 0101]原
= [0000 0000 0000 1101]反 + [1111 1111 1111 1010]反
= [1 0000 0000 0000 0111]反
= [0000 0000 0000 0111]反
= [0000 0000 0000 0111]原
= 7
那么相差的这个1要进行纠正,但是又不能影响小数减去大数,怎么办呢?于是人们又设计出了补码,给反码打了一个“补丁”,终于把相差的1给纠正过来了。
下面演示按照补码的计算过程:
6 - 18 = 6 + (-18)
= [0000 0000 0000 0110]补 + [1111 1111 1110 1110]补
= [1111 1111 1111 0100]补
= [1111 1111 1111 0011]反
= [1000 0000 0000 1100]原
= -12
18 - 6 = 18 + (-6)
= [0000 0000 0001 0010]补 + [1111 1111 1111 1010]补
= [1 0000 0000 0000 1100]补
= [0000 0000 0000 1100]补
= [0000 0000 0000 1100]反
= [0000 0000 0000 1100]原
= 12
5 - 13 = 5 + (-13)
= [0000 0000 0000 0101]补 + [1111 1111 1111 0011]补
= [1111 1111 1111 1000]补
= [1111 1111 1111 0111]反
= [1000 0000 0000 1000]原
= -8
13 - 5 = 13 + (-5)
= [0000 0000 0000 1101]补 + [1111 1111 1111 1011]补
= [1 0000 0000 0000 1000]补
= [0000 0000 0000 1000]补
= [0000 0000 0000 1000]反
= [0000 0000 0000 1000]原
= 8
从上面我们可以看到,采用补码的形式运算刚好把相差的1纠正过来,也没有影响小数减去大数。
小数减去大数,结果为负数,之前(负数从反码转换成补码要加1)加上的1,后来(负数从补码转换为反码要减1)还要减去,正好抵消,所以不受影响。
而大数减小数,结果为正数。之前(负数从反码转换成补码要加1)加上的1,后来(正数的补码、反码相同,从补码转换为反码不用减1)读取时就没再减去。这也就相当于给最终计算结果加了个1。
补码这种设计,达成了我们之前说的目标。简化了硬件电路。
上篇问题解析
我们之前提到,有符号数以无符号形式输出、无符号数以有符号形式输出时,会得到一个奇怪的值,如下:
#include <stdio.h>
int main()
{
short a = 0100; //八进制
int b = -0x1; //十六进制
long c = 720; //十进制
unsigned short m = 0xffff; //十六进制
unsigned int n = 0x80000000; //十六进制
unsigned long p = 100; //十进制
//以无符号的形式输出有符号数
printf("a=%#ho, b=%#x, c=%lu\n", a, b, c);
//以有符号数的形式输出无符号类型(只能以十进制形式输出)
printf("m=%hd, n=%d, p=%ld\n", m, n, p);
return 0;
}
运行结果:
a=0100, b=0xffffffff, c=720
m=-1, n=-2147483648, p=100
其中,b、m、n的输出结果看起来非常奇怪。下面我们来分析一下为何会出现这种结果。
b是有符号数,他在内存中的存储形式(补码)为:
b = -0x1
= [1000 0000 … 0000 0001]原
= [1111 1111 … 1111 1110]反
= [1111 1111 … 1111 1111]补
= [0xffffffff]补
而%#x
表示以无符号的形式输出,无符号数忽略了符号位,并且原码、反码、补码相同,所以不需要转换,直接输出0xffffffff即可。
m、n是无符号数(所有位都是数值位),他们在内存中的存储形式为:
m = 0xffff
= [1111 1111 1111 1111]补
n = 0x80000000
= [1000 0000 … 0000 0000]补
%hd
和%d
表示以有符号的形式输出,所以他们需要一个逆向转换的过程:
[1111 1111 1111 1111]补
= [1111 1111 1111 1110]反
= [1000 0000 0000 0001]原
= -1
[1000 0000 … 0000 0000]补
= -232
= -2147483648
所以-1和-2147483648才是最终的输出值。
注意,此处[1000 0000 … 0000 0000]是一个特殊的补码,无法按照我们现在所说的方法转换为原码,所以计算机直接规定这个补码对应值就是-231,至于为什么,我们接着说。
溢出的概念
short、int、long是C语言中常用的三种类型,分别称为短整型、整型、长整型。
在现在操作系统中,short、int、long的长度一般分别是2、4、4或者8,他们只能存储有限的数值。当数值过大或过小时,超出的部分会被直接截掉,数值就不能正确存储了,我们将这种现象称为溢出(Overflow)。
要想知道数值什么时候溢出,就要先知道各种整数类型的取值范围。
无符号数的取值范围
对于无符号数,他的取值范围很容易计算,将内存中的所有位(Bit)都置为1就是最大值,都置为0就是最小值。
以 unsigned char 为例,他的长度是1Byte,占用8位的内存。当所有位都置为1时,他的值为28-1 = 255,所有位都置为0时,他的值为0。所以,unsigned char 的取值范围是0~255。
此处提一下,28-1这个公式是如何来的。最大值在内存中的表现为 1111 1111。按照传统的算法,应该是20+21+22+23+24+25+26+27 = 1 + 2 + 4 + 8 + 16 + 32 + 64 + 128 = 255。如果这种数据类型占用字节比较多,用这种方式可能要算半个小时。我们就换个思路,先给1111 1111加上1,再减去1,所以最后式子就是28-1 = 255。
同理,我们可以算出其他类型无符号数的取值范围(括号内为假设的长度,实际根据系统的区别也会有不一样的长度):
unsigned char | unsigned short | unsigned int(4Byte) | unsigned long(8Byte) | |
---|---|---|---|---|
最小值 | 0 | 0 | 0 | 0 |
最大值 | 28-1 = 255 | 216- 1 = 65,535 ≈ 6.5万 | 232 - 1 = 4,294,967,295 ≈ 42亿 | 264 - 1 ≈ 1.84×1019 |
有符号数的取值范围
有符号数以补码形式存储,计算取值范围也要从补码入手。我们以char类型为例,从下表中找出取值范围:
补码 | 反码 | 原码 | 值 |
---|---|---|---|
1111 1111 | 1111 1110 | 1000 0001 | -1 |
1111 1110 | 1111 1101 | 1000 0010 | -2 |
1111 1101 | 1111 1100 | 1000 0011 | -3 |
… | … | … | … |
1000 0011 | 1000 0010 | 1111 1101 | -125 |
1000 0010 | 1000 0001 | 1111 1110 | -126 |
1000 0001 | 1000 0000 | 1111 1111 | -127 |
1000 0000 | – | – | -128 |
0111 1111 | 0111 1111 | 0111 1111 | 127 |
0111 1110 | 0111 1110 | 0111 1110 | 126 |
0111 1101 | 0111 1101 | 0111 1101 | 125 |
… | … | … | … |
0000 0010 | 0000 0010 | 0000 0010 | 2 |
0000 0001 | 0000 0001 | 0000 0001 | 1 |
0000 0000 | 0000 0000 | 0000 0000 | 0 |
这样列出来,很容易就能发现最大值和最小值。
-128数值的这一行是我要重点说明的。如果按照传统的由补码计算原码的办法,那么1000 0000是无法计算的,因为计算反码时要减去1,1000 0000需要向高位借位,而高位是符号位,不能借出去。
那么是不是就该把1000 0000作为无效的补码直接丢弃呢?并不是这样。计算机规定,1000 0000这个特殊补码就表示-128。
那么为什么偏偏是-128而不是其他数呢?
首先,-128使得char类型的取值范围保持连贯,中间没有“空隙”。
其次,我们再按照传统的办法计算一下-128的补码:
- -128的数值位原码是1000 0000,共八位,而char类型数值位只有七位,所以最高位的1会覆盖符号位,数值位剩下000 0000。最终,-128的原码为1000 0000
- 接着很容易计算出反码,为1111 1111
- 反码转换为补码时,数值位要加1,变为1000 0000,而char的数值位只有七位,所以最高位的1会再次覆盖符号位,数值位剩下000 0000。最终求得-128的补码为1000 0000。
-128从原码转成补码的过程中,符号位被1覆盖了两次,而负数的符号位本来就是1,被1覆盖多少次也不会影响数字的符号。
我们虽然从1000 0000这个补码推算不出-128,但是从-128能推算出1000 0000这个补码。
负数在存储前要先转换为补码,从-128推算出补码1000 0000这一点很重要,这意味着-128能正确的转换为补码,或者说能够正确的存储。
另外,我们观察上面那个表,发现在char取值范围内只有一个0值,并没有+0和-0的区别,并且多存储了一个特殊值,就是-128,这也是采用补码的另外两个小小优势。
按照上面的方法,我们可以计算出所有有符号数的取值范围(括号内为假设长度):
char | short | int(4Byte) | long(8Byte) | |
---|---|---|---|---|
最小值 | -27= -128 | -215 = -32,768 ≈ -3.2万 | -231 = -2,147,483,648 ≈ -21亿 | -263 ≈ -9.22×1018 |
最大值 | 27 - 1= 127 | 215 - 1 = 32,767 ≈ 3.2万 | 231 - 1 = 2,147,483,647 ≈ 21亿 | 263 - 1≈ 9.22×1018 |
之前我们还说到[1000 0000 … 0000 0000]补这个int类型的补码为什么对应的数值时-231,这种情况与char同理。
数值溢出
char、short、int、long的长度是有限的,当数值过大或者过小时,有限的几个字节就不能表示了,这样就发生了溢出。发生溢出时,输出结果一般会表现的很奇怪,如:
#include <stdio.h>
int main()
{
unsigned int a = 0x100000000;
int b = 0xffffffff;
printf("a=%u, b=%d\n", a, b);
return 0;
}
运行结果:
a=0,b=-1
由于a时unsigned int类型,长度为4个字节,能表示的最大值为0xffffffff,,而0x100000000 = 0xffffffff+1,占用33位,超出了a所能表示的最大值,所以发生了溢出。这导致了最高位的1被截去,剩下的32位都是0。也就是说,a被存储到内存后就变成了0,printf从内存中读取到的也是0。
变量b是int类型有符号数,在内存中以补码形式存储。0xffffffff的数值位的原码为1111 1111 … 1111 1111共32位,而int类型数值位只有31位,所以最高位的1会覆盖符号位,数值位只留下了31个1,所以b的原码为:
1111 1111 … 1111 1111
而这也是b在内存中的存储形式
当printf读取到b时,由于最高位是1,所以会被判定为负数,要从补码转换为原码:
[1111 1111 …… 1111 1111]补
= [1111 1111 …… 1111 1110]反
= [1000 0000 …… 0000 0001]原
= -1
所以b的输出结果为-1。
现在我们对于我上面说的“覆盖”可能还是一知半解,如果他不止33位呢,34位会怎么样?先总结一下我尝试的结果:
- 无符号的整数在发生数值溢出时会直接截去溢出的值
- 有符号的整数类型在发生数值溢出时,会先截去溢出的部分。剩下的部分如果最高位是0(当成正数),则正常的存储和运算。如果最高位是1,则最高位与符号位做与运算(1&1=0,0&1=1,1&0=1),然后取结果当成最高位后存储在内存中(当补码的形式存储),获取的时候要先转换原码。
我们举几个例子,拿int(32Bit)来说。
0x3ff7fffff0f,存储时应先舍弃7左边的数值(存不下)
此时二进制应该是0111 1111 1111 1111 1111 1111 0000 1111
由于是正数(符号位为0),并且最高位为0(判断为正数,原反补一致),直接将二进制存入内存中
取出的时候,也由于符号位为正数,直接取出转成想要的数值即可
0x3ff9fffff0f,存储时应先舍弃9左边的数值(存不下)
此时二进制应该是1001 1111 1111 1111 1111 1111 0000 1111
此时最高位为1,并且符号位为0(正数),先做与运算(1&0=1),作为最高位
由于系统已经判定是正数,所以直接存储到内存中(原反补一致,不需要经过转换)
取出的时候,由于符号位为1,则需要经过转换(补码->反码->原码->)
转换后为1110 0000 0000 0000 0000 0000 1111 0001,十进制为-1610612977
-0x3ff9fffff0f,先不考虑符号位的问题。数值位在存储时应先舍弃9左边的数值(存不下)
则此时二进制应该是1001 1111 1111 1111 1111 1111 0000 1111
此时最高位为1,并且符号位为1(负数),先做与运算(1&1=0),作为最高位
做完运算后结果是0001 1111 1111 1111 1111 1111 0000 1111
由于计算机指令已经告知是负数,所以此时存储需要经过原-反-补的转换,转换完后是0110 0000 0000 0000 0000 0000 1111 0001
取出的时候,由于符号位为0,则不需要经过转换,直接使用二进制即可,则为1610612977