字符编码演变史

开始记录我的 2019-Read-Record 记录一些有意思的知识点和疑难杂症。

1 整理字符工作

有这么一帮人,他们对字节,编码等计算机概念一窍不通。他们的职责是对人类日常生活中用到字符和文字进行归纳和整理:包含一组不重复,无序元素的集合,如下:


我的字符集

此时集合包含10个元素,无序且无重复元素,为了更直观且抽象的表示,他们采用两种方式来表示:

编号 字符
1 p
2 m
3 s
4 t
5
6
7
8
9
10
… ... … ...

采用一维数字编号,查找关系为 1->p2->m 以此类推.

区/位 1 2 3 4
1 p m s t
2
3 ... ...
4 ... ... ... ...

采用二维区位编号,查找需要一对数字(1,1)->p(1,2)->m 以此类推。

上述知识完全不涉及计算机知识,仅仅是集合中字符元素归纳整理的两种方式罢了,之后我们谈及更多的是一维数字编号方式。

2. 计算机背景下的字符整理

2.1 定长编码

计算机底层无法存储“p”,“m”...“码”等字符,而采用一连串的0、1表示数据,以字节(byte)为单位(00000000 - 11111111,16进制表示为:0x00 - 0xFF)。因此计算机底层真正存储的是字符编号,注意到计算机底层的编号索引是从0开始,所以我们需要对方式一编号稍作修改。

编号 编码后计算机实际存储 字符
1 0 p
2 1 m
3 2 s
4 3 t
5 4
6 5
7 6
8 7
9 8
10 9
… ... ... ... … ...

倘若计算机用一个字节存储字符的编号,同时以一个字节解读数据,例如计算机底层的一串数据是“00000000 00000001 00000010 00000011 00000100 00000101 00000110 00000111 00001000”根据上表可以解读为“pmst学习字符编码”。

那么问题来了,一个字节最多可以表示256个字符。随着源源不断地往集合中加入新字符,编号不断累加直到超过255,一个字节已经无法满足我们的需求。

因此我们改用两个字节来表示字符编号,同时以两个字节单位解读数据。此时范围为 0x0000 - 0xFFFF,容量为65536个字符。

尽管解决了字符容量问题,但是对于“m”这个字符我们需要用2个字节0x0001来表示,原先只需要一个字节0x01表示,底层存储字节多了一倍。显然这种并不是我们所期望的。

2.2 变长编码

我们既要考虑集合字符的容量问题,又要减少不必要的字节浪费,因此引入了"变长编码"概念,对于那些编号超过255的字符,我们保留两个字节表示,而对于那些编号较小的字符,我们当然希望使用1个字节存储,而不是2个字节。

变长设计的核心问题自然就是如何区分不同的变长字节,只有这样才能在解码时不发生歧义。

道理我都懂,但是按照表格映射关系解析计算机底层数据时,何时用一个字节解读,何时又用两个字节解读呢?这是个棘手的问题!

答案是高位区分。

编号 编码后计算机实际存储 字符
1 0 p
65 64(0x40) x
128 127(0x7F) h
129 32768(0x80 00) e
130 32769(0x80 01) l
131 32770(0x80 02) o
… ... ... ... … ...

仔细观察上述表格,集合中字符编号未变,但是在计算机中实际存储规则变了,计算机现在知道如何区分用一个字节去解读还是两个字节。当计算机读到0x00-0x7F范围的字节值,会直接按照表格映射关系去解释,例如碰到0x40这个字节,会直接解释成“x”;而碰到0x80-0xFF范围字节值,会先保留它不作解释,会继续读入下一个字节后才进行映射,例如“0x80 0x01”,先读入0x80,计算机知道处于0x80-0xFF范围,需要再读入一个字节才能解释,于是再次读入0x01,合并后为0x8001,到表格映射关系中找到对应的字符“l”。

