☰
STM32F407 FSMC+DMA驱动LVGL彩屏,刷新率翻倍实战与避坑指南
2026/10/5 1:15:08 网站建设 项目流程

STM32F407、FSMC、DMA、LVGL,这四个词凑在一起,基本就是草根工程师做彩屏界面的最佳组合。实测同一块3.5寸480x320屏,纯SPI驱动大概只有8~10fps,切到FSMC直写能到25fps左右,再让DMA接管搬运之后直接稳定在30fps以上,CPU占用掉了一大截,滑动、切换动画明显跟手了。这篇就把我怎么配置CubeMX、怎么写DMA刷新、以及那些容易踩的坑完整写出来,给打算在F407上跑LVGL的朋友当个参考。

先说结论:这个方案适合屏幕分辨率在480x320以内、硬件上已经引出了16位并口数据线的板子,不适合只有SPI接口的彩屏模块。核心思路是把LCD当成一块挂在FSMC总线上的SRAM来写,再让DMA代替CPU去完成“从LVGL缓冲区到屏幕显存”的数据搬运。整个过程涉及三个重要认知:FSMC为什么快、DMA如何绕开CPU、CubeMX里那些让人头晕的时序参数到底该填多少。

1. 整体设计思路:为什么FSMC+DMA这个组合能成立

1.1 FSMC不是“多一个外设”而已,它是给LCD开了一条快车道

很多人在STM32上第一次驱动彩屏,用的都是SPI屏。SPI好处是接线少、代码简单,但跑起来就露馅了。一块3.5寸屏分辨率480x320,RGB565格式下每帧数据量是480x320x2=307200字节,也就是300KB。SPI按20MHz时钟,理论速度只有2.5MB/s,传完一帧至少120多毫秒,算上协议和图像命令开销,实际帧率大概就是个位数到10fps。

FSMC不一样。它把外部并行SRAM/NOR Flash控制器直接挂在AHB总线上,访问外部存储器和访问内部SRAM在CPU眼里几乎没区别,一条写指令下去,16位数据线一次就传完一个像素。最粗暴的理解就是:SPI是2.5MB/s的乡道,FSMC是几十MB/s的高架。3.5寸并口屏的数据线DB0~DB15接到FSMC_D0~D15,WR接FSMC_NWR,RD接FSMC_NOE,CS接NE1,再拿一根地址线比如FSMC_A18去控制D/CX数据/命令选择。这样屏在你代码里就变成两个16位地址:一个写命令,一个写数据。

1.2 DMA的作用不是“让屏幕更快”,而是“让CPU不干活”

既然FSMC已经很快了,为什么还要DMA?这里要澄清一个常见误解:DMA并不会提高FSMC总线的极限带宽,它真正释放的是CPU的搬运负担。想想看,一帧300KB,假如用CPU循环写屏:

while(len--) { *(__IO uint16_t *)LCD_RAM_ADDR = *pBuf++; }

确实能跑得动,但LVGL本身在渲染界面时就要做大量绘图、裁剪、计算,CPU一边算一边搬,刷新期间核心几乎忙不过来。触摸扫描、动画定时、串口通信统统被排挤,界面看起来就是卡顿。

让DMA替代CPU做搬运之后,CPU只需要告诉DMA“源地址在哪、目标地址在哪、搬多少”,然后就可以继续回去跑LVGL渲染。DMA搬完触发一次中断,再告诉LVGL这块缓冲区已经刷完了。这个并行关系,才是流畅度真正“翻倍”的原因:刷新时间和渲染时间重叠了,而不是排队等。

1.3 3.5寸屏的硬件接线,决定你后续怎么配FSMC

