裸机使用 LVGL 字库实现 LCD 显示方案

在 AT32F421 等无 OS 单片机上不跑完整 LVGL,只提取其字库数据和渲染器实现高质量 4bpp 抗锯齿文字显示

前言

做仪表类产品时,经常会遇到这样的需求:

  • 屏幕不大,不需要复杂 UI、动画、触控
  • 只要显示几个数字、标签、单位,字要好看(抗锯齿)
  • 单片机资源有限,跑不动完整 LVGL

完整的 LVGL 虽然功能强大,但在一颗小资源 MCU 上可能要占 100KB+ ROM,还要引入内存管理、事件循环等一整套机制。

有没有办法只拿 LVGL 的字库,自己画字

有。本文介绍一种在 AT32F421G8U7(Cortex-M4、无 OS)上,不依赖 LVGL 框架,只提取其字库数据和轻量渲染器实现 LCD 显示的方案。


一、方案对比

方案 ROM 占用 依赖 适用场景
完整 LVGL ~100KB+ lvgl.h、内存管理、OS 可选 复杂 UI、动画、触控
本方案 lv_font_render ~13KB lcd.h 基础驱动 仪表盘等固定布局

本方案的核心思路:用 GUI Guider 生成漂亮的 LVGL 字库,但文字渲染用自己写的轻量代码。


二、文件结构

1
2
3
4
5
6
7
8
9
drivers/lcd/
├── lcd.h / lcd.c              # LCD 底层驱动 (SPI, 初始化, LCD_Fill 等)
├── lcd_draw.c                 # 基础绘图 + 旧式 ASCII 字库 (lcdfont.h)
├── lcdfont.h                  # 点阵字库 (1206/1608/2412/3216)
├── lv_font_render.h           # LVGL 字体渲染器头文件
├── lv_font_render.c           # LVGL 字体渲染器实现
├── lv_customer_font_*.c       # LVGL 字库数据文件 (GUI Guider 生成)
├── ui_screen.h / ui_screen.c  # UI 布局驱动
└── setup_scr_screen.c         # 参考: GUI Guider 的 LVGL 布局设计文件

三、核心原理

3.1 LVGL 字库文件格式

GUI Guider 生成的字库文件(如 lv_customer_font_Swis721BlkcnBtBlack_65.c)主要包含四部分:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 1. 字形位图 (4bpp 抗锯齿, 每像素 4bit 透明度)
static const uint8_t glyph_bitmap[] = { ... };

// 2. 字形描述符
static const lv_font_fmt_txt_glyph_dsc_t glyph_dsc[] = {
    {.bitmap_index = 0, .adv_w = 308, .box_w = 21, .box_h = 22, .ofs_x = -1, .ofs_y = 0},
    ...
};

// 3. 字符映射表 (unicode → glyph_id)
static const lv_font_fmt_txt_cmap_t cmaps[] = {
    {
        .range_start = 46, .range_length = 12,
        .glyph_id_start = 1,
        .unicode_list = NULL,
        .glyph_id_ofs_list = glyph_id_ofs_list_0,
        .list_length = 12,
        .type = LV_FONT_FMT_TXT_CMAP_FORMAT0_FULL
    }
};

// 4. 字体总描述
const lv_font_t lv_customer_font_Swis721BlkcnBtBlack_65 = {
    .get_glyph_dsc = lv_font_get_glyph_dsc_fmt_txt,
    .get_glyph_bitmap = lv_font_get_bitmap_fmt_txt,
    .line_height = 65,
    .base_line = 11,
    .dsc = &font_dsc
};

3.2 关键数据结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
typedef struct {
    uint32_t bitmap_index;  // 在 glyph_bitmap[] 中的起始偏移
    uint32_t adv_w;         // 字符前进量 (1/16 像素单位)
    uint8_t  box_w, box_h;  // 字形包围盒宽高
    int8_t   ofs_x, ofs_y;  // 相对基线的偏移
} lv_font_fmt_txt_glyph_dsc_t;

typedef struct {
    uint16_t range_start;       // 起始 unicode
    uint16_t range_length;      // 范围长度
    uint16_t glyph_id_start;    // glyph 起始 ID
    const uint16_t *unicode_list;
    const uint8_t *glyph_id_ofs_list;
    uint32_t list_length;
    uint8_t  type;              // 映射类型
} lv_font_fmt_txt_cmap_t;

typedef struct _lv_font_t {
    uint16_t line_height;   // 行高 (像素)
    uint16_t base_line;     // 基线距顶部距离 (像素)
    const void *dsc;
} lv_font_t;

3.3 cmap 映射类型

