公司动态

STM32F407 SPI+DMA刷ST7789 TFT屏,彻底告别刷屏卡顿

📅 2026/9/1 10:15:14
STM32F407 SPI+DMA刷ST7789 TFT屏,彻底告别刷屏卡顿
简介面向STM32嵌入式开发者的ST7789 TFT屏驱动工程代码包基于STM32F407RCT6主控通过SPI接口配合DMA传输实现高刷新率刷屏并集成触摸功能。代码涵盖GPIO配置、SPI与DMA通道初始化、ST7789显示驱动及触摸中断处理适合需要开发小型彩色屏显交互的物联网或消费电子项目。压缩包共178个文件以51个h头文件、48个c源文件为主另有Keil工程配置文件、编译生成的axf/hex及链接脚本等整体仅1.21MB结构紧凑便于直接导入参考。已有2045人学习下载资源内包含完整工程目录和关键外设驱动可帮助读者理解DMA与SPI协同工作机制并在此基础上快速移植到其他F4系列或同类屏控方案。 上次调一块STM32F407RCT6做仪表界面屏幕用的是SPI接口的ST7789 TFT240x240分辨率。一开始图省事直接HAL_SPI_Transmit阻塞发送刷整屏结果主循环直接被拖死刷一帧纯色要50多毫秒CPU全程傻等按键扫描、串口通信全部卡顿。后来把SPI的数据搬运交给DMA刷屏耗时降了一截CPU也被解放出来干正事。这篇文章就把F407RCT6 SPI DMA刷ST7789的完整思路、可抄的代码和调试验证过程中遇到的坑一起理一遍适合正准备用TFT屏做界面、或者已经在用SPI刷屏但觉得卡顿的朋友参考。先给结论SPIDMA刷屏不是玄学核心就两件事——把一个足够大的内存缓冲区准备好然后让DMA把缓冲区里的像素按SPI协议源源不断搬给屏幕CPU只在传输结束那一下被中断去收个尾。整个链路清晰之后剩下的就是调参和避坑了。1. 用SPIDMA刷屏前先想清楚这三点1.1 为什么选SPI屏而不是并口屏ST7789这类TFT屏常用接口有SPI和并口8080/6800F407本身带FSMC理论上也能驱动并口屏。但SPI屏的优势非常明显引脚少。一个240x240的ST7789模块SPI接法只要SCK、MOSI、CS、DC、RST、BL六根线整个走线非常干净。对PCB面积敏感或者引脚资源紧张的项目SPI屏几乎是唯一选择。并口屏虽然刷屏带宽更高但接口至少占掉十几根引脚对F407RCT6这种LQFP64封装的芯片来说代价太大。所以我最终的方案锁定在SPI屏而且明确只做主机发送方向MISO不接。1.2 DMA到底解决了什么很多人对DMA的理解停留在“让你不用写循环发数据”这个理解没问题但不深刻。真正的问题是SPI外设发送每个字节时CPU要做的事是“查状态寄存器、把数据丢进DR、等发送完成”。这个循环虽然简单但在高主频MCU上依然会占用大量CPU周期。刷一帧240x240 RGB565数据是115200字节如果每个字节都要CPU走一遍“查询写入等待”即便每字节只需要20个周期也是230万个周期按168MHz算就是13毫秒以上而且这段时间CPU几乎做不了别的事。DMA的作用是把“内存-SPI数据寄存器”这个过程整个接管CPU只需要在DMA配置好后做一次启动然后就该干嘛干嘛。传输完成后DMA触发中断CPU再处理一下收尾。这跟“把快递从A点搬到B点”是同一个道理自己跑一趟要花时间叫个搬运工DMA全程代劳你只需要等电话通知。F407的DMA带宽足够支撑SPI1跑到几十MHz刷屏这种高吞吐场景正好用得上。1.3 一帧数据量多大时间够不够先算账。ST7789在RGB565格式下每个像素占2字节。240x240分辨率一帧就是 240×240×2 115200字节 ≈ 112.5KB。假设SPI时钟42MHzF407 SPI1理论最高42MHz理论上传输时间是 115200×8 / 42MHz ≈ 21.9ms。但实际还要加上命令设置窗口、DC切换、SPI停顿时隙等开销一帧往往要25~30ms也就是30fps左右。如果要求60fpsSPI刷屏在物理上就很吃力了除非用更小的分辨率或者局部刷新。但30fps对大多数仪表界面、菜单动画、波形显示完全够用。关键是DMA传输这25ms里CPU是基本空闲的你可以同时画下一帧的图形数据到另一个缓冲区。所以在F407上SPIDMA是性价比很高的刷屏方案前提是你把实时性预期放在正确的位置。2. 引脚与时钟F407RCT6驱动ST7789的硬件准备2.1 接线方案与选型理由我用的模块是常见1.3寸/1.54寸ST7789驱动板接口引脚定义一般有SCL/SCK、SDA/MOSI、CS、DC、RST、BL。F407RCT6这边优先选择SPI1因为SPI1挂在APB2总线上时钟84MHz分频后能跑42MHz而SPI2/SPI3挂在APB1上最高才21MHz。具体接线如下屏幕引脚F407RCT6引脚功能说明SCKPA5 (SPI1_SCK)SPI时钟最高42MHzMOSIPA7 (SPI1_MOSI)主机发送数据CSPA4 (软件GPIO)片选低有效DCPB0 (软件GPIO)数据/命令选择RSTPB1 (软件GPIO)硬件复位BLPB2 或接3.3V背光控制SCK和MOSI用SPI1的默认映射不用重映射CubeMX里直接选就行。CS、DC、RST都走普通GPIO。关于“三线SPI”的说法在这里解释一下TFT模块常说的“四线SPI”是指SCK、MOSI、CS再加一条DC线DC不属于SPI协议线它是面板用来区分“当前字节是命令还是像素数据”的控制线。ST7789也支持不带DC的9bit模式命令带标志位但F407的SPI不支持硬件9bit帧所以常规做法就是用4线SPI加GPIO控制DC。建议别在3线/4线这个命名上纠结看模块手册看有没有DC引脚就清楚了。2.2 CubeMX的配置顺序与DMA通道CubeMX配置流程先配RCC外部晶振按你板子实际来、把SPI1选为Transmit Only Master模式硬件NSS关闭数据位8bit时钟极性/相位按模块手册设通常选Mode 0或Mode 3都行绝大多数ST7789模块都能兼容我用的是Low Power默认匹配的Mode 0。预分频我建议先按BR4也就是42MHz去试如果屏花再降。DMA配置里添加SPI1_TX请求这里有个必须记死的映射F407的SPI1_TX对应DMA2 Stream3 Channel3选错Stream或者Channel会直接不工作。DMA参数设置上Direction选Memory To Peripheral外设地址不变内存地址自增数据宽度都设ByteNormal模式即可。Normal模式的好处是每次传输都是可控的传输完自动停方便我按帧管理Circular模式虽然能自动连续发但缓冲区更新时机不好控制容易出现画面撕裂。另外外设和内存数据宽度都设Byte是有讲究的SPI的DR寄存器是8位有效DMA按Byte搬运能保证每个字节都对位如果用HalfWord内存数组必须字对齐而且字节序会出问题。2.3 软件CS还是硬件CSF407的SPI有NSS引脚可以做硬件片选但我强烈建议用GPIO软件控制CS。硬件NSS在单从机场景下看似方便实际坑不少NSS受SPI状态机控制发送完成和空闲时的行为在不同SPI版本上不一致调试时容易被CS时序干扰而且ST7789的CS引脚并不协议严格地要求每指令周期都拉高很多时候保持低电平能让传输更连贯。软件CS的好处是自由度和可控性都高多从机总线也不慌。热词里有人搜“SPI硬件片选与软件片选”这里直接给结论做TTF刷屏软件CS。3. ST7789的初始化时序为什么DMA只负责“像素搬运”3.1 命令发送用普通SPI数据发送用DMAST7789初始化需要发一大串命令比如软复位、休眠唤醒、色彩格式、扫描方向、伽马校正。命令数据量很小每条命令加参数也就几十字节这时候用DMA纯属杀鸡用牛刀而且初始化命令之间往往需要延时等待比如SLPOUT之后要等120ms用阻塞SPI发更简单直接。真正的数据量爆发点在“往显存写像素”这一步——先发窗口命令指定坐标区域再发内存写命令RAMWR然后连续发送像素数据。这一段就是DMA的用武之地。初始化序列里的几个关键命令我列一下这些是刷屏前必须配好的0x36 MADCTL设置扫描方向和颜色顺序这个直接影响图像正反左右不同模块默认值还不一样最常出问题。0x3A COLMOD像素格式设为0x05代表RGB56516bit/pixel。0x21 INVON反色打开。很多ST7789模块出厂默认反色不开这个颜色会偏。0x29 DISPON开显示。3.2 窗口指令与局部刷新CASET0x2A和RASET0x2B用来设置刷新窗口。比如要刷全屏就把列地址0~239、行地址0~239传进去。之后只要发一次RAMWR0x2C后面连续写入的数据会按窗口顺序自动填充写满后停止。这也是DMA能整段搬运的原因——不需要在每两个字节之间反复发命令。窗口指令的另一个价值是做局部刷新。比如只画一个20x20的进度条可以把窗口设成那个区域然后只DMA发送800字节20×20×2传输时间不到0.2ms。这比全屏重画快几十倍。做界面时尽可能用局部刷新别动不动整帧全刷。3.3 DC引脚的切换细节DC引脚的控制是整个刷屏过程里最容易出细节问题的地方。约定DC0时屏幕把SPI收到的字节解释为命令DC1时解释为数据。初始化发命令时每个命令字节前都要把DC拉低发完再拉高发参数。但在DMA发像素数据阶段DC要保持高电平直到整段数据发完。如果中途DC被拉低屏幕会把后续像素数据的一小段当成命令解析轻则花屏重则屏幕状态错乱。具体操作中我在启动DMA发送前设置DC为高在DMA完成中断或等待结束后才允许下次切DC。这里有个坑DMA传输完成中断触发时SPI外设可能还在发送最后一个字节。如果此时立刻操作DC或CS可能把最后一个字节搞坏。所以安全的做法是在DMA完成回调里等SPI忙标志清零再让后续流程去操作DC。4. 核心代码DMA方式刷新显存的完整实现4.1 缓冲区规划128KB RAM要精打细算F407RCT6有128KB SRAM看起来不小但一帧240x240 RGB565显存就是112.5KB如果直接定义一个全帧缓冲剩余RAM只剩15KB左右再来几个临时数组或协议栈就告急。所以我的做法是不建全帧缓冲区改用分段缓冲。比如定义一块uint16_t lcdBuf[240 * 16]一次刷16行占7680字节循环刷15次就刷完整个屏幕。这样RAM开销小DMA每次传输长度也只有7680字节完整落在HAL的uint16_t长度参数范围内。如果一定要全帧缓冲做动画可以选分辨率更小的屏比如128x160一帧40KB或160x80一帧25.6KB那全帧缓存也扛得住。具体取舍看项目需求但别一上来就挑战112.5KB全帧缓存除非你对剩余RAM使用量做过完整估算。4.2 SPIDMA发送的最小可运行代码核心发送代码其实很短。初始化完成后刷一段区域的流程是设置窗口、DC置高、启动DMA发送。下面给出一个简单但可运行的发送函数// 发送一段像素数据到屏幕buf为RGB565像素数组len为字节数 void LCD_WriteRAM_DMA(uint16_t *buf, uint32_t len) { LCD_DC_SET(); // 数据模式 __HAL_SPI_ENABLE(hspi1); // HAL库的len参数是uint16_t所以必须分段单次不超过65535字节 while (len 0) { uint32_t chunk (len 60000) ? 60000 : len; // 等待上一次DMA传输完成 while (HAL_SPI_GetState(hspi1) ! HAL_SPI_STATE_READY) { } HAL_SPI_Transmit_DMA(hspi1, (uint8_t *)buf, (uint16_t)chunk); buf chunk / 2; len - chunk; } // 等待最后一包彻底发完避免提前操作DC/CS while (HAL_SPI_GetState(hspi1) ! HAL_SPI_STATE_READY) { } while ((hspi1.Instance-SR SPI_FLAG_BSY) ! 0) { } LCD_DC_CLR(); }这段代码里有个很容易踩的坑HAL_SPI_Transmit_DMA的发送长度是uint16_t115200字节直接传进去会溢出变成负数。所以必须分段。上面的函数用60000作为分块上限每次发60000字节多一点循环发完每次等待DMA传输完成再发下一段。虽然轮询state会占用一点CPU但至少逻辑清晰适合作为最底层的稳定实现。要完全发挥DMA优势可以把“等待完成”改成回调驱动见下一节。4.3 DMA完成回调与流水线发送分段轮询的问题是CPU会卡在状态判断上最后一段DMA搬数据的时候CPU基本闲着。为了把空闲时间利用起来可以用完成回调自动加载下一段形成类似流水线的效果。先定义两个全局变量保存发送进度static uint8_t *lcdDmaBuf; static uint32_t lcdDmaLen; static uint16_t lcdDmaChunk; void LCD_WriteRAM_DMA_Start(uint16_t *buf, uint32_t len) { lcdDmaBuf (uint8_t *)buf; lcdDmaLen len; LCD_DC_SET(); lcdDmaChunk (lcdDmaLen 60000) ? 60000 : lcdDmaLen; HAL_SPI_Transmit_DMA(hspi1, lcdDmaBuf, lcdDmaChunk); } void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { lcdDmaBuf lcdDmaChunk; lcdDmaLen - lcdDmaChunk; if (lcdDmaLen 0) { lcdDmaChunk (lcdDmaLen 60000) ? 60000 : lcdDmaLen; HAL_SPI_Transmit_DMA(hspi1, lcdDmaBuf, lcdDmaChunk); } else { // 整帧发送完成收尾工作等待SPI不忙然后DC拉低 } } }这种方式下CPU只在每段DMA传完的中断里做一次指针累加和重新启动大部分时间真的在干别的。但要注意HAL_SPI_TxCpltCallback是中断上下文代码要短不要在里面做耗时操作。整个发送流程完成后最好置一个全局标志主循环看到标志再做帧结束后的处理。这在做动画刷新时特别重要——帧结束信号正好用来做帧同步计数器。5. 实测性能与后期优化从40MHz跑到稳定30帧5.1 实测帧率与CPU占用我在F407RCT6跑168MHz、SPI1预分频BR4即42MHz的条件下实测全屏240x240 RGB565刷新一帧大约28~32ms。测量方法是把“开始发送”和“发送完成”各翻转一次GPIO用逻辑分析仪看脉冲宽度。CPU占用方面用DMA回调流水线时主循环几乎不被阻塞而同样代码用阻塞发送CPU占用接近100%主轴任务明显抖动。对比数据大概这样刷新方式一帧耗时传输期间CPU状态SPI阻塞发送42MHz约50ms全程占用无法执行其他逻辑SPIDMA分段发送42MHz约30ms每段启动后空闲仅在中断里做事SPIDMA局部刷新小窗口依窗口大小空闲时间更多5.2 双缓冲与循环模式的取舍做连续动画时双缓冲是最容易想到的优化但前提是RAM够。F407RCT6在128KB RAM下全帧双缓冲很勉强所以我建议用“半帧双缓冲”两个缓冲区各存半帧比如各60KBDMA在发上半帧时CPU往下半帧缓冲里画数据上半帧发完DMA自动切换指针发下半帧CPU再去画上半帧。这样每帧内部DMA和绘图重叠执行实际体验帧率能有明显提升。至于DMA的Circular循环模式我不太推荐用在这个场景。Circular模式下DMA会自动连续搬运看起来省事但“什么时候更新缓冲区”变得不可控容易在传输途中改数据导致撕裂。如果你确实要无限刷纯色或者走马灯用Circular也注意只刷颜色不变的场景。动画刷新老老实实用Normal模式完成回调踢下一段这也是我最终稳定运行的方案。5.3 提升空间局部刷新、方向旋转与加速命令性能优化不用一开始就上复杂框架。先做几件低成本高收益的事第一局部刷新。界面静态背景只画一次变化的只有数字、图标、进度条区域每次刷新窗口设置成变化区域的坐标。画一个圆形进度环窗口只框住进度环的外接矩形更新量可能只有全屏的十分之一。第二合理配置MADCTL。ST7789的扫描方向寄存器可以旋转屏幕如果界面逻辑是横屏而屏默认竖屏与其在代码里做像素坐标旋转不如直接改MADCTL让硬件帮你转绘制代码简单速度也不损失。第三确保SPI时钟稳定。42MHz接近F407 SPI1极限如果出现偶发花屏或第一行错位优先降到21MHz。21MHz刷全屏约55ms但配合局部刷新后实际体验不会差太多。稳定比极限频率重要。另外说一句ST7789还支持RGB444格式每像素降到1.5字节数据量减25%但颜色精度损失肉眼可见。除非做极简配色界面否则不建议为了那点带宽牺牲色彩。最后分享一个调试技巧刷屏前先把ST7789的初始化序列单独抽出来用一个空的main工程只跑初始化清屏函数确认能稳定显示纯色。然后再加DMA刷图。这样能把“初始化不对”和“DMA配置不对”两类问题隔离开。我一开始直接把所有代码一起调花屏时根本分不清是颜色格式问题还是DMA时序问题白白浪费了一晚上。本文还有配套的精品资源点击获取