尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

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

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屏幕从上往下一点点“擦”出来肉眼能看到刷新过程跟车站那种旧式翻牌屏差不多。后来把传输改成硬件SPIDMA同一张图瞬间铺满速度快到让人怀疑是不是换了块屏。这篇文章不聊怎么点亮屏幕专门讲怎么让STM32快速刷屏把硬件SPIDMA的完整链路、初始化代码、实测数据和踩过的坑都摆出来。适合已经会用模拟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. 硬件SPIDMA的配合逻辑先想明白再写代码理解硬件SPIDMA怎么工作比直接抄代码更重要。搞懂了之后遇到问题你能自己定位而不是瞎试。2.1 一分二硬件SPI负责“位流”DMA负责“搬砖”硬件SPI外设做的是把并行的8bit数据转成串行时钟输出不需要CPU逐位操作。只要把数据写进SPI的数据寄存器DRSPI硬件就会自动按波特率产生时钟把8个bit通过MOSI移出去。这个过程对CPU来说是异步的写一个字节后SPI开始慢慢移CPU完全可以去干别的。那DMA干什么DMA负责把内存里的像素数据自动搬运到SPI的DR寄存器。搬运过程中CPU完全不用参与。用大白话说SPI是流水线上的“传送带”DMA是专门搬货的“机器人”CPU是这个车间的“管理员”。管理员只需要下指令让机器人把哪一堆货搬到传送带上传送带会自动往前走搬完之后机器人才会通知管理员。具体到硬件信号流程SPI发送寄存器DR为空硬件置TXE标志位TXE标志触发DMA请求DMA把内存里的下一个字节写入DRSPI继续把DR里的8bit移出等DR空了再次触发DMA请求如此循环直到DMA计数器减到0产生DMA传输完成中断。2.2 DMA请求通道与句柄绑定很多人第一次都卡在这STM32不是每个DMA都能服务所有外设。以F103为例SPI1的发送请求固定在DMA1通道3SPI1接收固定在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和SPI1static void MX_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); // CS用PA4DC用PB0RST用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×215360字节能塞进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%硬件SPIDMA约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有两个地址寄存器列地址CASET0x2A和行地址RASET0x2B以及一个方向控制寄存器MADCTL0x36。最容易犯的错误是列地址和行地址的顺序反了或者高字节低字节写反。比如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位来区分命令和数据。如果是第二种硬件SPIDMA会很痛苦因为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. 基于这套方案再往前走这套硬件SPIDMA的框架稳定之后可以做几件很有价值的事其中最推荐的是双缓冲。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 hrefhttps://download.csdn.net/download/n7o8p/92368243 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p
返回列表