UTF-8:工程杰作
UTF-8 不仅仅是"变长 Unicode 编码"。它是一种精心设计的前缀码,其特性使它成为 Web 的主导编码(截至 2024 年,98%+ 的网页使用 UTF-8)。
设计特性
1. 自同步:可以跳转到 UTF-8 流的任意字节位置,只需向前扫描最多 3 字节即可找到下一个字符的起始。后续字节(10xxxxxx)与引导字节明确可区分 —— 永远不需要向后扫描来确定上下文。
引导字节: 0xxxxxxx, 110xxxxx, 1110xxxx, 11110xxx
后续字节: 10xxxxxx
如果落在 10xxxxxx 字节上,向前跳过直到碰到
不以 10 开头的字节。那就是下一个字符的起始。
2. 前缀无歧义:任何一个字符的有效编码都不是另一个字符编码的前缀。这意味着面向字节的搜索(如 grep)无需理解 UTF-8 边界就能正确工作 —— ASCII 字符串的匹配永远不会误匹配多字节字符的片段。
3. ASCII 透明:所有 ASCII 字节(0x00–0x7F)在 UTF-8 中编码不变。反过来,多字节字符不包含 0x00–0x7F 范围的字节。这意味着:
- 现有的基于 ASCII 的工具(路径分隔符、空终止符、格式字符串)无需修改
- 搜索
/或\0的 C 字符串函数在 UTF-8 字符串上正确工作
4. 字节序无关:与 UTF-16 和 UTF-32 不同,UTF-8 没有字节序歧义,不需要 BOM。
5. 排序保持:UTF-8 字节序排序与 Unicode 码位排序产生相同结果。
编码表
| 码位范围 | 字节 1 | 字节 2 | 字节 3 | 字节 4 | 可用比特数 |
|---|---|---|---|---|---|
| U+0000–U+007F | 0xxxxxxx | — | — | — | 7 |
| U+0080–U+07FF | 110xxxxx | 10xxxxxx | — | — | 11 |
| U+0800–U+FFFF | 1110xxxx | 10xxxxxx | 10xxxxxx | — | 16 |
| U+10000–U+10FFFF | 11110xxx | 10xxxxxx | 10xxxxxx | 10xxxxxx | 21 |
编码示例:U+4E2D(中)
码位:0x4E2D = 0100 1110 0010 1101(16 位)
范围:U+0800–U+FFFF → 3 字节形式:1110xxxx 10xxxxxx 10xxxxxx
将 16 位分配到槽位:
1110[0100] 10[111000] 10[101101]
^^^^ ^^^^^^ ^^^^^^
字节 1: 0xE4 字节 2: 0xB8 字节 3: 0xAD
验证:在 UTF-8 文件中 grep "中" 就是搜索字节 E4 B8 AD。
这些字节(0xE4, 0xB8, 0xAD)都不在 0x00–0x7F 范围内,
所以不会与 ASCII 字符混淆。
为什么不用 UTF-16?
UTF-16 设计时假设 Unicode 能装进 16 位("基本多文种平面"假设)。当 Unicode 扩展到 U+FFFF 以上时,UTF-16 需要代理对 —— 同样变成变长编码,但失去了 UTF-8 的自同步和 ASCII 兼容性。
UTF-16 的劣势:
- 依赖字节序(需要 BOM 或外部协议)
- ASCII 文本包含空字节(破坏 C 字符串函数)
- 非 ASCII 透明(路径分隔符变成两字节)
- 仍然是变长(2 或 4 字节)—— 没有简洁性优势
- 对 ASCII 为主的文本浪费空间(大小翻倍)
UTF-16 存留在 Windows API、Java char、JavaScript 字符串和 .NET string 中 —— 都是"16 位够用"时代设计的。
Unicode 正规化:为什么 "Café" ≠ "Café"
问题
Unicode 允许同一个视觉字符有多种表示:
# 这两个看起来一样但字节序列不同:
a = "é" # U+00E9(预组合:带锐音的拉丁小写 e)
b = "é" # U+0065 U+0301(分解:e + 组合锐音符)
a == b # False!
len(a) # 1
len(b) # 2
这在字符串比较、数据库查找、文件名和安全检查中引发 Bug。
四种正规化形式
| 形式 | 名称 | 策略 | 使用场景 |
|---|---|---|---|
| NFC | 规范组合 | 分解后重新组合 | Web 默认、数据交换 |
| NFD | 规范分解 | 完全分解 | macOS 文件系统(HFS+) |
| NFKC | 兼容组合 | 兼容分解后重组 | 搜索、标识符 |
| NFKD | 兼容分解 | 兼容分解 | 排序、匹配 |
规范等价 vs 兼容等价
规范等价:同一字符,不同表示。NFC/NFD 在两者之间转换无信息损失。
é (U+00E9) ↔ e + ◌́ (U+0065 U+0301) [规范等价]
兼容等价:视觉相似但语义不同。NFKC/NFKD 将它们合并 —— 信息丢失。
fi (U+FB01) → fi (U+0066 U+0069) [兼容:连字 → 字母]
① (U+2460) → 1 (U+0031) [兼容:带圈 → 普通]
Ⅳ (U+2163) → IV [兼容:罗马数字]
正规化实践
import unicodedata
text = "Caf\u0065\u0301" # "Café",分解形式的 é
nfc = unicodedata.normalize('NFC', text)
nfd = unicodedata.normalize('NFD', text)
print(len(text)) # 5(C, a, f, e, 组合重音)
print(len(nfc)) # 4(C, a, f, é)
print(len(nfd)) # 5(始终完全分解)
# 数据库键比较必须先正规化
assert unicodedata.normalize('NFC', user_input) == stored_value
macOS HFS+ 问题
macOS HFS+(以及 APFS 为兼容性)以 NFD 形式存储文件名。创建名为 "café.txt" 的文件时,文件系统存储为 "cafe\u0301.txt"。如果应用程序不正规化就比较文件名,在 macOS 上会出现幽灵般的"文件未找到"错误,但在 Linux 上不会(Linux 原样存储字节)。
import os
import unicodedata
filename = "café.txt"
os.path.exists(filename) # 在 macOS 上如果文件名是 NFC 可能失败
# 安全的跨平台比较
def safe_filename_compare(a, b):
return unicodedata.normalize('NFC', a) == unicodedata.normalize('NFC', b)
编码安全
UTF-8 超长序列
UTF-8 规范要求每个码位使用最短可能的编码。字符 /(U+002F)必须编码为单字节 0x2F。但从比特模式上说,技术上允许:
U+002F 用 1 字节: 2F (合法)
U+002F 用 2 字节: C0 AF (非法超长)
U+002F 用 3 字节: E0 80 AF (非法超长)
安全影响:早期 Web 服务器先解码 UTF-8 再检查路径遍历。攻击者可以将点和斜杠编码为超长序列来绕过 ../ 检测。安全检查看到 C0 AE C0 AE C0 AF(不匹配 "../"),但解码器产生 "../"。
CVE-2000-0884(IIS Unicode 目录遍历)正是利用了这一点。
缓解措施:所有现代 UTF-8 解码器拒绝超长序列。永远不要自己实现 UTF-8 解码器 —— 使用平台的经验证实现。
同形字攻击
来自不同文字系统的视觉相同字符:
| 拉丁 | 西里尔 | 可混淆? |
|---|---|---|
| a (U+0061) | а (U+0430) | 是 |
| e (U+0065) | е (U+0435) | 是 |
| o (U+006F) | о (U+043E) | 是 |
| p (U+0070) | р (U+0440) | 是 |
| c (U+0063) | с (U+0441) | 是 |
| x (U+0078) | х (U+0445) | 是 |
这使域名欺骗成为可能:аpple.com(西里尔 а)vs apple.com(拉丁 a)。
缓解措施:
- IDN(国际化域名)显示规则:浏览器检测到混合文字时显示 punycode
- Unicode 安全机制(UTS #39):可混淆检测算法
- 标识符比较前进行 NFKC 正规化
双向覆盖漏洞
Unicode 包含方向控制字符:
| 字符 | 码位 | 效果 |
|---|---|---|
| RLO | U+202E | 从右到左覆盖 |
| LRO | U+202D | 从左到右覆盖 |
| RLI | U+2067 | 从右到左隔离 |
| U+202C | 弹出方向格式 |
攻击:包含 RLO 的文件名可以伪装文件扩展名:
显示:"document[RLO]fdp.exe"
看起来像:"documentexe.pdf"
用户看到 "documentexe.pdf" 但实际文件名以 ".exe" 结尾。
CVE-2021-42574(Trojan Source):源代码中的双向控制字符使代码审查看到的逻辑与编译器执行的不同。
缓解措施:
- 在用户提供的标识符中剥离或拒绝 bidi 控制字符
- 源代码编辑器应可见地渲染 bidi 标记
- GitHub 和 GitLab 现已标记包含 bidi 控制字符的文件
空字节注入
在基于 C 的系统中,空字节(0x00)终止字符串。如果高级语言(PHP、Python 2)将包含空字节的字符串传给 C 库:
用户输入: "malicious.php\x00.jpg"
PHP 检查: endsWith(".jpg") → 通过
C fopen(): 看到 "malicious.php"(在 \x00 处停止)
UTF-8 的设计防止了多字节序列中出现这种情况(后续字节永远不等于 0x00),但在混合编码无感知的 C API 与高级字符串时问题仍存在。
CJK 编码遗产
Unicode 之前,各地区开发了自己的编码:
| 编码 | 地区 | 字符数 | 字节/字符 |
|---|---|---|---|
| Shift_JIS | 日本 | ~7,000 汉字 | 1–2 |
| EUC-JP | 日本(Unix) | ~7,000 汉字 | 1–3 |
| GB2312 | 中国(简体) | 6,763 汉字 | 2 |
| GBK | 中国(扩展) | 21,886 汉字 | 1–2 |
| GB18030 | 中国(强制标准) | 70,000+ | 1–4 |
| Big5 | 台湾/香港(繁体) | 13,060 汉字 | 1–2 |
| EUC-KR | 韩国 | 2,350 韩文音节 | 1–2 |
乱码问题
"乱码"(日语:文字化け)发生在用一种编码方案编码的文本被另一种解码时:
# "中文"的 UTF-8 字节用 Latin-1 解码
"中文".encode('utf-8') # b'\xe4\xb8\xad\xe6\x96\x87'
b'\xe4\xb8\xad\xe6\x96\x87'.decode('latin-1') # '䏿\x96\x87'(乱码)
# "中文"的 GBK 字节用 UTF-8 解码
"中文".encode('gbk') # b'\xd6\xd0\xce\xc4'
b'\xd6\xd0\xce\xc4'.decode('utf-8', errors='replace') # '��ζ'(乱码)
GB18030:中国的强制标准
GB18030 值得注意因为:
- 法律强制在中国销售的软件必须支持(GB18030-2022)
- 是 GBK 的超集(向后兼容)
- 编码完整 Unicode 字符集(1–4 字节)
- 是 UTF-8 之外实现完整 Unicode 覆盖的替代方案
MySQL:utf8 vs utf8mb4
著名的坑
MySQL 的 utf8 字符集最多使用 3 字节/字符。这意味着无法存储 U+FFFF 以上的字符 —— 包括所有 emoji、许多 CJK 扩展字符和数学符号。
-- 失败:emoji 存入 utf8 列
INSERT INTO messages (text) VALUES ('Hello 😀');
-- Error: Incorrect string value '\xF0\x9F\x98\x80' for column 'text'
-- 😀 emoji 是 U+1F600,需要 4 个 UTF-8 字节(F0 9F 98 80)
修复:utf8mb4
-- 正确:使用 utf8mb4 以支持完整 Unicode
ALTER TABLE messages CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 新表
CREATE TABLE messages (
id INT PRIMARY KEY,
text VARCHAR(255) CHARACTER SET utf8mb4
) DEFAULT CHARSET=utf8mb4;
-- 连接字符集
SET NAMES utf8mb4;
utf8mb4 就是"真正的 UTF-8" —— 支持每字符 1–4 字节和完整 Unicode 范围。
索引长度影响
从 utf8 转换到 utf8mb4 时,索引键长度增加:
VARCHAR(255)utf8:最大索引大小 = 255 × 3 = 765 字节VARCHAR(255)utf8mb4:最大索引大小 = 255 × 4 = 1020 字节
InnoDB 默认索引前缀限制是 767 字节。转换到 utf8mb4 后,VARCHAR(255) 列的全长索引可能超限。解决方案:
- 使用
innodb_large_prefix=ON(MySQL 5.7+ 默认) - 减少 VARCHAR 长度到 191(191 × 4 = 764 < 767)
- 使用前缀索引:
INDEX (text(191))
Python:str vs bytes 边界
Python 3 的清晰模型
# str:Unicode 码位序列(抽象文本)
text = "Hello 中文"
type(text) # <class 'str'>
len(text) # 8(码位数,不是字节数)
# bytes:原始字节序列(编码后数据)
encoded = text.encode('utf-8')
type(encoded) # <class 'bytes'>
len(encoded) # 12(H,e,l,l,o,空格 = 6 字节 + 中 = 3 + 文 = 3)
# 不能混合 str 和 bytes
text + encoded # TypeError
编码边界规则
str(文本) bytes(网络/磁盘)
┌─────────────┐ ┌─────────────────┐
│ Unicode 码位 │ encode │ 原始字节流 │
│ │────────→│(UTF-8、GBK、…)│
│ │←────────│ │
│ │ decode │ │
└─────────────┘ └─────────────────┘
↑
编码边界:
所有 I/O 跨越此处
规则:在系统边界解码,内部使用 str 工作,输出时编码。
# 读文件
with open('data.txt', encoding='utf-8') as f:
text = f.read() # 返回 str(已解码)
# 网络响应
response = urllib.request.urlopen(url)
raw = response.read() # bytes
text = raw.decode('utf-8') # str
# 写输出
with open('output.txt', 'w', encoding='utf-8') as f:
f.write(text) # str → bytes 隐式发生
代理转义(surrogatepass)
Python 使用代理转义处理 Unix 上不是有效 UTF-8 的文件名:
import os
# Unix 文件名是字节,不保证是 UTF-8
# Python 用 'surrogateescape' 错误处理器解码它们
entries = os.listdir('.') # 返回 str,但可能包含代理
# 一个包含非法 UTF-8 字节 0xFF 的文件名:
# Python 将其表示为 U+DCFF(代理码位)
# 这样可以正确往返转换回原始字节
WHATWG 编码标准
Web 平台使用 WHATWG 编码标准(非 IANA 字符集注册表)来确定字符编码标签如何映射到解码器。
关键行为:
- "ascii" 标签映射到 windows-1252 解码器(不是真正的 ASCII)
- "iso-8859-1" 标签映射到 windows-1252 解码器
- 只有约 40 种支持的编码;其他全部回退到 UTF-8
- GB18030 解码器也处理 GBK 和 GB2312 标签
- 如果未指定
<meta charset>,UTF-8 是默认编码
// TextDecoder 遵循 WHATWG 编码标准
const decoder = new TextDecoder('windows-1252');
const text = decoder.decode(bytes);
// 'fatal' 模式在遇到非法序列时抛出错误而非替换
const strictDecoder = new TextDecoder('utf-8', { fatal: true });
try {
strictDecoder.decode(invalidBytes);
} catch (e) {
// TypeError: invalid UTF-8 sequence
}
字位簇:用户感知的"字符"
字符串长度的谎言
"café".length // 4 或 5(取决于正规化)
"👨👩👧👦".length // 11(7 个码位作为 UTF-16 单元)
"🇯🇵".length // 4(2 个区域指示符号,各为代理对)
"நி".length // 2(泰米尔语 NI = 辅音 + 元音符号)
// 用户感知的:
// "café" = 4 个字符
// "👨👩👧👦" = 1 个字符(家庭 emoji)
// "🇯🇵" = 1 个字符(国旗)
// "நி" = 1 个字符(音节)
Intl.Segmenter:正确的方案
function graphemeLength(str) {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
return [...segmenter.segment(str)].length;
}
graphemeLength("👨👩👧👦") // 1
graphemeLength("café") // 4
graphemeLength("🇯🇵") // 1
对文本处理的影响
| 操作 | 朴素方式(码元) | 正确方式(字位簇) |
|---|---|---|
| 截断 | 可能从 emoji 中间切开 | 保持完整字符 |
| 光标移动 | 跳入 emoji 内部 | 在视觉字符间移动 |
| 输入计数 | "280 字符"含义不同 | Twitter 使用 NFC 标量计数 |
| 子串 | 可能产生非法序列 | 干净边界 |
编码检测启发式
当没有元数据声明编码时,检测依赖统计启发式:
# chardet / charset-normalizer 方法:
# 1. 检查 BOM(如果存在则确定)
# 2. 尝试 UTF-8 严格解码 —— 如果有效,几乎确定是 UTF-8
# 3. 检查编码特有的字节模式:
# - Shift_JIS:0x81-0x9F 或 0xE0-0xEF 为引导字节
# - GBK:0x81-0xFE 为引导字节,0x40-0xFE 为尾随
# - EUC-KR:0xA1-0xFE 为引导和尾随
# 4. 字符频率分析(语言模型)
import charset_normalizer
results = charset_normalizer.from_bytes(raw_bytes)
best = results.best()
print(best.encoding) # 如 'utf-8', 'gbk', 'shift_jis'
print(best.language) # 如 'Chinese', 'Japanese'
根本局限:编码检测是启发式的,永远不确定。一个字节序列可能在多种编码下都有效。唯一可靠的方法是显式声明编码(HTTP Content-Type 头、HTML meta charset、BOM 或带外元数据)。
总结
| 原则 | 实现 |
|---|---|
| 始终使用 UTF-8 | HTTP Content-Type: text/html; charset=utf-8、<meta charset="utf-8">、数据库 utf8mb4 |
| 比较前正规化 | unicodedata.normalize('NFC', text) 或 text.normalize('NFC') |
| 在边界解码 | 读取字节 → 解码 → 处理文本 → 编码 → 写入字节 |
| 永远不要自己实现 UTF-8 解码器 | 使用平台 API;自制解码器遗漏超长/代理验证 |
| 怀疑编码标签 | WHATWG 将 "ascii" 映射到 windows-1252;声明的编码可能是错的 |
| UI 中计数字位簇 | Intl.Segmenter(JS)、grapheme crate(Rust)、ICU(C/C++) |
| 拒绝标识符中的 bidi 覆盖 | 剥离 U+202A–U+202E、U+2066–U+2069 |
参考文献
- Unicode Standard, Version 15.1 — unicode.org/versions/latest
- RFC 3629 — UTF-8, a transformation format of ISO 10646
- WHATWG Encoding Standard — encoding.spec.whatwg.org
- Unicode Technical Report #15 — Unicode Normalization Forms
- Unicode Technical Standard #39 — Unicode Security Mechanisms
- MySQL Reference Manual — "The utf8mb4 Character Set (4-Byte UTF-8 Unicode Encoding)"