前言
做仪表类产品时,经常会遇到这样的需求:
- 屏幕不大,不需要复杂 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_TINY 的 unicode_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:
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);
}
}
|
九、移植到新项目
- 复制
lv_font_render.h/c 和所需的 lv_customer_font_*.c
- 将字库文件的
#include "lvgl.h" 改为 #include "lv_font_render.h"
- Keil 开启 C99
- 确保
lcd.h 提供 LCD_Address_Set、LCD_WR_DATA、LCD_DrawPoint、LCD_Fill、LCD_SPI_SendBytes_DMA
- 确保 LCD 驱动已配置为 16-bit SPI + DMA 批量传输
- 参照
setup_scr_screen.c 的布局编写 ui_screen.c
总结
这个方案最大的亮点是按需取用:不需要完整 LVGL 的运行时,只需要它的字库数据格式。配合自研的轻量渲染器,在小资源 MCU 上也能实现高质量抗锯齿文字显示。
如果你的项目满足以下条件,这个方案很合适:
- 界面布局固定,不需要动画和触控
- 对 ROM 占用敏感
- 需要漂亮的矢量字体效果
- 已有稳定的 LCD 驱动基础
硬件:AT32F421G8U7 (Cortex-M4, 无 OS) | 工具:GUI Guider + Keil AC6