我用的是常见3.5寸RGB565并口屏,控制器多半是ILI9488或者R61581,背面除了LCD引脚还会引出一排“LCD接口”,很多开发板就是直接把FSMC信号引到上面。接线的关注点有三个:

  • 数据线:DB0~DB15分别接FSMC_D0~FSMC_D15,16位宽度一次一个像素,这也是RGB565的首选连接方式。
  • 地址线:D/C引脚接在哪根FSMC_Ax上,直接决定你代码里命令地址和数据地址的偏移量,这个很关键,后面会专门说。
  • 读写控制线:WR、RD、CS、RST、背光BLK按时序接好。背光别忽视,很多卡顿和闪屏其实是背光供电扛不住导致的。

我的板子D/C接在FSMC_A18上,CS接FSMC_NE1。下面所有代码都按这个接法展开,如果你的板子D/C接在A16上,把地址偏移改一下就行,原理一模一样。

2. CubeMX完整配置流程与关键参数避坑

2.1 第一步先把时钟树拉满:168MHz是FSMC时序计算的基础

进CubeMX第一步不是急着点FSMC,而是把主频配好。F407最高能跑168MHz,FSMC挂在AHB总线上,它的时序参数全部以HCLK为基准计数,168MHz下1个HCLK约等于5.95ns。这个数值后面算时序表要用,所以别偷懒用默认的16MHz内部时钟。

我一般这样配:

  • HSE选外部晶振,我这块板子是25MHz,有些板子是8MHz,按自己晶振选。
  • PLL倍频到主频168MHz,AHB设为168MHz。
  • APB1设42MHz,APB2设84MHz,这是给串口、定时器这些用的,FSMC不吃这两个总线时钟,但后续外设可能要用,先一起设对。

配完时钟再打开FSMC外设。在CubeMX左侧栏找到FSMC相关的选项,把Chip Select选为NE1,Memory type选SRAM,Memory width选16 bit。这时CubeMX会自动把相关GPIO拉出来,不要手动动这些引脚配置,否则生成代码后容易和FSMC冲突。

2.2 FSMC时序参数:别照抄网上的,自己算一遍最稳

FSMC给普通SRAM用的读时序、写时序分别有四个参数:地址建立时间、地址保持时间、数据建立时间、总线周转时间。在CubeMX里通常需要填的是:

  • Address setup time,地址建立时间,单位是HCLK周期数。
  • Address hold time,地址保持时间,部分版本叫Data hold time。
  • Data setup time,数据建立时间。
  • Bus turn around time,总线周转时间,只在两次连续访问切换时用,单设备场景可以设小一点。

关键是怎么把这些数字换算成真实时间。以写时序为例,一次写周期的总时间大约等于:

(Address setup + Address hold + Data setup + 2) 个 HCLK

按我最终用的ADDSET=1、ADDHLD=0、DATAST=4来算:

(1 + 0 + 4 + 2) × 5.95ns ≈ 41.65ns

对照ILI9488手册,并口写周期最小要求大概在66ns左右,41.65ns确实偏快,属于“能跑但留不住余量”的设置。我更推荐稳妥一点的组合:ADDSET=2、ADDHLD=0、DATAST=6,总周期约(2+0+6+2)×5.95=59.5ns,基本贴近屏的要求,稳定性更好。如果你不确定手上的屏体质,先用ADDSET=3、DATAST=7跑,稳定之后再逐步调快,千万不要一上来就追求极限频率。

CubeMX里Read timing和Write timing可以分开填。我习惯先按Write timing同样的参数填一次Read timing,因为后续可能要用FSMC读LCD控制器ID,读时序太严格会导致读回来全是0xFF。遇到读寄存器的需求,再去把Read timing单独放宽也不迟。

2.3 DMA配置的核心知识点:F407的FSMC没有硬件DMA请求,必须用内存到内存模式

这是最容易卡住新手的一步,网上很多教程也含糊。串口接收DMA、ADC连续采样DMA都有外设的硬件请求信号,但F407的FSMC并不向DMA发出传输请求。所以FSMC搬运数据不是“外设请求型”DMA,而是要多配一种方向:Memory to Memory。

