一次性弄懂Unicode和UTF-8

1,632 阅读13分钟

前言

在日常开发的过程中,有关 UnicodeUTF-8 的问题并不常出现,但在阅读技术文章或源码时出现频率就比较高了。笔者最近刚好就在开发时遇到了和 Unicode 相关的问题,发现自己对这方面的基础知识并没有充分掌握。因此将相关知识梳理出来,帮助大家理解清楚 UnicodeUTF-8

字符集

什么是字符集?

字符集(Character set)是多个字符的集合,并且每个字符都拥有唯一的编号(即码点,Code Point)。不同的字符集所包含的字符个数不同,常见的字符集有:ASCII 字符集、GB2312 字符集、BIG5 字符集、 GB18030 字符集、Unicode 字符集等。

在没有计算机之前,大部分信息以文本的形式存在,那么如何将文本存储到计算机中呢?

我们知道,在计算机中是通过二进制值来表示信息的,每个二进制位(bit)都有 01 两种状态。而计算机中存储的最小单位就是字节(Byte),由 8 个二进制位组成,那么就可以表示 2^8=256 种状态。

利用这 256 个二进制值,我们可以将字符转换为数值存储到计算机中,假设我们规定:

A: 00000000
B: 00000001
C: 00000010

这样有了一对一的映射关系后,我们就可以把文本 ABC00000000 00000001 00000010 存储到计算机中。这样的一个包含字符 ABC 的映射集合就是我们自定义的”字符集“。

ASCII码

我们在上一节介绍字符集时自定义了一个只包含 ABC 三个字母的字符集,仅仅作为例子可以,但是应用到实际的话显然是不够用的,因为既没有将所有的字母写入,也无法映射空格或标点符号等字符。

为了解决这个问题,在上世纪六十年代,美国制定了一套字符编码,即 ASCII 码(American Standard Code for Information Interchange,美国信息交换标准代码,详见维基百科-ASCII),将英语字符与二进制值进行一一对应,一直沿用至今。

标准 ASCII 码使用7位二进制数(剩下的首位二进制为0)来表示所有的大写和小写字母,数字 0 到 9、标点符号,以及在美式英语中使用的特殊控制字符。比如空格 SPACE 的十进制值是 32(二进制00100000 ),大写的字母 A 的十进制值是 65(二进制01000001),如下图所示。

image.png

ASCII 码对于美国这种使用英语作为母语的国家是够用了,但是对于使用其他语言的国家,128 个二进制值仍不足以表示所有字符,于是一些国家决定利用字节中的闲置最高位编入新的字符,这样一来这些国家使用了 8 位二进制值就可以表示最多 256 个字符。

然而这又带来了新的问题,即使不同国家都使用 256 个字符的编码方式,但是同一个二进制值在不同的国家却表示不同的字符,例如 130 在法语中表示 é ,在希伯来语编码中却代表了字母Gimel (ג),就会造成乱码。

为了解决多语言环境下产生的编码冲突问题,Unicode 应运而生。

Unicode

Unicode将世界上的所有字符囊括其中,并为每一个字符定义唯一的代码(即一个整数),称作码点(Code Point)。

码点的范围是 U+0000~U+10FFFFU+表示这是 Unicode 字符集,后面跟着一个十六进制数

目前的 Unicode 字符分为 17 组编排,每个编组存放65536 (即2^16)个码点,称为一个平面(Plane)。

image.png

例如,U+0041 表示英语的大写字母 AU+4E25 表示汉字,它们都位于基本多文种平面。详见维基百科-Unicode

字符编码

什么是字符编码?

字符,即字母、数字、运算符号、标点符号和其他符号,以及一些功能性符号。

编码,根据词性的不同,表示的含义也不同:

  • 作为动词时,表示信息从一种形式或格式转换为另一种形式的过程,例如将大写字母 A 转换为二进制值 1000001 的过程就是一个编码动作;
  • 作为名词时,有两种表示
    1. 表示将字符转为机器码的方案,例如 ASCII 编码、UTF-8 编码等;
    2. 另一种是表示将字符转换后得到的机器码,例如 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. 编码后的字节长度为 1 时,首位为 0 ,剩余 7 位为 Unicode 码点值。因此码点值的范围是0~128,在这个范围内 ASCII 编码和 UTF-8 是相同的
  2. 编码后的字节长度 n 大于 1 时,首个字节的前 n 位都是 1(即,有几个 1 就表示总共有几个字节),n+1 位为 0 ,其他字节的前两位均为 10,剩余的位为 Unicode 码点值。

在 Unicode 中,一般使用频率较高的都是编码值较小的字符(即大部分都位于基本多文种平面),并且 Unicode 中前 128 个字符也是和 ASCII 码的二进制值相同。UTF-8采用的这种变长编码规则,可以尽可能的节省内存空间,并且完全兼容 ASCII 码,因此,它逐渐成为电子邮件、网页及其他存储或传送文字优先采用的编码方式。

image.png

以大写字母A为例,码点为 U+0041 ,编码后为 1 个字节,和 ASCII 编码下的存储方式相同,都是 01000001

而对于汉字,码点为 U+4E25,编码后为 3 个字节。码点值转换为二进制是 1001110 00100101,共15位,由转换关系表可知它落在 3 字节序列中,因此转换后的格式应该是 1110xxxx 10xxxxxx 10xxxxxx,将码点值按顺序补位后得到 11100100 10111000 10100101,这就是 UTF-8 编码后的汉字严,过程如下图:

WX20220906-105351.png

我们来验证下上面的转换过程。

