数值信息的机器级存储

时间:2022-05-04
本文章向大家介绍数值信息的机器级存储,主要内容包括基本的位运算操作、整数的表示、浮点数的表示、基本概念、基础应用、原理机制和需要注意的事项等,并结合实例形式分析了其使用技巧,希望通过本文能帮助到大家理解应用这部分内容。

计算机中使用八位的块,或者说是「字节」,作为最小的寻址单元。你可以将整个存储器视作一个超大的「字节数组」,每个字节都有一个唯一的数字编号,这个编号就是所谓的地址,通过这个地址,我们可以唯一的确定一块数据。但是我们代码中定义的各种数值又是如何转换为二进制串存储在这些「字节」里面的呢?为什么两个整数相加之后的结果会变成负数?

等等这些类似问题,其实都归咎于 计算机中是如何存储各种类型的数值的。只有理解好这个问题,你才能对你程序中定义的各种数值型变量的范围以及相互运算后的结果『尽在掌握』,才不至于程序动不动就因为变量的相互运算而数据溢出,系统崩溃。

基本的位运算操作

首先,扫个盲,简单描述一下计算机中有关二进制位的几种基本的运算操作。

&(与) 运算:两个二进制位都为一结果才为一,其余情况都为零

例如:0110 & 1100 = 0100,1101 & 0110 = 0100 。

|(或)运算:两个二进制位至少有一个位一,结果即可为一

例如:0110 | 1100 = 1110 ,1101 | 0110 = 1110 。

~(非)运算:二进制串中的所有位颠倒,0 变成 1,1 变成 0

例如:~ 0110 = 1001 ,~ 1101 = 0010 。

^(异或)运算:两个二进制位有一个为一,但不全为一的时候,结果为一

例如:0110 ^ 1100 = 1010,1101 ^ 0110 = 1011。

位运算中还有一种操作很常见,各种源码中都很常见,那就是「移位运算」。

x << k(左移):x 向左移 k 个位,,也就是说丢弃高 k 位,右边补 k 个 零。它等价于 x * 2^k

例如:0101 << 1 = 101==0==(等于 0101 * 2^1),0101 << 2 = 01==00== (这种情况会产生溢出,关于溢出后文再详细说明)

位的右移一般分为两种形式,逻辑右移和算数右移

x >> k(逻辑右移):x 向右移 k 个位,丢弃右边 k 位,左边补 k 个 0 。它等价于 x / 2^k

例如:0110 >> 1 = ==0==011(等于 0110 / 2^1),0111 >> 1 = ==0==011 。

x >>> k(算数右移):算数右移的基本规则和逻辑右移一样,只是左边补位的时候,补的是最高有效位

例如:0111 >>> 2 = ==00==01(7/4),1100 >>> 2 = ==11==11(-4/4) 。

算数右移和逻辑右移的唯一不同点在于,对于缺失位的补齐方式不同,逻辑右移统一补零,而算数右移则补的是原二进制串的最高有效位(对于补码来说就是符号位)。

整数的表示

计算机中,整数可以有两种表述方式,无符号和有符号整数

C/C++ 中默认数据类型都是有符号的,但也可以通过申明 unsigned 来标识一个数据类型为无符号数据。但是,Java 中只支持有符号整数,所以本文对于无符号类型的描述只会「一带而过」,感兴趣的同学可以自行搜索比较两者之间的区别与联系。

下面我们主要来看看计算机中是如何存储有符号整数的,以及它们之间的基本运算又是如何进行的?

① 原码、反码、补码的基本概念

有符号整数的编码标准要求,二进制串的最高有效位为符号位,剩余的二进制位为该整数的「真值」。例如:

//这里统一使用八位一个字节来表述一个整数

5 :==0==000 0101

-10:==1==000 1010

最高有效位表述的是这个整数的符号,1 表示负数,0 表示正数。这就是计算机中整数的「原码」表示。

但是,在进行基本的加减运算的时候,发现问题了。

    5 :0000 0101
+  -10:1000 1010
-----------------------
   -5 :1000 1111(-15)

显然,虽然原码可以很好表示正负数,但是这个表述方式并不能正确的进行基本的运算操作。于是人们想出了「反码」。

反码:正数的反码是其原码本身,负数的反码为原码中除符号位不动其余位取反的结果。

    5 :0000 0101         -》 0000 0101
+  -10:1000 1010         -》 1111 0101
----------------------------------
   -5 :1000 1111(-15)     1111 1010(反码转原码得到结果:1000 0101 [-5])

貌似反码好像能够解决我们的基本位运算了,但是看下面这个例子:

    1: 0000 0001        -》 0000 0001      
+  -1:1000 0001        -》 1111 1110
----------------------------------------
    0:                     1111 1111(反码转原码得到结果:1000 0000 [-0] )

显然,0 本身无正负之分,而一个整数是不允许有两个二进制数值的。所以反码的一个问题就是对于零这个整数数值来说,产生了两种编码结果。于是,人们又发明了「补码」用于解决这个问题,而事实证明,「补码」最终成为计算机中编码数值的最后方案。

