汉字编码从区位码到机内码:外码、字形码与GB2312转换详解
聊到汉字编码这个话题估计不少人和我一样第一次接触时脑子是懵的。课本上、资料里动不动就冒出来一堆名词区位码、国标码、机内码、外码、字形码每个都说自己是汉字的编码可到底谁管谁、谁变成谁看半天也理不清。我在做嵌入式字库、处理过一些老系统的字符转换之后才算把这些东西彻底串起来了。这次就把这五个编码从头到尾捋一遍讲清楚它们各自站在汉字处理的哪个环节、相互之间怎么换算、实际操作里容易踩哪些坑。不管你是刚学计算机基础的学生还是被老项目编码问题折腾过的开发或是对中文信息处理感兴趣的爱好者看完应该都能建立起一张完整的脉络图。区位码是理解这一切的起点我们先把这张地图铺开。1. 从乱码说起这几个编码名词到底为什么必须搞懂先讲个很多人经历过的场景。你打开一个多年前保存的文本文件或者接了一台老设备的串口数据屏幕上出现的不是汉字而是一堆看不懂的方块或者奇怪的符号这就是乱码。乱码的本质其实很简单同一串字节被人用两套不同的规则去解释了。存储的时候按A规则编成字节读取的时候按B规则去还原自然就对不上号。汉字的编码问题之所以比英文复杂得多根本原因在于英文只有26个大小写字母加少量符号不到128个字符用一个字节完全可以表示一遍。而汉字常用字就有几千个一个字节最多256种状态根本不够用必须动用两个甚至更多字节。可一旦用多字节表示就要解决一连串问题怎么给每个汉字编号编号怎么和英文ASCII区分开用户从键盘敲进来的拼音怎么变成编号存储的编号又怎么变成屏幕上那个笔画分明的字形这一连串问题对应到的正是区位码、国标码、机内码、外码、字形码这五个概念。我习惯把它们理解成一条流水线上的五个工位。用户敲键盘是入口屏幕显示是出口中间经过编号、换算、存储、取形好几个环节每个环节用的编码规则都不一样。很多人学不明白就是因为把流水线上不同工位的编码混在一起谈觉得不都是汉字的编号吗搞这么多套干嘛。其实每一套编码都是为特定环节服务的各有各的存在理由。把这五者搞懂的实际价值很直接。做字库的要知道字形码的组织方式写驱动的要会和机内码打交道处理历史数据迁移的要能在区位码、国标码之间来回换算调试串口乱码的更要一眼看出哪一端的编码规则没对齐。可以说只要你的工作涉及到中文文本的存储、传输或显示这五个概念迟早会撞上。下面我就按流水线的顺序把它们逐个拆开。1.1 先把码这个词的歧义理清在正式展开之前有一个认知上的坎必须先跨过去那就是**码这个字在不同语境里含义完全不同**。我们平时说编码有时候指给字符分配的一个编号比如这个字是第54区第48位有时候指这个编号在计算机里实际存储的字节形式比如它存进去是0xD6 0xD0这两个字节。这两者不是一回事。举个生活化的例子就像给一个班的学生排学号。编号相当于学号本身比如三年二班第15号而存储形式相当于这个学号写进档案系统时用的具体格式可能是030215这样一串数字。学号的含义和它的存储格式是可以分开的。区位码、国标码更像是编号这一层机内码则明确是存储形式这一层外码是用户输入的表示字形码则是字符长什么样的数据。分清楚每一层在讲什么后面就不会乱。2. 汉字信息处理的完整链路五个编码各站哪个环节一个汉字从被输入到被显示走的路其实比想象中长。我们可以把它画成一条从人到机器的往返链路人通过键盘输入 → 系统识别输入码外码→ 转换成统一的内部存储编码机内码→ 需要显示时调取字形数据字形码→ 屏幕上绘制出来。区位码和国标码则更多是国家标准的编号体系是机内码的上游来源也是不同系统之间交换数据时的通用语言。理解这条链路有个关键点外码和字形码是面向人和设备两端的中间那一段才是面向计算机内部的。外码强调的是人怎么方便地输入字形码强调的是屏幕和打印机怎么把字画出来而机内码、国标码、区位码处理的都是计算机内部怎么统一地表示和存储这个字。这条分界线划清楚很多困惑就迎刃而解了。2.1 外码你在键盘上敲下的那一串外码也叫输入码是用户为了把一个汉字输入计算机而使用的编码。它直接面向人的操作习惯所以种类特别多。最常见的有拼音码敲zhong选出中、五笔字型码敲khk出中、区位码输入法直接敲5448出中、还有笔画码、仓颉码等等。外码最大的特点就是不唯一。同一个汉字用拼音输入法是一种码用五笔又是另一种码甚至同样是拼音全拼和双拼敲出来的键序也不一样。外码的存在意义就是让不同习惯的人都能方便地把字输进去。你想想如果强制所有人用同一套输入码那习惯拼音的人、习惯形码的人就都被绑死了体验会非常差。所以外码这一层的设计哲学就是多样性优先方便人使用。我早些年帮人处理过一个支持区位码输入的老系统那个输入法要求用户手动查表把汉字的区位号敲进去效率很低但在某些需要精确输入非常用字的场合反而特别有用——因为区位号是唯一的不依赖拼音也不依赖字形拆分。这就是外码的取舍牺牲易用性换取精确性和唯一性。日常打字当然用拼音或五笔但要精确指定一个编号区位码输入反而最直接。需要强调的是外码本身不参与存储。你敲完拼音选好字之后这个拼音串就被丢弃了系统记录的是转换后的机内码。所以外码是一次性的、临时的它的使命就是把人想输入的意思翻译成机器能懂的编号。2.2 区位码与国标码GB2312碰到的两套编号口径说到汉字编码绕不开的一个东西就是GB2312这是我国早期发布的一套汉字编码字符集标准。它把常用的汉字和符号收进了一张大表里然后给每个字符安排一个位置。这个安排位置的方式就催生了区位码。区位码的做法很直观把字符排成一个94行、94列的方阵行号叫区列号叫位区的编号从1到94位的编号也从1到94。一个字符的位置就用区号位号表示区号和位号各占一个字节。比如某个字在第54区第48位它的区位码就记作5448。这种表示方式特别符合人的直觉查表也方便就像查字典先翻到第几页再找第几行。具体分布上GB2312的这张表是有讲究的。1到9区放的是各种符号包括标点、数字、拉丁字母、日文假名、希腊字母、俄文字母等10到15区留空预留给后续扩展16到55区是一级汉字共3755个按拼音字母顺序排列56到87区是二级汉字共3008个按部首笔画顺序排列88到94区同样预留。一级二级汉字加起来正好6763个这就是GB2312收录的汉字总数。而国标码是在区位码基础上做了一次偏移得到的。规则很简单区号和位号各加上32也就是十六进制的0x20就得到了国标码。为什么要加32因为ASCII里0到32这段是控制字符不能拿来当可见字符用避开这段区域可以让汉字的编码落在可见字符的安全范围里。所以国标码的每个字节范围变成了33到126避开了控制字符区。区位码和国标码本质上表示的是同一个字在同一张表里的位置只是报数的方式不一样。区位码用1到94的自然位置国标码用加了偏移之后的值。理解这一点后面看机内码就顺了。2.3 机内码计算机内部真正认的那串字节机内码才是汉字在计算机内部实际存储和使用时的编码也是我们平时说的这个字的GB2312编码是什么时指的那个东西。它是在国标码基础上再偏移得来的规则同样是每个字节加上128十六进制的0x80也就是说机内码 国标码 0x8080。为什么要再加128这是为了解决一个非常现实的问题让汉字和英文ASCII能区分开。英文ASCII的字节范围是0到127最高位第8位都是0。如果汉字的字节也落在0到127这个区间那计算机读到一串字节时就分不清哪个是英文、哪个是半个汉字了。于是设计者干脆把汉字每个字节的最高位都置成1也就是加0x80让机内码的两个字节都落在128到255这个区间。这样计算机一读到字节最高位是1就知道这是个汉字的一部分读到最高位是0就知道这是ASCII字符。这个设计堪称巧妙用最高位当标志位简单高效。把公式串起来看机内码 区位码 0xA0A0先加0x2020得到国标码再加0x8080得到机内码两次偏移合起来就是0xA0A0。所以从区位码一步到位算机内码也完全可以记住这个0xA0A0很有用。我实测过一个典型例子。汉字中的区位码是5448区号54换算成十六进制是0x36位号48是0x30。给它加0x2020得到国标码0x5650再加0x8080得到机内码0xD6D0。你去任何一台按GB2312编码的机器上查中这个字的字节都会看到0xD6 0xD0。这个结果是稳定的全世界的GB2312系统都认这个编号。2.4 字形码屏幕上那个字到底长什么样前面几个编码解决的都是这个字是谁的问题而字形码解决的是这个字长什么样。计算机里存的只是一个编号比如0xD6D0它本身没有任何视觉信息。要把这个编号变成屏幕上看得见的中字必须去查一份字形数据也就是字形码。字形码最常见的形式是点阵。比如16×16的点阵就是把一个汉字的显示区域划分成16行16列共256个小方格每个方格用一个二进制位表示是1就点亮是0就不点亮。这样256个二进制位正好是32个字节256÷832也就是说一个16×16的汉字需要32字节来存储它的形状。字形码本质上就是一堆点阵数据的集合把所有这些点的亮灭按顺序排好屏幕或打印机就能照着画出来。除了点阵还有矢量字形码也叫轮廓字形它不直接存每个点的状态而是存储这个字的轮廓曲线和填充规则。你熟悉的TrueType字体、OpenType字体、PostScript字体用的都是矢量方式。矢量字形的好处是放大缩小不失真无论字号多大轮廓都是重新计算渲染出来的边缘始终平滑而点阵字一旦放大会出现明显的锯齿。早期因为存储和计算能力有限用点阵多现在系统基本都是矢量字库了。字形码和前四个编码最大的区别在于它根本不是一个编号而是一份绘图数据。前面几个编码都是为了定位是哪个字字形码是为了表达这个字怎么写。这就是为什么字库文件往往很大——它们存的是成千上万个汉字的形状数据不是简单的编号。3. 转换关系实测区位码、国标码、机内码的换算套路三个内部编码之间的换算关系是这一整套体系里最需要动手练的部分。公式本身不难但不动手算几遍很容易记混方向。我把它们的数学关系、推导逻辑和验证代码都摆出来你可以跟着一起算。3.1 三者关系的数学推导先把核心公式列清楚编码字节范围与区位码的关系区位码区号1-94位号1-94原始位置国标码区号0x20位号0x20区位码 0x2020机内码区号0xA0位号0xA0区位码 0xA0A0或国标码 0x8080这里的加法要理解成两个字节分别相加不是把区号和位号拼成一个数再加。比如区位码5448区号54加0x20等于860x56位号48加0x20等于800x50合起来国标码是0x5650。反过来从国标码减0x2020就回到区位码从机内码减0xA0A0也回到区位码。为什么不直接一步到位用机内码中间还要夹个国标码这就要从历史背景理解了。国标码定的是字符在标准表里的顺序编号它是一个规范层面的东西跟具体计算机怎么存储无关。而机内码是实现层面的东西是各家计算机系统为了让汉字和ASCII共存而采用的具体存储形式。早期不同厂商的机内码实现甚至不完全一样但国标码作为标准编号是统一的。这种标准编号和存储实现分离的设计在很多技术领域都能见到比如网络协议里的逻辑地址和物理地址思路是相通的。3.2 手工算一遍几个常见字光看公式容易麻木算几个实际的字更靠谱。我挑了三个有代表性的字把从头到尾的换算走一遍。第一个字中。区位码5448区号0x36位号0x30。加0x2020得国标码0x5650加0x8080得机内码0xD6D0。验证方式很简单在GB2312环境下取中的字节就是D6 D0。第二个字啊。它是一级汉字的第一个区位码是1601区号0x10位号0x01。加0x2020得国标码0x3021加0x8080得机内码0xB0A1。所以啊的GB2312编码是B0A1这个经常被拿来当例子因为它排在头一个。第三个字国。区位码2590区号0x19位号0x5A。加0x2020得国标码0x395A加0x8080得机内码0xB9FA。所以国的编码是B9FA。这三个例子算下来规律很清楚了机内码 区位码的每个字节加上0xA0。区号加0xA0就是机内码的高字节位号加0xA0就是机内码的低字节。记住这一条绝大部分换算都能心算出来。3.3 用代码把换算跑通手工算几遍有感觉之后我建议直接用代码验证一遍既快又不容易错。下面这段Python代码把区位码到机内码的转换和反推都写出来了。def quwei_to_guobiao(qu, wei): # 区号、位号各加 0x20 return qu 0x20, wei 0x20 def guobiao_to_jinei(gb_high, gb_low): # 国标码两个字节各加 0x80 return gb_high 0x80, gb_low 0x80 def quwei_to_jinei(qu, wei): # 一步到位区位码加 0xA0A0 return qu 0xA0, wei 0xA0 def jinei_to_quwei(jn_high, jn_low): # 反推机内码减 0xA0 return jn_high - 0xA0, jn_low - 0xA0 # 以中字为例区位码 5448 qu, wei 54, 48 gb quwei_to_guobiao(qu, wei) jn guobiao_to_jinei(*gb) print(国标码:, hex(gb[0]), hex(gb[1])) # 0x56 0x50 print(机内码:, hex(jn[0]), hex(jn[1])) # 0xd6 0xd0 # 直接用一步法验证 jn2 quwei_to_jinei(qu, wei) print(一步法机内码:, hex(jn2[0]), hex(jn2[1])) # 用 Python 自带的 gb2312 解码验证字节是否正确 print(解码结果:, bytes(jn).decode(gb2312)) # 中 # 反推区位码 print(反推区位码:, jinei_to_quwei(jn[0], jn[1])) # (54, 48)跑完这段你会看到国标码是0x56 0x50机内码是0xd6 0xd0解码出来正是中字反推也准确回到54、48。这种算一遍再用标准库验证一遍的做法是我强烈推荐的学习方式比死记公式管用得多。自己动手跑通一次这几个编码的关系基本就刻在脑子里了。3.4 踩坑提示别把加法方向搞反换算关系里最常见的错误就是加法和减法方向搞混。区位码加偏移得到机内码机内码减偏移回到区位码这个方向一定要记牢。我在帮别人排查数据迁移问题时见过不止一次因为把减0xA0写成加0xA0导致整个字库错位的。还有个容易犯的错是把两个字节的偏移算到一块去比如把区位码当成一个整体数字去减0xA0A0那肯定错——偏移是对每个字节分别进行的不是对一个合并后的数值。另外提醒一点区号和位号虽然叫号但它们是独立取值的别想着把54和48拼成一个5448当成纯数字去做运算。5448只是个书写上的方便记法真正的两个字节是0x36和0x30。想清楚这一点就不会在进制转换上翻车。4. 字形码的存储逻辑点阵、矢量与字库的那些事字形码这一块单独拎出来讲是因为很多人前面几个编号编码都理清了一到字库就卡壳。字库文件为什么动辄几兆、几十兆点阵字和矢量字到底差在哪这些问题的答案都藏在字形码的组织方式里。4.1 点阵字形码的容量计算先算一笔账看看点阵字库有多占地方。一个16×16点阵的汉字需要16×16256个点每个点1位共需256÷832字节。GB2312收录了6763个汉字加上1到9区的各种符号约682个总共约7445个字符。那么16×16点阵的字库大小大约是7445×32≈238240字节也就是约233KB。换个更大的字号24×24点阵每个字需要24×24÷872字节整个字库变成7445×72≈536040字节约523KB。32×32点阵更夸张每个字需要128字节字库涨到约931KB。字号每增大一圈容量增长非常明显因为点的数量是平方级增长的。这就是早期系统偏爱小点阵的原因——存储空间实在太宝贵。我整理了一张对比表方便你直观感受不同点阵规格的差异点阵规格每字字节数全字库(约7445字符)大小显示效果16×1632约233KB勉强能看笔画多的字挤成一团24×2472约523KB常规阅读可用32×32128约931KB比较清晰48×48288约2.1MB清晰适合标题点阵字形码的存储顺序也是有讲究的。通常按区号和位号的顺序依次排列每个字占据固定长度的字节。系统要显示某个字时先算出它的区位号再乘以每个字的字节数得到偏移去字库文件里这段位置取数据最后按点阵结构绘制出来。这个编号乘以长度得偏移的思路和数组下标访问是同一个道理。字形码取数据的效率很高本质就是一次简单的地址计算。4.2 矢量字形码为什么更省心点阵字的痛点很明显放大会糊。因为它的形状是固定分辨率下拍下来的放大之后每个点被拉伸成大块锯齿特别明显。解决这个问题的办法就是改用矢量字形码存储的是字符轮廓的几何描述也就是一系列直线和曲线的控制点加上填充规则。矢量字形渲染时系统根据当前字号重新计算轮廓应该落到哪些像素上再填充出来所以任意字号都清晰。代价是渲染计算量比点阵大早期硬件扛不住现在完全不是问题。TrueType字体就是典型的矢量字库它用二次贝塞尔曲线描述轮廓。打开一个.ttf文件里面存的不是一个个点的亮灭而是每个字的轮廓点坐标和指令。从点阵到矢量本质上是从存结果到存规则的转变。点阵存的是这个字在16×16网格下每个点亮不亮这个结果矢量存的是这个字的轮廓怎么画这个规则。想清楚这个区别你就能理解为什么矢量字能无限缩放而点阵字不行。这个思路在图形学里到处都是比如位图和矢量图的区别是一回事。5. 实操中常见的问题与排查技巧理论讲完来点实战的。这一节我把平时遇到的问题整理成速查表再补充几条只有踩过才懂的小经验。5.1 典型问题速查表现象可能原因排查方向文本中汉字显示成方块缺少对应字库或字体不支持该字符换用完整字库检查字体覆盖范围汉字显示成两个乱码符号机内码字节被当成两个ASCII单独解释确认读取端用的是GB2312等多字节规则数据迁移后汉字整体错位偏移量加减方向搞反核对是加0xA0还是减0xA0部分生僻字无法显示该字不在GB2312范围内属于GBK或更扩展区升级到GBK或GB18030字符集打印出来的字有毛边用的是小点阵字分辨率不足改用矢量字库或更高点阵串口收到的中文全乱收发两端字符集不一致一端UTF-8一端GB2312统一两端编码规则5.2 几个只有踩过才知道的细节第一区位码和机内码的区字容易混淆。有人看到区位码和机内码里都带位字就以为它们是一回事。其实一个用的是1到94的自然位置另一个是加了偏移的字节值差了0xA0。我见过有人拿着区位码当机内码去查表查半天查不出正确的字。第二一级汉字按拼音排二级汉字按部首排这个排序规则影响区位码的分配。所以你在推某个字的区位码时如果能大概判断它是常用字还是生僻字就能猜到它在16到55区还是56到87区缩小查找范围。这个技巧在手工查表时挺有用。第三UTF-8和GB系列是两套完全不同的体系。UTF-8用的是变长编码汉字通常占3个字节和GB2312的2字节机内码没有任何直接的换算关系。你可以通过Unicode码点做中转来互相转换但千万别以为机内码和UTF-8字节之间能直接加减。我见过有人试图用偏移公式去处理UTF-8数据结果当然是一塌糊涂。第四外码不要和机内码混谈。有人问为什么我敲五笔的编码和存储的编码不一样这问题本身就问错了——外码是给人用的输入表示机内码是给机器用的存储表示它们本来就是两回事。就像你点菜说的菜名和厨房里的食材编号一个面向客人一个面向后厨不通才正常。6. 从GB2312往后看区位码体系的延伸与局限GB2312解决了常用汉字的编码问题但随着用字需求增加它的局限也暴露出来只收了6763个汉字很多生僻字、繁体字放不进去。于是后续标准对这套体系做了扩展但底层逻辑依然是区位编号那一套思路的延续。GBK在GB2312基础上扩充收录了两万多个汉字编码空间也扩大了。它的机内码高字节范围扩展到0x81到0xFE低字节范围是0x40到0xFE避开0x7F这样就把可用空间从原来的94×94扩大到约两万多个位置。GB18030走得更远用变长编码既有单字节、双字节也有四字节形式理论上能覆盖Unicode的全部字符。从GB2312到GBK再到GB18030是一条不断向下兼容、不断扩充容量的演进路线老编码在新标准里依然能被正确解读这保证了历史数据的可用性。这套扩展思路背后有个重要的工程原则向后兼容。新标准必须能正确读取老标准编的数据否则所有历史文件都会变成乱码代价无法承受。所以GBK保留了GB2312的编码位置不变只是往里加新字符GB18030又保留了GBK那套双字节编码。在位扩展的思路本质上是给原来的方阵加行列而不是推倒重来。我在处理一批上世纪的老档案数据时就深刻体会到了这套兼容体系的价值。那些文件的机内码放到今天的系统里只要按GB18030去解释依然能准确地还原出原来的汉字一个字都不差。反过来如果我当初把标准升级当成换个新规则重来那些数据恐怕就永远读不出来了。所以理解区位码这一套编号体系不只是学个知识点更是理解中文信息化几十年演进的一把钥匙。6.1 一次完整转换的实战复盘最后复盘一个我实际做过的转换任务把五个编码串成一串。任务是把一批老系统导出的区位码数据转换成能在现代系统里显示和搜索的UTF-8文本。第一步读入原始数据里面每个汉字用四位数字表示前两位是区号后两位是位号。第二步按前面讲的公式把区号和位号分别加0xA0得到两字节的机内码。第三步用GB2312规则把这组字节解码成Unicode字符串。第四步把Unicode字符串按UTF-8编码写出去。整个流程里区位码负责告诉我这是哪个字机内码负责计算机怎么存字形码在显示阶段被字库系统自动调用外码呢从头到尾没出场因为数据是批量导入的没人从键盘敲。这个任务让我更清楚地看到五个编码虽然总被放在一起讲但它们其实分布在不同的环节不是每处理一个汉字都要把五个都走一遍。搞清楚每个编码的适用场景比死记它们之间的关系更重要。数据从哪个环节进就从哪个环节开始换算要显示出来就交给字库去处理字形用户要输入才需要外码登场。知道什么时候用哪个才是真正把这一套东西学会了。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →