跳转到内容
← 返回核心概念
计算机科学基础计算机科学 · 系统21 分钟阅读

Unicode 与文本编码

Unicode and Text Encoding

2003 年 10 月,软件开发者 Joel Spolsky 发表了一篇长文,标题就带着不容商量的口气:《每个软件开发者绝对、必须了解的 Unicode 与字符集知识(没有借口!)》。 文章里有一句话后来被反复引用——"脱离编码去谈一个字符串,是没有意义的"(It does not make sense to have…

UnicodeUTF-8字符编码国际化文本处理

2003 年 10 月,软件开发者 Joel Spolsky 发表了一篇长文,标题就带着不容商量的口气:《每个软件开发者绝对、必须了解的 Unicode 与字符集知识(没有借口!)》。

文章里有一句话后来被反复引用——"脱离编码去谈一个字符串,是没有意义的"(It does not make sense to have a string without knowing what encoding it uses)。他甚至半开玩笑地说,2003 年还不懂字符编码基础的程序员,应该被罚去潜艇里剥六个月洋葱。

这种夸张的严厉,来自一代人共同的痛苦:乱码。在中文 Windows 里打开一个日文文件,或者在网页上看到"锟斤拷",都是文本编码出错的直接后果。这些问题背后,是人类语言的多样性与计算机只懂二进制这一根本矛盾。

破除误解:文本不是字节

在计算机内部,所有数据都是字节(0-255 的整数)。但字节序列本身没有内在含义——相同的字节序列 0xC4 0xE9 0xBA 在 Windows-1252(拉丁文)编码里是 "ÄéD",在 GB2312(中文)里是"你",在 UTF-8 里则是无效的序列。

字符与编码的分离是理解文本的关键: - 字符(Character):抽象的意义单位(如"汉字'你'"或"拉丁字母'A'") - 码点(Code Point):给每个字符分配的唯一整数(如 Unicode 中 'A' 是 U+0041,'你' 是 U+4F60) - 编码(Encoding):把码点转换为字节序列的规则

脱离编码谈文本是无意义的——字节序列需要知道编码才能正确解读。

历史:从 ASCII 到编码战争

ASCII(American Standard Code for Information Interchange,1963):用 7 位(0-127)表示 128 个字符:英文字母(大小写各 26 个)、数字、标点和控制字符。足够表示英文,但完全不覆盖其他语言。

计算机传到世界其他地方后,各个地区自行扩展了 ASCII 的第 8 位(128-255)来表示本地字符:

  • Latin-1(ISO-8859-1):西欧语言
  • GB2312 / GBK / GB18030:中文简体
  • Big5:中文繁体
  • Shift_JIS / EUC-JP:日文
  • KOI8-R:俄文
  • Windows 代码页:微软的各地区扩展

这些编码互不兼容,在同一个文档中混用就会产生乱码。1990 年代的国际化软件开发是噩梦——同一份代码在不同地区的计算机上可能显示完全不同的结果。

Unicode:统一的宇宙

1987 年,Xerox 的工程师 Joe Becker 与 Apple 的 Lee Collins、Mark Davis 开始研究如何为世界上所有书写系统创建一个统一字符集。1988 年,Becker 在一份草案里造出了"Unicode"这个词——前缀 "uni" 一语三关:universal(覆盖所有语言)、uniform(早期设想为定长 16 位编码)、unique(每个字符只有一个码点)。Unicode 联盟于 1991 年 1 月在加州注册成立,同年 10 月发布了 Unicode 1.0 标准的第一卷。

Unicode 的核心思想:给世界上所有字符(包括古代文字、表情符号、数学符号)分配一个全球唯一的整数(码点,Code Point),从 U+0000 到 U+10FFFF,共 1,114,112 个位置(即 17×21617 \times 2^{16},约 110 万)。

值得注意的是,"uniform 定长 16 位"这个最初设想很快破产——65536 个位置远不够容纳所有汉字与历史文字,于是码点空间扩展到了 17 个平面。这个未兑现的承诺,后面会以 UTF-16 代理对的形式回来纠缠一代语言。

截至 2024 年 9 月发布的 Unicode 16.0,已分配约 15.5 万个字符(精确值 154,998 个),包括: - 基本多文种平面(BMP,U+0000-U+FFFF):日常使用的字符 - 补充平面(U+10000-U+10FFFF):罕见汉字、古代文字、表情符号(Emoji)

重要区分:Unicode 是字符集(定义码点),不是编码(不规定字节表示)。Unicode 码点可以用多种编码方式序列化为字节:UTF-8、UTF-16、UTF-32。

UTF-8:精妙的设计

UTF-8(Unicode Transformation Format,8-bit) 是目前互联网上最广泛使用的 Unicode 编码,由 Ken Thompson 和 Rob Pike(Unix 与 Go 的创造者)在 1992 年设计。

按 Rob Pike 后来的回忆,它的诞生近乎传奇:1992 年 9 月某个晚上,X/Open 委员会正为文件系统编码标准开会,电话打到了正在贝尔实验室做 Plan 9 系统的 Thompson 和 Pike 这里。两人去新泽西一家小餐馆吃饭,Ken Thompson 在一张餐垫纸(placemat)上推演出了位编码方案。当晚开始写代码,几天之内整个 Plan 9 操作系统就全面改用了 UTF-8,X/Open 委员会也投票把它采纳为标准;到 9 月 8 日凌晨,Thompson 已发出在 Plan 9 上全面启用的邮件。一个统治了互联网三十年的编码,就这样在一顿晚饭后定了下来。

UTF-8 的关键特性:

可变长度:不同范围的码点用不同字节数表示

码点范围字节数字节格式(二进制)
U+0000 - U+007F1 字节0xxxxxxx
U+0080 - U+07FF2 字节110xxxxx 10xxxxxx
U+0800 - U+FFFF3 字节1110xxxx 10xxxxxx 10xxxxxx
U+10000 - U+10FFFF4 字节11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

ASCII 兼容:U+0000-U+007F 的 128 个 ASCII 字符在 UTF-8 中用完全相同的单字节表示。这意味着所有合法的 ASCII 文本都是合法的 UTF-8 文本,ASCII 程序无需修改即可处理纯英文 UTF-8 内容。

自同步性(Self-Synchronizing):每个字节的最高几位指示了该字节是什么类型(首字节还是续字节),使得从任意字节位置都能可靠地找到字符边界,而不需要从头扫描。

最初的 UTF-8 其实最多能用 6 字节、编码到 31 位。2003 年 11 月的 RFC 3629 把它砍到了最多 4 字节、上限 U+10FFFF——目的正是和 UTF-16 的可表示范围对齐。这一刀去掉了全部 5、6 字节序列和四成以上的四字节序列,同时把 UTF-16 用作代理对的 U+D800–U+DFFF 区段也判为非法字节。换句话说,今天 Unicode 的码点天花板(约 111 万)不是由 UTF-8 决定的,而是被 UTF-16 的能力反向锁死的。

相比之下,UTF-16 把每个码点用 2 字节或 4 字节表示,破坏了 ASCII 兼容性(ASCII 字符被扩展成了 2 字节),且存在字节序(大端/小端)的问题(需要 BOM,Byte Order Mark,来区分)。

统计现状

根据 W3Techs 的持续追踪,到 2026 年,全球网站中约 98.9% 使用 UTF-8 编码,流量最高的前 1000 个站点更是高达 99.9%。而在 2014 年初,这一比例还只有 78.7%。短短十余年,UTF-8 几乎统一了互联网的文本编码——这是计算机历史上罕见的、一项技术近乎彻底胜出的案例。

字符串长度的陷阱

Unicode 的一个微妙问题:"字符串长度"有三种意思

  1. 字节数"你好".encode('utf-8') 在 Python 中返回 6 字节(每个汉字 3 字节)
  2. 码点数"你好" 是 2 个码点
  3. 字形符(Grapheme Cluster)数:用户感知的"字符"数

问题在于,某些字形符由多个码点组成: - "é"可以是单个码点 U+00E9,也可以是 "e"(U+0065)+ 组合重音符(U+0301)——两种表示在视觉上相同,但码点数不同 - 表情符号 "👨‍👩‍👧‍👦"(家庭)在 Unicode 中是 7 个码点的序列(用零宽连接符 ZWJ 连接) - 某些语言的字符需要在相邻上下文中才能确定字形

这导致了大量"字符串长度"相关的 bug,尤其在处理 Emoji、阿拉伯文(从右向左书写)、泰文(不用空格分词)时。

规范化:同一个字的两副面孔

上面"é 有两种码点表示"的问题不是孤例,而是一类系统性麻烦。Unicode 为此定义了四种规范化形式(Normalization Form),把等价的码点序列折叠成统一写法:

  • NFC(Canonical Composition):尽量合成为单一码点(é → U+00E9)
  • NFD(Canonical Decomposition):尽量拆成基字符 + 组合记号(é → U+0065 U+0301)
  • NFKC / NFKD:在 NFC/NFD 基础上再做"兼容分解",把全角"A"、上标"²"、连字"fi"等也归一到普通字符

只有把字符串先规范化到同一形式,比较"两个字符串是否相等"才有意义——否则两个看起来一模一样的文件名、用户名、密码,可能因为底层码点不同而判为不等,这正是无数登录失败和搜索失灵的根源。

一个真实且常见的坑发生在跨平台文件名上:macOS 的旧文件系统 HFS+ 强制把文件名以 NFD 存盘,而 Windows、Linux 普遍使用 NFC。于是一个带重音字母或韩文的文件名在 Mac 与 PC 之间同步时,会变成两个"看起来相同、字节不同"的文件,造成重复、冲突或"文件找不到"。新一代 APFS 改为不强制规范化,但代价是把规范化的责任又抛回给了应用层。

编码检测的困难

当你收到一个字节序列,没有任何元数据告诉你编码是什么时,如何判断?

BOM(Byte Order Mark):UTF-16 和 UTF-32 在文件开头可以加 BOM(UTF-8 的 BOM 是 0xEF BB BF)来标识编码和字节序。但许多工具不添加 BOM,BOM 也可能干扰某些解析器。

启发式检测:chardet、icu 等库通过统计字节模式猜测编码(如连续出现的高字节值,在 UTF-8 中有固定的后跟模式)。这不能保证 100% 准确,只是概率最高的猜测。

启发式会出多离谱的错,"Bush hid the facts"(布什隐瞒了真相)这个著名 bug 是最好的注脚。在记事本里输入这九个英文单词、保存再打开,文字会变成一串方块状汉字"畂桳栠摩琠敨映捡獴"。原因是 Windows 用 IsTextUnicode 函数猜编码:这串纯 ASCII 文本恰好满足"偶数位字节变化约为奇数位三倍"的判据,于是被误判成 UTF-16LE,两两字节被当成一个汉字解读。这个函数 1994 年随 Windows NT 3.5 引入,直到 2004 年才被发现。"this app can break"、甚至"a "都能触发同样的错乱。微软在 Windows Vista 里改了记事本的检测算法,但 IsTextUnicode 本身至今未动——任何还在调用它的程序仍会中招。

最佳实践:在任何文本在系统边界传递时,始终明确声明编码(HTTP Content-Type: text/html; charset=utf-8;HTML <meta charset="utf-8">;数据库连接字符集设置),不让接收方猜测。

乱码的解剖:锟斤拷与烫烫烫

中文程序员对两种乱码再熟悉不过,而它们恰好暴露了两类不同的错误。

"锟斤拷" 是编码不匹配的产物。当一段 GBK 字节被错当成 UTF-8 解码时,许多 GBK 字节组合在 UTF-8 里是非法序列,解码器只能用占位符 U+FFFD(REPLACEMENT CHARACTER,"�")替换。U+FFFD 的 UTF-8 字节恰好是 EF BF BD;当这些替换符再被以 GBK(每两字节一个汉字)显示时,EF BFBD EFBF BD 依次被读成"锟""斤""拷"。这一步是不可逆的——原始字节已经在变成 U+FFFD 时永久丢失,所以"锟斤拷"出现就意味着数据真的没了。

"烫烫烫" 则与编码无关,而是内存问题。微软 Visual C++ 在 Debug 模式下,用 0xCC 填充未初始化的栈内存(0xCC 同时是 x86 的 INT3 断点指令),用 0xCD 填充未初始化的堆内存。两字节 0xCCCC 在 GBK 里正是"烫",0xCDCD 正是"屯"。所以当程序错误地把一段未初始化内存当字符串打印,屏幕上就刷出一排"烫烫烫"或"屯屯屯"——它其实是一个未初始化变量 bug 透过字符编码露出的马脚。

代价与争议

UTF-16 的历史负担:Java、JavaScript、Windows API、.NET 在 Unicode 早期就采用了 UTF-16,当时认为 BMP 的 65536 个位置足够所有字符。后来 Unicode 扩展到了 BMP 以外,导致这些语言的字符串实际上是 UTF-16 代理对(Surrogate Pair),长度计算和字符串操作充满陷阱。Python 3 选择了更整洁的内部表示,但兼容历史遗留 UTF-16 代码仍是问题。

Emoji 的复杂性:表情符号序列的快速扩张(每年 Unicode 新增数十到数百个表情)使得字形符处理越来越复杂。不同平台(Apple、Google、微软)对同一个 Emoji 码点的渲染不同,导致同一条消息在不同设备上给人完全不同的感受。

CJK 统合(Han Unification)的争议:这是 Unicode 最受批评的设计决策之一。中文、日文、韩文、越南文共享大量同源汉字,但同一个字在各地的标准字形有细微差别(如"直""海""骨"在中日韩印刷体中笔画走向不同)。Unicode 把这些视为同一"字符"的不同字形(glyph),统一到同一个码点,以节省码位。后果是:一个字长什么样,取决于你用的是中文字体还是日文字体——日本用户长期抱怨自己的人名、地名在默认字体下"显示成了中国字"。Unicode 用一条源分离规则(source separation rule)打补丁:凡是在 JIS X 0208 等来源字符集里被当作两个不同字符的,进入 Unicode 后仍保留为两个码点;但这只覆盖历史包袱,新的区域差异仍要靠"变体选择符(Variation Selector)"或语言标记来区分。当年 ISO 曾有一份不做统合的草案(DIS 10646),最终在美欧成员国主导的投票中被否决,转而采纳了 Unicode 的统合方案——一个技术决定背后,是谁有话语权的政治。

当编码成为攻击面

Unicode 让"看起来一样的字其实不是同一个字"成为常态,而这正好可以被用来骗人。

域名只允许 ASCII,含非拉丁字符的国际化域名(IDN)要先用 Punycode(RFC 3492,由 Adam Costello 于 2003 年提出)转成 xn-- 开头的 ASCII 串再交给 DNS。问题在于:西里尔字母小写"а"(U+0430)和拉丁字母"a"(U+0061)在屏幕上几乎无法区分。攻击者可以注册一个全由西里尔字母拼成、但看起来与 apple.com 一模一样的域名(2017 年安全研究者用 xn--80ak6aa92e.com 公开演示了这一点),用来钓鱼。这类同形异义攻击(homograph attack)迫使浏览器引入对策:当一个标签混用多种文字、或整体由某种"高仿拉丁"文字组成时,地址栏直接显示其 Punycode 原形而非好看的 Unicode 形式。

字符编码因此不只是显示问题,更是密码学与系统安全的边界问题——大小写折叠(如土耳其语的 I/ı 与 İ/i)、规范化差异都曾导致真实的身份校验绕过漏洞。

跨域连接

  • 文字系统类型:编码单位的选择躲不开文字类型的差异:字母文字的字符大致对应音位,汉字对应语素,另一些文字则有强制的合体与重排。Unicode 把码点定为最小单位、把字形合成交给渲染,代价就是「用户感知的一个字」与码点数系统性不等。推论是任何按码点截断字符串的实现,在这些文字上必然产出错字。
  • 汉字与书写:统合决策把中日韩同源字视作同一字符的不同字形,只用源分离规则保住历史包袱。于是字形正确与否由字体与语言标记决定,而不由编码决定——这解释了为什么「人名被显示成外国写法」在技术上换编码解决不了,只能靠变体选择符或语言标注,而这两者都要求上游一路传递语言信息。
  • 全球治理:一个字算不算同一个字,最终由标准组织的投票决定,正文提到的那次否决就是例子。机制很清楚:技术标准的治理结构决定了谁的用法成为默认,而默认值会被数十年的软件继承。推论是受影响最大的用户群若在标准机构中代表不足,其需求只能靠事后补丁解决,而补丁的采纳率总是低于默认。
  • 克里克:遗传密码是定长三联体且不自同步,因此插入或删除一个碱基会移动阅读框,其后的全部氨基酸随之作废。UTF-8 用前导位区分首字节与续字节,从任意位置都能重新对齐。这条对照给出一个可检验的差别:截断一个 UTF-8 流最多毁掉一个字符,而截断一个代理对却会留下无法解释的半个码点。
  • 计算机安全原则:视觉等价而字节不等价,使「看到的域名」与「解析到的域名」分离,同形异义攻击由此成立;规范化形式不一致导致的身份校验绕过是同一问题的另一面。推论可以写成一条规则:任何安全判定都必须在规范化之后、并且在与执行相同的表示层上进行,比较层与执行层不一致就等于没比较。

参考文献

  • Pike, R. & Thompson, K. Hello World or Καλημέρα κόσμε or こんにちは 世界. Proceedings of the Winter 1993 USENIX Conference, 1993. (UTF-8 的原始设计文档)
  • Yergeau, F. UTF-8, a Transformation Format of ISO 10646. IETF RFC 3629, November 2003. (把 UTF-8 限定到 U+10FFFF / 4 字节的标准)
  • Costello, A. Punycode: A Bootstring Encoding of Unicode for Internationalized Domain Names in Applications (IDNA). IETF RFC 3492, March 2003. (xn-- 域名编码)
  • The Unicode Consortium. The Unicode Standard, Version 16.0.0. Mountain View, CA: The Unicode Consortium, 2024. (154,998 个已编码字符)
  • The Unicode Consortium. Unicode FAQ: Chinese and Japanese (Han Unification). unicode.org/faq/han_cjk.html. (CJK 统合的官方说明)
  • Pike, R. UTF-8 history. 2003 年致 IETF/Unix 社区的电子邮件(餐垫纸上的设计经过,存档于 cl.cam.ac.uk/~mgk25/ucs/utf-8-history.txt)。

延伸阅读

  • Spolsky, J. The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets (No Excuses!). Joel on Software, 2003. (https://www.joelonsoftware.com/2003/10/08/the-absolute-minimum-every-software-developer-absolutely-positively-must-know-about-unicode-and-character-sets-no-excuses/)
  • Bray, T. On the Goodness of Unicode. tbray.org, 2003.