补码:正数的补码依然是其原码本身,负数的补码即原码中符号位不变,其余真值为取反再加一的结果。

    5 :0000 0101         -》 0000 0101
+  -10:1000 1010         -》 1111 0110
----------------------------------
   -5 :1000 1111(-15)     1111 1011(补码转原码得到结果:1000 0101 [-5])
    1: 0000 0001        -》 0000 0001      
+  -1:1000 0001        -》 1111 1111
----------------------------------------
    0:                     1 0000 0000(最高位溢出,所以最后的补码值为 0000 0000 )

事实上,大部分计算机都采用的补码来表述有符号的整数。

② 扩展与截断数字

这是一类在类型转换时会遇到的问题,我们在编程中常常会将「小范围」类型的变量转换为「大范围」类型的变量,或者将「大范围」类型的变量强制转换成「小范围」类型的变量。

例如:Java 中 int 类型的变量占 32 bits,long 类型的变量占 64 bits,那么我一个 int 类型的变量 x,如果被赋值给了一个 long 类型的变量 y,那么 y 的高 32 位将是什么?

对于采用补码编码的整数而言,扩展的 32 位将全部为原最高有效位的值。

这是小范围扩展到大范围所代表的一类问题,那么大范围缩进为小范围,该怎么办呢?

大范围缩进至小范围的这一类问题,我们叫做「截断数字」。截断数字的最终结果是丢失最高的 k 位,以上面的例子来说,如果 64 位的数值被强转成 32 位的数值,将直接导致丢失最高的 32 位。

③ 补码编码整数的四则运算

这是本篇文章的重点内容之一,理解了补码的四则运算之后,对于程序中的数值运算的溢出将得到很好的控制。

补码的加法运算

对于加法,我们要分几种情况进行讨论。

  • 正数加正数
  • 负数加负数
  • 正数加负数

首先,对于正数加负数的情况,没什么好说的,不可能产生溢出问题。

对于正数加正数的情况而言,可能会产生「负溢出」。例如:

\我们以四位二进制的格式来表述一个数值,最高位符号位
    0101                    0110
+   0010                +   0010
--------------         ---------------
    0111(5+2=7)             1000(结果溢出)

对于第一个例子而言,并没有什么问题,但是第二个例子就有问题了,两个正数相加,结果却是个负数,这就是我们说的「负溢出」。

因为对于四位二进制表示的数值来说,除去最高位用于表示符号,它能表述的范围在:-8 ~ 7 之间。

而我们上述的例子中,6 + 2 = 8,显然超出所能表示的最大数值,于是溢出为 -8 。

对于负数加负数的情况中,则可能发生「正溢出」。

    1110                            1001
+   1101                        +   1101    
-------------                   --------------
    1011(-2 + (-3)= -5)         0110(-7 + (-3))

第二个例子,我们用 -7 + (-3) 得到结果为 6,出现了两个负数相加结果为正数的情况,其实就是 -10 小于 -8 ,不能表示,于是产生了溢出。这就是所谓的「正溢出」。

在计算机的世界里,只有加法,没有减法。并不是我们设计不出来减法的数字电路,只是加法已经可以完全取代减法,而没有必要专门再设计一个减法电路来增加底层电路的复杂程度了。

a - b 等价于 a + (-b)。

对于乘法操作而言,大多数计算机都有自己的乘法指令,只不过我们一般不用。原因就是乘法指令非常的慢,耗时。而相对于比较快的移位操作而言,编译器通常会将程序中数值的乘法操作优化为多次的移位操作的组合。

例如:x * 20 = x << 4 + x << 2 。

除法操作也是一个道理,只不过除法是右移。

对于除法来说,还存在一个舍入的问题,就是说,-7/2 的结果应该得到的是 -3 而不是 -4。具体是怎么做到让结果「向零舍入」的,可以参见「深入理解计算机系统第二章」相关内容,此处不再赘述。

浮点数的表示

我们知道,计算机中的数值并不总是整型类型的,还有所谓的「小数」。那么二进制的小数都长啥样?

100.10 = 1*2^2 + 1*2^(-1) = 4 + 1/2
10.010 = 1*2^1 + 1*2^(-2) = 2 + 1/4
010.11 = 1*2^1 + 1*2^(-1) + 1*2^(-2) = 2 + 1/2 + 1/4

显然,同一串二进制字符可能由于小数点的位置不同而整个数值字面量不同。这个「小数点」对于浮点数而言是相当重要的,不仅在于它决定了整个数值的字面量大小以及规格化后的二进制存储,还在于它能影响到后面的浮点数运算操作。

浮点数的存储遵循「IEEE 标准」,「IEEE 标准」使用下面的公式来表示一个浮点数。

V = (-1)^s M 2^E

其中,

  • s:符号位,1 表示负数,0 表示正数
  • M:尾数
  • E:阶码

