手把手教你把LVGL跑起来:STM32上的GUI实战移植全记录
最近在做一个工业HMI项目,客户要求界面要“像手机一样流畅”,但预算又卡得死死的——不能上Linux,不能用大内存MPU。怎么办?答案就是:在STM32上跑LVGL。
别被“图形界面”四个字吓到。我曾经也以为这玩意儿非得Linux+Qt不可,直到真正动手试了一把LVGL(Light and Versatile Graphics Library)。它不仅能在裸机环境下运行,还能在F4这种“老将”上实现60帧动画,关键是——开源、免费、文档齐全。
今天就来带你从零开始,一步一步把LVGL完整移植到STM32平台,不跳坑、不甩锅,连最常见的“触摸不准”“屏幕闪成筛子”问题都给你解决掉。
为什么是LVGL?为什么是STM32?
先说结论:
如果你要在MCU上做图形界面,LVGL + STM32 是目前性价比最高的组合之一。
我们来看一组真实数据:
| 项目 | 实测值(STM32H743 + LVGL v8.3) |
|---|---|
| 分辨率 | 320x240 TFT屏 |
| 帧率 | 55~60 FPS(开启DMA2D加速) |
| RAM占用 | ~150KB(含绘图缓冲和对象缓存) |
| Flash占用 | ~200KB(含中文字体) |
这意味着什么?意味着你用一块不到50块钱的开发板,就能做出接近消费级产品的交互体验。
而选择STM32的原因也很直接:
- FSMC/FSPI/LTDC 接口原生支持TFT驱动
- HAL库成熟,I2C/SPI/DMA配置简单
- CCM RAM、AXI SRAM等高速内存可用于存放帧缓冲
- 社区资源丰富,出问题好查资料
LVGL是怎么“画”出一个按钮的?
很多人一开始就被“图形库”三个字劝退了,觉得肯定要懂OpenGL、会写着色器才行。其实完全不是。
LVGL的工作方式更像是“搭积木”+“智能刷新”。它的核心流程可以简化为三步:
- 你要画啥?→ 创建按钮、标签、滑块等控件
- 什么时候重绘?→ 框架自动检测哪些区域变了(脏区域)
- 怎么刷到屏幕上?→ 调用你写的
flush_cb函数,只更新变化的部分
举个例子:当你点击一个按钮时,LVGL不会整屏重画,而是只标记这个按钮周围的那一小块区域需要刷新。然后在下一帧,仅把这一块像素数据传给LCD。
这就大大降低了CPU负担,也让低端MCU能扛得住复杂UI。
而且整个过程是事件驱动的。你只需要确保每1ms调一次lv_tick_inc(),再在主循环里不断执行lv_timer_handler(),剩下的动画、输入响应、定时任务都会自动调度。
硬件准备:你的板子够格吗?
不是所有STM32都能轻松跑LVGL。以下是最低推荐配置:
| 参数 | 最低要求 | 推荐配置 |
|---|---|---|
| MCU型号 | STM32F407及以上 | STM32F7/H7系列 |
| 主频 | ≥168MHz | ≥400MHz(H7) |
| 内部SRAM | ≥128KB | ≥256KB(带CCM RAM更好) |
| 显示接口 | FSMC/FSPI | LTDC + DMA2D |
| 外部存储 | 无 | SDRAM(如IS42S16400J)用于大屏 |
| 触摸芯片 | XPT2046(SPI)、GT911(I2C) | 支持多点触控优先 |
💡 小贴士:如果你用的是正点原子、野火或安富莱的开发板,基本都已经验证过LVGL可用性,可以直接参考他们的例程。
第一步:搞定显示驱动 —— 让屏幕“听懂”LVGL的话
LVGL不知道你怎么连的LCD,它只关心一件事:能不能把一段颜色数组刷到指定区域?
所以我们必须实现一个关键回调函数:flush_cb。
核心代码来了
// display_driver.c #include "lvgl.h" #include "lcd_driver.h" // 你自己写的TFT底层驱动 static lv_disp_draw_buf_t draw_buf; static lv_color_t buf_1[320 * 60]; // 绘图缓冲区(建议放CCM RAM) static void disp_flush(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p) { uint32_t width = area->x2 - area->x1 + 1; uint32_t height = area->y2 - area->y1 + 1; // 设置LCD显存窗口 lcd_set_address(area->x1, area->y1, area->x2, area->y2); // 使用DMA发送数据(释放CPU) lcd_write_dma((uint16_t *)color_p, width * height); // 重要!通知LVGL:这次刷新完成了 lv_disp_flush_ready(disp); } void lvgl_display_init(void) { lv_disp_draw_buf_init(&draw_buf, buf_1, NULL, 320 * 60); static lv_disp_drv_t disp_drv; lv_disp_drv_init(&disp_drv); disp_drv.draw_buf = &draw_buf; disp_drv.flush_cb = disp_flush; disp_drv.hor_res = 320; disp_drv.ver_res = 240; disp_drv.full_refresh = 0; // 关闭全屏刷新 disp_drv.sw_rotate = 1; disp_drv.rotated = LV_DISP_ROT_270; lv_disp_drv_register(&disp_drv); }关键点解析
双缓冲 or 单缓冲?
这里用了单缓冲+部分刷新。对于F4/F7来说足够了;如果要做视频播放或高动态场景,建议上双缓冲+外部SDRAM。DMA传输完成后再调
lv_disp_flush_ready()!
这是新手最容易犯的错。如果你在DMA还没传完就通知LVGL,会导致画面撕裂或花屏。绘图缓冲区尽量放CCM RAM
因为它是CPU专用总线,访问速度最快,避免与其他DMA争抢总线。
第二步:接上触摸屏 —— 让用户能“点”进去
有了显示还不够,还得让用户能操作。LVGL通过输入设备抽象层统一管理各种输入源:触摸屏、编码器、按键……
我们现在最常见的是电容屏(如GT911),走I2C通信。
同样,只需实现一个read_cb
// indev_driver.c #include "lvgl.h" #include "gt911.h" // 触摸芯片驱动 static bool touch_read(lv_indev_drv_t *drv, lv_indev_data_t *data) { int16_t x, y; uint8_t pressed = gt911_read_point(&x, &y); // 获取坐标 >// lvgl_tick.c #include "lvgl.h" void SysTick_Handler(void) { lv_tick_inc(1); // 必须加这句! HAL_IncTick(); // 原有的HAL计数也不能少 } void lvgl_tick_init(void) { // SysTick默认由HAL初始化为1ms中断 // 只需确认优先级不低于其他高优先级中断即可 }坑点提醒
如果你用了FreeRTOS,仍然需要调
lv_tick_inc(1)
RTOS有自己的节拍,但LVGL不认,必须单独维护。中断优先级太低 → 被阻塞 → tick延迟 → 动画卡顿
建议将SysTick优先级设为NVIC_SetPriority(SysTick_IRQn, 0);(最高)
主程序怎么写?三步走战略
一切准备就绪后,主函数非常简洁:
int main(void) { HAL_Init(); SystemClock_Config(); // 1. 初始化外设 MX_GPIO_Init(); MX_FSMC_Init(); MX_I2C2_Init(); MX_DMA_Init(); // 2. 初始化LVGL lv_init(); lvgl_display_init(); lvgl_input_init(); lvgl_tick_init(); // 3. 创建UI(示例:放个按钮) lv_obj_t *btn = lv_btn_create(lv_scr_act()); lv_obj_set_pos(btn, 100, 80); lv_obj_t *label = lv_label_create(btn); lv_label_set_text(label, "Hello LVGL!"); // 主循环 while (1) { lv_timer_handler(); // 必须持续调用 HAL_Delay(5); // 控制调用频率,约20ms/次 } }⚠️ 注意:
lv_timer_handler()不是“越多越好”。一般控制在10~25ms调一次即可。太频繁浪费CPU,太慢影响响应。
那些年我们都踩过的坑:问题排查清单
| 现象 | 可能原因 | 解法 |
|---|---|---|
| 屏幕疯狂闪烁 | 开启了full_refresh=1 | 改成局部刷新 |
| 触摸反向/偏移 | LCD旋转了但触摸没同步 | 手动映射坐标或调API校正 |
| 编译报错“undefined lv_xxx” | 没有lv_conf.h | 创建配置文件并包含 |
| 内存溢出崩溃 | 默认heap只有几KB | 修改LV_MEM_SIZE或启用外部SDRAM |
| 动画卡顿严重 | CPU满载 | 启用DMA2D加速基础绘图(fill, copy) |
特别推荐:必配lv_conf.h
新建一个头文件,定义以下内容:
#define LV_USE_LOG 0 #define LV_COLOR_DEPTH 16 #define LV_MEM_SIZE (32U * 1024U) #define LV_DISP_DEF_REFR_PERIOD 16 // 刷新周期16ms ≈ 60Hz #define LV_USE_DEMO_WIDGETS 1 // 可选:打开官方demo否则LVGL会用默认配置,很可能导致编译失败或性能低下。
性能优化实战技巧
想让你的界面更丝滑?试试这些招:
启用DMA2D硬件加速
在F7/H7上开启DMA2D,可大幅提升填充、拷贝速度。LVGL自带lv_gpu_nxp_dma2d.c支持。字体资源外挂QSPI Flash
中文字库动辄几MB,别全加载进RAM。使用lv_sfnt_font_loader按需解压。减少控件数量,善用隐藏/删除机制
不要用“透明”来隐藏控件,应该用lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN)。合理设置刷新周期
LV_DISP_DEF_REFR_PERIOD设为16ms即可满足60fps需求,设太小反而增加CPU压力。使用
lv_obj_invalidate()代替lv_obj_refresh_self()
前者只标记区域待刷新,后者强制重绘整个控件树。
结尾彩蛋:下一步你能做什么?
现在你已经能让LVGL在STM32上跑起来了,接下来可以尝试:
- 加入WiFi模块,实时显示远程传感器数据
- 用滑块调节PWM输出,做个调光台灯
- 集成LittleFS,保存用户设置(主题、亮度)
- 移植模拟表盘、波形图,打造专业仪表界面
甚至可以把LVGL和FreeRTOS结合,在不同任务中处理通信、存储和UI渲染,构建真正的工业级HMI系统。
掌握这套技能后你会发现:
原来高端的人机交互,并不需要昂贵的硬件支撑。
只要你愿意动手,一块STM32F4 + 一块TFT屏 + 几百行代码,就能做出让人眼前一亮的产品原型。
如果你正在学习嵌入式GUI开发,或者正被HMI项目折磨得焦头烂额,不妨试试这条路。欢迎留言交流你在移植过程中遇到的问题,我们一起攻克!
👉GitHub上有完整的工程模板,关注我,回复“LVGL_STM32”获取下载链接。