前言
在日常开发的过程中,有关 Unicode
、UTF-8
的问题并不常出现,但在阅读技术文章或源码时出现频率就比较高了。笔者最近刚好就在开发时遇到了和 Unicode
相关的问题,发现自己对这方面的基础知识并没有充分掌握。因此将相关知识梳理出来,帮助大家理解清楚 Unicode
和 UTF-8
。
字符集
什么是字符集?
字符集(Character set)是多个字符的集合,并且每个字符都拥有唯一的编号(即码点,Code Point)。不同的字符集所包含的字符个数不同,常见的字符集有:ASCII
字符集、GB2312
字符集、BIG5
字符集、GB18030
字符集、Unicode
字符集等。
在没有计算机之前,大部分信息以文本的形式存在,那么如何将文本存储到计算机中呢?
我们知道,在计算机中是通过二进制值来表示信息的,每个二进制位(bit)都有 0
和 1
两种状态。而计算机中存储的最小单位就是字节(Byte),由 8 个二进制位组成,那么就可以表示 2^8=256 种状态。
利用这 256 个二进制值,我们可以将字符转换为数值存储到计算机中,假设我们规定:
|
|
这样有了一对一的映射关系后,我们就可以把文本 ABC
用 00000000 00000001 00000010
存储到计算机中。这样的一个包含字符 ABC
的映射集合就是我们自定义的” 字符集 “。
ASCII 码
我们在上一节介绍字符集时自定义了一个只包含 ABC
三个字母的字符集,仅仅作为例子可以,但是应用到实际的话显然是不够用的,因为既没有将所有的字母写入,也无法映射空格或标点符号等字符。
为了解决这个问题,在上世纪六十年代,美国制定了一套字符编码,即 ASCII
码(American Standard Code for Information Interchange,美国信息交换标准代码,详见维基百科 - ASCII),将英语字符与二进制值进行一一对应,一直沿用至今。
标准 ASCII
码使用 7
位二进制数(剩下的首位二进制为 0
)来表示所有的大写和小写字母,数字 0 到 9、标点符号,以及在美式英语中使用的特殊控制字符。比如空格 SPACE
的十进制值是 32(二进制 00100000
),大写的字母 A
的十进制值是 65(二进制 01000001
),如下图所示。
ASCII
码对于美国这种使用英语作为母语的国家是够用了,但是对于使用其他语言的国家,128 个二进制值仍不足以表示所有字符,于是一些国家决定利用字节中的闲置最高位编入新的字符,这样一来这些国家使用了 8 位二进制值就可以表示最多 256 个字符。
然而这又带来了新的问题,即使不同国家都使用 256 个字符的编码方式,但是同一个二进制值在不同的国家却表示不同的字符,例如 130 在法语中表示 é ,在希伯来语编码中却代表了字母 Gimel (ג),就会造成乱码。
为了解决多语言环境下产生的编码冲突问题,Unicode
应运而生。
Unicode
Unicode
将世界上的所有字符囊括其中,并为每一个字符定义唯一的代码(即一个整数),称作码点(Code Point)。
码点的范围是 U+0000
~U+10FFFF
,U+
表示这是 Unicode
字符集,后面跟着一个十六进制数。
目前的 Unicode
字符分为 17 组编排,每个编组存放 65536
(即 2^16)个码点,称为一个平面(Plane)。
例如,U+0041
表示英语的大写字母 A
,U+4E25
表示汉字严
,它们都位于基本多文种平面。详见维基百科 - Unicode。
字符编码
什么是字符编码?
字符,即字母、数字、运算符号、标点符号和其他符号,以及一些功能性符号。
编码,根据词性的不同,表示的含义也不同:
- 作为动词时,表示信息从一种形式或格式转换为另一种形式的过程,例如将大写字母
A
转换为二进制值1000001
的过程就是一个编码动作; - 作为名词时,有两种表示
- 表示将字符转为机器码的方案,例如
ASCII
编码、UTF-8
编码等; - 另一种是表示将字符转换后得到的机器码,例如
100001
就是A
的编码。
- 表示将字符转为机器码的方案,例如
因此在阅读有关字符编码的文章时,应该根据当前上下文来判断编码一词的含义。
Unicode 的实现方式
Unicode
字符集解决了多语种间的冲突问题,但是并没有规定如何将编码存储到计算机中。
以大写字母 A 为例,它的 Unicode 码点为 U+0041
,转换成二进制为 1000001
,需要使用 1 个字节存储;汉字 严 的 Unicode 码点为 U+4E25
,转换成二进制为 1001110 00100101
,需要使用 2 个字节存储。而位于编号更靠后的平面中的字符,转换成二进制数字就会更长,最高位 U+10FFFF
甚至需要 3 个字节来存储。
在这种情况下所面临的问题就是,计算机无法得知某个字符究竟需要多少字节存储,假设统一使用 3 个字节来存储 1 个字符,那么存储位于基本多文种平面的字符,就会有 2 个字节的所有位都是 0 ,会造成存储资源的浪费。
为了解决存储方式上存在的问题,就出现了 UTF
(Unicode 转换格式,Unicode Transformation Formats,简称 UTF)系列的编码方式。下面介绍一下几种常见的实现方式。
UTF-8 编码
UTF-8
编码是互联网上使用最广泛的一种 Unicode
的实现方式。
它是一种变长编码。对于 ASCII
字符仍用 7 位编码表示,占用一个字节(首位补 0);而遇到其他 Unicode
字符时,将按一定算法转换,每个字符使用 1 到 4 个字节编码。
编码规则也很简单:
- 编码后的字节长度为 1 时,首位为 0 ,剩余 7 位为
Unicode
码点值。因此码点值的范围是 0~128,在这个范围内 ASCII 编码和 UTF-8 是相同的; - 编码后的字节长度 n 大于 1 时,首个字节的前 n 位都是 1(即,有几个 1 就表示总共有几个字节),n+1 位为 0 ,其他字节的前两位均为 10,剩余的位为
Unicode
码点值。
在 Unicode 中,一般使用频率较高的都是编码值较小的字符(即大部分都位于基本多文种平面),并且 Unicode 中前 128 个字符也是和 ASCII 码的二进制值相同。UTF-8 采用的这种变长编码规则,可以尽可能的节省内存空间,并且完全兼容 ASCII 码,因此,它逐渐成为电子邮件、网页及其他存储或传送文字优先采用的编码方式。
以大写字母 A
为例,码点为 U+0041
,编码后为 1 个字节,和 ASCII
编码下的存储方式相同,都是 01000001
;
而对于汉字严
,码点为 U+4E25
,编码后为 3 个字节。码点值转换为二进制是 1001110 00100101
,共 15 位,由转换关系表可知它落在 3 字节序列中,因此转换后的格式应该是 1110xxxx 10xxxxxx 10xxxxxx
,将码点值按顺序补位后得到 11100100 10111000 10100101
,这就是 UTF-8
编码后的汉字严,过程如下图:
我们来验证下上面的转换过程。
首先准备两个 txt 文件,第一个文件里只有字母 A,第二个文件里只有汉字 严,保存文件时以 UTF-8
格式保存。这里笔者用的是 Mac 系统,可以直接在命令行中执行 echo
命令输出到 txt 文件中,默认的编码格式就是 UTF-8
:
|
|
然后打开命令行,使用 hexdump
命令查看文件:
hexdump 命令可以以 ASCII、八进制、十进制或十六进制显示文件内容。
第一列的两个值是文件中的字符偏移量,我们的重点在第二列。显然 A.txt 的结果是符合预期的,因为 ASCII
码在 UTF-8
的编码方式下和 Unicode 码点值是相同的。而严.txt 的结果不太一样,显然是经过了变长的编码算法转换后得到的。
我们将 e4 b8 a5
的十六进制值转换一下。可以使用在线进制转换工具 tool.oschina.net/hexconvert/ 或者直接将每一个十六进制数转换为二进制进行组合,转换结果就是:
这样就和上文我们转换的结果相同了。
UTF-16 编码
UTF-16
也是一种变长编码,它将 Unicode
码点映射为 16 位长的整数(即码元)的序列,用于数据存储或传递。Unicode
字符的码点,使用 1 个或者 2 个 16 位长的码元来表示。
码元(Code Unit,也称 “代码单元”)是指一个已编码的文本中具有最短的比特组合的单元。对于 UTF-8 来说,码元是 8bit ;对于 UTF-16 来说,码元是 16bit;对于 UTF-32 来说,码元是 32bit。
Unicode
的基本多文种平面码点范围是 U+0000
到 U+FFFF
,不过从 U+D800
到 U+DFFF
之间的码位区段是永久保留不映射到 Unicode 字符的,因此 UTF-16 就利用保留下来的 U+D800
到 U+DFFF
区块的码位来对辅助平面的字符的码位进行编码。
编码规则如下:
- 对于从
U+0000
到U+D7FF
以及U+E000
到U+FFFF
的码位:UTF-16 编码这个范围内的码位为 16bit 的单个码元,数值就等价于对应的码位; - 对于从
U+10000
到U+10FFFF
的码位:这个范围是补充平面中的码位,在 UTF-16 中被编码为两个 16bit 的码元(即 32 位,4 字节),称作 代理对。
![(assets/image.png-5.webp)
编码过程如下:
- 码位减去
0x10000
,得到的值的范围为 20bit 的0...0xFFFFF
。 - 高位的 10bit 的值(从左往右数第 1 位到第 10 位)加上
0xD800
后得到第一个码元,称作前导代理,值的范围是0xD800...0xDBFF
。 - 低位的 10bit 的值(从左往右数第 11 位到第 20 位)加上
0xDC00
后得到第二个码元,称作后尾代理,值的范围是0xDC00...0xDFFF
。
以补充平面的字符 🐶 为例,它的码点值是 U+1F436:
unicode-table.com/cn/ 可以在这个网站中查找某个 Unicode 字符的码点值
- 第一步,
0x1F436
减去0x10000
,得到0xF436
,转为二进制位为11110100 00110110
- 高位 10bit 为
00 0011 1101
(不足的位补0
),转为 16 进制是0x003D
,加上0xD800
后为0xD83D
- 低位 10bit 为
00 0011 0110
,转为 16 进制是0x0036
,加上0xDC00
后为0xDC36
因此最终转换后的值应该是 D8 3D DC 36
。整个过程可以看下图:
同样地,我们来验证下上面的转换过程。
将单个字符存储在 txt 文件中,选择 UTF-16 BE
的编码方式进行保存。
UTF-16 BE 是一种字节序,有关字节序的知识见下一节。
同样使用 hexdump
命令查看:
符合推测结果。
UTF 字节序
字节序也叫尾序或端序,详细介绍可见 维基百科 - 字节序。
顾名思义,字节序就是指字节之间的顺序,在传输 or 存储过程中如果最小编码单元超过 1 个字节,需要指定编码单元内的字节间顺序。因此对于 UTF-8
不存在字节序的问题,而 UTF-16
时就需要考虑字节序的问题了。
字节序有两个通用规则:
- 小端序(little endian):多位数的低位放在较小的地址处,高位放在较大的地址处。
- 大端序(big endian):和小端序相反,多位数的高位放在较小的地址处,低位放在较大的地址处。
以上一节的文本为例,下图为字符🐶在 UTF-16
编码下使用不同端序时在内存中的存储结果:
网络传输中一般采用大端序,也被称之为网络字节序,或网络序。在网络传输时,不存在字节序列问题。而在解码时由于 cpu 硬件差异,存在字节序问题,所以需要通过 BOM
(byte order mark) 标识来标记字节顺序,通常出现在字节流的开头。
UTF-8 与 UTF-16 对比
综上所述,我们来对比一下 UTF-8 和 UTF-16。
标题 | UTF-8 | UTF-16 |
---|---|---|
兼容性 | 好,完美兼容 ASCII 码,字符空间足够大 | UTF-16 能表示的字符数有 6w 多,看起来很多,但是实际上目前 Unicode 收录的字符已经达到 9w 个字符,早已超过 UTF-16 的存储范围 |
字节序 | 不存在字节序问题,信息交换便捷 | 存在大小端字节序问题,信息交换时可能出现问题 |
容错率 | 高,个别字节的错误不会导致整个文档的不可用,字符边界明显 | 低,局部的字节错误,可能导致所有后续字符全部错乱 |
效率 | 变长字节导致计算字符数和执行索引等操作效率都不高 | 双字节,在计算字符串长度、执行索引操作时速度很快 |
多语种环境 | ASCII 码只占用一个字节,而对于 CJK 文字来说负担太大,一个字符占用 3 个字节 | 刚好和前者相反 |
无论是 UTF-8 和 UTF-16/32 都各有优缺点,因此选择的时候应当立足于实际的应用场景。
总结
本文主要介绍了字符编码和字符集、Unicode
编码以及 Unicode
的实现方式 —— UTF-8
和 UTF-16
两种编码方式的相关知识。需要注意的是 Unicode
编码一般指的是 Unicode
字符集,而 UTF-8
和 UTF-16
编码指的是 Unicode
的实现方式,希望本文能够帮助大家理解清楚这些知识。