news 2026/9/4 6:40:24

STM32硬件SPI+DMA加速TFT刷屏:从模拟SPI到满速传输

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32硬件SPI+DMA加速TFT刷屏:从模拟SPI到满速传输

简介:面向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是这个车间的“管理员”。管理员只需要下指令让机器人把哪一堆货搬到传送带上,传送带会自动往前走,搬完之后机器人才会通知管理员。

具体到硬件信号流程:

  1. SPI发送寄存器DR为空,硬件置TXE标志位;
  2. TXE标志触发DMA请求,DMA把内存里的下一个字节写入DR;
  3. SPI继续把DR里的8bit移出,等DR空了再次触发DMA请求;
  4. 如此循环,直到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.5fps100%
硬件SPI阻塞发送约70ms约14fps100%
硬件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>
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/4 8:16:54

分支循环语句学后总结

C语言属于结构化的程序设计语言。其中的结构指顺序结构选择结构循环结构。我们可以使用if switch实现分支结构使用for while do while实现循环结构。 分支结构用便于理解的话来讲就是判断表达式是否成立&#xff0c;根据是否成立再去执行不同的语句。表达式成立则为真&#xff…

作者头像 李华
网站建设 2026/9/3 19:37:54

Cesium POI点聚合实战:从EntityCluster到大数据性能优化

简介&#xff1a;面向Cesium开发者的POI点聚合源码包&#xff0c;解决原生Cesium缺少primitive聚合功能、常需修改EntityCluster源码的问题。方案利用DistanceDisplayCondition属性&#xff0c;按typename字段的层级关系动态计算显隐视距&#xff0c;分为远、中、近三档&#x…

作者头像 李华
网站建设 2026/9/4 6:53:18

STM32HAL库(Free RTOS) 01 实现第一颗灯泡的点亮

本次学习与开发基于CubeMX与HAL库,故我们可以一起学习到CucMX的使用&#xff0c;现在让我们开始点亮灯泡。 一、CubMX的基础配置首先是SYS的配置&#xff0c;一定要选Serial Wire串行线调试&#xff0c;否则烧录一次之后STM32开发板会锁住&#xff0c;我就犯过这个错误。 然后是…

作者头像 李华
网站建设 2026/9/4 17:34:11

从零搭建Arduino无线机械臂:硬件选型、代码解析与自动化实现

1. 先搞清楚这个项目到底能做什么&#xff0c;以及你需要准备什么 看到“复刻Arduino无线控制机械臂”这个标题&#xff0c;很多人第一反应可能是“听起来很酷&#xff0c;但会不会很难&#xff1f;”。这个开源项目的核心价值&#xff0c;就是让你能用一个相对清晰的路径&…

作者头像 李华
网站建设 2026/9/4 8:41:32

真实场景灭火器检测数据集:1618张图像VOC转YOLO训练全流程

简介&#xff1a;面向消防设备识别、智能巡检与安全监控场景的开发者&#xff0c;这份真实拍摄的灭火器图像数据集是训练目标检测模型的开箱即用素材。1618张图片覆盖不同角度、光照、背景及摆放状态&#xff0c;每张均配有标准VOC格式的XML标注&#xff0c;包含边界框与类别信…

作者头像 李华
网站建设 2026/9/2 13:50:15

MATLAB纹理特征提取实战:GLCM、LBP、Gabor等六种方法详解

简介&#xff1a;本资源是一套面向图像处理初学者与科研人员的MATLAB纹理特征提取工具集&#xff0c;聚焦于计算机视觉中的纹理分析核心任务&#xff0c;涵盖GLCM、GLDS、LBP、GMRF、FD和Gabor六类主流方法&#xff0c;适用于遥感图像分类、医学影像识别、工业缺陷检测等实际场…

作者头像 李华