简介:面向STM32嵌入式开发者,这份可运行的源码资源聚焦TFT屏幕的快速刷新问题,通过硬件SPI与DMA协同工作,帮助读者摆脱模拟SPI的低效瓶颈,实现高性能显示更新。资源包共3个文件,包含HTML演示页面、inscode工程配置以及gitignore忽略规则,压缩包仅6KB,轻量精简,便于快速下载与查阅。目前已有114人学习下载。内容以可直接参考的代码示例为核心,覆盖SPI与DMA的初始化配置、屏幕填充和清屏函数的DMA适配改造,并进一步扩展至字符与图片显示场景;同时提供模拟SPI与硬件SPI的速度对比数据,让读者清晰理解性能差异,掌握将DMA批量传输技巧迁移到其他显示函数的方法。整体结构紧凑,注释清晰,代码简洁易读,可直接套用,对需要优化显示响应速度的STM32项目具有直接借鉴价值。 做了几年嵌入式,最让我记忆犹新的一次,是用STM32驱动一块SPI接口的屏幕刷全屏照片。一开始用GPIO模拟SPI,屏幕从上往下一点点“擦”出来,肉眼能看到刷新过程,跟车站那种旧式翻牌屏差不多。后来把传输改成硬件SPI+DMA,同一张图瞬间铺满,速度快到让人怀疑是不是换了块屏。这篇文章不聊怎么点亮屏幕,专门讲怎么让STM32快速刷屏,把硬件SPI+DMA的完整链路、初始化代码、实测数据和踩过的坑都摆出来。适合已经会用模拟SPI点屏、想进一步压榨性能的朋友,也适合正要选型SPI屏方案的人参考。
1. 刷屏慢的根源是“像素搬运”,不是屏幕本身
很多人第一次遇到刷屏慢,第一反应是屏幕刷新率不够。实际大多数SPI接口的彩色屏,驱动IC内部扫描速度都在几十帧以上,真正的瓶颈根本不在屏,而在MCU这边怎么把数据送过去。
1.1 一帧RGB565画面到底有多少数据
以最常见的240×320 TFT屏为例,颜色格式用RGB565,也就是每个像素点占2个字节:
- 每行数据量:240 × 2 = 480字节
- 一帧数据量:320 × 480 = 153600字节,约150KB
- 如果刷60帧,每秒要传 153600 × 60 = 9216000字节,约9MB/s
这个数据量放在SPI场景里并不小。SPI本质是串行总线,每传一个字节要移出8个bit,也就是至少8个时钟周期。假设SPI时钟是18MHz,理论极限每秒能传18Mbit,约2.25MB/s。这意味着全速跑SPI,一帧就需要68ms左右。屏幕本身不是瓶颈,MCU和总线的传输能力才是。
1.2 模拟SPI慢在哪儿:CPU成了搬运工
模拟SPI的代码思路很简单:拉高SCK、拉低SCK,在上升沿/下降沿把MOSI置成对应电平,一位一位往外送。写起来快,跑起来慢,因为每一bit都要CPU亲自执行几条指令:
void SPI_WriteByte_Sim(uint8_t dat) { for (uint8_t i = 0; i < 8; i++) { if (dat & 0x80) MOSI_HIGH(); else MOSI_LOW(); SCK_HIGH(); SCK_LOW(); dat <<= 1; } }这段代码看起来简单,实际执行一条指令要几个系统周期,一次IO翻转在72MHz下也要几个周期,再加上函数调用和循环开销,一字节往往要花1~2μs。算下来150KB数据要300ms左右。而且整个过程CPU被占满,任何中断、任务都没法及时响应。模拟SPI慢不是慢在某个环节,而是每一步都在浪费周期。
2. 硬件SPI+DMA的配合逻辑:先想明白再写代码
理解硬件SPI+DMA怎么工作,比直接抄代码更重要。搞懂了之后,遇到问题你能自己定位,而不是瞎试。
2.1 一分二:硬件SPI负责“位流”,DMA负责“搬砖”
硬件SPI外设做的是把并行的8bit数据转成串行时钟输出,不需要CPU逐位操作。只要把数据写进SPI的数据寄存器DR,SPI硬件就会自动按波特率产生时钟,把8个bit通过MOSI移出去。这个过程对CPU来说是异步的,写一个字节后,SPI开始慢慢移,CPU完全可以去干别的。
那DMA干什么?DMA负责把内存里的像素数据自动搬运到SPI的DR寄存器。搬运过程中CPU完全不用参与。用大白话说:SPI是流水线上的“传送带”,DMA是专门搬货的“机器人”,CPU是这个车间的“管理员”。管理员只需要下指令让机器人把哪一堆货搬到传送带上,传送带会自动往前走,搬完之后机器人才会通知管理员。
具体到硬件信号流程:
- SPI发送寄存器DR为空,硬件置TXE标志位;
- TXE标志触发DMA请求,DMA把内存里的下一个字节写入DR;
- SPI继续把DR里的8bit移出,等DR空了再次触发DMA请求;
- 如此循环,直到DMA计数器减到0,产生DMA传输完成中断。
2.2 DMA请求通道与句柄绑定:很多人第一次都卡在这
STM32不是每个DMA都能服务所有外设。以F103为例,SPI1的发送请求固定在DMA1通道3,SPI1接收固定在DMA1通道2;而SPI2的发送固定在DMA1通道5。如果DMA通道选错,初始化能过,但传输始终不触发。
用HAL库的时候,还有一个特别容易错的地方:SPI句柄里的hdmatx指针必须绑定到DMA句柄。正确姿势是:
__HAL_LINKDMA(&hspi1, hdmatx, hdma_spi1_tx);这一步相当于告诉SPI:“你要用的发送DMA是这个。”漏掉这行,调用HAL_SPI_Transmit_DMA大概率直接返回HAL_ERROR,因为SPI发现没有可用的DMA句柄。CubeMX生成的代码里,这句通常放在DMA配置函数末尾,但如果你是自己手写初始化,特别容易漏。
3. 搭建一个可运行的刷屏工程:初始化代码逐段拆解
我这里用STM32F103C8T6,配ST7789屏幕,240×320分辨率,RGB565颜色。SPI1挂载在APB2总线上,72MHz主频下可以跑到36MHz,稳妥起见先用4分频,也就是18MHz。
3.1 GPIO和SPI初始化:几个关键位的选择理由
先初始化GPIO和SPI1:
static void MX_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); // CS用PA4,DC用PB0,RST用PB1,这里都是软件控制 GPIO_InitStruct.Pin = GPIO_PIN_4; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); GPIO_InitStruct.Pin = GPIO_PIN_0 | GPIO_PIN_1; HAL_GPIO_Init(GPIOB, &GPIO_InitStruct); // PA5 = SCK, PA7 = MOSI,复用推挽输出 GPIO_InitStruct.Pin = GPIO_PIN_5 | GPIO_PIN_7; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); LCD_CS_HIGH(); LCD_DC_HIGH(); LCD_RST_HIGH(); } static void MX_SPI1_Init(void) { hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; hspi1.Init.Direction = SPI_DIRECTION_2LINES; 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; hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; hspi1.Init.TIMode = SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE; hspi1.Init.CRCPolynomial = 10; HAL_SPI_Init(&hspi1); }几个选择理由说一下。第一,GPIO速度一定要配到HIGH,不然IO自身翻转速度不够,18MHz波形会变形,屏幕容易出干扰。第二,SPI模式用Mode0,也就是CPOL低、CPHA第一边沿采样,这是绝大多数屏幕IC默认的协议模式。第三,NSS选软件模式,片选CS用普通GPIO控制,刷屏时灵活度更高,原因后面专门讲。
3.2 DMA传输编排:命令用阻塞发,像素数据用DMA发
DMA初始化的核心是要把DMA通道绑到SPI1上:
static void MX_DMA_Init(void) { __HAL_RCC_DMA1_CLK_ENABLE(); hdma_spi1_tx.Instance = DMA1_Channel3; 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_BYTE; 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); }然后还要把DMA中断服务函数接上,否则传输完成的回调永远不执行:
void DMA1_Channel3_IRQHandler(void) { HAL_DMA_IRQHandler(&hdma_spi1_tx); }刷屏时数据分成两类:初始化命令和像素数据。命令只有几个字节,走阻塞发送即可,DMA的优势提现不出来,反而要处理完成事件,增加固定开销。像素数据量大,走DMA才有意义。所以刷一帧的流程是:先用阻塞方式发命令设置窗口,然后用DMA把整块像素数据砸过去。
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); }这里有个细节:设置窗口后,屏幕内部写入地址会自动递增,所以你可以只设置一次全屏窗口,然后分块DMA发送。对大屏来说,这是个关键设计。
3.3 连续刷屏的小框架:忙标志加回调推进
DMA传输是异步的,启动函数只负责把任务丢给DMA,之后立即返回。所以你要有一个标志位记录传输是否完成。另外,F103C8T6内部RAM只有20KB,放不下150KB的全屏缓存,所以必须分块。这里每次传输32行,缓冲大小是 240×32×2=15360字节,能塞进F103C8T6。
#define LCD_W 240 #define LCD_H 320 #define STRIP_H 32 uint8_t strip_buf[LCD_W * STRIP_H * 2]; volatile uint8_t dma_transfer_done = 1; void LCD_StartStripDMA(void) { dma_transfer_done = 0; LCD_CS_LOW(); HAL_SPI_Transmit_DMA(&hspi1, strip_buf, sizeof(strip_buf)); } void LCD_ShowFrame_DMA(void) { uint16_t strips = LCD_H / STRIP_H; LCD_SetWindow(0, 0, LCD_W - 1, LCD_H - 1); LCD_DC_HIGH(); // 像素数据,DC拉高 for (uint16_t s = 0; s < strips; s++) { // 往strip_buf里填充第s块像素,数据源可以是取模数组、SD卡、Flash GenerateStripPixels(s, STRIP_H, strip_buf); // 等上一块传输完 while (!dma_transfer_done); LCD_StartStripDMA(); } } void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { LCD_CS_HIGH(); dma_transfer_done = 1; } }主循环里调用LCD_ShowFrame_DMA()即可。注意DMA完成回调里只做两件事:拉高CS、置标志位。不要在中断回调里做耗时运算、调用HAL_Delay或者打印日志,否则DMA中断处理时间变长,数据节奏会乱,甚至丢帧。
4. 实测对比:三种传输方案的性能差距
我在F103C8T6 @72MHz + ST7789 + 18MHz SPI时钟的平台上测过三种方案,数据大致如下:
| 传输方案 | 一帧耗时 | 帧率 | CPU占用 |
|---|---|---|---|
| GPIO模拟SPI | 约280ms | 约3.5fps | 100% |
| 硬件SPI阻塞发送 | 约70ms | 约14fps | 100% |
| 硬件SPI+DMA | 约70ms | 约14fps | 传输期间CPU空闲 |
模拟SPI到硬件SPI是质变,硬件SPI阻塞到DMA,单看帧率似乎没变,但CPU占用从100%降到接近0。这意味着你可以在等待DMA传输的同时跑UI逻辑、传感器采集或者通信任务。对嵌入式产品来说,这个价值往往比帧率数字更关键。
理论上18MHz时钟算下来一帧是68ms,实测70ms多出的几毫秒是命令发送和块间切换开销。如果SPI1分频改成2分频,时钟到36MHz,一帧理论耗时34ms,但杜邦线超过10cm就很难保证波形质量,需要降低分频或缩短线缆。我实际跑下来,超过24MHz时花屏概率明显增加,屏幕本身未必不支持,是接线和信号完整性问题。
5. 刷屏路上我踩过的坑
刷屏代码写通不难,但稳定好用是另一回事。下面几个坑我都是实打实踩过的,按排查顺序写出来。
5.1 画面错位和花屏:先查坐标窗口和方向,别急着怀疑SPI
固定点出错、边缘错位、隔行跳变,这些问题八成不是SPI问题,而是窗口地址或扫描方向设置错误。ST7789有两个地址寄存器:列地址CASET(0x2A)和行地址RASET(0x2B),以及一个方向控制寄存器MADCTL(0x36)。
最容易犯的错误是:列地址和行地址的顺序反了,或者高字节低字节写反。比如240×320的屏,列地址范围0~239,行地址范围0~319。如果你把列高字节写1,列范围变成256起步,显示自然偏移。遇到错位,先把CASET/RASET打印出来盯着看,再用0x36把RGB 的BGR顺序、扫描方向调对。
5.2 DMA传输函数返回后立刻拉高CS,传输根本没完成
这个坑很隐蔽。我第一次写DMA刷屏时,在调用HAL_SPI_Transmit_DMA后面紧跟了一句LCD_CS_HIGH(),结果屏幕显示乱码。原因很简单:HAL_SPI_Transmit_DMA只是“启动”DMA传输,函数返回时数据可能才刚发了一半。这时拉高CS,屏幕立即停止接收,后半块数据全部丢失。
正确做法是CS在DMA传输前拉低,传输完成后在HAL_SPI_TxCpltCallback回调里拉高。一句话总结:控制片选时序必须和DMA传输严格同步,不能按函数调用顺序来。
5.3 “三线SPI”和四线SPI的接线差异
大家常说的三线SPI有两种含义。一种是不接MISO的简化接法,SPI照样工作,只是收不到屏幕的返回值。另一种是真正的3-wire 9bit模式,屏幕没有DC引脚,通过每帧数据的第9位来区分命令和数据。
如果是第二种,硬件SPI+DMA会很痛苦,因为SPI外设支持8bit和16bit数据传输,但不原生支持9bit事务。硬要用9bit模式,数据对齐、DMA搬运都会很别扭。我的建议是:如果板子上有富余GPIO,直接给屏幕接DC引脚,用经典的四线SPI;如果没有,干脆模拟SPI推9bit,别硬上DMA。刷屏追求的是稳定和效率,不是给自己找麻烦。
5.4 软件片选为什么比硬件片选更适合刷屏
HAL库里SPI的NSS可以配成硬件输出,硬件会自动拉低CS,看起来省事,实际刷屏场景下并不好用。硬件片选在每次DMA传输完成后会立刻拉高CS,但屏幕很多时候需要在一个CS低电平周期内连续接收命令+参数+像素数据。如果你用硬件片选,每次HAL_SPI_Transmit_DMA结束CS就跳高,同一批次的多段传输反而需要额外逻辑维持CS,非常别扭。
软件片选用GPIO控制,CS什么时候拉低、什么时候拉高完全由你决定,配合DMA完成回调,时序控制清清楚楚。刷屏工程我全部用软件片选。
6. 基于这套方案再往前走
这套硬件SPI+DMA的框架稳定之后,可以做几件很有价值的事,其中最推荐的是双缓冲。
6.1 双缓冲让刷屏不撕裂
目前代码里用while (!dma_transfer_done)等待上一块发完,浪费了DMA异步的优势。用双缓冲可以解决:准备两个strip缓冲区,DMA正在发缓冲A时,主循环往缓冲B填充下一块数据;DMA完成后切换到缓冲B,主循环填缓冲A。
uint8_t strip_buf[2][LCD_W * STRIP_H * 2]; volatile uint8_t dma_index = 0; void DMA_TC_Callback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { LCD_CS_HIGH(); dma_busy = 0; dma_index ^= 1; // 切换缓冲 } } void LCD_ShowFrame_DoubleBuffer(void) { uint16_t strips = LCD_H / STRIP_H; LCD_SetWindow(0, 0, LCD_W - 1, LCD_H - 1); LCD_DC_HIGH(); for (uint16_t s = 0; s < strips; s++) { GenerateStripPixels <p> <a href="https://download.csdn.net/download/n7o8p/92368243" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>