ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32F103+LVGL大屏优化:DMA双缓冲与FSMC刷屏实战

STM32F103+LVGL大屏优化:DMA双缓冲与FSMC刷屏实战 去年帮朋友调一块7寸800x480并口屏主控是STM32F103ZET6LVGL版本用的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的刷新机制给我们的信息很清楚优化要围绕“搬运”来做。搬得快界面就快搬运不再占用CPUCPU就有空去渲染下一帧界面就更跟手。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 bufferLVGL往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还剩32KBLVGL对象堆可以给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.8ns50MB/s差1~41.7ns~32MB/s一般2~55.6ns~24MB/s稳定3~69.5ns~19MB/s很稳定DATAST0虽然带宽最高但在温度、电压波动下很容易偶发花屏而且往往是不定时复现的那种花屏排查起来极其痛苦。我自己的习惯是先按DATAST2调通整个系统最后如果确实需要再压到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这个价位做到这个水平我认为已经算榨干了这颗芯片的潜力。
返回列表