STM32 SPI刷屏性能优化:从GPIO模拟到DMA的实战演进
1. 项目缘起:从GPIO模拟到DMA,一次SPI刷屏的性能探索
最近在做一个基于STM32的嵌入式显示项目,核心任务是把一张图片数据快速、稳定地刷到一块SPI接口的屏幕上。这听起来是个基础活,但真做起来,从最开始的GPIO模拟SPI,到启用硬件SPI,最后折腾上DMA,整个过程踩的坑和性能提升的体验,简直像给MCU做了一次“心肺复苏”。网上关于SPI驱动LCD的零散资料很多,但很少有文章能把这三种方式的实现细节、性能对比和切换过程中的“暗坑”讲透。正好借着这次项目总结,我把GPIO模拟SPI、硬件SPI、以及DMA+硬件SPI这三种方案的代码逻辑、配置要点和实测性能数据捋一遍。无论你用的是STM32F1、F4还是H7系列,只要涉及到SPI屏刷图,这里面的思路和避坑点都通用。
2. 理解你的SPI屏幕:通信协议与核心诉求
在动手写代码之前,必须吃透你的屏幕数据手册。SPI屏幕的刷图,本质上就是通过SPI总线向屏幕的显存(GRAM)连续写入像素数据。这里有几个关键点决定了后续驱动方式的选择。
2.1 SPI模式与速率:屏幕的“语言”和“语速”
绝大多数SPI屏幕工作在模式0(CPOL=0, CPHA=0)或模式3(CPOL=1, CPHA=1)。你必须在屏幕的数据手册里确认这一点,并在STM32的SPI配置中严格匹配,否则通信根本建立不起来。关于速率,屏幕手册通常会给出一个最大SCLK频率,比如50MHz。但实际能跑多快,取决于STM32芯片SPI外设的性能、你的PCB布线质量以及屏幕本身的接收能力。盲目设置最高速率可能导致数据错乱。一个稳妥的做法是,从较低速率(如10MHz)开始测试,逐步提高,直到屏幕显示稳定。
2.2 数据格式与命令/数据切换
SPI屏通常不是只传像素数据。它需要先发送命令(Command)来设置地址、模式等,再发送数据(Data)。这通常通过一根额外的“数据/命令选择线”(DCX或D/C)来实现。拉低这根线时,SPI发送的是命令;拉高时,发送的是像素数据。这是刷图逻辑中非常重要的一环,三种驱动方式下,对这根线的控制时机需要仔细处理。
2.3 刷图性能的瓶颈分析
刷一张320x240(QVGA)的16位色(RGB565)图片,需要传输的数据量是 320 * 240 * 2 = 153,600 字节。我们的目标就是尽可能快且不占用太多CPU资源地完成这153.6KB的传输。GPIO模拟的方式,CPU被完全捆绑在翻转引脚和查表移位上;硬件SPI解放了CPU,但传输本身仍需要CPU参与发起;而DMA的目标是让CPU在传输过程中完全“隐身”,去处理其他任务。理解了这个递进关系,我们就能明白每种方案优化的核心在哪里。
3. 方案一:GPIO模拟SPI——最原始的控制力
当你的项目使用的STM32型号没有多余的硬件SPI外设,或者硬件SPI被其他设备占用时,GPIO模拟就成了唯一选择。它的优势是引脚分配灵活,不受硬件外设映射限制,但代价是极低的效率和极高的CPU占用率。
3.1 模拟SPI的时序实现
核心就是用一个GPIO作为SCLK(时钟),一个作为MOSI(主设备输出),严格按照SPI时序图,用代码控制引脚的高低电平变化。下面是一个模拟SPI发送一个字节(8位)的基础函数,假设使用模式0(CPOL=0, CPHA=0):
/** * @brief 使用GPIO模拟SPI发送一个字节(模式0) * @param data: 要发送的数据 * @retval 无 */ void SPI_WriteByte(uint8_t data) { for(uint8_t i = 0; i < 8; i++) { // 在时钟上升沿之前,设置数据位(CPHA=0) if(data & 0x80) { MOSI_GPIO_Port->BSRR = MOSI_Pin; // 拉高MOSI } else { MOSI_GPIO_Port->BSRR = (uint32_t)MOSI_Pin << 16; // 拉低MOSI } data <<= 1; // 左移一位,准备发送下一位 // 产生时钟上升沿 SCLK_GPIO_Port->BSRR = SCLK_Pin; // 拉高SCLK // 此处可以插入极短的延时,确保建立时间,具体取决于主频 // Delay_us(0.1); SCLK_GPIO_Port->BSRR = (uint32_t)SCLK_Pin << 16; // 拉低SCLK,完成一个时钟周期 // 在模式0下,下降沿后可以采样数据,但我们是主设备,只关心发送 } }注意:函数中注释掉的
Delay_us(0.1)是一个关键点。在高速主频下(如72MHz),几条指令的执行时间可能已经短于屏幕要求的数据建立(Setup)和保持(Hold)时间。如果屏幕出现乱码,可能需要在这里加入微秒级甚至纳秒级的延时来“降速”。这是GPIO模拟调试中最常见的问题。
3.2 模拟SPI刷图函数剖析
基于上面的字节发送函数,我们可以构建刷图函数。核心逻辑是:设置好屏幕的显示窗口(通过发送命令和数据),然后循环发送每一个像素的RGB565数据。
/** * @brief 使用GPIO模拟SPI刷一块指定颜色的矩形区域(效率低下,仅作演示) * @param x1, y1: 区域左上角坐标 * @param x2, y2: 区域右下角坐标 * @param color: RGB565格式的颜色值 * @retval 无 */ void LCD_Fill_GPIO(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t color) { // 1. 设置显示窗口(发送命令序列) LCD_Write_Cmd(0x2A); // 列地址设置命令 SPI_WriteByte(x1 >> 8); // 发送起始列高8位 SPI_WriteByte(x1 & 0xFF); // 发送起始列低8位 SPI_WriteByte(x2 >> 8); // 发送结束列高8位 SPI_WriteByte(x2 & 0xFF); // 发送结束列低8位 LCD_Write_Cmd(0x2B); // 行地址设置命令 // ... 发送行地址数据,类似上面 LCD_Write_Cmd(0x2C); // 内存写命令,之后发送的都是像素数据 // 2. 循环发送像素数据 uint32_t total_pixels = (x2 - x1 + 1) * (y2 - y1 + 1); for(uint32_t i = 0; i < total_pixels; i++) { SPI_WriteByte(color >> 8); // 发送颜色高字节 SPI_WriteByte(color & 0xFF); // 发送颜色低字节 } }性能实测与痛点:在STM32F103C8T6(72MHz)上,用这种方式刷满一张QVGA单色图,耗时大约在1.5秒到2秒之间,CPU占用率接近100%。屏幕刷新肉眼可见的缓慢,根本无法用于动态界面。其瓶颈在于每个比特位的发送都需要至少4条CPU指令(判断、置位、翻转、移位),并且全程阻塞。
4. 方案二:硬件SPI——解放CPU的第一步
启用STM32内置的硬件SPI外设,是性能提升的第一个质变点。硬件SPI由专门的时钟逻辑和移位寄存器负责,CPU只需要把数据扔进数据寄存器(DR),硬件就会自动按配置好的时序和速率发送出去,发送完成后通过标志位或中断通知CPU。
4.1 硬件SPI的配置关键(以HAL库为例)
使用STM32CubeMX配置或直接编写初始化代码时,要关注以下几点:
- 模式:选择“Full-Duplex Master”或“Transmit Only Master”。刷屏我们通常只需要发送,选择只发模式可以节省一个引脚。
- 数据大小:设置为8位或16位。对于RGB565数据,一次发送16位更高效,但需要屏幕和驱动IC支持16位SPI模式。更通用的做法是使用8位模式,分两次发送一个颜色字。
- 时钟极性与相位:严格匹配屏幕的CPOL和CPHA。
- NSS(片选)管理:对于SPI屏幕,通常使用一个普通GPIO作为片选(CS),在初始化配置里将硬件SPI的NSS设置为“Software”,然后在代码中手动控制这个GPIO。这样更灵活。
- 波特率预分频器:这是决定速度的核心。在保证稳定的前提下,尽可能设高。例如,在APB2时钟为72MHz时,选择SPI_BaudRatePrescaler_2可以得到36MHz的SCLK。
4.2 阻塞式发送与刷图优化
最简单的使用方式是阻塞式发送,HAL库提供了HAL_SPI_Transmit。刷图函数和模拟SPI类似,但发送数据部分被替换了:
void LCD_Fill_HardwareSPI(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t color) { // ... 设置显示窗口(命令部分) ... uint32_t total_pixels = (x2 - x1 + 1) * (y2 - y1 + 1); uint32_t total_bytes = total_pixels * 2; uint8_t color_buffer[2] = {color >> 8, color & 0xFF}; // 方法1:循环发送每个像素(不推荐,效率低) // for(uint32_t i=0; i<total_pixels; i++) { // HAL_SPI_Transmit(&hspi1, color_buffer, 2, HAL_MAX_DELAY); // } // 方法2:构建完整缓冲区一次发送(消耗大量RAM) // uint8_t *pixel_buffer = malloc(total_bytes); // ... 填充缓冲区 ... // HAL_SPI_Transmit(&hspi1, pixel_buffer, total_bytes, HAL_MAX_DELAY); // free(pixel_buffer); // 方法3:分块发送(折中方案) #define BLOCK_SIZE 512 uint8_t block_buffer[BLOCK_SIZE]; uint16_t color_high = color >> 8; uint16_t color_low = color & 0xFF; // 预填充一个数据块 for(int i=0; i<BLOCK_SIZE; i+=2) { block_buffer[i] = color_high; block_buffer[i+1] = color_low; } uint32_t bytes_sent = 0; while(bytes_sent < total_bytes) { uint32_t send_this_time = (total_bytes - bytes_sent) > BLOCK_SIZE ? BLOCK_SIZE : (total_bytes - bytes_sent); HAL_SPI_Transmit(&hspi1, block_buffer, send_this_time, HAL_MAX_DELAY); bytes_sent += send_this_time; } }性能实测与瓶颈转移:采用方法3的分块发送,同样的QVGA单色图,刷图时间可以缩短到200-300毫秒左右,提升近10倍!CPU占用率在传输期间仍然是100%,但因为它只需要调用几次HAL_SPI_Transmit,而不是像模拟SPI那样执行数十万条指令,所以实际可以挤出时间处理一些中断。但瓶颈依然存在:HAL_SPI_Transmit是阻塞的,CPU必须等待当前块全部发送完毕才能继续执行后续代码。
5. 方案三:DMA+硬件SPI——终极性能解放
直接内存访问(DMA)控制器是STM32中的一个“数据搬运工”,它可以在不打扰CPU的情况下,在外设和内存之间搬运数据。将DMA与硬件SPI的发送结合起来,是刷屏操作的终极解决方案。CPU只需要启动一次DMA传输,就可以去处理其他任务(如响应触摸、运行业务逻辑),等DMA传输完成,通过中断或标志位通知CPU即可。
5.1 DMA通道与数据流配置
这是配置中最容易出错的地方。不同的STM32系列(如F1, F4, H7)的DMA架构差异很大。
- F1系列:只有DMA通道(Channel),需要映射到具体的外设请求(如SPI1_TX)。
- F4/F7/H7系列:引入了数据流(Stream)和通道(Channel)的概念。你需要为一个外设(如SPI1)的发送请求,选择一个可用的数据流(如DMA2_Stream3),并在这个数据流上配置对应的通道(Channel 3对应SPI1_TX)。
使用CubeMX配置会直观很多,关键参数如下:
- 方向:Memory To Peripheral(内存到外设)。
- 外设地址:填入SPI数据寄存器(DR)的地址,如
(uint32_t)&(SPI1->DR)。 - 内存地址:填入你的像素数据数组的地址(通常是一个变量,在代码中指定)。
- 数据宽度:外设和内存端通常都设置为Byte(8位),以匹配SPI的8位数据模式。如果SPI配置为16位,这里也要对应。
- 模式:选择Normal(传输指定数量后停止)或Circular(循环传输,适用于连续刷新)。刷单张图用Normal。
- 增量模式:内存地址需要递增(Increment),因为我们要连续发送数组中的多个字节;外设地址固定(No Increment)。
- 中断:使能传输完成中断(TC),以便在发送完后进行后续处理(如关闭片选)。
5.2 DMA刷图代码实现与状态管理
代码逻辑变得清晰,但状态管理需要更小心。
// 定义一个大缓冲区,存放要发送的像素数据。可以放在外部SDRAM以节省内部RAM。 uint8_t lcd_frame_buffer[320 * 240 * 2]; // QVGA RGB565 volatile uint8_t dma_transfer_complete = 0; // DMA传输完成标志 void DMA1_Stream3_IRQHandler(void) { if(__HAL_DMA_GET_FLAG(&hdma_spi1_tx, DMA_FLAG_TCIF3)) { __HAL_DMA_CLEAR_FLAG(&hdma_spi1_tx, DMA_FLAG_TCIF3); dma_transfer_complete = 1; // 设置完成标志 LCD_CS_HIGH(); // 传输完成,拉高片选(可选,取决于屏幕) } } void LCD_Refresh_DMA(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2) { // 1. 等待上一次DMA传输完成(如果有) while(dma_transfer_complete == 0); // 简单等待,实际应用中应加超时 dma_transfer_complete = 0; // 2. 设置显示窗口(此部分仍需CPU控制GPIO发送命令) // ... 发送0x2A, 0x2B, 0x2C等命令序列,注意这部分不能用DMA,因为涉及DCX引脚切换 // 通常用阻塞式SPI发送或GPIO模拟快速发送命令。 // 3. 启动DMA传输像素数据 HAL_SPI_Transmit_DMA(&hspi1, (uint8_t*)&lcd_frame_buffer[y1*320*2 + x1*2], (x2-x1+1)*(y2-y1+1)*2); // 4. 此时CPU被立即释放,可以去执行其他任务 // process_touch(); // update_system_logic(); // 5. 如果需要等待本次刷图完成,可以轮询标志位或等待中断 // while(dma_transfer_complete == 0); }5.3 性能巅峰与隐藏陷阱
使用DMA+硬件SPI后,刷同样的QVGA区域,CPU的参与时间几乎为零(仅限像素数据传输阶段),传输耗时完全由SPI时钟速率和DMA总线仲裁决定。理论上,SPI以最大速率(如36MHz)传输153.6KB数据,理论时间约为35毫秒。实测在F4系列上,考虑到总线竞争和屏幕处理时间,通常在40-60毫秒内完成,画面流畅度得到质的飞跃。
然而,这里有几个高级陷阱:
- 内存对齐与突发传输:对于高性能系列(如H7),如果内存缓冲区地址没有对齐到32字节边界,可能会无法使用DMA的最高效的突发(Burst)传输模式,导致性能下降。确保你的帧缓冲区地址是32字节对齐的(例如使用
__attribute__((aligned(32)))修饰)。 - 缓存一致性(Cache Coherency):当CPU和DMA共同访问同一块内存(如帧缓冲区)时,如果CPU有Cache(如H7的D-Cache),问题就来了。CPU写入缓冲区的数据可能还留在Cache里,没有写回内存(DMA能访问的区域)。此时DMA启动,发送出去的就是旧数据或乱码。必须在启动DMA传输前,对发送数据的内存区域执行缓存清理(Clean)操作。使用HAL库可以调用
SCB_CleanDCache_by_Addr。 - DMA传输完成中断的延迟:在高主频下,DMA传输完成到CPU进入中断处理函数存在延迟。如果你在中断里立刻开始下一帧的准备(如交换缓冲区),这个延迟可能导致SPI在极短时间内处于空闲状态,某些屏幕可能会因此出现细微闪动。一个技巧是,在传输完成前一点点(比如还剩最后几十个字节时),就提前开始准备下一帧的命令部分。
6. 混合策略与实战优化:并非所有数据都值得DMA
在实际项目中,我们不会教条地全部使用DMA。一个高效的驱动应该是混合策略。
6.1 命令用阻塞SPI,数据用DMA
屏幕的初始化序列、设置窗口坐标的命令等,数据量小且不规则,使用阻塞式SPI或甚至GPIO模拟(为了节省SPI外设复杂度)更加简单直接。而大量的、连续的像素数据发送,则交给DMA。这就是上面示例代码所采用的策略。
6.2 双缓冲与撕裂效应
当你的界面需要动态更新(如LVGL图形库)时,如果DMA正在从缓冲区A读取数据发送,同时CPU又在向缓冲区A写入下一帧的数据,就会导致屏幕上半部分显示旧帧,下半部分显示新帧,产生撕裂。解决方案是双缓冲:准备两个缓冲区,Buffer1和Buffer2。DMA从Buffer1发送时,CPU向Buffer2绘制下一帧。等DMA发送完成,交换两个缓冲区的角色。这需要驱动和图形库的紧密配合。
6.3 针对特定屏幕的“骚操作”
有些屏幕支持“内存写连续”模式,设置一次窗口后,可以连续写入,地址自动递增,跨行时也无需重新发送命令。这能最大化DMA的连续传输优势。而有些屏幕则需要每行都重新设置列地址。你需要根据数据手册调整驱动,在行切换时插入命令发送。这时,单一的DMA传输就需要拆分成多次(设置命令+行数据),增加了CPU的调度负担,但相比纯CPU搬运,效率依然高得多。
7. 调试心得:从现象倒推问题的链路
当你从GPIO模拟切换到硬件SPI或DMA时,如果屏幕白屏、花屏、错位,可以按以下链路排查:
首先确认SPI基础通信:用逻辑分析仪或示波器抓取SCLK, MOSI, CS, DCX的波形。检查:
- 时钟极性和相位是否正确?
- 数据位在时钟的哪个边沿变化和采样?是否满足屏幕时序?
- CS和DCX的控制时序是否正确?建立和保持时间够吗?
检查数据内容:确保你发送的命令字节和数据字节完全符合屏幕手册。一个常见的错误是RGB565的高低字节顺序(大端/小端)弄反。
排查硬件SPI配置:确认SPI外设时钟是否使能?引脚复用映射是否正确?波特率是否过高(导致信号畸变)或过低?
深入DMA问题:
- 数据错乱:检查DMA配置的内存/外设地址、数据宽度、增量模式。检查缓存一致性问题(H7系列重点)。
- 传输不启动或只传一部分:检查DMA通道/数据流映射是否正确。检查传输数据量(NDTR寄存器)设置是否过大(超过65535)。对于大数据量,需要拆分成多个DMA传输或在内存中分段。
- 屏幕闪动:检查是否在DMA传输中被打断(更高优先级中断抢占)。检查双缓冲机制是否正确。
电源与信号完整性:SPI速率很高时(>20MHz),PCB走线质量、电源去耦会影响稳定性。如果问题随温度或振动变化,需怀疑硬件。
从GPIO模拟的“农耕时代”,到硬件SPI的“工业时代”,再到DMA的“信息时代”,优化SPI刷图的过程,本质上是对STM32芯片资源理解不断加深的过程。没有一种方案是银弹,GPIO模拟在引脚紧张或调试初期仍有其价值,硬件SPI在简单场景下足够高效,而DMA则是追求极致性能和低功耗的必由之路。最关键的是,理解每一种方式背后的硬件原理,才能根据项目需求做出最合适的选择,并快速定位那些令人头疼的故障。
