在嵌入式GUI这个圈子里,LVGL的名字现在已经不新鲜了。9.2版本出来之后,渲染架构和API风格比8.x改动不小,很多从老版本迁移过来的朋友在移植时踩了不少坑。这次我拿正点原子的阿波罗STM32F429开发板做底子,把LVGL 9.2完整跑起来,覆盖显示驱动接入、触摸适配、DMA2D加速和性能调优几个环节。本文写给正在准备在STM32F4系列上跑LVGL 9.x的开发者,尤其是手头有阿波罗开发板或者类似RGB接口LCD方案的板子,可以直接照做,省去翻源码和查文档的时间。
有人可能会问,STM32F429主频180MHz,跑LVGL这种带复杂矢量图形和阴影效果的GUI,行不行?我的回答是,在正确优化下,完全够用。F429内部虽然只有256KB RAM,但板载SDRAM把内存短板补上了,RGB屏幕直接挂在LTDC控制器上,刷新数据和内存复制还能交给DMA2D硬件加速。这套硬件组合,正是LVGL这类中型GUI框架比较理想的运行平台。
1. 移植前的准备与环境搭建
1.1 阿波罗开发板硬件资源盘点
拿到阿波罗STM32F429这块板子之后,不要马上打开工程,先把硬件资源梳理一遍。主控是STM32F429ZIT6,Cortex-M4F内核,最高主频180MHz,片上Flash 2MB,RAM 256KB。这些参数决定了两件事:一是计算性能能不能撑起LVGL 9.2的渲染场景,二是内存带宽是否满足图形帧缓冲的读写需求。实测下来,在800x480分辨率、RGB565颜色格式的前提下,单帧裸数据量是800像素乘以480行再乘以2字节,大约750KB。这个数据量放在片内RAM里肯定不现实,所以板载的SDRAM是刚需。
阿波罗板卡上有一颗32MB的SDRAM,型号通常是W9825G6KH,挂在FMC的Bank1区域,地址从0xC0000000开始。屏幕方面,标配是4.3寸480x272或者7寸800x480的RGB电容触摸屏,接口直接走LTDC,触摸屏芯片常见的是GT9147和FT5206。这些信息在移植LVGL之前都必须确认清楚,因为显示驱动和触摸驱动的接入方式,直接由这些硬件细节决定。
内存带宽这里也想多说一句。SDRAM数据总线是16位的,工作频率通常在100MHz以上,理论带宽能到400MB/s左右,实际读写下受刷新周期和总线抢占影响,打个六折也有240MB/s。对一个800x480的RGB565屏幕来说,60Hz刷新率需要消耗800×480×2×60字节每秒,大约是46MB/s的读取带宽。也就是说,光屏幕显示就吃掉差不多五分之一的SDRAM带宽,剩下还有LVGL渲染、触摸和程序数据访问,所以后面优化时一定要想着DMA2D和双缓冲,否则CPU直接搬数据会把总线搅得很慢。
1.2 软件工具链选择
在LVGL 9.2的移植方式上,官方推荐用CMake,但对于平时用Keil、IAR或STM32CubeIDE的嵌入式工程师来说,直接把源码包添加进工程更顺手。我的建议是,个人学习或快速验证场景,用Keil MDK按原来的习惯来,把LVGL源码加入分组即可;如果考虑后续做PC端模拟器调试和自动化测试,那就按CMake方式建一个独立工程,用Visual Studio Code做PC模拟器,等逻辑验证完后再放回板卡。两条路线各有适用场景,并不冲突。
LVGL的获取比较直接,到GitHub仓库下载发布版源码,或者从正点原子资料包获取已经适配过的旧版本。这里我要强调一点:LVGL 9版本之后,项目结构做了很大调整,很多以前的文件目录都变了,比如lvgl目录下面拆分成了src、demos、examples等子模块,如果你手里的是8.x时代的移植模板,千万别直接套用到9.2上。尤其是lv_conf.h的配置方式、缓冲区大小设置,以及如何添加头文件,这些细节稍有不慎就会导致编译失败。
工具链版本也有讲究。Keil最好用5.29及以上版本,IAR建议8.50以上,STM32CubeIDE则建议用2023年之后的新版本。原因是LVGL 9.x的源码里用到了部分C11特性,老编译器对复合字面量、静态断言的支持不完整,很容易出现看不懂的报错。我最初用同事电脑上的老版Keil编译,一堆奇怪语法错误,换了新版编译器之后代码原封不动就过了,所以遇到奇奇怪怪的编译错误,先怀疑编译器版本,再怀疑自己的代码。
2. LVGL 9版本的核心变化与设计思路
2.1 LVGL 9.2比8.x改了什么
LVGL 9.x停掉了很多8.x时期的旧API,重构了渲染管线。之前在LVGL 8时代,一个典型的显示驱动接口在lv_disp_drv_t里配置,注册回调即可。到了9.x,结构体改名为lv_display_t,初始化函数和刷新回调也有了变化,最直观的一点是:在LVGL 9里不再有lv_disp_drv_register函数,而是通过lv_display_create加lv_display_set_flush_cb来注册显示器和刷新回调。触摸输入设备也一样,从lv_indev_drv_t换成了lv_indev_t,通过lv_indev_create创建输入设备,再设置读取回调。
另一个显著变化是颜色模型处理。LVGL 9对RGB565、RGB888、ARGB8888等颜色格式的支持更加规范,渲染内部还引入了软件着色器来支撑多颜色格式和Alpha混合。这对硬件内存和CPU资源的消耗比8.x更大,所以在STM32F429这类中低端MCU上,需要谨慎选择颜色深度。多数情况下我建议直接用RGB565,一来是F429的LTDC原生支持RGB565,二来能省一半显存带宽,间接提升帧率。
了解这些变化后,你就会发现,网上那些基于LVGL 8.3的移植教程,并不能直接搬运,很多接口名和结构体布局都不一样。我见过不少人拿着8.3的模板硬套9.2,最后编译报错一大堆。这种时候建议从头到脚重新初始化显示设备和输入设备,以官方文档和源码为准,不要心存侥幸。
2.2 移植过程中的三大核心模块
一个LVGL图形系统跑起来,至少需要三个模块协同工作:显示驱动、输入驱动、操作系统抽象层。显示驱动负责把LVGL绘制好的帧数据搬运到LCD面板上,通常的做法是通过LTDC控制器的显存地址来直接呈现内容;输入驱动负责把触摸屏坐标转换成LVGL事件;操作系统抽象层在裸机下可以直接用定时器中断生成LVGL心跳,但如果跑FreeRTOS,就要把tick和时间管理切到系统节拍上。
在阿波罗STM32F429上,我选择了“裸机加单缓冲”的方案启动,先把显示和触摸跑通,再考虑上FreeRTOS和双缓冲。这个顺序很重要,一上来就追求双缓冲和垂直同步,很容易把问题复杂化。先把功能跑起来,然后再做优化,是我个人一直比较推荐的移植节奏。
需要特别提醒的是,LVGL 9.x内部有自己的内存分配和缓存管理器,默认情况下从堆里分配。在裸机环境里,要保证启动文件里的堆空间足够大,至少预留32KB以上。如果堆太小,界面一复杂,LVGL申请不到内存,屏幕上会出现局部不刷新甚至卡死的现象。如果你把LVGL的渲染buffer和帧缓冲放到SDRAM,那堆可以留在片内SRAM里,速度更快,也更稳定。
3. 核心移植过程——一步步实现
3.1 用STM32CubeMX初始化底层外设
虽然阿波罗开发板自带的例程已经把时钟、FMC、LTDC和触摸芯片都配好了,但我是从CubeMX重新生成的工程,这样便于锁定每一步的作用。时钟方面,HSE用25MHz外部晶振,主频拉到180MHz,APB2时钟拉到90MHz,同时要保证LTDC像素时钟在合理范围内。以800x480屏幕为例,若刷新率60Hz,像素时钟大约在33.3MHz左右,可以在CubeMX的时钟树里计算得到。像素时钟设置太高或太低,屏幕表现完全不一样:太高液晶翻转不过来,容易花屏;太低画面刷新慢,肉眼可见闪烁。
SDRAM配置属于比较容易翻车的地方。FMC接口需要配置SDRAM时序参数,包括行地址、列地址、突发长度、CAS延迟等。W9825G6KH是4个Bank、13位行地址、9位列地址,总计32Mbit乘以8等于32MB。时序上我参考正点原子的例程,将关键参数填入CubeMX对应的寄存器,比如RCD=2、RP=2、RC=6、WR=2,这样SDRAM控制器才能稳定工作。如果之前没有接触过SDRAM,建议直接用厂家的初始化例程,不要自己猜时序。
LTDC部分,最重要的是配置Layer1的窗口大小、像素格式和帧缓冲地址。在裸LVGL中,帧缓冲地址必须是4字节对齐的,大小要等于屏幕宽高乘以像素字节数。这里建议用CubeMX图形化配置,先指定一个SDRAM中的起始地址,比如0xC0000000,并在上面连续分配两个缓冲区,方便后续LVGL直接使用。
3.2 将LVGL源码加入工程
工程初始化完毕后,回到Keil或STM32CubeIDE,把下载好的LVGL 9.2源码按照如下方式加入工程。
第一,lvgl/src目录是整个库的核心,需要全部编译,里面又细分了core、draw、font、widgets、themes等多个子目录,编译时都要包含进来。第二,lvgl/examples和lvgl/demos按需加入,比如先用demos里的widgets demo来验证渲染。第三,lv_conf.h是配置文件,虽然LVGL 9.x已经弱化了一些配置项,但颜色深度、缓存大小、裁剪和字体开关仍然在这个文件里设置。
为了编译顺利,有几个宏必须正确设置:LV_COLOR_DEPTH设为16,对应RGB565;LV_MEM_SIZE设置为SDRAM或者片内RAM的某个值,建议至少32KB起步,供LVGL内部内存管理使用;LV_USE_DRAW_SW默认开启,软件渲染的缓冲区需要足够空间,建议给到4KB以上,不然复杂控件会明显卡顿。如果你把LVGL的渲染内存放到SDRAM里,读取速度会比片内RAM慢不少,但只要显示缓存和DMA2D配合得当,肉眼差别不大。
阿波罗开发板自带的SDK工程里,如果之前带过其他GUI库或者旧版LVGL,建议把旧版本整个删干净,再导入9.2,避免头文件冲突。我踩过一次,8.3和9.2的lvgl.h混在同一个工程里,编译链接时符号重定义,报错提示怪异,排查了半天才明白是两个版本的头文件同时被包含了。
3.3 实现显示刷新回调
LVGL渲染好一个新帧后,会通过刷新回调把数据交出来。LVGL 9.2中,注册显示器和回调的方式大致是这样:
static lv_display_t *disp; disp = lv_display_create(hor_res, ver_res); lv_display_set_flush_cb(disp, disp_flush_cb); lv_display_set_buffers(disp, buf1, buf2, buf_size, LV_DISPLAY_RENDER_MODE_PARTIAL);这里的disp_flush_cb就是我们自己写的函数,LVGL会把待刷新的区域坐标和颜色数据传进来,我们负责把这块数据写到LTDC指定的显存区域。LTDC控制器本身是从显存地址读数据并送到LCD的,所以如果我们的显存地址和帧缓冲地址重合,刷新动作就退化成了“搬运数据到显存”。
我的裸机实现思路是:定义一块显存位于SDRAM,当LVGL刷新回调传入数据后,把数据写入LTDC Layer1对应的显存地址。最直接的做法就是memcpy,先把功能跑通:
void disp_flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *pixel_p) { uint32_t addr = ltdc_framebuf + (area->x1 * 2 + area->y1 * lcd_width * 2); uint32_t size = (area->x2 - area->x1 + 1) * (area->y2 - area->y1 + 1) * 2; memcpy((void *)addr, pixel_p, size); lv_display_flush_ready(disp); }对,这个最简单的版本并没有用DMA2D,而是直接memcpy搬运数据。好处是逻辑简单,便于排错;坏处是memcpy在F429上并不能完全发挥内存带宽,复杂界面下帧率有限。等基础流程没问题后再换DMA2D方案,性能释放会更充分。
3.4 触摸输入驱动适配
触摸芯片一般通过I2C或SPI接入MCU,正点原子的4.3寸屏常用GT9147,7寸屏常用FT5206。拿到触摸驱动源码后,把原始坐标读出来放到LVGL的输入设备回调里。LVGL 9.2的触摸回调与8.x不同,需要在回调里填充一个数据结构,并通过返回值告诉LVGL是否有触摸事件。
以GT9147为例,驱动读取坐标后,在read_cb里这样上报:
void touchpad_read_cb(lv_indev_t *indev, lv_indev_data_t *data) { static lv_coord_t last_x, last_y; uint16_t x_raw, y_raw; uint8_t touched = get_touch_point(&x_raw, &y_raw); if (touched) { last_x = x_raw; last_y = y_raw; >while (1) { lv_timer_handler(); delay_ms(5); }lv_timer_handler()会处理所有LVGL内部已注册的任务,包括动画、输入设备轮询、刷新回调触发等。5ms的延时既能保证界面响应流畅,又不会让CPU一直满转。如果是带FreeRTOS的环境,可以创建一个专门的任务来调用lv_timer_handler(),优先级设中等即可,或者在空闲任务里调用,但注意不要阻塞其他高优先级任务。
LVGL的心跳也依赖一个全局时钟,默认通过lv_tick_inc()喂给LVGL。在STM32上,可以在SysTick中断里调用:
void SysTick_Handler(void) { lv_tick_inc(1); }这里传入的1表示1ms的时间步长,LVGL会根据这个时间戳驱动动画进度和长按检测。如果忘记喂心跳,动画不会动,触摸长按事件也异常,这是一个非常隐蔽的低级错误,却很常见。
4. 性能优化与显示效果调试
4.1 用DMA2D分担数据搬运
裸机LVGL在F429上最大的性能瓶颈是颜色数据搬运。如果显示缓存指定在SDRAM,LVGL软件渲染出的像素都必须写回SDRAM,接着LTDC再从SDRAM读取数据送往屏幕,这两个过程如果都用CPU耗时,不仅浪费时间,还可能造成画面撕裂。
DMA2D在这里能派上用场。我们可以把LVGL输出的局部缓冲通过DMA2D传输到LTDC的整个帧缓冲地址。DMA2D支持存储器到存储器传输,不需要CPU参与。示例配置如下:
void disp_flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *pixel_p) { uint32_t dest = ltdc_framebuf + (area->x1 + area->y1 * lcd_width) * 2; uint32_t len = (area->x2 - area->x1 + 1) * (area->y2 - area->y1 + 1); DMA2D_ConfigTransfer(DMA2D_M2M, (uint32_t)pixel_p, dest, len * 2); DMA2D_StartTransfer(); while (DMA2D_GetTransferState() != DMA2D_TRANSFER_DONE) {} lv_display_flush_ready(disp); }实际测试下来,开启DMA2D后,简单控件界面渲染帧率能提升30%以上,复杂图表界面由于主要瓶颈还在软件渲染,提升幅度没有那么大,但也足够改善整体流畅度。DMA2D配置要注意源地址和目的地址的字节对齐,以及传输数据长度必须是4的倍数,否则有些DMA2D外设配置会导致字节错位。
4.2 垂直同步和撕裂问题处理
屏幕撕裂是显示系统常见问题,原因在于LTDC刷新屏幕的过程是自顶向下逐行扫描的,如果此时显存里的数据被修改,屏幕上的画面就会出现上下两部分属于不同帧的情况。根除撕裂的办法主要有三种:一是双缓冲,二是重传同步,三是抽时间避开扫描区。
在LVGL中,最优雅的做法是开双缓冲,结合LVGL的渲染机制,让LVGL在后台绘制新的帧,前台LTDC始终显示另一块缓冲区的旧帧,刷新完成后再切换显存地址。这种方式需要占用的SDRAM空间大概是两个完整帧的大小。800x480分辨率下,两个RGB565帧缓冲需要约1.5MB,阿波罗板卡32MB的SDRAM完全没有压力。
如果嫌双缓冲配置麻烦,也可以在LTDC的垂直消隐期触发更新,利用行中断或VSYNC中断作为将新数据拷贝到显存的时间窗口。但精确控制在行扫描区间内比较费事,我个人更推荐双缓冲方案,成熟而且对LVGL这种自动管理渲染目标的框架很友好。
LVGL配置双缓冲其实很简单,在lv_display_set_buffers里传入两个buffer指针即可:
lv_display_set_buffers(disp, buf1, buf2, buf_size, LV_DISPLAY_RENDER_MODE_DIRECT);当LVGL检测到有两个缓冲区时,会自动在渲染过程中做flush和buffer切换,配合LTDC的垂直同步中断,可以做到无撕裂显示。这里把buf1和buf2都放到SDRAM中,再让LTDC的两个层地址分别指向它们,效果最理想。
4.3 帧率测量和瓶颈定位
调优之前先量化,看现在的帧率到底是多少。可以在disp_flush_cb里统计每秒刷新次数,用串口或者OLED打印出来。做法是维护两个变量,一个是帧计数,另一个是上一秒的时间戳;当两次刷新时间超过1秒时,计算差值得到帧率。
volatile uint32_t frame_count = 0; volatile uint32_t fps = 0; void disp_flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *pixel_p) { frame_count++; // 每秒钟计算一次fps if (HAL_GetTick() - last_tick >= 1000) { fps = frame_count; frame_count = 0; last_tick = HAL_GetTick(); } // 原有flush逻辑 }实测下来,在阿波罗F429板上,RGB565、800x480的屏幕,纯软件渲染跑一个复杂仪表盘demo,不开DMA2D时帧率大约20到25帧;开启DMA2D后能到35到40帧;再开双缓冲和垂直同步,帧率虽然不是最高,但画面稳定平滑,感官体验反而更好。对工业HMI来说,稳定比峰值更重要,这个结论在后面选型时很有参考价值。
5. 常见问题与排查技巧
5.1 屏幕白屏或黑屏
白屏和黑屏是移植中最常见的问题。白屏一般说明LTDC初始化和屏幕配置没问题,但LVGL没有数据输出,重点检查LVGL的flush_cb是否被执行、显示缓冲区地址是否合法、颜色深度是否匹配。黑屏则可能出在LTDC或者背光控制上,先检查背光引脚有没有被拉高,再看LTDC的Layer配置和时钟是否正常。
我遇到过一种情况:白屏但把光标放在flush回调里能看到LVGL有数据输出,后来发现是我把显存地址写到SDRAM后,LTDC读到的地址和SDRAM实际初始化区域不一致。SDRAM的Bank选择或者行地址宽度任何一个配置错位,都会导致这种“看起来有数据但显示不出来”的诡异问题。排查思路是用J-Link或ST-Link在调试器里检查SDRAM的数据能否正常读写,如果读写验证都失败,基本就可以判定SDRAM配置出了问题。
还有一种白屏情况是LVGL颜色深度和LTDC像素格式对不上。比如LVGL配了16位颜色,但LTDC Layer配置成了ARGB8888格式,那么LVGL输出的数据会被当成完全透明的像素,显示结果就是白屏。检查LTDC的PFCR寄存器或者CubeMX里的Pixel Format,确保和LV_COLOR_DEPTH保持一致。
5.2 触摸失灵或方向错乱
触摸完全失灵,先确认触摸芯片的中断引脚有没有接入NVIC,其次要看I2C时序对不对。GT9147读ID一般需要上电后等一小段时间,如果初始化太快,芯片还没准备好,读ID就会失败。触摸方向错乱是软件映射问题,以GT9147为例,有时需要交换x和y,有时需要镜像坐标,这些都要根据屏体和贴合方式微调。
排查触摸问题时,一个捷径是在LVGL的例子程序里跑一点回显代码,把read_cb获取到的原始坐标打印到串口,用手指划过屏幕,对比输出数值和位置的对应关系。反馈坐标范围是否线性、方向是否相反,通常一眼就能看出来,比盯着代码猜要有用得多。如果触摸坐标一直处于0点,多半是触摸芯片的中断或I2C通信没有初始化成功。
还有一种容易忽略的情况:触摸芯片的复位引脚和LCD复位共用同一个GPIO,如果LCD复位后触摸芯片没有单独重新初始化,GT9147会处于休眠状态,I2C读不到坐标。解决办法是在GT9147初始化前给一个低电平脉冲,让芯片彻底复位,然后再进入正常读取流程。
5.3 编译和链接问题
LVGL 9.2的工程在底层硬件上编不过,多半是编译宏和芯片选择不匹配所致。检查芯片相关宏是否定义,比如STM32F40x系列是否需要定义STM32F429xx;芯片类型宏会影响到部分底层驱动的头文件选择。此外,LVGL源码中涉及浮点计算的部分会占用较多Flash,阿波罗的2MB Flash空间够用,但其他F4型号如果Flash只有512KB,就要注意裁剪字体和widget组件,否则链接时会报区域溢出。
另一个容易掉坑的地方是C标准。LVGL 9.x本身要求较高的C标准,在Keil里要在Options for Target - C/C++ - Language / Code Generation里选择C99以上标准,否则有些声明方式会报错。IAR相对宽容,但如果代码中使用了复合字面量和变长数组,旧标准也会报错。把标准选对,这类问题能少六七成。
链接时如果报undefined symbol,也不用慌张,多半是漏加了源码文件。LVGL 9.x里有些功能模块化程度很高,比如用到图表控件就要把lv_chart.c加进工程,用到动画就要确保lv_anim相关文件在编译列表里。最保险的办法是把整个lvgl/src目录全部加进来,让编译器把所有源文件都编一遍,虽然编译时间会变长,但至少不会因为缺文件而报链接错误。
5.4 画面闪烁和刷新不完整
画面闪烁通常和缓冲策略有关。单缓冲模式下,LTDC一直在扫描显存,LVGL一边写入新的渲染数据,一边被屏幕读取,就会出现局部闪烁或噪点。换双缓冲能显著改善这个问题。刷新不完整,画面出现一片一片的“马赛克”或者局部残留,多半是flush回调里area坐标计算有误,刷新区域和实际写入的显存地址错位。
检查area坐标时,有一个小技巧:在flush回调里临时把传入区域打印出来,对比屏幕上的实际刷新范围。如果打印的区域和应该更新的区域偏差正好是屏幕宽度的一半,那么很有可能是area->x参数和y参数按字节地址计算时,少乘了颜色深度。这种错误在把代码从8.3移植到9.2时尤其常见,因为8.x的area坐标为1字节对齐,而9.x支持多字节像素格式后,坐标到地址的映射要重新确认。我自己栽在这一类问题上至少花了半天,后来干脆把坐标到地址的换算单独抽出一个小函数,所有地方统一调用,这才彻底规避。
6. 经验体会与扩展方向
最后再多聊几句自己的体会。我在整个移植过程中,最大的教训是不要把LVGL 8.x的习惯带到9.x。LVGL 9的API风格与旧版差别太大,官方在迁移指南里也明确指出了各种替换关系。如果你从8.x迁移到9.2,建议边看官方迁移文档边动手改,哪怕手头有8.3的成品代码,也不要照搬。
做移植时,也可以先在PC上搭LVGL 9.2的模拟器练习,用Visual Studio Code配合CMake一键编译运行,先把界面效果调好,再回来写板端驱动。这样能把“显示效果层面的问题”和“底层硬件层面的问题”分开,排查起来省力得多。我在阿波罗板上正式调试之前,就是在PC模拟器里把demo界面改得差不多了,上板后主要处理的是驱动适配和帧率优化。
如果想把系统进一步产品化,可以考虑往LVGL工程里加入FreeRTOS。LVGL本身支持多任务环境下自动加锁和定时刷新,只要把LVGL的心跳从裸机定时器切换到FreeRTOS的tick就可以。配合双缓冲和DMA2D,系统整体流畅度还能再上一个台阶。另外,如果后续资源紧张,LVGL 9还支持GPU适配接口,官方提供了一些draw unit扩展机制,可以直接对接外置GPU或者DMA控制器,不过对F429来说,DMA2D已经足够满足大多数界面需求了。
这个项目做完之后,下一步我还打算在阿波罗板上跑一套完整的LVGL工业应用,把表盘、图表、动画都放入正式环境中测试,再看看F429在这种带资源消耗负载下的实际表现。毕竟LVGL 9.2的新特性不只是个小升级,它带给嵌入式产品的是更接近现代UI框架的体验,值得静下心来撸一遍。
个人体会放在最后说:移植过程中,我会先把“能用”放第一位,再谈“好用”。先把显示、触摸跑通,再考虑DMA2D和双缓冲,否则问题叠着问题,很容易怀疑人生。嵌入式开发就是这样,一步一步验证,比一鼓作气推到成品要靠谱得多。希望这次的实战记录,能帮你少走几段弯路。