4、字符编码与字库处理:GB2312/GBK编码转换、Unicode转码、内置字库调用
做打印机驱动,最头疼的问题是什么?
我个人觉得,就是字符编码。你想想看,上位机发过来一串数据,打印机吐出来一堆乱码。用户第一反应就是打印机坏了。其实呢,多半是编码没处理好。
我在做第一代热敏打印机驱动时,就栽过这个跟头。当时客户反馈说打出来的小票全是「口口口」,排查了三天,最后发现是GBK和GB2312的映射表少了一个字节。嗯,从那以后,我对编码转换就格外小心了。
4.1 为什么POS机离不开GB2312/GBK?
POS机主要面向国内市场。国内的中文系统,底层字库基本都是GB2312或GBK编码。GB2312收录了6763个汉字,覆盖了日常使用的绝大部分。GBK是它的扩展版,增加了生僻字和繁体字,总共两万多个。
说白了,你的打印机要显示中文,就得跟这两种编码打交道。Unicode虽然通用,但很多低成本打印机芯片,根本不带Unicode字库。所以你得自己做转码。
核心要点:
- GB2312:双字节编码,每个汉字占2字节。范围从0xA1A1到0xFEFE。
- GBK:也是双字节,但第一字节从0x81开始,范围更宽。
- 两者不兼容!GBK能向下兼容GB2312,但反过来不行。
4.2 编码转换实战:从GB2312到Unicode
我习惯的做法是,在驱动层维护一张转换表。这张表把GB2312的码点映射到Unicode。为什么这么做?因为打印机的内置字库,很多是基于Unicode索引的。
举个例子:
// GB2312 转 Unicode 查找表(片段)
const uint16_t gb2312_to_unicode[] = {
// 0xA1A1 -> 0x3000 (全角空格)
// 0xA1A2 -> 0x3001 (、)
// 0xB0A1 -> 0x554A (啊)
// ...
};
uint16_t gb2312_to_unicode_converter(uint16_t gb_code) {
// 检查是否在有效范围内
if (gb_code < 0xA1A1 || gb_code > 0xFEFE) {
return 0xFFFD; // 替换字符
}
uint16_t index = gb_code - 0xA1A1;
return gb2312_to_unicode[index];
}
我的经验:这张表不要自己手写。去网上找现成的映射表文件,或者用工具生成。我当年手写过一个,结果漏了十几个字符,调试了整整两天。后来学乖了,直接用标准库生成的表。
4.3 Unicode转码:双向转换的坑
Unicode转GBK,比GB2312转Unicode要复杂一些。因为GBK的编码空间不是连续的。有些Unicode字符,在GBK里找不到对应的码点。
我曾经遇到过一个场景:客户的小票里包含了一个「𠀀」字(U+20000,属于CJK扩展B区)。这个字在GBK里根本没有。打印机直接罢工了。
解决方案是这样的:
// Unicode 转 GBK
uint16_t unicode_to_gbk(uint16_t unicode) {
// 先查基本映射表
uint16_t gbk = lookup_unicode_to_gbk(unicode);
if (gbk != 0xFFFF) {
return gbk;
}
// 如果找不到,尝试查扩展映射表
gbk = lookup_unicode_to_gbk_ext(unicode);
if (gbk != 0xFFFF) {
return gbk;
}
// 实在找不到,返回一个替代字符
// 我习惯用全角问号(0xA1BF)
return 0xA1BF;
}
注意:千万不要直接返回0x00或者跳过这个字符。那样会导致后续的字符错位。我曾经见过一个驱动,遇到不识别的字符就跳过,结果后面的汉字全部乱码了。因为GBK是双字节编码,跳过一个字节,整个解析就全乱了。
4.4 内置字库调用:直接操作还是走API?
打印机的内置字库,通常以两种方式存在:
- 点阵字库:每个字符对应一个固定大小的位图。比如16x16点阵,一个字占32字节。
- 矢量字库:用曲线描述字形,需要渲染引擎。成本高,但效果好。
对于POS机打印机,99%用的是点阵字库。便宜、速度快、实现简单。
调用内置字库,我一般分三步走:
// 1. 获取字符的码点
uint16_t char_code = get_char_code_from_buffer(buffer, &pos);
// 2. 将码点转换为字库索引
uint16_t font_index = code_to_font_index(char_code);
// 3. 从字库中读取点阵数据
uint8_t font_data[32]; // 16x16点阵
read_font_data(font_index, font_data);
// 4. 将点阵数据发送到打印机
send_to_printer(font_data, 32);
关键点:第二步的「码点到字库索引」转换,是性能瓶颈。我建议用二分查找或者哈希表。不要用线性查找,尤其是字库大的时候。我见过一个驱动,字库有8000多个字符,线性查找一次要几毫秒,打印一行字卡得不行。
4.5 避坑指南:我踩过的三个坑
坑一:半角全角混排
ASCII字符是单字节,中文是双字节。有些驱动在处理混合文本时,会把ASCII当成中文的一部分来解析。结果就是「A好」变成了「䀀」之类的乱码。
我的做法是:在解析时,先判断当前字节是否在ASCII范围内(0x00-0x7F)。如果是,按单字节处理;否则,按双字节处理。
坑二:字库对齐问题
有些打印机的字库,要求数据按4字节对齐。如果你直接memcpy一个32字节的点阵数据,可能会触发硬件异常。我习惯在读取字库时,用专门的读取函数,内部处理对齐。
坑三:编码声明不一致
上位机说自己是GB2312,实际发的是GBK数据。这种情况很常见。我建议在驱动层做一个编码自动检测。检测方法很简单:如果连续出现两个字节都在0x81-0xFE范围内,就按GBK处理;否则按GB2312。
一个小技巧:在调试阶段,打印出每个字符的原始码点。这样能快速定位是编码问题还是字库问题。我一般会在驱动里加一个调试模式,通过串口输出码点信息。上线前再关掉。
4.6 性能优化:批量转换与缓存
打印一行字,可能要转换几十个字符。如果每个字符都查一次表,效率很低。我建议做批量转换:
// 批量转换:将缓冲区中的GBK数据一次性转为Unicode
void batch_convert_gbk_to_unicode(uint8_t *gbk_buf, uint16_t *unicode_buf, int len) {
int i = 0, j = 0;
while (i < len) {
if (gbk_buf[i] < 0x80) {
// ASCII字符,直接复制
unicode_buf[j++] = gbk_buf[i++];
} else {
// 中文字符,查表转换
uint16_t gbk_code = (gbk_buf[i] << 8) | gbk_buf[i+1];
unicode_buf[j++] = gbk_to_unicode_table[gbk_code - 0x8100];
i += 2;
}
}
}
另外,常用的字符可以缓存起来。比如数字、字母、标点符号。这些字符在每张小票里都会出现。缓存命中率很高。
我做过一个测试:加了缓存之后,打印速度提升了30%左右。对于高频打印的场景,这个优化很值得做。
4.7 总结一下
字符编码和字库处理,说白了就是三件事:
- 知道数据是什么编码(GB2312还是GBK)
- 能把它转成字库认识的格式(Unicode或直接索引)
- 从字库里把点阵数据读出来,发给打印机
每一步都有坑。但只要你把映射表做对、解析逻辑写严谨、再加上缓存优化,基本就不会出大问题。
嗯,下一章我会讲打印指令集的封装。到时候再聊聊怎么把这些编码处理逻辑,封装成好用的API。