5.9. 字符编码

5.9.1. 字符编码简介

多年来,美国人一直使用 ASCII 字符集交换文本;由于基本上所有美国系统都支持 ASCII,这使得英语文本的交换变得容易。不幸的是,ASCII 在处理几乎所有其他语言的字符时完全不足。多年来,不同的国家采用了不同的技术来交换不同语言的文本,这使得在一个日益互联互通的世界中交换数据变得困难。

最近,ISO 开发了 ISO 10646,即“通用多八位编码字符集 (UCS)”。UCS 是一种编码字符集,它为世界上所有的字符定义了一个 31 位的值。UCS 的前 65536 个字符(因此可以容纳在 16 位中)被称为“基本多文种平面”(BMP),BMP 旨在涵盖当今几乎所有的口语。Unicode 论坛制定了 Unicode 标准,该标准专注于 UCS,并添加了一些额外的约定以帮助互操作性。从历史上看,Unicode 和 ISO 10646 是由相互竞争的团体开发的,但值得庆幸的是,他们意识到需要协同工作,现在他们彼此协调。

如果您正在编写处理国际化字符的新软件,您应该使用 ISO 10646/Unicode 作为处理国际字符的基础。但是,您可能需要处理各种旧的(特定于语言的)字符集中的旧文档,在这种情况下,您需要确保不受信任的用户无法控制另一个文档的字符集设置(因为这会显着影响文档的解释)。

5.9.2. UTF-8 简介

大多数软件并非设计为处理 16 位或 32 位字符,但要创建通用字符集,则需要超过 8 位。因此,开发了一种名为“UTF-8”的特殊格式,用于以现有程序和库更易于处理的格式对这些潜在的国际字符进行编码。UTF-8 在 IETF RFC 2279 等地方定义,因此它是一个定义明确的标准,可以自由阅读和使用。UTF-8 是一种可变宽度编码;编号为 0 到 0x7f (127) 的字符编码为自身,作为单个字节,而值较大的字符则编码为 2 到 6 个字节的信息(取决于其值)。该编码经过专门设计,具有以下优点(此信息来自 RFC 和 Linux utf-8 手册页)

简而言之,UTF-8 转换格式正成为交换国际文本信息的主要方法,因为它既可以支持世界上所有的语言,又可以向后兼容美国 ASCII 文件,并且还具有其他优点。对于许多目的,我推荐使用它,尤其是在将数据存储在“文本”文件中时。

5.9.3. UTF-8 安全问题

提及 UTF-8 的原因是某些字节序列不是合法的 UTF-8,这可能是一个可利用的安全漏洞。UTF-8 编码器应该使用“尽可能短的”编码,但幼稚的解码器可能会接受比必要长度更长的编码。事实上,早期的标准允许解码器接受“非最短形式”的编码。这里的问题是,这意味着潜在的危险输入可以用多种方式表示,因此可能会破坏检查危险输入的安全例程。RFC 以这种方式描述了这个问题:

UTF-8 的实现者需要考虑如何处理非法 UTF-8 序列的安全性方面。可以想象,在某些情况下,攻击者可能能够通过向不谨慎的 UTF-8 解析器发送不符合 UTF-8 语法的八位字节序列来利用它。

这种攻击的一种特别微妙的形式可能是针对解析器执行的,该解析器对其输入的 UTF-8 编码形式执行安全关键的有效性检查,但将某些非法八位字节序列解释为字符。例如,解析器可能会禁止编码为单八位字节序列 00 的 NUL 字符,但允许非法的双八位字节序列 C0 80(非法,因为它比必要的长度更长)并将其解释为 NUL 字符 (00)。另一个例子可能是解析器禁止八位字节序列 2F 2E 2E 2F (“/../”),但允许非法八位字节序列 2F C0 AE 2E 2F。

有关此问题的更长讨论,请访问 Markus Kuhn 的适用于 Unix/Linux 的 UTF-8 和 Unicode 常见问题解答,网址为 http://www.cl.cam.ac.uk/~mgk25/unicode.html

5.9.4. UTF-8 合法值

因此,在接受 UTF-8 输入时,您需要检查输入是否是有效的 UTF-8。以下是所有合法 UTF-8 序列的列表;任何不符合此表的字符序列都不是合法的 UTF-8 序列。在下表中,第一列显示了编码为 UTF-8 的各种字符值。第二列显示了这些字符如何编码为二进制值;“x”表示数据放置的位置(0 或 1),尽管某些值不应被允许,因为它们不是最短的可能编码。最后一行显示了每个字节可以具有的有效值(十六进制)。因此,程序应检查每个字符是否符合右侧列中的模式之一。“-”表示合法值的范围(包括在内)。当然,仅仅因为一个序列是合法的 UTF-8 序列并不意味着您应该接受它(您仍然需要进行所有其他检查),但通常您应该在执行其他检查之前检查任何 UTF-8 数据的 UTF-8 合法性。

表 5-1. 合法 UTF-8 序列

UCS 代码(十六进制)二进制 UTF-8 格式合法 UTF-8 值(十六进制)
00-7F0xxxxxxx00-7F
80-7FF110xxxxx 10xxxxxxC2-DF 80-BF
800-FFF1110xxxx 10xxxxxx 10xxxxxxE0 A0*-BF 80-BF
1000-FFFF1110xxxx 10xxxxxx 10xxxxxxE1-EF 80-BF 80-BF
10000-3FFFF11110xxx 10xxxxxx 10xxxxxx 10xxxxxxF0 90*-BF 80-BF 80-BF
40000-FFFFFF11110xxx 10xxxxxx 10xxxxxx 10xxxxxxF1-F3 80-BF 80-BF 80-BF
40000-FFFFFF11110xxx 10xxxxxx 10xxxxxx 10xxxxxxF1-F3 80-BF 80-BF 80-BF
100000-10FFFFF11110xxx 10xxxxxx 10xxxxxx 10xxxxxxF4 80-8F* 80-BF 80-BF
200000-3FFFFFF111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx过大;请参见下文
04000000-7FFFFFFF1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx过大;请参见下文

正如我之前提到的,字符集有两个标准,ISO 10646 和 Unicode,它们已同意同步其字符分配。ISO/IEC 10646-1:2000 和 IETF RFC 中 UTF-8 的定义目前也支持五字节和六字节序列来编码 Uniforum 的 Unicode 不支持范围之外的字符,但这些值不能用于支持 Unicode 字符,并且预计未来版本的 ISO 10646 将具有相同的限制。因此,对于大多数用途,五字节和六字节 UTF-8 编码不是合法的,您通常应该拒绝它们(除非您有特殊用途)。

这组有效值很难确定,事实上,本文档的早期版本犯了一些错误(在某些情况下,它允许过长的字符)。语言开发人员应该在其库中包含一个函数来检查有效的 UTF-8 值,因为它确实很难正确处理。

我应该注意到,在某些情况下,您可能希望放宽限制(或在内部使用)十六进制序列 C0 80。这是一个过长的序列,如果允许,它可以表示 ASCII NUL (NIL)。由于 C 和 C++ 在普通字符串中包含 NIL 字符时遇到问题,因此有些人开始在他们想要将 NIL 表示为数据流的一部分时使用此序列;Java 甚至将这种做法奉为圭臬。在处理数据时,可以随意在内部使用 C0 80,但从技术上讲,您真的应该在保存数据之前将其转换回 00。根据您的需要,您可能会决定“马虎”并接受 C0 80 作为 UTF-8 数据流中的输入。如果它不损害安全性,那么接受此序列可能是一个好的做法,因为接受它有助于互操作性。

处理这个问题可能很棘手。您可能需要检查 Unicode 开发的 C 例程来处理转换,可在 ftp://ftp.unicode.org/Public/PROGRAMS/CVTUTF/ConvertUTF.c 获取。我不清楚这些例程是否是开源软件(许可证没有明确说明它们是否可以修改),因此请注意这一点。

5.9.5. UTF-8 相关问题

本节讨论了 UTF-8,因为它是 UCS 最流行的多字节编码,简化了许多国际文本处理问题。但是,它当然不是唯一的编码;还有其他编码,例如 UTF-16 和 UTF-7,它们具有相同类型的问题,并且必须出于相同的原因进行验证。

另一个问题是,在 ISO 10646/Unicode 中,某些短语可以用多种方式表达。例如,一些带重音的字符可以表示为单个字符(带重音符号),也可以表示为一组字符(例如,基本字符加上单独的组合重音符号)。这两种形式可能看起来相同。还有一个零宽度空格可以插入,结果是表面上相似的项目被认为是不同的。注意此类隐藏文本可能会干扰程序的情况。这是一个总体上难以解决的问题;大多数程序对客户端没有如此严格的控制,以至于它们完全知道特定序列将如何显示(因为这取决于客户端的字体、显示特性、语言环境等等)。