第四节 点阵字体原理:存储结构与读取方法

点阵字体,说白了就是把每个字符画在一个网格里。有笔画的格子填1,没笔画的格子填0。就这么简单。

我刚开始接触嵌入式显示时,总觉得点阵字体很神秘。后来自己动手解析过一次字库文件,才发现背后的逻辑其实非常直白。今天咱们就把这层窗户纸捅破。

4.1 点阵字体的存储结构

点阵字体的核心思想是:用二维矩阵表示字符形状。每个字符对应一个固定大小的网格,比如16×16、12×12、8×8等。

以最常见的16×16点阵为例:

  • 横向16个点,纵向16个点
  • 总共256个点,每个点用1位表示(0或1)
  • 所以一个字符需要 256 ÷ 8 = 32 字节

关键公式:字符点阵大小 = (宽度 × 高度) ÷ 8 字节

例如:16×16 = 32字节,8×8 = 8字节,24×24 = 72字节

存储顺序有两种主流方式:

存储方式 说明 常见场景
逐行存储 从上到下,每行从左到右排列字节 大多数英文字库、HZK系列中文字库
逐列存储 从左到右,每列从上到下排列字节 部分日文字库、某些LCD驱动内置字库

我个人习惯用逐行存储,因为和屏幕扫描方向一致,处理起来更直观。但如果你遇到显示出来的字符是旋转90度的,那八成是存储顺序搞反了。

4.2 中文字库的特殊性

中文和英文不一样。英文字母只有几十个,但常用汉字有几千个。所以中文字库不能像英文字库那样按ASCII码顺序排列,而是要用区位码或Unicode来索引。

我记得第一次用HZK16字库时,直接按字符编码去读,结果读出来的全是乱码。后来才搞清楚,HZK16用的是区位码,需要先把GB2312编码转换成区位码才能正确索引。

转换公式其实不复杂:

// GB2312编码转区位码
// 输入:gb_code - GB2312编码的高字节和低字节
// 输出:区码和位码

uint8_t area = gb_code[0] - 0xA0;  // 区码
uint8_t pos  = gb_code[1] - 0xA0;  // 位码

// 计算在字库中的偏移
uint32_t offset = ((area - 1) * 94 + (pos - 1)) * bytes_per_char;

注意:GB2312编码的汉字范围是0xA1A1到0xFEFE,区码和位码都是从1开始的。计算偏移时一定要减1,否则会读到前一个字符的数据。

4.3 从字库中读取点阵数据

读取点阵数据,本质上就是根据字符编码计算偏移,然后从字库文件中读取固定长度的字节。

我给大家画个流程图,一看就明白:

点阵字符读取流程 输入字符编码 判断编码类型 ASCII GB2312 直接使用ASCII码 转换为区位码 计算文件偏移 读取点阵数据 得到点阵数据 偏移 = (区码-1)×94 + (位码-1) 再乘以每字符字节数

嗯,流程清楚了,咱们直接看代码。下面是一个从HZK16字库中读取16×16点阵数据的示例:

#include <stdio.h>
#include <stdint.h>

#define FONT_WIDTH  16
#define FONT_HEIGHT 16
#define BYTES_PER_CHAR (FONT_WIDTH * FONT_HEIGHT / 8)

/**
 * 从HZK16字库中读取一个汉字的点阵数据
 * @param font_file  字库文件路径
 * @param gb_code    GB2312编码(2字节)
 * @param buffer     输出缓冲区,至少BYTES_PER_CHAR字节
 * @return 0成功,-1失败
 */
