全文链接:
- 字符编码(一:术语及字符编码由来)
- 字符编码(二:简体汉字编码与 ANSI 编码 )
- 字符编码(三:Unicode 编码系统与字节序)
- 字符编码(四:UTF 系列编码详解)
- 字符编码(五:网络传输编码 Base64、百分号编码)
1.8 UTF-8 编码
接下来将分别介绍 Unicode 字符集的三种编码方式:UTF-8、UTF-16、UTF-32。这里先介绍应用最为广泛的 UTF-8。
UTF-8(8-bit Unicode Transformation Format)是一种针对Unicode的可变长度字符编码,也是一种前缀码,是现代字符编码模型中的第三层 CEF 。它可以用一至四个字节对 Unicode 字符集中的所有有效编码点进行编码,属于Unicode标准的一部分,最初由肯·汤普逊和罗布·派克提出。由于较小值的编码点一般使用频率较高,直接使用 Unicode 编码效率低下,大量浪费内存空间。UTF-8 就是为了解决向后兼容 ASCII 码而设计,Unicode 中前 128 个字符(与 ASCII 码一一对应),使用与 ASCII 码相同的二进制值的单个字节进行编码,这使得原来处理 ASCII 字符的软件无须或只须做少部分修改,即可继续使用。因此,它逐渐成为电子邮件、网页及其他存储或发送文字优先采用的编码方式。
—— 维基百科
为满足基于 ASCII、面向字节的字符处理的需要,Unicode 标准中定义了 UTF-8 编码方式。UTF-8 应该是目前应用最广泛的一种 Unicode 编码方式。
由于 UTF-16 对于 ASCII 字符也必须使用两个字节(因为是 16 位码元)进行编码,存储和处理效率相对低下,并且由于ASCII 字符经过 UTF-16 编码后得到的两个字节,高字节始终是 0x00,很多 C 语言的函数都将此字节视为字符串末尾从而导致无法正确解析文本。
因此,UTF-16 一开始推出的时候就遭到很多西方国家的抵制,大大影响了 Unicode 的推行。于是设计了 UTF-8 编码方式,才解决了这些问题。
ps:按个人理解纠正下原文:UTF-16 编码 1996 年才得以公布,且微软 Windows 2000 以后才使用 UTF-16,之前实质为 UCS-2。所以上文中的 UTF-16 实指 UCS-2。
UTF-8 的码元由 8 位单字节组成;在 UTF-8 中,因为码元较小的缘故,Unicode 码点值被映射到一个、两个、三个或四个码元;换言之,UTF-8 使用一个至四个 8 位单字节码元的序列来表示 Unicode 字符。因此,UTF-8 是一种使用单字节码元的变宽(即变长或不定长)码元序列的编码方式。
UTF-8 编码方式对所有 ASCII 码点值(0x00~0x7F)具有透明性。所谓透明性,具体指的是在 U+0000 到 U+007F 范围内(十进制为 0~127)的 Unicode 码点值,亦即 ASCII 字符的 Unicode 码点值,被直接转换为 UTF-8 单一字节码元0x00~0x7F,与 ASCII 码没有区别。
并且,0x00~0x7F 不会出现在 UTF-8 编码的非 ASCII 字符的首字节与非首字节的任意一个字节中(非 ASCII 字符的 UTF-8 编码为由两个或两个以上的单字节码元所组成的码元序列),这样就保证了与早已应用广泛且已成为工业标准的 ASCII 编码的完全兼容,且避免了歧义,同时纠错能力也强。
发展历史
1990年, ISO/IEC 10646的初稿(DIS 10646)中有一个非必须的附录,名为 UTF。当中包含了一个供 32 比特的字符使用的字节串编码系统(即当时的 UCS-4,后来的 UTF-32 )。这个编码方式的性能并不令人满意,但它提出了将 0-127 的范围保留给 ASCII 以兼容旧系统的概念。
1992 年初,为创建良好的字节串编码系统以供多字节字符集使用,开始了一个正式的研究。
1992 年 7 月,X/Open委员会 XoJIG 开始寻求一个较佳的编码系统。Unix 系统实验室(USL)的 Dave Prosser 为此提出了一个编码系统的建议。它具备可更快速实现的特性,并引入一项新的改进。其中,7 比特的ASCII符号只代表原来的意思,所有多字节序列则会包含第 8 比特的符号,也就是所谓的最高有效比特。
1992 年 8 月,这个建议由IBM和X/Open的代表流传到一些感兴趣的团体。与此同时,贝尔实验室九号项目操作系统工作小组的肯·汤普逊(Ken Thompson)对这编码系统作出重大的修改,让编码可以自我同步,使得不必从字符串的开首读取,也能找出字符间的分界。1992 年 9 月 2 日,肯·汤普逊和罗勃·派克一起在美国新泽西州一架餐车的餐桌垫上描绘出此设计的要点。接下来的日子,派克及汤普逊将它实现,并将这编码系统完全应用在九号项目当中,及后他将有关成果反馈 X/Open。
1993 年 1 月 25-29 日的在圣地牙哥举行的USENIX会议首次正式介绍 UTF-8。
自 1996 年起,微软的CAB(MS Cabinet)规格在 UTF-8 标准正式落实前就明确容许在任何地方使用 UTF-8 编码系统。但有关的编码器实际上没有实现这方面的规格。
2003 年十一月 UTF-8 被标准化为 RFC 3629。
RFC 是由互联网工程任务组(IETF)发布的一系列备忘录,也可称为 “互联网标准”,文件收集了有关互联网相关信息,以及UNIX和互联网社群的软件文件,以编号排定。目前 RFC 文件是由互联网协会(ISOC)赞助发行
RFC 在成为标准或标准的一部分之前,首先成为互联网草案,通常在经过若干次修订后被 RFC 编辑所接受被标注为提名标准。在此之后,RFC 可以被标注为互联网标准。
从 2009 年以来,UTF-8 一直是万维网的最主要的编码形式(并由 WHATWG 宣布为强制性的 “适用于所有事物( for all things )”。
结构与描述
UTF-8 使用一至六个字节为每个字符编码(尽管如此,2003 年 11 月 UTF-8 被 RFC 3629 重新规范,只能使用原来Unicode 定义的区域,U+0000 到 U+10FFFF,也就是说最多四个字节):
- 128 个 US-ASCII 字符只需一个字节编码( Unicode 范围由 U+0000 至 U+007F )。
- 带有附加符号的拉丁文、希腊文、西里尔字母、亚美尼亚语、希伯来文、阿拉伯文、叙利亚文及它拿字母则需要两个字节编码( Unicode 范围由 U+0080 至 U+07FF )。
- 其他基本多文种平面(BMP)中的字符(这包含了大部分常用字,如大部分的汉字)使用三个字节编码( Unicode范围由 U+0800 至 U+FFFF )。
- 其他极少使用的 Unicode 辅助平面的字符使用四至六字节编码( Unicode 范围由 U+10000 至 U+1FFFFF 使用四字节,Unicode 范围由 U+200000 至 U+3FFFFFF 使用五字节,Unicode 范围由 U+4000000 至 U+7FFFFFFF 使用六字节)。
对上述提及的第四种字符而言,UTF-8 使用四至六个字节来编码似乎太耗费资源了。但 UTF-8 对所有常用的字符都可以用三个字节表示,而且它的另一种选择,UTF-16编码,对前述的第四种字符同样需要四个字节来编码,所以要决定 UTF-8 或 UTF-16 哪种编码比较有效率,还要视所使用的字符的分布范围而定。不过,如果使用一些传统的压缩系统,比如DEFLATE,则这些不同编码系统间的的差异就变得微不足道了。若顾及传统压缩算法在压缩较短文字上的效果不大,可以考虑使用Unicode 标准压缩格式(SCSU)。
目前有好几份关于 UTF-8 详细规格的文件,但这些文件在定义上有些许的不同:
- RFC 3629 / STD 63(2003),这份文件制定了 UTF-8 是标准的互联网协议元素
- 第四版,The Unicode Standard ,§3.9-§3.10(2003)
- ISO/IEC 10646 - 1:2000 附加文件 D(2000)
它们取代了以下那些被淘汰的定义:
- ISO/IEC 10646-1:1993 修正案2/附加文件R(1996)
- 第二版,The Unicode Standard,附录A(1996)
- RFC 2044(1996)
- RFC 2279(1998)
- 第三版,The Unicode Standard,§2.3(2000)及勘误表#1:UTF-8 Shortest Form(2000)
- Unicode Standard 附加文件#27: Unicode 3.1(2001)
事实上,所有定义的基本原理都是相同的,它们之间最主要的不同是支持的字符范围及无效输入的处理方法。
UTF-8 优点
UTF-8 同其他的多字节码元编码方式相比具有以下优点:
-
UTF-8 的编码空间足够大,未来 Unicode 新标准收录更多字符,UTF-8 也能适应,因此不会再出现 UTF-16 那样的尴尬。
注:这里所指的编码空间并不是前文所提到的编号空间 Code Space,编号空间属于编号字符集 CCS 里的概念,而编码空间属于字符编码方式 CEF 里的概念,两者不能等同;这里的编码空间可理解为编码方式的未来可扩展性、高适应性,详见后文。
-
UTF-8 是变长编码(准确地说是变长码元序列,而码元本身是固定长度为 8 位单字节的,也就是说,UTF-8 采用的是单字节码元),比如一个字节足以容纳所有的 ASCII 码字符,就用一个字节来存储,不必在高位补 0 以浪费更多的字节来存储,因此在英语作为国际语言的现实情况下,UTF-8 因其 ASCII 字符的单字节编码这一特性可节省大量存储空间。
-
UTF-8 完全直接兼容 ASCII 码,而非不完全间接兼容。
-
UTF-8 的码元序列的第一个字节指明了后面所跟的字节的数目(即带有前缀码),这对字节流的前向解析非常有效(详见后文)。
-
也因为 UTF-8 编码带有前缀码,所以容错性好,即使在传输过程中发生局部的字节错误,比如即便丢失、增加、改变了某些字节,也不会导致所有后续字符全部错乱这样传递性、连锁性的错误问题(否则,若存在错误传递性、连锁性的话,一旦中间某些字节出错,则必须丢弃从出错点开始到结尾的所有编码字节,比如 GB 码、UTF-32 码就是如此),因此很容易重新同步,具有很强的鲁棒性(即健壮性)。
-
由于 UTF-8 编码没有状态,从 UTF-8 字节流的任意位置开始可以有效地找到一个字符的起始位置,字符边界很容易界定、检测出来,所以具有很好的 “自同步性”。
-
UTF-8 已经成为互联网所采用的字符编码方式的事实标准。
-
UTF-8 是字节顺序无关的(因为采用的是单字节码元,而非像 UTF-16、UTF-32 采用的是多字节码元),它的字节顺序在所有系统中都是一样的,其码元序列与字节序列相同,因此它实际上并不需要字节顺序标记 BOM( Byte-Orde Mark ),虽然 Windows 系统经常 “多此一举” 地加上 BOM 。(有关字节序标记 BOM 的介绍见下文)
字节序问题在进行信息交换时会带来不小的麻烦。如果字节序未协商好,将导致乱码;若协商结果为双方一个采用大端一个采用小端,则必然有一方要进行大小端转换,性能损失不可避免(字节序的大小端问题其实不像看起来那么简单,有时会涉及硬件、操作系统、上层应用软件多个层次,可能会导致多次转换,详见前文中字节序内容)。
-
字节 FE(二进制为 1111 1110 )和 FF(二进制为 1111 1111 )在 UTF-8 编码中永远不会出现(因为 UTF-8 编码方式中,每个字节只能以 0、110、1110、11110 或 10 开头,详见后文介绍)。因此可以用称之为零宽度不中断空格( ZERO WIDTH NO-BREAK SPACE )的字符( Unicode 字符名称为 U+FEFF )作为字节顺序标记 BOM 来标明 UTF-16 或 UTF-32 文本的字节序。
Windows 系统中 BOM 有时也用在 UTF-8 编码的文本文件的开头,虽然 UTF-8 编码并不存在字节序问题,但Windows 却用 BOM 来表明该文本文件的编码格式为 UTF-8,这看起来有点 “多此一举”,其具体原因详见后文
-
UTF-8 编码可以通过屏蔽位和移位操作快速读写。
-
字符串比较时 strcmp() 和 wcscmp() 的返回结果相同,因此使排序变得更加容易。
由于 UTF-8 编码方式以一个字节(8 位)作为码元,属于单字节码元,在计算机处理、存储和传输时不存在字节序问题(字节序问题只跟多字节码元有关),因此避免了平台依赖性,跨平台兼容性好。
它相对于其他编码方式对英语更为友好,同样也对计算机语言(如 C++、Java、C#、JavaScript、PHP、HTML 等)更为友好。它在处理 ASCII 等常用字符集时很少会比 UTF-16 低效。
所以,**UTF-8 是较为平衡、较为理想的 Unicode 编码方式。**虽然 Windows 平台由于历史的原因 API 缺乏对 UTF-8 的原生支持( Windows 原生支持的是 UTF-16,因为 UTF-16 早于 UTF-8 面世),导致 UTF-8 推出后的早期使用不广,但目前是应用最为广泛的三大 UTF 编码方式之一。
因此,应该尽量使用 UTF-8(准确地说,应该尽量使用 UTF-8 without BOM,即不带字节顺序标记 BOM 的 UTF-8 )。
UTF-8 缺点
UTF-8 编码方式也并非完美无缺,大致上有如下缺点:
-
不利于正则表达式检索:无法根据字符数直接判断出 UTF-8 文本的字节数,因为 UTF-8 是一种变长编码方式(码元虽然固定为 8 位单字节,但码元序列是变长的,可能是单个码元共 8 位,比如 ASCII 字符;也可能是两个码元共 16 位、三个码元共 24 位、四个码元共 32 位等)。因此,无论是计算字符数,还是执行索引操作,效率都不高。
-
方块文字占用空间大一些:需要用 2 个字节编码那些在扩展 ASCII(即 EASCII )字符集中只需 1 个字节编码的扩展字符。与其他 Unicode 编码相比,特别是 UTF-16,在 UTF-8 中 ASCII 字符占用的空间只有一半,可是在一些字符的 UTF-8 编码占用的空间就要多出 1/3,特别是中文、日文和韩文这样的方块文字。其中大部分汉字需要使用三个字节,极少数使用 4 个字节表示,而 UTF-16 基本使用 2 个字节表示汉字。
-
以 8 位单字节码元编码的 UTF-8 字符会被 Email 网关过滤:因为 Internet 上的信息传输最初设计为 7 位 ASCII 码字符( ASCII 仅用到了 1 个字节的低 7 位)的传输。因此产生了 UTF-7 编码(类似于同样为 Email 传输而设计的Base64编码或 quoted-printable 编码,由于 Base64 编码或 quoted-printable 编码各有其不足,因此又设计了 UTF-7 编码)。
-
UTF-8 在它的表示中使用值 100xxxxx 的几率超过 50%,而现存的实现如 ISO 2022、4873、6429 和 8859 编码系统(也就是那些 ANSI 编码 ),会把它错认为是 C1 控制码。因此产生了 UTF-7.5 编码。
编码算法、前缀码
UTF-8 编码是 Unicode 字符集的一种字符编码方式(CEF),其特点是使用变长字节数(即变长码元序列或称变宽码元序列)来编码。目前一般是 1 到 4 个字节。
为什么要变长呢?这可以理解为按需分配,比如一个字节足以容纳所有的 ASCII 字符,那何必补一堆 0,导致占用更多的字节来存储呢?
实际上变长编码有其优势,也有其劣势,优势方面除了上面所讲的节省存储空间之外,还有就是自动纠错性能好、利于传输、扩展性强,而劣势方面主要是由于字符的编码字节数不固定导致不利于程序内部处理,比如导致正则表达式检索的复杂度大为增加;而 UTF-32 这样的等长码元序列(即等宽码元序列)的编码方式就比较适合程序处理,当然,缺点是比较耗费存储空间。
那 UTF-8 究竟是怎么编码的呢?也就是说其编码算法是什么?
UTF-8 编码最短的为一个字节、最长的目前为四个字节,从首字节就可以判断一个 UTF-8 编码有几个字节:
- 如果首字节以 0 开头,肯定是单字节编码(即单个单字节码元);
- 如果首字节以 110 开头,肯定是双字节编码(即由两个单字节码元所组成的双码元序列);
- 如果首字节以 1110 开头,肯定是三字节编码(即由三个单字节码元所组成的三码元序列),以此类推。
另外,UTF-8 编码中,除了单字节编码外,由多个单字节码元所组成的多字节编码其首字节以外的后续字节均以 10 开头(以区别于单字节编码以及多字节编码的首字节)。
0、110、1110 以及 10 相当于 UTF-8 编码中各个字节的前缀,因此称之为前缀码。其中,前缀码 110、1110 及 10 中的 0,是前缀码中的终结标志。
UTF-8 编码中的前缀码起到了很好的区分和标识的作用:
- 当解码程序读取到一个字节的首位为 0,表示这是一个单字节编码的 ASCI I字符;
- 当读取到一个字节的首位为 1,表示这是一个非 ASCII 字符的多字节编码字符中的某个字节(可能是首字节,也可能是后续字节),接下来若继续读取到一个 1,则确定为首字节,再继续读取直到遇见终结标志 0 为止,读取了几个1,就表示该字符为几个字节的编码;
- 当读取到一个字节的首位为 1,紧接着读取到一个终结标志 0,则该字节显然是非 ASCII 字符的后续字节(即非首字节)。
所以,1~4 字节的 UTF-8 编码看起来分别是这样的:

单字节可编码的 Unicode 码点值范围十六进制为 0x0000 ~ 0x007F,十进制为 0 ~ 127;
双字节可编码的 Unicode 码点值范围十六进制为 0x0080 ~ 0x07FF,十进制为 128 ~ 2047;
三字节可编码的 Unicode 码点值范围十六进制为 0x0800 ~ 0xFFFF,十进制为 2048 ~ 65535;
四字节可编码的 Unicode 码点值范围十六进制为 0x10000 ~ 0x1FFFFF,十进制为 65536 ~ 2097151(目前 Unicode 字符集码点编号的最大值为 0x10FFFF,实际尚未编号到 0x1FFFFF;这说明作为变长字节数的 UTF-8 编码其未来扩展性非常强,即便目前的四字节编码也还有大量编码空间未被使用,更不论还可扩展为五字节、六字节……)。
具体算法
上述 Unicode 码点值范围中十进制值 127、2047、65535、2097151 这几个临界值是怎么来的呢?
因为 UTF-8 编码中的每个字节中都含有起到区分和标识之用的前缀码 0、110、1110 以及 10 之一,所以 1~4 个字节的UTF-8 编码其实际有效位数分别为 8-1 = 7 位( 2^7-1=127 )、16-5 = 11 位( 2^11-1=2047 )、24-8 = 16 位( 2^16-1=65535 )、32-11 = 21 位( 2^21-1=2097151 )。如下表所示:

**注:*上图中的 Unicode range 为 Unicode 码点值范围(也就是 Unicode 码点编号范围),Hex 为 16 进制,Binary 为二进制;Encoded bytes 为 UTF-8 编码中各字节的编码方式(即编码算法),其中,x 代表 Unicode 二进制码点值的单字节或低字节中的低 7 位或 8 位、y 代表两字节码点值的高字节中的低 3 位或 8 位以及三字节码点值的中字节中的 8 位、z 代表三字节码点值的高字节中的低 5 位。
因此,UTF-8 编码的算法简单地来概括就是:首先确定 UTF-8 编码中各个字节的前缀码;之后再将 UTF-8 编码中各个字节除了前缀码所占用之外的位,依次分配给 Unicode 字符码点值二进制中各个位的值。换言之,就是用 Unicode 字符码点值二进制中各个位的值,依次填充 UTF-8 编码中的各个字节除了前缀码所占用之外的位。
由于 ASCII 字符的 UTF-8 编码使用单字节,而且和 ASCII 编码一模一样,这样所有原先使用 ASCII 编码的文档就可以直接解码了,无需进行任何转换,实现了完全兼容。考虑到计算机世界里的英文文档数量之多,这一点意义重大。
而对于其他非 ASCII 字符,则使用 2~4 个字节的编码来表示。其中,首字节中前置的 “1” 的个数代表该字符编码的字节数(如 110 代表两个字节、1110 代表三个字节,以此类推),非首字节之外的剩余后续字节的前两位始终是 10,这样就不会与 ASCII 字符编码( “0” 开头)以及非 ASCII 字符的首字节编码( 110 或 1110 等至少两个 “1” 开头)相冲突。
例如,假设某个字符的首字节是 1110yyyy,前置有三个 1,说明该字符编码总共有三个字节,必须和后面两个以 10 开头的字节结合才能正确解码该字符。
由此可知,UTF-8 编码设计得非常精巧,虽说不上完美无瑕,但若与后文将要介绍的 UTF-16、UTF-32 以及前文介绍过的那些 ANSI 编码相比较,对于其精巧设计将体会得更为深切透彻。因此,UTF-8 越来越得到全球一致认可,大有一统字符编码之势。
小结
由于 UTF-8 编码方式以一个字节( 8 位)作为码元,属于单字节码元,在计算机处理、存储和传输时不存在字节序问题(字节序问题只跟多字节码元有关。ps:记住定义:UTF-8 是单字节码元可变长编码方式),因此避免了平台依赖性,跨平台兼容性好。
它相对于其他编码方式对英语更为友好,同样也对计算机语言(如 C++、Java、C#、JavaScript、PHP、HTML 等)更为友好。
所以,**UTF-8 是较为平衡、较为理想的 Unicode 编码方式。**虽然 Windows 平台由于历史的原因 API 缺乏对 UTF-8 的原生支持( Windows 原生支持的是 UCS-2,后升级为 UTF-16。因为 UCS-2 早于 UTF-8 面世,详细见《 1.6 Unicode 编码系统 > 发展节点》),导致 UTF-8 推出后的早期使用不广,但目前是应用最为广泛的三大 UTF 编码方式之一。
因此,应该尽量使用 UTF-8(准确地说,应该尽量使用 UTF-8 without BOM,即不带字节顺序标记 BOM 的 UTF-8 )。
1.9 UTF-16 编码
UTF-16 编码方式源于 UCS-2( Universal Character Set coded in 2 octets、2-byte Universal Character Set ),是一种变长码元序列编码方式,位于现代字符编码模型的第三层 CEF。
发展历史
1988 年成立的 Unicode 团队和 1989 年成立的 UCS 团队。等到他们发现了对方的存在,很快就达成一致:世界上不需要两套统一字符集。
1991 年 10 月,两个团队决定合并字符集。也就是说,从今以后只发布一套字符集,就是 Unicode,并且修订此前发布的字符集,UCS 的码点将与 Unicode 完全一致。
但是由于 UCS 的开发进度快于 Unicode,1990 年就公布了第一套编码方法 UCS-2,使用 2个 字节表示已经有码点的字符。(那个时候只有一个平面,就是基本平面,所以 2 个字节就够用了。)UTF-16 编码迟至 1996 年 7 月才公布,明确宣布是 UCS-2 的超集,即基本平面字符沿用 UCS-2 编码,辅助平面字符定义了 4 个字节的表示方法,即代理区。
1996 年 7 月,UTF-16 正式定义于ISO/IEC 10646-1的附录 C,而 RFC 2781 也在 2000 年 2 月定义了相似的做法,将其标准化。
ps:Windows 2000( 2000 年 7 月 31 日) 及之后的版本是支持 UTF-16 的,之前的 Windows NT/95/98/ME( 1993 年 7 月 27 日)是只支持 UCS-2 的。
UTF-16 由来与 UCS-2/4
UCS-2,是早期遗留下来的历史产物。其将字符编号直接映射为字符编码( CEF,而非 CES,详见前文中对现代字符编码模型的解释),亦即字符编号就是字符编码,中间没有经过特别的编码算法转换。因此,从现代字符编码模型的角度来看的话,此时并没有将编号字符集 CCS 与字符编码方式 CEF 作严格区分,既可以将 UCS-2 看作是编号字符集 CCS 中的字符编号,也可以看作是字符编码方式 CEF 中的字符编码。
后来,随着 Unicode 联盟与 ISO/IEC 就创建全球统一的单一通用字符集进行合作,Unicode 字符集与 UCS 字符集逐渐相互融合,两者最终基本保持了一致。
这之后,Unicode 逐渐占据了主导地位(据说是因为 Unicode 这个名称好记。。。),并引入了 UTF-16 编码方式。为什么要引入 UTF-16 编码方式呢?
前文已经介绍过了,Unicode 字符集(CCS)到目前为止定义了包括 1 个基本平面 BMP 和 16 个增补平面(即辅助平面)在内的共 17 个平面。
每个平面的码点数量为 2^16=65536 个,因此 17 个平面的码点总数为共 65536 x 17= 1114112 个。其中,基本平面码点为 65536 个(码点编号范围为 0x0000~0xFFFF ),增补平面码点为 1114112-65536=65536 x 16=1048576 个(码点编号范围为 0x10000~0x10FFFF )。
很明显,简单地用一个 16 位码元肯定无法表示所有 17 个平面的这么多码点(因为 2^16=65536,而码点总数为65536*17=1114112 )。而 UCS-2,正是用固定两个字节共 16 位来表示一个字符的,其直接使用 Unicode 码点值来表示基本平面 BMP 码点范围。为支持字符编号超过 U+FFFF 的增补字符,扩展势在必行。
UCS 因而又提出了 UCS-4(与 UCS-2 同是 1990 年先后,用来容纳下汉字),即用四个字节共 32 位来表示一个字符(此时 UCS-4 同样既可认为是编号字符集 CCS 中的字符编号,也可认为是字符编码方式 CEF 中的字符编码,即同样直接使用 Unicode 码点值来表示所有字符),但码元也因此从 16 位扩展到了 32 位。
而 Unicode 却提出了不同的扩展方式 — 代理机制。具体而言,就是为了能以一个统一的 16 位码元同时编码基本平面以及增补平面中的字符码点编号,Unicode 设计引入了 UTF-16 编码方式,并且通过代理机制实现了扩展。
UTF-16 编码方式的引入,从现代字符编码模型的角度来看的话,彻底将编号字符集 CCS 与字符编码方式 CEF 作了严格区分。也就是说,在 UTF-16 编码方式中,编号字符集 CCS 中的字符编号与字符编码方式 CEF 中的字符编码不再仅仅是简单的直接映射关系。
具体来说,就是 Unicode 字符集基本平面 BMP 中的字符(大致相当于 UCS 字符集中的 UCS-2 字符,但必须除开U+D800~U+DFFF 这一在 Unicode 字符集 BMP 中称之为代理码点的部分),仍然是直接映射关系,亦即这部分字符的字符编号与字符编码是等同的。
但 Unicode 字符集增补平面中的字符(大致相当于 UCS 字符集 UCS-4 字符中除开 UCS-2 字符的部分,因为广义上的UCS-4 字符实际上包含了 UCS-2 字符,当然狭义上的 UCS-4 字符不包括 UCS-2 字符),却不是直接映射关系,而是必须通过代理机制这一编码算法的转换,亦即这部分字符的字符编号与字符编码不是等同的。
因此,在 Unicode 引入了 UTF-16 编码方式之后,站在现代字符编码模型的角度上来看的话,再将 UCS-2 和 UCS-4 直接称之为字符编码方式 CEF 已不是很合适,更多的应该是编号字符集 CCS 中的概念(当然,在了解其历史原因之后,将UCS-2 和 UCS-4 同时理解为编号字符集 CCS 和字符编码方式 CEF 也未尝不可)。
代理机制、代理对
UTF-16中的所谓代理机制,实际上就是用两个对应于基本平面 BMP 代理区( Surrogate Zone )中的码点编号的 16 位码元来表示一个增补平面码点,这两个用来表示一个增补平面码点的特殊 16 位码元被称之为代理对 (Surrogate Pair)
UTF-16 编码方式及其代理机制是在 Unicode 2.0 中为支持字符编号超过 U+FFFF 的增补字符而引入的,于是从此就由 UCS-2 的等宽( 16 位)码元序列编码方式(如前文所述,从现代字符编码模型的角度来看的话,UCS-2 更多是的编号字符集 CCS 中的概念,但考虑到其历史原因,称之为字符编码方式 CEF 亦未尝不可,下同,不再赘述),变成了 UTF-16 的变宽( 16 位或 32 位)码元序列编码方式。不过,码元依然保持了 16 位不变。
ps:码元是最小,最短的比特位组合。不是字节变宽变多了,码元就变了。
UCS-2 所编码的字符集中的 U+D800~U+DFFF 这部分代理码点除外的话,UTF-16 所编码的字符集可看成是 UCS-2 所编码的字符集的父集。
在没有引入增补平面字符之前,UTF-16 与 UCS-2( U+D800~U+DFFF 这部分代理码点除外)的编码完全相同。但当引入增补平面字符后,UTF-16 与 UCS-2 的编码就不完全相同了(事实上,由于 UCS-2 只有两个字节,根本无法编码增补平面字符)。
现在若有软件声称自己支持 UCS-2 编码,那相当于是在暗示其仅支持 UCS 字符集或 Unicode 字符集中的基本平面字符,而不能支持增补平面字符。
所以说,UTF-16 是变长编码方式,每个字符编码为 16 位或 32 位;而 UCS-2 是定长编码方式,每个字符编码固定为16 位。但两者的码元却都是 16 位的(而 UTF-32 和狭义的 UCS-4 的码元都是 32 位的)。
另外,UTF-16 中,大部分汉字采用两个字节编码,少量不常用汉字采用四个字节编码。
UTF-16 一方面使用变长码元序列的编码方式,相较于定长码元序列的 UTF-32 算法更复杂(甚至比同样是变长码元序列的 UTF-8 也更为复杂,因为引入了独特的代理对这样的代理机制);另一方面仍然占用过多字节,比如 ASCII 字符也同样需要占用两个字节,相较于 UTF-8 更浪费空间和带宽。
因此,UTF-16 在 Unicode 字符集的三大编码方式( UTF-8、UTF-16、UTF-32 )中表现较为糟糕。它的存在是历史原因造成的,引起了很多混乱。不过由于其推出时间最早( ps:纠正原文,其实是 UCS-2 早),已被应用于大量环境中,目前虽然不被推荐使用,但长期来看,作为程序人员都不得不与之打交道。因而,对于其具体的编码算法的了解是十分必要的,下面将详细介绍其复杂的编码算法(主要是代理编码算法)。
首先要注意的是,代理 Surrogate 是专属于 UTF-16 编码方式的一种机制,UTF-8 和 UTF-32 是不用代理的。
如前文所述,为了让 UTF-16 能继续编码基本平面后面的增补平面中的码点值,于是扩展了 UTF-16 编码方式。
具体的扩展方法就是为其增加了代理机制,用两个对应于基本平面码点(即 BMP 代理区中的码点)的 16 位码元来表示一个增补平面码点,这两个用来表示一个增补平面码点的特殊 16 位码元就被称为 “代理对”。
如果要用简单的一句话来概括,就是:所有大于 0xFFFF 的码点值(即增补平面码点编号,范围为 0x10000~0x10FFFF,十进制为 65536~1114111;注意,0xFFFF 是十六位二进制数的最大值的十六进制表示)要编码成 UTF-16 编码方式的话,就必须使用代理机制(也就是用代理对来表示)。
在 UTF-16 编码方式中,被合起来称为 “代理对” 的这两个 16 位码元就其中的任一单个码元而言,其实就直接对应于基本平面 BMP 中的某一个码点(即 BMP 中每一个码点的值必然对应于一个 16 位码元的值,因为基本平面中的码点总数为2^16=65536 个,而 16 位码元能表示的值也等于 2^16=65536 个)。
这样一来,就产生了冲突:某个 UTF-16 码元到底是用于表示基本平面字符的码元,还是用于表示增补平面字符的代理对中的代理码元?
因此,为避免冲突,这些被用作 “代理” 的任一码元所对应的码点在基本平面中均未定义字符,即均没有指定字符。且形成 “代理对” 的两个码元所对应的码点其编号必定是连续的。
“代理” 的真实含义或许就在于此:用两个基本平面中未定义字符的连续码点合起来 “代为署理” 增补平面中的码点。
因此,基本平面中这些用作 “代理” 的码点区域就被称之为 “代理区(Surrogate Zone)”,其码点编号范围为0xD800~0xDFFF (十进制 55296~57343 ),共 2048 个码点。
增补平面一共有 16 个平面(即第 2 平面~第 17 平面),码点编号范围为 0x10000~0x10FFFF(十进制为65536~1114111,码点总数为 1048576 个)。用两个代理码元表示,第一个码元的取值范围为 0xD800~0xDBFF(二进制为 1101 1000 0000 0000 ~ 1101 1011 1111 1111,十进制为 55296 ~ 56319 ),第二个码元的取值范围为0xDC00~0xDFFF(二进制为 1101 1100 0000 0000 ~ 1101 1111 1111 1111,十进制为 56320 ~ 57343 )。
因此,增补平面的第一个码点的编号 0x10000 其 UTF-16 编码就是 0xD800 0xDC00(即 0x10000 经 UTF-16 编码后的码元序列为 0xD800 0xDC00 ),其余类推。展现为二进制形式后如下:
| 代理码元1 | 代理码元2 |
|---|---|
| 1101 10pp ppxx xxxx | 1101 11xx xxxx xxxx |
其中代理码元 1 中的 110110、代理码元 2 中的 110111 是定数(同时也是标志位),p、x 是变数。去掉定数后组合起来就是 pppp xxxx xxxx xxxx xxxx,共 20 位( 2^20=1048576 ),刚好能够表示目前 16 个增补平面中的全部码点( 0x10000~0x10FFFF,共 1048576 个)。其中 pppp 共 4 位,表示 16 个增补平面之一的编号( 2^4=16 );紧接着的16 位 x 表示某个增补平面内的某个码点( 2^16=65536 个码点,而 65536 个码点 * 16 个平面 = 1048576 个码点 )。
按照上面的编码方式,代理对里面的两个代理码元分别称之为高 16 位代理码元(或称为 lead surrogates 引导代理、前导代理),和低 16 位代理码元(或称为 trail surrogates 尾随代理、后尾代理)。
由于引导代理和尾随代理的值分别在 0xD800~0xDBFF(十进制为 55296 ~ 56319 )之间和 0xDC00~0xDFFF(十进制为56320 ~ 57343 )之间,所以首尾两个代理总共可以组合出( 56319-55296+1 )*( 57343-56320+1 )= 1048576 个代理对,也就是总共可以表示 1048576 个增补码点,而目前 Unicode 标准所确定的 16 个增补平面的码点总和也就是 65536 * 16 = 1048576 个。
显然,Unicode 字符集作为开放式字符集,未来不断增补字符进来,以至于增补平面超过 16 个,则按目前的 UTF-16 编码算法是无法编码的。也正是因为如此,UTF-16 编码方式的扩展性、适应性是不足的,未来全面被具备高扩展性、高适应性的 UTF-8 编码方式代替是必然的。
代理对编码算法
从增补平面的码点值通过基本平面中的代理对编码为增补平面字符的码元序列的具体算法如下:
-
增补平面中的码点值( 0x10000~0x10FFFF,二进制为 0001 0000 0000 0000 0000~1 0000 1111 1111 1111 1111,对应的码点名称为 U+10000~U+10FFFF )减去 0x10000(二进制为 0001 0000 0000 0000 0000 ),可得到20 位长的比特组(值的范围为 0x00000~0xFFFFF,二进制为 0000 0000 0000 0000 0000 ~ 1111 1111 1111 1111 1111 );
-
将得到的 20 位长的比特组分拆为两部分:高位 10 比特和低位 10 比特;
-
20 位长的比特组中的高位 10 比特(值的范围为 0x000~0x3FF,二进制为 00 0000 0000~11 1111 1111 )加上0xD800(二进制为 1101 1000 0000 0000 ),得到第一个代理码元即引导代理 (值的范围是 0xD800~0xDBFF,二进制为 1101 1000 0000 0000 ~ 1101 1011 1111 1111 );
-
20 位长的比特组中的低位 10 比特(值范围也是 0x000~0x3FF,二进制为 00 0000 0000~11 1111 1111 )加上0xDC00(二进制为 1101 1100 0000 0000 ),得到第二个代理码元即尾随代理(值的范围是 0xDC00~0xDFFF,二进制为 1101 1100 0000 0000 ~ 1101 1111 1111 1111 );
-
将引导代理与尾随代理按前后顺序组合在一起成为 “代理对”,就得到了增补平面字符的码元序列。
例如,增补平面中码点值为 10437(码点名称为 U+10437 )的字符( ? ):
-
0x10437 减去 0x10000,结果为 0x00437,二进制为 0000 0000 0100 0011 0111。
-
分拆成高 10 位值和低 10 位值两部分:0000000001(即 0x0001 )及 0000110111(即 0x0037 )。
-
添加 0xD800 到高位值,以形成高位的引导代理:0xD800 + 0x0001 = 0xD801(二进制为 1101 1000 0000 0001 )。
-
添加 0xDC00 到低位值,以形成低位的尾随代理:0xDC00 + 0x0037 = 0xDC37(二进制为 1101 1100 0011 0111 )。
-
将高位的引导代理与低位的尾随代理按前后顺序组合在一起成为 “代理对”,就得到了增补平面字符 ?(码点名称为U+10437 )的码元序列:1101 1000 0000 0001 1101 1100 0011 0111。
下表总结了该转换。不同的颜色表示码点值是如何被分布到 UTF-16 码元序列中的,而由 UTF-16 编码过程中加入的代理附加位则以不同的红色(亮红色与暗红色)显示:

显然,增补平面中的码点值从 0x10000 到 0x10FFFF,共计 0xFFFFF + 0x1 个,即 1,048,576 个,刚好也就是需要 20 位来表示( 2^20=1,048,576 )。如果用两个 16 位长的码元组成的序列来表示,意味着引导代理要容纳上述 20 位中的前 10 位,尾随代理要容纳上述 20 位中的后 10 位。
另外,还要能够根据每个 16 位码元来直接判断该码元到底是属于引导代理(标志位为前 6 位 11 0110,还剩下 10 位,因此总个数为 2^10=1024 个),还是属于尾随代理(标志位为前 6 位 11 0111,也剩下 10 位,因此总个数也是2^10=1024 个)。
为避免冲突,因此需要在基本多语言平面 BMP 中保留未定义 Unicode 字符的 1024+1024=2048 个码点,就可以容纳引导代理与尾随代理所需要的编号空间(码点空间、代码空间),也就是 16 个增补平面所需要的编号空间,共计1024*1024=2^20=1048576 个码点。这 BMP 中的 2048 个码点对于 BMP 总计 65536 个码点来说,仅占 3.125%( 2048/65536=0.03125 )。

在 UTF-16 编码方式中,引导代理的后面应该是一个尾随代理,而尾随代理的前面就应该是一个引导代理;不能出现一个引导代理的后面是一个非代理的普通 UTF-16 码元的情况,也不能出现一个引导代理的后面还是一个引导代理的情况。
UTF-16 文本(字符串)的最后一个码元不能是引导代理,不允许出现一个尾随代理的前面是一个尾随代理的情况,也不允许出现一个尾随代理的前面是一个非代理的普通 UTF-16 码元的情况;UTF-16 文本(字符串)的第一个码元不能是尾随代理。
而单独的一个代理码元(不管是引导代理还是尾随代理)是不合法的,代理必须以一个 “引导代理+尾随代理” 编码对(即代理对)的形式出现。
UTF-16 的这种 “代理对” 编码规则保证了文本处理程序能够正确地访问和处理包括了基本平面和增补平面在内的全部 UTF-16 码元序列,并消除了基本平面字符和增补平面字符之间发生冲突的可能性。
因为引导代理和尾随代理码元被各自规定在一个特定范围内取值,所以很简单的一个原则就是:凡是在代理编码范围内的码元就是“ 代理” 增补平面字符的 “代理码元”,否则就是 “基本平面 BMP 字符的码元”。由于 BMP 中的字符码元和代理码元分别在各自独立的编码范围内进行编码,所以对于一个符合格式规范的 UTF-16 码元来讲,它必须满足以下条件:
- 非代理码元( BMP 字符码元)必须避开代理码元所占用的范围 0xD800~0xDFFF(二进制为 1101 1000 0000 0000 ~ 1101 1111 1111 1111,共 2048 个);
- 引导代理必须是代理对中的第一个码元;
- 尾随代理必须是代理对中的第二个码元。
在处理 UTF-16 文本时,为了确保文本数据的完整性,绝对不能把任意一个代理从代理对中拆出来,也不能在代理对中间插入另一个字符的码元或码元序列。
在 UTF-16 编码方式里面,一个 Unicode 字符码点值由一个或两个 16 位码元编码。所以,如果想在一个 UTF-16 码元序列里面判断某个码元是属于哪个字符的话,就需要检查那个码元的值,然后根据码元的类型(是否具有代理标志位)决定是否还需要向前或向后检查一个相邻的码元的值(可以不必理会除了前后相邻的两个码元之外的其他码元)。
由于引导代理、尾随代理、BMP 字符码元,三者互不重叠,搜索就很简单,这意味着 UTF-16 具有 “自同步”( self-synchronizing )性:通过仅检查一个码元就可以判断当前字符的下一个字符的起始码元,每个字符码元的边界很明确;同时,还具有 “非传递” 性:单独的一个 UTF-16 码元出错涉及的只是一个字符,不会传递到文本的其他部分去,因此,即使文本中某些字符数据遭到破坏,其影响也只是局部性的。
UTF-8 也有类似优点。但许多早期的编码方式就不是自同步的,比如大多数的多字节编码标准如 GBK、Big5 等,必须从头开始分析文本才能确定不同字符的码元的边界;也不具有非传递性,局部字符数据被破坏,很可能传递到整个文件,导致整个文件无法正确显示。
因此,UTF-8 和 UTF-16 编码方式所具有的 “自同步性”、“非传递性” 等特点除了增强抗干扰能力外,也提供了随机访问的能力。
由于在大多数的文本数据中,代理对(即增补平面字符码元序列)出现的概率是很小的,很多情况下处理的还是非代理对(即基本平面字符码元序列),导致许多软件处理代理对的部分往往得不到充分的测试。这导致了一些长期的bug与潜在安全漏洞,甚至有些广为流行、得到良好评价的优秀软件也是如此。
因此,虽然编程时同时考虑文本中可能出现的不同存储长度的字符(基本平面字符是单 16 位编码,即单码元编码;增补平面字符是双 16 位编码,即双码元编码)并相应做出不同的处理,会比单纯只考虑 16 位编码在性能上要逊色一些。但实际上,现有的遵循定长 16 位编码规范但不能处理代理对的程序只需做很小的一点修改就可以同时处理基本平面字符和增补平面字符的编码了。
另外,需要特别注意的是,虽然 Unicode 标准规定 BMP 代理区( U+D800~U+DFFF )的码点值不对应于任何字符,即未作定义,但在 UCS-2 中,U+D800~U+DFFF 是被定义了的,也就是已经用于某些字符了。不过,只要前后两个 16 位码元不是恰好构成了代理对,许多程序还是能把这些不匹配 Unicode 标准的字符码元正确地辨识、转换成合规的码元。这种由历史原因造成的码元序列按现在的 Unicode 标准来看,应算作是编码错误。
现代软件通常都会统一使用 UTF-8 作为内码,为了兼容 Windows ,则在最后调用 API 的时候再和 UTF-16 做转换。具体可看《Windows 下 UTF-16 的坑》。
字节顺序与大小端
作为逻辑意义上的 UTF-16 编码(码元序列),由于历史的原因,在映射为物理意义上的字节序列时,分为 UTF-16BE( Big Endian )、UTF-16LE ( Little Endian )两种情况。比如,“ABC” 这三个字符的 UTF-16 编码(码元序列)为:00 41 00 42 00 43;其对应的各种字节序列如下:

Windows 平台下(还有 lunix )的 UTF-16 编码(即上述的 FF FE 41 00 42 00 43 00 )默认为带有 BOM 的小端序(即 Little Endian with BOM )。你可以打开记事本,写上 ABC,保存时选择 Unicode(这里的 Unicode 实际上指的是 UTF-16 Little Endian with BOM,即带 BOM 的 UTF-16 小端序 CES 编码 )

然后保存,再用 UltraEdit 编辑器看看它的编码结果:

Windows 从 NT 3.1 时代( 1993 年 )开始就采用了 UTF-16 编码方式( ps:其实是 UCS-2 ),很多流行的编程平台,例如 .Net、Java、Qt、JavaScript 还有 Mac 下的 Cocoa 等都是使用 UTF-16 作为基础的字符编码。例如代码中的字符串,在内存中相应的字节流就是 UTF-16 字节序列的。(注意,UTF-16 编码在 Windows 环境中被误用为 “widechar” 和 “Unicode” 的同义词)。
UTF-16 优缺点
总结下 UTF-16 的优点:
- UTF-8 对于原生英语国家支持好,单字节即可完全兼容,直接使用 ASCII 码点值,但是对于大部分方形文字,比如汉字,则需要三个字节,甚至少数的四个字节表示。而 UTF-16 中大部分汉字只需要两个字节即可,极少数不常用汉字使用 4 字节。会相对 UTF-8 省那么点空间。
- UTF-16 与 UTF-8 一样都有自同步性与非传递性,根据代理标志,明确字符码元边界,除了增强抗干扰能力外(即一个地方出错,不影响其他字符),也提供了随机访问的能力。
- 相对于 UTF-8 的一到四字节边长编码,UTF-16 只需要 2 或者 4 字节边长。对于字符存储或者检索处理,UTF-16 性能相对好一些。当然与那些固定长度的字符编码不能比,比如 UTF-32 固定 4 字节,好检索,但是浪费空间。
以及 UTF-16 的缺点:
- 因为 UCS-2(即后来的 UTF-16 )出现的早,Windows、Lunix 和一些编码语言 Java、JavaScript 等都是使用 UTF-16 字符,需要做额外注意或者兼容处理。
- UTF-16 对于 ASCII 编码只是半兼容,用两个字节表示单字节的 ASCII 码,16 减 7,其他 9 个高位全部补 0。在大部分英文文档或者英语世界中,很浪费空间。
- UTF-16 有字节序大小端问题需要处理,此为历史原因造成的。
- UTF-16 的代理区范围有限,为了将来 Unicode 发展后能够兼容 UTF-16,连累 Unicode 也被限制仅支持 U+10FFFF 以内的码位。虽然目前看来足够表示全世界所有字符,未来为知。
- UTF-16 直接是 UCS-2 的超集,其将 UCS-2 中的 U+D800~U+DFFF 不做定义,做为代理区 ,通过算法得到 4 字节字符。但是 UCS-2 本身是将 U+D800~U+DFFF 做了字符映射的(即定义了)。有些程序不能正确识别这些,有些程序可以,毕竟判断前后两个字符码元有没有形成代理对即可。但是这也算是一个坑点。
附文 1:Win 记事本的诡异
这一段附文本是原文最后一节, 可跳过。
当用一个软件(比如 Windows 记事本或 Notepad++ )打开一个文本文件时,它要做的第一件事是确定这个文本文件究竟是使用哪种编码方式保存的,以便于该软件对其正确解码,否则将显示为乱码。
一般软件确定文本文件编码方式的方法有如下三种:
- 检测文件头标识;
- 提示用户手动选择;
- 根据一定的规则自行推断。
文件头标识一般指的是字节顺序标记 BOM( Byte Order Mark ),位于文件的最开始。当打开一个文本文件时,就 BOM而言,有如下几种情形:
- BOM 为:EF BB BF ——表示编码方式为 UTF-8;
- BOM 为:FF FE ——表示编码方式为 UTF-16LE(小端序);
- BOM 为:FE FF ——表示编码方式为 UTF-16BE(大端序);
- BOM 为:FF FE 00 00 ——表示编码方式为 UTF-32LE(小端序);
- BOM为:00 00 FE FF ——表示编码方式为UTF-32BE(大端序);
- 没有 BOM —— 要么显式地提示用户手动选择一种编码方式,要么隐式地由软件按规则自行推断出编码方式。
接下来,是见证诡异怪事的时刻。
当你在简体中文版的 Windows 记事本里新建一个文件,输入 “联通” 两个汉字之后,保存为一个 txt 文件。然后关闭,再次打开该 txt 文件后,你会发现刚才输入并保存的 “联通” 两个汉字竟然莫名其妙地消失了,取而代之的是几个乱码。如下图所示。

这是为什么呢?难道是微软跟联通有仇吗?
原来,当你用 Windows 记事本新建一个文本文件时,其编码方式默认为 ANSI 编码(在简体中文版 Windows 中实际为GBK 编码),没有 BOM。
注:Windows 系统中的 ANSI 编码指的是在区域设置中所设置的系统默认编码方式,在简体中文版 Windows 系统中指的是 GBK,即 CP936 代码页,具体可参看前文《 GBK 码 》

在这种编码方式下,该文本文件仅仅保存了 “联通” 两个汉字的 GB 内码的四个字节,如下所示:(左边为十六进制,右边为二进制)。
- c1 1100 0001
- aa 1010 1010
- cd 1100 1101
- a8 1010 1000
通过 Notepad++ 的 HEX-Editor 插件可查看内码(十六进制),如下图所示。

通过 UltraEdit 的 “十六进制编辑” 模式也可查看内码(十六进制),如下图所示。

当用记事本再次打开该文本文件时,由于没有 BOM,记事本又没有提供显式地提示用户手动选择编码方式的功能,于是就只能隐式地按其推断规则自行推断,推断的结果就是被误认为了这是一个 UTF-8 编码方式的文件。
为什么会推断错误呢?又为什么会将其编码方式错误地推断为 UTF-8 呢?
注意,“联通” 两个汉字的 GB 内码,其第一第二个字节的起始部分分别是 “110” 和 “10”,第三第四个字节的起始部分也分别是 “110” 和 “10”,这刚好符合了 UTF-8 编码方式里的两码元序列的编码算法规则(即与 UTF-8 的两码元序列 “110xxxxx 10xxxxxx” 中的前缀码 “110” 和 “10” 刚好是完全一致的;
让我们按照 UTF-8 的编码算法规则,将第一个字节的前缀码 110 去掉,得到 “00001”,将第二个字节的前缀码 10 去掉,得到 “101010”,将两者组合在一起,得到 “00001101010”,再去掉多余的前导的 0( ps:不满足 8 位自动高位补 0,方便存储,计算机最小存储单位是字节),就得到了 “0110 1010"(十六进制为 6A ),这正好是 Unicode 字符集里的 U+006A,也就是小写字母 “j” 的码点值。
同理,之后的第三个字节与第四个字节按同样的方法用 UTF-8 解码之后正好是 Unicode 字符集里的 U+0368,这个字符为“ͨ”(抱歉,这里的左双引号貌似被这个字符所影响,看起来像是半角左双引号,而无法正常显示为全角左双引号),很像是上标的一个小 c,这应该是个组合字符(组合字符是 Unicode 字符集中的一种特殊字符,必须与其他字符组合在一起以形成一个新字符,一般不单独使用)。
这就是只有 “联通” 两个汉字的文本文件没有办法在记事本里被正确解码显示的原因。这里要特别说明的是,在记事本里打开时显示的不是 “j” 和 “ͨ”,而是显示为了 “��ͨ”(注意右上角是 “ͨ”)。
而用 UltraEdit 打开,如果在设置中选择了 “自动检测 UTF-8 文件”,显示的是 “j” 和 “ͨ” 组合在一起的字符 “jͨ”。注意这个字符不是小写字母 “j”,而是小写字母 “j” 上面的点变成了一个上标的小 c,因为 U+0368 这个字符 “ͨ” 应该是个组合字符,与其前面的小写字母 “j” 组合在一起而形成了一个新字符 —— jͨ(再次提醒注意:小写字母 “j” 上面的点变成了 “c” )。

注意:在 UltraEdit 的早期版本中,没有 “自动检测 UTF-8 文件” 这一选项
这里还有一个问题:既然已经推断为了 UTF-8,那为什么 Windows 记事本还是将前两个字节,亦即原本为 “联” 字的 GB 内码的那两个字节,显示为了 “��” 这样的乱码,而不是显示为小写字母 “j” 呢?
我想主要是因为小写字母 “j” 属于 ASCII 字符,在 UTF-8 编码中 ASCII 字符属于单字节编码,出现在双字节编码中是非正常的,因而被 Windows 记事本认为是错误编码,而 UltraEdit 则作了容错处理,仍然将其解读为了小写字母 “j”。
而后两个字节,亦即原本为 “通” 字的 GB 内码的那两个字节,之所以 Windows记 事本将其按 UTF-8 编码的规则解读为了字符 “ͨ”,那是因为字符 “ͨ” 的 UTF-8 编码正好就是双字节编码,因此按 UTF-8 编码的规则去解读的话不属于错误。
其实,用记事本默认的编码方式( ANSI )分别单独保存 “联” 字和 “通” 字为两个独立的 txt 文件,则:
-
再用记事本打开时,“联” 字显示的是 “��”,“通” 字显示的是 “ͨ”;
-
用 UltraEdit 打开时,
- 如果选择 了“自动检测 UTF-8 文件”,“联” 字显示的是小写字母 “j”,“通” 字显示的 “ͨ”(不过看不清,我开始还以为是个空格);
- 如果没有选择 “自动检测 UTF-8 文件”,“联” 字和 “通” 字均能正常显示(说明这种情况下 UltraEdit 正确地推断出了编码方式为 GBK,从这一点来看,UltraEdit 比 Windows 记事本要强);
-
用 NotePad++ 打开时,
- 如果 在“格式” 中选择的是 “以 ANSI 格式编码”(亦即显式地手动选择了正确的编码方式),“联” 字和 “通” 字均能正常显示;
- 如果编码方式选择的是 UTF-8、UTF-8 无 BOM、UCS-2 Big Endian 或 UCS-2 Little Endian 时,则 “联” 字均显示为 “xC1xAA”(有意思的是,直接复制 “xC1xAA” 然后粘贴到 Word 里,则显示为了小写字母 “j” ),“通” 字均显示为 “ͨ”。
而如果是用记事本默认的编码方式( ANSI )保存 “联通通信” 四个字,则用记事本、UltraEdit(即便选择的是 “自动检测 UTF-8 文件”的情况下)打开后都可正常显示。
这充分说明,Windows 记事本在文件头没有 BOM 的情况下,只能自行推断,由于 “联通” 两个汉字保存为 ANSI 编码方式时,内码只有四个字节,在信息不够充足的情况下(尤其是其内码又刚好符合了 UTF-8 的编码算法规则),于是被错误地推断为了 UTF-8 编码方式;当以 ANSI 编码方式保存的是 “联通通信” 四个汉字时,内码有八个字节,这时信息较为充足,因此被正确地推断为了 ANSI 编码方式(在简体中文版 Windows 中 ANSI 编码默认为 GBK 编码)。
上面分析的是 Windows 系统中采用 ANSI 编码时没有添加 BOM 的情况。那么,对于采用非 ANSI 编码时添加了 BOM 的情况,是否就万事大吉了呢?其实,添加 BOM 来标记字符编码表面看起来貌似不错,但实际上经常会带来麻烦,因为它和很多协议、规范并不兼容。
Windows 里的软件在采用非 ANSI 编码时,即便对于根本不存在字节序问题的 UTF-8 编码默认也会添加 BOM,而像Unix、Linux、Mac OS 等 Unix 系统对于 UTF-8 编码都默认不添加 BOM。
既然 Unix 系统都可以不添加 BOM,那为什么 Windows 系统却非要添加 BOM 呢?这很可能是因为 Windows 系统有大量普通用户使用,在必须兼容传统 ANSI 编码的情况下,从用户体验角度考虑而没有采用显式地要求用户手动选择字符编码方式的做法,因此特别依赖于通过 BOM 来防止隐式地自行推断字符编码方式而出错。
微软这种为了照顾广大普通用户而从用户体验角度出发 “好心办坏事” 的例子其实还有很多。
因此,在 Windows 系统中,尽量不要使用记事本来打开并编辑文本文件,尤其是作为程序员,应使用 Notepad++ 或 UltraEdit 等更为专业的文本文件编辑软件。
这一方面是可以避免出现上述这样的 “诡异” 错误,另一方面也是为了避免 Windows 记事本 “多此一举” 地添加 BOM,从而给在与其他系统(比如 Unix 系统)交流时带来不必要的麻烦。
附文 2:Win 记事本的奇葩命名

Windows 记事本中,对常用编码方式的命名非常 “奇葩”,微软这种自行其是的非标准命名,很是令人费解,现解释如下:
-
ANSI 指的是对应当前系统区域设置(即系统 locale )中的默认 ANSI 编码,不带 BOM。在简体中文版 Windows 系统中默认 ANSI 编码指的就是 GBK 编码,即 CP936,具体可参看前文《 ANSI 编码与代码页》。
-
Unicode 指的是带有 BOM 的小端序 UTF-16(即 UTF-16LE with BOM )。
-
Unicode big endian 指的是带有 BOM 的大端序 UTF-16(即 UTF-16BE with BOM )。
-
UTF-8 指的是带有 BOM 的 UTF-8(即 UTF-8 with BOM )。UTF-8 编码方式实际上并不存在字节序的问题,之所以仍然 “多此一举” 地添加BOM,应该是由于要兼容不添加 BOM 的 ANSI 编码,从用户体验角度考虑,避免用户显式地手动选择编码方式。
注:如果 UTF-8 编码不添加 BOM,则有两种不添加 BOM 的编码方式,从而导致隐式地自行推断编码方式更容易出错,上文所介绍的对 “联通” 推断出错即是明证。当然反过来也说明了 Windows 记事本对于不添加 BOM 的 UTF-8 编码其实同样是支持的,而并非简单粗暴地直接提示错误,这应该是为了兼容 Unix 系统不添加 BOM 的做法而不得不采取的策略。只是这样一来,就很难避免陷入左右为难的困境。
ps:原作者在书写这两篇附文时并未考虑 win10 1903 版之后已经修复了此问题。win10 1903 之前乃至 win7、xp 等皆有此问题存在。
附文 3:JavaScript 中的字符编码
下文来自阮一峰老师(原文地址):
ES6 之前与 UCS-2
JavaScript 语言采用 Unicode 字符集,但是只支持一种编码方法。这种编码既不是 UTF-16,也不是 UTF-8,更不是 UTF-32。上面那些编码方法,JavaScript 都不用。
JavaScript 用的是 UCS-2!( ps:通过前文,咱们知道 UCS-2 其实就是 UTF-16 的前身,除了U+D800~U+DFFF代理区,其他完全兼容的。)
那么,为什么 JavaScript 不选择更高级的 UTF-16,而用了已经被淘汰的 UCS-2 呢?
答案很简单:非不想也,是不能也。因为在 JavaScript 语言出现的时候,还没有 UTF-16 编码。
1995 年 5 月,Brendan Eich 用了 10天 设计了 JavaScript 语言;10 月,第一个解释引擎问世;次年 11 月,Netscape 正式向 ECMA 提交语言标准(整个过程详见《 JavaScript诞生记 》)。对比 UTF-16 的发布时间( 1996 年 7 月),就会明白 Netscape 公司那时没有其他选择,只有 UCS-2 一种编码方法可用!

**由于 JavaScript 只能处理 UCS-2 编码,造成所有字符在这门语言中都是 2 个字节,如果是 4 个字节的字符,会当作两个双字节的字符处理。**JavaScript 的字符函数都受到这一点的影响,无法返回正确结果。
同时请注意,JavaScript 引擎内部是自由的使用 UCS-2 或者 UTF-16。而大部分JS 引擎使用的是 UTF-16,无论它们使用什么方式实现,它只是一个具体的实现,这不将影响到语言的特性。
以字符



上面代码表示,JavaScript 认为字符


要解决这个问题,必须对码点做一个判断,然后手动调整。下面是正确的遍历字符串的写法。
while (++index < length) {
// ...
if (charCode >= 0xD800 && charCode <= 0xDBFF) {
output.push(character + string.charAt(++index));
} else {
output.push(character);
}
}
上面代码表示,遍历字符串的时候,必须对码点做一个判断,只要落在 0xD800 到 0xDBFF 的区间,就要连同后面 2 个字节一起读取。
类似的问题存在于所有的 JavaScript 字符操作函数。
String.prototype.replace()
String.prototype.substring()
String.prototype.slice()
...
上面的函数都只对 2 字节的码点有效。要正确处理 4 字节的码点,就必须逐一部署自己的版本,判断一下当前字符的码点范围。
ECMAScript 6 的修复
JavaScript 的下一个版本 ECMAScript 6(简称 ES6 ),大幅增强了 Unicode 支持,基本上解决了这个问题。
(1)正确识别字符
ES6 可以自动识别 4 字节的码点。因此,遍历字符串就简单多了。
for (let s of string ) {
// ...
}
但是,为了保持兼容,length 属性还是原来的行为方式。为了得到字符串的正确长度,可以用下面的方式。
Array.from(string).length
(2)码点表示法
JavaScript 允许直接用码点表示 Unicode 字符,写法是 "反斜杠 + U + 码点"(注意:里面不需要添加 + 号)。
'好' === '\u597D' // true
但是,这种表示法对 4 字节的码点无效。ES6 修正了这个问题,只要将码点放在大括号内,就能正确识别。

(3)字符串处理函数
ES6 新增了几个专门处理 4 字节码点的函数。
- String.fromCodePoint():从 Unicode 码点返回对应字符
- String.prototype.codePointAt():从字符返回对应的码点
- String.prototype.at():返回字符串给定位置的字符
(4)正则表达式
ES6 提供了 u 修饰符,对正则表达式添加 4 字节码点的支持。

(5)Unicode 正规化
有些字符除了字母以外,还有附加符号。比如,汉语拼音的 Ǒ,字母上面的声调就是附加符号。对于许多欧洲语言来说,声调符号是非常重要的。
Unicode 提供了两种表示方法。一种是带附加符号的单个字符,即一个码点表示一个字符,比如 Ǒ 的码点是 U+01D1;另一种是将附加符号单独作为一个码点,与主体字符复合显示,即两个码点表示一个字符,比如 Ǒ 可以写成O(U+004F) + ˇ(U+030C)。
// 方法一
'\u01D1'
// 'Ǒ'
// 方法二
'\u004F\u030C'
// 'Ǒ'
这两种表示方法,视觉和语义都完全一样,理应作为等同情况处理。但是,JavaScript 无法辨别。
'\u01D1'==='\u004F\u030C'
//false
ES6 提供了 normalize 方法,允许" Unicode 正规化",即将两种方法转为同样的序列。
'\u01D1'.normalize() === '\u004F\u030C'.normalize()
// true
关于ES6的更多介绍,请看《 ECMAScript 6 入门》。
1.10 UTF-32 编码
UTF-32 在 UTF 目前常用的三种编码方式( UTF-8、UTF-16、UTF-32 )中,是最为简单的一种编码方式。UTF-32 编码方式不使用任何编码算法将 Unicode 字符码点值(编号字符集 CCS 中的字符编号)转换为码元序列,而是将每个Unicode 字符码点值直接表示为一个 32 位的码元序列。
因此,目前 UTF-32 是一种固定宽度(也称为等宽、等长或定长)码元序列的 Unicode 字符编码方式。
UTF-32 中的码元由 32 位组成。UTF-32 使用的 32 位码元足够大,目前 Unicode 字符集中所收录的每个字符的码点值都可直接映射为单个码元。
换言之,UTF-32 使用一个 32 位的码元序列来表示 Unicode 字符(严格地说,是单个 32 位的码元,并没有形成两个或两个以上码元所组成的码元序列,除非未来 Unicode 码点值扩展到 64 位,这样才可能出现由两个 32 位的码元所组成的序列)。
因此,即使是 ASCII 字符,同样需要占用 32 位(即四个字节)。这在三大 UTF 编码方式中无疑是最为浪费存储空间的;不过,由于 UTF-32 是定长编码( UTF-8 和 UTF-16 都是变长编码),因此在文本处理速度上又是三大 UTF 编码方式中最快的。
UTF-32 在编码序列中查找第 N 个编码是一个常数时间操作。相比之下,其他可变长度编码需要进行循序访问操作才能在编码序列中找到第 N 个编码。这使得在计算机程序设计中,UTF-32 编码序列中的字符位置可以用一个整数来表示,整数加一即可得到下一个字符的位置,就和 ASCII 字符串一样简单。
由于 UTF-32 直接以四个字节的码元来表示码点值,这样按目前的情况来看,UCS-4 或 Unicode 增补平面中的所有码点值就都可以完全直接表示,而无需像 UTF-16 那样使用复杂的代理算法来间接表示。
当然,如前所述,Unicode 字符集是一个在不断增加字符的开放字符集,如果未来 Unicode 字符集的字符编号(即码点值)超过了四个字节,则 UTF-32 可能也需要像 UTF-16 一样使用某种特殊编码算法来间接表示。不过,按目前情况来看,真到了那一天,UTF-32 编码方式可能也已经完全淘汰了。
与 UTF-16类似,作为逻辑意义上的 UTF-32 码元序列,由于历史的原因,在映射为物理意义上的字节序列时,也分为UTF-32BE 大端序、UTF-32LE 小端序两种编码模式,因此 UTF-32 也同样需要使用 BOM。
比如,“ABC” 这三个字符的 UTF-32 码元序列为:00 00 00 41 00 00 00 42 00 00 00 43;其对应的各种字节序列如下:

每个 UTF-32 码元的值与 Unicode 码点的值完全相同,但其字节序列因字节序的不同而表现为有相同也有不同。
由于 UTF-32 在三大 UTF 编码方式中,既不是最早推出的编码方式(最早推出的是 UTF-16 ),也不是最优设计的编码方式(公认为最优设计的是 UTF-8 ),因此在实践中使用得最少,目前几乎已处于淘汰状态。