具体在CubeMX里这样设置:

  • 选择一个DMA Stream,我用的是DMA2 Stream0。
  • Direction选择Memory to Memory。
  • Priority选Very High,尽量让这趟搬运优先生效。
  • Mode选Normal,不要选Circular。LVGL每次刷新的缓冲区地址和大小都可能在变,循环模式反而出问题。
  • Data Width源和目标都选Half Word,对应16位RGB565像素。
  • Memory Increment选Enable,源地址要自增。
  • Peripheral Increment也选Enable,目标地址也要自增,因为屏幕GRAM地址随着连续写入是线性累加的。

生成代码后,你会看到CubeMX自动生成了MX_FSMC_Init和MX_DMA_Init。部分版本里DMA_Init会放在FSMC之前或者之后,这点不用太纠结,只要两块代码都在就行了。但我建议打开main.c确认一下两个外设的时钟使能都开了,特别是在大工程里,有裁剪过的HAL库可能会漏掉RCC时钟宏,导致一运行就HardFault。

3. 代码落地:从FSMC地址定义到LVGL刷新回调

3.1 第一步,搞定LCD命令和数据地址

前面说过D/C接在FSMC_A18上,NE1对应Bank1的基地址0x60000000。访问地址时,D/C这根地址线的值就会出现在FSMC_A18上。所以命令地址是0x60000000,数据地址是0x60000000加上A18对应偏移量,即0x00040000,得到0x60040000。

代码里定义成两个宏:

#define LCD_BASE_ADDR ((uint32_t)0x60000000) #define LCD_REG_ADDR (*(volatile uint16_t *)LCD_BASE_ADDR) #define LCD_RAM_ADDR (*(volatile uint16_t *)(LCD_BASE_ADDR + 0x00040000))

如果你板子的D/C接在FSMC_A16上,那数据地址的偏移是0x00010000,也就是0x60010000。关键就是看原理图,D/C接在哪根FSMC_Ax,数据地址的偏移就是1 << x,这里不再展开HADDR映射的细节,照着开发板例程的地址判断即可。

有了这两个宏,读写ILI9488寄存器就很简单:

static void lcd_write_cmd(uint16_t cmd) { LCD_REG_ADDR = cmd; } static void lcd_write_data(uint16_t data) { LCD_RAM_ADDR = data; }

注意,FSMC访问这些地址时走的是16位总线,所以一定要用volatile uint16_t去定义,不能用32位指针,否则一次访问变成32位写,数据线高位会多出不该有的信号。

3.2 第二步,DMA传输函数与中断回调

这里我直接用HAL库的HAL_DMA_Start_IT,参数分别是DMA句柄、源地址、目标地址、传输数据量。数据量是Half Word的数量,不是字节数,别搞混。

extern DMA_HandleTypeDef hdma_mem2mem; static volatile uint8_t lcd_dma_busy = 0; static uint32_t lcd_dma_dst = 0; void LCD_DMA_Start(uint16_t *srcAddr, uint32_t pixelCount) { lcd_dma_busy = 1; lcd_dma_dst = LCD_BASE_ADDR + 0x00040000; __HAL_DMA_DISABLE(&hdma_mem2mem); HAL_DMA_Start_IT(&hdma_mem2mem, (uint32_t)srcAddr, lcd_dma_dst, pixelCount); }

中断服务函数在stm32f4xx_it.c里已经有了入口:

void DMA2_Stream0_IRQHandler(void) { HAL_DMA_IRQHandler(&hdma_mem2mem); }

DMA传输完成后HAL会调用回调函数:

