一、引言:你真的懂字符串吗
每一个程序员都觉得自己理解字符串。毕竟,"hello"
有 5 个字符,strlen 返回
5——还能有什么复杂的?
然后你遇到了中文:"你好" 在 UTF-8 里占 6
个字节。你遇到了 emoji:"👨👩👧👦" 这个”一个字符”在
UTF-8 里占 25 个字节,包含 7 个码点(code
point),但在屏幕上渲染为 1 个字素簇(grapheme
cluster)。你遇到了阿拉伯文、泰文、藏文的复杂组合规则,你意识到——字符串可能是计算机科学中最被低估的数据结构。
Unicode 不是一张码表。它是一套算法体系。本文将深入这些算法的核心:从 UTF-8 编码的精巧设计、DFA/SIMD 验证,到规范化(normalization)、排序(collation)、字素簇分割(grapheme cluster breaking)、双向排版(bidi),直到真实世界中的安全陷阱。
我假设读者已经知道”Unicode 是什么”,但不一定知道”Unicode
是怎么工作的”。如果你在工作中写过 s.length()
却没有想过它返回的到底是什么,这篇文章就是为你准备的。
二、UTF-8 编码设计:Ken Thompson 的天才之作
起源故事
1992 年的某个晚上,Ken Thompson 和 Rob Pike
在新泽西一家餐厅的餐巾纸上设计了 UTF-8。这不是传说,这是 Rob
Pike 自己讲述的真实历史。当时 Plan 9 操作系统需要一种能与 C
语言的字符串约定兼容的 Unicode
编码方式——特别是,不能在多字节序列中嵌入
\0(NUL),因为 C 的字符串以 NUL 结尾。
他们的设计在第二天早上就完成了实现。这个设计后来征服了整个互联网——截至 2025 年,超过 98% 的网页使用 UTF-8 编码。
编码规则
UTF-8 是一种变长编码,使用 1 到 4 个字节表示一个 Unicode 码点:
| 码点范围 | 字节数 | 字节模式 | 有效数据位 |
|---|---|---|---|
| U+0000..U+007F | 1 | 0xxxxxxx |
7 |
| U+0080..U+07FF | 2 | 110xxxxx 10xxxxxx |
11 |
| U+0800..U+FFFF | 3 | 1110xxxx 10xxxxxx 10xxxxxx |
16 |
| U+10000..U+10FFFF | 4 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
21 |
设计的精妙之处
这个设计有多优雅?让我列举它的关键性质:
性质一:ASCII 完全兼容。 所有 ASCII 字符(0x00-0x7F)在 UTF-8 中就是它们自身。任何合法的 ASCII 文本同时也是合法的 UTF-8 文本。这意味着大量已有的 C 程序不需要任何修改就能处理 UTF-8——只要它们不试图按字符拆分字符串。
性质二:自同步(self-synchronizing)。
从任意字节位置开始,你最多向前扫描 3
个字节就能找到下一个码点的起始位置。因为前导字节(leading
byte)的高位模式与续接字节(continuation byte)的
10xxxxxx
模式是互不重叠的。这个性质对于错误恢复和随机访问至关重要。
性质三:字节序无关(endianness-free)。 不像 UTF-16 和 UTF-32 需要 BOM(Byte Order Mark)来标识大小端,UTF-8 的字节顺序是固定的。这消除了一整类 bug。
性质四:字节排序等于码点排序。 对 UTF-8
字节流做 memcmp,其结果与对 Unicode
码点序列做比较是一致的。这意味着你可以用标准的字节排序算法对
UTF-8 字符串排序,得到的就是 Unicode 码点顺序。
性质五:前导字节直接编码序列长度。 看第一个字节就知道整个序列有多长。不需要状态机,不需要前瞻。
性质六:多字节序列中不包含 NUL。
续接字节的范围是 0x80-0xBF,永远不会是 0x00。这意味着 C 的
strchr(s, '/') 和 strlen 在 UTF-8
字符串上仍然能正确工作(只要你搜索的是 ASCII 字符)。
性质七:不存在合法的超长编码。
每个码点有且仅有一种合法的编码形式——最短的那种。比如 NUL
必须编码为 0x00,而不能用两字节的
0xC0 0x80。这个性质是安全关键的,后面会详细讨论。
让我用一段话总结 Thompson 的设计哲学:用最小的代价实现与已有系统的最大兼容性,同时不牺牲正确性和安全性。在软件工程的历史上,能同时满足这些目标的设计屈指可数。
三、UTF-8 验证算法:从 DFA 到 SIMD
为什么需要验证
UTF-8 验证不是可选的。如果你接受来自外部的字节流并假设它是合法的 UTF-8,你就为安全漏洞打开了大门。非法的 UTF-8 序列可以导致:
- 缓冲区溢出(读取超出分配的内存)
- 超长编码攻击(绕过安全过滤器)
- 非法代理对(surrogate pairs,U+D800..U+DFFF 在 UTF-8 中不合法)
- 超出 Unicode 范围的码点(>U+10FFFF)
DFA 方法
Bjoern Hoehrmann 提出了一种优雅的 DFA(确定性有限自动机)方法来验证 UTF-8。其核心思想是用一个状态转移表,对每个输入字节进行一次查表操作。整个验证器只需要 364 字节的查找表和几行代码。
这个 DFA 有 9 个状态,其中状态 0 是接受状态(accept),状态 1 是拒绝状态(reject)。转移函数由两个查表操作组成:首先根据字节值查出字节类型(共 12 种),然后根据当前状态和字节类型查出下一个状态。
/* Bjoern Hoehrmann 的 UTF-8 DFA 验证器 */
/* 字节分类表:将 0x00-0xFF 映射到 12 种类型 */
static const uint8_t utf8d[] = {
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,
7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,
8,8,2,2,2,2,2,2,2,2,2,2,2,2,2,2, 2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
10,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3, 11,6,6,6,5,4,4,4,4,4,4,4,4,4,4,4,
/* 状态转移表 */
0,12,24,36,60,96,84,12,12,12,48,72, 12,12,12,12,12,12,12,12,12,12,12,12,
12, 0,12,12,12,12,12, 0,12, 0,12,12, 12,24,12,12,12,12,12,24,12,24,12,12,
12,12,12,12,12,12,12,24,12,12,12,12, 12,24,12,12,12,12,12,12,12,24,12,12,
12,12,12,12,12,12,12,36,12,36,12,12, 12,36,12,12,12,12,12,36,12,36,12,12,
12,36,12,12,12,12,12,12,12,12,12,12,
};
#define UTF8_ACCEPT 0
#define UTF8_REJECT 12
static inline uint32_t
utf8_decode(uint32_t *state, uint32_t *codep, uint32_t byte)
{
uint32_t type = utf8d[byte];
*codep = (*state != UTF8_ACCEPT)
? (byte & 0x3Fu) | (*codep << 6)
: (0xFFu >> type) & byte;
*state = utf8d[256 + *state + type];
return *state;
}这个设计的精髓在于:验证和解码是同时完成的。
每个字节只需要两次查表和几条算术指令。在整个字节流处理完之后,如果状态回到
UTF8_ACCEPT,则输入是合法的 UTF-8。
Daniel Lemire 的 SIMD 验证
DFA 方法虽然优雅,但每次处理一个字节。在现代 CPU 上,我们可以用 SIMD(Single Instruction, Multiple Data)指令一次验证 16 或 32 个字节。Daniel Lemire 和 John Keiser 在 simdjson 项目中实现了一种极快的 SIMD UTF-8 验证器。
其核心思想是:
用
vpshufb(向量查表)替代标量查表。 将字节的高 4 位和低 4 位分别作为索引,通过两次 SIMD 查表和一次 AND 操作完成字节分类。利用位移和 OR 操作传播前导字节的信息到续接字节的位置。 这样每个续接字节”知道”它应该跟在什么样的前导字节后面。
所有错误检测并行进行。 超长编码、非法代理对、超范围码点都在同一轮 SIMD 操作中检测。
用伪代码表示核心逻辑:
/* SIMD UTF-8 验证的核心思路(伪代码) */
/* 对 16 字节输入 input[0..15] */
__m128i classify_high = _mm_shuffle_epi8(lut_high, input >> 4);
__m128i classify_low = _mm_shuffle_epi8(lut_low, input & 0x0F);
__m128i category = _mm_and_si128(classify_high, classify_low);
/* 将前导字节信息传播到续接字节位置 */
__m128i prev1 = _mm_alignr_epi8(input, prev_input, 15); /* 前移 1 字节 */
__m128i prev2 = _mm_alignr_epi8(input, prev_input, 14); /* 前移 2 字节 */
__m128i prev3 = _mm_alignr_epi8(input, prev_input, 13); /* 前移 3 字节 */
/* 检查每个续接字节的前导字节是否合法 */
__m128i expected = compute_expected(prev1, prev2, prev3);
__m128i errors = _mm_xor_si128(category, expected);
/* 累积错误 */
error_acc = _mm_or_si128(error_acc, errors);性能对比
在 x86-64 架构上,对随机 UTF-8 文本的验证速度:
| 方法 | 吞吐量 (GB/s) | 每字节指令数 |
|---|---|---|
| 朴素逐字节(naive) | ~0.8 | ~12 |
| Hoehrmann DFA | ~1.5 | ~6 |
| SIMD SSE4.2 | ~8.0 | ~1.2 |
| SIMD AVX-512 | ~16.0 | ~0.6 |
SIMD 方法比朴素方法快了约 20 倍。对于需要处理大量外部输入的系统(HTTP 服务器、数据库、JSON 解析器),这个差距是实实在在的。
我的观点是:如果你在写一个需要高吞吐量的文本处理系统,SIMD UTF-8 验证不是优化——它是基础设施。 simdjson 能够以 CPU 的内存带宽极限速度解析 JSON,UTF-8 验证就是其中一个关键环节。
四、完整的 UTF-8 编解码器实现
下面是一个完整的 C 语言 UTF-8 编解码器和验证器实现。代码追求清晰而非极致性能,适合嵌入到任何 C/C++ 项目中。
/*
* utf8.h — 完整的 UTF-8 编解码器与验证器
*
* 功能:
* - utf8_encode: 将一个 Unicode 码点编码为 UTF-8 字节序列
* - utf8_decode: 从 UTF-8 字节流中解码一个码点
* - utf8_validate: 验证一段字节流是否为合法 UTF-8
* - utf8_codepoint_length: 根据码点值返回编码所需字节数
* - utf8_sequence_length: 根据前导字节返回序列长度
* - utf8_count_codepoints: 计算 UTF-8 字符串中的码点数量
*
* 安全考虑:
* - 拒绝超长编码(over-long encoding)
* - 拒绝代理对码点(U+D800..U+DFFF)
* - 拒绝超出 Unicode 范围的码点(> U+10FFFF)
* - 拒绝非法的续接字节
*/
#ifndef UTF8_H
#define UTF8_H
#include <stdint.h>
#include <stddef.h>
/* 错误码 */
#define UTF8_OK 0
#define UTF8_ERR_TRUNCATED -1 /* 序列在中间被截断 */
#define UTF8_ERR_OVERLONG -2 /* 超长编码 */
#define UTF8_ERR_SURROGATE -3 /* 代理对码点 */
#define UTF8_ERR_RANGE -4 /* 超出 Unicode 范围 */
#define UTF8_ERR_INVALID -5 /* 非法的前导字节或续接字节 */
/* Unicode 相关常量 */
#define UNICODE_MAX 0x10FFFF
#define SURROGATE_MIN 0xD800
#define SURROGATE_MAX 0xDFFF
#define UTF8_REPLACEMENT 0xFFFD
/*
* utf8_codepoint_length - 根据码点值返回 UTF-8 编码所需字节数
*
* 返回 1-4,或 0 表示非法码点
*/
static inline int utf8_codepoint_length(uint32_t cp)
{
if (cp <= 0x7F) return 1;
if (cp <= 0x7FF) return 2;
if (cp <= 0xFFFF) {
/* 排除代理对 */
if (cp >= SURROGATE_MIN && cp <= SURROGATE_MAX) return 0;
return 3;
}
if (cp <= UNICODE_MAX) return 4;
return 0;
}
/*
* utf8_sequence_length - 根据前导字节判断 UTF-8 序列长度
*
* 返回 1-4,或 0 表示非法前导字节(0x80-0xBF, 0xF8-0xFF)
*/
static inline int utf8_sequence_length(uint8_t lead)
{
if (lead < 0x80) return 1;
if (lead < 0xC2) return 0; /* 0x80-0xBF 是续接字节, 0xC0-0xC1 是超长 */
if (lead < 0xE0) return 2;
if (lead < 0xF0) return 3;
if (lead < 0xF5) return 4; /* 0xF5+ 会产生 > U+10FFFF */
return 0;
}
/*
* utf8_encode - 将一个码点编码为 UTF-8
*
* buf: 输出缓冲区,至少 4 字节
* cp: 要编码的码点
*
* 返回写入的字节数(1-4),或 0 表示非法码点
*/
static inline int utf8_encode(uint8_t *buf, uint32_t cp)
{
if (cp <= 0x7F) {
buf[0] = (uint8_t)cp;
return 1;
}
if (cp <= 0x7FF) {
buf[0] = (uint8_t)(0xC0 | (cp >> 6));
buf[1] = (uint8_t)(0x80 | (cp & 0x3F));
return 2;
}
if (cp <= 0xFFFF) {
if (cp >= SURROGATE_MIN && cp <= SURROGATE_MAX)
return 0;
buf[0] = (uint8_t)(0xE0 | (cp >> 12));
buf[1] = (uint8_t)(0x80 | ((cp >> 6) & 0x3F));
buf[2] = (uint8_t)(0x80 | (cp & 0x3F));
return 3;
}
if (cp <= UNICODE_MAX) {
buf[0] = (uint8_t)(0xF0 | (cp >> 18));
buf[1] = (uint8_t)(0x80 | ((cp >> 12) & 0x3F));
buf[2] = (uint8_t)(0x80 | ((cp >> 6) & 0x3F));
buf[3] = (uint8_t)(0x80 | (cp & 0x3F));
return 4;
}
return 0;
}
/*
* utf8_decode - 从 UTF-8 字节流解码一个码点
*
* data: 输入字节流
* len: 剩余字节数
* out_cp: 输出码点
* out_size: 输出消耗的字节数
*
* 返回 UTF8_OK 或错误码
*/
static inline int utf8_decode(const uint8_t *data, size_t len,
uint32_t *out_cp, int *out_size)
{
if (len == 0) return UTF8_ERR_TRUNCATED;
uint8_t lead = data[0];
int seq_len = utf8_sequence_length(lead);
if (seq_len == 0) {
*out_size = 1;
return UTF8_ERR_INVALID;
}
if ((size_t)seq_len > len) {
*out_size = (int)len;
return UTF8_ERR_TRUNCATED;
}
/* 检查续接字节 */
for (int i = 1; i < seq_len; i++) {
if ((data[i] & 0xC0) != 0x80) {
*out_size = i;
return UTF8_ERR_INVALID;
}
}
uint32_t cp;
switch (seq_len) {
case 1:
cp = lead;
break;
case 2:
cp = ((uint32_t)(lead & 0x1F) << 6)
| ((uint32_t)(data[1] & 0x3F));
/* 检查超长:2 字节序列最小值为 U+0080 */
if (cp < 0x80) {
*out_size = 2;
return UTF8_ERR_OVERLONG;
}
break;
case 3:
cp = ((uint32_t)(lead & 0x0F) << 12)
| ((uint32_t)(data[1] & 0x3F) << 6)
| ((uint32_t)(data[2] & 0x3F));
/* 检查超长:3 字节序列最小值为 U+0800 */
if (cp < 0x800) {
*out_size = 3;
return UTF8_ERR_OVERLONG;
}
/* 检查代理对 */
if (cp >= SURROGATE_MIN && cp <= SURROGATE_MAX) {
*out_size = 3;
return UTF8_ERR_SURROGATE;
}
break;
case 4:
cp = ((uint32_t)(lead & 0x07) << 18)
| ((uint32_t)(data[1] & 0x3F) << 12)
| ((uint32_t)(data[2] & 0x3F) << 6)
| ((uint32_t)(data[3] & 0x3F));
/* 检查超长:4 字节序列最小值为 U+10000 */
if (cp < 0x10000) {
*out_size = 4;
return UTF8_ERR_OVERLONG;
}
/* 检查范围 */
if (cp > UNICODE_MAX) {
*out_size = 4;
return UTF8_ERR_RANGE;
}
break;
default:
*out_size = 1;
return UTF8_ERR_INVALID;
}
*out_cp = cp;
*out_size = seq_len;
return UTF8_OK;
}
/*
* utf8_validate - 验证一段字节流是否为合法 UTF-8
*
* data: 输入字节流
* len: 字节数
* err_pos: 若非法,输出第一个错误位置(可为 NULL)
*
* 返回 UTF8_OK 或错误码
*/
static inline int utf8_validate(const uint8_t *data, size_t len,
size_t *err_pos)
{
size_t pos = 0;
while (pos < len) {
uint32_t cp;
int size;
int rc = utf8_decode(data + pos, len - pos, &cp, &size);
if (rc != UTF8_OK) {
if (err_pos) *err_pos = pos;
return rc;
}
pos += (size_t)size;
}
if (err_pos) *err_pos = 0;
return UTF8_OK;
}
/*
* utf8_count_codepoints - 计算 UTF-8 字符串中的码点数量
*
* 假设输入已经过验证。对于未验证的输入,请先调用 utf8_validate。
*
* 返回码点数量
*/
static inline size_t utf8_count_codepoints(const uint8_t *data, size_t len)
{
size_t count = 0;
size_t pos = 0;
while (pos < len) {
int seq_len = utf8_sequence_length(data[pos]);
if (seq_len == 0) {
pos++;
continue;
}
count++;
pos += (size_t)seq_len;
}
return count;
}
/*
* utf8_next - 安全地前进到下一个码点
*
* 用于遍历合法的 UTF-8 字符串
*/
static inline const uint8_t *utf8_next(const uint8_t *p, const uint8_t *end)
{
if (p >= end) return end;
int len = utf8_sequence_length(*p);
if (len == 0) return p + 1;
const uint8_t *next = p + len;
return (next > end) ? end : next;
}
#endif /* UTF8_H */这个实现的关键设计决策:
- 所有函数都是
static inline:作为头文件库,零链接开销,编译器可以内联优化。 - 区分不同的错误类型:不是简单的”合法/非法”,而是精确报告错误原因和位置。
- 前导字节 0xC0 和 0xC1 在
utf8_sequence_length中直接拒绝:因为它们只能产生超长编码(编码 U+0000..U+007F 的两字节形式)。同理,0xF5-0xFF 也直接拒绝。 - 解码时的超长检查是逐类进行的:不是解码后再统一检查,而是在每个
case分支中检查该长度类的最小值。
五、Unicode 规范化:NFC、NFD 与那些看起来一样的字符串
同一个字符,多种表示
考虑字符 “é”(带锐音符的 e)。在 Unicode 中,它有两种合法的表示方式:
- 预合成形式(precomposed):U+00E9,一个码点,LATIN SMALL LETTER E WITH ACUTE
- 分解形式(decomposed):U+0065 U+0301,两个码点,e + COMBINING ACUTE ACCENT
这两种形式在屏幕上看起来完全一样,但它们的字节序列不同。这意味着:
# Python 3 示例
s1 = "\u00e9" # é (预合成)
s2 = "\u0065\u0301" # é (分解)
print(s1 == s2) # False!
print(len(s1)) # 1
print(len(s2)) # 2如果你用这样的字符串作为文件名、数据库键、或者做字符串匹配,你就会遇到”幽灵 bug”——两个看起来完全一样的字符串不相等。macOS 的 HFS+ 文件系统(以及后继的 APFS)在创建文件时会自动将文件名转换为 NFD 形式,而 Linux 不做任何转换。如果你在 macOS 上创建一个文件,用 Linux 去匹配文件名,就可能找不到。
四种规范化形式
Unicode 定义了四种规范化形式(Normalization Form):
| 形式 | 全称 | 描述 |
|---|---|---|
| NFD | Canonical Decomposition | 规范分解:将所有预合成字符分解为基础字符 + 组合标记 |
| NFC | Canonical Decomposition + Canonical Composition | 规范分解后再合成:先分解,再尽可能合成回预合成形式 |
| NFKD | Compatibility Decomposition | 兼容分解:在 NFD 基础上还分解兼容等价字符 |
| NFKC | Compatibility Decomposition + Canonical Composition | 兼容分解后再合成 |
规范等价(canonical equivalence) 和 兼容等价(compatibility equivalence) 的区别很重要:
- 规范等价:两种表示在语义上完全相同,只是编码方式不同。例如 “é” 的预合成和分解形式。
- 兼容等价:两种表示在某些上下文中可以互换,但可能有不同的含义。例如 “fi”(U+FB01,LATIN SMALL LIGATURE FI)与 “fi”(两个独立字符)。
规范化算法
NFC 的规范化算法分三步:
第一步:规范分解。 递归地将每个字符分解为其规范分解映射(Canonical Decomposition Mapping)。例如:
U+01D5 (Ǖ) → U+00DC (Ü) + U+0304 (◌̄)
→ U+0055 (U) + U+0308 (◌̈) + U+0304 (◌̄)
分解映射是一个查表操作,数据来自 Unicode Character Database(UCD)。
第二步:规范排序。 对所有组合标记按其 Canonical Combining Class(CCC)值排序。CCC 为 0 的字符(基础字符和某些特殊标记)保持原位,CCC 非零的相邻字符按 CCC 值升序排列。这确保了无论原始输入的组合标记顺序如何,规范化后的结果是唯一的。
/* 排序规则:CCC 值相同的标记保持原始顺序(稳定排序) */
/* 例如:U+0308 (CCC=230) 和 U+0304 (CCC=230) 保持原序 */
/* 但 U+0327 (CCC=202) 会排在 U+0308 (CCC=230) 之前 */
第三步:规范合成(仅 NFC)。 从左到右扫描,尝试将基础字符与后续的组合标记合成为预合成字符。合成是有条件的——只有当基础字符和组合标记之间没有”阻塞字符”(CCC 不小于该组合标记的 CCC 的字符)时才能合成。
实用建议
我的建议很简单:
- 在系统边界做一次 NFC 规范化。 不要假设输入是什么形式,接收后统一转为 NFC。
- 数据库中存储 NFC。 W3C 和 WHATWG 都推荐 NFC。
- 搜索和匹配时考虑使用 NFKC。 如果你希望 “file” 和 “file” 匹配到同一个结果。
- 不要自己实现。 用 ICU 库。规范化涉及大量的查表数据和边界情况。
六、字素簇:什么才是一个”字符”
码点不是字符
我前面提到了一个例子:"👨👩👧👦" 包含 7
个码点但是”一个字符”。让我拆解给你看:
U+1F468 👨 MAN
U+200D ZERO WIDTH JOINER
U+1F469 👩 WOMAN
U+200D ZERO WIDTH JOINER
U+1F467 👧 GIRL
U+200D ZERO WIDTH JOINER
U+1F466 👦 BOY
这 7 个码点通过 ZWJ(Zero Width Joiner)连接,渲染引擎将它们组合为一个家庭 emoji。在 Unicode 术语中,这整个序列是一个扩展字素簇(extended grapheme cluster)。
再看韩语的例子。韩语音节 “한” 可以表示为:
- 一个预合成码点 U+D55C
- 或者三个 Jamo 码点:U+1112 (ᄒ) + U+1161 (ᅡ) + U+11AB (ᆫ)
两者都是一个字素簇。
UAX #29:文本分割
Unicode Standard Annex #29(UAX #29)定义了如何将文本分割为字素簇。其核心规则可以用一组 break/no-break 规则表示:
# 简化版字素簇分割规则
# "÷" 表示可以断开,"×" 表示不可断开
GB1: sot ÷ Any # 文本开头
GB2: Any ÷ eot # 文本结尾
GB3: CR × LF # CR + LF 是一个簇
GB4: Control ÷ # 控制字符后断开
GB5: ÷ Control # 控制字符前断开
GB6: L × (L|V|LV|LVT) # 韩语 Jamo 规则
GB7: (LV|V) × (V|T)
GB8: (LVT|T) × T
GB9: × Extend # 组合标记不断开
GB9a: × SpacingMark
GB9b: Prepend ×
GB11: ExtPict Extend* ZWJ × ExtPict # ZWJ emoji 序列
GB12: RI RI ÷ RI # 区域指示符(国旗)成对
GB999: Any ÷ Any # 默认断开
为什么这很重要
几乎所有与用户交互的文本操作都应该基于字素簇,而不是码点:
- 光标移动:按左右键应该以字素簇为单位移动。
- 删除:按 Backspace 应该删除一个字素簇。
- 选中:双击选中一个词,词的边界应该对齐到字素簇。
- 截断:将字符串截断到 N 个”字符”,应该以字素簇为单位。
- 反转:
reverse("é")如果按码点反转,会把组合标记和基础字符分开。
如果你用的语言/库只提供码点级别的操作(例如 Go 的
range 遍历
rune),你需要额外引入字素簇分割库。Swift
是少数原生以字素簇为基本”字符”单位的语言,这也是它的字符串
API 看起来比较”奇怪”的原因——因为它是正确的。
七、排序算法:UCA 与区域定制
排序不是 strcmp
你可能以为对字符串排序就是比较字节或码点。但是:
- 在德语中,“ö” 排在 “o” 和 “p” 之间。
- 在瑞典语中,“ö” 排在 “z” 之后。
- 在德语电话簿排序中,“ö” 等价于 “oe”。
- 在日语中,“は” 和 “ば” 和 “ぱ” 的排序关系取决于上下文。
Unicode Collation Algorithm(UCA) 定义了一个与语言无关的默认排序,以及一套机制来为特定语言定制排序规则。
UCA 的多层级比较
UCA 使用多层级(multi-level)比较策略,典型配置为 4 个层级:
| 层级 | 名称 | 区分内容 | 示例 |
|---|---|---|---|
| L1 | 基础字符 | 不同字母 | a < b < c |
| L2 | 重音/变音 | 相同字母的不同变音 | a < á < ä |
| L3 | 大小写 | 相同字母相同变音的大小写 | a < A |
| L4 | 特殊 | 标点、空格等 | “co-op” < “coop” |
比较过程如下:
- 将两个字符串中的每个字符映射到其
排序元素(collation
element),每个元素是一个多层级权重的元组
[L1, L2, L3, L4]。 - 先比较所有 L1 权重。如果不同,结果确定。
- 如果所有 L1 权重相同,比较所有 L2 权重。
- 依此类推。
/* UCA 比较的核心逻辑(简化) */
int uca_compare(const collation_element *a, size_t a_len,
const collation_element *b, size_t b_len)
{
/* 层级 1:基础字符 */
for (size_t i = 0; i < min(a_len, b_len); i++) {
if (a[i].l1 != b[i].l1)
return (a[i].l1 < b[i].l1) ? -1 : 1;
}
if (a_len != b_len) return (a_len < b_len) ? -1 : 1;
/* 层级 2:重音 */
for (size_t i = 0; i < a_len; i++) {
if (a[i].l2 != b[i].l2)
return (a[i].l2 < b[i].l2) ? -1 : 1;
}
/* 层级 3:大小写 */
for (size_t i = 0; i < a_len; i++) {
if (a[i].l3 != b[i].l3)
return (a[i].l3 < b[i].l3) ? -1 : 1;
}
return 0;
}区域定制(Tailoring)
CLDR(Common Locale Data Repository)为每种语言提供排序规则的定制数据。定制通过”重置”和”移位”操作实现:
# 瑞典语定制:将 ö 移到 z 之后
&z < ö
# 德语电话簿定制:ö 等价于 oe
&o << ö / e
# 中文拼音排序:按拼音首字母
&a < 啊 < 阿 < 吖
&b < 八 < 巴 < 把
排序键(Sort Key)
为了避免每次比较都执行完整的多层级算法,UCA
可以为每个字符串生成一个排序键——一个字节序列,使得对排序键的
memcmp 等价于完整的 UCA
比较。排序键的生成是将各层级权重按顺序连接,层级之间用
0x00 分隔:
"café" → [L1 weights][0x00][L2 weights][0x00][L3 weights]
这使得排序算法可以用标准的字节比较,大幅提升排序性能。
八、双向算法:当文本方向不一致
问题背景
大多数程序员使用的是从左到右(LTR)的文字。但阿拉伯语和希伯来语是从右到左(RTL)的。当 LTR 和 RTL 文本混合出现时——比如一段阿拉伯语中嵌入了英文单词和数字——确定每个字符的显示顺序就成了一个非平凡的算法问题。
Unicode Bidirectional Algorithm(UAX #9)就是解决这个问题的。
算法概述
Bidi 算法分为以下主要步骤:
第一步:确定段落方向。 找到段落中第一个具有强方向性的字符(L、R、AL),其方向就是段落方向。
第二步:确定每个字符的嵌入级别(embedding level)。 级别是一个 0-125 的整数,偶数表示 LTR,奇数表示 RTL。基础级别由段落方向决定(LTR=0,RTL=1)。显式方向标记(如 LRO、RLO、LRE、RLE、PDF,以及 Unicode 6.3 引入的 LRI、RLI、FSI、PDI)可以改变嵌入级别。
第三步:解析弱类型和中性类型。 数字、标点、空格等字符的方向由上下文决定。例如,两个阿拉伯词之间的空格被视为 RTL,而两个英文词之间的空格被视为 LTR。
第四步:处理隐式级别调整。 确保 LTR 字符的级别为偶数,RTL 字符的级别为奇数。
第五步:按级别反转。 从最高级别开始,依次反转每个级别范围内的字符顺序。这就是最终的显示顺序。
逻辑顺序: "The title is مفتاح in Arabic"
(mftaH)
嵌入级别: 0000000000000001111100000000000000
← RTL →
显示顺序: "The title is حاتفم in Arabic"
(反转后)
Bidi 的安全隐患
2021 年,Nicolas Boucher 和 Ross Anderson 发表了 “Trojan Source” 攻击,利用 Unicode 方向控制字符在源代码中制造视觉与逻辑不一致:
/* 攻击示例(概念性展示)*/
/* 代码在屏幕上看起来是一回事,实际逻辑是另一回事 */
/* 通过嵌入 RLO (U+202E) 和 LRI (U+2066) 等控制字符 */
/* 可以让代码审查者看到的逻辑与编译器看到的不同 */这导致了 Rust 编译器(CVE-2021-42574)和其他工具紧急添加了对源代码中 bidi 控制字符的警告。
我的看法:源代码应该禁止包含 bidi 控制字符。 任何代码审查工具都应该将其标记为高风险。
九、大小写折叠:Turkish İ 问题
简单的 toLowerCase 不简单
大小写转换看起来是最简单的文本操作之一。但 Unicode 的大小写映射是上下文相关的、语言相关的、一对多的。
土耳其语/阿塞拜疆语的 İ 问题 是最经典的案例:
| 操作 | 英语 | 土耳其语 |
|---|---|---|
| ‘I’ → lowercase | ‘i’ | ‘ı’ (U+0131, DOTLESS I) |
| ‘i’ → uppercase | ‘I’ | ‘İ’ (U+0130, DOTTED I) |
| ‘İ’ → lowercase | ‘i’ (非标准) | ‘i’ |
| ‘ı’ → uppercase | ‘I’ (非标准) | ‘I’ |
如果你在土耳其语环境下用默认的大小写转换比较文件名或用户名,"FILE"
和 "file" 就不会匹配——因为
"FILE".toLower("tr") 是
"fıle"。
这个问题在 Java 中是经典考题:
// Java 中的陷阱
"TITLE".toLowerCase(); // "title" (英语环境)
"TITLE".toLowerCase(new Locale("tr")); // "tıtle" (土耳其语环境!)
// 如果你的 JVM 默认 locale 是 tr_TR,所有 toLowerCase() 都会受影响
// 这就是为什么你应该总是显式传递 Locale.ROOT
"TITLE".toLowerCase(Locale.ROOT); // "title" (总是正确的)其他大小写陷阱
一对多映射:德语的 “ß” 大写是
“SS”(两个字符)。这意味着 toUpperCase
可能改变字符串长度。2017 年 Unicode 引入了大写的
ẞ(U+1E9E),但传统规则仍然有效。
上下文相关映射:希腊语的
σ(sigma)在词尾变为 ς(final
sigma)。"ΟΔΟΣ".toLowerCase() 应该是
"οδός" 而不是 "οδοσ"。
大小写折叠(case folding)
是为了大小写不敏感比较而设计的。它与
toLowerCase 不同——case folding
是幂等的、与语言无关的(除了可选的土耳其规则),并且某些字符会被折叠到既不是大写也不是小写的形式。
# Python 的 casefold() 实现了 Unicode case folding
"straße".casefold() # "strasse" (ß → ss)
"MASSE".casefold() # "masse"
"straße".lower() # "straße" (lower 不做这个转换!)
# 正确的大小写不敏感比较
def case_insensitive_eq(a, b):
return a.casefold() == b.casefold()工程建议
- 永远不要用
toLowerCase()+ 比较来做大小写不敏感比较。 用casefold()或库提供的 case-insensitive 比较函数。 - 显式指定 locale。 不要依赖系统默认 locale。
- 在协议和标识符中使用 ASCII 大小写折叠。 HTTP 头、DNS 名、email 地址——这些应该只做 ASCII 范围的大小写折叠。
- 记住大小写转换可以改变字符串长度。 不要假设 buffer 大小不变。
十、安全问题:当 Unicode 成为攻击向量
同形字攻击(Homoglyph Attack)
看看这两个 URL:
apple.com
аpple.com
它们看起来一样,对吧?但第二个中的 “а” 不是 ASCII 的
“a”(U+0061),而是西里尔字母 “а”(U+0430)。攻击者可以注册
аpple.com 并用于钓鱼。
类似的同形字对比比皆是:
| 拉丁字母 | 同形字 | 来源 |
|---|---|---|
| a (U+0061) | а (U+0430) | 西里尔 |
| o (U+006F) | о (U+043E) | 西里尔 |
| p (U+0070) | р (U+0440) | 西里尔 |
| e (U+0065) | е (U+0435) | 西里尔 |
| c (U+0063) | с (U+0441) | 西里尔 |
| H (U+0048) | Н (U+041D) | 西里尔 |
| 0 (U+0030) | О (U+041E) | 西里尔 |
| 1 (U+0031) | l (U+006C) | 拉丁 |
| 1 (U+0031) | Ι (U+0399) | 希腊 |
浏览器的应对策略是 IDN(Internationalized Domain Names)同形字检测:如果一个域名混合使用了多个脚本(scripts),浏览器会显示 Punycode 形式而不是 Unicode 形式。但这个防御不是万能的。
超长编码攻击
早期的 UTF-8 实现不检查超长编码。攻击者可以用超长编码绕过安全过滤器:
'/' = 0x2F (正常 ASCII)
'/' = 0xC0 0xAF (非法的 2 字节超长)
'/' = 0xE0 0x80 0xAF (非法的 3 字节超长)
'/' = 0xF0 0x80 0x80 0xAF (非法的 4 字节超长)
如果你的 Web 服务器先对路径做安全检查(“不允许
../”),然后再做 UTF-8
解码,攻击者就可以用超长编码的 . 和
/ 绕过检查。这就是 IIS/Apache
历史上多个目录遍历漏洞的根源。
防御:在处理任何语义之前先验证 UTF-8 的合法性。这是第一道防线,没有例外。
不可见字符和零宽字符
Unicode 包含大量不可见或零宽度的字符:
- U+200B:零宽空格(Zero Width Space)
- U+200C:零宽非连接符(Zero Width Non-Joiner)
- U+200D:零宽连接符(Zero Width Joiner)
- U+FEFF:零宽非断行空格(BOM / Zero Width No-Break Space)
- U+2060:词连接符(Word Joiner)
- U+2062:隐式乘号(Invisible Times)
- U+2063:隐式逗号(Invisible Separator)
攻击者可以利用这些字符:
- 绕过关键词过滤:在敏感词中插入零宽字符,
password看起来像 “password” 但不匹配简单的字符串比较。 - 水印追踪:在文本中嵌入零宽字符的不同组合,可以唯一标识每个接收者。
- 污染训练数据:在 ML 训练数据中注入不可见字符。
正则表达式与 Unicode
很多程序员写的正则表达式在 Unicode 面前不堪一击:
# 这些正则表达式都有问题
import re
# "匹配单词字符" —— 取决于 re.UNICODE 标志
re.match(r'\w+', '中文') # 在 Python 3 中匹配(默认 Unicode)
# "匹配任意字符" —— 不匹配 astral plane 字符(某些语言中)
# Python 3 正确处理,但 JavaScript 旧版本需要 /u 标志
# "匹配字母" —— 应该用 Unicode 属性
re.match(r'[\p{L}]+', '中文') # 需要 regex 模块,标准库不支持 \p{}
# 最危险的:用 [a-zA-Z] "匹配字母"
re.match(r'[a-zA-Z]+', 'café') # 只匹配 "caf",漏掉了 é安全清单
下面是一份 Unicode 安全检查清单,供工程团队参考:
| 检查项 | 风险等级 | 建议 |
|---|---|---|
| 在处理语义前验证 UTF-8 | 严重 | 使用经过验证的库,不要自己写 |
| 拒绝超长编码 | 严重 | 现代库默认拒绝,但要确认 |
| 检测混合脚本 | 高 | 用于用户名、域名等安全敏感场景 |
| 规范化后再比较 | 高 | 使用 NFC 或 NFKC |
| 过滤不可见字符 | 中 | 在用户输入中过滤零宽字符 |
| 限制组合标记数量 | 中 | 防止”zalgo text”式的拒绝服务 |
| 正则表达式使用 Unicode 模式 | 中 | Python 3 默认,JS 需要 /u |
| 禁止源代码中的 bidi 控制字符 | 高 | 配置 linter 检查 |
| 大小写比较使用 casefold | 中 | 不要用 toLowerCase |
| 字符串截断按字素簇 | 低 | 避免在多字节序列中间截断 |
十一、工程实践:常见陷阱与对策
陷阱汇总表
| 陷阱 | 错误做法 | 正确做法 |
|---|---|---|
| 字符串长度 | strlen()、.length |
区分字节数、码点数、字素簇数 |
| 字符串反转 | 按码点/字节反转 | 按字素簇反转 |
| 字符串截断 | 按字节截断到 N | 按字素簇截断,或至少在码点边界截断 |
| 字符串比较 | strcmp / == |
规范化后比较,或使用 collation |
| 大小写比较 | toLower + == |
casefold() 或 ICU
u_strCaseCompare |
| 正则表达式 | [a-zA-Z] 匹配”字母” |
\p{L} 或 Unicode-aware 库 |
| 子字符串搜索 | strstr 搜索非 ASCII |
NFC 规范化后搜索 |
| 排序 | memcmp / 默认 sort |
ICU collator 或等效实现 |
| JSON 解析 | 不验证 UTF-8 | 先验证后解析 |
| 文件名比较 | 直接字节比较 | NFC 规范化后比较(注意 macOS NFD) |
| URL 处理 | 假设 ASCII | percent-encoding 处理非 ASCII |
| 数据库 | VARCHAR + 默认 collation |
显式指定 utf8mb4 + collation |
MySQL 的 utf8 不是 UTF-8
一个经典的坑:MySQL 的 utf8 字符集只支持最多
3 字节的 UTF-8 序列,也就是只覆盖 BMP(Basic Multilingual
Plane,U+0000..U+FFFF)。emoji 和其他 supplementary plane
的字符会被截断或报错。
正确的做法是使用 utf8mb4:
-- 错误
CREATE TABLE users (
name VARCHAR(255) CHARACTER SET utf8
);
-- 正确
CREATE TABLE users (
name VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
);MySQL 8.0 起默认字符集终于变成了
utf8mb4。如果你还在用旧版本,请检查你的表定义。
字节计数 vs 码点计数 vs 字素簇计数
s = "👨👩👧👦"
# Python 3(内部用 UTF-32)
len(s) # 7 (码点数)
len(s.encode('utf-8')) # 25 (字节数)
# 字素簇计数需要第三方库
import grapheme
grapheme.length(s) # 1 (字素簇数)// Go
s := "👨👩👧👦"
len(s) // 25 (字节数)
utf8.RuneCountInString(s) // 7 (码点数)
// 字素簇计数需要 unicode/text 或第三方库// Rust
let s = "👨👩👧👦";
s.len() // 25 (字节数)
s.chars().count() // 7 (码点数)
// 字素簇:使用 unicode-segmentation crate
use unicode_segmentation::UnicodeSegmentation;
s.graphemes(true).count() // 1 (字素簇数)// JavaScript
const s = "👨👩👧👦";
s.length; // 11 (UTF-16 码元数!)
[...s].length; // 7 (码点数,ES6+)
// Intl.Segmenter (Chrome 87+, Node 16+)
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
[...segmenter.segment(s)].length; // 1 (字素簇数)注意 JavaScript 的 .length
返回的既不是字节数也不是码点数,而是 UTF-16 码元数。这是因为
JavaScript 内部使用 UTF-16 编码。每个 BMP 字符占 1
个码元,supplementary plane 字符占 2
个码元(代理对)。这可能是所有主流语言中最令人困惑的语义。
性能考虑
Unicode 正确性和性能之间存在张力。以下是一些实用的优化策略:
快速路径:ASCII 检测。 绝大多数现实世界的文本都包含大量 ASCII 字符。在处理循环中,先检查字节是否 < 0x80,如果是就走快速路径。很多库(包括 Go 的标准库和 V8 引擎)都使用这个策略。
/* ASCII 快速路径示例 */
size_t fast_utf8_count(const uint8_t *data, size_t len)
{
size_t count = 0;
size_t i = 0;
/* 快速路径:批量处理 ASCII */
while (i + 8 <= len) {
uint64_t chunk;
memcpy(&chunk, data + i, 8);
if ((chunk & 0x8080808080808080ULL) == 0) {
count += 8;
i += 8;
continue;
}
break;
}
/* 慢速路径:处理非 ASCII */
while (i < len) {
int seq_len = utf8_sequence_length(data[i]);
if (seq_len == 0) { i++; continue; }
count++;
i += (size_t)seq_len;
}
return count;
}预计算排序键。 如果你需要对大量字符串排序,预先为每个字符串计算排序键(sort key),然后用字节比较排序。这将 O(n log n) 次 UCA 比较转化为 O(n) 次排序键生成 + O(n log n) 次字节比较。
缓存规范化结果。
规范化是幂等的。如果你知道一个字符串已经是 NFC
形式(比如它来自一个总是输出 NFC
的数据源),就不要再规范化。ICU 提供了
quickCheck
函数来快速判断一个字符串是否已经是指定的规范化形式。
十二、结语:尊重文本的复杂性
写这篇文章的过程中,我反复感受到一件事:文本处理的复杂性被严重低估了。
Unicode 不仅仅是一张将数字映射到字形的表。它是人类语言多样性在计算机中的映射,而人类语言是几千年演化的结果,充满了不规则性、上下文依赖和文化特异性。UCA 的排序规则反映了不同文化对”顺序”的不同理解;bidi 算法反映了不同文字系统的书写方向;字素簇规则反映了人类对”一个字符”的直觉认知。
作为工程师,我们的责任是尊重这种复杂性,而不是假装它不存在。每次你写
s.length()、s[i]、s.toLower(),你都在做一个关于”字符”的假设——请确保你知道这个假设是什么。
一些最终建议:
- 使用 ICU。 不要自己实现 Unicode 算法(除了 UTF-8 编解码这样的简单操作)。ICU 是 Unicode Consortium 维护的参考实现,覆盖了规范化、排序、分割、大小写转换、bidi 等所有算法。
- 在系统边界验证和规范化。 接收外部输入时验证 UTF-8、转换为 NFC。这是防线,不是优化。
- 测试非 ASCII 输入。 你的测试用例中应该有中文、阿拉伯文、emoji、组合字符、零宽字符。
- 理解你的工具链。 知道你用的语言的字符串
.length返回的是什么。知道你的数据库的字符集配置。知道你的正则表达式引擎是否 Unicode-aware。 - 保持谦逊。 Unicode 的复杂性不是设计缺陷,而是现实世界复杂性的忠实映射。我们能做的是站在 ICU、CLDR、Unicode Consortium 这些巨人的肩膀上,正确地使用他们的工作成果。
参考资料
- Unicode Standard, Version 15.1: https://www.unicode.org/versions/Unicode15.1.0/
- UAX #9: Unicode Bidirectional Algorithm: https://unicode.org/reports/tr9/
- UAX #15: Unicode Normalization Forms: https://unicode.org/reports/tr15/
- UAX #29: Unicode Text Segmentation: https://unicode.org/reports/tr29/
- UTS #10: Unicode Collation Algorithm: https://unicode.org/reports/tr10/
- Rob Pike, “UTF-8 history”: https://www.cl.cam.ac.uk/~mgk25/ucs/utf-8-history.txt
- Bjoern Hoehrmann, “Flexible and Economical UTF-8 Decoder”: https://bjoern.hoehrmann.de/utf-8/decoder/dfa/
- Daniel Lemire, “Validating UTF-8 bytes using only 0.45 cycles per byte”: https://lemire.me/blog/2018/05/09/how-quickly-can-you-check-that-a-string-is-valid-unicode-utf-8/
- Nicolas Boucher, Ross Anderson, “Trojan Source: Invisible Vulnerabilities”: https://trojansource.codes/
- ICU (International Components for Unicode): https://icu.unicode.org/
- CLDR (Common Locale Data Repository): https://cldr.unicode.org/
上一篇: SIMD 字符串处理 下一篇: HyperLogLog 相关阅读: - SIMD 字符串处理 - DFA 最小化