4. 点阵字体显示:单色点阵字体的显示算法、8x8/16x16点阵的C语言实现、字模数据在内存中的排列方式

点阵字体,说白了就是用一个网格来画字。每个格子要么亮(1),要么灭(0)。你想想看,LED广告牌、老式手机屏幕、电子价签,这些资源受限的设备上,点阵字体依然是主力。

我刚开始做嵌入式GUI时,总觉得用字库芯片或者矢量字体才够专业。后来做一个温控器项目,MCU只有2KB RAM,Flash也紧巴巴的。嗯,最后还是老老实实回到点阵字体。效果其实不差,关键是心里有底——我知道每个像素是怎么来的。

4.1 单色点阵字体的显示原理

单色点阵的核心就一句话:用二进制位控制像素的亮灭。

一个8x8的点阵,就是64个像素点。在内存里,我们用8个字节来表示。每个字节的8个bit,对应一列(或一行)的8个像素。

关键公式:

点阵大小 = 宽度(像素) × 高度(像素) / 8 (字节)

例如:16x16点阵 = 16 × 16 / 8 = 32字节

显示算法其实不复杂。逐行扫描,判断每个bit是1还是0。是1就画点,是0就跳过。我在一个电梯楼层显示项目里,就是用这个逻辑驱动128x64的OLED屏,刷新率能做到60fps。

4.2 字模数据在内存中的排列方式

这里有个坑,我当年栽过。字模的排列方式,不同厂家的工具生成的结果可能完全不同。

常见的排列方式有两种:

排列方式 说明 常见场景
列行式(Column-Major) 按列存储,每列一个字节,从上到下 大多数取模软件默认方式
行列式(Row-Major) 按行存储,每行一个字节,从左到右 某些LCD控制器硬件要求

我习惯用列行式。为什么?因为很多点阵屏的驱动芯片,数据写入顺序就是按列来的。你按行列式存,显示的时候还得转置,多一道工序就多一个bug源。

举个例子,一个8x8点阵的字母'A',列行式存储可能是这样的:

// 8x8 点阵 'A' - 列行式排列
// 每列一个字节,从上到下
const uint8_t font_A[8] = {
    0x00,  // 第0列:全灭
    0x18,  // 第1列:00011000
    0x24,  // 第2列:00100100
    0x42,  // 第3列:01000010
    0x7E,  // 第4列:01111110
    0x42,  // 第5列:01000010
    0x42,  // 第6列:01000010
    0x00   // 第7列:全灭
};

你看,第4列是0x7E,也就是中间那一横。这就是'A'的骨架。

我曾经踩过的坑:用某款取模软件生成16x16中文字模,默认是行列式。我直接烧进去,显示出来的字是躺倒的。后来排查了半天,才发现是排列方式不匹配。从那以后,我每次拿到字模数据,第一件事就是确认排列方式。

4.3 8x8点阵的C语言实现

8x8点阵适合显示ASCII字符。实现起来很直接。

先定义一个字体表:

