ARTICLE DETAIL

资讯详情

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

STM32F103驱动800x480大屏跑LVGL的FSMC+DMA实战方案

STM32F103驱动800x480大屏跑LVGL的FSMC+DMA实战方案 STM32F103跑LVGL这事放在几年前我是不敢想的。72MHz主频、64KB的SRAM常规认知里这芯片也就点个TFT小屏、跑个裸机逻辑跟800x480这种分辨率的彩色大屏似乎八竿子打不着。但最近一个项目非要用F103ZET6去带7寸屏还得上LVGL做UI我硬是把方案啃了下来。FSMC并行接口、DMA刷屏、LVGL局部刷新这三板斧用上之后实测简单界面能到15fps以上拖动和点击的响应基本称得上“丝滑”。这篇就把我踩过的坑、算过的账、调过的时序全部写出来给想在F103上跑LVGL大屏的朋友一个完整的参考。这个方案的本质不是让F103去硬算一整个800x480画面的像素而是利用FSMC把LCD控制器当成一块外部SRAM来写同时让DMA接管从内存到屏幕的数据搬运再加上LVGL本身只刷新变化区域的机制把单片机的算力集中在UI渲染上。下面我从方案选型讲到硬件连接再讲CubeMX配置、LVGL移植最后给出一份实测数据和常见问题清单。1. 方案解析F103凭什么能带得动800x4801.1 内存是硬门槛先把账算明白F103ZET6看起来64KB SRAM不算少但放到大屏场景就要命了。800x480分辨率RGB565格式一帧完整画面的数据量是800乘以480乘以2字节等于768KB。这已经超过了整颗芯片的全部内存连一帧都放不下。所以第一步就得放弃“全屏缓冲”这个思路。LVGL从7.x开始就支持局部刷新机制每次只绘制屏幕上变化的那一小块区域比如你点了一个按钮它只刷新按钮周围的那块矩形。这样MCU每次要处理的数据量就小得多。但这还不够因为800x480的分辨率仍然意味着水平方向一行就有800个像素哪怕刷新一个几十像素的区域缓冲区的设计也得精打细算。我的做法是给LVGL分配一个约16KB的局部绘制缓冲也就是800像素宽、10行高。LVGL在这个缓冲里渲染好一小块画面后我通过FSMC把数据发到LCD控制器自带显存里再由控制器去驱动屏幕显示。LCD控制器本身承担了显存和RGB数据扫描的工作MCU只负责“往显存里填数据”这样F103才可能吃得消。1.2 FSMC是唯一靠谱的接口方式SPI屏别想了很多人一上手就想用SPI接口的屏幕毕竟SPI屏模块便宜、接线少。但在800x480这种分辨率下SPI方案可以直接判死刑。常规SPI TFT屏即使跑在18MHz的SPI时钟下一个像素16bit理论上每秒最多传约1.1M像素实际全屏刷新一帧需要300毫秒以上加上协议开销和命令周期直接卡成幻灯片。FSMC则完全不同。F103ZET6带有FSMC控制器F103C8这种48脚芯片没有可以把外部设备映射到ARM核的地址空间中。我把带GRAM的LCD控制器如RA8875、SSD1963、ILI9806类的方案配置成异步SRAM设备MCU往某个地址写一个16位数据FSMC硬件自动产生片选、读写时序LCD控制器就把这个数据写入自身的显存地址。这个写操作只要一条代码效率比SPI高了几个数量级。用FSMC还有一个特别关键的优势DMA可以参与刷屏。因为FSMC外设对应的是一个内存地址DMA可以直接从这个地址把数据搬运到另一个地址全程不需要CPU逐像素干预。1.3 DMA到底加速了什么不是传得更快而是省出了CPU先说个容易被误解的点DMA并不一定让数据在总线上“跑得更快”。FSMC的访问时序是固定的DMA写一个字照样要等那些建立时间、保持时间。但从整个系统的角度看DMA的价值在于它接管了搬运过程后CPU被彻底解放出来。LVGL在单缓冲模式下渲染和发送是串行的但如果你有双缓冲DMA搬运第一批数据时CPU可以同时渲染下一批。这个并行就能让整体帧率上一个台阶。打个比方CPU像是仓库打包员屏幕是客户。以前的流程是打包员包好一件货自己开车送到客户手里再回来包下一件。DMA就像请了一个专职司机打包员只要把货放上车司机送过去的同时打包员已经在包下一件了。对于F103这种单核MCU少干一点重复搬运的活UI渲染的算力就多了一点。2. 硬件设计与接口连接实战前的准备工作2.1 最小系统里最容易忽略的电源问题做F103最小系统是老生常谈8MHz晶振加两个20pF电容、复位电路、BOOT0下拉再加上3.3V电源滤波基本就能跑起来。但一旦接上800x480大屏电源问题就会立刻暴露。大屏一般都有背光LED背光的电流通常在100mA到300mA之间。很多开发板的3.3V是LDO从5V转换过来的本身电流余量就不大如果背光直接从3.3V取电屏幕一亮MCU电压猛地掉下去轻则花屏重则直接复位。我遇到过DAP下载器连不上查了半天发现是背光把电源拖垮了。所以强烈建议背光单独用一路5V供电如果必须3.3V至少用独立的DC-DC模块不要把背光和MCU电源混在一起。另外FSMC接口用到的引脚比较多典型接法会占用GPIOB、GPIOD、GPIOE的大部分引脚。这些引脚要留足空间给杜邦线或者PCB走线特别是D0到D15这16根数据线建议等长设计否则高频切换时眼图不好时序裕量会变小。2.2 FSMC地址映射命令口和数据口的偏移用FSMC驱动LCD控制器必须理解地址线的错位问题。FSMC在16位模式下FSMC_A0对应的是MCU地址空间的bit1不是bit0。也就是说如果你把LCD控制器的RS引脚接到FSMC_A0上那么MCU侧地址的bit1控制RS状态。以NE1片选为例基地址是0x60000000。RS为低时是命令端口RS为高时是数据端口。因为A0接到了bit1的位置所以数据端口地址就是0x60000000加2也就是0x60000002。我习惯这样定义#define LCD_BASE 0x60000000 #define LCD_REG (*(volatile uint16_t *)(LCD_BASE)) // 命令地址 #define LCD_RAM (*(volatile uint16_t *)(LCD_BASE 0x0002)) // 数据地址如果控制器用的是RA8875或某些SSD1963方案RS也可能接FSMC_A1那数据地址偏移就变成了4。具体多少取决于你的硬件连接但原理是一样的MCU侧地址bit1对应FSMC_A0bit2对应FSMC_A1以此类推。数据宽度我统一用16位LCD控制器一般也支持16位模式。RGB565正好一个像素两个字节一次FSMC写操作就是一个像素非常方便。3. CubeMX配置FSMC、DMA、串口一个都不能少3.1 FSMC初始化与时序参数的计算在CubeMX里配置FSMC选择Bank1的NE1存储器类型选“SRAM”数据宽度选16bit然后打开写使能。最关键的是时序参数AddressSetupTime地址建立时间、DataSetupTime数据建立时间、BusTurnAroundDuration总线周转周期。F103的HCLK是72MHz一个周期大约13.9纳秒。CPU通过FSMC访问外部设备时总访问时间大约是ADDSET加DATAST加几个固定HCLK周期。LCD控制器的写信号最小周期一般在60到100纳秒左右所以时序算起来并不需要太极限。我第一次做这个项目时用了比较保守的参数ADDSET设为6DATAST设为12。这样一次访问大约需要20个HCLK周期约278纳秒非常稳定但速度偏慢。全屏刷一次要100毫秒以上明显能感觉到卡。后来我逐步把参数往小调最终稳定在ADDSET等于1、DATAST等于3的组合一次访问约6到7个HCLK大约90纳秒基本接近控制器规格的极限。这个参数下花屏概率极低实测速度比保守参数快了近3倍。要注意的是每个LCD控制器不太一样RA8875和ILI9806的时序裕量有差异建议以保守参数起步能点亮后再逐步调快。CubeMX生成的FSMC初始化核心代码如下static void MX_FSMC_Init(void) { FSMC_NORSRAM_TimingTypeDef Timing {0}; FSMC_NORSRAM_InitTypeDef NorSRAM {0}; __HAL_RCC_FSMC_CLK_ENABLE(); NorSRAM.Controller FSMC_NORSRAM_DEVICE; NorSRAM.DataAddressMux FSMC_DATA_ADDRESS_MUX_DISABLE; NorSRAM.MemoryType FSMC_MEMORY_TYPE_SRAM; NorSRAM.MemoryDataWidth FSMC_NORSRAM_MEM_BUS_WIDTH_16; NorSRAM.Bank FSMC_NORSRAM_BANK1; NorSRAM.WriteOperation FSMC_WRITE_OPERATION_ENABLE; NorSRAM.ExtendedMode FSMC_EXTENDED_MODE_DISABLE; Timing.AddressSetupTime 1; Timing.DataSetupTime 3; Timing.AccessMode FSMC_ACCESS_MODE_A; HAL_SRAM_Init(hsram, NorSRAM, Timing); }3.2 DMA配置内存到内存、半字宽度、目标地址固定F103的DMA控制器在CubeMX里看起来功能不算多但对刷屏来说刚好够用。我的配置思路是方向选“Memory to Memory”源地址是LVGL的绘制缓冲目标地址是LCD的数据端口0x60000002。关键是源地址要自增目标地址必须固定否则DMA每传一个字就把LCD端口地址也往后挪了写不到同一个显存数据寄存器里。数据宽度我选半字也就是16位和RGB565像素格式、FSMC总线宽度保持一致。DMA传输个数就是本次要刷新的像素总数。模式用Normal模式每次刷新重新配置启动。如果你用的是标准库DMA启动函数写起来更直观void LCD_DMA_Start(uint32_t src, uint32_t dst, uint16_t len) { DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA1_Channel6); DMA_InitStructure.DMA_PeripheralBaseAddr dst; DMA_InitStructure.DMA_MemoryBaseAddr src; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize len; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode DMA_Mode_Normal; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_M2M DMA_M2M_Enable; DMA_Init(DMA1_Channel6, DMA_InitStructure); DMA_ClearITPendingBit(DMA1_IT_TC6); DMA_ITConfig(DMA1_Channel6, DMA_IT_TC, ENABLE); DMA_Cmd(DMA1_Channel6, ENABLE); }这里有个容易搞混的点DMA_DIR_PeripheralDST指的是“外设端口是目标地址”也就是LCD数据端口这一端。DMA_M2M_Enable则是让DMA在没有外设请求的情况下靠软件触发连续搬运这正是我们需要的。目标地址固定、源地址递增这两个配置必须成对出现一旦搞反画面就是乱码。3.3 调试串口优先用USART1顺便说说USART1和USART3的差异调试LVGL帧率、打印错误信息离不开串口。F103上有USART1和USART3很多人随手选一个就用但这两个串口的时钟源不一样。USART1挂在APB2总线上时钟最高可以到72MHz。USART3挂在APB1总线上最高只能到36MHz。波特率的分频计算依赖于外设时钟同样设置115200波特率USART1的BRR寄存器值更接近理想值误差更小。在高波特率、长数据帧或者开了奇偶校验的时候USART3更容易出现误码。如果只是9600或115200打印日志USART3也能用。但我在跑LVGL时经常用串口长时间打印帧率统计数据量一大USART3偶尔会出现断帧。后来把调试日志挪到USART1问题就消失了。如果你手头的板子USART1被FSMC占用了比如PB6、PB7和FSMC的NBL引脚冲突那也得权衡一下尽量给调试串口留一条干净的路。4. LVGL移植与刷屏驱动的核心实现4.1 lv_conf.h里的关键配置项LVGL移植到F103上第一步是配置lv_conf.h。有几个参数直接影响能不能跑起来以及跑得顺不顺。LV_COLOR_DEPTH必须设成16对应RGB565。如果你用24位或32位颜色F103的内存会直接爆炸。LV_MEM_SIZE是LVGL自己管理的内存堆大小这个值要看UI复杂度。我一开始给了32KB后来为了给绘制缓冲腾空间缩减到了24KB。LV_DISP_DEF_REFR_PERIOD控制LVGL的刷新周期默认是33毫秒也就是约30fps的调度频率我保留默认因为实际渲染一帧花的时间往往远大于33毫秒。如果芯片没有GPULV_USE_GPU必须关掉。LV_USE_OS如果没上RTOS也保持0。字体方面默认字体只有ASCII字符如果是中文界面要么用外部字库要么把用到的汉字生成一个很小的子集字体别直接把全字库加载进去否则LVGL内存堆根本装不下。4.2 绘制缓冲设计单缓冲还是双缓冲LVGL的绘制缓冲有两套方案。单缓冲简单LVGL画完一块等你发完数据再画下一块两者是完全串行的。双缓冲则是LVGL画A缓冲的时候你正在把B缓冲里的数据通过DMA发出去画完A以后立刻切换去画B流水线就串起来了。双缓冲对DMA方案来说几乎是必需的否则DMA解放CPU的优势发挥不出来。但F103的SRAM总共就64KBLVGL内存堆占了24KB还要考虑栈、全局变量和中断上下文剩下能留给绘制缓冲的空间极其有限。我的分配方案是单块缓冲800像素宽、10行高也就是16KB。如果上双缓冲那就是两个8行高的缓冲每块约12.8KB两个加起来25.6KB内存会非常紧张。权衡之后我在实际项目里用的是“1个16KB缓冲”虽然DMA搬运时CPU会等待但配合局部刷新体验仍然比纯CPU轮询好很多。内存预算大致如下项目大小说明LVGL内存堆24KBlv_conf.h里的LV_MEM_SIZE绘制缓冲16KB800x10行x2字节栈全局变量中断8KB含RTOS任务栈如果有合计48KBF103ZET6的64KB SRAM剩余约16KB这里要提醒一句LVGL的buffer定义建议用static修饰放到全局区而不是局部变量里否则一个16KB的局部数组会直接把栈撑爆。4.3 flush_cb从LVGL回调到DMA搬运的关键代码LVGL绘制完一个区域后会调用你注册的flush回调函数。回调里拿到区域坐标和缓冲区指针第一件事是设置LCD控制器的显示窗口把后续写入的像素限制在指定坐标范围内然后启动DMA搬运。以ILI9806这类控制器为例设置窗口的代码长这样static void lcd_set_window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_REG 0x2A; LCD_RAM (x0 8); LCD_RAM (x0 0xFF); LCD_RAM (x1 8); LCD_RAM (x1 0xFF); LCD_REG 0x2B; LCD_RAM (y0 8); LCD_RAM (y0 0xFF); LCD_RAM (y1 8); LCD_RAM (y1 0xFF); LCD_REG 0x2C; }然后是flush回调函数volatile static uint8_t dma_busy 0; void my_display_flush(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p) { uint16_t w lv_area_get_width(area); uint16_t h lv_area_get_height(area); lcd_set_window(area-x1, area-y1, area-x2, area-y2); // 等待上次DMA传送完成单缓冲必须等 while (dma_busy) {} dma_busy 1; LCD_DMA_Start((uint32_t)color_p, (uint32_t)LCD_RAM, w * h); }DMA传输完成中断里做的事情很简单清标志、关DMA、调用lv_disp_flush_ready通知LVGL缓冲已经释放void DMA1_Channel6_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC6)) { DMA_ClearITPendingBit(DMA1_IT_TC6); DMA_Cmd(DMA1_Channel6, DISABLE); dma_busy 0; lv_disp_flush_ready(disp_drv); } }这里最核心的坑就是lv_disp_flush_ready绝对不能在DMA还没传完的时候调用。一旦你提前告诉LVGL“这块缓冲用完了”LVGL立刻会往同一个缓冲里画下一块而DMA还在往屏幕搬运旧数据结果就是画面撕裂、花屏、残影全来了。我刚开始实现时图省事直接在启动DMA之后马上调用flush_ready结果画面各种错乱查了半天才意识到这个顺序问题。另外要注意LVGL 8.x的lv_disp_flush_ready参数是lv_disp_drv_t指针而LVGL 7.x用的是void指针移植老代码的时候要顺手改掉。5. 性能实测与调优过程5.1 实测帧率从7fps到18fps项目跑通之后我用定时器统计flush回调的调用次数再配合串口每一秒打印一次统计一秒钟内LVGL实际刷新了多少帧。测试条件F103ZET672MHz主频FSMC时序ADDSET1、DATAST3绘制缓冲800x10共16KBDMA搬运界面含两个按钮、一个滑条、几个文本标签。结果如下场景纯CPU写屏FSMCDMA刷屏简单界面按钮点击8-10fps15-18fps复杂界面滑条拖动动画4-6fps8-12fpsCPU写屏和DMA刷屏的差距在不同场景下表现不一样。滑块拖动这类操作往往只刷新局部几十像素宽的细长矩形区域DMA一次性搬运的数据量小中断延迟低所以帧率提升非常明显。全屏背景切换这种操作则是DMA优势最小的时候因为大量数据不管用不用DMA都要走FSMC时间减不掉多少。5.2 进一步调优图片、字体和缓冲行数LVGL界面上如果用了图片尽量用RGB565格式不要用带透明通道的ARGB8888。同样一张800x480的图RGB565是768KBARGB8888直接翻倍到1.5MBF103根本加载不动。图标素材尽量在不失真的前提下压缩尺寸能有效地减少LVGL渲染时的内存开销。字体方面如果你只是显示固定几个数字和状态文字可以把所有用到的字符提取成一个自定义子集字体LVGL渲染时直接命中速度快而且省内存。反之加载一个完整的16点阵中文字库虽然方便但LVGL内存堆可能直接爆掉。缓冲行数也是可以调的。我用过800x6和800x10两种方案。6行缓冲更省内存每帧只有9.6KB但LVGL绘制时被切分成更多小矩形渲染开销增加实测帧率反而比10行缓冲低。10行缓冲是性价比最高的配置如果内存还有余量可以朝12行甚至16行方向试帧率会继续小幅提升。5.3 如果上了FreeRTOSDMA和LVGL怎么配合F103跑LVGL完全可以在裸机上跑但如果项目里还有通信、传感器采集等任务上FreeRTOS会更合理。LVGL官方提供操作系统适配层关键点有两个时间基准和任务调度。lv_tick_inc函数必须在某个定时器中断里周期调用可以用SysTick也可以在FreeRTOS的tick钩子里调用。另一个点是DMA传输完成中断最好不要在中断里直接调用lv_disp_flush_ready而是用一个二值信号量唤醒刷新任务由任务上下文去通知LVGL。这样能避免中断里做太多逻辑导致RTOS调度延迟变大。我在FreeRTOS里的实现是刷新任务阻塞在信号量上DMA中断里释放信号量任务拿到信号量后调用lv_disp_flush_ready然后回到阻塞状态。整体逻辑清晰也不会跟其他任务的临界区打架。5.4 什么时候DMA反而是负担DMA不是万能的。有一种情况会让DMA方案失去优势你的UI代码每次触发全屏刷新。比如你有一个全屏清屏操作或者一个铺满大半个屏幕的图片切换这时候DMA要搬运的数据量巨大F103的DMA又只有一条通道在跑传输期间CPU除了等没别的办法。单缓冲模式下这种等待感尤其明显。如果确实避免不了全屏刷新可以考虑把一次大刷新拆成几个小区域分时提交让LVGL在中间穿插一些渲染工作。或者接受这是F103平台的物理上限把UI设计成以局部控件刷新为主远离全屏动画。6. 常见问题与排查技巧实录6.1 花屏、乱码、颜色不对花屏是FSMC驱动LCD时最常遇到的问题。首先检查地址映射命令口和数据口的地址偏移对不对。如果RS接FSMC_A0数据端口必须用0x60000002写成0x60000001或者0x60000000都会导致数据被当成命令屏幕立刻乱掉。其次检查DMA方向和数据宽度。很多人在DMA的方向上栽跟头DMA_DIR_PeripheralDST表示外设端是目标源地址是内存如果配反了DMA会把LCD端口里的数据读出来写到内存里还不如不配。最后检查FSMC时序。时序太紧会出现随机花屏时好时坏尤其在温度变化后更明显。如果出现这种偶发花屏把ADDSET和DATAST各加1再观察几分钟。6.2 DMA传输完成后界面卡死如果DMA传完数据后程序卡在某个地方不动大概率是DMA中断标志没清对或者Normal模式下的DMA通道没有在传输完成后关闭。F103的DMA在Normal模式传完指定数量后会自动停止但中断标志必须手动清除。如果你重新启动DMA时不先DeInit残留的配置也可能导致通道状态异常。还有一个小坑我在DMA中断里调用了lv_disp_flush_ready之后没有再显式地触发一次LVGL刷新。如果下次刷新发生在很久以后画面会停留在上一次的状态。解决办法是在flush_ready之后调用lv_timer_handler系统不会自动补帧但下次事件触发时自然就正常了。这里不用过度担心LVGL内部的事件机制会把pending状态串起来。6.3 DAP下载失败和BOOT引脚的关系大屏项目跑一段时间后偶尔会遇到DAP下载器连不上芯片。这段经历跟BOOT引脚有直接关系。如果程序里定义了某些引脚作为FSMC输出而恰好跟SWD调试口有冲突调试器就可能在下载阶段失败。更常见的其实是程序进入低功耗或者死循环导致内核没有响应调试请求。遇到这种情况把BOOT0拉高让芯片从系统存储器启动DAP能连上后立刻擦除Flash再把BOOT0拉低复位恢复正常下载。注意BOOT1一般不用动保持和启动方式匹配即可。另外刚才提到的背光供电不足引起的电压跌落也会表现为下载失败或者下载过程中断排查时先量一下3.3V电压是否稳定。6.4 画面闪烁和字体残影如果画面在按钮切换时出现闪烁先检查是不是显示窗口设置和LVGL的area不一致。窗口没锁对控制器会把数据写到别的坐标视觉上就像残影。另一个常见原因是绘制缓冲放在了一个可能被中断或DMA改写的区域可以检查链接脚本把大数组放到固定的SRAM区段。字体残影问题通常是颜色格式不一致导致的。LVGL配置为16位色深但屏幕控制器实际工作在18位或24位模式低位的颜色数据对不上边缘就会出现类似半透明的残影。检查LCD初始化代码里的像素格式设置确保与LV_COLOR_DEPTH统一。我在实际项目中最大的体会是F103跑LVGL大屏本质上是一场“内存和速度的极限平衡”。LVGL本身的优化能力决定了UI渲染的上限FSMC接口决定了数据传输的通畅度而DMA则是让CPU和传输引擎各司其职的关键。如果你也准备用F103硬扛800x480我的建议是先把内存预算表拉出来每个字节都算清楚再动手写代码。FSMC时序从保守开始DMA刷屏一定要配局部刷新最后再用串口把帧率统计出来每一步调优都有数据支撑这个方案就真的能落地了。
返回列表