从零开始,在STM32上跑通LVGL:一次真实的移植实践
最近接手了一个智能温控面板项目,客户明确要求“要有滑动动画、支持触控操作、界面要像手机一样流畅”。听到这句话时我第一反应是:完了,得上图形界面了。
传统的段码屏和字符LCD早就撑不起这种需求。好在现在有成熟的嵌入式GUI方案可选,而LVGL + STM32的组合,几乎成了中低端MCU实现高颜值HMI的事实标准。
但说“移植”容易,真动手才发现:官方文档偏理论、社区教程碎片化、HAL库配置绕来绕去……尤其当你面对一块SPI接口的ILI9341屏幕和GT9147触摸芯片时,那种“明明每一步都做了,为什么就是没显示”的崩溃感,谁用谁知道。
所以这篇文章,不讲空话,不堆术语,带你从点亮第一帧画面开始,完整走完LVGL在STM32上的移植全过程——就像一个老工程师坐在你旁边手把手教那样。
为什么是LVGL?它真的适合你的MCU吗?
先泼一盆冷水:不是所有项目都适合上LVGL。
如果你的MCU只有20KB RAM、主频不到48MHz,还想跑复杂动画,那大概率会卡成幻灯片。但在满足基本硬件条件的前提下,LVGL的优势非常明显:
- 开源免费,商业可用(MIT协议)
- C语言编写,无依赖,极易集成
- 资源占用极低:最小仅需几KB内存
- 自带50+控件、动画引擎、样式系统
更重要的是,它不绑定任何硬件。你可以用SPI屏、RGB屏、甚至OLED;可以用触摸、按键、编码器输入——只要你会写驱动回调,LVGL就能接上去。
相比之下,TouchGFX虽然效果惊艳,但依赖ST专用工具链且授权昂贵;emWin功能强但闭源;Qt for MCUs太重,对MCU要求高。而LVGL,更像是为“务实派”工程师量身打造的工具。
✅ 我的开发环境:
- 芯片:STM32F407ZGT6(1MB Flash,128KB SRAM)
- 屏幕:2.8寸SPI TFT(ILI9341,240x320)
- 触摸:GT9147 I2C电容屏
- IDE:Keil MDK + STM32CubeMX
- LVGL版本:v8.3(最新稳定版)
第一步:准备LVGL源码并接入工程
1. 下载LVGL源码
直接从GitHub克隆:
git clone https://github.com/lvgl/lvgl.git将整个lvgl文件夹复制到你的工程目录下。
2. 添加头文件路径
在IDE中添加以下路径到include搜索目录:
./lvgl ./lvlg/src3. 复制配置文件模板
进入lvgl\examples\porting目录,拷贝两个关键文件到项目根目录:
-lv_conf_template.h→ 改名为lv_conf.h
-lv_port_disp_template.c→ 可改名为lvgl_display.c
注意:必须确保LV_CONF_H在预处理器定义中,否则配置不会生效!
4. 简化初始配置
打开lv_conf.h,我们先做最简配置,后续再逐步开启功能:
#define LV_USE_LOG 1 // 开启日志(调试用) #define LV_LOG_LEVEL LV_LOG_LEVEL_INFO #define LV_COLOR_DEPTH 16 // 使用RGB565,节省内存 #define LV_HOR_RES_MAX 240 #define LV_VER_RES_MAX 320 #define LV_MEM_SIZE (32U * 1024) // 分配32KB动态内存池其他模块如文件系统、字体压缩等暂时关闭,避免编译错误。
第二步:搞定显示驱动——让屏幕“动起来”
这是最关键的一步。很多人卡住,是因为没理解LVGL的“刷新机制”。
核心概念:flush_cb回调函数
LVGL自己不画屏,它只负责把像素数据准备好,然后告诉你:“这块区域变了,请刷到屏幕上。” 这个通知就是通过flush_cb实现的。
工作流程如下:
- LVGL渲染一帧UI → 得到一个矩形区域(x1,y1,x2,y2)和对应的颜色数组
- 调用你注册的
flush_cb函数 - 你在函数里把这段数据发给屏幕
- 数据发送完成后,调用
lv_disp_flush_ready()告诉LVGL:“我已经刷完了” - LVGL继续下一帧
如果忘了第4步,LVGL会一直等待,界面就卡住了。
实战代码:SPI屏驱动对接
假设你已经用STM32CubeMX配置好了SPI1,并写了基本的ILI9341驱动函数(如ili9341_write_command()、ili9341_write_data()),接下来我们注册LVGL显示设备。
// lvgl_display.c #include "lvgl.h" #include "ili9341.h" #define LCD_WIDTH 240 #define LCD_HEIGHT 320 #define DISP_BUF_SIZE (LCD_WIDTH * 60) // 缓冲区大小:一行高度×60 static lv_disp_draw_buf_t draw_buf; static lv_color_t buf[DISP_BUF_SIZE]; // 必须为全局或静态变量 /* 刷新回调函数 */ static void disp_flush(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p) { uint32_t w = area->x2 - area->x1 + 1; uint32_t h = area->y2 - area->y1 + 1; // 设置屏幕窗口 ili9341_set_window(area->x1, area->y1, area->x2, area->y2); // 发送颜色数据(使用DMA更佳) ili9341_write_color((uint16_t *)color_p, w * h); // ⚠️ 关键!必须调用此函数通知LVGL刷新完成 lv_disp_flush_ready(disp); } /* 显示驱动初始化 */ void lvgl_display_init(void) { lv_disp_drv_t disp_drv; lv_disp_drv_init(&disp_drv); // 初始化绘制缓冲区 lv_disp_draw_buf_init(&draw_buf, buf, NULL, DISP_BUF_SIZE); // 配置显示参数 disp_drv.draw_buf = &draw_buf; disp_drv.flush_cb = disp_flush; disp_drv.hor_res = LCD_WIDTH; disp_drv.ver_res = LCD_HEIGHT; disp_drv.full_refresh = 0; // 启用局部刷新(性能更好) disp_drv.sw_rotate = 1; // 软件旋转(若需要) disp_drv.rotated = LV_DISP_ROT_0; // 默认方向 // 注册显示设备 lv_disp_drv_register(&disp_drv); }📌重点说明:
-DISP_BUF_SIZE决定性能与内存消耗。建议设置为屏幕宽度 × 30~60行。
- 若使用外部SRAM(如IS61WV102416),可将buf定位到FSMC地址空间,大幅扩展缓冲区。
- 推荐启用DMA传输SPI数据,避免CPU忙等。
第三步:接入触摸输入——让用户能“点”
没有交互的GUI等于摆设。LVGL的输入系统非常灵活,支持触摸、按键、编码器等多种方式。
我们以最常见的I2C电容触摸屏(GT9147)为例。
核心机制:read_cb回调
LVGL每隔几毫秒就会调用一次read_cb,询问当前是否有触摸事件。你需要返回状态和坐标。
与中断方式不同,这种方式更简单,也更容易与RTOS配合。
实战代码:触摸驱动集成
// lvgl_input.c #include "lvgl.h" #include "gt9147.h" // 自定义触摸驱动 static bool touch_read(lv_indev_drv_t *indev, lv_indev_data_t *data) { touch_point_t tp; if (gt9147_read_touch(&tp)) { >int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_SPI1_Init(); // ILI9341 MX_I2C2_Init(); // GT9147 // 必须先初始化LVGL lv_init(); // 初始化显示和输入设备 lvgl_display_init(); lvgl_input_init(); // 创建第一个界面(示例:放一个按钮) lv_obj_t *btn = lv_btn_create(lv_scr_act()); lv_obj_align(btn, LV_ALIGN_CENTER, 0, 0); lv_obj_t *label = lv_label_create(btn); lv_label_set_text(label, "Hello LVGL!"); lv_obj_center(label); while (1) { lv_tick_inc(1); // 每毫秒增加一次系统时间 lv_task_handler(); // 处理GUI任务(动画、重绘、事件) osDelay(1); // 延时1ms(若使用FreeRTOS) } }📌关键点解析:
-lv_tick_inc(1)必须每毫秒调用一次,用于驱动动画计时器。可通过SysTick中断实现更高精度。
-lv_task_handler()是LVGL的“心跳”,必须周期性调用(推荐1~10ms一次)。
- 所有LVGL API必须在主线程或GUI线程中调用,禁止在中断中直接调用!
常见坑点与解决秘籍
别急着庆祝,下面这些“经典陷阱”,我踩过一半。
❌ 问题1:屏幕全黑 / 只闪一下
原因:flush_cb中未调用lv_disp_flush_ready()
✅ 解决:检查是否遗漏该调用,尤其是在DMA传输完成回调中才应调用。
❌ 问题2:触摸不准 / 反向响应
原因:坐标未校准或方向错误
✅ 解决:使用lv_disp_set_rotation()设置正确朝向,或在read_cb中做映射转换。
❌ 问题3:内存不足,lv_mem_alloc失败
原因:LV_MEM_SIZE设置过小 或 缓冲区太大
✅ 解决:
- 将frame buffer移至外部SRAM
- 减少缓冲区行数(如改为30行)
- 关闭非必要功能:#define LV_USE_ANIMATION 0
❌ 问题4:界面卡顿,动画不流畅
原因:SPI速度慢、频繁全屏刷新、CPU负载过高
✅ 优化策略:
- 提升SPI时钟至50MHz(使用DMA)
- 启用局部刷新(full_refresh=0)
- 使用DMA2D加速填充(适用于F4/F7/H7系列)
例如,启用DMA2D后,矩形填充速度可提升5倍以上:
// lv_conf.h #define LV_USE_GPU_STM32_DMA2D 1性能优化实战建议
想让你的GUI真正“丝滑”,光跑通还不够。以下是我在多个量产项目中总结的经验:
1. 内存布局设计
| 区域 | 建议位置 |
|---|---|
frame buffer | 外部SRAM 或 CCM RAM |
lv_mem_pool(heap) | 主SRAM |
| 字体资源 | Flash(const存储) |
使用链接脚本.ld文件精确定位:
._fb_buf ALIGN(4) : { PROVIDE(_fb_start = .); KEEP(*(.fb_section)) PROVIDE(_fb_end = .); } > FMC_SRAM2. 使用部分刷新减少带宽
LVGL默认只刷新变化区域。确保full_refresh=0,并合理设置缓冲区大小。
测试表明:相比全屏刷新,局部刷新可减少80%以上的SPI数据量。
3. 控件复用代替创建销毁
频繁调用lv_obj_del()和lv_btn_create()会导致内存碎片。
✅ 正确做法:隐藏/显示对象,或使用页面容器(lv_page/lv_tabview)管理界面切换。
4. 启用日志监控运行状态
lv_log_register_print_cb(my_print_func); // 重定向日志到串口 void my_print_func(const char *buf) { HAL_UART_Transmit(&huart1, (uint8_t*)buf, strlen(buf), HAL_MAX_DELAY); }可实时查看内存使用、任务延迟等信息。
最终效果:做一个简单的设置菜单
验证成功的最好方式,就是做出点东西来。
void create_settings_ui(void) { lv_obj_t *cont = lv_obj_create(lv_scr_act()); lv_obj_set_size(cont, 200, 200); lv_obj_align(cont, LV_ALIGN_CENTER, 0, 0); lv_obj_t *label = lv_label_create(cont); lv_label_set_text(label, "亮度调节"); lv_obj_align(label, LV_ALIGN_TOP_MID, 0, 10); lv_obj_t *slider = lv_slider_create(cont); lv_obj_set_size(slider, 160, 10); lv_obj_align(slider, LV_ALIGN_CENTER, 0, 0); lv_slider_set_value(slider, 50, LV_ANIM_OFF); lv_obj_t *val_label = lv_label_create(cont); lv_label_set_text_fmt(val_label, "%d%%", 50); lv_obj_align_to(val_label, slider, LV_ALIGN_OUT_BOTTOM_MID, 0, 10); // 绑定事件 lv_obj_add_event_cb(slider, slider_event_cb, LV_EVENT_VALUE_CHANGED, val_label); } static void slider_event_cb(lv_event_t *e) { lv_obj_t *slider = lv_event_get_target(e); lv_obj_t *label = lv_event_get_user_data(e); int val = lv_slider_get_value(slider); lv_label_set_text_fmt(label, "%d%%", val); }运行效果:拖动滑块,百分比实时更新,带有平滑动画。
写在最后:这不是终点,而是起点
当你第一次看到那个按钮出现在屏幕上,还能被手指点击时,那种成就感,值得所有熬夜调试的夜晚。
但这只是开始。LVGL的强大在于它的可扩展性:
- 想换主题?
lv_theme_default_init()一键切换深色/浅色模式。 - 想加图标?导入SVG或使用Font Awesome字体。
- 想国际化?内置Unicode支持中文、阿拉伯文等。
- 想更高效?结合FreeRTOS创建独立GUI任务,解耦业务逻辑。
未来你还可以尝试:
- 使用LittleFS实现界面皮肤热更新
- 集成Lua脚本动态控制UI行为
- 移植到GD32、CH32等国产RISC-V平台
嵌入式GUI不再是高端产品的专利。只要你愿意动手,一块STM32和LVGL,就能让冰冷的设备拥有温度。
如果你觉得这篇教程帮到了你,欢迎点赞收藏。
也欢迎在评论区分享你在移植过程中遇到的问题,我们一起解决。