总结: 这里以一个简单的示例解释了变长编码的概念。上面提到的简单变长编码方式弊端很多,首先可表示字符的范围现在为0x00-0x7F 以及 0x8000 - 0xFFFF,硬生生地把0x80-0x7FFF这段可利用空间去除了。至于原因,请思考:倘若这段区间也做字符映射,那么计算机碰到 0x80 或是其他值,它还能区分用1个字节还是2个字节解释吗?

3 Unicode 字符集

还记得第一节说到专门有一帮人负责归纳整理世界上所有的字符吗?即Unicode字符集,看下定义:

Unicode(统一码、万国码、单一码)是计算机科学领域里的一项业界标准,包括字符集、编码方案等。Unicode 是为了解决传统的字符编码方案的局限而产生的,它为每种语言中的每个字符设定了统一并且唯一的二进制编码,以满足跨语言、跨平台进行文本转换、处理的要求。1990年开始研发,1994年正式公布

现在来说说这个字符集的容量有多少。按照之前一维数字编号,目前范围是0x0000 - 0x10FFFF(21位 bits)。这里要引入“平面”这一概念,一个平面能放置65536(0xFFFF)个字符。因此Unicode字符集可以划分为17个平面:

平面号 平面范围
Plane0 —— Basic Multilingual Plane 基本多语言平面 0x0000-0xFFFF
Plane1 —— 后续16个平面统称为SP Supplementary Planes) 0x10000-0x1FFFF
Plane2 0x20000-0x2FFFF
... ...
Plane15 0xF0000-0xFFFFF
Plane16 0x100000-0x10FFFF

用图表示:

Unicode 字符集平面

第一个平面即是BMP(Basic Multilingual Plane 基本多语言平面),也叫Plane 0,它的码点范围是U+0000~U+FFFF。这也是我们最常用的平面,日常用到的字符绝大多数都落在这个平面内。

上图彩色的平面即 BMP,范围为0x0000 - 0xFFFF,但我们并没有“塞满”这个平面,其中部分位置用于保留,换句话说,BMP的容量只有6万多个字符,想象一下把这些字符整合起来放在一起,密密麻麻!GNU Unifont就制作了一张这样的图片。见http://unifoundry.com/pub/unifont-7.0.03/unifont-7.0.03.bmp 打开可能会费一点时间。

前面说到BMP是我们最常用的平面,日常用到的字符绝大多数都集中在这里,而对于中文来说,我们常用[\u4E00-\u9FA5]正则表达式来匹配,其实也就是指定中文在Unicode字符集中的编号范围0x4E00 - 0x9FA5。其实稍加计算就知道这个范围的容量不过两万多点,中文显然不止这个数。其实9FA5后面还有不少的汉字,它们中间又还夹杂着一些符号,所以想正确地表示Unicode中的汉字还是个不小的挑战。

这里引用一段:应该说,Unicode处在不断发展中,它有一百多万的空间,目前也只是定义了十万左右的字符,还会不断增加,汉字自然也有可能增加,所以汉字的范围实际上是动态的,变化的。当然了,常用的基本落在了这一范围内,而事实上已经包含了许多的不常用汉字,毕竟连只有6千多字的GB2312中都含有大量的不常用汉字。在要求不那么严格的应用中,按以上范围去判断基本也OK,而“汉字”这一概念实际上也没有准确定义,比方说上图中一些“偏旁部首”,这些是“汉字”吗?

平常我们看到诸如 \uU+ 紧跟十六进制数字,其实就是Unicode字符集中字符的编号了,例如 \u4E00 其实就是对应 Unicode 字符集中的汉字“一”。再次强调这里其实不涉及计算机知识,而是单纯的归纳整理的方式而已:一维数字编号。

现在说说计算机底层如何存储这个编号,是直接存储0x4E00吗,还是找到一种映射关系,将0x4E00处理成其他数据后再存储到计算底层里,这里涉及的东西很多,下文会一一解答。

4 UTF - Unicode 转换格式

