漫谈计算机编码

3,472 阅读14分钟

我们知道,在计算机内部,所有的信息都是以二进制形式进行存储。无论是字符,或是视频音频文件,最终都会对应到一串由 0 和 1 构成的数字串。所以从我们能看懂的人类信息转变为机器级别的二进制语言的过程就可以理解为一种编码的过程,自然,相反的过程就是所谓的解码的过程。

可以这么说,所有的乱码都是源于解码方式与编码方式的不一致。就好像我用英文给你写了一封信(我要表达的信息用英文这种方式 [编码] 了),而你只懂中文,你用中文去读信的内容(用中文 [解码]),于是整封信在你看来就是所谓的 [乱码]。其实,所谓的乱码不是什么复杂的问题,仅仅就是解码的方式不同于编码的方式而已,只要换成合适的解码方式就好了。

本文根据计算机编码的演变历史,从最早的 ASCLL 编码,到一统编码界的 Unicode 编码方式,探讨一下我们的 [人类信息] 究竟是如何被编码成 [计算机级信息] 的。

一、始终被兼容的 ASCLL

上个世纪中旬,美国人发明了计算机,当时并没有考虑到计算机的普及程度会如此之快,所以当时美国人只制定了英文字符和一些控制字符与二进制之间的映射标准,这个最初的标准就是 ASCLL 编码标准

ASCLL 首先对所有需要编码的字符进行了一个编号(总共编排了 128 个字符),例如:数字 0 的编号是 48,字母 a 的编号是 97 等。于是 ASCLL 使用一个字节(8 个比特位)来描述这些字符,将他们各自的编号的十进制转换成二进制即可。于是从 00000000 -- 01111111 (0-127)都被编排了字符。所以,所有采用 ASCLL 编码标准的文件在解析的时候,每八位二进制一起被解释成一个字符,这样所有的英文字符、数字、其他一些字符都已经可以被存储被读取了。下面附一张经典的 ASCLL 表:

image

可见,虽然一个字节只用了七个比特位,但是包含的字符还是相当多的,对于美国人来说,这完全足够用了,但是对于一些欧洲国家,乃至我们伟大的中国来说,一个字节实在是太少了,于是很多地区国家就有了自己的扩展编码标准,但无一例外的兼容 ASCLL 编码(毕竟人家是鼻祖)。

二、Windows-1252 来自欧洲人的扩展

美国人的 ASCLL 标准只定义了 128 个字符的编码方式,使用了 00000000 -- 01111111 这个区间段的二进制。于是欧洲人直接使用 10000000 -- 11111111(128-255)区间段的 127 个二进制位来定义他们自己的一些符号。

image
image

三、GB2312 来自中国人的扩展

我伟大的中华民族有着成千上万的汉字,美国人的一个字节的编码标准怎么能好使? GB2312 (国家标准编码)主要针对的是我们日常中经常使用的一些简体中文,总共收录 6763 个汉字,采用双字节编码,向前兼容 ASCLL 标准。

那么有一个问题,ASCLL 标准的字符采用的一个字节进行编码方式,而我们的中文汉字采用的两个字节进行编码,计算机在解码的时候究竟是一次读取一个字节并把它按照 ASCLL 标准解析成一个字符,还是一次读取两个字节并把它按照我们的 GB2312 标准解析成一个汉字呢?

GB2312 规定,编码汉字的两个字节中,第一个字节的最高位必须为 1。这样,由于 ASCLL 标准的所有字符(00000000-01111111),最高位都是 0,所以当计算机读取到某个字节的最高位为 1 的时候,就连着读取两个字节按照 GB2312 标准解析为一个汉字,否则则认为这是一个普通字符并按照 ASCLL 将它解析为一个普通字符。

下面我们简单描述一下 GB2312 的具体编码细节:

首先,GB2312 是通过所谓的 [分区] 来编排每一个汉字的。

  • 01-09 区编排了一些特殊字符
  • 16-55 区编排了一级常用汉字
  • 56-87 区编排了二级常用汉字
  • 00-15 区及 88-94 区则未有编码

GB2312 的编码方式:0xA0 + 区号,0xA0 + 位号。例如:[杨] 的区位号是 4978(49 区 78 位),所以杨的 GB2312 编码为:0xA0 + 49 ,0xA0 + 78 ,即:D1EE。所以以前有一种区位输入法,就是通过输入四位的数字来进行打字的,而这四位数字就是该汉字的区位号。至于为什么要在区号位号加 0xA0 ,查了很多资料,没有明确的说法,可能就是一种规定吧。

