3. 字库文件解析:HZK16与ASC16结构

字库文件解析,说白了就是搞清楚「点阵数据怎么存、怎么取」。我刚开始做嵌入式GUI时,以为字库就是个二进制文件,直接读出来就能用。结果第一次显示汉字,屏幕上全是乱码——嗯,从那以后我再也不敢跳过字库结构分析了。

这一章,我们就把HZK16和ASC16这两个最常用的字库文件,彻底拆开来看。

3.1 HZK16字库结构

HZK16是16×16点阵的汉字字库。每个汉字用16行×16列的点阵表示,每行16个点正好占2个字节。所以一个汉字占32个字节。

我个人习惯把字库想象成一个「二维表格」:行号对应区码,列号对应位码。GB2312标准把汉字分成94个区,每区94个位。你想想看,94×94=8836,正好覆盖了常用的6763个汉字和符号。

核心公式:

汉字在HZK16中的偏移地址 = ((区码 - 0xA1) × 94 + (位码 - 0xA1)) × 32

其中32是每个汉字的字节数,94是每区的汉字数。

我在项目中遇到过一个问题:有些字库文件开头有文件头,有些没有。HZK16是纯数据文件,没有文件头,直接从第一个汉字开始存。这一点很容易被忽略。

3.2 ASC16字库结构

ASC16是16×8点阵的ASCII字库。每个字符用16行×8列的点阵表示,每行8个点占1个字节。所以一个ASCII字符占16个字节。

为什么ASCII字库是8列?因为英文字母不需要那么宽。16列的话,字母之间间距太大,显示效果反而不好看。

经验之谈:

ASC16字库通常包含128个ASCII字符(0x00-0x7F),但实际显示时,0x00-0x1F是控制字符,没有图形。我一般从0x20(空格)开始取。

ASCII字符的定位更简单:

偏移地址 = 字符ASCII码 × 16

16是每个ASCII字符的字节数。

3.3 两种字库的对比

对比项 HZK16 ASC16
点阵大小 16×16 16×8
每字符字节数 32 16
编码方式 GB2312(区码+位码) ASCII码
字符总数 8836(含符号) 128
定位公式 ((区-0xA1)×94 + (位-0xA1))×32 ASCII码 × 16
文件头 无 无

3.4 字库文件读取与定位

读取字库文件,本质上就是「打开文件 → 计算偏移 → 读取固定字节数」。我建议用标准C的fopen/fread/fclose,或者用POSIX的open/read/close。在嵌入式环境下,如果字库放在SPI Flash里,那就用Flash驱动接口。

下面是一个完整的读取示例:

// 读取HZK16中的汉字点阵数据
int get_hzk16_bitmap(unsigned char q, unsigned char w, unsigned char *buffer) {
    // q: 区码, w: 位码, buffer: 输出缓冲区(至少32字节)
    FILE *fp;
    long offset;
    int ret;

    fp = fopen("HZK16", "rb");
    if (fp == NULL) {
        return -1;  // 文件打开失败
    }

    // 计算偏移:((区码-0xA1)*94 + (位码-0xA1)) * 32
    offset = ((q - 0xA1) * 94 + (w - 0xA1)) * 32;

    ret = fseek(fp, offset, SEEK_SET);
    if (ret != 0) {
        fclose(fp);
        return -2;  // 定位失败
    }

    ret = fread(buffer, 1, 32, fp);
    if (ret != 32) {
        fclose(fp);
        return -3;  // 读取失败
    }

    fclose(fp);
    return 0;  // 成功
}

// 读取ASC16中的ASCII字符点阵数据
int get_asc16_bitmap(unsigned char ch, unsigned char *buffer) {
    // ch: ASCII字符, buffer: 输出缓冲区(至少16字节)
    FILE *fp;
    long offset;
    int ret;

    fp = fopen("ASC16", "rb");
    if (fp == NULL) {
        return -1;
    }

    // 计算偏移:ASCII码 * 16
    offset = ch * 16;

    ret = fseek(fp, offset, SEEK_SET);
    if (ret != 0) {
        fclose(fp);
        return -2;
    }

    ret = fread(buffer, 1, 16, fp);
    if (ret != 16) {
        fclose(fp);
        return -3;
    }

    fclose(fp);
    return 0;
}

注意:

我曾经在STM32上踩过一个坑:SPI Flash的读取速度很慢,每次显示字符都去读Flash,导致屏幕刷新卡顿。后来我加了一层缓存,把常用字符的点阵数据预读到RAM里,问题就解决了。

3.5 字库定位流程图

下面这张图展示了从字符编码到点阵数据的完整流程:

字库定位流程图 字符编码输入 判断字符类型 汉字 ASCII 提取区码和位码 (q = byte0, w = byte1) 计算偏移地址 ((q-0xA1)*94 + (w-0xA1))*32 直接取ASCII码 (ch = byte0) 计算偏移地址 ch * 16 fseek定位 + fread读取 读取32字节(HZK16)或16字节(ASC16) 点阵数据输出

3.6 避坑指南

我总结几个实际开发中容易踩的坑:

  • 区码位码范围:GB2312的区码范围是0xA1-0xFE,位码也是0xA1-0xFE。如果遇到0xA0以下的编码,那可能是扩展字库或者编码错误。
  • 文件大小校验:HZK16文件大小应该是8836×32=282752字节。如果文件大小不对,说明字库文件可能损坏或者版本不匹配。
  • 多线程安全:如果多个任务同时读取字库,记得加锁。我遇到过两个线程同时fseek,结果定位错乱,显示出来的字全是乱的。
  • 内存对齐:有些MCU要求读取缓冲区4字节对齐。32字节的缓冲区一般没问题,但如果你只读16字节,注意检查对齐。

调试小技巧:

如果你不确定字库文件是否正确,可以写一个简单的测试程序:读取「啊」字(区码0xB0,位码0xA1)的点阵数据,然后逐字节打印出来。正常的「啊」字点阵数据不会是全0或全1,而且前几个字节应该有明显的图案特征。