从零实现LVGL显示驱动:STM32 + HAL库实战手记
你有没有遇到过这样的情况?
屏幕接上了,电源正常,SPI通信也通了,但就是“有屏无显”——明明调用了LVGL的lv_label_set_text(),界面上却纹丝不动。或者更糟:界面能出,但一动就卡顿、撕裂、花屏。
这不是硬件坏了,而是显示驱动没写对。
在嵌入式GUI开发中,把LVGL跑起来不难,但要让它“跑得稳、刷得快、吃得少”,关键就在于手写一套真正理解底层机制的显示驱动。今天,我就带你从零开始,在STM32平台上,用HAL库一步步搭建一个高效、稳定、可复用的LVGL显示系统。
为什么不能直接“拿来主义”?
网上有很多LVGL移植教程,甚至GitHub上也能找到现成的工程模板。但当你换一块屏、换个MCU型号,或者发现刷新卡顿时,那些“复制粘贴”的代码立刻失效。
根本原因在于:大多数示例忽略了两个核心问题:
- DMA传输完成前就通知LVGL刷新结束→ 图像撕裂;
- 帧缓冲区放在不可靠内存区域→ 传输失败或性能下降。
而这些问题,只有当你亲手写一遍驱动,才能真正理解背后的逻辑。
所以,我们不讲“怎么改配置”,我们讲“为什么要这么写”。
LVGL是怎么“画”出第一个像素的?
很多人以为LVGL是“实时渲染”的,其实不然。它的工作方式更像是一个“画家+快递员”的协作模式:
- 画家(LVGL Core):负责画画,把UI元素画到一块叫“帧缓冲区”的画布上;
- 快递员(Display Driver):只管把画布上“脏了的区域”打包送去屏幕。
这个“脏区域”就是所谓的invalid area。LVGL不会整屏重绘,只会标记哪些区域需要更新,然后通过flush_cb回调告诉你:“喂,这块地方变了,去刷一下。”
所以,你的任务不是“怎么画图”,而是“如何高效地把数据送出去,并且确保送完了再让画家动笔”。
显示驱动四步走:初始化 → 刷新 → 传输 → 同步
第一步:让硬件准备好——LCD接口初始化
我们以常见的SPI接口TFT屏为例(如ILI9341),使用STM32F4系列+HAL库。
首先,别急着注册LVGL,先把硬件打通。
// lcd_driver.h #define LCD_CTRL_PORT GPIOA #define LCD_CS_PIN GPIO_PIN_4 #define LCD_DC_PIN GPIO_PIN_3 #define LCD_RST_PIN GPIO_PIN_2 void lcd_gpio_init(void); void lcd_reset(void); void LCD_WriteCommand(uint8_t cmd); void LCD_WriteData(uint8_t *data, uint32_t len); void LCD_SetAddressWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h);这些函数的作用很明确:
- 控制CS、DC、RST引脚;
- 发送命令和数据;
- 设置显示窗口(即你要更新的矩形区域)。
其中最关键的是LCD_SetAddressWindow,它告诉LCD控制器:“接下来我要写哪一块区域”。如果你这一步错了,哪怕数据发出去了,也可能出现在错位的位置。
// lcd_driver.c void LCD_SetAddressWindow(uint16_t x, uint16_t y, uint16_t width, uint16_t height) { uint16_t x_end = x + width - 1; uint16_t y_end = y + height - 1; LCD_WriteCommand(0x2A); // Column Address Set uint8_t col_start[4] = {x >> 8, x & 0xFF, x_end >> 8, x_end & 0xFF}; LCD_WriteData(col_start, 4); LCD_WriteCommand(0x2B); // Row Address Set uint8_t row_start[4] = {y >> 8, y & 0xFF, y_end >> 8, y_end & 0xFF}; LCD_WriteData(row_start, 4); LCD_WriteCommand(0x2C); // Memory Write }✅ 小贴士:有些屏幕命令是0x2C,有些是0x2D(RGB接口),一定要查清楚你的屏幕手册!
第二步:绑定LVGL的“刷新钩子”
现在轮到LVGL登场了。我们需要告诉它三件事:
- 屏幕分辨率是多少?
- 帧缓冲区在哪里?
- 数据怎么送出去?
先初始化LVGL库本身:
#include "lvgl.h" void lvgl_display_init(void) { lv_init(); // 必须最先调用 }接着分配帧缓冲。这里有个大坑:不要一股脑申请一整个屏幕的缓冲!
比如320×240的RGB565屏,一帧就要 320×240×2 = 150KB RAM —— 对很多MCU来说太奢侈了。
我们可以采用行缓冲策略(line buffering),只缓存几行:
static lv_color_t disp_buf_array[LV_HOR_RES_MAX * 10]; // 缓存10行 static lv_disp_buf_t disp_buf; lv_disp_buf_init(&disp_buf, disp_buf_array, NULL, LV_HOR_RES_MAX * 10);NULL表示单缓冲模式。如果想防撕裂,可以再加一块做双缓冲,但RAM够用吗?自己掂量。
然后注册显示设备:
lv_disp_drv_t disp_drv; lv_disp_drv_init(&disp_drv); disp_drv.hor_res = 320; disp_drv.ver_res = 240; disp_drv.flush_cb = disp_flush; // 刷屏回调 disp_drv.buffer = &disp_buf; lv_disp_drv_register(&disp_drv);注意:flush_cb是重点,下面细说。
最后别忘了时间基准:
uint32_t custom_tick_get(void) { return HAL_GetTick(); } lv_tick_set_cb(custom_tick_get);LVGL内部靠这个时间戳处理动画、延时、事件调度。必须和HAL_GetTick()对齐,否则动画会乱跳。
第三步:真正的重头戏——disp_flush怎么写?
这是最容易出错的地方。来看标准写法:
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_SetAddressWindow(area->x1, area->y1, width, height); // 启动DMA传输 HAL_SPI_Transmit_DMA(&hspi1, (uint8_t *)color_p, width * height * 2); }看起来没问题?错!这里埋了个致命隐患:DMA还没传完,LVGL就已经认为“刷完了”。
结果就是:LVGL马上开始下一帧绘制,覆盖了还在传输的数据,导致画面撕裂或花屏。
正确做法是:在DMA传输完成中断里通知LVGL。
void SPI_DMATransferCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi == &hspi1) { lv_disp_flush_ready(&disp_drv); // 只有这时才安全! } }这样,LVGL才会放心地继续后续操作。
⚠️ 重要提醒:确保你在CubeMX中启用了SPI的DMA Tx,并正确关联了中断服务例程。
如何避免常见“翻车”现场?
翻车1:图像错位/偏移
- 排查点:检查
LCD_SetAddressWindow是否与实际坐标匹配; - 技巧:先手动画一个红框测试,确认区域是否准确。
翻车2:刷新慢如蜗牛
- 典型症状:滑动列表卡顿,按钮响应延迟;
- 根因:SPI速率太低,或未启用DMA;
- 解决方案:
- 在CubeMX中将SPI Baud Rate设置为 fPCLK/4 或更高(如42MHz);
- 使用DMA而非轮询发送;
- 考虑升级到FSMC/FMC接口驱动大屏。
翻车3:内存溢出或DMA报错
- 现象:程序崩溃、HardFault;
- 原因:帧缓冲区分配在DTCM以外的区域,DMA无法访问;
- 解决方法:
- 将缓冲区放在SRAM1或启用MPU配置;
- 或使用
__attribute__((section(".sram1")))指定内存段。
例如:
__attribute__((section(".sram1"))) static lv_color_t disp_buf_array[LV_HOR_RES_MAX * 10];并在链接脚本中定义.sram1段。
高阶优化思路:不只是“能用”
当你已经能让LVGL流畅运行后,下一步可以考虑以下优化:
✅ 合理选择缓冲策略
| 模式 | 内存占用 | 视觉效果 | 适用场景 |
|---|---|---|---|
| 单缓冲 | 最低 | 可能撕裂 | 资源极紧张 |
| 双缓冲 | ×2 | 平滑无撕裂 | 中高端应用 |
| 部分缓冲(10行) | 折中 | 轻微闪烁 | 大多数项目 |
推荐优先尝试“单缓冲 + DMA完成通知”方案,兼顾性能与资源。
✅ 提高SPI效率的小技巧
- 开启SPI FIFO(若支持)减少中断次数;
- 批量传输:合并多个小区域为一次大传输;
- 使用QSPI替代SPI(适用于Flash型OLED);
✅ 定时器精准调度
lv_timer_handler()必须定期调用,建议每5~10ms执行一次。
在裸机系统中可用定时器中断:
HAL_TIM_Base_Start_IT(&htim6); // 10ms周期 void TIM6_IRQHandler(void) { HAL_TIM_IRQHandler(&htim6); } void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim == &htim6) { lv_timer_handler(); // 让LVGL检查是否有事要做 } }在FreeRTOS中则可用独立任务:
void lvgl_task(void *pvParameters) { while(1) { lv_timer_handler(); vTaskDelay(pdMS_TO_TICKS(5)); } }实战心得:我踩过的坑,都值得你绕开
- 不要在
flush_cb里加 delay 或阻塞操作→ 直接卡死GUI; - 确保
color_p指针指向有效数据→ 特别是在动态分配场景下; - 调试时打开LVGL日志:
lv_log_register_print_cb(my_print_func); void my_print_func(const char *buf) { printf("%s", buf); }能帮你快速定位控件创建、内存分配等问题。
- 对于OLED屏(如SSD1306):注意它是单色的,需配置
LV_COLOR_DEPTH=1,否则内存爆炸。
写在最后:掌握底层,才有自由
LVGL的强大之处,不在于它有多少控件,而在于它的可塑性。你可以把它移植到任何带屏幕的设备上,只要你愿意沉下心来理解它的机制。
本文没有讲触摸输入、中文字库、文件系统这些“高级功能”,因为那些都是锦上添花。真正的基础,是你能否写出一个可靠的显示驱动。
当你亲手把第一个标签成功刷上屏幕,并且滑动时不卡、不动态闪烁时,那种成就感,远胜于复制一百个Demo工程。
如果你正在做HMI面板、医疗设备界面、智能家居终端,这套方案完全可以作为你的标准模板。无论是ILI9341、ST7789、SSD1351还是RM67162,只要换一下底层驱动函数,其他部分几乎不用改。
这才是嵌入式开发的魅力:抽象之上构建秩序,细节之中掌控全局。
如果你在实现过程中遇到了其他挑战,欢迎在评论区分享讨论。