ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32移植LVGL 8.3全攻略:从零配置到彻底解决花屏问题

2026/10/6 3:28:44 拓冰建站 浏览量
STM32移植LVGL 8.3全攻略:从零配置到彻底解决花屏问题 嵌入式圈子的朋友应该都有过类似经历花了大半天把LVGL源码拷进工程、配好各种宏满怀期待地点下载屏幕亮了然后——满屏雪花点、撕裂横纹、色块乱跳第一反应就是“驱动代码是不是有问题”。折腾一晚上最后发现往往就是几个配置项没对齐颜色格式不对、缓冲区大小不匹配、或者刷屏回调里少了关键的一行通知函数。我在多个项目里踩过同一批坑这篇文章用STM32CubeIDE完整走一遍LVGL 8.3的移植流程每一步都附上代码和参数说明读完可以直接照着操作把“花屏”这个最常见的幺蛾子一次按死。整个过程基于STM32F103系列MCUF407/F429等型号同理差异只在显存和DMA配置部分用官方CubeIDE 1.13以上版本新建工程配合Cortex-M3内核的CRC、DMA等外设驱动一块SPI接口的TFT-LCD屏幕常见如ILI9341、ST7735、ST7789。LVGL版本固定用8.3.x这是目前图形库在性能、内存占用和易用性上比较均衡的版本既不像7.x那样缓冲区管理相对粗糙也没有升级到9.x之后接口大改的迁移成本。无论你是刚接触GUI开发的新手还是已经在用裸机屏幕做产品的工程师这篇文章都适用。1. 移植前的准备与整体思路1.1 为什么选LVGL 8.3而不是其他版本很多人纠结“到底用7.11还是8.3还是9.0”我的建议很简单新项目直接上8.3老项目能不动就不动。LVGL 8.x把整个框架重构了一遍最直观的变化是API风格统一了对象、样式、事件的概念比7.x清晰很多。比如lv_obj_t的创建、lv_obj_set_style_bg_color()这种样式接口写起来更像是在操作一个现代GUI框架而不是裸奔地堆像素。8.3作为8.x系列的后期版本修掉了很多内存泄漏和边缘情况稳定性明显比8.0~8.2好网上能搜到的资料也最多遇到问题几乎都能找到解答。9.x我也试过API变化太大了lv_display_t、lv_event_t这些核心结构体都改了如果你参照的是8.3的教程去写9.0的代码基本没法编译通过。除非你的项目必须用9.x的新特性比如更精细的渲染管线否则没必要在这个版本上折腾。1.2 硬件方案与接口选择LVGL只是一个图形库它本身不直接操作屏幕而是通过你提供的“驱动回调函数”来刷新像素。所以移植工作的本质是告诉LVGL“你的显存长什么样”“你往哪里写像素”仅此而已。屏幕和MCU之间最常见的接口是SPI和并口接口类型优点缺点适用场景SPI引脚少SCK、MOSI、CS、DC、RST、BL布线简单刷新速度受限于SPI时钟实测一般20~40MHz绝大多数小尺寸屏1.3寸~4.3寸8位并口刷新速度快一次传输8bit占用引脚多至少10个布线复杂大屏、高帧率要求场景RGB接口刷新速度最快直接并行输出像素时钟MCU必须带LTDC控制器F429以上4.3寸以上大屏、视频播放I2C引脚最少2个速度太慢刷新一帧要几百ms仅用于OLED小屏、静态界面我这篇文章用的SPI接口这是STM32LVGL最常见、覆盖面最广的搭配。比如ILI9341这种经典驱动ICSPI时钟跑到40MHz配合320x240分辨率整屏刷新时间大约在120ms左右对GUI应用来说完全够用。1.3 整体架构图视角下的移植路径在使用LVGL之前先想清楚你的软件分层用户界面代码创建控件、绑定事件 ↓ LVGL图形库对象管理、渲染、事件分发 ↓ 显示驱动flush_cb回调 输入设备驱动touchpad/encoder回调 ↓ 屏幕IC驱动SPI发送命令/数据 ↓ STM32硬件SPI外设、DMA、GPIO这套分层看懂之后你就明白为什么LVGL可以轻松跨平台——它只依赖三个东西一个合法的显存缓冲、一个能完成刷屏的回调函数、一个能读取输入设备的回调函数。剩下的东西LVGL全帮你处理了。移植流程总共六步缺一步都可能出问题在CubeIDE里配置好基础工程时钟、调试口下载LVGL 8.3源码并拷入工程修改lv_conf.h配置头文件核心编写显示驱动flush回调编写输入设备驱动触屏/编码器在主循环里调用lv_timer_handler()并测试下面按这个顺序逐步展开。2. CubeIDE工程基础配置2.1 新建工程与芯片型号设置打开STM32CubeIDEFile → New → STM32 Project在弹窗里输入你的芯片型号。我用的是STM32F103RCT6Flash 256KBRAM 48KB跑LVGL做简单界面足够。选好型号后进入图形化配置界面.ioc文件。这里有两个坑必须提前避免一是调试接口别选错。如果你用ST-Link下载调试在SYS → Debug里选Serial Wire只用SWD两根线。默认的No Debug会导致第一次下载后芯片无法再次连接只能按住复位键掐时间点下载非常痛苦。二是时钟树必须确认。进入Clock Configuration页签把HCLK配到芯片的最高主频。F103系列一般是72MHz配置方法是在HCLK输入框里键入72然后回车CubeIDE会自动帮你反推PLL参数。如果时钟不对SPI的波特率计算全都会错LCD初始化时序不对屏幕轻则花屏重则白屏。2.2 SPI外设与DMA配置以ILI9341为例我用的SPI1配置如下Mode: Transmit Only Master只发送主机模式Hardware NSS Signal: Disable我们用软件控制CS引脚Prescaler: 分频后SPI时钟尽量接近20~40MHzF103主频72MHz选4分频18MHz比较稳Data Size: 8 BitsFirst Bit: MSB First在DMA Settings页签里把SPI1_TX加入DMA请求Direction选Memory To PeripheralMode选Normal。这里有个非常重要的细节不要选Circular循环模式。LVGL每次刷屏的数据长度可变循环模式会导致DMA传输完一批数据后又自动重新开始传输逻辑上完全错误。DMA添加完成后SPI的初始化代码里会自动出现HAL_SPI_Transmit_DMA()之类的接口我们后面在驱动里直接调用。2.3 GPIO引脚规划我习惯把屏幕控制引脚这样分配功能引脚说明SPI1_SCKPA5时钟SPI1_MOSIPA7数据主发从收LCD_CSPB6片选低有效LCD_DCPB7数据/命令选择1数据0命令LCD_RSTPB8复位低有效LCD_BLPB9背光高有效可直接接3.3V这些引脚在.ioc界面的GPIO选项卡里全部设为GPIO_OutputCS、DC、RST、BL初始电平按需设置CS默认高、RST默认高、DC默认高、BL默认高。初始化代码生成后不需要再改直接在驱动文件里操作这些引脚。3. LVGL源码下载与工程文件组织3.1 获取8.3版本源码到GitHub上找lvgl/lvgl仓库切换到release/v8.3分支下载ZIP压缩包。注意版本号目前8.3系列的最新小版本是8.3.11优先下载带这个版本号的tag里面的API和文档最匹配。解压后你会看到这些目录lvgl/ ├── examples/ # 官方示例可移植部分代码 ├── src/ │ ├── core/ # 核心对象、事件、刷新机制 │ ├── draw/ # 软件渲染引擎sw │ ├── extra/ # 高级控件仪表盘、图表、动画等 │ ├── fonts/ # 内置字体 │ ├── hal/ # 硬件抽象层就是咱们要填的接口 │ ├── libs/ # 第三方依赖如png解码 │ ├── misc/ # 杂项工具 │ ├── the mes/ # 主题 │ └── widgets/ # 基础控件按钮、标签、滑块等 ├── env_support/ # RTOS适配层 ├── lv_conf_template.h # 配置文件模板 └── lvgl.h # 总头文件3.2 拷贝哪些文件到你的工程整个src目录直接拷进项目的Core/Src同级目录下。我习惯在工程根目录建一个Middlewares/LVGL文件夹把lvgl整个文件夹放进去然后在CubeIDE里右键工程 → Refresh让IDE自动索引新增文件。需要手工拷出来的文件还有一个lv_conf_template.h把它复制一份到lvgl目录外面比如Core/Inc下重命名为lv_conf.h。LVGL的头文件搜索路径会自动找到这个配置文件通过LV_CONF_INCLUDE_SIMPLE宏来控制。编译之前需要确认两件事C语言版本必须设为C99或更高。右键工程Properties → C/C Build → Settings → MCU GCC Compiler → Language standard选gnu99或c99。LVGL大量使用了stdint.h和bool类型不设C99会报一堆莫名其妙的错误。头文件路径必须加全。在Compiler → Include paths里添加Middlewares/LVGL Middlewares/LVGL/src如果不加编译器找不到lvgl.h所有引用都会标红。3.3 首次编译常见错误我第一次移植时遇到过两个特别典型的编译错误错误一lv_conf.h找不到这个几乎都是头文件路径配置问题。确认在Include paths里加了Middlewares/LVGLlv_conf.h的所在目录而不是只加了src。错误二LV_COLOR_DEPTH未定义这说明lv_conf.h没有正确生效。检查文件开头是否有#ifndef LV_CONF_H的防重复包含结构并且确认没有在多个地方重复定义。在lvgl.h里搜LV_COLOR_DEPTH如果是在lv_conf_internal.h中被覆盖那就要检查lv_conf.h里的#if 1是否被错误地改成了#if 0。4. 核心配置修改lv_conf.h详解4.1 必须修改的配置项打开lv_conf.h找到文件开头#if 1确保是1启用。然后逐项核对以下参数/* 颜色深度16bitRGB565是绝大多数SPI屏的规格 */ #define LV_COLOR_DEPTH 16 /* 屏幕分辨率改成你自己的屏幕参数 */ #define LV_HOR_RES 320 #define LV_VER_RES 240 /* 显示缓冲区大小单位是像素不是字节 */ #define LV_DISP_BUF_SIZE (LV_HOR_RES * 40) // 40行缓冲 /* 内存池大小单位是KB */ #define LV_MEM_SIZE (32 * 1024) // 32KBLV_COLOR_DEPTH是花屏的头号元凶。如果你的屏幕是RGB565接口而LVGL内部按RGB88824bit渲染刷出去的数据长度和格式全都对不上显示出来就是典型的花屏颜色错乱。设置成16后LVGL内部的像素格式就是lv_color_t占2字节跟你的RGB565屏幕一一对应。LV_DISP_BUF_SIZE的单位是像素不是字节。设成(320 * 40)表示缓冲区能存40行像素因为RGB565一个像素2字节这个缓冲区占320*40*2 25600字节也就是25KB。对于48KB RAM的F103RCT6来说这个大小是安全的既给了LVGL足够的工作空间又不会把内存吃干净。LV_MEM_SIZE是LVGL内部动态内存池的容量。界面简单几个按钮、标签、滑块32KB足够如果控件比较多或者用了图片解码建议加到48KB。分配方式在LVGL 8.3里默认是标准C的malloc对应LV_MEM_CUSTOM设为0时用的是内置内存池设为1则走系统堆。4.2 推荐开启的功能宏/* 开启动画、抗锯齿、主题 */ #define LV_USE_ANIMATION 1 #define LV_USE_SHADOW 1 /* 启用FPS监控 */ #define LV_USE_PERF_MONITOR 1 #define LV_USE_MEM_MONITOR 1 /* 启用常用控件 */ #define LV_USE_BTN 1 #define LV_USE_LABEL 1 #define LV_USE_SLIDER 1 #define LV_USE_ARC 1 /* 字体大小按需开启体积从几KB到几十KB不等 */ #define LV_FONT_MONTSERRAT_14 1 #define LV_FONT_MONTSERRAT_20 1这里特别提醒一下字体不要全部开启。LV_FONT_MONTSERRAT_*系列每开一个Flash占用增加约10~20KB如果全开256KB的F103放完LVGL主程序就所剩无几了。只开你实际用到的字号。4.3 缓冲模式与刷新机制LVGL支持三种缓冲模式在lv_display.c的lv_display_create()阶段决定但lv_conf.h里的LV_DISP_BUF_SIZE会影响双缓冲是否可行单缓冲LVGL渲染一帧到缓冲区然后整个刷给屏幕。实现简单但刷屏期间CPU等待DMA传输画面可能有撕裂感。双缓冲LVGL在一个缓冲区被刷屏的同时往另一个缓冲区渲染下一帧显著减少撕裂。代价是内存翻倍。部分缓冲缓冲区只覆盖屏幕的一部分比如10~40行LVGL分段渲染分段刷屏内存占用最小适合RAM紧张的MCU。代价是CPU利用率稍高。对于F103这种48KB RAM的芯片我推荐用部分缓冲40行缓冲是性能与内存的均衡点。如果RAM充足比如F429的192KB直接上全屏双缓冲体验最好刷屏频率和动画流畅度都会有明显提升。5. 显示驱动移植flush回调的完整写法5.1 初始化你的屏幕IC以ILI9341为例初始化代码通常由屏幕模组厂商提供本质就是向IC写入一串寄存器序列。我把它整理成一个LCD_Init()函数放在lcd_driver.c里。初始化过程分为三步硬件复位RST拉低再拉高延时至少10ms、关闭屏幕、按厂商序列写入寄存器。最后打开显示、设置背光。static void LCD_Reset(void) { HAL_GPIO_WritePin(LCD_RST_GPIO_Port, LCD_RST_Pin, GPIO_PIN_RESET); HAL_Delay(20); HAL_GPIO_WritePin(LCD_RST_GPIO_Port, LCD_RST_Pin, GPIO_PIN_SET); HAL_Delay(20); } static void LCD_WriteCmd(uint8_t cmd) { HAL_GPIO_WritePin(LCD_DC_GPIO_Port, LCD_DC_Pin, GPIO_PIN_RESET); HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, cmd, 1, HAL_MAX_DELAY); HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_SET); } static void LCD_WriteData(uint8_t *data, uint16_t len) { HAL_GPIO_WritePin(LCD_DC_GPIO_Port, LCD_DC_Pin, GPIO_PIN_SET); HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, data, len, HAL_MAX_DELAY); HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_SET); }注意一个细节HAL_SPI_Transmit()阻塞等待如果数据量大一帧几十KB会长时间占用CPU。后面讲优化时再换成DMA。初始化序列太长这里贴核心的一部分void LCD_Init(void) { LCD_Reset(); LCD_WriteCmd(0x01); // Soft Reset HAL_Delay(10); LCD_WriteCmd(0x11); // Sleep Out HAL_Delay(120); LCD_WriteCmd(0x3A); // Pixel Format uint8_t pfmt 0x05; // 16bit/pixel (RGB565) LCD_WriteData(pfmt, 1); LCD_WriteCmd(0x36); // Memory Access Control uint8_t mac 0x48; // 根据屏幕方向调整 LCD_WriteData(mac, 1); LCD_WriteCmd(0x29); // Display ON LCD_WriteCmd(0x2C); // Memory Write }5.2 flush_cb函数LVGL与屏幕之间的桥梁LVGL要把渲染好的像素刷到屏幕靠的就是lv_disp_flush_cb_t类型的回调函数。这个函数的名字可以随便取但签名必须是void my_display_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p);area参数告诉你要刷哪块矩形区域左上角坐标和右下角坐标color_p指向缓冲区里的像素数据。我们的任务就是把这块区域的像素写到屏幕的对应位置。void my_display_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { LCD_SetWindow(area-x1, area-y1, area-x2, area-y2); LCD_WriteData((uint8_t *)color_p, (area-x2 - area-x1 1) * (area-y2 - area-y1 1) * 2); lv_disp_flush_ready(drv); // 这行绝对不能漏 }LCD_SetWindow是屏幕IC的“设置窗口”命令告诉屏幕“接下来我要往这个矩形区域写像素”。对ILI9341来说就是发0x2A列地址和0x2B行地址。static void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_WriteCmd(0x2A); uint8_t data[4] {x0 8, x0 0xFF, x1 8, x1 0xFF}; LCD_WriteData(data, 4); LCD_WriteCmd(0x2B); uint8_t data2[4] {y0 8, y0 0xFF, y1 8, y1 0xFF}; LCD_WriteData(data2, 4); LCD_WriteCmd(0x2C); // Memory Write }5.3 最关键的一行lv_disp_flush_ready()这一行是全文最重要、最容易忘、也最直接影响花屏的代码。LVGL的刷新机制是这样的它把一帧画面切成若干个矩形块依次调用flush回调发送给屏幕每发完一块必须调用lv_disp_flush_ready()通知LVGL“这块发完了你可以继续渲染/发送下一块”。如果这个函数漏掉LVGL会一直等待画面永远刷不完——最多显示第一块区域其余全是花屏或者白屏系统像死机了一样。所以我的建议是写完flush回调后第一件事就检查这行在不在然后检查它在不在错误的位置。正确位置是数据传输完全结束后DMA传输完成中断里或者在阻塞传输返回后立即调用。这里还有一个需要特别牢记的细节如果用DMA传输lv_disp_flush_ready()必须在DMA传输完成中断里调用而不是在调用HAL_SPI_Transmit_DMA()之后立刻调用。否则LVGL以为数据已经发完了接着渲染下一块前面数据的DMA传输还没结束缓冲区就被覆盖了——结果就是随机花屏、画面撕裂。这个坑我在双缓冲下踩过一次排查了一整天。6. 输入设备移植触摸屏与编码器6.1 注册输入设备驱动LVGL通过lv_indev_drv_t结构体管理输入设备。触摸屏和按键/编码器都走同一套机制只是设备类型不同。static lv_indev_drv_t indev_drv; void ili9341_touch_init(void) { lv_indev_drv_init(indev_drv); indev_drv.type LV_INDEV_TYPE_POINTER; // 触摸屏属于指针类设备 indev_drv.read_cb touchpad_read_cb; // 读取回调 lv_indev_drv_register(indev_drv); // 注册到LVGL }LV_INDEV_TYPE_POINTER对应触摸屏、鼠标LV_INDEV_TYPE_ENCODER对应旋转编码器LV_INDEV_TYPE_KEYPAD对应矩阵键盘。每种类型在LVGL内部的事件处理逻辑不同别混用。6.2 触摸屏读取回调如果用的是电阻屏或电容屏模块比如常见的XPT2046、GT911读取回调的写法如下void touchpad_read_cb(lv_indev_drv_t *drv, lv_indev_data_t *data) { uint16_t x, y; if (touch_read_point(x, y) 0) { >data-point.x (uint16_t)((uint32_t)touch_raw_x * LV_HOR_RES / 4096);>void encoder_read_cb(lv_indev_drv_t *drv, lv_indev_data_t *data) { static int32_t last_enc_diff 0; >int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_SPI1_Init(); MX_DMA_Init(); lcd_init(); lv_init(); lv_disp_drv_register(disp_drv); lv_indev_drv_register(indev_drv); ui_init(); // 创建你的界面 while (1) { lv_timer_handler(); HAL_Delay(5); } }HAL_Delay(5)给LVGL的定时器一个5毫秒的节拍这是最省事的方案。LVGL内部用lv_tick_get()获取当前时间如果使用HAL库在lv_conf.h里把LV_TICK_CUSTOM设为1然后实现uint32_t lv_tick_get(void) { return HAL_GetTick(); }这样LVGL的时间基准就和系统保持同步动画时序不会乱。7.2 在RTOS环境中如何调用如果你的系统已经跑了FreeRTOS不能直接用while(1)死循环刷LVGL。正确做法是创建一个专用任务void lvgl_task(void *param) { while (1) { lv_timer_handler(); vTaskDelay(pdMS_TO_TICKS(5)); } }任务栈大小建议1024字节的倍数我用的是2048字节够用。优先级比临界任务低一档别让它抢占通信任务。如果在FreeRTOS里遇到莫名其妙的死机先查任务栈大小。LVGL的lv_timer_handler()内部会处理大量控件事件栈不够时直接进HardFault。用uxTaskGetStackHighWaterMark()看一下栈余量稍微调大即可。7.3 帧率监控与内存监控LVGL自带性能监控开启后在屏幕右上角显示FPS和CPU占用率#define LV_USE_PERF_MONITOR 1开启LV_USE_MEM_MONITOR后左上角会显示内存池使用情况。这两个开关在调试阶段建议打开肉眼可见地定位性能瓶颈。实测数据F103RCT6 72MHzSPI 18MHz320x240 RGB56540行缓冲界面复杂度帧率(FPS)说明静态界面纯文字简单图形45~55画面很少全屏刷新滑块拖动30~38局部刷新性能OK全屏动画如换页20~28能接受但能感觉到拖影8. 花屏问题的系统排查方案8.1 花屏的本质原因分析花屏从现象上分几类原因各不相同现象原因排查优先级满屏雪花噪点SPI时钟太快/时序不稳或数据线接错先降SPI时钟再查接线颜色错乱红变蓝/反色RGB565与RGB888格式不匹配查LV_COLOR_DEPTH图像整体偏移/镜像屏幕扫描方向配置不对0x36寄存器调MADCTL的BGR/扫描位只有一小块有内容其余是杂色flush回调坐标窗口设置错查LCD_SetWindow参数刷屏时有撕裂横条纹单缓冲刷屏期间CPU介入升级双缓冲或减少缓冲区大小画面不动但代码在跑漏了lv_disp_flush_ready()立即检查flush回调8.2 我的排查顺序我一般按下面的顺序排查效率最高第一步先屏幕裸测。不初始化LVGL只初始化屏幕IC然后手动把整屏填充为纯色如红色0xF800。如果能正常显示纯色说明屏幕硬件、SPI通信、初始化序列这层没问题问题出在LVGL配置或flush回调上。第二步缩小缓冲区测试。把LV_DISP_BUF_SIZE从40行降到10行如果花屏消失说明是内存越界或DMA覆盖问题如果花屏依旧说明是配置或信号完整性问题。第三步检查配置宏。逐个核对LV_COLOR_DEPTH、LV_HOR_RES、LV_VER_RES。特别注意分辨率的单位是像素如果和你屏幕物理分辨率不一致LVGL渲染的坐标和屏幕IC实际扫描范围对不上结果就是画面错位或只显示一部分。第四步开监控。开启LV_USE_PERF_MONITOR和LV_USE_MEM_MONITOR看内存池是否溢出。内存不足时LVGL会自己打印out of memory之类的日志如果花屏伴随日志刷屏先把LV_MEM_SIZE调大再说。8.3 从“每次上电花屏状态不同”反推问题一个非常微妙的场景屏幕上电第一次刷新是花的但手动复位一次后又正常了。这种情况通常是上电时序问题——屏幕IC还没完全复位完成LVGL就开始发数据了。解决办法是在lcd_init()最开始加足够长的延时确保屏幕电源稳定再拉复位。我用的是HAL_Delay(100)100ms对绝大多数屏幕模组都够。如果还不行检查屏幕的供电电压和背光电路是否在MCU初始化之前就绪。9. 性能优化与进阶技巧9.1 用DMA释放CPU前面的flush回调用的是HAL_SPI_Transmit()这是阻塞传输一帧数据几百KB发下来CPU全卡在等待上。改成DMA传输后CPU可以在SPI搬数据的同时继续跑LVGL渲染帧率提升立竿见影。DMA版本的flush回调static volatile bool spi_tx_done false; void my_display_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { LCD_SetWindow(area-x1, area-y1, area-x2, area-y2); // 选LCD_DATA引脚 HAL_GPIO_WritePin(LCD_DC_GPIO_Port, LCD_DC_Pin, GPIO_PIN_SET); HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_RESET); spi_tx_done false; HAL_SPI_Transmit_DMA(hspi1, (uint8_t *)color_p, (area-x2 - area-x1 1) * (area-y2 - area-y1 1) * 2); // 注意这里不能直接调用 lv_disp_flush_ready() } void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { spi_tx_done true; // 在DMA传输完成后调用flush_ready原理参见第5.3节 // 但这里需要知道当前是哪个driver在刷屏所以在flush回调里存一个指针 if (current_disp_drv) { lv_disp_flush_ready(current_disp_drv); } } }在my_display_flush_cb里启动DMA前把current_disp_drv drv存下来DMA完成中断里就调用lv_disp_flush_ready(current_disp_drv)。需要注意HAL_SPI_Transmit_DMA()传入的数据指针必须保持有效直到传输完成。还有一个小技巧如果屏幕IC支持CS引脚可以不拉高保持低电平整个刷屏周期能省去来回切换GPIO的时间。这在刷全屏大色块时效果显著。9.2 降低刷新面积提升帧率LVGL是智能局部刷新的——只有脏区域lv_obj_invalidate()标记过的区域才会触发重绘。所以在设计界面时尽量避免全屏背景频繁变化。设置静态背景用lv_obj_set_style_bg_color()不如直接设置屏幕背景色后者不会触发整屏重绘。/* 正确直接设置屏幕背景 */ lv_obj_set_style_bg_color(lv_scr_act(), lv_color_hex(0x000000), LV_STATE_DEFAULT);9.3 合理选择缓冲区大小与双缓冲如果你的RAM允许双缓冲能显著减少撕裂感。双缓冲配置在lv_disp_drv_t里static lv_color_t buf1[LV_HOR_RES * 40]; static lv_color_t buf2[LV_HOR_RES * 40]; static lv_disp_drv_t disp_drv; disp_drv.buffer buf1; disp_drv.buffer2 buf2; // 有第二个buffer就是双缓冲判断触发条件lv_conf.h里的LV_DISP_BUF_SIZE决定了“单缓冲的尺寸上限”当缓冲区大到能覆盖全屏时自然就全屏刷了。部分缓冲双缓冲的性价比很高比如两个40行缓冲总共80行RAM占用只有320*40*2*251200字节刚好卡在F103RCT6的48KB边缘如果想跑双缓冲建议用更大RAM的芯片。对于F103系列如果想流畅跑LVGL我建议换F407或F429RAM从192KB起步跑双缓冲复杂界面毫无压力。10. 完整示例代码与配置速查10.1 工程文件结构Project/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── lv_conf.h # LVGL配置从模板复制 │ │ └── lcd_driver.h │ └── Src/ │ ├── main.c │ ├── lcd_driver.c │ └── lv_port.c # LVGL显示/输入设备注册 ├── Middlewares/ │ └── LVGL/ │ └── lvgl/ # LVGL源码根目录 └── STM32F103RCTX.ioc10.2 lv_port.c 核心代码#include lvgl.h #include lcd_driver.h static lv_disp_draw_buf_t disp_buf; static lv_disp_drv_t disp_drv; static lv_color_t buf[LV_HOR_RES * 40]; static lv_disp_drv_t *current_disp_drv; void my_display_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p); void lv_port_display_init(void) { lv_disp_draw_buf_init(disp_buf, buf, NULL, LV_HOR_RES * 40); lv_disp_drv_init(disp_drv); disp_drv.flush_cb my_display_flush_cb; disp_drv.draw_buf disp_buf; disp_drv.hor_res LV_HOR_RES; disp_drv.ver_res LV_VER_RES; lv_disp_drv_register(disp_drv); } void my_display_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { LCD_SetWindow(area-x1, area-y1, area-x2, area-y2); LCD_WriteData((uint8_t *)color_p, (area-x2 - area-x1 1) * (area-y2 - area-y1 1) * 2); lv_disp_flush_ready(drv); }10.3 配置速查表配置项推荐值说明LV_COLOR_DEPTH16RGB565少改错的关键LV_HOR_RES/LV_VER_RES320 / 240和你的屏幕一致LV_DISP_BUF_SIZELV_HOR_RES * 4040行缓冲LV_MEM_SIZE32KB按界面复杂度调整LV_TICK_CUSTOM1用HAL_GetTick作时基LV_USE_PERF_MONITOR1调试后关显示帧率SPI时钟≤ 40MHz建议18MHz起步不稳就降频DMA模式Normal不能选Circular11. 常见问题与排查技巧实录11.1 问题速查表现象大概率原因解决方案编译时报undefined reference to lv_tick_getLV_TICK_CUSTOM设为1但没实现函数在任意.c文件里实现uint32_t lv_tick_get(void){return HAL_GetTick();}上电白屏但MCU代码在跑屏幕初始化有问题或背光没打开先裸测屏幕能否显示纯色排除LVGL层触摸没反应输入设备坐标映射不对串口打印原始坐标确认ADC方向和缩放动画卡顿刷新面积太大或SPI太慢降低SPI分频值提升时钟或优化界面减少全屏刷新点击控件没反应但界面正常输入设备和显示分辨率不一致检查触摸坐标映射是否使用同一套LV_HOR_RES/VER_RES程序运行一段时间后死机LVGL内存池耗尽调大LV_MEM_SIZE检查是否频繁创建控件未删除和FreeRTOS一起用时死机任务栈过小调到2048字节以上用高水位宏检测11.2 独家避坑经验坑一LVGL 8.3的lv_conf.h必须放在头文件搜索路径最前面。如果同时存在多个lv_conf.h编译器会优先找左边路径导致你的配置根本没生效全部走默认值。排查办法在lv_conf.h末尾故意加一行错误代码如果编译没报错说明这个文件根本没被包括进来。坑二SPI时钟讲究“先稳后快”。很多花屏问题是SPI通信误码导致时钟跑太高、总线过长、没有上拉电阻都会产生噪声。调试阶段先用18MHz跑通确认流程没问题再尝试提高。升级到40MHz时必须用示波器看信号质量否则就是碰运气。坑三屏幕初始化序列不要轻易简化。有些国产屏幕模组的初始化序列和标准ILI9341序列略有差异直接套用网上通用序列可能白屏或颜色异常。最稳妥的办法是找屏幕厂商拿到原厂初始化代码然后逐条核对。坑四电阻触摸屏的坐标滤波不能省。XPT2046这类ADC触摸IC在按下时会抖动直接上报坐标会导致控件乱跳、误触。简单的做法是在回调里做一次滑动平均滤波或者至少做一次“按下后延时10ms再采样”的消抖。坑五LVGL的日志输出默认走串口。如果界面卡住且不确定原因打开串口看LVGL的日志。在lv_conf.h里开启LV_USE_LOG并设置级别为LV_LOG_LEVEL_WARN很多运行期错误比如内存不足、事件死锁都会打到这里。从第一次接触LVGL到现在我把8.3移植流程跑熟了之后新项目接屏幕基本上半小时能出画面。但回过头看最开始踩的那些坑——颜色位数不对、漏了flush_ready、DMA完成回调位置错误——全都是在最基础的地方翻车。如果你照着这篇文章操作还是花屏我强烈建议你从“裸测纯色填充”这一步重新走一遍把问题逐层隔离别一上来就怀疑LVGL。大概率问题就出在SPI通信或者颜色格式上这两关过了LVGL移植就算成功了80%。后面的时间可以用来琢磨界面设计和动画效果那才是真正有意思的部分。