去年帮朋友调一块7寸800x480并口屏,主控是STM32F103ZET6,LVGL版本用的v8。他第一版demo跑起来,页面切换卡到只能叫“看幻灯片”,一个按钮按下去,等半秒钟才出动画。当时第一反应也是怪LVGL太重,后来用示波器一段一段量,才发现瓶颈根本不在控件渲染,而在最后一步“把像素搬到屏幕”这件事上。把这条路径改成DMA异步搬运之后,整体手感完全换了一个级别。
这篇文章就把整个优化过程拆开讲一遍。目标读者是手头有F103或者类似Cortex-M3低端主控、想用LVGL跑大屏并口屏的朋友。内容涉及数据量估算、DMA选型、flush_cb改造、时序调整,以及几个我实际踩过的坑。每一步都可以直接照抄,也能帮你少走不少弯路。
1. 先把账算清楚:800x480一帧到底有多少数据
1.1 一个F103躲不开的数学题
800x480分辨率,RGB565色深,一帧无压缩的像素数据量是:
800 × 480 × 2 = 768000字节,也就是750KB左右。
而STM32F103ZET6的SRAM最大只有64KB。别说整帧缓冲了,连一帧的三分之一都塞不下。这也是很多人一听“F103跑800x480”就觉得不现实的原因,内存先判了死刑。
但LVGL这套GUI框架从来就不是全屏缓冲的玩法,它走的是脏矩形局部刷新策略。每次界面变化,它只重绘发生变化的区域,不是把整块屏推倒重画。所以真正决定流畅度的指标,不是屏幕物理尺寸,而是“每次变化多少像素,以及这些像素以多快的速度送进屏幕控制器的GRAM”。
接着说搬运速度。F103的FSMC走的是AHB总线,时钟和HCLK相等,72MHz。典型的一个16位写周期,算上地址建立、数据建立和总线转换,大概3到4个HCLK。折算下来理论带宽在36MB/s左右,实际工程上能有20MB/s已经算是时序压得比较激进的水平。
拿这个数字算全屏刷新:
768000字节 ÷ 20000000字节/秒 ≈ 38ms。
也就是说,即便什么都不干,光是把一帧全屏数据从内存写进GRAM,也要接近40ms,换算成帧率也就25fps左右,还没算FSMC时序保守的情况和LVGL渲染本身的时间。所以结论很直接:不优化搬运路径,F103跑800x480大屏,卡是必然的。
1.2 LVGL的局部刷新机制决定了优化方向
LVGL内部有一套无效区域机制。控件位置、大小、内容变化时,它会调用invalidate接口往无效区域链表里插入矩形块,刷新任务再把这些小矩形合并成尽可能少的几个大矩形,然后逐块重绘。
举个例子,界面上一个100x100像素的按钮被按下,按下效果的重绘范围通常就控制在这100x100附近。这个面积折算成RGB565数据是20KB,按20MB/s的搬运速度算,1ms就搬完了,体感上根本不可能卡。
但现实是,初次上电的页面切换、全屏弹窗、横向滑动的列表,都会触发大面积刷新。面积只要到半个屏幕,一帧就是375KB,搬运时间就奔着20ms去了。如果这20ms里CPU还得逐像素地写FSMC,那整个系统就像被按住了一样,什么都干不了。
所以LVGL的刷新机制给我们的信息很清楚:优化要围绕“搬运”来做。搬得快,界面就快;搬运不再占用CPU,CPU就有空去渲染下一帧,界面就更跟手。
2. 为什么说DMA方案是F103刷大屏的最划算选择
2.1 DMA和FSMC的关系,得先说清楚
DMA的本质是让数据从一个地址搬到另一个地址,全程不需要CPU干预。如果能把这个“另一个地址”指向FSMC映射出来的LCD数据寄存器地址,那就等于实现了DMA刷屏。
这里有一个关键知识点,也是很多教程含含糊糊带过去的地方:F103要用DMA2,别用DMA1。原因在于DMA2挂在AHB总线上,可以访问FSMC、BCKP SRAM这些区域;而DMA1挂在APB外设总线侧,想碰FSMC的地址空间基本没戏。网上不少人的DMA刷屏代码跑不出效果,查到最后就是DMA1/DMA2用错。
DMA传输模式选择memory-to-memory,也就是Mem2Mem。这个模式不依赖外设请求线,只要软件触发,源地址和目的地址填好,DMA就会一直搬。对于刷屏来说,源地址就是LVGL的绘制buffer地址,目的地址就是LCD数据寄存器映射地址,比如0x60000002。
数据宽度这里一定要配成半字模式。因为并口屏的数据总线是16位,DMA如果用字节模式往16位外设地址上写,数据会错位。这一点配错,画面花到你怀疑人生。
2.2 为什么必须上双缓冲
很多人第一次改DMA时,直接把flush_cb里的for循环换成了HAL_DMA_Start_IT,然后发现画面是花的。原因也很简单:
LVGL的flush_cb是异步回调。你启动DMA之后函数立刻返回,LVGL觉得“这一块已经刷完了”,转头就开始往同一个draw buffer里渲染下一帧。DMA才搬了一半,buffer里的数据就被新渲染覆盖了,不花屏才怪。
解决办法有两个方向。一个是启动DMA之后阻塞等待传输完成再返回,但这样CPU在搬数据期间依然是空转的,整体提升有限。另一个就是LVGL的双缓冲机制:
配置两块大小相同的draw buffer,LVGL往buffer A里渲染的时候,DMA正在搬运buffer B的数据。等DMA完成,中断里调用lv_disp_flush_ready通知LVGL“这块buffer空了,你可以继续画”,LVGL再切回buffer A继续画。两边就像流水线一样并行工作。
F103的内存有限,这个双缓冲不可能做到全屏800x480,但可以做部分缓冲,比如高度10到20行的两块buffer。LVGL自带的分块刷新机制会把一个大的刷新区域切成多个小块,每一块的大小都不会超过你的buffer容量。所以部分缓冲完全够用,设计上也是这么预期的。
3. flush_cb实战改造:一段能直接抄的DMA异步实现
3.1 FSMC初始化与LCD地址映射的几个要点
先说FSMC。并口屏在FSMC眼里就是一块普通的SRAM,配置成Bank1的NE1片选即可,16位数据线。关键参数是写入时序:
HCLK跑72MHz时,一个时钟周期大约是13.9ns。ILI9806这类常见800x480控制IC,写周期最小值一般标在80到100ns左右,那DATAST设成2到3就比较保守稳妥。千万别一上来就为了快把DATAST压到0,偶发花屏排查起来会非常痛苦。
引脚初始化的代码我就省略了,主要看时序结构体:
FSMC_NORSRAM_TimingTypeDef timing = {0}; timing.AddressSetupTime = 0x0; timing.AddressHoldTime = 0x0; timing.DataSetupTime = 0x2; timing.BusTurnAroundDuration = 0x0; timing.CLKDivision = 0x0; timing.DataLatency = 0x0; timing.AccessMode = FSMC_ACCESS_MODE_A; FSMC_NORSRAM_InitTypeDef fsmc = {0}; fsmc.FSMC_Bank = FSMC_Bank1_NORSRAM1; fsmc.FSMC_DataAddressMux = FSMC_DATA_ADDRESS_MUX_DISABLE; fsmc.FSMC_MemoryType = FSMC_MEMORY_TYPE_SRAM; fsmc.FSMC_MemoryDataWidth = FSMC_NORSRAM_MEM_BUS_WIDTH_16; fsmc.FSMC_BurstAccessMode = FSMC_BURST_ACCESS_MODE_DISABLE; fsmc.FSMC_WaitSignalPolarity = FSMC_WAIT_SIGNAL_POLARITY_LOW; fsmc.FSMC_WrapMode = FSMC_WRAP_MODE_DISABLE; fsmc.FSMC_WaitSignalActive = FSMC_WAIT_TIMING_BEFORE_RS; fsmc.FSMC_WriteOperation = FSMC_WRITE_OPERATION_ENABLE; fsmc.FSMC_WaitSignal = FSMC_WAIT_SIGNAL_DISABLE; fsmc.FSMC_ExtendedMode = FSMC_EXTENDED_MODE_DISABLE; fsmc.FSMC_AsyncWait = FSMC_ASYNC_WAIT_DISABLE; fsmc.FSMC_WriteBurst = FSMC_WRITE_BURST_DISABLE; fsmc.FSMC_ReadWriteTimingStruct = &timing; fsmc.FSMC_WriteTimingStruct = &timing; FSMC_NORSRAM_Init(&fsmc); __HAL_FSMC_ENABLE(FSMC_Bank1_NORSRAM1);然后是地址映射。很多屏的原理图里D/C引脚(数据/命令选择)接的是FSMC_A0。这种情况下,FSMC的字节地址0x60000000和0x60000002就分别对应命令和数据:
#define LCD_CMD_BASE ((uint32_t)0x60000000) #define LCD_DATA_BASE ((uint32_t)0x60000002)因为FSMC在16位总线模式下,CPU字节地址的bit1会映射到外部地址线FSMC_A0上。写命令时往0x60000000写,D/C被拉低;写数据时往0x60000002写,D/C被拉高。屏幕初始化时的所有寄存器操作和最终的像素写操作,都靠这两个地址区分。
3.2 双buffer + DMA异步flush_cb实战代码
LVGL这边先把draw buffer定义好:
#define H_RES 800 #define V_RES 480 static lv_color_t buf_1[H_RES * 10] __attribute__((aligned(4))); static lv_color_t buf_2[H_RES * 10] __attribute__((aligned(4))); static lv_disp_draw_buf_t draw_buf; static lv_disp_drv_t disp_drv; lv_disp_draw_buf_init(&draw_buf, buf_1, buf_2, H_RES * 10);这里两块buffer各8000像素,每像素2字节,一块16KB,两块共32KB。对于64KB SRAM的ZET6来说,剩下的空间分给LVGL对象堆、系统栈和全局变量,规划得当是能装下的。
flush_cb的实现如下:
void lvgl_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { lcd_set_windows(area->x1, area->y1, area->x2, area->y2); uint32_t px_cnt = (area->x2 - area->x1 + 1) * (area->y2 - area->y1 + 1); if (px_cnt < 64) { uint32_t i; for (i = 0; i < px_cnt; i++) { *(volatile uint16_t *)LCD_DATA_BASE = color_p[i].full; } lv_disp_flush_ready(drv); } else { HAL_DMA_Start_IT(&hdma_lcd, (uint32_t)color_p, LCD_DATA_BASE, px_cnt); } }注意几个细节。
首先是窗口函数。每次flush前必须先用CPU写命令的方式设置屏幕控制器的显示窗口,让后续写入GRAM的数据自动落到正确区域。窗口设置的命令量很小,几条寄存器操作,耗时可以忽略。但它必须在启动DMA之前完成。
其次是那个小于64像素走CPU的小分支。DMA启动本身有开销,包括通道配置、中断触发和总线仲裁。如果只是搬几十个像素,DMA的启动和中断开销反而比CPU直接写更大。实测下来64这个阈值是个比较合理的平衡点,各位也可以根据自己的工程调。
最关键的一点是:这段代码里,DMA分支没有调用lv_disp_flush_ready。因为这是一次异步传输,要等DMA真正搬运完成,在中断回调里再告诉LVGL。
3.3 DMA配置与中断回调
DMA2的初始化代码:
DMA_HandleTypeDef hdma_lcd; void LCD_DMA_Init(void) { __HAL_RCC_DMA2_CLK_ENABLE(); hdma_lcd.Instance = DMA2_Channel3; hdma_lcd.Init.Direction = DMA_MEMORY_TO_MEMORY; hdma_lcd.Init.PeriphInc = DMA_PINC_ENABLE; hdma_lcd.Init.MemInc = DMA_MINC_ENABLE; hdma_lcd.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; hdma_lcd.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; hdma_lcd.Init.Mode = DMA_NORMAL; hdma_lcd.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma_lcd); HAL_NVIC_SetPriority(DMA2_Channel3_IRQn, 5, 0); HAL_NVIC_EnableIRQ(DMA2_Channel3_IRQn); }通道选DMA2_Channel3,主要原因是这个通道的中断向量在F103上是独立的,不和Channel4/5共享,写IRQHandler时逻辑更干净。DMA模式一定要是NORMAL,不能用CIRCULAR,否则DMA会一遍一遍地把同一块buffer往屏上刷。
中断处理:
void DMA2_Channel3_IRQHandler(void) { HAL_DMA_IRQHandler(&hdma_lcd); } void HAL_DMA_TxCpltCallback(DMA_HandleTypeDef *hdma) { if (hdma == &hdma_lcd) { lv_disp_flush_ready(&disp_drv); } }这里有个容易忽略的坑:lv_disp_flush_ready接收的disp_drv必须是全局变量,不能是注册时临时使用的局部变量。因为DMA中断回调根本拿不到LVGL内部传过来的drv指针,只能靠全局引用。如果回调里传错对象,LVGL内部的刷新状态会错乱,表现出来就是刷着刷着不更新了。
另一个和HAL库相关的问题是,如果工程里同时用了USART、SPI等外设的DMA传输,它们可能共享同一个DMA中断向量。中断服务函数里虽然统一走HAL_DMA_IRQHandler分发,但在写TX/RX回调时一定要通过hdma句柄区分是哪个外设触发的。否则一次串口DMA完成就把屏幕的flush_ready给调了,画面逻辑会乱。
3.4 buffer尺寸怎么定才不浪费内存
buffer开多大,本质是在渲染效率和内存占用之间做取舍。800x480的分辨率下,常见的做法是开800x10或者800x12像素一块,两块buffer翻倍占用。
800x10的方案:一块8000像素,16KB,两块32KB。F103ZET6还剩32KB,LVGL对象堆可以给8到12KB,系统栈2到4KB,剩下的留给全局变量和中间数据。这个配比在常规界面下是能跑的。
800x12的方案:两块共38.4KB,内存明显拥挤,但渲染效率略高。适合界面控件少、不需要太多动态对象的项目。
buffer数组定义时最好加__attribute__((aligned(4))),保证4字节对齐。虽然DMA的半字模式只要求半字对齐,但4字节对齐可以让DMA内部总线访问效率更高,而且能避免某些编译器莫名其妙的对齐问题。用malloc动态申请时也要注意对齐,HAL库没有自动帮你对齐的机制。
4. 调完DMA之后,真正决定“丝滑”的细节
4.1 FSMC时序:不是越小越好
DMA只是解放了CPU,并没有提高FSMC总线的绝对吞吐率。真正决定带宽上限的,还是FSMC写入时序里那几个参数。
在72MHz HCLK下,地址建立时间和数据建立时间的每一档都是13.9ns。常见的配置组合如下:
| DATAST | 写入周期估算 | 理论带宽 | 稳定性 |
|---|---|---|---|
| 0 | ~27.8ns | >50MB/s | 差 |
| 1 | ~41.7ns | ~32MB/s | 一般 |
| 2 | ~55.6ns | ~24MB/s | 稳定 |
| 3 | ~69.5ns | ~19MB/s | 很稳定 |
DATAST=0虽然带宽最高,但在温度、电压波动下很容易偶发花屏,而且往往是不定时复现的那种花屏,排查起来极其痛苦。我自己的习惯是先按DATAST=2调通整个系统,最后如果确实需要再压到1,并且做长时间的拷机验证。产品追求的是稳定复现,不是跑分好看。
屏幕读取时序如果项目里用不到,就保持一个大一点的数值,不要让它成为瓶颈。反正刷屏只走写方向。
4.2 窗口设置与DMA总线的协调机制
每次flush时,窗口设置命令也是通过FSMC总线写出去的。这时候如果DMA正好在搬运上一块数据,CPU和DMA会竞争FSMC总线。但实际影响很小,因为窗口设置只有几条命令,FSMC总线仲裁会让DMA暂停几个周期,丢不了数据。
真正需要理解的是LVGL的协调机制:DMA中断里调用lv_disp_flush_ready,之后LVGL才可能再次调用新的flush_cb。所以窗口设置一定发生在DMA传输完成之后,不会出现“新窗口设置覆盖正在进行的DMA窗口”这种竞态。前提是你不要在别的任务里私自往LCD写数据。
我在实际项目里见过有人为了显示开机logo,在系统启动后手动调了一次lcd_set_windows,后面LVGL的DMA刷新就开始错位。原因就是那次手写窗口的时机在DMA传输过程中,把屏幕控制器的自动增量坐标打乱了。所以一旦走了LVGL,所有屏幕写入都要统一从flush_cb走,别在外面私自操作。
4.3 除了DMA,这几件事也能明显提升手感
DMA解决的是搬运瓶颈,但LVGL渲染本身在F103上也有不少可优化的空间。
阴影效果能关就关。LVGL的shadow是用软件渲染出来的,一个带阴影的按钮,重绘面积可能是按钮本身的三四倍,这对F103来说太伤了。模糊、渐变、抗锯齿这些效果同理,可以在lv_conf.h里裁剪掉。
动画尽量控制重绘区域。页面切换如果做全屏的滑动动画,每帧都要搬运大量像素,F103这个带宽天花板摆在那里,效果有限。我的做法是:页面切换用淡入淡出,并且重绘范围只限定在内容区域,不触发整屏刷新。
屏幕控制器的扫描方向和LVGL的坐标方向要保持一致。如果屏幕扫描方向是横屏,但LVGL配置里没开对应方向的旋转,每次刷新都要做一次坐标换算,不光代码麻烦,刷屏区域边界也容易出各种奇怪的错位。直接在屏幕初始化驱动里把扫描方向设对,比在LVGL里做旋转要高效得多。
5. 真机排错笔记:花屏、卡死和“更慢了”
5.1 用了DMA1,刷新区域全是乱码
这个案例挺典型的。有人移植DMA刷新代码时,照着DMA1的某个通道配置,然后把源和目的地址填好,结果画面全是乱的或者干脆没反应。用调试器看FSMC地址空间里的数据,发现GRAM根本没被写入。
原因就是我前面说的,DMA1无法访问FSMC地址空间。这个不是配置细节的问题,是总线架构决定的。排查方法很简单:把DMA通道从DMA1换到DMA2上,同样的代码逻辑,问题立刻消失。
5.2 花屏一半,斜切错位
画面一半正常,另一半颜色错乱或者位置偏移,大概率是窗口设置函数的坐标有问题。常见于屏幕控制器的X/Y坐标范围没有配合扫描方向设置。
我调这种问题有一个固定的排查顺序:先用CPU阻塞方式,往全屏刷一块纯色,确认屏和FSMC是通的;再手动设置一个小窗口,往里面写几个像素,确认窗口坐标系是对的;最后才接上LVGL的flush_cb。这样一层一层分离变量,比对着代码发呆有效得多。
5.3 DMA中断没进,屏幕刷一下就停住
DMA配置没问题,flush_cb也调了,但屏幕只刷新了一次就再也不动了。用调试器看,DMA2_Channel3_IRQHandler根本没进来。
这个坑多半出在NVIC中断使能上。F103的DMA2_Channel3中断使能是在RCC里先开DMA2时钟,然后HAL_NVIC_EnableIRQ(DMA2_Channel3_IRQn)。顺序反了或者漏了,中断就永远不触发。另一个常见位置是HAL_DMA_Start_IT之后,DMA的传输完成标志没有清除,导致下一次启动失败。正常情况HAL库会帮你清,但如果你之前手动清过别的标志,容易搞混。
排查这类问题时,我习惯在DMA中断服务函数第一行翻转一个GPIO,用示波器看这个引脚有没有脉冲。没有脉冲,说明中断链路有问题;有脉冲但屏幕不动,说明问题在lv_disp_flush_ready的调用时机或者LVGL状态机上。
5.4 用了DMA之后,小按钮点击反而变慢
这个现象很有意思,也容易让人怀疑DMA方案是不是有问题。实际原因就是DMA启动和中断的开销在小数据量下比CPU直接拷贝还要大。
一个20x20像素的按钮,重绘面积400像素,DMA搬这点数据只需要几微秒,但启动DMA、响应中断、切换流水线的开销叠加起来,反而比CPU顺手写过去慢。这也是我在flush_cb里加那个64像素阈值分支的原因。
如果你发现点击小控件变慢,可以看看flush_cb里有没有对面积做判断。没有的话加上阈值判断,小面积走CPU,大面积走DMA,两者兼得。
5.5 刷新时整机花屏或复位,查了一晚上发现是电源问题
这个案例是最后验证时发现的。刷大屏的时候屏幕偶尔会闪一下,或者干脆复位,不是每次都能复现。用示波器抓3.3V电源轨,发现每次大面积DMA刷新时都会掉到3.0V以下。
原因是大面积刷新时,DMA高优先级连续访问FSMC,屏幕控制器内部GRAM频繁翻转,瞬时电流明显增大。再加上板子电源余量不足,电压就跳水了。
这种问题软件再怎么调都解决不了。靠近MCU的电源引脚和屏幕背光供电端多加几个100nF瓷片电容,把DMA优先级从HIGH降到MEDIUM,能缓解大部分情况。如果是电池供电的低功耗产品,刷新大面积界面时尽量不要同时开射频或电机之类的其它大电流负载。
最后再分享一个调试小技巧
调这个项目最痛苦的时候,我一直在用GPIO翻转法定位耗时。在flush_cb入口拉高一个引脚,在DMA中断回调里拉低,示波器一看,每次刷屏占用了多少时间、CPU和DMA之间有没有多余等待,全都很直观。这个比任何逻辑分析仪都来得快,也是嵌入式调GUI时我一直保留的习惯。
整体调下来,F103配合800x480并口屏,DMA双缓冲加局部刷新,普通按钮点击和数值刷新已经完全能跟手了。全屏切换这种大动作仍然需要取舍,想让它像手机一样顺滑,那就真得换带LTDC的主控了。但在F103这个价位做到这个水平,我认为已经算榨干了这颗芯片的潜力。