首先准备两个 txt 文件,第一个文件里只有字母 A,第二个文件里只有汉字 严,保存文件时以 UTF-8 格式保存。这里笔者用的是 Mac 系统,可以直接在命令行中执行echo命令输出到txt文件中,默认的编码格式就是UTF-8

echo 'A' > 'A.txt'
echo '严' > '严.txt'

然后打开命令行,使用hexdump命令查看文件:

hexdump 命令可以以 ASCII、八进制、十进制或十六进制显示文件内容。

image.png

第一列的两个值是文件中的字符偏移量,我们的重点在第二列。显然 A.txt 的结果是符合预期的,因为ASCII 码在 UTF-8 的编码方式下和 Unicode 码点值是相同的。而严.txt的结果不太一样,显然是经过了变长的编码算法转换后得到的。

我们将e4 b8 a5的十六进制值转换一下。可以使用在线进制转换工具 tool.oschina.net/hexconvert/ 或者直接将每一个十六进制数转换为二进制进行组合,转换结果就是:

image.png

这样就和上文我们转换的结果相同了。

UTF-16 编码

UTF-16 也是一种变长编码,它将 Unicode 码点映射为 16 位长的整数(即码元)的序列,用于数据存储或传递。Unicode 字符的码点,使用 1 个或者 2 个 16 位长的码元来表示。

码元(Code Unit,也称“代码单元”)是指一个已编码的文本中具有最短的比特组合的单元。对于 UTF-8 来说,码元是 8bit ;对于 UTF-16 来说,码元是 16bit;对于 UTF-32 来说,码元是 32bit。

Unicode 的基本多文种平面码点范围是 U+0000U+FFFF ,不过从 U+D800U+DFFF 之间的码位区段是永久保留不映射到 Unicode 字符的,因此 UTF-16 就利用保留下来的 U+D800U+DFFF 区块的码位来对辅助平面的字符的码位进行编码。

编码规则如下:

  • 对于从 U+0000U+D7FF 以及 U+E000U+FFFF 的码位:UTF-16 编码这个范围内的码位为 16bit 的单个码元,数值就等价于对应的码位;
  • 对于从 U+10000U+10FFFF 的码位:这个范围是补充平面中的码位,在 UTF-16 中被编码为两个 16bit 的码元(即 32 位,4 字节),称作代理对。

image.png

编码过程如下:

  1. 码位减去 0x10000,得到的值的范围为20bit 的 0...0xFFFFF
  2. 高位的 10bit 的值(从左往右数第 1 位到第 10 位)加上 0xD800 后得到第一个码元,称作前导代理,值的范围是 0xD800...0xDBFF
  3. 低位的 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。整个过程可以看下图:

WX20220906-142132.png

同样地,我们来验证下上面的转换过程。

将单个字符存储在 txt 文件中,选择 UTF-16 BE 的编码方式进行保存。

UTF-16 BE是一种字节序,有关字节序的知识见下一节。

同样使用 hexdump 命令查看:

image.png

符合推测结果。

UTF 字节序

字节序也叫尾序或端序,详细介绍可见 维基百科-字节序

顾名思义,字节序就是指字节之间的顺序,在传输or存储过程中如果最小编码单元超过1个字节,需要指定编码单元内的字节间顺序。因此对于UTF-8不存在字节序的问题,而UTF-16时就需要考虑字节序的问题了。

字节序有两个通用规则:

  1. 小端序(little endian):多位数的低位放在较小的地址处,高位放在较大的地址处。
  2. 大端序(big endian):和小端序相反,多位数的高位放在较小的地址处,低位放在较大的地址处。

以上一节的文本为例,下图为字符🐶在 UTF-16 编码下使用不同端序时在内存中的存储结果:

大端序小端序 (1).png

网络传输中一般采用大端序,也被称之为网络字节序,或网络序。在网络传输时,不存在字节序列问题。而在解码时由于 cpu 硬件差异,存在字节序问题,所以需要通过BOM( byte order mark ) 标识来标记字节顺序,通常出现在字节流的开头。

UTF-8 与 UTF-16 对比

综上所述,我们来对比一下 UTF-8 和 UTF-16。

标题UTF-8UTF-16
兼容性好,完美兼容 ASCII码,字符空间足够大UTF-16 能表示的字符数有 6w 多,看起来很多,但是实际上目前 Unicode 收录的字符已经达到 9w 个字符,早已超过 UTF-16 的存储范围
字节序不存在字节序问题,信息交换便捷存在大小端字节序问题,信息交换时可能出现问题
容错率高,个别字节的错误不会导致整个文档的不可用,字符边界明显低,局部的字节错误,可能导致所有后续字符全部错乱
效率变长字节导致计算字符数和执行索引等操作效率都不高双字节,在计算字符串长度、执行索引操作时速度很快
多语种环境ASCII 码只占用一个字节,而对于 CJK 文字来说负担太大,一个字符占用 3 个字节刚好和前者相反
·

无论是 UTF-8 和 UTF-16/32 都各有优缺点,因此选择的时候应当立足于实际的应用场景

总结

本文主要介绍了字符编码和字符集、Unicode 编码以及 Unicode 的实现方式—— UTF-8UTF-16 两种编码方式的相关知识。需要注意的是 Unicode 编码一般指的是 Unicode 字符集,而 UTF-8UTF-16 编码指的是 Unicode 的实现方式,希望本文能够帮助大家理解清楚这些知识。

参考

  1. home.unicode.org/
  2. zh.wikipedia.org/wiki/Unicod…
  3. zh.wikipedia.org/wiki/Unicod…
  4. zh.m.wikipedia.org/zh-hans/UTF…
  5. unicode-table.com/cn/