STM32F103+LVGL驱动800×480大屏:FSMC与DMA优化实践

发布时间:2026/10/5 6:33:02
STM32F103+LVGL驱动800×480大屏:FSMC与DMA优化实践 先放结论STM32F103跑LVGL还要带800×480大屏能跑但前提是你会做选型和传输优化。很多人一上来就买一块SPI屏然后把LVGL自带的flush回调改成CPU模拟时序逐点刷结果当然卡成幻灯片。这篇记录的是我实际调通的方案主控用STM32F103ZET6屏幕带RA8875控制器通过FSMC接口挂载LVGL负责界面渲染DMA负责把渲染结果刷进显存。整个过程涉及FSMC、DMA、LVGL缓冲策略、FreeRTOS任务安排和一堆调试细节。适合想在手头F103开发板上搞图形界面的朋友参考尤其是对“大屏也能丝滑”这个目标还有怀疑的人。1. 先搞清楚瓶颈F103和800×480之间到底差在哪1.1 渲染侧一颗72MHz Cortex-M3的天然限制很多朋友拿到一块7寸800×480屏第一反应是把STM32F103的IO直接怼上去再跑个LVGL demo。实际一测卡成PPT。问题不是LVGL本身而是你踩了两个传统瓶颈渲染性能不够、数据传输路径太慢。搞清楚这两个瓶颈优化才有方向。LVGL本质上是一个软件渲染引擎。它把按钮、文字、进度条解析成绘制指令然后在内存里一点一点填颜色。CPU主频越高、内存带宽越大绘制越快。STM32F103最典型的型号ZET6主频只有72MHzCortex-M3没有缓存、没有硬件浮点绘一个带圆角的按钮可能要几十微秒到上百微秒。800×480全屏一共384000个像素RGB565下一帧就是约750KB数据。即使只重绘一小块区域渲染压力也比480×272这种屏成倍上涨。所以第一步是别幻想全屏60帧目标定在“刷新不闪烁、交互跟手”即可。F103ZET6虽然SRAM有64KB但连一个全屏framebuffer都放不下LVGL只能用局部缓冲的方式一帧一帧地拼出完整画面。这决定了后续所有优化都必须围绕“小块渲染 加速搬运”来展开。1.2 传输侧SPI屏为什么救不回来再说传输。LVGL渲染完的像素数据要送到屏幕。如果你用的是SPI接口屏幕单条SPI在72MHz系统下能跑到18MHz已经不错部分屏可以超到36MHz。18Mbit/s就是约2.25MB/s750KB一帧要333ms算下来只有3fps36MHz也只有6fps不到。这么低的上限DMA来了也白搭DMA只是把数据从SRAM搬到SPI外设但物理管脚上就那么多bit。800×480这种大屏必须走16位并口。而F103又没有LTDC不能直接驱动RGB接口屏幕所以最合适的方案是选一块带显存控制器的并口屏让控制器去刷面板F103只负责往控制器显存里写数据。RA8875和SSD1963就是这类控制器的代表。它们内部有足够大的显示RAM面板刷新信号完全由控制器自己产生MCU的压力一下子小了很多。2. 整体设计与硬件选型2.1 主控和屏幕为什么是ZET6 RA8875F103系列只有带FSMC的大容量型号适合干这活ZET6或VET6都行。C8T6没有FSMC接并口屏只能模拟总线刷屏效率会打折带800×480基本不现实。ZET6的FSMC可映射NOR/SRAM设备其中Bank1 NE1的地址从0x60000000开始正好用来挂RA8875。RA8875内置800×480显存和TFT时序发生器F103不用管RGB信号只要把像素数据写入RA8875的显存RA8875会自己刷新屏体。SSD1963也类似但我实际用下来RA8875的资料更多窗口裁剪、BTE块传输这些功能也更顺手。屏幕驱动板建议买带RA8875的7寸模块通常引出16位数据线、nCS、nWR、nRD、RS和复位脚接线非常直接。还有一个容易被忽略的点是供电。大屏刷新瞬时电流不小手头有些USB线供电不稳刷到一半会出现花屏。我后来改成5V适配器供电再由板载LDO稳到3.3V问题立刻消失。硬件最小系统部分ZET6最小系统板需要把8MHz晶振、复位、SWD下载口都检查一遍尤其SWD引脚不要和FSMC复用冲突。2.2 FSMC接口和地址映射命令口、数据口要分清RA8875通常提供一个16位并行接口有数据总线、nCS、nWR、nRD和RS寄存器选择脚。以我手上的模块为例把nWR接FSMC_NWE、nRD接FSMC_NOE、nCS接FSMC_NE1、RS接FSMC_A18。这样做的好处是命令口和数据口的地址是固定的写起来非常直观。#define RA8875_CMD_ADDR ((volatile uint16_t *)0x60000000) #define RA8875_DATA_ADDR ((volatile uint16_t *)0x60020000)这里0x60020000是怎么来的RA8875的片选接在FSMC_NE1所以基地址是0x60000000。RS接在A18当地址线A18为高时地址增加0x40000。由于FSMC是16位数据宽度A0在FSMC内部不参与外部寻址所以最终数据口地址就是0x60000000 0x20000 0x60020000。如果你的RS接的是A0那数据口地址就是0x60000002。注意是偏移2而不是1因为16位模式下地址线A0不会按字节递增这一点特别容易写错。2.3 软件工程结构CubeMX、Keil、LVGL源码怎么搭我习惯先用STM32CubeMX生成底层工程勾选RCC外部高速晶振、SWD调试口、FSMC的Bank1 NOR/SRAM、一个UART用于log时钟配置到72MHz然后生成MDK工程。LVGL源码直接拷到工程目录下只要把lvgl/src下的所有.c文件加入编译再把lv_conf.h放到Include路径同时确保LV_CONF_INCLUDE_SIMPLE开启。LVGL版本我建议锁在v8.3.xAPI稳定网上资料多SquareLine Studio导出的代码也是这个格式。如果平时用VS Code看代码可以装EIDE插件Keil只负责编译下载开发效率会高很多。实际项目里还用了FreeRTOS做任务调度但我个人的建议是先裸机把LVGL和DMA调通再加入OS否则问题交叉查找非常痛苦。3. LVGL移植与关键配置3.1 移植流程从lv_conf.h到显示驱动LVGL移植其实就是四件事提供tick时钟、实现flush回调、注册显示驱动、配置内存。lv_conf.h有几项必须认真看#define LV_COLOR_DEPTH 16 #define LV_MEM_SIZE (32U * 1024U) #define LV_TICK_CUSTOM 1 #define LV_USE_PERF_MONITOR 1LV_COLOR_DEPTH必须和屏幕一致RA8875我用的是RGB565 16位色。LV_MEM_SIZE是LVGL自己管理对象和临时缓冲的内存池F103ZET6总共64KB SRAM不能给得太大我最后定成32KB留足剩余空间给显示缓冲和任务栈。tick时钟用TIM6因为STM32CubeMX默认的SysTick已经被HAL占用了直接在TIM6中断里调用lv_tick_inc(1)就好。如果你用FreeRTOS也可以把LVGL的tick接到vTaskGetTickCount上但裸机阶段定时器中断是最省事的。显示驱动注册时注意分辨率和缓冲指针static lv_disp_draw_buf_t disp_buf; static lv_disp_drv_t disp_drv; static lv_color_t buf1[800 * 10]; static lv_color_t buf2[800 * 10]; void lvgl_port_init(void) { lv_init(); lv_disp_draw_buf_init(disp_buf, buf1, buf2, 800 * 10); lv_disp_drv_init(disp_drv); disp_drv.hor_res 800; disp_drv.ver_res 480; disp_drv.flush_cb lvgl_flush_cb; disp_drv.draw_buf disp_buf; lv_disp_drv_register(disp_drv); }很多人在LVGL里改分辨率会找不到地方其实就在这段代码里。如果你用SquareLine Studio生成UI导出的工程里也有对应的hor_res和ver_res改掉之后还要重新计算缓冲行数。3.2 800×480分辨率下的缓冲策略F103ZET6的SRAM只有64KB全屏缓冲想都不要想。LVGL支持局部缓冲意思是它不会一次性渲染完整画面而是先渲染一段“条带”然后交给flush回调刷到屏幕再继续下一段。我用的缓冲是两段每段10行800×10×2字节约15.6KB两段共约31KB。LVGL内部再留32KB管理对象剩下的给FreeRTOS任务栈刚好塞进64KB。双缓冲非常关键。如果只有一段bufferLVGL渲染完第N块后必须等DMA搬运完才能开始渲染第N1块CPU和DMA串行刷新率上不去。换双缓冲之后LVGL渲染第N块DMA同时搬运上一块两块交替使用DMA的传输时间就被“藏”起来了。实际效果是简单界面下能感觉到掉帧明显减少。不过要注意F103的DMA计数器是16位的单次最大传输65535个像素。因为我用800×10的缓冲每次进flush回调的像素数最多8000所以不存在溢出问题。如果谁把缓冲改得特别大比如接近满屏就一定要自己做分段DMA不能直接传384000像素给DMA。3.3 flush回调与DMA的接口方式LVGL v8显示驱动注册完成后每次渲染完一块区域就会调用flush回调。传统写法是在里面死循环写FSMC但我们要改成DMA搬运。基本流程是先设置RA8875的显示窗口再告诉RA8875进入写显存模式最后启动DMA把color_p指向的像素数据写到数据寄存器地址。void lvgl_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { RA8875_SetWindow(area-x1, area-y1, area-x2, area-y2); RA8875_WriteRAM_Prepare(); LCD_DMA_Start((uint32_t)color_p, (uint32_t)RA8875_DATA_ADDR, lv_area_get_size(area)); // 不要在这里等DMA完成 // 等DMA中断里调用 lv_disp_flush_ready }DMA完成中断里必须调用lv_disp_flush_ready(drv)否则LVGL会认为这次flush一直没结束后续渲染全部卡死。这是很多人移植时最常见的坑要么忘了在完成中断里调用要么在flush回调里傻等效果都不好。4. DMA加速刷屏的具体实现4.1 DMA初始化内存到内存模式STM32F103的DMA支持内存到内存模式这种模式不需要外设请求启动后DMA会自动把数据从源地址搬运到目的地址。我把DMA2的Channel4留作LCD专用初始化代码如下DMA_HandleTypeDef hdma_lcd {0}; void LCD_DMA_Init(void) { __HAL_RCC_DMA2_CLK_ENABLE(); hdma_lcd.Instance DMA2_Channel4; hdma_lcd.Init.Direction DMA_MEMORY_TO_MEMORY; hdma_lcd.Init.PeriphInc DMA_PINC_DISABLE; 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); hdma_lcd.XferCpltCallback LCD_DMA_Complete_CB; HAL_NVIC_SetPriority(DMA2_Channel4_IRQn, 5, 0); HAL_NVIC_EnableIRQ(DMA2_Channel4_IRQn); }这里的核心是PeriphInc要关闭因为RA8875的数据口地址固定不变每次写入同一个地址RA8875内部显存地址会自动递增。MemInc必须开启因为源地址是一个连续的像素数组每传完一个像素地址要往后走。数据宽度都配成半字对应RGB565两个字节。中断函数很简单void DMA2_Channel4_IRQHandler(void) { HAL_DMA_IRQHandler(hdma_lcd); } void LCD_DMA_Complete_CB(DMA_HandleTypeDef *hdma) { lv_disp_flush_ready(disp_drv); }4.2 刷屏封装的代码框架DMA启动我单独封装了一个函数方便flush回调调用HAL_StatusTypeDef LCD_DMA_Start(uint32_t src, uint32_t dst, uint32_t size) { return HAL_DMA_Start_IT(hdma_lcd, src, dst, size); }需要说明的是RA8875一侧的连续写由它自己的显存地址递增完成不需要DMA循环模式更不需要“continuous requests”这类外设请求模式。DMA_NORMAL模式一次传输完就停正好匹配LVGL一块一块刷新的机制。如果设置成循环模式DMA会反复搬运同一块缓冲区反而导致画面错乱。使用DMA后F103的CPU基本不参与搬运。每次flush回调只设置一下窗口、启动DMA就返回CPU可以继续跑LVGL的渲染逻辑等DMA中断来了再通知“这块刷完了”。整体代码结构非常清晰性能瓶颈也能准确压低。4.3 为什么这种方案比CPU逐像素写快CPU逐点写RA8875数据口时每写一个像素都要执行多条指令计算偏移、读取数据、写入地址、等待FSMC总线操作完成。即便编译器优化很到位每次写入也要花好几个总线周期几千个像素下来差距就出来了。DMA内存到内存模式一旦启动源地址自动递增目的地固定总线仲裁会把一个个写请求排好队CPU只需要处理首尾中断不用管中间每个像素。实测在72MHz下用DMA做全屏纯色填充大约32ms而CPU轮询写同样区域要110ms以上。看起来只有3倍差距但配合双缓冲后对LVGL交互感的提升非常明显因为CPU多出来的时间全都能用在渲染控件上。4.4 FreeRTOS下的任务安排如果只跑LVGL裸机一把梭就够了。但项目里如果还要做通讯、数据采集建议加FreeRTOS。LVGL本身不是线程安全的所有UI操作必须放同一个任务。我创建了一个gui_task优先级中等里面循环调lv_timer_handler()每5ms一次void gui_task(void *argument) { lvgl_port_init(); create_ui(); for (;;) { lv_timer_handler(); vTaskDelay(pdMS_TO_TICKS(5)); } }DMA中断里直接调用lv_disp_flush_ready在这个工程里没问题。如果你们项目对临界区要求特别严格可以用一个二值信号量通知GUI任务在任务上下文里再调flush_ready避免在中断里做LVGL的事。5. 调优实录怎么把“能跑”变成“丝滑”5.1 先测基线纯色填充和LVGL基准我习惯先脱离LVGL单独测一下屏幕驱动和DMA速度。写一个LCD_Fill_Color(0x001F)一次性填充整屏蓝色用逻辑分析仪或简单翻转GPIO测量时间。我这边全屏填充约32ms也就是每秒能刷30帧纯色这个成绩对于RA8875已经很理想。然后跑LVGL自带的demo或者自己写一个带按钮、弧线、进度条的页面。你会发现帧率会从纯色填充的30帧掉到十几帧因为LVGL渲染控件本身就是CPU密集活。这时候不要急着骂DMA先确认瓶颈是渲染还是传输把LV_USE_PERF_MONITOR打开屏幕角落里会显示FPS和CPU占用如果FPS低但CPU占用不高问题在等待刷新如果CPU占用接近100%那就要从UI样式下手。5.2 渲染侧优化样式、图片、字体的取舍800×480屏上的UI如果照抄手机风格F103必然跑不动。我踩过的坑主要是几个大圆角带阴影的卡片、毛玻璃效果、大尺寸字体和超清图片。圆角和阴影都需要大量像素计算尤其阴影还要对周围区域做渐变叠加软件渲染代价极高。我在这个项目里把所有卡片改成直角或小圆角阴影全部去掉掉帧立刻减少。毛玻璃这种效果在F103上不要碰纯属找虐。图片资源尽量直接转RGB565数组放进ST仓库不要在运行时解码PNG或JPEG。如果你用SquareLine Studio导出图片时选择RGB565格式这样LVGL加载图片只是内存拷贝。字体方面能用12~16号字号就不要上大字号中文字库本身就是结构化数据每个字符绘制都要CPU参与字号越大开销越大。5.3 传输侧优化双缓冲和DMA中断传输侧我们已经用DMA把搬运成本压到最低但要真正流畅还要把“等待DMA完成”的时间藏起来。这就是双缓冲的意义。LVGL的draw_buf里注册了两块buffer渲染第1块时DMA在搬第0块等DMA中断触发后LVGL知道第0块已经刷完立刻把第2块交给flush不再空等。你可能会问DMA中断里直接调用lv_disp_flush_ready会不会卡住其他任务我实测下来只要DMA中断优先级不要高于SysTick问题不大。如果发现DMA中断太频繁导致FreeRTOS低优先级任务饿死就把DMA中断优先级调低或者改成前面说的信号量通知方式。5.4 触摸与输入事件处理800×480大屏一般配电容触摸或电阻触摸。我手上这块是电阻触摸用XPT2046通过SPI接口读取坐标。LVGL输入设备驱动注册方式和显示驱动类似lv_indev_drv_t indev_drv; lv_indev_drv_init(indev_drv); indev_drv.type LV_INDEV_TYPE_POINTER; indev_drv.read_cb touch_read_cb; lv_indev_drv_register(indev_drv);关键点在于read_cb里不能做长时间阻塞否则LVGL的主循环会被拖慢。XPT2046每次读取也就几十微秒可以接受。如果你用的是中断SPI读取一定要把读取结果缓存等LVGL下一次查询时直接返回最近一次坐标不要在read_cb里突发启动SPI传输。6. 常见问题排查与避坑心得6.1 快速自查表下面是我实际调试中遇到的高频问题整理成表格方便直接对着查。现象常见原因解决思路白屏/无显示RA8875初始化失败、FSMC时序太快检查复位延时把FSMC地址/数据建立时间调大刷屏颜色错乱像素格式、字节序不匹配统一16位RGB565检查LV_COLOR_16_SWAPDMA搬完画面撕裂单缓冲、LVGL在DMA还没结束就修改数据改成双缓冲或者flush_ready只在DMA完成中断里调显示区域错位RA8875窗口设置没生效写显存前必须先调用写入窗口命令再启动DMA运行一段时间后卡死LVGL内存池不足、任务栈溢出调大LV_MEM_SIZE增大gui_task栈精简界面对象点击位置不准触摸校准没做LVGL支持触摸校准或者根据屏幕分辨率换算坐标6.2 我个人踩过的一些坑第一个坑是RA8875的窗口设置。刚开始我图省事不调用RA8875_SetWindow直接在写显存前设置光标。结果每次LVGL只刷一小块区域周围全会被旧数据覆盖整个屏幕看起来就像在滚动。后来才知道RA8875的写显存命令会把内部地址自动递增你必须事先指定一个矩形窗口告诉它本次只允许在这个区域内写否则地址一路滚到整屏之外。第二个坑是DMA中断里有没有清标志。如果使用老的StdPeriph库很多人会在中断里忘了清DMA_FLAG_TC导致中断反复进入。换成HAL之后HAL_DMA_IRQHandler会自动处理标志但还是要注意回调函数不能改动disp_drv结构体的关键字段否则会造成LVGL内部状态错乱。第三个坑是FSMC时序调太激进。刚开始为了追求刷屏速度我把AddressSetup和DataSetup调得很小结果屏幕偶发花屏尤其是周边器件工作温度变化时更明显。后来老老实实按RA8875数据手册里的写入周期把地址建立时间设为3个HCLK数据建立时间设为8个HCLK稳定很多。速度下降不多但可靠性提升明显。最后一个想说的是F103 LVGL 800×480这个组合真正的上限在LVGL软件渲染不在DMA刷屏。DMA解决的是“渲染完的数据怎么送出去”而LVGL渲染本身依然是CPU在做。我的经验是先把UI样式简到不能再简再把DMA双缓冲跑通最后再加FreeRTOS和复杂交互逻辑。一步步来这个组合能跑出让你满意的流畅度。上面这些坑我基本都踩过一遍尤其窗口设置和DMA中断的问题前后调了一个晚上。如果你也打算在F103上跑大屏LVGL希望这篇能帮你少走这一段弯路。