字库文件使用 3 种字符映射方式,渲染器必须全部支持:

类型 说明 查找方式
FORMAT0_TINY 连续范围,一一对应 glyph_id = start + (letter - range_start)
FORMAT0_FULL 连续范围,偏移表 glyph_id = start + ofs_list[letter - range_start]
SPARSE_TINY 稀疏列表 unicode_list[j] == (letter - range_start)

关键坑SPARSE_TINYunicode_list 存的是相对于 range_start 的偏移,不是 unicode 值本身。


四、渲染器实现

4.1 字形查找

1
2
3
4
5
6
7
const lv_font_fmt_txt_glyph_dsc_t *lv_font_get_glyph_dsc_fmt_txt(
    const lv_font_t *font, void *cache, uint32_t letter, uint32_t letter_next)
{
    // 1. 遍历 cmap 表
    // 2. 根据 type 用不同方式查找 glyph_id
    // 3. 返回 &glyph_dsc[gid]
}

4.2 坐标计算

LVGL 官方的字形左上角坐标公式:

1
2
py = y + (line_height - base_line) - box_h - ofs_y
px = x + ofs_x
  • y:文字行顶部坐标
  • line_height:字体行高
  • base_line:基线距顶部距离
  • box_h:字形高度
  • ofs_y:字形相对基线的偏移(可负)

注意:计算结果可能为负值(字形超出屏幕上方),必须用 int32_t 并做裁剪:

1
2
int32_t py_s = (int32_t)y + (font->line_height - font->base_line) - box_h - ofs_y;
if (py_s < 0) { row_start = -py_s; h += py_s; py_s = 0; }

4.3 两种渲染模式

模式 A:非透明(bg != 0xFFFF)— 批量写,快

用于动态数值刷新,一次设窗口,整行像素写入行缓冲后 DMA 批量发送:

1
2
3
4
5
6
7
8
9
LCD_Address_Set(px, py, px + w - 1, py + h - 1);
for (row ...) {
    for (col ...) {
        // 取 4bpp alpha,与前景色/背景色混合
        s_line_buf[col] = blend_table[alpha];  // alpha 0..15
    }
    // 整行一次性 DMA 发送,CS 保持低电平
    LCD_SPI_SendBytes_DMA(s_line_buf, w * 2);
}

优化点:同一段文字前景/背景色固定,可预先计算 blend_table[16],避免逐像素做乘除法。

模式 B:透明(bg == 0xFFFF)— 逐像素,慢但安全

用于彩色背景上的标签文字,只画前景像素,不碰背景:

1
2
3
4
5
6
for (row ...) {
    for (col ...) {
        if (alpha > 0)
            LCD_DrawPoint(px + col, py + row, color);
    }
}

⚠️ 注意:透明模式下 alpha 混合目标默认是黑色,会导致 4bpp 抗锯齿字体边缘出现灰色光晕。彩色背景应使用模式 A 并传入真实背景色。


五、UI 驱动设计

5.1 布局参考

布局完全参照 GUI Guider 生成的 setup_scr_screen.c

1
2
3
4
5
6
7
8
 y=9    电压数值    172x57   Swis721_65   白色  居中
 y=72   V标签       172x36   SourceHan_30  黑字  青底
 y=111  电流数值    172x57   Swis721_65   白色  居中
 y=171  A标签       172x36   SourceHan_30  黑字  绿底
 y=207  功率数值    125x45   Swis721_50   白色  左对齐
 y=210  功率单位w   34x45    Swis721_50   白色
 y=255  Total Power 172x36   SourceHan_27  黑字  橙底
 y=296  计时器      172x19   Swis721_20   白色  居中

5.2 初始化

1
2
3
4
5
6
7
8
9
void UI_Init(void)
{
    LCD_Fill(0, 0, UI_W - 1, UI_H - 1, UI_BLACK);  // 清屏

    // 标签: LCD_Fill 铺色底 + 传入真实背景色画字
    LCD_Fill(0, 65, 171, 99, UI_CYAN);
    LV_Font_DrawStringCentered(0, UI_W, 72, &font_30, "V     voltage", UI_BLACK, UI_CYAN);
    // ...
}

5.3 动态刷新与防闪

1
2
3
4
5
6
7
void UI_UpdateVoltage(const char *text)
{
    if (strcmp(text, prev_v) == 0) return;  // 数值不变不重画
    strcpy(prev_v, text);
    // bg=UI_BLACK → 非透明批量写路径,不闪
    LV_Font_DrawStringCentered(0, UI_W, 9, &font_65, text, UI_WHITE, UI_BLACK);
}