至此,我们对Unicode字符集有了初步的了解,再次强调一下:即使对字节,编码等计算机概念一窍不通此时是没有任何关系的!正如前面多次强调,字符集就是收集日常所用的所有字符,然后将其归纳整理,比如Unicode这个组织用数字对每个字符编号,将世界上所有文字和符号的字符收录,从0开始编号,一直到0x10FFFF(强调:21位 bits)。想象下,有一张两列的表,左边是编号,右边是对应的字符,看到这你可能脱口而出:一一对应关系。即得到编号0去查表,就知道对应的字符是NULL;同理,拿“a”字符去查索引,应该是0x0041(十进制的65)。

编号 字符
0 NULL
... ...
0x10FFFF 未知...

再换种表示形式,本质其实是一样的:

3.png

更多时候 Unicode 字符集中的字符表示形式应该有两种:U+[xxxx] 和 \u[xxxx],其中x代表十六进制数字。等等!Uncode字符集范围不是到21位的 0x10FFFF吗,怎么这里只有4个x? 难道是因为常用的都在第一平面BMP,它的范围是0x0000-0xFFFF?如果你已经开始这么思考,恭喜你,差不多已经理解我要表达的意思了。

4.1 UTF-32

计算机底层并非直接存储"a","1","丁"等字符,而是先转变成另外一种形式后再存储。首先想到的且最简单的:将编号以二进制存储。如下:

编号 编码后的索引 字符
0 0 NULL
... ... ...
0x10FFFF 0x10FFFF 未知...

编码后的索引和之前归纳整理的编号一模一样,编码或者说映射关系其实很简单:

int map(int idx){
  return idx;
}

早期字符数量还没有现在这么庞大,使用2个字节存储编号足矣(0x0000-0xFFFF)。但随着新字符的加入,字符数量已经超出了上限65536。既然2个字节不够表示,解决方法很简单:4个字节,即 UTF-32 编码。

