第四节 点阵字体原理:存储结构与读取方法
点阵字体,说白了就是把每个字符画在一个网格里。有笔画的格子填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 从字库中读取点阵数据
读取点阵数据,本质上就是根据字符编码计算偏移,然后从字库文件中读取固定长度的字节。
我给大家画个流程图,一看就明白:
嗯,流程清楚了,咱们直接看代码。下面是一个从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,显示效果会好很多。