防闪策略

  • strcmp 比较,数值不变不重画
  • 静态标签用真实背景色批量画
  • 动态数值用非透明模式,一次写完整个区域

六、字库文件适配

6.1 修改 include

GUI Guider 生成的字库文件默认 #include "lvgl.h",需改为:

1
2
// #include "lvgl.h"          // 删除
#include "lv_font_render.h"   // 替换为独立渲染器头文件

6.2 开启 C99

字库文件使用 designated initializer 语法(.member = value),Keil 需在工程配置中开启 C99:

1
<uC99>1</uC99>

6.3 字体对应关系示例

GUI Guider 字体 文件 用途
Swis721BlkcnBtBlack_65 lv_customer_font_Swis721BlkcnBtBlack_65.c 电压/电流数值
Swis721BlkcnBtBlack_50 lv_customer_font_Swis721BlkcnBtBlack_50.c 功率数值/单位
Swis721BlkcnBtBlack_20 lv_customer_font_Swis721BlkcnBtBlack_20.c 计时器
SourceHanSansSCBold_30 lv_customer_font_SourceHanSansSCBold_30.c V/A 标签
SourceHanSansSCBold_27 lv_customer_font_SourceHanSansSCBold_27.c Total Power

七、常见问题

Q:字体完全不显示

检查 cmap 类型是否都支持:

1
grep "type = " lv_customer_font_*.c

确保渲染器处理了所有 FORMAT0_TINY / FORMAT0_FULL / SPARSE_TINY 类型。

Q:字形位置偏移 / 显示在屏幕外

检查坐标公式:

1
2
py = y + (line_height - base_line) - box_h - ofs_y;  // 正确
// 不是: py = y + base_line - ofs_y - box_h         // 错误

py 必须用 int32_t 计算,负值做裁剪。

Q:刷新时频闪

  • 静态标签:LCD_Fill 铺底色 + 传入真实背景色画字,不要用 0xFFFF
  • 动态数值:用非透明模式,走批量写路径
  • strcmp 防抖

Q:字体边缘灰色光晕

这是透明模式(bg == 0xFFFF)下 alpha 与黑色混合导致的。彩色背景必须传入真实背景色:

1
2
3
4
5
6
7
// ❌ 错误:透明模式,alpha 与黑色混合
LCD_Fill(0, 65, 171, 99, UI_CYAN);
LV_Font_DrawStringCentered(..., "voltage", UI_BLACK, 0xFFFF);

// ✅ 正确:传入真实背景色
LCD_Fill(0, 65, 171, 99, UI_CYAN);
LV_Font_DrawStringCentered(..., "voltage", UI_BLACK, UI_CYAN);

Q:数字残影(如 “0” → “1”)

LV_Font_DrawStringCentered 非透明模式下会一次性写完整个区域(包括左右黑边),无需额外 LCD_Fill


八、main.c 调用示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include "ui_screen.h"

int main(void)
{
    // ... 硬件初始化 ...

    LCD_Init();
    UI_Init();              // 一次性绘制全部静态元素

    while (1) {
        fmt_v(vb, ina.voltage);
        UI_UpdateVoltage(vb);   // 数值不变不重画

        fmt_i(ib, ina.current);
        UI_UpdateCurrent(ib);

        fmt_p(pb, ina.power);
        UI_UpdatePower(pb);

        ERTC_GetTimeStr(tb);
        UI_UpdateTimer(tb);

        wk_delay_ms(100);
    }
}

九、移植到新项目

  1. 复制 lv_font_render.h/c 和所需的 lv_customer_font_*.c
  2. 将字库文件的 #include "lvgl.h" 改为 #include "lv_font_render.h"
  3. Keil 开启 C99
  4. 确保 lcd.h 提供 LCD_Address_SetLCD_WR_DATALCD_DrawPointLCD_FillLCD_SPI_SendBytes_DMA
  5. 确保 LCD 驱动已配置为 16-bit SPI + DMA 批量传输
  6. 参照 setup_scr_screen.c 的布局编写 ui_screen.c

总结

这个方案最大的亮点是按需取用:不需要完整 LVGL 的运行时,只需要它的字库数据格式。配合自研的轻量渲染器,在小资源 MCU 上也能实现高质量抗锯齿文字显示。

如果你的项目满足以下条件,这个方案很合适:

  • 界面布局固定,不需要动画和触控
  • 对 ROM 占用敏感
  • 需要漂亮的矢量字体效果
  • 已有稳定的 LCD 驱动基础

硬件:AT32F421G8U7 (Cortex-M4, 无 OS) | 工具:GUI Guider + Keil AC6

世界是你们
使用 Hugo 构建
主题 StackJimmy 设计