C语言学习(九)整数在内存中是如何存储的?数值溢出的本质是什么?从源头了解奇怪的整数输出问题

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 charunsigned shortunsigned int(4Byte)unsigned long(8Byte)
最小值0000
最大值28-1 = 255216- 1 = 65,535 ≈ 6.5万232 - 1 = 4,294,967,295 ≈ 42亿264 - 1 ≈ 1.84×1019

有符号数的取值范围

有符号数以补码形式存储,计算取值范围也要从补码入手。我们以char类型为例,从下表中找出取值范围:

补码反码原码
1111 11111111 11101000 0001-1
1111 11101111 11011000 0010-2
1111 11011111 11001000 0011-3
1000 00111000 00101111 1101-125
1000 00101000 00011111 1110-126
1000 00011000 00001111 1111-127
1000 0000-128
0111 11110111 11110111 1111127
0111 11100111 11100111 1110126
0111 11010111 11010111 1101125
0000 00100000 00100000 00102
0000 00010000 00010000 00011
0000 00000000 00000000 00000

这样列出来,很容易就能发现最大值和最小值。

-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,这也是采用补码的另外两个小小优势。

按照上面的方法,我们可以计算出所有有符号数的取值范围(括号内为假设长度):

charshortint(4Byte)long(8Byte)
最小值-27= -128-215 = -32,768 ≈ -3.2万-231 = -2,147,483,648 ≈ -21亿-263 ≈ -9.22×1018
最大值27 - 1= 127215 - 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

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

JayerZhou

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值