其实仔细想一下,所谓的编码过程不就是两个步骤的组合么,理解这一点很重要。

  • 使用唯一的一个标识编排表示该字符
  • 制定统一的规则将标识映射为底层二进制

ASCLL 标准如此,GB2312 也是如此。

例如:ASCLL 为所有字符进行编号,并且相互不重复(第一步),然后制定了一个规则,某个字符编号的二进制就是它的字符编码(第二步)。

例如:GB2312 为所有的汉字进行分区编号,相互不重复(第一步),然后制定规则使得可以通过区位号得到该汉字的二进制字符编码(第二步)。

GBK 向下兼容并扩展了 GB2312 ,收录了 21003 个汉字,依然是采用的固定两个字节来编码汉字,只是高位字节的取值范围不同而已,此处不再赘述。

四、有雄心的 Unicode

上面我们介绍了美国人的编码标准、欧洲人的编码标准、中国人的编码标准,当然这只是冰山一角,世界上存在着各种各样的编码标准。每个国家的计算机厂商都要根据不同的地域使用不同的编码标准来生产计算机,繁琐低效。有没有一种编码标准能收录世界上所有的字符,并提供存储实现呢?

Unicode 的诞生就是为了统一世界上所有编码的,它编排了世界上近乎所有的字符,总共收录将近 110 多万个字符集合,编号范围从 0x000000 到 0x10FFFF。但大多数字符在范围:0x0000 到 0xFFFF 之间(即小于 65536),每个字符都有一个 Unicode 编号并且一般用十六进制表示,前置 U+。例如:[杨] 的 Unicode 表示为:U+6768。

Unicode 是一种编码标准,它只是为世界上的所有字符进行了编号,并没有指定每个字符每个编号该如何映射为某个二进制串,而 Unicode 的主要实现者有:UTF-32,UTF-16 和 UTF-8。下面,我们分别来看看这些实现者的具体实现细节。

1、UTF-32

这是一种最粗暴的实现方式,采用固定四个字节存储单个字符,所有的字符都使用四个字节进行存储,空间浪费,实际使用中很少采用。

2、UTF-16

针对 Unicode 的存储实现来说,应当遵循一个基本的理念:越常用的字符应当使用越少的字节数表示,而越少见的字符才应该用最多的字节数进行表示。下面我们看看 UTF-16 的具体实现细节:

Unicode 的编码范围从 0x000000 - 0x10FFFF,总共可以编排 1,112,064 个字符。UTF-16 的策略是,编号范围 0x00000 - 0x10000(0-65532)属于常用字符,采用固定的两个字节存储。其中,字符所对应的二进制数值就是该字符本身编号的二进制字面量值。但是,其中 0xD800 到 0xDFFF 编号区间没有编排任何字符,这个区间将用于后续的增补字集编码,这里暂时先不说。

可见,对于常用的字符来说,采用两个字节进行编码,但是不常用不代表用不到,我们接着看看那些增补字符集,也就是所谓的不常用字符集是如何编码的。

对于编号范围 0x10000 - 0x10FFFF 之间的字符来说,UTF-16 使用固定的四个字节进行存储,但是你会发现 0x10000 - 0x10FFFF 之间总共有 FFFF 个字符,即 2^20=1,048,576 个字符,也就是需要 20 个比特位才能编码这么多字符。所以,我们的四个字节里,前两个字节共 16 位至少要提供 2^10(111...111,十个一)种可能,后两个字节也要提供 2^10 种可能,才能组合编排所有的增补字符集。

但是,现在有一个问题:一串二进制数值,我如何判断某个字符是常用字符(使用固定的两个字节存储的),或是增补字符(使用四个字节存储的)?

UTF-16 的解决办法如下:

每个 Unicode 字符都有一个自己的 Unicode 编号,并且对于增补字符来说,他们的编号都大于 0x10000 。用字符本身的编号减去 0x10000 即可得到该字符在所有增补字符集中的排列序号。这个序号的值必然位于范围:0x00000 - 0xFFFFF 之间,占 20 个比特位 ,因为剩下的增补字符数目不会超过 0xFFFF 个。

