ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ISO/IEC 10646 字符集与编码:UTF-8/16、规范化与 CJK

ISO/IEC 10646 字符集与编码:UTF-8/16、规范化与 CJK 简介该资源为ISO/IEC 10646:2020《信息技术——通用编码字符集UCS》第六版英文原版标准文档面向从事字符编码、国际化软件、操作系统与文本处理的开发者及标准化研究人员用于解决多语言文本表示、跨设备信息交换与编码兼容性问题。压缩包内共1个PDF文件体积约118.06MB完整收录2020年12月发布的2812页标准正文涵盖范围、规范性引用文件、术语与定义、符合性要求、电子数据附件、UCS总体结构、基本结构与命名法、字符编码与码点类型、UID标识及子集划分等章节。目前已有142人学习下载。读者可据此掌握UCS的空间划分与平面结构、字符命名规则、码点与八位组序列标识方法并与Unicode标准对照使用为字体开发、编码转换、协议实现与合规测试提供权威依据。1. 一个 UFFFD 引出的问题ISO/IEC 10646 的字符集与编码边界有位同事把一段日文 CSV 导进数据库回来发现满屏的替换字符。查了半天不是驱动的问题也不是字段长度的问题是上游把 Shift_JIS 的字节流当 UTF-8 解码了。这类事故的根子在于把「字符集」和「编码」当成了一回事。ISO/IEC 10646:2020 这份 2812 页的第六版国际标准定义的恰恰是前半截一个与语言、平台、程序都无关的通用编码字符集UCS给每一个抽象字符分配一个稳定的整数码位至于这个整数怎么落成字节是后面 UTF-8、UTF-16、UTF-32 那些编码形式的事。做国际化中间件、数据库排序规则、字库工具链和协议解析的人最该翻它——遇到「同一个字两种写法」「同一个码位两种字节序」时查标准的收益远高于搜帖子。2. 码位、平面与命名规则把 UCS 地址空间画出坐标系2.1 十七个平面UCS 的地址空间怎么切整个编码空间从 U0000 一直排到 U10FFFF一共 1,114,112 个码位每个码位对应一个整数。标准第 6、7 章把它切成 17 个平面每个平面正好 65536 个码位也就是 16 位能铺满的宽度。换算关系很直接平面号等于码位右移 16 位平面内再用「行」高字节和「列」低字节定位一行 256 个码位。标准第 14 章讲的代码表结构就是按这个坐标排的每个页面铺开一行16×16 的网格所以拿到一个码位你能一眼说出它落在哪一页哪一格。平面名称码位范围典型内容0BMP 基本多文种平面U0000–UFFFF拉丁、希腊、西里尔、常用汉字、假名1SMP 多文种补充平面U10000–U1FFFF音乐符号、数学字母、表情符号、历史文字2SIP 表意文字补充平面U20000–U2FFFFCJK 扩展 B 及之后的汉字3TIP 表意文字第三平面U30000–U3FFFFCJK 扩展 G、H 等新增汉字4–13未分配U40000–UDFFFF预留暂不分配14SSP 特殊用途补充平面UE0000–UEFFFF标签字符、变体选择符补充集15–16专用区 A、BUF0000–U10FFFF私用标准不定义含义平面 0 之所以特殊是因为它和历史上那些单字节、双字节编码体系的兼容关系最复杂U0000 到 U00FF 这一段在多数西欧编码里都能直接对应U4E00 往后又是汉字主力区。做协议解析时只要码位落在 BMP 之外就要立刻意识到「一个字符可能占 4 个字节」很多长度校验就是这么炸的。2.2 码位的类型与字符命名法标准第 7.3 节把码位分成几类图形字符、格式字符、控制功能、私用、代理surrogate、非字符noncharacter、保留。代理码位 UD800–UDFFF 只服务于 UTF-16 的编码形式永远不对应任何抽象字符非字符 UFDD0–UFDEF 以及每个平面末尾的 xxFFFE、xxFFFF 也永不分配。这两个区间在校验代码里最常被漏掉结果是某些「看起来正常」的输入悄悄穿过了过滤。命名规则在 7.4 节完整字符名由大写字母、数字、连字符和空格组成比如 LATIN CAPITAL LETTER A。每个名字在整个字符集里唯一27.5 节而且一旦分配就永久冻结27.4 节后续只能通过注解补充说明不能改名。CJK 表意文字是个例外它们没有描述性名字统一叫 CJK UNIFIED IDEOGRAPH- 加码位。还没分配名字的码位用 UID 引用——就是去掉 U 前缀的十六进制串长度 4 到 6 位标准 7.5 节专门规定了格式。第 9 章还定义了两个子集limited subset 和 selected subset前者大致是 Latin-1 那一档的字符范围后者配合 ISO/IEC 2022 的转义序列声明使用。对接老式终端或报文协议时会碰到通用文本处理里基本可以忽略但读到标准第 9 章别以为它没用。2.3 用 Python 打一份字符档案把坐标规则写成代码只需要几行但它是后面所有排查的基础工具。import unicodedata def ucs_profile(ch: str) - dict: 输出一个字符在 UCS 地址空间中的坐标和属性 cp ord(ch) plane cp 16 # 平面号每个平面 65536 个码位 row (cp 8) 0xFF # 行号平面内的高字节 col cp 0xFF # 列号平面内的低字节 return { codepoint: fU{cp:04X}, # 标准正文一律用 U 加至少四位十六进制 plane: plane, cell: f{row:02X}-{col:02X}, # 对应代码表里的行-列位置 name: unicodedata.name(ch, 未命名), category: unicodedata.category(ch), # Cs 是代理Co 是私用Cn 是未分配 combining: unicodedata.combining(ch), # 规范组合类0 表示不参与重排 } for ch in [A, 汉, \u200d, \ufeff, \U0002a6d6]: print(ucs_profile(ch))逻辑上没什么花活ord拿到码位右移和掩码切出平面、行、列三个坐标。要注意的是数据来源——unicodedata底层就是 UnicodeData.txt和 10646 的字符名列表同源所以name()返回的字符串可以直接和标准里的字符名对照。category返回两个字母的类别看到Cs说明这个码位是代理绝不能单独出现在合法文本里看到Co说明落在私用区含义由应用自己约定标准不管。combining返回规范组合类0 既可能是普通基字符也可能是变体选择符这个坑留到第 4 章再展开。一个常见的困惑是同一段代码在不同机器上某个新分配的码位有时返回正式名字、有时返回未命名。原因不在标准而在 Python 打包的 Unicode 数据版本不同。unicodedata.unidata_version能看出当前版本号跨环境做一致性校验之前先把这个值对齐。3. 编码形式与编码方案UTF-8/UTF-16/UTF-32 的字节序实战3.1 编码形式管码元编码方案管字节标准第 10 章定义三种编码形式UTF-8、UTF-16、UTF-32。它们负责把码位映射成码元序列——UTF-8 的码元是 8 位UTF-16 是 16 位UTF-32 是 32 位。第 11 章定义对应的编码方案负责把码元再串成字节序列于是名字里多出 UTF-16BE、UTF-16LE、UTF-16、UTF-32BE、UTF-32LE、UTF-32 这些变体。差异的关键在字节序。UTF-8 的码元只有一个字节不存在先出谁的问题所以它的编码形式和编码方案是同一个东西。UTF-16 和 UTF-32 的码元是多字节必须回答「先出高位还是先出低位」于是 BE 和 LE 分成两个方案名字里不带后缀的 UTF-16、UTF-32则是「字节序待定、由 BOM 决定」的那一档。编码形式码元宽度对应编码方案字节序处理UTF-88 位UTF-8不涉及唯一方案UTF-1616 位UTF-16BE / UTF-16LE / UTF-16BE、LE 固定UTF-16 靠 BOM 判定UTF-3232 位UTF-32BE / UTF-32LE / UTF-32同上UTF-8 的字节长度按码位区间固定U0000–U007F 占 1 字节U0080–U07FF 占 2 字节U0800–UFFFF 占 3 字节U10000–U10FFFF 占 4 字节。代理区 UD800–UDFFF 在 UTF-8 里是非法序列严格校验器必须显式拒绝否则会出现同一个字符有多种字节写法的情况。UTF-16 里超出 BMP 的码位用一对代理码元表示算法要背下来先算 cp 减 0x10000高 10 位加 0xD800 得到高位代理低 10 位加 0xDC00 得到低位代理反解就是(high - 0xD800) 10 | (low - 0xDC00)再加 0x10000。写流式解码器时必须自己实现一遍因为字节边界完全可能把一个代理对切成两半读到半个代理时要缓存而不是报错。3.2 BOM 是签名不是乱码UFEFF 出现在流开头时是 BOM也叫签名。第 11.5 节规定UTF-16 方案的流如果以 FEFF 开头说明本流是大端以 FFFE 开头则是小端。如果开头没有 BOM按标准默认大端解——这条默认值制造过无数跨平台事故因为主流处理器架构本机是小端很多实现会想当然地按小端处理。另一个坑更隐蔽UTF-32LE 的 BOM 是FF FE 00 00前两个字节和 UTF-16LE 的FF FE完全一样。探测编码时必须先比 4 字节再比 2 字节顺序反了大文件会整篇解错而且错得很安静。UTF-8 的EF BB BF严格说是「签名」而不是 BOM因为 UTF-8 没有字节序问题。它出现在流中间时是一个真正的 UFEFF 字符也就是零宽不换行空格放进正文里会造成肉眼看不见的比较失败。3.3 字节流诊断脚本把上面两条规则落成代码就是一个能直接塞进排查工具包的探测器。import codecs def sniff_bom(raw: bytes) - str: 按 BOM 推断编码方案先长后短避免 UTF-32LE 被误判成 UTF-16LE if raw.startswith(codecs.BOM_UTF32_LE) or raw.startswith(codecs.BOM_UTF32_BE): return utf-32 # Python 的 utf-32 编解码器会自己读 BOM 定字节序 if raw.startswith(codecs.BOM_UTF8): return utf-8-sig # sig 后缀表示解码时吞掉前导 BOM if raw.startswith(codecs.BOM_UTF16_LE) or raw.startswith(codecs.BOM_UTF16_BE): return utf-16 # 同理交给编解码器判断 try: raw.decode(utf-8) return utf-8 except UnicodeDecodeError: return utf-16-be # 无 BOM 的 UTF-16 按标准默认大端 def diagnose(raw: bytes, enc: str) - None: 失败时把出错偏移和上下文一起打出来比抛栈有用得多 try: text raw.decode(enc) print(f{enc} 解码成功{len(text)} 个字符 / {len(raw)} 字节) except UnicodeDecodeError as e: print(f{enc} 在第 {e.start} 字节失败{e.reason}) lo, hi max(0, e.start - 8), e.start 8 print(上下文, raw[lo:hi].hex( ))参数上要注意两处utf-8-sig和utf-8的区别只在开头的 BOM 是否被吞掉选错会在文本首部多出一个不可见的 UFEFFe.start是相对整个字节串的偏移分块读取时得累加块偏移才能还原成文件位置。修复阶段如果只想先看内容errorsreplace会把坏字节替换成 UFFFDerrorssurrogateescape则把坏字节保留成代理码位修完再写回去还能原样还原——后者在清洗历史脏数据时非常好用。3.4 特性声明与转义序列第 13 章讲的是「数据流自己说明用了哪套编码方案」。在 ISO/IEC 2022 体系里这靠转义序列实现一段字节流开头先发一个转义串声明进入 UTF-8 还是 UTF-16接收端据此切换解码器。老式终端、部分打印机协议和某些报文格式至今还在用。如果对接方给你一段1B 25 47开头的字节那不是乱码是声明 UTF-8 的转义序列把这三个字节按文本解当然全错。第 13 章还规定了如何声明子集和所用的控制功能集做协议兼容层时值得翻一遍。4. 组合字符、规范化与变体选择符字符串比较的三个坑4.1 组合类与规范排序组合字符是那些要叠在前一个基字符上显示的字符比如重音、变音符号。标准第 21 章规定了两件事每个组合字符有一个规范组合类canonical combining class简称 ccc取值 0 到 254一串连续的组合字符必须按 ccc 从小到大重排ccc 为 0 的不参与排序因为它本身就标志着新基字符的开始。字符码位ccc说明COMBINING OGONEKU0328202下加钩排在重音之前COMBINING ACUTE ACCENTU0301230上加重音COMBINING HEBREW POINT SHEVAU05B010希伯来元音点DEVANAGARI SIGN VIRAMAU094D9梵语连字符ZERO WIDTH JOINERU200D0格式字符不是组合标记拿「ą́」举例它的规范分解是a U0328 U0301。如果输入顺序反了写成a U0301 U0328规范化时会被自动重排回正确顺序但在这之前做裸字节比较两个串不相等。所以凡是涉及组合字符的比较第一件事就是先归一化别指望字节层面能对上。4.2 四种规范化形式的选型第 22 章定义四种规范化形式差别在于是否做兼容分解。形式全称做什么典型场景NFCCanonical Composition规范分解后最大化合成存储、显示、一般字符串比较NFDCanonical Decomposition完全规范分解部分文件系统、排序算法中间态NFKCCompatibility Composition兼容分解后再合成搜索索引、输入宽容匹配NFKDCompatibility Decomposition完全兼容分解配合自定义比较函数使用兼容分解会改语义这是最容易被忽视的一点。U2460①在 NFKC 下变成1UFB01fi 连字变成f加i全角字母变成半角。把这些形式拿去做用户标识符或文件名比较会制造出真实的碰撞——两个本来不同的账号被判定成同一个人。稳妥的分工是标识符、主键、URL slug 一律用 NFC只有检索索引这类明确追求召回的场景才上 NFKC而且要在文档里写清楚。import unicodedata nfc lambda s: unicodedata.normalize(NFC, s) # 只接受这四种形式名 nfkc lambda s: unicodedata.normalize(NFKC, s) s1 caf\u00e9 # é 是一个预组合码位 U00E9 s2 cafe\u0301 # e 加上组合重音 U0301 print(s1 s2) # False码位序列不同 print(nfc(s1) nfc(s2)) # True归一化之后才是同一个字符串 print(nfkc(\u2460)) # 输出 1语义被兼容映射改掉了 print(nfkc(\ufb01)) # 输出 fi连字被拆开unicodedata.normalize的第二个参数只认NFC、NFD、NFKC、NFKD四个字符串写错会直接抛异常这一点比很多语言宽容的接口要安全。另外顺序有讲究要先规范化再做大小写折叠str.casefold()反过来的话某些字符折叠后才产生的组合形式不会被重排比较结果不一致。4.3 变体选择符ccc 为 0 的陷阱变体选择符 UFE00–UFE0FVS1 到 VS16和 UE0100–UE01EFVS17 到 VS256用来给同一个码位指定不同字形不改变语义只影响显示。汉字里用得尤其多叫表意文字变体序列IVS同一个汉字码位后面挂一个补充平面的变体选择符落到字库里就是不同的字形。麻烦在于变体选择符的规范组合类也是 0。如果按「ccc 为 0 就切新字符」的思路分串变体选择符会被当成分隔点基字符和它的变体被硬生生拆开后面按字形匹配就全错。正确写法是把变体选择符和前面的基字符绑在一起判断。import unicodedata VS_SET set(range(0xFE00, 0xFE10)) | set(range(0xE0100, 0xE01F0)) def iter_clusters(text: str): 把基字符 组合标记 变体选择符收成一个视觉单元 buf for ch in text: cp ord(ch) # 变体选择符的 combining() 也是 0只看 combining 会切错 if buf and unicodedata.combining(ch) 0 and cp not in VS_SET: yield buf buf buf ch if buf: yield buf print(list(iter_clusters(骨\U000E0100\u0301病)))实际工程里还要把零宽连接符 U200D 和零宽非连接符 U200C 一并算进「粘着」集合否则表情符号序列会被拆散。严格的分字素簇规则在 UAX #29 里但大多数业务场景把基字符、组合标记、变体选择符和两个零宽字符归成一组就够了。另外 U034FCombining Grapheme Joiner的 ccc 同样是 0它的用途恰恰是挡住规范重排在希伯来文和阿拉伯文处理里会碰到——看到它插在组合标记中间不要当成脏数据删掉。5. CJK 源参照与字符名冻结交付前的版本一致性自查5.1 汉字没有描述性字符名只能靠源参照CJK 统一表意文字的字符名一律是CJK UNIFIED IDEOGRAPH-加码位十六进制U4E00 就叫 CJK UNIFIED IDEOGRAPH-4E00。名字里没有读音、没有部首、没有笔画数这意味着你没法用名字做检索、排序或去重一切以码位为准。第 24 章定义了一套源参照source references体系把康熙字典、汉语大字典以及各国工业标准这些来源的字形与码位的对应关系记录在案第 25、26 章则分别覆盖西夏文和女书。做字形库交付、异体字归并、古籍数字化的时候判断「这两个码位是不是同一个字的异写」靠的是源参照文件里的来源标注而不是字符名——字符名里根本没有这个信息。5.2 名字不可变性与版本比对脚本27.4 节规定字符名一经分配永不更改。这条规则在产品层面非常有用如果你从两个数据源里读到同一个码位对应了不同的名字问题一定出在解析逻辑或数据源版本上不可能是标准改了。可以写个比对脚本挂在 CI 上每次升级运行时或字库后跑一遍。def check_name_immutability(pairs): pairs: [(版本名, {码位整数: 字符名}), ...]按版本先后排列 problems [] for (v1, d1), (v2, d2) in zip(pairs, pairs[1:]): for cp, old in d1.items(): new d2.get(cp) if new is None: problems.append(fU{cp:04X} 在 {v2} 中消失) elif new ! old: problems.append(fU{cp:04X}: {v1} 命名 {old}{v2} 变成 {new}) return problemspairs按版本先后排列每个元素是版本名加一个码位到名字的字典返回空列表就说明名字完全冻结。有一处例外要排除UID 不能参与这个比对。未分配码位的 UID 只是码位的十六进制写法等它被正式分配时会让位给正式字符名这不算改名。名字冻结有个经典副产物UFEFF 在早期版本里叫 ZERO WIDTH NO-BREAK SPACE之后它的实际角色变成了 BOM 和零宽不换行空格但名字一直保留到今天。名字和语义脱节的情况在标准里并不罕见读字符名时要留意分配年代。5.3 校验前先把 Unicode 数据版本钉死不同 Python 小版本打包的 Unicode 数据版本不一样同一个码位在一台机器上返回正式名字、在另一台返回未命名直接导致校验结果分叉。跨环境比对之前先把版本号读出来对齐。import unicodedata print(unicodedata.unidata_version) # 例如 15.0.0决定 name()/category() 的判定边界 # 生产校验要么两端版本一致要么把用到的码位表冻结成离线数据把这段和上一节的名称比对脚本拼在一起接进构建流水线每次升级运行时或 ICU 之后自动跑一遍比等用户截图来报要便宜得多。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进