ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RT-Thread SPI DMA传输实践指南:从配置到踩坑排查

RT-Thread SPI DMA传输实践指南:从配置到踩坑排查 RT-Thread上把SPI改成DMA传输看着是打开几个宏、配两个通道的事实际上从底层驱动到应用层每一步都有不少隐形门槛。我最早在项目里把SPI从轮询切到DMA时光最后一个字节丢失和DMA中断不触发这两个问题就来回折腾了快一周网上资料要么是裸机HAL库的写法要么只讲了CubeMX里怎么勾选几乎没有一篇把RT-Thread框架下的完整链路讲清楚。这篇文章就把我从配置到落地踩过的坑、验证过的方案一次性整理出来写给正在RT-Thread上做SPI外设、被CPU占用率和传输速率困扰的朋友。1. 整体设计与思路拆解1.1 为什么要给SPI加DMA先算一笔CPU账先别急着改代码想清楚为什么要上DMA这决定了你后面怎么做取舍。SPI总线本身是一主多从的高速同步串口很多MCU的SPI时钟能跑到几十MHz比如STM32F4上SPI1挂在APB2总线上108MHz主频下可以跑到54MHz但如果你用轮询方式来收发数据CPU得不停往SPI数据寄存器里写一个字节、等一个字节再写下一个字节。我们来算一笔实际的账。假设SPI时钟10MHz一个字节8位也就是每传输一个字节需要0.8微秒。轮询模式下一个字节从写入数据寄存器到等待硬件发送完成再写下一个字节CPU至少要空转大半个周期。别说10MHz了就算只有1MHz你传输1KB数据CPU基本就全程被卡在那里。在RT-Thread这种RTOS环境里系统靠SysTick和PendSV调度任务SPI传输期间如果一直占用CPU轮询高优先级的中断还能抢一下但任务调度就明显变慢了传感器数据采集、显示刷屏、日志输出这种高频SPI操作多了之后整个系统的响应性都会被拖垮。加DMA之后CPU做的事情就变成了配置一次DMA传输然后去干别的等DMA搬运完再通过中断告诉你。理论上每传输1KB数据CPU只需要在开始和结束各处理一次中间上千个字节的搬运全部交给DMA硬件。实际测试中我在96MHz主频的MCU上把SPI Flash擦写速度从轮询的约400KB/s提升到了700KB/s以上同时CPU占用率从接近50%降到了个位数。这个提升幅度非常直观。1.2 RT-Thread SPI框架下的DMA实现路径RT-Thread的SPI驱动和裸机HAL库调用有很大区别。裸机开发时你直接调用HAL_SPI_TransmitReceive_DMA()传完参数就等中断回调DMA的配置、回调、通道选择全部自己管理。在RT-Thread里SPI被抽象成了设备模型上层应用拿到的是一个rt_spi_device指针你调用rt_spi_transfer_message()或rt_spi_send_then_recv()这样的API中间穿过SPI总线层最后才落到具体芯片的驱动函数里。这个分层结构决定了你不能像裸机那样直接在应用层启动一个DMA搬运你需要在驱动层让drv_spi的spi_transfer函数内部去调用HAL库的DMA接口。好在官方BSP里已经做好了这部分工作RT-Thread的STM32驱动包drv_spi.c根据配置宏来选择是轮询还是DMA。你打开rtconfig.h看到类似BSP_SPI1_RX_USING_DMA和BSP_SPI1_TX_USING_DMA这样的宏就是DMA进出口开关。这里有个关键理解点RT-Thread的SPI DMA驱动用的是信号量等待机制。drv_spi发起HAL_SPI_TransmitReceive_DMA()之后会去获取一个静态信号量DMA传输完成后触发的回调函数里释放这个信号量这样驱动线程会阻塞在信号量上等待DMA完成。所以你在应用层调用SPI API时表现是同步的等函数返回就代表传输完成但底层其实是异步的DMA搬运这种方式既有DMA的高效又保持了API的简单一致性。1.3 方案选型轮询、中断、DMA怎么选不是所有SPI场景都要上DMA用错场合反而增加复杂度。我做方案选型时会按数据量来分传输长度小于16字节的短帧控制命令比如读寄存器、写配置寄存器轮询模式是最合适的函数调用的开销远小于初始化DMA通道的开销。中等长度的定频数据传输比如周期性的传感器采样值读取数据长度固定且相对较小用中断模式通常足够每次传输的CPU开销很低。大批量连续数据传输比如SPI Flash读写、LCD刷屏、无线模块组包收发才必须上DMA否则CPU会被拖到崩溃边缘。DMA的优势和代价是并存的。优势是CPU占用低、吞吐量高代价是代码配置变复杂、要考虑缓冲区对齐、DMA通道优先级要避开冲突、还要会排查各种诡异的数据错乱。所以我的建议是按需引入管你产品数据量大小先上一套DMA的做法其实并不可取。2. 核心细节解析与实操要点2.1 SPI参数配置时钟、模式、位宽不能说改就改SPI的通信参数中最容易被忽视也最容易出问题的是时钟极性CPOL和时钟相位CPHA的组合。你配置一个外接的SPI Flash或者传感器如果模式配错了读回来的数据就会全是0xFF或者随机乱码尤其在DMA模式下这种问题更隐蔽因为你光看DMA计数和接收长度都对但数据内容就是不对。SPI有四种模式本质上就是SCK空闲电平和采样点位置不同。模式0CPOL0CPHA0是绝大多数SPI器件默认的模式SCK空闲拉低上升沿采样数据。你接一个不熟悉的芯片时首选模式0通常没错但如果芯片数据手册明确写了模式1或模式2就必须按手册来。在RT-Thread里配置方式如下struct rt_spi_configuration cfg { .mode RT_SPI_MODE_0 | RT_SPI_MSB, .data_width 8, .max_hz 10 * 1000 * 1000, }; rt_spi_configure(spi_device, cfg);然后是时钟频率。SPI是有极限的max_hz设置太高可能导致从机跟不上设置太低又会拖慢速度。有个经验原则先把max_hz设成芯片手册标称最大速率的1/2开始测试稳定后再慢慢往上提。我在GD25Q128 Flash上的实测标称支持120MHz但在长走线和3.3V供电条件下跑到20MHz左右是稳定可靠的再往上就开始偶尔读到乱码。数据宽度要特别盯紧。RT-Thread里SPI设备默认支持8位但部分芯片比如某些LCD控制器支持16位模式。如果你要读的SPI设备每次按8位单位寻址但驱动里配成了16位那么你每次传输的数据量会翻倍接收缓冲区会被填得乱七八糟。这个问题在DMA模式下尤其难查因为DMA是按照配置的长度无脑搬运的搬错了也不知道。2.2 DMA通道分配与请求映射方向别搞反DMA最难的部分在于搞清楚你用的具体芯片的DMA请求映射表。以STM32F4系列为例SPI1的发送请求映射到DMA2的Stream 3 Channel 3接收请求映射到DMA2的Stream 0 Channel 3SPI2的发送映射到DMA1的Stream 5 Channel 0接收映射到DMA1的Stream 3 Channel 0。不同型号、不同系列这个映射关系完全不一样如果你是Cortex-M7或者新出的M33内核又要查新的表。所以SPI需要两个DMA吗这个问题就有了明确答案全双工SPI收发各需要一个DMA通道所以至少两个。但如果你只做只发不收的单向通信比如单纯向LCD发显存数据只开TX DMA就够了如果只做只收不发的通信比如只读传感器寄存器只开RX DMA也行。注意SPI的RX和TX不能复用同一个DMA通道因为发送和接收的DMA请求是独立产生的一个通道在同一时间只能服务一个方向。实际配置建议直接借助STM32CubeMX先关联好DMA通道再对照生成的代码在RT-Thread里填对应通道参数。RT-Thread Studio的图形化配置界面里勾选对应SPI外设的DMA功能后它会在board.h里生成这样一段宏定义#define BSP_SPI1_TX_USING_DMA #define BSP_SPI1_RX_USING_DMA在stm32_spi.h或drv_spi.h配置文件里则有一组DMA实例和通道的宏。我当时就在这里栽过跟头——CubeMX里明明选的是DMA2 Stream 3结果RT-Thread配置文件里漏改了Stream编号导致DMA一直不动作整整排查了一个下午。2.3 关键在于DMA完成中断与信号量的配合DMA搬运完数据后靠什么通知CPU答案是DMA传输完成中断。在RT-Thread的驱动框架里这个中断回调函数的职责相当具体释放一个信号量让阻塞在传输函数里的线程继续往下执行。这个机制看起来简单但有两个容易出问题的点。第一DMA完成中断的优先级设置。在RT-Thread里中断优先级越低数值越小实际占用的优先级越高。DMA中断优先级如果设置得太低在高负载系统中可能被其他中断长时间阻塞导致SPI传输完成很久后CPU才得到通知这样DMA带来的延迟优势就被削弱了。我一般把DMA中断优先级设为中等偏上数值2或3既不影响SysTick又能及时处理。第二DMA传输完成中断和SPI总线空闲之间的区别。DMA搬运完最后一个字节SPI硬件可能还在把最后一位发给从机。如果DMA中断一触发就立刻操作GPIO拉高片选可能导致最后一个字节的传输被打断这就是很多人在DMA模式下遇到的最后一个字节丢失或读回数据少一个字节的根因。RT-Thread的驱动里通常会在DMA传输完成回调里等待SPI的SPI_SR_EOT事件或者加少量延时来处理这个间隙确保SPI总线确实空闲后才算传输完成。2.4 硬件片选与软件片选的坑SPI片选信号的控制方式也是一个经典坎。硬件片选是指SPI硬件外设自动控制NSS引脚软件片选是指用普通GPIO在软件里手动拉低和拉高片选信号。RT-Thread的SPI框架天然支持软件片选。你在挂载SPI设备时只会给一个片选GPIO编号每次传输时驱动会根据消息里的cs_take和cs_release标志来控制GPIO拉低或拉高。但要注意的是DMA传输过程中片选时序需要自己关注如果设备要求片选在整个多字节传输过程中持续拉低你在消息里就不能设置cs_take和cs_release在同一消息内完成否则GPIO控制函数在DMA还没结束时就提前拉了片选。struct rt_spi_message msg { .send_buf tx_buf, .recv_buf rx_buf, .length 256, .cs_take RT_TRUE, .cs_release RT_FALSE, }; rt_spi_transfer_message(spi_dev, msg);比如你要向Flash芯片发送读命令地址的5字节命令段紧接着再读一大块数据应该分成两个消息发送第一段cs_release设为假第二段cs_take设为假、cs_release设为真这样片选才能在整个过程中保持有效。如果用DMA发送第一段数据驱动返回时DMA可能还没发送完毕这时候如果立刻操作片选GPIO就可能导致片选时序错误。所以片选要不要软件拉和DMA同步等待这两件事必须一起考虑。硬件片选这块很多人图省事直接用SPI的NSS引脚但这种方式在DMA传输中并不一定可靠。硬件NSS的拉低和拉升完全由硬件状态机控制在某些MCU上DMA模式下NSS时序并不精确特别是要求CS保持低电平超过一帧SPI数据的时候硬件NSS的自动控制很容易出现提前拉高的现象。我的经验是除非芯片的SPI主机带真正的硬件片选增强模式否则默认都改用GPIO软件片选反而更可控。3. 实操过程与核心环节实现3.1 环境准备BSP与rtconfig.h配置要用RT-Thread做SPIDMA第一步是把SPI功能在系统里打开。我用的是官方针对STM32F407的BSP基于RT-Thread Studio来管理工程。打开board.h你会看到类似这样的配置选项#define BSP_SPI1_RX_USING_DMA #define BSP_SPI1_TX_USING_DMA如果用的是RT-Thread Studio的配置向导你可以在板级配置界面上直接勾选。勾上DMA发送和接收之后还需要在stm32_spi.h里确认DMA实例编号#define SPI1_TX_DMA_INSTANCE DMA2_Stream3 #define SPI1_RX_DMA_INSTANCE DMA2_Stream0 #define SPI1_TX_DMA_CHANNEL DMA_CHANNEL_3 #define SPI1_RX_DMA_CHANNEL DMA_CHANNEL_3 #define SPI1_DMA_IRQ DMA2_Stream3_IRQn #define SPI1_DMA_IRQ_HANDLER DMA2_Stream3_IRQHandler这些宏是驱动文件里面用来初始化DMA通道、注册中断处理函数的依据。如果你在CubeMX里重新配置过引脚回来一定要核对这里是否与实际芯片手册一致。另外还有两个必须打开的宏DMA中断相关的RT_DMA_IRQ有的BSP里叫BSP_SPI1_DMA_INTERRUPT以及SPI总线的总开关RT_USING_SPI。打开之后重新编译如果有报错说找不到DMA头文件或者某个DMA通道定义你需要检查libraries/HAL_Drivers路径下面有没有正确包含HAL的DMA驱动文件。这部分通常没什么坑但偶尔会遇到BSP裁剪后漏掉了DMA的HAL源文件直接编译报错加上即可。3.2 底层SPIDMA驱动的配置进入drv_spi.c源码你会看到RT-Thread的SPI驱动主体实现了spi_transfer这个函数。当配置了DMA宏时内部会走HAL_SPI_TransmitReceive_DMA这条路。这里有一个很多人没注意到的点这个驱动函数会把你的传输分成两个阶段来处理——先取事务handle-pTxRxBuf然后启动DMA再等待信号量。底层驱动的DMA配置核心其实是初始化一个DMA_HandleTypeDef结构体并关联SPI句柄static int spi_dma_config(struct stm32_spi *spi_dev) { DMA_HandleTypeDef *rx_handle spi_dev-dma_hdma_rx; DMA_HandleTypeDef *tx_handle spi_dev-dma_hdma_tx; rx_handle-Init.Request DMA_REQUEST_SPI1_RX; rx_handle-Init.Direction DMA_PERIPH_TO_MEMORY; rx_handle-Init.PeriphInc DMA_PINC_DISABLE; rx_handle-Init.MemInc DMA_MINC_ENABLE; rx_handle-Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; rx_handle-Init.MemDataAlignment DMA_MDATAALIGN_BYTE; rx_handle-Init.Mode DMA_NORMAL; rx_handle-Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(rx_handle); }我建议你重点关注两个字段。第一个是Mode设为DMA_NORMAL还是DMA_CIRCULAR。对SPI来说绝大多数场景用DMA_NORMAL就够了——每次传输是一次性的传输完成即停止。DMA_CIRCULAR循环模式适合串口这种持续接收未知长度数据的场景SPI没有串口那样的空闲中断来判定一帧数据结束用循环模式反而难以处理数据边界问题。网上有人问dma continuous requests这类词其实就是在问循环模式在SPI DMA场景下很少用别被带偏了。第二个是Priority优先级分配要综合考虑系统中的其他DMA通道。比如你的串口也用了DMA优先级高的DMA会抢占优先服务SPI传输如果被高优先级DMA一直抢占可能会在高速模式下出现接收数据覆盖慢的情况。合理做法是给SPI设一个高优先级给串口DMA设中优先级保证SPI时序的完整性。3.3 应用层调用读一颗SPI Flash的完整例子底层配好之后应用层其实相当简单。下面是我在RT-Thread上读取GD25Q128 Flash ID的完整流程这段代码也是我用来快速验证SPIDMA是否正常工作的一个探针程序。#include rtthread.h #include rtdevice.h #include spi_flash.h static struct rt_spi_device *flash_dev; int spi_flash_probe(void) { struct rt_spi_configuration cfg; flash_dev (struct rt_spi_device *)rt_device_find(spi10); if (!flash_dev) { rt_kprintf(find spi10 failed\n); return -1; } cfg.mode RT_SPI_MODE_0 | RT_SPI_MSB; cfg.data_width 8; cfg.max_hz 10 * 1000 * 1000; rt_spi_configure(flash_dev, cfg); /* 读Flash ID指令 */ uint8_t cmd 0x9F; uint8_t id_buf[3] {0}; struct rt_spi_message msg1 { .send_buf cmd, .recv_buf RT_NULL, .length 1, .cs_take RT_TRUE, .cs_release RT_FALSE, }; rt_spi_transfer_message(flash_dev, msg1); struct rt_spi_message msg2 { .send_buf RT_NULL, .recv_buf id_buf, .length 3, .cs_take RT_FALSE, .cs_release RT_TRUE, }; rt_spi_transfer_message(flash_dev, msg2); rt_kprintf(flash id: 0x%02X 0x%02X 0x%02X\n, id_buf[0], id_buf[1], id_buf[2]); return 0; } INIT_COMPONENT_EXPORT(spi_flash_probe);这段代码有两点值得说明。第一rt_device_find(spi10)里的spi10是我在板级适配文件里挂载SPI设备时取的名字它表示SPI1总线上的0号设备。名字随便取只要和rt_hw_spi_device_attach里的参数对应上即可。第二为什么发读ID命令时不用rt_spi_send_then_recv这个现成接口因为这个接口内部会自己处理消息的标志位内部实现时通过消息的cs_take和cs_release来控制片选而我要演示的正是如何通过手动构造消息来控制片选在整个读操作期间保持低电平。实际项目里直接用rt_spi_send_then_recv会更简洁它内部就是两个消息的组合。读ID命令成功时串口终端会输出flash id: 0xC8 0x40 0x18这样的数据不同芯片厂商ID不同但只要不是全0xFF或全0x00说明SPIDMA链路基本打通了。这个探针程序跑通过一次之后后面写Flash读取、写入、擦除函数都信心倍增。4. 常见问题与排查技巧实录4.1 DMA中断不触发/数据不动这个现象最气人函数调用了等待信号量卡死在那里DMA没有任何动作。排查路径基本是固定的。先看配置宏是否真的生效了。很多人在rtconfig.h里加了宏但工程里多个配置文件相互覆盖实际编译时还是旧配置。我的建议是先在代码里用条件编译打印一条日志确认驱动走的是DMA分支而不是传统的轮询分支。再看DMA通道编号。对照你目标芯片的参考手册确认真实引脚外设对应的DMA实例和通道号同时检查在drv_spi配置里是否有两套不同的编号定义。我自己就遇到过SPI1_RX_DMA_INSTANCE和SPI1_TX_DMA_INSTANCE定义反了的情况导致发送还能工作接收完全没反应。第三步查DMA中断回调是否注册成功。RT-Thread的驱动需要在启动时调用HAL_DMA_Start_IT来启动传输并注册中断回调如果中断没有注册成功或者写入了错误的句柄指针传输完成了也没有人来发信号量。你可以在回调函数里加一个计数变量看看到底有没有进入中断。4.2 最后一个字节丢失或数据错位最后一个字节丢失这个现象在DMA接收模式下非常经典本质问题是DMA判定传输完成的时机和SPI外设读数完成/写数完成的时机不完全同步。SPI的发送DMA的结束并不意味着SPI总线已经发送完最后一位。SPI发送移位寄存器里最后一个字节可能还在往外挪。如果DMA完成中断刚触发你就立刻拉高片选从机端收到的可能是只有7位有效数据的残缺字节。解决办法有两种第一在发送完成中断后用查询方式检查SPI的BSY位或发送空标志等硬件彻底空闲第二设置一个几微秒到几十微秒的延时等最后一位落定再操作片选。接收方向的DMA还没搬完但SPI已经填满接收缓冲区导致的错位则是另一回事。有的RT-Thread BSP版本在接收时会多发一个字节做缓冲也就是dummy read。如果驱动里没有处理好这个dummy字节的位置读回的数据数组就会整体偏移一位。这个时候用逻辑分析仪抓取MOSI和MISO的时序会非常直观地看到问题所在。4.3 缓冲区对齐与Cache一致性问题DMA需要直接访问内存这对缓冲区有硬性要求地址对齐和Cache一致性。在Cortex-M4及以下的内核比如M3、M4F上DMA缓冲区要求4字节对齐因为DMA外设以字为单位搬运数据效率最高有些HAL配置在某些芯片上还有32位对齐的要求。如果不对齐轻则DMA配置失败重则进入硬件错误中断直接跑飞。确保对齐的常见方法RT_ALIGN(static uint8_t rx_buf[256], 4);用了RT_ALIGN宏在编译期就把数组对齐到了4字节边界省事又可靠。如果缓冲区最终分配给DMA后偶尔还是出现hardfault检查一下缓冲区是否存在大小不足DMA搬运超出数组边界会直接踩到相邻内存这是最隐蔽的崩溃来源。在Cortex-M7芯片比如STM32H7系列上还有缓存一致性问题。CPU写过缓冲区之后数据可能还停留在D-Cache里没有刷到SRAMDMA去读的时候读到的还是旧数据反过来DMA写完数据后CPU去读缓冲区时读到的可能是Cache里的陈旧内容。解决办法是在启动DMA传输前执行SCB_CleanDCache在DMA完成后执行SCB_InvalidateDCache。RT-Thread使用的一些H7 BSP里已经做了这部分处理但你如果在自己的代码里手动构造缓冲区就要特别留意。4.4 片选时序引起的设备无响应外设没响应时我第一个怀疑的不是SPI时钟而是片选时序。DMA模式下片选由GPIO控制但GPIO设置往往在DMA完成回调之前或之后被调用了导致从机在片选有效期间实际没有收到完整的命令。排查片选问题逻辑分析仪是最好的工具把CS、SCK、MOSI三根线同时抓下来一看便知。软件片选还有一个常见的坑如果有人把片选GPIO配置成了开漏模式且没有上拉CS信号处于不确定状态从机可能在你完全没操作的时候被误选中。GPIO务必配置成推挽输出默认电平设为高。4.5 常见问题速查表现象可能原因排查/解决思路DMA不启动等待信号量卡死配置宏未生效、DMA通道编号填错、中断未注册条件打印确认分支查芯片手册核对通道号在中断回调里打计数最后一个字节丢失发送DMA完成后SPI总线尚未空闲就拉高片选等待SPI的BSY位清空或加短延时再操作片选接收数据整体错位接收dummy字节未正确跳过逻辑分析仪抓MISO时序确认从机输出时序读回数据全0xFFSPI模式CPOL/CPHA不匹配、时钟速率太高查从机数据手册确认模式降频到标称值的1/2试跑读回数据全0x00发送数据未正确发出、从机未选中检查MOSI波形、CS片选电平是否正常跑飞进hardfault缓冲区未对齐、数组越界、Cache一致性问题用RT_ALIGN对齐缓冲区检查数组长度M7芯片上处理Cache一致性传输速度慢且CPU占用高DMA配置没生效实际还在走轮询条件编译打印确认走的传输路径4.6 一些你自己踩过才懂的细节最后分享几个项目落地后才总结出来的经验这些在官方文档和例程里很难找到。第一DMA缓冲区尽量不要放在外部SDRAM里。外部SDRAM的访问延迟远大于内部SRAMDMA从外部SDRAM搬运数据时总线仲裁更复杂遇到FMC冲突时SPI吞吐量会骤降。除非你封装了专门的内存管理否则一律用内部SRAM数组做缓冲。第二RT-Thread的INIT_COMPONENT_EXPORT和INIT_APP_EXPORT顺序有讲究。SPI设备挂载通常在板级初始化阶段完成应用层导出函数一定要在设备挂载之后执行否则rt_device_find会找不到设备。在工程里发现find设备失败时检查一下初始化节是否靠前。第三如果你的板子SPI总线上挂了多个设备每个设备的DMA缓冲区最好独立分配。共用缓冲区在并发场景下会出现数据覆盖问题RT-Thread的SPI设备框架管理的是总线级的访问互斥但多个设备之间如果共享同一块内存作为接收缓冲那只能是自找麻烦。第四做完DMA模式切换后务必做一次长时间压力测试。SPIDMA的bug很多是偶发性的运行几分钟可能才出现一次。我一般会写一个CRC校验循环从Flash连续读取几百KB数据实时比对校验值跑一晚上不报错才敢说这套配置是可靠的。RT-Thread上SPIDMA调通之后再做LCD刷屏、Flash日志、无线模块收包这些大数据量场景都会顺畅非常多。我个人在实际操作中的体会是DMA这块最大的难度不在于配置本身而在于理解硬件到驱动的完整数据流——知道数据从哪里来、经过哪里、靠什么通知完成这才是排查问题的底气。希望这篇能帮你少走点弯路有问题欢迎在评论区交流。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进