尝试下,创建一个文件并写入内容“1a”,然后以 UTF-32 编号格式存储,接着以16进制查看这个文件内容(你可以选择可视化工具,或者使用hexdump命令查看),最后看到的应该是0x00000031 0x00000061;现在从计算机读取内容,即``0x00000031 0x00000061`,编辑器每读入4个字节开始解码,查表得到相应的字符最后呈现给我们,这也是最终我们看到的,这里用到的是定长编码方式。

思考:既然 UTF32 存储方便容易理解,为何不一用到底?干嘛还要UTF8-16,UTF-8呢?

其实原因前面已经提及过一次:当存储内容是英文字符或是其他较小编号时,每个字符都要使用4个字节来存储,太过浪费,利用率极低!

为此才有了变长编码 UTF-16 和 UTF-8 。

4.2 UTF-8

UTF-8是变长的编码方案,可以有1,2,3,4四种字节组合。在前面的定长与变长篇章我们提到UTF-8采用了高位保留方式来区别不同变长,如下:

0XXXXXXX                              有效编码位:7 bits
110XXXXX 10XXXXXX                     有效编码位:11 bits
1110XXXX 10XXXXXX 10XXXXXX            有效编码位:16 bits
11110XXX 10XXXXXX 10XXXXXX 10XXXXXX   有效编码位:21 bits

一问:为何使用 0,110,111011110来作为区分?使用1,11,1111111可以吗?
一答:显然使用后者无法区分,比如读入第一个1,你无法确定是一个字节数据;继续读入一个1,你还是无法确定是2个字节数据,同理,读入111 也一样无法确定,但是读入1111就可以确定是四个字节数据了,至于前面的,我们必须读到0才可以确定几位数,比如110,我们才知道是两个字节数据,但是这样使得有效数据位变少,因此我们还是打算以0,110,111011110区分。
二问:为何第二位起使用固定的 10
二答:倘若我们使用0,10,1101110区分读入几个字节,讲道理之后可以不用保留位啊,直接读 XXXX XXXX不是利用率更高吗? —— 我个人认为计算机可以从任何一个字节读入,万一从某个XXXX XXXX读取时,恰巧这个数又是以110或者1110打头的,那一旦开始就读错,之后就是连锁反应,全部都解释错误了。所以我们对于后面的字节也需要保留位,至于10,应该是为了增加可利用位数吧,总不能用11110来作为保留位吧。

如上,0,10,110这些都是保留的固定位,X表示是有效编码位。单字节最高位都是0,多字节的最高位都是1.
针对多字节来讲,我们可以称之为N(N > 1)字节模式,首字节以“N个1再加0”打头,后跟“N-1”个以“10”打头的字节。

说完用 UTF-8 编码后的四种计算机底层存储形式,再来反向说说读取过程。计算机每次读入一个字节,以高位来区分是用1个字节解码,还是2,3,4字节解码。

重点来了:我们实际真正要用的是有效编码位,也就是X,UTF-8 编码就是从这些二进制数据中提取有效编码位,组合得到一个新的十六进制值作为索引去Unicode字符集中查找对应字符。—— 就是这么简单!

  1. 一字节有效编码位有7位,2^7=128,可以表示字符集区间 U+0000U+007F(0127)。

一字节留给了ASCII,所以UTF-8兼容ASCII。

  1. 二字节有效编码位只有5+6=11位,最多只有2^11=2048个编码空间,所以数量众多的汉字是无法容身于此的了。字符集区间 U+0080U+07FF(1282047)使用二字节。

注意:这里编号范围是128~2047,因为去掉了一字节的码点(因为下限是U+0080),所以不会占满2048个编码空间,是有冗余的,但你不能把适用于一字节的码点放到这里来编码。下同。
这个需要解释下为什么范围设定了U+0080~U+07FF 其实可以看下编号后的字符集 U+D800~U+DFFF 是空的!要用作代理区,其实吧就是为了不影响之后的解码冲突。

  1. 三字节模式可看到光是保留位就达到4+2+2=8位,相当一字节,所以只剩下两字节16位有效编码位,它的容量实际也只有65536。码点U+0800U+FFFF(204865535)使用三字节编码。

我们前面说到,一些汉字字典收录的汉字达到了惊人的10万级别。基本上,常用的汉字都落在了这三字节的空间里,这就是我们常说的汉字在UTF-8里用三字节表示。当然了,这么说并不严谨,如果这10万的汉字都被收录进来的话,那些偏门的汉字自然只能被挤到四字节空间上去了。

  1. 四字节的可以看到它的有效位是3+6+6+6=21位,前面说到最大的码点10FFFF也是21位,U+FFFF以上的增补平面的字符都在这里来表示。

按照UTF-8的模式,它还可以扩展到5字节,乃至6字节变长,但Unicode说了码点就到10FFFF,不扩充了,所以UTF-8最多到四字节就足够了。

下面演示如何将汉字”你“(U+4F60)编码存储到计算机底层,来自字符集与编码一文,强烈推荐!

上图显示了一有效位为 15 位的码点到三字节转换的一个基本原理,我们还可看到原来4F60 中的一头一尾的两个 4 和 0 在转换后还存在于最终的三字节结果中。UTF-8 三字节模式固定了 1110 的开头模式,所以多数汉字总是以 1110 开头,换成 16 进制形式,1110 就是字母 E。

如果看到一串的 16 进制有如下的形式:EX XX XX EX XX XX…每三个三个字节前面都是 E 打头,那么它很可能就是一串汉字的 UTF-8 编码了。

其它变长字节转换道理也类似,其中分组从低位开始,高位如不足则补零。这里就不再示例了。

UTF-8 编码的误解:首先并非将 Unicode 字符集编号直接存储到计算机底层,其次前文已经说过按照高位来区分读取多少个字节(四种情况=1个字节 - 0xxxxxxx,2 - 110xxxxx,3 - 1110xxxx,4 - 11110xxx),但是对于读到的数据并非一定是字符在 Unicode 字符集中的编号,我们需要通过提取有效编码位并组合成新的索引,才去字符集中查询。

小结:UTF-8 变长编码方案很好地帮我们解决了字节利用率的问题,在 UTF32 编码方案里,字符"a"底层存储4个字节 0x00000041。而改用 UTF8 只需要一个字节。当然喽,正如上面演示的 UTF-8编码方式,比UTF-32编码f(x){return x}的要复杂一些。

另外我们也能注意到,对于U+0800~U+FFFF范围内(即 BMP 平面)的中文字符,上文说了用 UTF-8 编码需要三个字节,但是中文显然不可能只有6万多个字,那些偏门的中文在Unicode字符集中的编号都是在 U+FFFF 之上了,显然用 UTF-8 编码要用四个字节。

4.3 UTF-16

UTF-8 作为变长编码方案对于 U+0000~U+07FF 范围字符的字节利用率是值得肯定的,而对于 BMP 中U+0800~U+FFFF 确要用3个字节,至于大于U+FFFF的通通都是4个字节,有没有在这方面更好的方案呢?

有,就是作为压轴出场的UTF-16,它是一种变长的2或4字节编码模式。对于BMP内的字符使用2字节编码,其它的则使用4字节组成所谓的代理对来编码。这是如何做到的呢? 且听我一一道来。

还记得GNU Unifont就制作了一张Unicode字符集成员的图片吗?地址:http://unifoundry.com/pub/unifont-7.0.03/unifont-7.0.03.bmp ,请找到D8行至DF行,这里不对应任何字符,是一块空白区域,也就是所谓的代理区(Surrogate Area),在Unicode字符集中的范围是U+D800 ~ U+DFFF(容量为2048),如果以U+DBFF为中心点,将其一分为二,也就有了高代理区(D800–DBFF)和低代理区(DC00–DFFF)两部分,各占1024。下面请回想第一章的二维表示方式,如果行列都有1024,那么这张表格就有1024*1024=16*65536= 16 * 0xFFFF,我们的一个平面容量为0xFFFF,所以它恰好可以表示增补的16个平面中的所有字符。 这张表格如下:

U+0800~U+DFFF 之所以不对应字符,是因为以代理方式存储到内存中时,只要遇到字节在这个范围内的 我们就知道需要进行代理转换了!计算得到0xFFFF编号之上的字符了 也就是那16个补充平面。Unicode 字符集编号显然也会为了编码适当做出调整,比如这里为了代理映射,我们将U+0800~U+DFFF这段区域保留了,不映射字符了。

再次强调:UTF-16 是一种变长的2或4字节编码模式,我们将 Unicode 字符集划分2个范围来讨论:

  1. 字符集区间 U+0000~U+FFFF:计算机以2个字节为单位读取底层数据,如果读到的两个字节不在代理区的范围(U+D800 ~ U+DFFF),那么就直接当做编号直接去Unicode 字符集中查询,例如0x0041查询到的是”a“,0x4F60查询到的是”你“ —— 还记得UTF-8是编码成3个字节存储和读取解码的吗?

  2. 字符集区间 U+10000~U+10FFFF,即16个增补平面:计算机同样以2个字节为单位读取底层数据,如果读到的两个字节高代理区的范围(D800–DBFF),接着再次读取2个字节,一定要是低代理区(DC00–DFFF)。这样2+2 组成一个代理对(Surrogate Pair)查询对应的字符。注意必须是这种先高后低的顺序,如果出现两个高,两个低,或者先低后高,都是非法的。

UTF-16的例子其实很简单,核心其实不过是上面的那种二维表格,(D800,DC00)对应了 Unicode 字符集中编号为U+01000的字符;(DBFF,DFFF)对应了 Unicode 字符集中编号U+10FFFF的字符。就是这么简单。

5 BOM

reserved

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容