STM32 SPI刷屏性能优化:从GPIO模拟到DMA的实战演进

📅 2026/7/31 8:08:52
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屏幕工作在模式0CPOL0 CPHA0或模式3CPOL1 CPHA1。你必须在屏幕的数据手册里确认这一点并在STM32的SPI配置中严格匹配否则通信根本建立不起来。关于速率屏幕手册通常会给出一个最大SCLK频率比如50MHz。但实际能跑多快取决于STM32芯片SPI外设的性能、你的PCB布线质量以及屏幕本身的接收能力。盲目设置最高速率可能导致数据错乱。一个稳妥的做法是从较低速率如10MHz开始测试逐步提高直到屏幕显示稳定。2.2 数据格式与命令/数据切换SPI屏通常不是只传像素数据。它需要先发送命令Command来设置地址、模式等再发送数据Data。这通常通过一根额外的“数据/命令选择线”DCX或D/C来实现。拉低这根线时SPI发送的是命令拉高时发送的是像素数据。这是刷图逻辑中非常重要的一环三种驱动方式下对这根线的控制时机需要仔细处理。2.3 刷图性能的瓶颈分析刷一张320x240QVGA的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位的基础函数假设使用模式0CPOL0 CPHA0/** * brief 使用GPIO模拟SPI发送一个字节模式0 * param data: 要发送的数据 * retval 无 */ void SPI_WriteByte(uint8_t data) { for(uint8_t i 0; i 8; i) { // 在时钟上升沿之前设置数据位CPHA0 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); // 发送颜色低字节 } }性能实测与痛点在STM32F103C8T672MHz上用这种方式刷满一张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 i0; itotal_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 i0; iBLOCK_SIZE; i2) { block_buffer[i] color_high; block_buffer[i1] 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)。内存地址填入你的像素数据数组的地址通常是一个变量在代码中指定。数据宽度外设和内存端通常都设置为Byte8位以匹配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-x11)*(y2-y11)*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速率很高时20MHzPCB走线质量、电源去耦会影响稳定性。如果问题随温度或振动变化需怀疑硬件。从GPIO模拟的“农耕时代”到硬件SPI的“工业时代”再到DMA的“信息时代”优化SPI刷图的过程本质上是对STM32芯片资源理解不断加深的过程。没有一种方案是银弹GPIO模拟在引脚紧张或调试初期仍有其价值硬件SPI在简单场景下足够高效而DMA则是追求极致性能和低功耗的必由之路。最关键的是理解每一种方式背后的硬件原理才能根据项目需求做出最合适的选择并快速定位那些令人头疼的故障。