Datas sheet
,就会在最底层的计算机硬件里,发现
01
的真实身份就是一个个具体的
电信号
,信息的
存储、传送、加工
,最终、也是最真实的面目就是这些电信号流在
CPU、内存、外存、乃至网络
中的
游走、转移和各种位级的运算
,这些底层的
电信号
的活动,被一层层的
抽象
、
汇聚
成
更高级
、
也更复杂
的计算机软硬件理论及知识体系。
深入理解计算机系统
这本书的最终目的就是这些
01
串所组成的大千世界的具体轮廓给讲清楚,其方式是最简单的至底向上的逐层讲解。而在
本章
关注的是最底层的
01
信号具体是如何抽象表示更高一层的各种
基础数据类型
和
基础数据操作
的。
本章共5小节,除去最后一小节的总结内容,前四节分别介绍了
信息存储
、
整数表示
、
整数运算
和
浮点数
,从全章的目录来看,本章的内容非常清晰,就是计算机底层的
01
串是
如何存储
、
如何表示
的。更数学一点的概括,就是介绍了几个转换函数,输入是计算机所擅长的
01
二进制串,输出是我们所熟悉的
十进制数
,以及如何通过
位级的算术计算以及逻辑操作
来
模拟实现
十进制数中的算术计算以及逻辑操作
。这几种
函数
的特点是:
输入都是
01
二进制串
输出则是符合各种意义和模式的
基础数据类型
函数体如下: 本章的内容就是介绍了这么几种转换函数$ f(x)$。
大小端
问题:
在将一个
多字节
表示的
程序对象
存储到连续的
地址空间
的时候,我们必然要面临两个问题:
这个对象的地址是什么?
在内存中如何排列这些字节?
在几乎所有机器上,这两个问题的答案都将是:
多字节都被存储为
连续
的字节序列,对象的地址为
所使用字节中最小的地址
;
对于大多数
Intel
兼容机都只用小端模式,而许多新的微处理器的大小端是可选的,也即所谓的双端机(
bi-endian
),比如
ARM
微处理器,但有意思的是运行于其上的两种最流行的
Android
和
iOS
却只能运行于小端模式;
在网络中进行的传送的
字节序
都统一使用
大端模式
,这是事实上的网络协议簇
TCP/IP
中明确指定的。
那么为什么
TCP/IP
协议里规定一定要使用
大端法
表示
网络字节序
呢?先不急着回答这个问题,我们先来看看什么是
大端
和
小端
。
在本文前面我们提到目前计算机的
最小寻址单元
是
1字节
。而
大小端
问题存在的根本原因就是:
很多基础数据类型的二进制表示的长度超出了计算机的最小寻址单元(1字节 or byte),在存储这些超过最小寻址单元的数据类型的值的时候,就必然要指定拆分后的若干块的存储顺序。
下图列出了
C语言声明
的基础数据类型在
32位计算机
和
64位计算机
上的字节数:
lihux.me-1
C语言中常见的数据类型及其在不同系统中的字长
当一个数据类型长度大于
1最小寻址单元
的时候(在
C语言
中
sizeof(type) > 1
),如果它的一个
值
如果要存入内存中,那么
1个内存单元
显然是不够的,需要将
实例
进行
拆分
为
sizeof(type)
个
数据块
,每一个
数据块
存入一个
最小寻址单元
中。我们以一个简单的32位整形变量的存储来说明问题,为了便于表示,我们采用
十六进制
:
int32_t dog = 0x99887700
数学常识告诉我们
99
是最高字节,
00
是最低字节,这种排列是
从左到右
按照
从高字节到低字节
的顺序排列的,这里
高字节
的
高
就意味着其拥有更高的
权重
或者:
power
。现在假设我们的计算机内存只有
4字节
,那么这块内存的地址就是
0 1 2 3
:
因为我们要存储的
0x99887700
需要占用
4 bytes
的存储空间(在这里也就是要占满整个内存),那么我们首先要将
0x99887700
拆分为4块:
99
、
88
、
77
和
00
。
那么问题来了:该先存入这四块中的哪一个值到内存的第0位呢?
按照我们视觉上
从左到右
的阅读习惯,我们会很自然的按照
99 88 77 00
的顺序存储它们:而这就是所谓的
大端法(big-endian)
:高字节存入到底地址(当然你也可以说是:
低地址存高字节
)
如果按照阿拉伯人的
从右到左
的阅读习惯,按照
00 77 88 99
的顺序依次存入内存,这就是所谓的
小端法(little-endian)
:低字节存入到低地址(或者说是:
低地址存低字节
)。
好了,关于
大小端
,其实就是这么简单。对于机器而言,选择大端还是小端没有优劣之分。
最后的最后,我们可以回答一下开头提出的问题了:为什么
TCP/IP
协议明确要求选用大端表示呢?
其实,答案很简单:
TCP/IP
传输的
报文
为了便于人们阅读(前面提到了绝大部分人的阅读习惯是从左到右的顺序)。关于这一点,在
TCP/IP
协议的
RFC1700
中也做了说明:
The convention in the documentation of Internet Protocols is to
express numbers in decimal and to picture data in “big-endian” order
[COHEN]. That is, fields are described left to right, with the most
significant octet on the left and the least significant octet on the
right.
The order of transmission of the header and data described in this
document is resolved to the octet level. Whenever a diagram shows a
group of octets, the order of transmission of those octets is the
normal order in which they are read in English.
我猜要是阿拉伯人发明的
TCP/IP
协议,他们肯定会选用
小端法
。
双射 (bijection)
,这就保证了 编码的唯一性(双射也称
一一映射
)。
在这列举的5中方案中,有3套方案(方案2 原码表示、3 反码表示、4 补码表示)是计算机系统中的
标准表示方法
,但只有补码表示是
事实上的标准
被几乎所有计算机系统采用:
lihux.me-12
计算机系统采用的三种编码方案
那么为什么只有
补码
会成为计算机系统中整数表示事实上的标准呢?答案就是只有补码能让计算机的算术运算单元
ALU
按照无符号数的加法来计算补码的加法:即
将补码表示的二进制序列看做是无符号数序列,然后对其按照无符号数的加法方法进行相加,得到的结果按照补码的映射规则得到的结果刚好是补码加法应该得到的值!
因此,可以认为
补码
被选中仅仅是因为计算机硬件能够更高效的处理而已。
为了证明这一点,让我们首先来看一张图:如图
lihux.me-13
所示,我们仍以前文的
4 bit
整型
int4_t
值:$x = 2$以及其相反数$y = (-x) = -2$为例,如果我们要计算$ x + y = ?$:
对于原码表示的按照无符号整数的计算结果$1100_2$,根据定义,其最高位是符号位,因而其值转回补码是$-4$,不符合要求;
对于补码表示的按照无符号整数的计算结果是$10000_2$,但巧妙的是,
最高位发生了溢出
,实际得到的结果是$0$,转回补码表示,结果依然是$0$;
对于反码表示按照无符号整数的计算结果是$1111_2$,转回反码表示是$-0$,虽然结果正确,但奇葩的是对于$0$它有$+0$何$-0$两种表示,严格意义上并不满足
双射
。
其实补码和反码的英文名称更能反映出其各自本身的特征:
Two’s Complement
来自于:对于非负数$x$,我们用$2^w - x$来计算$-x$的表示,这里有一个$2$,所以是
Two's
;但对于补码,则是用$[111···1]-x$来计算补码表示,这里有很多个$1$,所以是
Ones'
。
总结一下就是,采用
补码
表示的
有符号整数
和采用
原码
表示的
无符号整数
,对于计算机的算术运算单元而言,二者都是透明的,它不用去区分不同的编码方式,而只采用一种固定的
运算逻辑
就能实现两种编码表示的算术运算。计算机执行的
整数
运算实际上是一种
模运算形式
,通过
溢出
来巧妙的使得运算结果符合我们的预期。
实数
相比整数是一种范围更大、更实用的数据集合。
Ask Us
中,曾认真的回答了这个问题(虽然没有给出具体的数值,但大概是1后面几十个0)。而最微小的呢,也许是大学物理我们学到的
普朗克常量
,它的值是:
也不过是小数点后面34个零而已,光是这点浮点数就能穷尽宇宙之浩瀚、之微渺,那我们有完全有理由相信
64 bit
表示的浮点数已经足够我们用了。
既然所能表示出来的浮点数集合是有限的,那么
好钢要用在刀刃上
,我们必须要选择将常用的浮点数(精度)都能尽可能精确的表示出来,而对于其他浮点数值,则可以通过
舍入
的办法来表示。
IEEE 754标准
。再说为什么:
Demo App
中将这些点给画出来了:
从图中可以看到,这些点均匀分布,看上去很完美。那么下面我们来看看浮点表示:
概况而言,浮点数的表示的基本公式如下:
符号
s (short for sign)
决定表示的是正数
(s=0)
还是负数
(s=1)
,因此要占用
1 bit
;
尾数
M
,尾数
significand
是一个
二进制小数
,它的范围是$1\sim2-\varepsilon$(规格化),或者是$0\sim1-\varepsilon$(非规格化);
-阶码
E (short for Exponent)
的作用是对浮点数加权,权重是2的
E
次幂(可能是负数);
也就是说浮点数需要存储三个值:
s/M/E
。看定义觉得很难懂,还是来点儿例子吧:我们以
32 bit
长度的
单精度浮点数
的表示为例来说明这一点:
图中可以看出,
s/M/E
三部分占用的位数分别是
1
、
8
和
23
,除了
符号位
需要固定
1 bit
之外,
尾数
和
阶码
占用的位数实际上是根据实际需要自己定义的,这里因为我们通常对
精度
要求非常高,而对极大数的表示的要求有相对比较低,所以尾数表示占用的位数是阶码的三倍。
而这里令人不愉快的是常规数的表示还分为
规格化
和
非规格化
两种,原因就是我们对
精度
要求的
贪婪
:在浮点数表示中,二进制的小数部分,第一位一定是
1
(要么是0要么是1),既然一定会有
1
的存在,那么我们就没有必要存储了,将其省掉,这样就等于后面又能多存储一位尾数!
这里的阶码表示采用
无符号数表示
,但因为无符号表示的数只能是$0\sim255$,因此对于规格化的阶码部分的
无符号数表示
的值会在$1\sim254$之间,但我们还需要负数以表示
绝对值极小
的数,因此我们对这个值再减去一个常量$2^7 - 1$,也就是127,因此阶码的实际范围是$-126\sim127$。让我们继续用公式简单的总结一下对于
w
位表示的阶码值的计算我们有:
其中
e
为无符号表示的阶码的值,
Bias
为偏移量。
规格化的浮点数表示虽然帮助
贪婪
的我们多表示了
1位精度
,但是却带来了另外一个尴尬的问题:由于我们贪婪的添加了一个
无形的1
(让我想起了经济学中的无形的手了^_^),就
导致尾数部分永远不为零
,这些完蛋了:我们没法表示
零
了!!!
别急,我们的计算机科学家们并没有被这个困难所吓到,他们想了想,既然这样,那干脆在阶码为
0
的时候,我就不加那个
无形的1
了呗!但是呢,因为去掉了那个
无形的1
,其实是等价于我们的阶码值在规格化的基础上进行了减1($2^{-1}$),那么为了保证
在数值表示上,规格化到非规格化的平滑过渡
,我们需要对阶码的计算公式人为的做一下调整,以
对冲
我们拿掉
无形的1
之后的阶码值的
跳变
,因此对于
w
位的
非规格化
的阶码值的计算我们有:
ok,浮点数的定义现在我们彻底搞清楚了,这里的
浮(float)
的意义我们也清楚了:就是相对于
定点数
的表示,浮点数
通过阶码的值的变化
来达到
小数点的浮动
的效果,而不是我们的
尾码或者阶码
本身表示的位数变动的意思。
如果不出意外的话,我们现在应该只剩下最后一个问题了:为什么选用浮点表示而不是定点表示呢?答案是
浮点数表示更能贴服我们的需求
:对于我们经常使用的实数范围进行更高密度的表达,而不是像定点数那个
一视同仁
,我们仍在Demo中看看,既然是要对比着找答案,那么我们就将
8 bit
长度的定点数和浮点数放在一起同时绘制出来,让我们来看看他们有什么不同:
很容易我们就发现了两点:
浮点数表示的实数范围更小;
浮点数表示的实数集合分布不均匀,越靠近
零点
,分布的越密集;
关于上述两点,我们可以通过设置不同的二进制位数来确认这不是一种偶然:
浮点数的分布不均匀的特性正式我们选择它的一个重要原因:既然实数是一个无穷集合,而我们的计算机资源是有限的,那么就必须
要将好钢用在刀刃上
:对于表示的范围不必那么大,够用就行;对于表示范围内常用的数值区间,我们提供更高的精度(尾数更长)来表示。
好了,关于浮点数这一步分就先到这里,关于浮点数的计算就先不做展开了,不然本文的长度恐怕是有点吓人了。