添加链接
link管理
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

1. 概述

对于绝大多数语言而言,基本上都会有 整数 浮点数 字符串 二进制串 这几种数据类型,但在计算机系统中,如果追溯到最底层,这些不同种类的 信息 的表示,都会归结为最简单的 01 串的有限组合:

01010101010100101010101011111110001111110000111111100011110001

在最底层的计算机硬件实现里, 1 一般会用一个高电平表示,而 0 则会用一个低电平表示,回忆一下大学里学的随便一个简单的电子芯片,比如 3-8译码器 你去翻它的 Datas sheet ,就会在最底层的计算机硬件里,发现 01 的真实身份就是一个个具体的 电信号 ,信息的 存储、传送、加工 ,最终、也是最真实的面目就是这些电信号流在 CPU、内存、外存、乃至网络 中的 游走、转移和各种位级的运算 ,这些底层的 电信号 的活动,被一层层的 抽象 汇聚 更高级 也更复杂 的计算机软硬件理论及知识体系。

深入理解计算机系统 这本书的最终目的就是这些 01 串所组成的大千世界的具体轮廓给讲清楚,其方式是最简单的至底向上的逐层讲解。而在 本章 关注的是最底层的 01 信号具体是如何抽象表示更高一层的各种 基础数据类型 基础数据操作 的。