对于前 两个字节(维基百科上称做前导代理),定义他们的取值范围:0xD800(0xD800 + 0x0000)到 0xDBFF(0xD800 + 0x3FF [10 个 1]),刚好提供了 2^10 种可能取值。

对于后 两个字节(维基百科上称做后尾代理),同样定义了他们的取值范围:0xDC00(0xDC00 + 0x0000)到 0xDFFF(0xDC00 + 0x3FF [10 个 1]),也刚好提供了 2^10 种可能取值。

image

所以,如果发现前两个字节的二进制数值位于范围 0xD800 到 0xDBFF 之间,则说明这个字符属于增补字符并且在编码的时候采用四个字节固定存储了,依次读取四个字节即为当前字符的二进制数值。否则,则说明这是一个由两个固定字节存储的基本常用字符,依次读取两个字节就好了。

下面看几个示例:

1、Unicode 编号 U+0024 的字符

首先,判断得知该编号小于 0x10000,该字符隶属于普通常用字符集,所以该字符的 UTF-16 编码值就是其本身的编号二进制形式。

2、Unicode 编号 U+24B62 的字符

首先,判断该字符的编号值是大于 0x10000 的,说明该字符隶属于增补字符集。

于是,用 0x24B62 减去 0x10000 得到该字符在增补字符集中的排序:0x14B62

通过 UTF-16 编码标准,得到前导代理和后导代理,组合后就是该字符的 UTF-16 编码。以下是计算过程:

0x14B62 -> 0001 0100 1011 0110 0010

前导代理项:0001 0100 10 + 0xD800 = 0xD852

后尾代理项:11 0110 0010 + 0xDC00 = 0xDF62

所以,U+24B62 字符的 UTF-16 编码为:0xD852 DF62

总结一下 UTF-16 的编码标准,对于编号小于 65536 的字符,采用固定两个字节以编号的二进制作为编码的值。对于增补字符集(编号大于 65536),首先拿本身的 Unicode 编号减去 65536 得到当前字符在增补字符集中的排列序号,接着分出两个代理项并加上特定的数值,使得他们各自位于特定的范围中,并以此来区分某个字符究竟是两个字节存储的还是四个字节存储的。

3、UTF-8

UTF-8(8-bit Unicode Transformation Format),是一种针对 Unicode 的可变长度字符编码。使用一到四个字节来编码 Unicode 字符,最常用的字符使用最少的字节数进行存储,很少用的字符使用相对多一点的字节数进行存储。

UTF-8 的编码规则如下图所示:

image

对于编号小于 127 的字符来说,UTF-8 编码标准等同于 ASCLL 编码标准。

对于其余编号范围,按照如图中所示的格式进行编码,其他的也不多说了,现在我们通过一个示例来看看究竟是如何编码的。

汉字 [杨] 的 Unicode 编号是:0x6768 ,十进制:26472

显然,该汉字的 UTF-8 标准编码格式为:1110xxxx 10xxxxxx 10xxxxxx

0x6768 的二进制是:0110 0111 0110 1000

从这个二进制的最后一位开始,依次从后向前替换编码格式中的 [x] 即可。

image

显然,结果已经出来了,对应的十六进制代码为:0xE69DA8

总结一下,UTF-8 编码标准对所有 Unicode 编号进行了分类,排名越靠前,存储时使用的字节数目就越少。不同范围的 Unicode 编号字符集在进行 UTF-8 编码的时候会有不同的模板,以自己编号的二进制按照相应的规则去套模板,即可得到相对应的 UTF-8 编码。

相反的,指定了 UTF-8 编码的文件,计算机在进行解码的时候,以字节为最小单位。如果当前字节的最高位是 0,那么反向我们上述的几个步骤,可以得到该字符的 Unicode 编号二进制形式,继而查表可以得到该字符。

如果当前字节开头有多个一,那么有几个一,该字符的编码后的二进制数值就有几个字节,顺序读取即可。然后同样的反向操作,自然可以得到相对应的字符。

常见的几种编码方式就简单介绍到这,关于编码这块,始终要记得本篇中所总结过一个结论。所有的编码标准实际上都做了两件事情,第一件就是为所有需要编码的字符进行一个编号或标识,第二件就是指定一个规则统一得将这个编号或标识与二进制串进行一个映射。


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

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

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

image