1. 五个"码"的职责划分,以及它们串联起来的完整链路
做嵌入式显示或者处理老系统数据的人,几乎都会在某个时刻被"汉字编码"这四个字卡住。我第一次真正把这事想明白,是在调一块 128×64 的单色液晶屏。字库文件里明明有字,取出来的点阵却总是错位一格,查了半天驱动、时序、页地址,最后发现问题出在一个非常基础的地方:我手上那个用来算偏移量的数字,压根不是我以为的那一层编码。
区位码、国标码、机内码、外码、字形码,这五个名字经常被并列着讲,好像它们是同一个东西的五种叫法。实际上它们根本不是并列关系,而是分布在输入、交换、存储、输出四个不同环节上。把它们当成五个同义词去理解,后面一定会乱。先把它们的身份对齐,后面所有的换算都好办了。
1.1 一张分工表说清五个概念
| 名称 | 所处环节 | 归属方 | 典型形态 | 取值范围 |
|---|---|---|---|---|
| 外码 | 输入 | 输入法设计者 | 拼音串、五笔字根、四位数字 | 无固定标准 |
| 区位码 | 字符集编排 | 国家标准 | (区, 位) 二元组 | 1~94 各自取值 |
| 国标码 | 交换 | 国家标准 | 双字节 | 21H~7EH |
| 机内码 | 存储与处理 | 计算机系统 | 双字节 | A1H~FEH |
| 字形码 | 输出 | 字库与显示设备 | 点阵位图、曲线轮廓 | 与字号相关 |
从这张表能看出一件很关键的事:前四个码描述的是"这个汉字是谁",第五个码描述的是"这个汉字长什么样"。前四个是身份问题,字形码是外观问题。很多初学者把"编码"和"字模"混在一起谈,实际上它们之间隔着一个查表动作——先通过内码确定身份,再通过字库索引拿到外观。
另外还有一层容易忽略的区别:区位码和国标码是标准规定死的,任何一个符合标准的实现算出来的结果都一样;而外码是输入法厂商自己定的,同一个汉字用拼音输入法和用五笔输入法,外码完全不同。这也是为什么这五个概念里,外码是唯一不能用来做数据交换的一个。
1.2 从键盘到屏幕:一次按键的完整旅程
我习惯用一条链路来描述这几层的协作关系,理解了这条链路,八成的乱码问题都能定位到具体环节:
键盘输入 → 外码字符串 → 输入法码表查表 → 机内码 → 存储/传输 → 国标码(交换) → 机内码(接收端)→ 字库偏移计算 → 字形码 → 屏幕或打印机
拆开来读:
第一步是输入。你敲下zhong,输入法拿到的是一串 ASCII 字符7A 68 6F 6E 67,这就是外码的原始形态。输入法内部的码表把这个串映射到候选字列表,你选中的那个字,输入法会交出它的机内码。
第二步是存储与传输。程序和文件里实际落地的字节,就是机内码。所谓"UTF-8 文件"和"GBK 文件"的区别,主要就体现在这一步。机内码在需要跨系统交换时,可以转成国标码(减去 8080H)再发出去,这也是"交换码"这个名字的由来。
第三步是输出。拿到机内码之后,程序拿它去字库里定位字模数据。点阵字库的做法是"内码减去 A0A0H 得到区位,再用区位算文件偏移";矢量字库的做法稍有不同,中间会插一层映射表,后面细说。
这三步里,任何一步用错了编码层,结果都是乱码或者花屏。最常见的错法就是——把外码当内码存,或者把国标码当机内码用。这两种错法我都在实际项目里见过,而且排查起来特别费时间,因为字节看起来"挺正常的",不像明显的乱码。
2. 区位码:94×94 的网格是怎么把汉字装进去的
区位码的设计思路非常朴素,就是把汉字安排进一张 94 行 94 列的二维表格里。横向叫"区",纵向叫"位",合起来就是区位码。这个 94 这个数字不是随便选的,它等于 7 位二进制能表示的字符数(128)减去 32 个控制字符位置,剩下的可打印空间是 95 个,去掉一个空格或者说是去掉 0x7F,就变成 94 个。
这个数字在东亚的字符编码体系里反复出现,日本、韩国、中国的标准都是 94×94 的结构,后面会展开讲为什么。
2.1 区号与位号:两个 1 到 94 的数字
区位码的完整写法是一个二元组(区号, 位号),两者都从 1 开始,最大到 94。日常书写时习惯合并成一个四位数字,比如1601表示 16 区第 1 位,5448表示 54 区第 48 位。
这里埋着第一个容易踩的坑:四位数字的合并方式不是补零,而是区号乘 100 加位号。所以你解析1601的正确做法是:
n = 1601 qu, wei = divmod(n, 100) # qu=16, wei=1如果位号本身是两位数,比如 48,那么5448拆出来就是 54 和 48,正好;但如果位号小于 10,比如 5,那合并后是5405,拆出来是 54 和 5,也对。真正会出问题的是手动敲入的时候——很多人会想当然地写成545,那divmod(545, 100)得到的就是 5 和 45,完全错了。
所以我的习惯是:内部一律用二元组,只在给人看的场合才合并成四位数字,解析时严格按整除和取余来拆。这个习惯能省掉很多难查的错位问题。
2.2 分区清单:符号、一级汉字、二级汉字、空白区
94 个区不是全部拿来放汉字的,标准对每个区段做了明确分工:
- 1 区到 9 区:图形符号,包括中文标点、全角数字和字母、日文假名、希腊字母、西里尔字母、汉语拼音字母、制表符等,合计 682 个符号
- 10 区到 15 区:留空未定义
- 16 区到 55 区:一级汉字,共 3755 个
- 56 区到 87 区:二级汉字,共 3008 个
- 88 区到 94 区:留空未定义
汉字总数 3755 + 3008 = 6763,加上 682 个符号,整个字符集是 7445 个图形字符。
注意 10~15 区和 88~94 区这两段空白。这不是设计失误,而是预留的扩展空间。后来的 GBK 和 GB18030 在扩展时,正是从这些空白区以及更多空间下手的。理解这一点很重要——如果你的老系统里出现了 10 区的数据,那多半不是标准的 GB2312 内容,而是某个扩展实现的产物。
2.3 排序规则背后的检索思路
一级汉字按汉语拼音排序,二级汉字按部首和笔画排序。这个设计不是随意的,它对应着两种不同的查字需求。
一级汉字是 3755 个最常用的字,日常使用中遇到不认识的字,第一反应是"这个字怎么读",所以按读音排序,方便用拼音检索。二级汉字是 3008 个相对生僻的字,遇到的时候往往不知道读音,只能靠字形去查,所以按部首笔画排。
实测下来,这个排序规则还有个副作用:一级汉字的区位码和拼音之间存在单调关系。比如拼音 a 开头的字集中在 16 区,拼音 z 开头的字靠近 55 区。这在做数据校验的时候很有用——如果你拿到一个一级汉字,它的区位码落在了 55 区的靠后位置,那它大概率是拼音 y 或 z 开头的字。反过来,如果有人在你的系统里输入了一个区位码落在 20 区却说它是"张"字,那基本可以判定数据有问题。
2.4 四位数字写法的解析陷阱
再强调一下四位数字的坑,因为这个坑我见过至少三次。
最常见的错误是把区位码的十进制数字直接当成十六进制用。比如有人拿到区位码5448,然后直接转成字节0x54 0x48,再往字库里查,结果查出来完全不相干的字。正确做法是先把54和48分别转换成字节,再按规则加偏移:
qu, wei = 54, 48 guobiao = bytes([qu + 0x20, wei + 0x20]) # b'\x56\x50',即 5650H neima = bytes([qu + 0xA0, wei + 0xA0]) # b'\xd6\xd0',即 D6D0H5650H是"中"的国标码,D6D0H是它的机内码。这两个结果一定要记牢,后面所有验证都靠它们。
还有一类错误来自输入法层面的混淆。有些系统的输入法里有一项叫"区位输入",你敲四位数字,它直接出字。但还有一项叫"电报码输入",敲的也是四位数字。这两套数字体系完全无关——电报码是另一套独立编码,0001到9999覆盖常用汉字,跟区位码的 94×94 结构没有任何对应关系。我见过有人拿电报码的数字去算区位偏移,结果自然是一塌糊涂。判断方法很简单:区位码的两个数字都不会超过 94,而电报码可以出现 9 开头的区号。
3. 国标码加 2020H、机内码加 8080H:这两次偏移分别躲开了什么
区位码本身有一个致命问题:区号和位号都在 1 到 94 之间,也就是说这两个数字的取值范围是0x01到0x5E。这个区间里塞满了 ASCII 的控制字符——0x0A是换行,0x0D是回车,0x1B是转义,0x7F是删除。
如果直接拿区位码当字节存,那么区号 10 就会产生一个换行符,区号 13 就会产生一个回车符。数据一旦经过终端或者某些通信设备,就会被当成控制指令执行,整个数据流就废了。所以必须做两次偏移。
3.1 加 20H:绕开 0x00 到 0x1F 的控制字符
第一次偏移是给区号和位号各加 32(也就是0x20),这样得到的结果叫国标码:
def quwei_to_guobiao(qu, wei): assert 1 <= qu <= 94 and 1 <= wei <= 94 return bytes([qu + 0x20, wei + 0x20])加上 32 之后,最小值是1 + 32 = 33 = 0x21,最大值是94 + 32 = 126 = 0x7E。这两个数值都落在 ASCII 的可打印字符区间里,不会跟控制字符撞车。用!到~这一段可打印字符来承载汉字,是那个年代非常聪明的做法——数据在终端上显示虽然还是乱码,但至少不会触发控制动作。
国标码也常被叫做交换码,因为它主要用于系统之间的数据交换。不过在实际工程里你会发现,真正拿去传输的往往已经是机内码了,国标码更多是作为一个中间形态存在于换算链条里。
3.2 最高位置 1:让 ASCII 和汉字在一串字节里共存
第二次偏移是给国标码的每个字节最高位置 1,也就是加上0x80,得到的就是机内码:
def quwei_to_neima(qu, wei): return bytes([qu + 0xA0, wei + 0xA0])偏移之后,机内码的每个字节都落在0xA1到0xFE之间,最高位必然是 1。而标准 ASCII 字符的最高位是 0,范围是0x00到0x7F。两者在最高位上天然区分开了。
这个设计的精妙之处在于:解析程序可以逐字节扫描,看到最高位是 0 就按单字节 ASCII 处理,看到最高位是 1 就知道接下来要读双字节。这是"变长编码"在早期的一个朴素实现,比 UTF-8 的前缀码设计要简单得多,但在中文环境里够用了。
代价也很明显。机内码的范围是A1A1H到FEFEH,实际可用的码位比区位码的 94×94 少——因为次字节不能是0x7F(在加 0x80 之后对应的就是0xFF),所以第二字节的上界实际上是0xFE。这个限制后来在 GBK 里被打破了,也带来了新的麻烦,第 6 节会细说。
3.3 三个汉字的完整手算与反推公式
理论讲完,来点能验证的。下面这几个汉字的换算我实操跑过很多遍,可以直接拿来对答案:
| 汉字 | 区位码 | 国标码 | 机内码 | Python 验证 |
|---|---|---|---|---|
| 啊 | 1601 | 3021H | B0A1H | b'\xb0\xa1' |
| 国 | 2590 | 397AH | B9FAH | b'\xb9\xfa' |
| 中 | 5448 | 5650H | D6D0H | b'\xd6\xd0' |
| 字 | 5554 | 5756H | D7D6H | b'\xd7\xd6' |
拿"中"来手算一遍:区号 54 是0x36,位号 48 是0x30。加 32 后,0x36 + 0x20 = 0x56,0x30 + 0x20 = 0x50,得到国标码5650H。再各加0x80,0x56 + 0x80 = 0xD6,0x50 + 0x80 = 0xD0,得到机内码D6D0H。用 Python 验证一下:
>>> '中'.encode('gb2312') b'\xd6\xd0'对上了。
反推同样简单,减法就完事:
def neima_to_guobiao(b): return bytes([b[0] - 0x80, b[1] - 0x80]) def neima_to_quwei(b): return b[0] - 0xA0, b[1] - 0xA0 >>> neima_to_quwei('国'.encode('gb2312')) (25, 90) >>> neima_to_guobiao('字'.encode('gb2312')).hex() '5756'这两组数值跟表格里的完全一致。有了这几个函数,你就能在任何一个 GB2312 编码的数据上做双向验证,排查问题时特别管用。
3.4 东亚编码体系的同款设计
有个细节值得一提:加0x20再加0x80这套操作,不是 GB2312 独有的。
日本的 JIS X 0208 同样采用 94×94 的区位结构,它的"区点码"转换成 JIS 码时也是加0x20,转成 Shift-JIS 或者 EUC-JP 时也有类似的偏移处理。韩国的 KS X 1001 用的是同一套框架。这不是巧合,而是因为它们都参考了同一套国际编码框架的设计思路——用 94×94 的字符集结构来实现双字节字符的编排,再用字节层面的偏移来区分单字节和双字节。
所以如果你理解了 GB2312 的这套偏移逻辑,再看日文和韩文的老编码,会发现结构上几乎是照搬的,只是具体哪个区放什么字符不同而已。这个认知在做多语言老系统维护的时候非常有用,能省掉大量重新学习的时间。
4. 外码:唯一不由标准规定的一层
前面四个码里,区位码、国标码、机内码、字形码都有明确的标准或者行业惯例可循,只有外码是完全开放的。外码就是用户在键盘上输入汉字时所使用的那套编码,它由输入法设计者决定,跟国家标准没有任何关系。
这一点听起来是常识,但在实际排查问题时经常被忽略。我见过有人试图把一个汉字在拼音输入法里的编码串存进数据库,然后指着数据问为什么显示不出来。这显然是混淆了层次——存进去的应该永远是机内码,外码只是一个临时的中间状态。
4.1 音码、形码、序码的取舍
外码大致可以分成三类,每类的设计取舍完全不同:
音码以读音为基础,典型的是全拼和双拼。全拼的好处是几乎零学习成本,会拼音就会打字;缺点是码长,一个"张"字要敲五个字母,而且重码率高,同音字要靠候选框来选。双拼把声母韵母各压缩到一个键,码长固定成两键,代价是要背键位表。
形码以字形结构为基础,最典型的是五笔。五笔的优势是重码率极低,熟练之后可以盲打,码长也短,一个"张"字三键就能出。代价是学习曲线陡峭,要背字根表和拆分规则,忘字的时候打不出来。郑码、仓颉也是同一思路,各有各的字根体系。
序码则完全不依赖读音和字形,纯粹按字符集的顺序编号。区位输入法、电报码输入法都属于这一类。序码的优势是唯一性强、没有重码,一个码对应一个字;缺点是必须先查表才知道码,日常使用完全不现实。它的真正用武之地在数据录入和校验环节——比如一个单位要把纸质档案录入系统,直接用区位码一条一条敲,比拼音选字效率高得多,而且不会选错。
4.2 四位数字不一定是区位码:电报码与区位码的区别
前面提过电报码,这里展开说一下,因为这是实际工作中很容易混淆的一对概念。
中文电报码是民国时期就有的体系,用四位数字表示一个汉字,从0000编到9999,覆盖大约一万个常用字。它跟区位码一样都是四位数字,但编排逻辑完全不同——电报码是按字的使用频率和查字习惯编排的,没有任何二维网格结构。
区分方法很直接:区位码的区号和位号都必须在 1 到 94 之间,所以一个合法的区位码四位数字,拆开后的两个数都不能超过 94。而电报码可以出现9601、9988这种数字,区号部分已经超过 94 了,一眼就能看出不对劲。
还有一种情况更隐蔽:有些电报码恰好拆出来两个数字都在 94 以内,比如1234,这时候光看数字判断不了。这种时候只能靠数据来源来确认——如果一个系统的需求文档里写的是"按区位码录入",那它就不会是电报码;如果写的是"支持电报码输入",那就别拿它去算区位偏移。这种信息在项目交接的时候一定要问清楚,凭数字猜是靠不住的。
4.3 输入法内部的码表与重码处理
从外码到机内码的转换,核心是一张码表。码表的结构很朴素,就是"外码串 → 候选字列表"的映射。拼音输入法的码表里,zhong这个键对应着"中、种、重、众、终、钟……"一长串候选,用户选哪一个,输入法就交出哪一个的机内码。
五笔输入法的码表容量小得多,因为码长固定且分散性好,大部分码位只对应一两个字。这也是形码重码率低的原因——它的键位空间是 25 个字母键的四次方,接近 39 万种组合,编码空间远大于汉字数量,碰撞自然少。
现代输入法在码表之上还叠了很多层处理:简拼(只敲部分字母就能出字)、模糊音(把 z 和 zh 视为等价)、整句联想(根据上下文调整候选顺序)、用户词库(记住你常打的组合)。这些都属于外码层面的优化,不改变任何一个汉字的机内码。理解了这一点,很多"为什么我在这台电脑上打得出、在那台电脑上打不出"的问题就解释得通了——不是字不存在,是外码层的码表或者策略不一样。
4.4 为什么外码最容易变
外码的不稳定性来自三个方面。
第一,它没有标准约束。任何一家输入法厂商都可以定义自己的码表格式、自己的简拼规则、自己的候选排序。你今天习惯的输入习惯,换一个输入法可能就是另一套。
第二,它依赖用户习惯。同一个汉字,有人用拼音打,有人用五笔打,有人用语音转文字,产生的中间状态完全不同。任何试图在外码层面做数据交换或者数据存储的设计,都会在用户更换输入方式时崩溃。
第三,它跟操作系统和软件环境强耦合。同一套输入法在不同系统上的表现可能不一样,候选词的排序、模糊音的默认开关、词库同步的策略,都可能存在差异。
所以一条经验,是我在很多项目里反复验证过的:外码只在输入瞬间存在,绝不能落到任何需要持久化的地方。日志里不要记外码,数据库里不要存外码,接口传参不要用外码。要存就统一转成机内码或者统一码点,这样不管用户怎么输入,下游拿到的都是确定的东西。
5. 字形码:从 32 字节的点阵到可缩放的轮廓
前面四层解决的都是"这个汉字是什么",字形码解决的才是"这个汉字长什么样"。这两件事在概念上必须分开,因为同一个汉字在不同字号、不同字体下的字形码是完全不同的,但它的机内码永远是同一个。
字形码主要有两条技术路线:点阵和矢量。早年嵌入式设备几乎清一色用点阵,因为它简单、确定、读取快;桌面出版和现代操作系统则走矢量路线,因为要支持任意缩放。
5.1 点阵字模的字节数怎么算
点阵字模的存储方式是把一个汉字拆成N × N个方格,每个方格用一位(1 bit)表示,黑点记 1,白点记 0。一行有 N 个点,就需要N / 8个字节来存;一共 N 行。所以字节数公式是:
字模字节数 = (点阵宽度 / 8) × 点阵高度
几组常见规格的结果:
| 点阵规格 | 每行字节 | 行数 | 字模总字节 | 典型用途 |
|---|---|---|---|---|
| 12×12 | 2 | 12 | 24 | 极低资源设备 |
| 16×16 | 2 | 16 | 32 | 传统嵌入式显示主力 |
| 24×24 | 3 | 24 | 72 | 中小尺寸屏、报表 |
| 32×32 | 4 | 32 | 128 | 高分辨率单色屏 |
| 48×48 | 6 | 48 | 288 | 大字提示、仪表 |
12×12 这一行需要说明一下:12 个点其实只需要 1.5 个字节,但存储时按字节对齐,实际占 2 个字节,低 4 位空着不用。所以它的字模字节数是 24 而不是 18。这个"空位"在某些取模软件里可以拿去存其他东西,但绝大多数情况下就是浪费掉的。
5.2 HZK16 偏移公式和四个取模参数
16×16 点阵字库是最经典的一类,文件名通常叫 HZK16,是一个裸的二进制文件,里面按区位顺序连续排列着 94×94 个字符的字模,每个字模 32 字节。定位某个汉字的字模,用这个公式:
def hzk16_offset(qu, wei): """返回该字符在 16x16 点阵字库中的字节偏移""" return ((qu - 1) * 94 + (wei - 1)) * 32 # 读取"中"字的字模 with open('HZK16', 'rb') as f: f.seek(hzk16_offset(54, 48)) bitmap = f.read(32)公式里用(qu - 1) * 94 + (wei - 1)而不是qu * 94 + wei,是因为序号从 1 开始,要转成从 0 开始的偏移。这个细节很多人第一次写会写错,结果整体偏移多出几十个字节。
需要提醒的是,不是所有叫 HZK16 的文件都用这个公式。有些精简版本裁掉了空白区,只保留 1 到 87 区的有效数据;有些版本追加了扩展汉字。如果你拿到的字库文件是从某个老项目里翻出来的,最好先拿一个已知汉字验证一下——比如用"啊"(区位 1601)取 32 字节,看看前几个字节是不是0x00 0x00 0x00 0x00 ...这种上部空白的形态。"啊"字的上半部分是空心的,前几行应该全是 0,这是个很好的验证样本。
然后是取模参数的四个坑,嵌入式显示的经典问题:
- 横向取模还是纵向取模:横向是逐行扫描,纵向是逐列扫描。搞反了,显示出来的字是横竖错位的。
- 字节内位序:一个字节里的 8 个点是高位在前还是低位在前。搞反了,字会是镜像的。
- 存储方向:从左到右还是从右到左。
- 阴阳码:1 表示亮点还是 0 表示亮点。搞反了,字是反色的。
这四个参数组合起来有十几种可能,取模软件里一般用下拉框选。我的经验是:先用取模软件生成一个已知汉字的 32 字节,跟你字库文件里同一位置的字节做逐字节比对,一致了就说明参数对上了,不一致就换一组参数再比。这比看文档猜参数快得多,一般试两三组就能命中。
5.3 矢量字形:cmap 查表和曲线轮廓
矢量字库的思路完全不同。它不存点阵,而是存轮廓——也就是汉字外框的数学描述,通常由直线段和曲线段组成。渲染的时候再根据当前字号把轮廓缩放、栅格化成点阵。
TrueType 格式用二次贝塞尔曲线描述轮廓,存储时用on-curve和off-curve标志区分控制点和端点。OpenType 里的 CFF 轮廓则用三次贝塞尔曲线。两者的几何处理方式略有不同,但整体思路一致:用尽量少的点描述出字的形状,然后靠渲染引擎填补细节。
从字符编码到轮廓,中间多了一层映射表,叫cmap。它的作用是把字符编码(早期是机内码,现代是统一码点)映射到字形索引(glyph ID)。为什么要插这一层?因为一个字体文件里可能包含几千个字形,但它们的排列顺序跟编码顺序不一定一致,而且同一个字形可能被多个编码共用(比如全角和半角的某些符号)。有了 cmap,字体设计者可以自由安排字形的存储顺序,不用迁就编码。
现代渲染链路的完整流程是:
统一码点 → cmap 查表得 glyph ID → 从 glyf 或 CFF 表取出轮廓 → 按字号缩放 → 抗锯齿栅格化 → 位图 → 屏幕
对比老的点阵链路:
机内码 → 减去 A0A0H 得区位 → 算文件偏移 → 读出固定字节 → 直接刷屏
两条链路的差别一目了然。老链路是零开销、确定性极强的,因为字模位置可以精确计算,不需要任何查表,单片机上一段几十行的代码就能跑通。代价是字库体积大(一个 16×16 全字库大约 260KB 以上)、不能缩放、字体样式单一。
新链路灵活但重。要支持任意字号、要处理 hinting(小字号下的笔画对齐)、要做抗锯齿,背后是一整套渲染引擎。桌面系统上这不是问题,但在几百 KB 内存的设备上就完全不现实了。
所以选型逻辑很清晰:资源受限的嵌入式设备用点阵,桌面和移动端用矢量。这两条路线今天依然并行存在,并没有谁取代谁。
6. 混用 GB2312、GBK、GB18030 时最容易踩的三个坑
理解了前面五层编码的分工,剩下的问题基本都在"实际数据不按标准来"这个范畴。GB2312 之后,为了容纳更多汉字,先后出现了 GBK 和 GB18030 两个扩展标准,它们向下兼容 GB2312,但扩展方式带来了一些新的解析陷阱。
6.1 次字节落进 ASCII 区间,高位判断法失效
前面说过,GB2312 机内码的两个字节最高位都是 1,所以用高位就能区分单字节和双字节。GBK 打破了这个约定。
GBK 的首字节范围是0x81到0xFE,最高位是 1,这一点没变。但次字节的范围扩展成了0x40到0xFE(排除0x7F),也就是说次字节可以是0x40到0x7E这一段 ASCII 可打印字符。
这意味着什么?意味着如果有人用一个简单的循环去扫描 GBK 数据、靠"看最高位是不是 1"来判断字节类型,会在遇到次字节0x41(字母 A)的时候出错——它会把0x41当成一个独立的 ASCII 字符处理,然后后面的字节全部错位,后续解析全乱。
正确的做法是有状态的解析:只有当你确定当前处于"期望首字节"的状态,读到最高位为 1 的字节时,才切换到"期望次字节"状态,此时无论次字节是什么(只要不是0x7F),都把它吞掉。这个逻辑用状态机写最清楚:
def gbk_decode(data): result = [] i = 0 while i < len(data): b = data[i] if b < 0x80: result.append(bytes([b])) i += 1 elif 0x81 <= b <= 0xFE and i + 1 < len(data): nxt = data[i + 1] if nxt != 0x7F: result.append(bytes([b, nxt])) i += 2 else: # 非法序列,按单字节处理 result.append(bytes([b])) i += 1 else: result.append(bytes([b])) i += 1 return result这段代码我第一次写的时候没处理好0x7F的排除,结果在某个特定数据上卡了很久。0x7F是 DEL 字符,GBK 规定它不能作为次字节出现,所以遇到这种情况要回退,把首字节单独处理。
6.2 按字节截断把汉字劈成两半
第二个坑更常见,几乎每个处理中文的系统都会遇到:按字节长度截断字符串。
假设你要把一段中文截断到 10 个字节,直接切片data[:10],如果第 10 个字节恰好是某个汉字的低字节,那这个汉字就被劈成了两半——前一半留在结果里,后一半被丢掉。渲染的时候,前半截字节会跟后面的数据重新组合,形成一个完全不相干的字,或者直接显示成问号。
正确做法是先解码成字符序列,按字符数截断,再重新编码:
def safe_truncate(data, max_bytes): text = data.decode('gbk', errors='ignore') out = [] used = 0 for ch in text: b = ch.encode('gbk') if used + len(b) > max_bytes: break out.append(ch) used += len(b) return ''.join(out).encode('gbk')还有一个更隐蔽的变体:0x5C问题。GBK 里存在一些汉字,它们的次字节恰好是0x5C,也就是反斜杠。在不同系统之间传递这种数据时,如果中间有一层会转义反斜杠的处理逻辑,这个汉字就会被拆坏。这种问题很难从现象上联想到编码,但理解了双字节结构就能很快定位。
6.3 编码声明和实际字节不一致
第三个坑是声明与实际不符。这在老系统迁移的时候特别常见。
典型场景:一个文件里存的是 GBK 机内码,但文件头的元数据里写着 UTF-8;或者一份网页的Content-Type声明是 UTF-8,实际内容却是 GBK 编码。解析程序按声明的方式去解码,轻则乱码,重则抛异常。
还有一种情况是数据库字段编码和连接编码不一致。表定义是 GBK,连接串写的是 UTF-8,查询出来的中文变成问号或者乱码。这个问题排查起来要同时确认三个地方:表定义、连接参数、客户端字符集。
我的习惯是不信任任何声明,直接看字节。方法很简单,取一段中文内容,看它的字节序列:
- 如果一个汉字占两个字节,两个字节都大于
0x80,那是 GB2312 或 GBK - 如果一个汉字占三个字节,第一个字节在
0xE0到0xEF之间,后两个字节在0x80到0xBF之间,那是 UTF-8 - 如果开头有
EF BB BF,那是带 BOM 的 UTF-8
判断出实际编码之后,再针对性处理,比追着一堆配置项看要高效得多。
7. 这些老编码今天还出现在哪些地方
有人会说,现在都是 UTF-8 了,这些老编码还有必要学吗?我的答案是:在你不会遇到它们的时候不必要,但只要接触老系统,就必须会。
7.1 嵌入式点阵屏与串口屏
嵌入式领域是 GB2312 机内码最后的大本营之一。单片机上的中文字库基本都还是点阵加 GB2312 机内码的组合,原因很实际:字库文件小、定位公式简单、不需要庞大的渲染引擎。
一块 12864 的液晶屏,主控可能只有几十 KB 的 Flash,跑一个完整的 TrueType 渲染引擎是不现实的。用 HZK16 加上前面那个偏移公式,几十行 C 代码就能显示中文,内存占用也就一个 32 字节的缓冲区。这种方案在工业控制面板、电力仪表、医疗设备的小屏上至今大量存在。
做这类项目的时候,一定要确认清楚字库文件的具体版本和取模参数。我见过不止一次,因为换了一批字库文件但忘了核对参数,上电之后满屏错位字符的情况。后来养成的习惯是在代码里固化一组验证数据——上电之后先取"中"字的字模,跟代码里预存的 32 字节做比对,不一致就直接报错。这样问题在开发阶段就暴露了,不会拖到现场。
7.2 老数据库、老文件名、Windows 代码页
另一个重灾区是历史遗留数据。十年以上的业务系统,数据库里存的很可能就是 GBK 机内码。迁移的时候如果直接按字节搬,可能会因为目标库是 UTF-8 而产生乱码;如果按字符转换,又要小心那些在 GBK 里存在、在 UTF-8 里找不到对应的生僻字。
Windows 上的文件名也是一样。中文 Windows 的默认代码页是 936,也就是 GBK。跨平台共享文件夹的时候,Linux 端如果挂载参数没配对,中文文件名就会显示成问号。这种情况不需要改文件内容,只需要在挂载的时候指定正确的字符集参数。
处理这类问题,我一般会先做一次编码探查:用脚本遍历一遍数据,统计有多少条记录不能按 UTF-8 解码、多少条不能按 GBK 解码。正常情况下,能成功按某一种解码的比例会明显更高,那就是真实的编码。
def detect_encoding(sample_bytes): for enc in ('utf-8', 'gbk', 'gb18030'): try: sample_bytes.decode(enc) return enc except UnicodeDecodeError: continue return None注意顺序很重要,UTF-8 要放在前面。因为很多 GBK 字节序列恰好也是合法的 UTF-8,反过来则不然。按这个顺序试,误判概率最低。这个方法在处理小样本时特别有效,但要注意 GB18030 几乎是万能的(它覆盖了全部 Unicode 码位),所以它只能作为兜底,不能作为判断依据。
7.3 什么时候该换、怎么换
最后聊一个实际问题:老编码什么时候必须换掉,怎么换。
我的判断标准是三条:是否面临数据交换需求、是否出现字符集外字符、是否要支持多语言。只要命中一条,就该考虑迁移到 UTF-8。数据交换的原因是不同系统的默认编码不一样,来回转换容易出错;字符集外字符是因为 GB2312 只有 6763 个汉字,人名、地名、专业术语里经常遇到打不出来的字;多语言则是因为 GB 系列本身只覆盖中文,混排日文、韩文会很别扭。
迁移的时候有两个地方要特别注意。一是往返转换的可逆性,从 GBK 转 UTF-8 再转回来,必须保证字节完全一致,任何一点差异都说明有字符被替换了。二是长度语义的变化,GBK 下一个汉字占两个字节,UTF-8 下占三个字节,所有按字节长度设计的字段(数据库列宽、协议报文长度、文件偏移索引)都要重新核算。
我自己踩过的最深的一个坑,是把一个按字节长度做分页的数据源直接切到 UTF-8,结果每页能装的内容少了三分之一,前端分页完全错乱。这种问题不会报错,只会表现为"最后一页数据少了几条"或者"某些条目被跳过了",非常难查。
所以迁移之前,一定要把所有跟字节长度相关的逻辑梳理一遍,宁可多花两天做检查,也不要上线之后靠日志慢慢找。