最近调一个小工具处理一批UTF-16编码的文本流遇到辅助平面字符时总乱码。问题不在读取而在把Unicode码点转成UTF-16码元序列时我把代理对当成了两个独立字符。借着这个坑我把Unicode码转UTF-16编码的规则从头捋了一遍这也是本文想讲清楚的事。适合正在写文本解析、字符集转换、协议解码脚本的开发者看尤其是那些被乱码和半个字符折磨过的朋友。1. 从半个乱字符说起码点、码元与UTF-161.1 我遇到的那次排查当时我从字节流里按两个字节一组切码元遇到常规ASCII和中文都一路畅通。直到用户反馈只要文本里出现五线谱符号U1D11E输出就变成两个莫名其妙的字符。我用十六进制dump了那段字节流发现0xD834、0xDD1E这样的值被单独当成了两个Unicode码点去显示——一个变成了乱码另一个触发了解码器的替换符逻辑。问题根源很清晰UTF-16是变长编码一个Unicode码点可能占1个16位码元也可能占2个。我当时按一个码元等于一个字符处理BMP里的字符侥幸全对一碰到辅助平面就翻车。1.2 三条核心概念先对齐在展开公式之前先把三个概念摆正后面所有讨论都依赖它们码点Code PointUnicode为每个字符分配的唯一编号范围是U0000到U10FFFF一共1,114,112个位置。你可以把它理解成户籍编号。码元Code Unit某种编码方案一次输出的最小独立单元。UTF-16的码元是16位也就是两个字节UTF-8的码元是一个字节。编码方式Encoding把码点转换成码元序列的规则。同一个码点在UTF-8、UTF-16、UTF-32里得到的字节序列完全不同。有个容易混淆的点UTF-16不等于把每个Unicode码点直接塞进两个字节。它只对基本多文种平面BMPU0000到UFFFF内部的部分码点做直接映射超出范围的码点要走另一套代理对机制。1.3 为什么需要代理机制从固定两字节到变长上世纪80年代末UCS-2是二字节能表示所有字符的时代Unicode先辈们认为65536个位置足够容纳全人类文字。后来CJK扩展区、生僻汉字、音乐符号、emoji等陆续进来65536明显不够。扩展码点空间不难难的是兼容。UTF-16不能推翻BMP内字符用单个16位码元表示的既有现实又得用16位码元表达更多码点于是设计了一个优雅的折中在BMP里划出两个特殊区间。高位代理区UD800 ~ UDBFF共1024个值低位代理区UDC00 ~ UDFFF共1024个值这两个区间合起来2048个码位不分配给任何真实字符只用来做组合标记。一个辅助平面字符由一个高位代理码元 一个低位代理码元组合表示这正是代理对Surrogate Pair的由来。两个1024的组合能力是1024×10241,048,576个位置恰好对应从U10000到U10FFFF的辅助平面空间。这也是为什么Unicode上限是U10FFFF而不是U1FFFFF——它被UTF-16的代理机制锁定了。2. 正向编码代理对公式到底是怎么设计出来的2.1 分界线0x10000以下直接映射规则第一层非常简单码点在U0000 ~ UD7FF之间直接写成一个16位码元码元值等于码点值。码点在UE000 ~ UFFFF之间直接写成一个16位码元码元值等于码点值。码点在UD800 ~ UDFFF之间这是代理区不是合法Unicode码点编码器应当拒绝。也就是说BMP里有2048个码位被保留出来真实字符编码不会走到那里。你在文本文件里看到单个性状的0xD800基本可以断定这个文件要么被截断过要么被错误编码过。2.2 代理对公式减0x10000、拆10位、加偏移码点超过UFFFF时走代理对流程。设码点为U算法分三步第一步delta U - 0x10000 第二步high 0xD800 (delta 10) low 0xDC00 (delta 0x3FF) 第三步输出码元序列 [high, low]很多人第一次看这公式觉得像魔法其实每一步都有明确目的。为什么要减0x10000辅助平面从U10000开始减完以后delta范围是0x00000到0xFFFFF正好是20位二进制能表达的所有值。用一个20位偏移量来描述所有辅助平面字符是公式的出发点。为什么要拆成高10位和低10位两个代理区各有1024个位置高代理区只能容纳1024种状态低代理区也只能容纳1024种状态。要表达0到1048575的偏移量正好拆成10位10位。为什么要用0xD800和0xDC00做基底高位代理区从0xD800开始加0到1023正好覆盖0xD800到0xDBFF低位代理区从0xDC00开始加0到1023正好覆盖0xDC00到0xDFFF。两个区间首尾相接但不重叠解码时就能靠值域直接判断一个码元是高位还是低位。2.3 手算三个例子光看公式不够亲手算三个例子边界情况就全清楚了。例子1笑脸符号 U1F600delta 0x1F600 - 0x10000 0xF600高10位0xF600 10 0x3D也就是61 低10位0xF600 0x3FF 0x200也就是512high 0xD800 0x3D 0xD83D low 0xDC00 0x200 0xDE00所以U1F600的UTF-16码元序列是D83D DE00。这个字符的emoji形态你大概率见过在各种聊天工具里高频出现。例子2生僻汉字 U2070Edelta 0x2070E - 0x10000 0x1070E高10位0x1070E 10 0x41也就是65 低10位0x1070E 0x3FF 0x30E也就是782high 0xD800 0x41 0xD841 low 0xDC00 0x30E 0xDF0E所以U2070E的UTF-16码元序列是D841 DF0E。这类CJK扩展区汉字在中文古籍数字化场景里经常出现。例子3最大合法码点 U10FFFFdelta 0x10FFFF - 0x10000 0xFFFFF高10位0xFFFFF 10 0x3FF 低10位0xFFFFF 0x3FF 0x3FFhigh 0xD800 0x3FF 0xDBFF low 0xDC00 0x3FF 0xDFFF这个例子的意义在于验证边界最高位的高代理恰好落在0xDBFF最低位的低代理恰好落在0xDFFF与代理区上界严丝合缝。如果计算过程中出现某个值超出这个范围一定是算错了。码点delta高10位低10位高位代理低位代理U1F6000xF600615120xD83D0xDE00U2070E0x1070E657820xD8410xDF0EU10FFFF0xFFFFF102310230xDBFF0xDFFF提示手算时先用十六进制算出delta再考虑二进制拆位。一定要记住delta必须按20位来看直接把0xF600当16位拆会得到完全错误的代理对。3. 反向解码还原码点之前先过一遍合法性检查3.1 逆向公式正向公式可逆解码逻辑就是从两个代理码元反推码点U 0x10000 ((high - 0xD800) 10) (low - 0xDC00)先用高位代理减0xD800得到高10位再用低位代理减0xDC00得到低10位两者拼成20位偏移量最后加0x10000回到真实码点。举个例子验证0xD83D和0xDE00。high - 0xD800 61 low - 0xDC00 512 U 0x10000 (61 10) 512 0x10000 62464 512 0x1F600和正向结果完全闭合。3.2 常见的误解代理对不是高低字节有人会把代理对理解成一个字符的高字节和低字节这是错的。代理对是两个码元每个码元本身是16位。在字节层面一个码元还会继续拆成两个字节于是辅助平面字符在文件里实际占4个字节。高代理低代理指的是这两个码元在组合中的位置和值域角色不是大小端意义上的高低。3.3 解码时四项合法性检查写解码器时不能拿到两个码元就直接套公式得先做检查值域检查16位码元范围就是0x0000到0xFFFF这个不用特殊处理但后续判断要覆盖全。孤立高位检查遇到0xD800到0xDBFF的值必须确认它后面还有另一个码元否则就是孤立高位代理。配对检查高位代理后面的那个码元必须在0xDC00到0xDFFF区间否则就是坏组合。孤立低位检查单独的0xDC00到0xDFFF值不能作为普通字符解码必须报错或替换。真实世界里孤立代理最常见的原因是文本被从中间截断或者有人在按两个字节一切时把代理对从中间劈开了。3.4 Unicode标量值再确认严格来说Unicode标准里定义了一个概念叫Unicode标量值Unicode Scalar Value所有码点里排除掉代理区UD800到UDFFF之后剩余的部分。合法可编码的码点就是标量值。工具链在处理单个码点时遇到代理区值应该直接拒绝而不是尝试编码。这一点特别容易在循环遍历字符串时翻车如果一个字符串里混入孤立代理项很多语言的标准库在编码时要么抛异常要么悄悄地输出替换符UFFFD。理解了这个规则你就能预判这些行为而不是等乱码出现了再猜。4. 可直接抄走的编解码函数Python与JavaScript4.1 Python实现Python里其实有内置的chr和encode但为了彻底掌控规则我一般会在工具脚本里放一份显式实现便于调试和测试def cp_to_utf16(cp): if cp 0 or cp 0x10FFFF: raise ValueError(f码点越界: {cp:#x}) if 0xD800 cp 0xDFFF: raise ValueError(代理区不是合法Unicode码点) if cp 0xFFFF: return [cp] delta cp - 0x10000 high 0xD800 (delta 10) low 0xDC00 (delta 0x3FF) return [high, low] def utf16_to_cp(units): cps [] i 0 n len(units) while i n: unit units[i] if 0xD800 unit 0xDBFF: if i 1 n: raise ValueError(f孤立高位代理: {unit:#x}) low units[i 1] if not (0xDC00 low 0xDFFF): raise ValueError(f高位代理后不是低位代理: {low:#x}) cp 0x10000 ((unit - 0xD800) 10) (low - 0xDC00) cps.append(cp) i 2 elif 0xDC00 unit 0xDFFF: raise ValueError(f孤立低位代理: {unit:#x}) else: cps.append(unit) i 1 return cps调用示例cp 0x1F600 units cp_to_utf16(cp) print([hex(u) for u in units]) # [0xd83d, 0xde00] # 编码成LE字节序 le_bytes b.join(u.to_bytes(2, little) for u in units) print(le_bytes.hex()) # 3dd800de # 从LE字节流还原 data b\x3d\xd8\x00\xde units_read [int.from_bytes(data[i:i2], little) for i in range(0, len(data), 2)] cps utf16_to_cp(units_read) print(.join(chr(c) for c in cps)) # 笑脸字符这段代码里我故意把错误信息全抛出来而不是静默替换。生产环境里怎么处理取决于场景解析协议报文时建议直接报错渲染用户输入时可以换成替换符避免一个坏字符拖垮整个界面。4.2 JavaScript实现JavaScript的字符串本身就是UTF-16码元序列所以这套规则在JS里更贴近日常function unicodeToUtf16CodeUnits(cp) { if (cp 0 || cp 0x10FFFF) throw new Error(码点越界); if (cp 0xD800 cp 0xDFFF) throw new Error(代理区不是合法Unicode码点); if (cp 0xFFFF) return [cp]; const delta cp - 0x10000; return [ 0xD800 (delta 10), 0xDC00 (delta 0x3FF) ]; } function utf16CodeUnitsToUnicode(units) { const cps []; for (let i 0; i units.length;) { const u units[i]; if (u 0xD800 u 0xDBFF) { if (i 1 units.length) throw new Error(孤立高位代理); const lo units[i 1]; if (!(lo 0xDC00 lo 0xDFFF)) throw new Error(高位代理后不是低位代理); cps.push(0x10000 ((u - 0xD800) 10) (lo - 0xDC00)); i 2; } else if (u 0xDC00 u 0xDFFF) { throw new Error(孤立低位代理); } else { cps.push(u); i; } } return cps; }JS里更常用的其实是内置APIString.fromCharCode(0xD83D, 0xDE00)可以显式造出一个代理对字符。String.fromCodePoint(0x1F600)直接用码点生成字符。笑脸字符.codePointAt(0)能从字符串取出完整码点而charCodeAt(0)只能取出第一个码元。用codePointAt配合Array.from处理用户输入的按码点遍历可以绕开一大半代理对陷阱。4.3 内存和文件里的字节顺序编码规则完成以后还有一个落地的维度字节序。UTF-16的码元是16位写进文件时要么低字节在前LE要么高字节在前BE。以U1F600的代理对D83D DE00为例存储方式字节序列UTF-16 LE3D D8 00 DEUTF-16 BED8 3D DE 00UTF-16带LE BOMFF FE 3D D8 00 DEUTF-16带BE BOMFE FF D8 3D DE 00LE意味着每个码元的低8位先写出来所以0xD83D写为3D D8BE相反D8 3D。动手处理二进制时不要一看到D8 3D就觉得数据错了先确认自己按什么字节序在组装码元。5. 绕过实战中的四个隐形地雷5.1 JavaScript的length不等于字符数这是我踩过最深的一个坑。JS字符串的length统计的是码元数量不是用户感知的字符数。AB.length是2正常。笑脸符号.length是2因为它是代理对存成的两个码元。.length也是2生僻汉字同样中招。排查文本长度、做输入框字数限制时如果直接用length统计结果比用户看到的多一倍。处理办法是Array.from(s).length或手动遍历码点计数。这两个方法按码点切分序列代理对会被当成一个整体。5.2 截断和拼接时把代理对拆成两半字符串截断比length陷阱更隐蔽。用slice(0, 1)截取一个代理对字符只会拿到半个代理项单独打印出来通常是个替换符方块。拼接字符串时如果把一个代理对劈开拼到什么中间位置整个字符就废了。我的处理习惯是先把字符串转成码点数组做截断、补全、打码等操作最后再用String.fromCodePoint拼回去。这套流程绕开了代理对这个层面的所有坑。如果用的是Python字符串默认按码点迭代反而没有这个烦恼但要注意把字符串编码成utf-16-le后的字节长度别拿字节长度当字符数。5.3 混淆LE和BE导致的整篇乱码UTF-16没有BOM的文件光靠数据本身极难判断字节序。我用过一个很笨但有效的办法读取前两个字节如果是FF FE就是LEFE FF就是BE。有BOM情况简单。没有BOM时先按LE解析看码元落点如果大量落在代理区大概率字节序反了换BE再试。还有一个判断小技巧如果前两个字节解析出的码元是0xFEFF或0xFFFE说明这可能是另一个字节序的BOM文件要么被转换过要么读取时设错了序。5.4 正则和协议解析把代理项当独立字符默认情况下正则里的.匹配的是单个码元不是完整码点。用/^.$/去测试一个代理对字符结果会是false测试半个代理项反而可能为true。做协议解析时更要注意如果你在按分隔符切割字符串切割结果里可能出现孤立代理项。典型场景是CSV字段被截断或者JSON字符串被从中间切断再拼接。识别这种数据的方法很简单遍历码元发现0xD800到0xDBFF后面没跟低位或者孤零零出现0xDC00到0xDFFF就是被切坏了。我在实际工作中有一套自检习惯每次写完编码相关逻辑先跑一遍黄金用例包括U0000单码元下界、UFFFF单码元上界、U10000双码元下界、U10FFFF双码元上界外加一个CJK扩展区生僻字和一个音乐符号。四个边界用例全过了逻辑基本就稳了。这个习惯帮我省掉了大量线上乱码排查的时间算是处理Unicode编码问题性价比最高的一个操作。
阅读完成 · 觉得有帮助?