例如:0100.10 可以表述为 (-1)^0 1.0010 2^2

但是实际上,IEEE 标准在实际转储成二进制的时候,会有更加严格的要求,这里只是简单的描述。下图是浮点数存储的标准格式,当然单双精度在各自的模块使用的位数不尽相同。

image

IEEE 标准规定,单精度和双精度浮点数的存储格式如下:

image

我们分几种情况来讨论这个浮点数的二进制存储。

  • 规格化存储
  • 非规格化存储
  • 特殊值存储

首先,我们看看规格化的浮点数存储有哪些要求。

这里的 s 用于标识当前的浮点数的正负性,1 和 0 分别代表负数和正数,这没什么说的。

这里的 exponent 表示的是阶码,阶码 ==E = e - Bias==,这个 e 的二进制将填充在 exponent 里面。有人可能会好奇,为什么不直接存储 E 呢,而是选择加上一个 Bias 再存呢?

因为计算机在进行加法运算的时候,如果两个浮点数的阶码不同,会首先统一一下两者的阶码,然后将他们的尾数部分相加。那么就必然需要比较两者阶码的大小了,如果两者的阶码都是正数,那么计算机可以「无脑」得比较了,如果一个正数一个负数,就得另外设计数字电路用于比较正负数之间谁大谁小,本着让底层数字电路越简单越好的原则,肯定是选择一种方案让同一套数字电路可以处理这两种不同的情况了。

于是人们想到了让阶码加上一个很大的正数以保证加完后的结果是正数,这样阶码之间的大小比较就完全变成了两个正数之间的数值比较。但是这个「很大的正数」该如何取才能保证,无论原来的阶码有多小都能被转换成一个正数呢?

IEEE 标准规定,单精度浮点数的这个 Bias 为 127,双精度的 Bias 为 1023 。(2^(k-1) -1)

由于单精度的阶码占八个比特位,也就是说 e 的取值在 0 - 255,==其中规格化的数值在阶码部分不允许全部为 0 或全部为 1== 。所以 e 的实际范围在 1 - 254 ,因此,我们的 E = e - Bias 取值范围在 ==-126 - 127== 之间。

同理,双精度的阶码 E 的实际取值范围为,==-1022 - 1023== 之间。

对于符号位和阶码的部分上述已经介绍了,下面我们看看,规格化的数对于尾数有没有什么特殊的要求。

规格化的尾数被定义为 M = 1 + f 。 而我们只存储 f,例如:

010111.001 :1.0111001 * 2^4 -> 我们只存储 f = 0.0111001

这样会很方便我们读取,因为我们知道尾数一定位于 0 - 1 之间,所以当我们读取的时候,取出存储的尾数统统加一就得到了实际的尾数值,而不用担心,到底尾数是位于 0 到 1,或是 1 到 2 等范围。

接着,我们看看非规格化的浮点数的表述有哪些要求

当阶码部分全为 0 的时候,所表示的浮点数就是非规格化格式的。此时我们的阶码值 E = 1 - Bias 。对于单精度(八个零)来说,E = 1 - (2^7 -1) = -126 ,对于双精度(十六个零)来说,E = 1 - (2^15 - 1) = -1022 。

非规格化的尾数 M = f。

最后,我们看看几种特殊值的浮点数表示

当阶码部分全为 1,并且尾数部分全为 0 的时候,表示无穷。其中如果,s 等于 0,则表示正无穷,如果 s 等于 1 则表示负无穷。

除此之外,如果尾数部分不是全 0,那么当前的浮点数 「NaN」,不是一个数字。

下面,我们看一个简单的例子:

float num = 9.0;

那么 num 的二进制存储是什么样的呢?

9 的二进制表述为:0000 0000 0000 0000 0000 0000 0000 1001

9.0 = (-1)^0 1.001 2^3

s = 0, M = 1 + 0.001 , E = 3

所以,该浮点数的规格化存储为:0 ==1000 0001== 001 0000 0000 0000 0000 0000

反过来,当计算机读到这么一长串的二进制,又会如何还原该二进制串所表示的浮点数的值呢?

首先,第一位符号位表示该浮点数是正数。

然后接着读取八个比特位(无符号),减去偏置值 127 得到实际的阶码值。

最后的 23 个比特位表示该浮点数的尾数部分,加上一就能得到实际的尾数值。

最终,计算机通过计算就能得到我们的浮点数的十进制表述。

至此,关于计算机中整型和浮点型的数值是如何存储的,我们已经详尽介绍了,可能有些人会疑问,这些有什么用??

就目前而言,我也不能保证,懂得了计算机是如何存储数值的就一定能够提高你的编程能力,但是等到你程序中出现数值运算错误而无法解决的时候,这一点点基础知识一定能帮上忙。


文章中的所有代码、图片、文件都云存储在我的 GitHub 上:

(https://github.com/SingleYam/overview_java)

欢迎关注微信公众号:扑在代码上的高尔基,所有文章都将同步在公众号上。

image