void HAL_DMA_TC_Callback(DMA_HandleTypeDef *hdma) { if (hdma == &hdma_mem2mem) { lcd_dma_busy = 0; // 在这里调用LVGL的 flush ready,下面讲 } }

有一个细节要提醒:DMA搬运时源缓冲区必须是SRAM,而不是CCM。F407有64KB CCM内存在0x10000000地址上,DMA无法直接访问这块区域。如果LVGL缓冲区被编译器放到CCM里,DMA读出来的数据就全是乱的,花屏或黑屏跑都跑不了。解决办法要么在链接脚本里调整LVGL缓冲区的放置段,要么干脆用普通SRAM地址,不碰CCM。

3.3 第三步,LVGL移植与flush回调

LVGL版本一直在更,函数名在V8和V9之间改动不小。V8里是lv_disp_buf_init、lv_disp_drv_register,V9改成了lv_display_create、lv_display_set_flush_cb。下面代码以V9风格写,你用V8的话把API名对应替换一下就行。

先定义显示缓冲区。以我的3.5寸屏为例,一帧要300KB,F407片上SRAM无论如何放不下整个显存缓冲,但只要用“局部刷新”思想,缓冲区只覆盖屏幕的一部分就够了:

#define LCD_H_RES 480 #define LCD_V_RES 320 static lv_color_t buf_1[LCD_H_RES * 80]; static lv_color_t buf_2[LCD_H_RES * 80]; static lv_display_t *lcd_disp; void lvgl_port_init(void) { lv_init(); lcd_disp = lv_display_create(LCD_H_RES, LCD_V_RES); lv_display_set_flush_cb(lcd_disp, disp_flush_cb); lv_display_set_buffers(lcd_disp, buf_1, buf_2, sizeof(buf_1), LV_DISPLAY_RENDER_MODE_PARTIAL); }

两块480x80的缓冲,每块大小是480×80×2=76800字节,约75KB,两块共150KB,F407的常规SRAM挤一挤够用。如果你的工程还要跑协议栈、音频缓冲,可以把每块缩到480×40=38.4KB,两块也才76.8KB,照样能用。

flush回调是整个刷新流程的心脏:

static void disp_flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map) { uint16_t x0 = area->x1; uint16_t y0 = area->y1; uint16_t x1 = area->x2; uint16_t y1 = area->y2; LCD_SetWindow(x0, y0, x1, y1); uint32_t pixelCount = (x1 - x0 + 1) * (y1 - y0 + 1); LCD_DMA_Start((uint16_t *)px_map, pixelCount); // 如果要更稳妥,这里可以先判断DMA没在忙,忙的话等待完成后再启动 }

LCD_SetWindow就是给ILI9488下发列地址和行地址命令,然后发一个写显存命令0x2C:

static void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { lcd_write_cmd(0x2A); lcd_write_data(x0 >> 8); lcd_write_data(x0 & 0xFF); lcd_write_data(x1 >> 8); lcd_write_data(x1 & 0xFF); lcd_write_cmd(0x2B); lcd_write_data(y0 >> 8); lcd_write_data(y0 & 0xFF); lcd_write_data(y1 >> 8); lcd_write_data(y1 & 0xFF); lcd_write_cmd(0x2C); }

关键是DMA完成之后必须告诉LVGL刷新完成,否则LVGL会一直等下一次刷新机会,界面会卡死:

static lv_display_t *pending_disp = NULL; void HAL_DMA_TC_Callback(DMA_HandleTypeDef *hdma) { if (hdma == &hdma_mem2mem) { lcd_dma_busy = 0; if (pending_disp != NULL) { lv_display_flush_ready(pending_disp); pending_disp = NULL; } } }

对应的,在disp_flush_cb里要把pending_disp记下来:

static void disp_flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map) { // ... 设置窗口,启动DMA pending_disp = disp; }

这种异步方式的好处是:DMA搬运LCD显存期间,CPU可以立刻去渲染下一块区域,等中断来了再通知LVGL“这块刷完了”。如果改成在flush回调里傻等DMA完成,虽然也能用,但分块渲染时渲染和搬运又会退化成串行关系。

3.4 第四步,系统心跳和主循环

LVGL需要一个毫秒级的心跳来驱动动画和事件。最简单的方法是让SysTick每1ms调用一次lv_tick_inc(1):

void SysTick_Handler(void) { lv_tick_inc(1); }

如果你用了FreeRTOS,则可以直接用vApplicationGetTickCount或者configTICK_RATE_HZ来提供时钟源,但记得不要再在SysTick里重复调用lv_tick_inc,否则动画会快一倍或乱跳。裸机的话主循环就简单:

while (1) { lv_timer_handler(); delay_ms(5); }

触摸部分不在本文范围,但提一个经验:如果你用XPT2046这类触摸芯片,读取坐标时尽量放到定时器或者独立任务里做,不要在lv_timer_handler的上下文里做长时间阻塞读取,否则DMA动画刚带来的流畅感会被触摸轮询毁掉。

4. 实测效果:流畅度翻倍到底是怎么个翻法

4.1 三套方案数据对比

为了验证提升幅度,我在同一块3.5寸屏、同一份LVGL工程里做了三组测试。测试方法是让界面全屏刷新一个纯色页面,统计每秒能刷多少帧,同时用DWT计数器统计CPU在刷新阶段的占用率,结果很有参考价值。

方案接口与搬运方式理论带宽实测帧率参考CPU占用感受
SPI驱动SPI 20MHz,CPU循环搬运2.5MB/s8~12fps高,刷新时明显卡顿
FSMC直写FSMC 16位,CPU循环memcpy20~30MB/s20~28fps中,动画能看但不跟手
FSMC+DMAFSMC 16位,DMA内存到内存搬运30MB/s以上30fps以上低,动画流畅且触控响应快

第一个方案里,SPI一帧就要120毫秒左右,再叠加LVGL的渲染时间,所以8fps是正常水平,拖动列表时明显掉帧。第二个方案其实已经能接受,但问题在于CPU负担重,一旦同时跑MQTT协议栈或者外部传感器采集,时序容易乱。第三个方案才是本文重点,DMA几乎不占CPU,我在日志里跑串口打印、LED动画的同时,LVGL界面依然能保持流畅。

注意这里的帧率数字和屏幕刷新策略强相关。如果你开的是LVGL官方demo中的widgets,里面大量区域是局部刷新,实际每帧数据量可能只有几KB到几十KB,帧率会更高,但单次全屏纯色刷新时这个对比最能体现搬运方式差异。

4.2 “翻倍”的真正体感在UI响应速度上

很多人只看帧率数字,觉得从25fps到35fps不过是数字变化,实际体验差别非常大。我跑LVGL自带demo时最明显的感觉是:滑动列表时手指轨迹不拖泥带水了,按钮按下释放的动画不再有“顿一下再弹”的迟滞感,盖板转圈的时候动画帧率也稳定。

造成这个体感差异的关键原因就是CPU被释放出来了。FSMC直写方案里,哪怕界面元素不算多,搬运一帧数据也要占用好几毫秒的纯循环时间,这段时间里LVGL的timer_handler全被卡住。DMA方案里,这几毫秒变成了后台总线搬运,CPU在同样的时间里可以做触摸消抖、动画插值、数据解析,整个系统的“响应速度”自然就上去了。

我自己的工程里还有一段音频播放任务,以前用FSMC直写时播放偶尔会出干扰杂音,换成DMA搬运后,DMA期间CPU不被占用,音频缓冲填充的时序稳定多了,这个算是意外收获。

5. 避坑清单与问题排查实录

5.1 地址偏移和窗口设置的坑

最常见的花屏问题是:所有画面都好像被“错位”了,或者刷新区域出现莫名其妙的分块。这种八成是命令地址和数据地址的偏移没接对。我见过有人把D/C接在FSMC_A16,代码里还是按A18的0x60040000写,结果屏幕能亮但颜色和坐标完全对不上。先对着原理图把A16、A18的偏移算清楚,再烧个纯红纯绿测试页验证颜色顺序,比什么都快。

还有一类顺序很隐蔽:DMA搬运前忘记设置显存窗口。UNI9488内部GRAM有一个当前写入地址指针,连续写0x2C之后也会自动递增。但如果你上一次刷新区域在屏幕左上角,这一次DMA搬运的是右下角的一块,没有重新设置窗口,数据就会继续写在左上角后面的位置,画面会出现叠影和横条纹。我在flush回调里把LCD_SetWindow放在启动DMA之前,就是强制保证每次DMA目标都从正确的窗口起点开始。

5.2 DMA不触发传输完成中断的排查顺序

DMA搬运偶尔会进不了中断,现象是画面刷新一次就停了,或者压根没有显示。我按频率排一下原因:

  • DMA Stream没有使能对应中断。CubeMX生成代码后,要检查HAL_DMA_Init之后有没有把对应的全局中断打开,DMA2 Stream0对应的是DMA2_Stream0_IRQn,如果没注册到中断向量表,无论传输多少次都不会有回调。
  • 数据长度传成了字节数。HAL_DMA_Start_IT最后一个参数是Half Word数量,480×80像素就是38400,不是76800。传错的话会搬完一半就停了,而且不会按预期触发TC。
  • 源地址或目标地址非法。访问了不存在的FSMC地址区间,比如把目标地址写成了0x50000000,一次DMA搬过去就是总线错误,轻则HardFault,重则DMA一直停在错误状态。检查地址范围必须在0x60000000到0x6FFFFFFF之间。
  • DMA句柄配置被后续代码覆盖。如果你在初始化之后又手动改动了hdma_mem2mem的Init参数但没有重新调用HAL_DMA_Init,可能导致句柄状态和实际硬件不一致。

5.3 缓冲区不要放在CCM,双缓冲也不是越大越好

之前提过CCM不能给DMA访问,这个坑我踩过一次,当时为了省SRAM,把LVGL的缓冲区放到了0x10000000的CCM区,LVGL渲染能正常出图,但DMA把数据源指向CCM后完全读不出来,画面直接花屏。排查方法是看map文件确认缓冲区地址,或者直接把缓冲区地址打印出来检查。

双缓冲的大小也要算账。F407可用的常规SRAM大约是192KB,两块全屏480x320的RGB565缓冲加起来600KB,想都别想。我建议按内存预算选用两块1/4屏或1/6屏的缓冲,比如480×80的两块是150KB,已经能获得很好的并行效果;如果工程里音频、协议栈占用大,就退到480×40的两块,体感也不会差太多。单缓冲不是不行,但DMA完成前LVGL要等ready,瓶颈会出现,所以双缓冲是性价比最高的适配方案。

5.4 供电和硬件的稳定性问题

3.5寸屏的背光功耗比想象中大,如果整板靠USB供电,拔插瞬间或者界面大面积切换时很容易掉电压,表现是闪屏、随机花屏、甚至系统重启。我一开始图省事直接从主板的3.3V取电,跑LVGL动画不到十分钟就出现花斑。后来把屏的背光单独用一颗LDO供电,数据逻辑电平仍然和F407共用3.3V,问题就消失了。如果你原理图上也有类似PA8控制VBUS这类USB供电结构,更要小心,核心板自带的USB足迹供电能力有限,带屏项目最好外接独立电源。

还有一点,FSMC线束如果飞线太长,高速总线反射会很严重。3.5寸屏大多通过排针直插,问题不大,但如果你用了杜邦线延长,时序参数就必须放宽,否则看起来是时好时坏的“接地问题”,实际上是信号完整性问题。优选方案是短排线加规则走线,不推荐超过10cm的飞线跑FSMC。

5.5 一点个人体会

把FSMC、DMA、LVGL这三层打通之后,我最大的感受是:屏幕流畅度真的不是靠“堆主频”或者“放大缓冲区”解决的,关键还是在“谁来做最后的搬运”以及“搬运过程中CPU在干什么”。F407虽然只是M4核,168MHz放到今天不算强,但把FSMC总线和DMA用好,跑个像样的彩色界面绰绰有余。

最后分享一个小技巧:调试时可以在DMA的TC回调里翻转一个GPIO,再用逻辑分析仪或示波器看这个引脚的脉冲宽度,就能准确测出每一帧的搬运耗时。这个数据比肉眼判断靠谱得多,我调时序参数的时候全靠这个办法定位瓶颈。希望这套实战经验和配置避坑点能帮你少走点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询