简介:本资源面向嵌入式开发工程师与STM32学习者,提供一套基于STM32F767的SPI DMA高速驱动9341 TFT液晶屏的完整工程方案,重点解决刷屏速率低、CPU占用高的问题。工程通过SPI外设配合DMA控制器实现像素数据自动搬运,SPI时钟配置至54MHz,实测刷屏频率可达20Hz,并包含9341初始化序列、颜色模式与扫描方向设置等关键环节。压缩包共1099个文件,以591个c源文件、275个h头文件、51个s汇编文件为主,另含icf链接脚本、uvprojx与ioc工程配置及hex固件,整体约54.23MB,目录涵盖Drivers、MDK-ARM与Core等模块,结构清晰便于二次开发。目前已有2121人学习下载,适合希望掌握SPI DMA刷屏、优化显示性能的中高级开发者参考借鉴。
1. STM32F767 SPI DMA 9341高速刷新:从刷屏卡顿到流畅动画的分水岭
如果你用 STM32F767 驱动过 ILI9341 这类 240×320 的 TFT 屏,大概率经历过这个场景:主循环里调HAL_SPI_Transmit刷一屏纯色,示波器上 SCLK 跑得挺欢,但实际帧率只有个位数,动画像幻灯片。问题不在 SPI 时钟,而在 CPU 被逐字节搬运数据这件事绑死了。STM32F767 的 SPI1 挂 APB2,理论可以跑到 54MHz 以上,配合 DMA 双缓冲,把整屏数据交给硬件搬运,CPU 只负责准备下一帧——这才是「高速刷新」的真正含义。这篇笔记面向已经能点亮 9341、但被刷新率卡住的嵌入式开发者,把 SPI+DMA 的配置、9341 的初始化时序、双缓冲切换和踩坑点一次讲透,目标是让你把 240×320 全屏刷新从十几毫秒压到 5ms 以内,跑出可用的动画效果。
2. 为什么 9341 刷不快:SPI 阻塞发送与 DMA 搬运的本质差别
2.1 阻塞发送到底慢在哪
先算一笔账。240×320 分辨率,RGB565 每像素 2 字节,一屏就是 153600 字节。SPI 时钟设到 27MHz,理论上每秒能传 3.375MB,一屏耗时约 45.5ms。但这是理想值,实际用HAL_SPI_Transmit阻塞发送时,CPU 要等 TXE 标志、写 DR、等 BSY 清零,每个字节都有函数调用开销和循环判断。实测下来,27MHz 下阻塞发送一屏往往要 60ms 以上,帧率不到 16fps,而且这期间 CPU 完全被占用,没法处理触摸、传感器或动画逻辑。
更隐蔽的问题是 HAL 库的HAL_SPI_Transmit内部有超时判断和状态机切换,每次调用都有几十个时钟周期的额外开销。如果你还用了HAL_SPI_TransmitReceive或者逐字节while轮询,开销更大。所以「SPI 时钟高」和「刷新快」之间隔着一整个 CPU 搬运效率的鸿沟。
2.2 DMA 把 CPU 从搬运工变成调度员
DMA 的思路很直接:把「从内存取数据写到 SPI 数据寄存器」这件事交给 DMA 控制器,CPU 只配置一次源地址、目标地址和传输长度,然后就可以去干别的。STM32F767 的 DMA2 支持内存到外设的传输,SPI1_TX 有对应的 DMA 请求通道。配置好之后,SPI 每次 TXE 置位,DMA 自动搬一个字节过去,CPU 完全不参与。
关键收益有两个:第一,传输期间 CPU 空闲,可以准备下一帧数据或处理其他任务;第二,DMA 搬运没有函数调用开销,实际吞吐更接近 SPI 时钟的理论值。27MHz 下单屏 45ms 左右,如果 SPI 能跑到 54MHz,单屏可以压到 22ms 上下。再配合双缓冲和局部刷新,动画帧率能上到 30fps 以上。
2.3 9341 的写时序对 DMA 的约束
ILI9341 的 8080 并口时序和 SPI 时序不同,SPI 模式下它靠 CS 片选、D/C 数据命令切换、SCLK 和 MOSI 四根线工作。写一帧图像前要先发命令0x2C(Memory Write),然后连续发像素数据。D/C 线在命令阶段为低,数据阶段为高。用 DMA 时,D/C 线的切换必须由软件在启动 DMA 前完成,DMA 传输过程中 D/C 保持不变。
这意味着你不能把命令和像素数据混在同一个 DMA 传输里,除非用两个 DMA 流或者手动分两次。常见做法是:先拉低 D/C 发命令0x2C,再拉高 D/C 启动 DMA 发像素数据。CS 线可以在整个过程中保持低电平,也可以每帧拉低一次。注意 9341 的 CS 建立时间和保持时间,太快会导致命令丢失。
3. 用 SPI1+DMA2 在 F767 上跑通 9341 的最小工程
3.1 CubeMX 配置与时钟树要点
在 CubeMX 里选好 STM32F767 芯片后,SPI1 配置为 Transmit Only Master,数据宽度 8 位,CPOL=Low,CPHA=1Edge,预分频器先设 4 分频。F767 的 SPI1 挂 APB2,如果 APB2 时钟 108MHz,4 分频后 SCLK 为 27MHz。想跑更快可以设 2 分频到 54MHz,但要注意 9341 模块的走线和排线质量,太长或没屏蔽的杜邦线在 54MHz 下容易出错。
DMA 配置里给 SPI1_TX 添加 DMA2 Stream3 或 Stream5,方向 Memory to Peripheral,数据宽度 Byte,模式 Normal,优先级 High。中断可以先不开,用轮询 DMA 传输完成标志。GPIO 方面,SCK、MOSI 配复用推挽,CS、D/C、RST 配普通推挽输出,初始电平拉高。
// SPI1 初始化关键参数(CubeMX 生成后手动确认) hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; hspi1.Init.Direction = SPI_DIRECTION_1LINE; // 只用 MOSI hspi1.Init.DataSize = SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; hspi1.Init.NSS = SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_4; // 27MHz hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; hspi1.Init.TIMode = SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE;这段配置里SPI_DIRECTION_1LINE表示只发不收,省一根 MISO 线。NSS用软件控制,因为 CS 由 GPIO 手动拉。BaudRatePrescaler是唯一影响刷新速度的参数,改小就快,但稳定性下降。建议先用 4 分频调通,再逐步降到 2 分频。
3.2 9341 初始化序列与命令发送封装
9341 上电后需要一段初始化序列,包括电源控制、伽马校正、内存访问控制等。这些命令通过 SPI 发送,D/C 拉低表示命令,拉高表示数据。封装两个基础函数:
// 发送一个命令字节 void LCD_WriteCmd(uint8_t cmd) { LCD_DC_LOW(); // D/C 拉低,命令模式 LCD_CS_LOW(); // 片选使能 HAL_SPI_Transmit(&hspi1, &cmd, 1, 10); LCD_CS_HIGH(); } // 发送一个数据字节 void LCD_WriteData(uint8_t data) { LCD_DC_HIGH(); // D/C 拉高,数据模式 LCD_CS_LOW(); HAL_SPI_Transmit(&hspi1, &data, 1, 10); LCD_CS_HIGH(); }初始化序列里比较关键的是0x36(Memory Access Control)和0x3A(Pixel Format)。0x36的参数决定屏幕扫描方向,RGB 顺序和行列交换都在这里设。0x3A设成0x55表示 RGB565。这两个参数设错会导致颜色反了或者图像错位,是新手最容易翻车的地方。
3.3 启动 DMA 刷一屏纯色的完整代码
刷纯色是最简单的验证方式,不需要准备图像数据,直接用一个填充好的缓冲区。下面代码把 153600 字节的缓冲区通过 DMA 发到屏幕:
#define LCD_WIDTH 240 #define LCD_HEIGHT 320 #define LCD_PIXELS (LCD_WIDTH * LCD_HEIGHT) // 双缓冲,每个缓冲区一屏大小 static uint8_t lcd_buf[2][LCD_PIXELS * 2] __attribute__((aligned(4))); // 用指定颜色填充一屏并启动 DMA 传输 void LCD_FillScreen_DMA(uint8_t buf_idx, uint16_t color) { uint32_t i; uint8_t *p = lcd_buf[buf_idx]; // 填充 RGB565 颜色,高字节在前 for (i = 0; i < LCD_PIXELS; i++) { p[i * 2] = color >> 8; p[i * 2 + 1] = color & 0xFF; } // 设置列地址和页地址,限定刷新区域 LCD_SetWindow(0, 0, LCD_WIDTH - 1, LCD_HEIGHT - 1); // 发写内存命令 0x2C LCD_WriteCmd(0x2C); // D/C 拉高,CS 拉低,启动 DMA LCD_DC_HIGH(); LCD_CS_LOW(); HAL_SPI_Transmit_DMA(&hspi1, p, LCD_PIXELS * 2); }LCD_SetWindow是设置 9341 的列地址和页地址,对应命令0x2A和0x2B。刷全屏时设成 0 到 239 和 0 到 319。HAL_SPI_Transmit_DMA启动后立即返回,CPU 可以继续执行。传输完成后 HAL 会调用HAL_SPI_TxCpltCallback,在这个回调里拉高 CS,并可以启动下一帧。
注意缓冲区要 4 字节对齐,DMA 访问非对齐内存会降低效率甚至出错。__attribute__((aligned(4)))是 GCC 的写法,IAR 下用#pragma data_alignment。
4. 双缓冲与局部刷新:把帧率再往上推一档
4.1 双缓冲切换的时机与标志位管理
单缓冲的问题是:DMA 正在发送时,CPU 不能改缓冲区,否则会撕裂画面。双缓冲让 CPU 在 DMA 发缓冲区 A 时填充缓冲区 B,DMA 完成后切换。关键是切换时机——必须在 DMA 传输完成中断里切换,不能在主循环里轮询,否则可能提前覆盖正在发送的缓冲区。
static volatile uint8_t dma_busy = 0; static volatile uint8_t front_buf = 0; // DMA 传输完成回调 void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { LCD_CS_HIGH(); // 拉高片选,结束本次传输 dma_busy = 0; // 标记空闲 front_buf ^= 1; // 切换前台缓冲 } } // 主循环里检查并启动下一帧 void LCD_Update(void) { if (!dma_busy) { dma_busy = 1; LCD_FillScreen_DMA(front_buf, current_color); } }dma_busy用volatile修饰,因为它在中断和主循环里都被访问。front_buf异或 1 实现 0/1 切换。这个结构下,CPU 填充缓冲区和 DMA 发送是并行的,帧率取决于两者中较慢的那个。
4.2 局部刷新怎么算窗口和偏移
全屏刷新 153600 字节,即使 54MHz 也要 22ms。如果只改屏幕上一小块区域,比如一个 64×64 的图标,数据量只有 8192 字节,耗时不到 1.5ms。局部刷新的关键是正确设置 9341 的窗口:
// 设置刷新窗口,x0,y0 左上角,x1,y1 右下角 void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_WriteCmd(0x2A); // 列地址设置 LCD_WriteData(x0 >> 8); LCD_WriteData(x0 & 0xFF); LCD_WriteData(x1 >> 8); LCD_WriteData(x1 & 0xFF); LCD_WriteCmd(0x2B); // 页地址设置 LCD_WriteData(y0 >> 8); LCD_WriteData(y0 & 0xFF); LCD_WriteData(y1 >> 8); LCD_WriteData(y1 & 0xFF); LCD_WriteCmd(0x2C); // 写内存 }窗口设好后,后续像素数据会按从左到右、从上到下的顺序填入这个矩形区域。局部刷新时缓冲区只需要存这块区域的数据,大小是(x1-x0+1)*(y1-y0+1)*2。注意 9341 的窗口坐标是包含边界的,x1 和 y1 是闭区间。
4.3 用 DMA 传输局部区域的参数计算
局部刷新的 DMA 传输长度不是固定的,要根据窗口大小算。下面是一个刷新矩形的函数:
// 刷新一个矩形区域,data 里是按行排列的 RGB565 数据 void LCD_BlitRect_DMA(uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint8_t *data) { LCD_SetWindow(x, y, x + w - 1, y + h - 1); LCD_DC_HIGH(); LCD_CS_LOW(); HAL_SPI_Transmit_DMA(&hspi1, data, w * h * 2); }w * h * 2是字节数,DMA 按字节传输。如果 w 或 h 是奇数,总字节数可能是奇数,但 DMA 字节传输不要求偶数对齐。注意data的排列顺序必须和窗口扫描顺序一致,否则图像会错位。9341 默认是左上到右下逐行扫描,如果你的数据是列优先的,需要在0x36命令里改扫描方向。
5. 高速刷新避坑:5 个让画面撕裂、花屏、丢命令的现场
5.1 花屏且颜色错乱:D/C 线在 DMA 传输中被意外拉低
现象是屏幕出现随机彩色条纹,或者图像整体偏色。原因通常是 D/C 线在 DMA 传输过程中被其他代码改动,比如中断里操作了同一个 GPIO。DMA 传输期间 D/C 必须保持高电平,任何翻转都会让 9341 把像素数据当命令解析。解决方法是把 D/C 的 GPIO 操作集中管理,DMA 传输期间禁止其他任务碰这个引脚,或者用硬件 SPI 的 NSS 加软件 D/C 的组合,确保时序不被中断打断。
5.2 画面撕裂:双缓冲切换发生在 DMA 完成之前
现象是屏幕上半部分是旧帧、下半部分是新帧。原因是主循环里提前切换了缓冲区,DMA 还在发旧数据时 CPU 已经改了同一块内存。解决方法是严格在HAL_SPI_TxCpltCallback里切换front_buf,并且用dma_busy标志阻止重入。如果用了 RTOS,注意回调在中断上下文,不能调用阻塞 API。
5.3 传输卡死:DMA 流被重复启动导致状态机锁死
现象是第一次刷新正常,第二次调用HAL_SPI_Transmit_DMA后没有任何反应。原因是上一次 DMA 还没完成就再次启动,HAL 库的状态机卡在 BUSY。解决方法是每次启动前检查hspi1.State是否为HAL_SPI_STATE_READY,或者用dma_busy标志做软件互斥。另外,DMA 传输完成后要调用HAL_SPI_TxCpltCallback里的清理逻辑,确保 SPI 状态回到 READY。
5.4 高时钟下偶发丢命令:CS 建立时间不足
现象是 27MHz 正常,切到 54MHz 后初始化偶尔失败,屏幕不亮或颜色异常。原因是 CS 拉低到第一个 SCLK 边沿之间的建立时间不够,9341 来不及识别命令。解决方法是 CS 拉低后加几个 NOP 延时,或者降低 SPI 时钟到 36MHz 左右。另一个办法是 CS 在整帧传输期间保持低电平,只在帧间拉高,减少切换次数。
5.5 缓冲区数据错位:DMA 源地址非对齐或长度不是 2 的倍数
现象是图像整体偏移几个像素,或者最后几个像素颜色不对。原因是 DMA 配置为字节传输时,如果源地址不是 4 字节对齐,F767 的 DMA 可能按字访问导致数据错位。解决方法是缓冲区用aligned(4)声明,传输长度确保是偶数。RGB565 每像素 2 字节,只要像素数正确,长度自然是偶数。
6. 把刷新率再压榨 30%:DMA 传输完成中断里的一个隐藏技巧
前面所有配置跑通后,全屏刷新大概在 22ms 到 45ms 之间,取决于 SPI 时钟。但还有一个容易被忽略的优化点:DMA 传输完成中断的响应延迟。HAL 库的HAL_SPI_TxCpltCallback是在 DMA 传输完成中断里调用的,但从 DMA 完成到 SPI 状态清理、再到回调执行,中间有几十个时钟周期的开销。如果每帧都等这个回调再启动下一帧,帧间会有固定间隙。
我一般会在 DMA 传输快结束时提前准备下一帧。具体做法是用 DMA 的半传输中断HAL_SPI_TxHalfCpltCallback,在传输到一半时就开始填充下一块缓冲区。这样当全传输完成中断到来时,下一帧数据已经就绪,可以立即启动下一次 DMA,帧间间隙几乎为零。
// 半传输回调:提前准备后半段数据 void HAL_SPI_TxHalfCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { // 此时前半段已发完,可以安全修改后半段缓冲区 // 如果做动画,可以在这里计算下一帧的后半部分 } } // 全传输回调:立即启动下一帧 void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { LCD_CS_HIGH(); dma_busy = 0; front_buf ^= 1; // 如果下一帧数据已就绪,直接在这里启动 if (next_frame_ready) { dma_busy = 1; LCD_FillScreen_DMA(front_buf, next_color); } } }这个技巧的代价是代码复杂度上升,半传输中断和全传输中断的缓冲区边界要算清楚,否则会写出越界访问。我自己的习惯是先用全传输中断把功能跑稳,确认帧率瓶颈确实在帧间间隙上,再引入半传输优化。另外,如果 SPI 时钟已经跑到 54MHz,全屏 22ms 里 DMA 传输占大头,帧间间隙可能只有 1ms 到 2ms,优化收益有限;但如果做局部刷新,单次传输只有几百微秒,帧间间隙占比就很高,这时候半传输优化效果明显。
还有一个验证方法:用 GPIO 翻转做标记,在 DMA 启动时拉高一个测试引脚,在传输完成回调里拉低,用示波器看高电平持续时间。如果高电平时间和理论传输时间差很多,说明中断响应或 HAL 状态清理有开销。我曾在某块 F767 开发板上测到 27MHz 全屏传输理论 45ms,实际高电平 52ms,多出来的 7ms 就是 HAL 库的状态机开销和中断延迟。把 SPI 时钟提到 54MHz 后,理论 22ms,实际 26ms,开销占比反而更小,所以高时钟下 HAL 开销相对可接受。
最后说一个血泪教训:不要在没有示波器的情况下盲目调 SPI 时钟。我曾经把预分频器从 4 改成 2,屏幕直接花屏,以为是 DMA 配置问题,查了半天才发现是杜邦线太长导致信号完整性下降。换短排线后 54MHz 稳定运行。所以调高速刷新时,先确认硬件走线,再动软件参数。希望帮到你。
本文还有配套的精品资源,点击获取