int read_hzk16_char(const char *font_file, uint8_t *gb_code, uint8_t *buffer) {
    FILE *fp = fopen(font_file, "rb");
    if (!fp) {
        printf("无法打开字库文件: %s\n", font_file);
        return -1;
    }

    // 计算区位码
    uint8_t area = gb_code[0] - 0xA0;
    uint8_t pos  = gb_code[1] - 0xA0;

    // 计算偏移:每个区94个位,每个位32字节
    uint32_t offset = ((area - 1) * 94 + (pos - 1)) * BYTES_PER_CHAR;

    // 定位并读取
    fseek(fp, offset, SEEK_SET);
    size_t read_size = fread(buffer, 1, BYTES_PER_CHAR, fp);
    fclose(fp);

    if (read_size != BYTES_PER_CHAR) {
        printf("读取失败,期望%d字节,实际读取%zu字节\n", 
               BYTES_PER_CHAR, read_size);
        return -1;
    }

    return 0;
}

小技巧:如果你手头没有HZK16字库文件,可以用Linux的mkfs工具生成一个空文件占位,或者从开源项目里找一个。我个人习惯在项目仓库里放一个精简版字库,只包含用到的字符,能省不少Flash空间。

4.4 点阵数据的解析与显示

读出来的点阵数据是一串字节,怎么把它变成屏幕上的像素呢?

说白了就是逐位检查。每个字节的8个位,对应一行中的8个像素点。如果是1就点亮,是0就熄灭。

看个例子,假设我们读到了16×16点阵的前两个字节:

// 假设 buffer[0] = 0x10, buffer[1] = 0x08
// 二进制:00010000 00001000
// 对应第一行:第4列和第11列有笔画

// 解析并显示到单色屏
void display_char_bitmap(uint8_t *buffer, int width, int height) {
    int bytes_per_row = width / 8;

    for (int row = 0; row < height; row++) {
        for (int col = 0; col < width; col++) {
            int byte_index = row * bytes_per_row + col / 8;
            int bit_index  = 7 - (col % 8);  // 高位在前

            if (buffer[byte_index] & (1 << bit_index)) {
                // 画一个亮点(像素置1)
                set_pixel(col, row, 1);
            } else {
                // 画一个暗点(像素置0)
                set_pixel(col, row, 0);
            }
        }
    }
}

这里有个细节要注意:字节内的位顺序。大部分字库是高位在前(MSB first),也就是字节的最高位对应最左边的像素。但有些字库是低位在前,如果你发现显示出来的字符是镜像的,八成是这个原因。

4.5 避坑指南

做点阵字体驱动这些年,我踩过不少坑。挑几个典型的说说:

  • 字库文件太大? 我曾经在一个Flash只有256KB的MCU上跑全字库,结果HZK16一个文件就占266KB。后来改用分段加载,只把当前界面用到的字符读到RAM里,其他字符留在Flash上按需加载。
  • 显示速度慢? 如果每次显示都去读Flash,速度肯定上不去。我的做法是加一层缓存,把最近用过的几十个字符的点阵数据缓存到RAM里,命中率能到80%以上。
  • 字符显示不全? 检查一下字库的起始编码。有些字库从0x20开始,有些从0x00开始。我遇到过最坑的一次,字库文件前256字节是空的,所有字符的偏移都要加256才能正确读取。

重要提醒:字库文件通常比较大,在嵌入式设备上建议使用只读存储(如Flash、SPI Flash)。不要试图把全字库放到RAM里,除非你的RAM非常充裕。另外,注意文件系统的扇区对齐,否则读取效率会很低。

4.6 不同字体的点阵参数对比

最后给个表格,方便大家选型时参考:

字体规格 每字符字节数 适用场景 优缺点
8×8 8 LED点阵屏、简单状态显示 省空间,但笔画细节差
12×12 18 小尺寸LCD、低分辨率屏 折中选择,可显示简单汉字
16×16 32 通用嵌入式显示 最常用,笔画清晰,体积适中
24×24 72 高分辨率屏、大字体显示 美观,但占用空间大
32×32 128 高端UI、大屏幕 精细度高,Flash消耗大

我个人最常用的是16×16点阵。它能在大多数128×64或128×128的屏幕上显示4行8列汉字,信息密度和可读性都还不错。如果你的屏幕分辨率更高,可以考虑24×24,显示效果会好很多。