手把手教你用LVGL在STM32上点亮LCD:从零开始的嵌入式GUI实战
你有没有遇到过这样的场景?项目需要一个带触摸屏的HMI界面,老板说“别搞Linux,成本太高”,同事说“emWin要授权费,TouchGFX又太吃资源”……这时候,LVGL + STM32就成了你的最佳拍档。
这不是一篇堆砌术语的理论文章,而是一份真实可落地的工程笔记。我会带你走完从芯片选型、外设配置到UI渲染的完整流程,让你不仅能跑通Demo,更能理解每一步背后的“为什么”。
为什么是LVGL?不是别的GUI?
先说个现实:很多工程师第一次接触嵌入式图形界面时,往往被复杂的移植过程劝退。emWin功能强但闭源,TouchGFX漂亮却对硬件要求高,Qt for MCUs更是重量级选手。
而LVGL不一样——它开源、免费、文档齐全,社区活跃到连GitHub issue都有中文回复。更重要的是,它的设计哲学非常“嵌入式友好”:
- 模块化架构:你可以只启用按钮和标签,关掉图表和动画;
- 内存可控:最小RAM占用几KB,Flash不到100KB也能跑;
- 跨平台不假大空:真的能在裸机、FreeRTOS甚至Zephyr上无缝切换。
我曾经在一个STM32F407项目中,用不到50KB RAM实现了包含滑动菜单、实时曲线和多语言切换的工业控制面板。这就是LVGL的魅力。
硬件怎么搭?STM32+FSCM+TFT-LCD三剑合璧
芯片选型:别再死磕F1了
虽然STM32F1系列便宜,但它主频低(72MHz)、SRAM小(最多96KB),跑LVGL会很吃力。建议直接上F4系列(如F407VG或F429ZI):
- 主频168MHz,带ART加速,代码执行效率高;
- FSMC接口支持NOR/PSRAM模式,可直接驱动并口屏;
- SRAM有192KB,足够放下LVGL对象池+半帧缓冲。
如果你要做4.3寸以上大屏,推荐F429,它还支持LTDC专用显示控制器,能进一步降低CPU负载。
屏幕怎么接?FSMC才是王道
市面上常见TFT-LCD分三种接口:
| 接口类型 | 速度 | CPU占用 | 适用场景 |
|---|---|---|---|
| SPI | 慢(~10Mbps) | 高(DMA救不了全屏刷新) | 小尺寸圆形表盘 |
| 8080并口(FSMC) | 快(>50MB/s) | 极低(硬件生成时序) | QVGA及以上 |
| RGB接口 | 最快 | 中等 | 大屏+LTDC |
我们要做流畅UI,首选16位8080并口 + FSMC驱动。以ILI9341为例,典型连接如下:
// FSMC Bank1_NORSRAM1, 基址 0x60000000 #define LCD_CS_PIN GPIO_PIN_7 // PB7 -> FSMC_NE1 #define LCD_RS_PIN GPIO_PIN_0 // PD11 -> FSMC_A16 // 数据线 D0-D15 接 PD0-PD15 和 PE7-PE15🛠️ 实战提示:布线时尽量让数据线等长,否则高速写入可能出错。可以用示波器抓WR信号看是否干净。
LVGL初始化:不只是复制粘贴
很多人照着例程改参数,结果卡在lv_init()就崩了。关键在于理解每一行代码的意义。
第一步:告诉LVGL系统时间
LVGL内部靠毫秒级tick来驱动动画和定时任务。必须提供一个get_tick_ms函数:
uint32_t get_tick_ms(void) { return HAL_GetTick(); // 使用SysTick提供的基准时间 }然后注册给LVGL:
lv_tick_set_cb(get_tick_ms);⚠️ 注意:不要自己用HAL_Delay()模拟tick!会导致阻塞。
第二步:分配显示缓冲区
这是最容易出问题的地方。缓冲区太小会导致撕裂,太大又占RAM。
#define HOR_RES 320 #define VER_RES 240 #define BUFFER_SIZE (HOR_RES * VER_RES / 10) // 十分之一屏 static lv_color_t buf[BUFFER_SIZE]; // lv_color_t 默认是lv_color16_t (RGB565) lv_disp_draw_buf_t draw_buf; lv_disp_draw_buf_init(&draw_buf, buf, NULL, BUFFER_SIZE);这里用了单缓冲+部分刷新机制。LVGL只会标记“脏区域”去重绘,而不是全屏刷,极大减轻负担。
第三步:注册显示驱动
核心是实现flush_cb回调函数——当LVGL画好一块区域后,会调这个函数把像素送进LCD。
static lv_disp_drv_t disp_drv; lv_disp_drv_init(&disp_drv); disp_drv.hor_res = HOR_RES; disp_drv.ver_res = VER_RES; disp_drv.flush_cb = lcd_flush; // 刷新函数 disp_drv.draw_buf = &draw_buf; lv_disp_drv_register(&disp_drv);记住:flush_cb必须异步执行!不能在里面循环写数据卡住主线程。
显示驱动怎么写?别再轮询了!
看看这个典型的错误写法:
void lcd_flush_bad(lv_disp_drv_t * drv, const lv_area_t * area, lv_color_t * color_map) { for(int i = 0; i < len; i++) { LCD_DATA_ADDR = color_map[i].full; // 直接写,慢且阻塞 } lv_disp_flush_ready(drv); // 立即通知完成 → 错! }这会导致画面严重卡顿。正确做法是:启动DMA传输,在中断里通知完成。
正确姿势:DMA + 中断双保险
void lcd_flush(lv_disp_drv_t * drv, const lv_area_t * area, lv_color_t * color_map) { uint32_t addr_start = (uint32_t)&LCD_DATA_ADDR; uint32_t data_len = (area->x2 - area->x1 + 1) * (area->y2 - area->y1 + 1); // 设置GRAM地址范围(省略命令发送) lcd_set_address_window(area->x1, area->y1, area->x2, area->y2); // 启动DMA传输(假设使用FSMC+DMA) HAL_DMA_Start_IT(&hdma_fmc, (uint32_t)color_map, addr_start, data_len); // 不要在这里调 lv_disp_flush_ready!等DMA中断再说 }在DMA完成中断中通知LVGL:
void HAL_DMA_IRQHandler(DMA_HandleTypeDef *hdma) { if(hdma == &hdma_fmc) { lv_disp_flush_ready(&disp_drv); // 这句至关重要! } }✅ 效果对比:
- 轮询方式:刷新一帧耗时 ~80ms(12fps),CPU占用90%
- DMA方式:刷新一帧 ~10ms(100fps潜力),CPU占用<5%
常见坑点与调试秘籍
❌ 问题1:屏幕闪得像迪斯科灯球
原因:LVGL还没画完新帧,DMA就把旧数据发出去了。
✅ 解决方案:启用双缓冲机制。
static lv_color_t buf1[BUFFER_SIZE]; static lv_color_t buf2[BUFFER_SIZE]; lv_disp_draw_buf_init(&draw_buf, buf1, buf2, BUFFER_SIZE);LVGL会在两个缓冲区间切换绘制,避免边画边传。
❌ 问题2:触摸不准,点哪都不对
原因:坐标没校准,或者中断优先级太低被延迟处理。
✅ 解决方案:
- 使用XPT2046这类电阻屏控制器时,务必做触摸校准;
- 把SPI中断优先级设为最高(比如0),确保上报及时;
- 在LVGL输入注册中开启去抖:
indev_drv.read_cb = touch_read; indev_drv.long_press_time = 500; indev_drv.focal_point_only = true; // 只返回焦点对象❌ 问题3:编译报错一堆未定义符号
原因:lv_conf.h没配好,或者头文件路径不对。
✅ 秘籍:
- 复制
lvgl/lv_conf_template.h为lv_conf.h放到项目根目录; - 启用关键宏:
#define LV_COLOR_DEPTH 16 #define LV_HOR_RES_MAX 320 #define LV_VER_RES_MAX 240 #define LV_USE_PERF_MONITOR 1 // 性能监控,调试神器- 确保编译器能找到所有
.c文件(LVGL有约50个源文件,别漏加)。
主循环该怎么写?别让LVGL饿着
最后一步,也是最关键的一步:持续喂狗——哦不,喂lv_timer_handler()。
int main(void) { HAL_Init(); SystemClock_Config(); // 初始化外设... lcd_hardware_init(); lvgl_init(); // 包含上面所有步骤 create_ui(); // 创建你的界面 while (1) { lv_timer_handler(); // 必须每5~10ms调一次 HAL_Delay(5); // 控制刷新率约20fps } }📌 关键点:
-lv_timer_handler()是LVGL的心跳,少了它动画不动、事件不响;
- 延迟不宜过长,否则交互迟钝;也不宜过短,浪费CPU;
- 如果用了RTOS,可以用独立任务跑这个循环,优先级高于普通任务。
写在最后:这不仅仅是个教程
当你第一次看到按钮在屏幕上平滑弹起,滑块随着手指拖动渐变颜色,那种成就感远超“Hello World”。
这套方案我已经用于多个量产项目:智能电表、医疗设备操作面板、农业灌溉控制器……无一例外都稳定运行超过两年。
它证明了一件事:低成本MCU也能做出媲美智能手机体验的HMI,只要你掌握了正确的打开方式。
如果你正在为下一个带屏项目发愁,不妨试试这条路。代码可以从最简单的“显示一个按钮”开始,逐步迭代成完整系统。
想要完整工程模板?欢迎留言交流,我可以分享基于STM32CubeIDE的可编译项目结构(含LVGL 8.x + ILI9341 + XPT2046驱动)。一起少走弯路,多出产品。