// 8x8 ASCII字体表(部分)
// 列行式排列
const uint8_t ascii_font[95][8] = {
    // 空格 ' ' (0x20)
    {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
    // '!' (0x21)
    {0x18, 0x18, 0x18, 0x18, 0x00, 0x00, 0x18, 0x00},
    // '"' (0x22)
    {0x24, 0x24, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
    // ... 中间省略 ...
    // 'A' (0x41)
    {0x00, 0x18, 0x24, 0x42, 0x7E, 0x42, 0x42, 0x00},
    // ... 后续字符 ...
};

显示函数的核心逻辑:

/**
 * @brief 在指定位置显示一个8x8字符
 * @param x, y  屏幕坐标(左上角)
 * @param ch    要显示的ASCII字符
 */
void draw_char_8x8(uint16_t x, uint16_t y, char ch) {
    uint8_t idx = ch - 0x20;  // 字符在字体表中的索引
    if (idx > 94) return;     // 超出范围
    
    for (uint8_t col = 0; col < 8; col++) {
        uint8_t data = ascii_font[idx][col];
        for (uint8_t row = 0; row < 8; row++) {
            if (data & (0x80 >> row)) {
                // 该像素点亮
                set_pixel(x + col, y + row, 1);
            } else {
                // 该像素熄灭
                set_pixel(x + col, y + row, 0);
            }
        }
    }
}

这里有个细节:0x80 >> row 是从高位到低位扫描。因为字模数据的高bit对应点阵的上方。你要是搞反了,字就上下颠倒了。

4.4 16x16点阵的C语言实现

16x16点阵主要用来显示中文。一个汉字需要32个字节。

16x16的显示逻辑和8x8类似,但要注意:每个字节只表示8个像素,所以一列需要2个字节。

/**
 * @brief 在指定位置显示一个16x16汉字
 * @param x, y  屏幕坐标(左上角)
 * @param font  字模数据指针(32字节)
 */
void draw_chinese_16x16(uint16_t x, uint16_t y, const uint8_t *font) {
    for (uint8_t col = 0; col < 16; col++) {
        // 每列需要2个字节:上半部分和下半部分
        uint8_t data_high = font[col * 2];      // 上半部分
        uint8_t data_low  = font[col * 2 + 1];  // 下半部分
        
        // 绘制上半部分(第0-7行)
        for (uint8_t row = 0; row < 8; row++) {
            if (data_high & (0x80 >> row)) {
                set_pixel(x + col, y + row, 1);
            }
        }
        
        // 绘制下半部分(第8-15行)
        for (uint8_t row = 0; row < 8; row++) {
            if (data_low & (0x80 >> row)) {
                set_pixel(x + col, y + 8 + row, 1);
            }
        }
    }
}

我个人的优化习惯:如果MCU性能允许,我会把内层循环展开。8次循环的展开,在72MHz的Cortex-M3上能省下大约2μs。别小看这点时间,显示一整屏汉字时,差距就出来了。

4.5 点阵字体的显示算法流程

我把整个流程画成了一张图,方便你理解数据是怎么从字模变成屏幕上的像素的。

点阵字体显示算法流程 字模数据(二进制) 解析排列方式(列/行) 逐列/行扫描字节 逐位判断(1亮/0灭) 绘制像素到屏幕 关键参数 • 8x8点阵 = 8字节 • 16x16点阵 = 32字节 • 排列方式决定解析顺序 常见问题 • 字模颠倒:bit顺序反了 • 字躺倒:行列方式不匹配 • 显示不全:索引计算错误

4.6 性能优化与避坑

实际项目中,点阵显示的性能瓶颈往往不在计算,而在像素写入速度。我做过一个测试:

操作 耗时(72MHz Cortex-M3) 说明
逐像素写入(8x8) ~12μs 每次调用set_pixel
整列写入(8x8) ~3μs 一次写入8个像素
DMA批量写入(16x16) ~8μs 整字模一次性传输

你看,整列写入比逐像素快了4倍。如果你的屏幕驱动支持列写入模式,一定要用。

我曾经遇到的一个诡异问题:在ST7789驱动的屏幕上显示16x16汉字,边缘总有毛刺。排查到最后,发现是字模数据里包含了非预期的bit。有些取模软件会在字节末尾补0,但补的位置不对。解决方案很简单:显示前做一次掩码操作,只取有效的8个bit。

4.7 字模数据的生成与管理

实际项目中,字模数据通常用工具生成。我个人推荐两种方式:

  • PC端取模软件:如PCtoLCD2002、FontCreator。设置好点阵大小和排列方式,一键导出C数组。
  • Python脚本生成:用PIL库读取系统字体,渲染成点阵。适合需要动态生成字模的场景。

不管用哪种方式,一定要在代码里加上注释,标明排列方式。我见过太多项目,换了一个人维护,字模排列方式搞混了,整个显示系统崩掉。

// 字模数据示例 - 16x16汉字"中"
// 排列方式:列行式,高位在上
// 生成工具:PCtoLCD2002 v2.0
const uint8_t font_zhong[32] = {
    0x00, 0x00,  // 第0列
    0x00, 0x00,  // 第1列
    0x00, 0x00,  // 第2列
    0x7F, 0xFC,  // 第3列:11111111 11111100
    0x08, 0x04,  // 第4列
    0x08, 0x04,  // 第5列
    0x08, 0x04,  // 第6列
    0x7F, 0xFC,  // 第7列
    0x08, 0x04,  // 第8列
    0x08, 0x04,  // 第9列
    0x08, 0x04,  // 第10列
    0x7F, 0xFC,  // 第11列
    0x00, 0x00,  // 第12列
    0x00, 0x00,  // 第13列
    0x00, 0x00,  // 第14列
    0x00, 0x00   // 第15列
};

嗯,看到第3列和第7列的0x7F、0xFC了吗?那就是"中"字的那一竖和一横。点阵字体就是这么直观——你看到数据,就能在脑子里画出字形。

做嵌入式GUI,点阵字体是基本功。别看现在各种高级字体渲染库满天飞,真正到了资源受限的场合,点阵字体依然是那个最可靠的选择。把这一章的内容吃透,后面讲抗锯齿、灰度渲染的时候,你才能理解那些优化到底在优化什么。