本章共5小节,除去最后一小节的总结内容,前四节分别介绍了 信息存储 整数表示 整数运算 浮点数 ,从全章的目录来看,本章的内容非常清晰,就是计算机底层的 01 串是 如何存储 如何表示 的。更数学一点的概括,就是介绍了几个转换函数,输入是计算机所擅长的 01 二进制串,输出是我们所熟悉的 十进制数 ,以及如何通过 位级的算术计算以及逻辑操作 模拟实现 十进制数中的算术计算以及逻辑操作 。这几种 函数 的特点是:

  • 输入都是 01 二进制串
  • 输出则是符合各种意义和模式的 基础数据类型
    函数体如下: 本章的内容就是介绍了这么几种转换函数$ f(x)$。

    2. 信息的表示所带来的问题

    在讲具体的函数之前,首先要明白信息是如何在计算机中存储的:几乎所有的计算使用8位的块( byte ,以后统称为 字节 ),作为 最小的可寻址 的内存单元:
  • 10010110

    程序数据、指令和控制信息,都是由这些 bytes 的组装起来的,对于多 字节 的数据类型来说,就有一个 字节序 如何存储和解析的问题:这就是著名的 大小端 问题:

    在将一个 多字节 表示的 程序对象 存储到连续的 地址空间 的时候,我们必然要面临两个问题:

  • 这个对象的地址是什么?
  • 在内存中如何排列这些字节?
    在几乎所有机器上,这两个问题的答案都将是:
  • 多字节都被存储为 连续 的字节序列,对象的地址为 所使用字节中最小的地址
  • 对于大多数 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 协议,他们肯定会选用 小端法

    3.整数的表示

    先说结论,在 C 语言中,整数包括无符号整数和有符合整数,在计算机中他们分别采用 原码 补码 予以表示。再留一个问题:为什么无符号整数要用 原码 ,而有符合整数却要用 补码 表示呢?

    在我们上小学的一二年级的时候,我们的数学世界是非常干净的:没有小数、没有负数,更不用提无理数了,那个时候我们以为仅仅是 正整数 就足以表达整个世界,后来我们发现,是我们太幼稚了,这个世界太复杂,复杂到必须要造出那么多复杂的数才够用。

    计算机世界里的 抽象 都是对真实世界的 模拟 ,因此这种 抽象 必须要包含各种复杂的

    3.1无符号整数(正整数和0)

    我们先从最简单(至少是看上去最简单)的整数的表示讲起,我们这里约定计算机世界里用一个 32 bit 的内存来存放一个 整数 。我们来看看如何用这 32 bit 内存空间来存储和表示 正整数(也即无符号数) 整数
    @|center|500*0

    lihux.me-5 中每一个小方块代表 1 bit ,每一个小方块都只可能填充 0 或者 1 。那么用这些方块的 01 组合一共有多少种呢? 答案是$2^{32}$种!

    根据前文介绍的,整数的表示,实际上就是定义一个函数,输入为 32 位 二进制序列(因为这里我们假定只使用 32 bit 来表示整数),这里我们引入数学中向量的概念来表示这个输入的二进制序列:

    那么向量$\vec x $的值一共有$2^{w}, w=32$,因此每一个值都可以实际表示一个整数(或者浮点数),剩下的就是定义编码函数对这些值做映射了:

    lihux.me-6 32位二进制表示的全部集合
    如果全部列全,则上面这个表格将会有$2^{32}$列,也即 4 294 967 296 列!约 42.9 亿列,马云爸爸的银行卡账户如果用这个 32 位的整形表示则肯定是要溢出的!^ ^,年前王健林曾说要先定一个小目标, 比如说先赚它一个亿 ,那对于码农的我来说,我也定个小目标: 10年以后,我的银行卡余额要让w位 $\vec x$ 表示的无符号整数产生溢出 ^ ^,但问题是这个 w 会是多少呢?
    无符号数 的编码函数可以定义为( B2U == Binary to Unsigned Integer ):

    数学公式的魅力就是非常简洁、精确的表达了某种确定的函数关系,比如上面这个公式,就一行,就说明了一切。但数学公式简洁的同时,却又不够直观,可能有些人看了半天才明白,而另外一些人则始终看不明白^_^。好吧,让我们来直观一些用图说话:

    无符号数 的编码函数$B2U_w(\vec x)$产生的映射结果是:
    |center|500x0

    3.整数(负整数、正整数和0)

    在上一小节中我们知道, 32 bit 的二进制序列的所有组合是一个有限集合,集合中元素的个数是 $ 2^{32} $,它所能表示的 无符号整数 的范围也刚好是$2^{32}$个,那么现在要用这$2^{32}$个元素表示 整数 ,那么显然,依然是只能表示最多$2^{32}$个整数集合,但是整数还包含有 负数 ,那么直觉告诉我们,它所能表示的 整数 肯定会减半的,事实上也正是如此。不过比较尴尬的是,现在能表示的整数个数是偶数,但我们又刚好又一个 0 既非 正整数 ,也不是 负整数 ,那么剩下的$2^{32}-1$个元素所能表示的 正整数 负整数 的个数必然不是相等的 (除非0也用两个元素表示,分表为$\stackrel{+}{-}0$,实际上还真有这么表示的) ,一个为$2^{31}$另外一个为$2^{31}-1$,由此,很自然的我们就能推导出: 最大正整数和最大负整数的绝对值是不相等的!

    为了简化我们的 整数表示 的推演,我们这里讲前面的 32 bit 二进制流再进一步简化为 4 bit ,那么在这种情况下,所能表示的无符号整数有 0 15 ,共 16 个,全部集合见下图:
    Alt text

    现在我们想要用同样的 16 个二进制集合表示 整数 ,那么显然我们就被迫要从这表示的 无符号整数 挤掉 7 个或者 8 正整数 用以表示 负整数 ,那么显然为了保持表示 整数值域 连续性 ,我们剔除掉 8~15 8 位来表示 负数或者-0 ,但是问题又来了,这剩下的 8 位该如何表示 负数 ?我们不妨脑洞大开,自己设计一下吧,能想到的至少有四种方案:
    |center|500x0

    事实上映射方案远不止这些,如果我们让正整数的映射位置不和无符号整数的映射位置保持一致的话,我们还可以这样映射:将无符号整数的映射关系整体上移(-8)如图:
    |center|500x0

    这五种编码方案,我们很容易写出他们的映射函数:

    方案1

    方案2

    方案3

    方案4

    方案5

    从数学的角度来看,这五种映射方案都是 双射 (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'

    总结一下就是,采用 补码 表示的 有符号整数 和采用 原码 表示的 无符号整数 ,对于计算机的算术运算单元而言,二者都是透明的,它不用去区分不同的编码方式,而只采用一种固定的 运算逻辑 就能实现两种编码表示的算术运算。计算机执行的 整数 运算实际上是一种 模运算形式 ,通过 溢出 来巧妙的使得运算结果符合我们的预期。

    4.浮点数:实数在计算机中的表示

    首先说明一点, 浮点数 只是一种计算机表示 实数 的编码规则的统称,并不是数学世界里的一种数据类型。前面我们讲到,真实的世界中原不止 整数 一种数据类型那么简单, 实数 相比整数是一种范围更大、更实用的数据集合。

    4.1 固定字长表示实数存在的固有问题

    那么问题来了,又是常识告诉我们,浮点数是一个 无限集合 ,而我们的 位数固定的二进制序列 是一个 有限集合 ,那么我们现在想用一个 有限集合 映射到一个 无限集合 上去,显然映射$f(x)$不可能是 双射 ,也就是说浮点数集合中必然有无法从这个 有限集合 映射得到的元素,更进一步讲,是 只有极少数的浮点数能够映射到这个有限集合中 ,对于 32位 的系统,我们所能表示的浮点数最多也就只能有$2^{32}$个。这真是一个很悲观的事实。

    然而,幸亏,我们常人的一般生活中,对浮点数的要求很有限:我们既不太可能经常使用(绝对值)太过于巨大的浮点数(万万万万亿),也不太可能经常使用(绝对值)太过于微小的浮点数。举例来说,当我们小的时候,夏夜仰望星空的时候,我们一定曾问过我们的妈妈: 天上有多少颗星星呢? ,妈妈的回答也许是我们最高听到的最大的数了。美国宇航局 NASA 在它的一篇科目系列 Ask Us 中,曾认真的回答了这个问题(虽然没有给出具体的数值,但大概是1后面几十个0)。而最微小的呢,也许是大学物理我们学到的 普朗克常量 ,它的值是:

    也不过是小数点后面34个零而已,光是这点浮点数就能穷尽宇宙之浩瀚、之微渺,那我们有完全有理由相信 64 bit 表示的浮点数已经足够我们用了。

    既然所能表示出来的浮点数集合是有限的,那么 好钢要用在刀刃上 ,我们必须要选择将常用的浮点数(精度)都能尽可能精确的表示出来,而对于其他浮点数值,则可以通过 舍入 的办法来表示。

    4.2 为什么选用浮点数而不是定点数表示实数呢?

    先说结论,目前计算机系统采用的浮点数表示都是1980s前后制定的 IEEE 754标准 。再说为什么:

    定点数表示

    和整数相比,实数多了小数部分,也就是 小数点之后还有值 ,因此,对于给定的 w 位表示的浮点数,我们还要拿出一定的位数 s 来表示小数,这是基本思路。按照这个思路我们一个最直接的方案就是比如对于 16 位的存储空间,我们选用后 7 位作为小数部分, 以小数点为原点,每左移一位,其权重就翻倍,每右移一位,其权重就降50%(也即除以2) 。如 lihux.me-14 所示:
    @|center|400*0
    为了表示正实数和负实数,我们选择小数点左边的整数部分采用补码表示,因此我们很容易得到 4 bits 所能表示的整数范围是$[-8,7]$。而小数点右边表示的小数范围是:$[0/16, 1/16, 2/16, …, 15/16]$,因此我们得到 8 bits 定点表示的实数范围是:$[-8,7].[0/16, 1/16, 2/16, …, 15/16]$,共计$16 * 16 = 256$个浮点数。为了对这些点的分布有一个直观的理解,我在自己的
    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 长度的 单精度浮点数 的表示为例来说明这一点:
    @|center|600*0
    图中可以看出, 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 长度的定点数和浮点数放在一起同时绘制出来,让我们来看看他们有什么不同:
    @|center|600*0

    很容易我们就发现了两点:

  • 浮点数表示的实数范围更小;
  • 浮点数表示的实数集合分布不均匀,越靠近 零点 ,分布的越密集;
  • 关于上述两点,我们可以通过设置不同的二进制位数来确认这不是一种偶然:
    @|center|600*0
    @|center|600*0

    浮点数的分布不均匀的特性正式我们选择它的一个重要原因:既然实数是一个无穷集合,而我们的计算机资源是有限的,那么就必须 要将好钢用在刀刃上 :对于表示的范围不必那么大,够用就行;对于表示范围内常用的数值区间,我们提供更高的精度(尾数更长)来表示。

    好了,关于浮点数这一步分就先到这里,关于浮点数的计算就先不做展开了,不然本文的长度恐怕是有点吓人了。

    5.0 小结

    没想到关于第二章的读书笔记会写这么长,也许是觉得信息的表示是计算机系统中基础的基础吧。本文通过 19 副图和一个 APP Demo ,强大的资源集合以及10个左右的数学公式,力图将信息在计算机系统的表示给讲清楚。

    计算机将信息编码为 位(比特 or bit) ,通常组织成字节序列。所有的整数、实数和字符串都通过这样一位位 01 字符串来表示,要想解析出其中所表示的信息,我们必须要按照预先约定好的解码规则进行解码。而本篇基本围绕着基本数据类型的编码规则来进行讲解:编码的方式(函数)以及为什么选用某一个编码方式而不选用另外一种方式。通过数学分析以及编写程序,从理论上和实际使用中找出原因,以期给自己也给本文的读者一个令人信服的答案。