简介:这份资源面向嵌入式开发学习者与显示驱动调试人员,聚焦STM32F767通过SPI结合DMA高速驱动9341 TFT液晶屏的完整工程实现,可用于理解高主频MCU下外设协同刷屏的配置思路与性能优化方法。压缩包共1099个文件,约54.23MB,以591个c源文件与275个h头文件为主体,辅以51个s汇编启动文件、42个icf链接脚本及d、o、crf等编译中间文件,并含a、lib静态库与uvprojx、ioc等工程配置,覆盖HAL驱动、核心源码与构建产物。已有2123人学习下载,说明该方案在SPI高速刷屏场景中具备一定参考价值。工程围绕54MHz SPI时钟、DMA内存到外设传输、9341初始化序列与20Hz整屏刷新展开,读者可据此梳理DMA请求配置、传输缓冲区设定与完成中断处理流程,并对照目录结构快速定位驱动与工程文件,为自身项目的刷屏性能调优提供可复用的实践参照。
1. 一块 9341 屏刷出 60 帧,瓶颈从来不在 SPI 时钟
手里有块 STM32F767 的开发板,屏是常见的 ILI9341,320×240、16 位色,SPI 接口。很多人第一次点亮它,刷个全屏纯色要肉眼可见地“从上往下扫”,刷一张图更是卡成幻灯片。直觉反应是“SPI 时钟拉满就行”,于是把分频调到 2、4,结果要么花屏,要么速度没涨多少。真正卡住刷新率的,是每帧几十万次字节搬运全压在 CPU 上,加上 9341 的写显存命令、窗口设置、片选翻转这些开销,SPI 时钟再高也喂不饱。
这份资源围绕 STM32F767 的 SPI + DMA 组合,把 ILI9341 的高速刷屏链路拆开:DMA 接管数据搬运,CPU 只负责发命令和切窗口,配合 F767 的 216MHz 主频和双缓冲思路,把全屏刷新从“扫屏”压到接近屏体极限。适合已经能点亮 9341、想从“能显示”跨到“能流畅刷图/刷 UI”的嵌入式开发者,也适合拿它当 SPI+DMA 外设组合的练手模板。下面按“链路怎么搭 → 参数怎么定 → 坑在哪 → 怎么验证”走一遍。
2. 把刷屏链路拆开:SPI 时序、DMA 通道与 9341 写显存
2.1 为什么是 DMA 而不是中断搬运
先算一笔账。320×240×2 字节 = 153600 字节一帧。SPI 在 F767 上挂 APB2,时钟可到 54MHz,SPI 分频后实际 SCK 常见跑到 27MHz 或 13.5MHz。按 27MHz、每字节 8 位算,纯传输时间约 153600×8/27e6 ≈ 45ms,也就是理论 22 帧。但这是“纯传输”,前提是数据源源不断送进 DR 寄存器。
如果用中断逐字节搬,每字节一次中断,一帧 15 万次中断,F767 再快也被中断开销吃光,实测往往掉到个位数帧率。用 DMA 则是一次配置、整块搬运,CPU 只在传输完成中断里收尾。这就是这条链路的核心选型理由:把“搬字节”从 CPU 手里拿走,CPU 只做“发命令、设窗口、启动 DMA”三件事。
9341 的写显存流程是固定的:发 0x2C(Memory Write)命令后,后续数据按窗口自动填充。所以 DMA 要搬的正是 0x2C 之后的那一大坨像素数据,命令本身仍走普通 SPI 发送。这个“命令走 CPU、数据走 DMA”的分工,是整条链路的地基。
2.2 硬件连接与 SPI 模式确认
接线是老三样,但顺序和模式容易翻车。9341 的 SPI 模式要求:CPOL=0、CPHA=0(模式 0),数据在 SCK 上升沿采样,MSB 先行,8 位数据帧。F767 的 SPI1/SPI2/SPI4 都能用,选哪个看你的引脚和 DMA 请求映射。以 SPI1 为例,常见引脚是 PA5(SCK)、PA6(MISO)、PA7(MOSI),片选和 DC 另配 GPIO。
// SPI1 初始化关键片段(HAL 库风格,参数按 27MHz SCK 估算) hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; hspi1.Init.Direction = SPI_DIRECTION_1LINE; // 只写屏,省一根 MISO hspi1.Init.DataSize = SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; // CPOL=0 hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; // CPHA=0,模式0 hspi1.Init.NSS = SPI_NSS_SOFT; // 片选软件控制 hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_4; // 54M/4≈13.5M,先稳后快 hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; HAL_SPI_Init(&hspi1);这里几个参数值得说清:Direction设成 1LINE 是因为刷屏只写不读,省掉 MISO 能简化布线;NSS用软件是因为 9341 的片选要和 DC 信号配合,硬件 NSS 反而不好控时序;BaudRatePrescaler先给 4 分频跑稳,等链路验证通过再往 2 分频压。很多人一上来就 2 分频,结果杜邦线一长就花屏,误以为是代码问题,其实是信号完整性——这是后面避坑章要展开的。
2.3 DMA 通道映射与传输宽度
F767 的 DMA 请求映射是固定的,SPI1_TX 一般走 DMA2 Stream3(Channel 3)或 DMA2 Stream5,具体查参考手册的 DMA 请求映射表,别凭记忆填。传输宽度这里有个关键决策:SPI 数据寄存器是 8 位还是 16 位访问,直接决定 DMA 的MemDataAlignment和PeriphDataAlignment。
// DMA 配置:内存 16 位像素 -> 外设按字节流写出 hdma_spi1_tx.Instance = DMA2_Stream3; hdma_spi1_tx.Init.Channel = DMA_CHANNEL_3; hdma_spi1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH; hdma_spi1_tx.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址固定 hdma_spi1_tx.Init.MemInc = DMA_MINC_ENABLE; // 内存地址递增 hdma_spi1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_spi1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; hdma_spi1_tx.Init.Mode = DMA_NORMAL; // 单次整块,不用循环 hdma_spi1_tx.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma_spi1_tx); __HAL_LINKDMA(&hspi1, hdmatx, hdma_spi1_tx);这段配置的意图是:内存里像素按uint16_t存(RGB565),DMA 按半字读内存、按字节写外设。这里有个容易忽略的点——当内存宽度大于外设宽度时,DMA 会把一个半字拆成两次字节传输,顺序由芯片决定,对 RGB565 这种“高字节在前”的格式,如果拆出来顺序反了,颜色就会红蓝互换。稳妥做法是内存和外设都设成BYTE,自己在发送前把像素缓冲按字节序排好;或者都设HALFWORD并确认 SPI 的DataSize也是 16 位。两种方案各有取舍,前者灵活、后者省事,我一般先用字节对齐跑通,再视性能决定是否改半字。
2.4 一次完整刷屏的调用序列
把上面的零件串起来,刷一块区域的流程是:设窗口 → 发 0x2C → 启动 DMA → 等完成 → 拉高片选。窗口设置本身是几条命令加参数,走阻塞式发送即可,数据量小。
void LCD_FlushArea(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint8_t *buf, uint32_t len) { LCD_SetWindow(x0, y0, x1, y1); // 发 0x2A/0x2B 设行列范围 LCD_WriteCmd(0x2C); // Memory Write LCD_DC_Set(); // 切到数据模式 LCD_CS_Clr(); // 片选拉低 HAL_SPI_Transmit_DMA(&hspi1, buf, len); // DMA 搬像素 // 传输完成回调里再拉高 CS,见 2.5 }LCD_SetWindow内部是 0x2A(列地址)和 0x2B(行地址)各带 4 个参数字节,注意 9341 的坐标是“起始高字节、起始低字节、结束高字节、结束低字节”的顺序,写反了窗口就错位。len是字节数,如果 buf 是uint16_t数组,要乘 2。DC 引脚在命令和数据之间切换,这个切换必须在片选有效期间完成,时序错了屏会收到错乱的命令。
2.5 完成回调与片选收尾
DMA 传输完成中断里做两件事:拉高片选、置标志位。不要在回调里做耗时操作,否则会拖慢下一帧的启动。
void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { LCD_CS_Set(); // 一帧数据结束,释放片选 lcd_dma_busy = 0; // 通知主循环可以发下一帧 } }片选拉高的时机很讲究:必须在最后一个字节真正移出移位寄存器之后。HAL 的 DMA 完成回调是在 DMA 传输计数归零时触发,此时 SPI 移位寄存器里可能还有最后一两个字节没发完。如果立刻拉高 CS,最后几个像素会丢,表现为屏幕右下角一条细线颜色不对。稳妥做法是在回调里先等SPI_FLAG_BSY清零再拉 CS,或者干脆用HAL_SPI_GetState确认空闲。这个细节在后面避坑章还会提。
3. 参数怎么定:SCK 频率、缓冲策略与帧率估算
3.1 SCK 频率的实际上限
9341 数据手册标称写周期最小 100ns 左右,对应理论 SCK 上限约 10MHz 量级,但实际模块(带电平转换、排线)往往能跑到 20~30MHz 不出错,取决于布线和模块质量。F767 的 SPI1 在 APB2=108MHz 下,分频 2 得 54MHz、分频 4 得 27MHz、分频 8 得 13.5MHz。实践路径是:先用 13.5MHz 跑通全流程,确认图像正确;再逐级往 27MHz、54MHz 试,每级都刷全屏彩条看有无花屏、错位、颜色偏移。
| 分频 | SCK 估算 | 全屏理论传输 | 适用场景 |
|---|---|---|---|
| 8 | 13.5MHz | ~91ms | 长排线、初次调试 |
| 4 | 27MHz | ~45ms | 板载排线、稳定首选 |
| 2 | 54MHz | ~23ms | 短排线、模块质量好 |
注意“理论传输”只是数据搬运时间,实际帧率还要加上设窗口命令、DMA 启动开销、以及你应用层的逻辑时间。想冲高帧率,除了压 SCK 分频,更有效的是减少全屏刷新次数——只刷变化区域。
3.2 单缓冲、双缓冲与“半屏交替”
单缓冲就是一块帧缓存,DMA 从它搬数据,搬的时候 CPU 不能改它,否则撕裂。双缓冲是两块,DMA 搬 A 时 CPU 画 B,完成回调里交换。F767 有 512KB RAM,320×240×2 = 150KB 一块,双缓冲 300KB 放得下,这是 F767 相对小容量型号的优势。
但双缓冲不是无脑上。如果你的应用是“刷静态图”或“低频 UI 更新”,单缓冲足够,省内存也省切换逻辑。真正需要双缓冲的是动画、视频、滚动列表这类连续刷新场景。还有一种折中叫“半屏交替”:把屏分上下两半,DMA 刷上半时 CPU 准备下半,用两个半屏缓冲轮转,内存只要一块全屏大小。这个技巧在内存紧张时很实用,代价是窗口设置次数翻倍。
3.3 帧率估算与瓶颈定位
按 27MHz、全屏 153600 字节算,纯传输 45ms,加设窗口约 1ms、DMA 启动开销忽略,单帧约 46ms,理论 21 帧。实测如果只有 10 帧,说明有别的地方在拖:可能是 DMA 完成回调里做了打印、可能是主循环里还有阻塞延时、可能是片选收尾等了太久。定位方法是拿 GPIO 翻转做打点——在启动 DMA 前拉高一个空闲引脚、完成回调里拉低,用逻辑分析仪或示波器看这个引脚的高电平宽度,就是单帧实际耗时。这个“黑匣子”手段比猜快得多。
3.4 像素格式与字节序
RGB565 一个像素两字节,高字节是 RRRRRGGG、低字节是 GGGBBBBB。SPI 发送时先发高字节还是低字节,取决于你的缓冲组织方式和 DMA 对齐设置。常见翻车是颜色整体偏蓝或偏红,本质是高低字节颠倒。验证方法很简单:刷一个纯红0xF800、纯绿0x07E0、纯蓝0x001F的色块,看屏上颜色对不对,不对就调字节序,别去改初始化命令表——初始化表通常没问题,问题在数据通路。
4. 避坑与排查:花屏、撕裂、丢尾字节的现场记录
4.1 现象:刷图花屏、局部错位,降频就好
原因几乎都是信号完整性或时序余量不足。杜邦线过长、SCK 和 MOSI 没有地线伴随、模块电平转换芯片跟不上,都会在高速下表现为偶发花屏。解决顺序:先把 SCK 降到 13.5MHz 确认逻辑正确,再换短排线、给 SCK/MOSI 旁边加地线、必要时串 22~33 欧电阻做阻抗匹配,然后逐级升频。别一上来就怀疑 DMA 配置,八成是线的问题。
4.2 现象:屏幕右下角一条细线颜色不对或缺失
这是 2.5 节说的片选提前释放。DMA 计数归零时移位寄存器还有残留字节,CS 一拉高就丢了。解决:完成回调里先轮询SPI_FLAG_BSY清零,或调用HAL_SPI_GetState等到HAL_SPI_STATE_READY再拉 CS。另一种情况是len算少了,比如uint16_t数组忘了乘 2,表现是整帧少最后若干像素,和片选问题的区别是“少一整段”还是“少一两个字节”,看现象能区分。
4.3 现象:画面撕裂,上半帧新图下半帧旧图
单缓冲下 CPU 在 DMA 搬运期间改了缓冲,或者双缓冲切换时机不对。解决:单缓冲场景下,DMA 忙时 CPU 只读不写缓冲,用lcd_dma_busy标志挡住写操作;双缓冲场景下,交换指针必须在完成回调里做,不能在主循环里提前切。还有一种隐蔽情况:DMA 配置成DMA_NORMAL但忘了在下次传输前重新使能,第二帧根本没发出去,屏上停在上一次内容,看起来像“卡住”,其实是没启动。
4.4 现象:颜色红蓝互换或整体偏色
字节序问题,见 3.4。也可能是初始化命令表里 MADCTL(0x36)的 BGR 位设反了。先刷三原色定位:如果纯红显示成蓝,是字节序或 BGR 位;如果纯红显示成暗红,是 Gamma 或电压参数。前者改数据通路,后者改初始化表,别混为一谈。
4.5 现象:DMA 传输完成回调不进,或进了一次不再进
检查三处:DMA 通道映射是否和 SPI 实例匹配(查参考手册映射表,别抄别的型号);__HAL_LINKDMA是否在HAL_SPI_Init之前调用;中断优先级里 DMA 流中断是否使能。还有一种情况是HAL_SPI_Transmit_DMA返回HAL_BUSY,说明上一次传输没结束,主循环没等lcd_dma_busy就发了下一帧,加个忙等待或状态机即可。
5. 进阶验证:用打点和分块刷新把帧率压到屏体极限
链路跑通后,真正决定体验的是“怎么少刷”。全屏刷新再快也有上限,UI 场景里往往只有局部在变。把屏幕按 16×16 或 32×32 分块,维护一个脏块标记,只对脏块设窗口、发 DMA,帧率能翻几倍。代价是窗口设置命令变多,块太小反而被命令开销拖累,实测 32×32 是个不错的平衡点。
验证帧率别靠肉眼,用 GPIO 打点最实在。下面这段在每帧开始和结束各翻一次引脚,逻辑分析仪一抓就知道真实周期:
// 帧率打点:PB0 作为观测脚 #define FRAME_MARK_H() (GPIOB->BSRR = GPIO_PIN_0) #define FRAME_MARK_L() (GPIOB->BSRR = (uint32_t)GPIO_PIN_0 << 16) void LCD_FlushFull(uint8_t *buf) { while (lcd_dma_busy) { } // 等上一帧结束 FRAME_MARK_H(); LCD_FlushArea(0, 0, 319, 239, buf, 320*240*2); // 完成回调里拉低,见下 } void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { while (__HAL_SPI_GET_FLAG(hspi, SPI_FLAG_BSY)) { } // 等移位寄存器空 LCD_CS_Set(); FRAME_MARK_L(); lcd_dma_busy = 0; } }BSRR的高低 16 位分别控制置位和复位,比HAL_GPIO_WritePin快且不会被中断打断成半截。打点脚的高电平宽度就是“设窗口 + DMA 搬运 + 收尾”的总时间,拿它和 3.1 节的理论值对比,差得多就说明有额外开销——常见的是设窗口命令走了阻塞发送、或者忙等待里被别的中断插队。
还有一个容易被忽略的验证点:连续刷 100 帧同一张图,看有没有偶发错位。单帧正确不代表连续正确,DMA 和 SPI 的状态机在边界条件下才暴露问题。我一般会跑一个“彩条循环 500 次”的压力测试,任何一次错位都记下来复现,往往能揪出片选时序或缓冲竞争的隐患。
血泪经验是:这套链路里最贵的不是代码,是“以为跑通了”的错觉。从那以后我每次改 SPI 分频或 DMA 配置,都强制先跑三原色 + 彩条 + 连续 500 帧压力测试这三关,过了才敢往应用层接。希望帮到你。
本文还有配套的精品资源,点击获取