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 个位置(即 ,约 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+007F | 1 字节 | 0xxxxxxx |
| U+0080 - U+07FF | 2 字节 | 110xxxxx 10xxxxxx |
| U+0800 - U+FFFF | 3 字节 | 1110xxxx 10xxxxxx 10xxxxxx |
| U+10000 - U+10FFFF | 4 字节 | 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 的一个微妙问题:"字符串长度"有三种意思:
- 字节数:
"你好".encode('utf-8')在 Python 中返回 6 字节(每个汉字 3 字节) - 码点数:
"你好"是 2 个码点 - 字形符(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 BF、BD EF、BF 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.