ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

RT-Thread SPI+DMA实战:从原理、配置到踩坑排障完整指南

RT-Thread SPI+DMA实战:从原理、配置到踩坑排障完整指南 不知道你有没有遇到过这种情况板子跑着跑着主循环突然被 SPI 传输卡住整个系统像掉进泥潭一样。我之前调一个 SPI 接口的屏幕驱动数据量一上来轮询方式直接把 CPU 占用塞满系统响应变得一塌糊涂。后来把所有 SPI 收发都切到 RT-Thread 的 DMA 通道上这个问题才算彻底解决。这篇文章就围绕 RT-Thread 上使用 SPIDMA 这条主线把从原理到配置、从代码到排障的完整路径捋一遍。内容偏实战适合已经能跑通 RT-Thread 基础 BSP、但对 DMA 和 SPI 框架关系还不太清楚的开发者。我尽量把当时踩过的坑和想明白的道理都写清楚后面有人再遇到类似问题至少能少走两三天弯路。1. 我为什么把 SPI 换成 DMA以及它解决的真正问题先说一个很多人容易混淆的点在 RT-Thread 上使用 SPIDMA并不是应用层调一个什么神秘 API而是驱动层把传输通道从轮询/中断切换成 DMA 引擎。对上面调用rt_spi_transfer的代码来说接口没有任何变化但 CPU 的负载曲线完全不同。1.1 三种 SPI 传输方式的时间账SPI 传输数据的方式有三种轮询、中断和 DMA。很多人以为中断已经足够省 CPU 了但实际算一下就会发现问题。假设 SPI 时钟配置为 36MHz、数据宽度 8bit、每次传输 4KB 数据。纯粹的理论传输时间是$$T \frac{4096 \times 8}{36 \times 10^6} \approx 0.91ms$$轮询方式在传输期间CPU 每个字节都要等待 TXE 标志位、查询 RXNE 标志位。按每个字节需要若干条指令来算CPU 在这 0.91ms 内是全程被占用的别的任务一个都跑不了。中断方式每传输一个字节触发一次中断。4KB 数据就是 4096 次中断每次中断从压栈、进入 ISR、读取标志位、写数据到出栈保守算 100 个周期。这部分的 CPU 占用率大约是 $4096 \times 100 / 72 \times 10^6 × 100% \approx 5.7%$。反而是中断处理开销最重的场景。DMA 方式CPU 只需要在传输开始前配置好 DMA 通道在传输结束中断里做一次收尾。传输期间 CPU 完全自由。在 RT-Thread 这种实时系统里轮询 SPI 还会带来一个更隐性的问题它把调度器给卡死了。如果你的 SPI 传输发生在线程上下文里那么整个线程会被长时间阻塞高优先级的就绪任务也插不进来。我当时的屏幕刷新一帧大概 0.9ms虽然数字看着不大但在 72MHz 的主频下这就是几万条指令被白白吃掉。1.2 DMA 是不是真的没有代价DMA 不是免费的午餐。它需要占用 DMA 控制器通道需要额外的内存块用于缓冲区还要处理 DMA 和 CPU 之间可能发生的缓存一致性问题尤其是在带 D-Cache 的 M7 内核上。但是相比它带来的收益这些代价通常完全值得。在 RT-Thread 上你付出去的代价通常是一次性的驱动配置而换回来的是系统调度延迟的大幅降低和 CPU 利用率的明显提升。我后来在三个不同项目上做了同一套切换结论一致只要单次 SPI 传输超过 128 字节DMA 的综合收益就开始明显领先如果每次传输在 1KB 以上几乎没有任何理由继续用轮询。2. 动手前先把 RT-Thread 的 SPI 框架和 DMA 关系理顺很多人一上来就改代码结果发现rt_spi_transfer_message明明被调用了、驱动层却没走 DMA 通道。这是因为没搞明白 RT-Thread SPI 设备框架的设计逻辑。2.1 RT-Thread SPI 框架的四层结构和数据流RT-Thread 的 SPI 框架从上到下大致分四层应用层调用rt_device_find(spi1)、rt_spi_transfer_message等标准接口。设备管理层rt_spi_transfer_message内部会调用spi-ops-transfer这个ops就是 BSP 驱动层实现的回调函数集合。BSP 驱动层例如drv_spi.c它实现了spi_ops结构体里的各个成员函数内部调用 STM32 HAL 库或者裸机寄存器操作。硬件层SPI 外设和 DMA 控制器。关键点在第三层的transfer回调里。驱动层实现者可以选择循环读写数据寄存器这是轮询每次收发一个字节在中断里处理这是中断方式配置好 DMA 通道后启动传输等 DMA 传输完成中断这是 DMA 方式。应用层的rt_spi_transfer_message根本不知道底层用的是什么方式。所以如果你在应用层找不到任何 DMA 相关的 API这很正常DMA 本来就是驱动层的事情。2.2 RT-Thread 的 SPI 消息接口与 DMA 的配合逻辑来看一下常见的驱动层实现伪代码static rt_ssize_t spix_transfer(struct rt_spi_device *device, struct rt_spi_message *message) { rt_uint32_t len message-length; rt_uint8_t *send_buf message-send_buf; rt_uint8_t *recv_buf message-recv_buf; if (len 0) { /* 配置片选 */ if (message-cs_take) rt_pin_write(device-config-cs_pin, PIN_LOW); /* 内部可以在这里走 DMA也可以走轮询 */ if (use_dma) { /* 配置 DMA 发送/接收通道 */ /* 启动 DMA */ /* 等待 DMA 完成信号量 */ } else { /* 普通循环 */ } if (message-cs_release) rt_pin_write(device-config-cs_pin, PIN_HIGH); } return len; }你注意到了吗cs_take和cs_release的逻辑在 DMA 传输的前后。这里藏着一个大坑后面我会专门展开讲软件片选和 DMA 配合的时序问题。2.3 硬件片选和软件片选在 DMA 场景下的选择这是从网络热词里也能看到的高频问题。SPI 片选有两种管理方式硬件片选NSS 自动控制由 SPI 外设自动拉低/拉高片选信号。STM32 的 NSS 硬件控制在某些系列上有延迟问题且它的时序很难和 DMA 启动完美对齐。软件片选GPIO 手动控制在主控cs_take时拉低 GPIO在cs_release时拉高。优点是时序完全受控缺点是 CPU 要管这个引脚。在 DMA 场景下我强烈建议使用软件片选。原因很简单DMA 一启动整个传输过程 CPU 基本不参与。如果你让硬件 NSS 自动管理片选一旦 DMA 配置中某个参数不对比如 FIFO 阈值、数据宽度片选时机就会和你期待的不一致造成从设备误读数据或者协议错乱。而用软件片选时你可以明确控制片选拉低的时间点在 DMA 启动前准备好一切最大程度避免时序偏移。3. 一步步把 SPIDMA 装配到 RT-Thread 驱动接下来是完整可落地的配置过程。我以 STM32F103 系列 RT-Thread 5.0.x 为例用 CubeMX 做外设初始化然后手工把 DMA 相关代码接到 RT-Thread 的drv_spi.c上。其他系列的流程大差不差关键是思路。3.1 CubeMX 里做对的基础配置在 CubeMX 中启用 SPI1做以下设置配置项推荐值说明SPI ModeTransmit Only Master 或 Full-Duplex Master视外设而定Hardware NSS SignalDisable用软件片选DirectionTwo Line Tx/Rx全双工场景推荐Data Size8 Bits配合 SPI Flash 等常见设备Prescaler按需分频先慢后快便于调通DMA Settings添加 SPI1_TX 和 SPI1_RX 两个 DMA 请求优先级调到 High有个容易被忽视的点DMA Settings 里的 SPI1_TX 和 SPI1_RX 底层其实是两个不同的 DMA 通道。以 F103 为例SPI1_TX 默认映射在 DMA2_Channel3SPI1_RX 默认映射在 DMA2_Channel2。如果你的应用只用发送比如刷屏也要把 RX 通道配上否则 HAL 库的某些回调路径可能走不通。3.2 把 DMA 配置接入 RT-Thread BSP 的移植方法CubeMX 生成的HAL_SPI_MspInit里已经有 DMA 初始化代码此时它只是底层初始化。要让 RT-Thread 真正使用 DMA 收发需要在drv_spi.c里做三件事第一件事在 SPI 设备初始化时找到 DMA 通道对应的句柄。一般地drv_spi.c中维护一个struct stm32_spi结构体里面存了SPI_HandleTypeDef和两个 DMA 句柄struct stm32_spi { SPI_HandleTypeDef handle; DMA_HandleTypeDef dma_tx; DMA_HandleTypeDef dma_rx; struct rt_spi_configuration *cfg; rt_bool_t dma_enabled; };在驱动的初始化函数里把 CubeMX 中生成的变量关联进去并设置 DMA 中断优先级。注意 DMA 中断优先级至少比 RT-Thread 的调度器能容忍的临界区优先级低两档否则会出现中断嵌套导致的调度延迟不可控。第二件事在 transfer 回调里增加 DMA 分支。伪代码结构如下static rt_ssize_t spix_transfer(struct rt_spi_device *device, struct rt_spi_message *message) { struct stm32_spi *spi rt_container_of(device, struct stm32_spi, device); SPI_HandleTypeDef *hspi spi-handle; rt_base_t level; rt_size_t len message-length; if (len 0 || (message-send_buf NULL message-recv_buf NULL)) { return 0; } if (message-cs_take) rt_pin_write(device-config-cs_pin, PIN_LOW); if (spi-dma_enabled len DMA_THRESHOLD) { level rt_hw_interrupt_disable(); hspi-hdmatx spi-dma_tx; hspi-hdmarx spi-dma_rx; rt_hw_interrupt_enable(level); /* 启动 DMA 发送接收 */ if (HAL_SPI_TransmitReceive_DMA(hspi, send_buf, recv_buf, len) ! HAL_OK) { /* 错误处理 */ } else { /* 等待传输完成信号量 */ rt_sem_take(spi-dma_complete, rt_wait_for_ever); } } else { /* 小数据量走轮询或老逻辑 */ HAL_SPI_TransmitReceive(hspi, send_buf, recv_buf, len, HAL_MAX_DELAY); } if (message-cs_release) rt_pin_write(device-config-cs_pin, PIN_HIGH); return len; }DMA_THRESHOLD可以设成 32 或者 64 字节小于这个值直接走轮询更省事因为 DMA 本身的配置开销也需要几个微秒数据量太小不值得。第三件事在 DMA 中断回调里释放信号量。CubeMX 生成的 DMA 中断服务函数会调用HAL_SPI_TxRxCpltCallback。在这个回调里把等待中的信号量释放掉void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { struct stm32_spi *spi container_of(hspi, struct stm32_spi, handle); rt_sem_release(spi-dma_complete); }这里要特别注意这个回调是在中断上下文执行的。你不能在回调里做任何可能睡眠的操作只能释放信号量或者发事件标志。rt_sem_release安全吗它不会阻塞所以可以被用在中断上下文。但我遇到过一个问题如果 DMA 中断和某个临界区竞争信号量释放可能被延迟这会导致上层rt_sem_take超时。解决方案是给 DMA 中断设置一个合理的优先级别让它低于PendSV或SysTick的最坏情况干扰。3.3 应用层代码保持不变但你要理解消息的边界切换 DMA 后应用层不需要改动。仍然是struct rt_spi_device *spi_dev (struct rt_spi_device *)rt_device_find(spi10); struct rt_spi_configuration cfg; cfg.data_width 8; cfg.mode RT_SPI_MASTER | RT_SPI_MODE_0; cfg.max_hz 36 * 1000 * 1000; rt_spi_configure(spi_dev, cfg); rt_spi_transfer(spi_dev, send_buf, recv_buf, sizeof(send_buf));但这里有一个容易被误用的地方rt_spi_transfer内部会把一次传参包装成rt_spi_message并调用rt_spi_transfer_message。如果发送缓冲区的指针不是 4 字节对齐的某些 DMA 控制器会抛错或产生未定义行为。所以DMA 模式下发送和接收缓冲区的内存地址最好按 4 字节对齐。如果你用rt_malloc申请的内存RT-Thread 默认是 8 字节对齐问题不大如果是局部数组最后手动做一次对齐rt_align(4) static rt_uint8_t send_buf[512];这个细节看着不起眼但在 DMA 随机失败、偶发乱码时常常就是它惹的祸。4. 实测踩过的坑乱码、卡死、缓存一致性和片选时序这一部分是重点。这一套 DMA 方案不是一次就通了的我前前后后调了两天遇到四个让人印象特别深的问题。每个问题我尽量还原完整的排查链路而不是只给答案。4.1 偶发乱码不是 SPI 速率的问题是 DMA 和 CPU 争抢总线现象SPI 速率设到 18MHz数据量一大就偶尔出现零星乱码看起来像时序问题但把速率降到 4.5MHz 依然没有彻底消失。排查过程先用逻辑分析仪抓 SPI 引脚看 CLK、MOSI、MISO 的波形。结果波形干净没发现毛刺或跳变。怀疑是 SPI 从设备的问题换了一块从设备现象依旧。仔细读 RM0008 参考手册发现这套 MCU 的 DMA 和 CPU 共享总线矩阵当 DMA 优先级与 CPU 访问发生竞争时可能导致数据总线仲裁延迟。这种延迟在某些情况下会对 SPI 的发送时序产生微小扰动。尝试把 DMA 优先级从 High 改为 Very High同时把 SPI 的 TX/RX DMA 请求的优先级设置为最高。重新测试乱码概率从每小时几次降到几小时一次。继续追查发现真正的原因是系统里其他模块也在使用 DMA 通道比如串口 DMA多个 DMA 通道争抢总线。给 SPI 分配独立的 DMA 控制器比如 DMA1 给串口、DMA2 给 SPI彻底解决。结论DMA 模式下出现偶发乱码不要第一时间怀疑 SPI 速率。先看 DMA 通道是否和其他外设冲突再看 DMA 优先级配置。有时候一个__HAL_LINKDMA的顺序也会影响 DMA 通道选择。4.2 卡死问题DMA 中断丢失导致线程永久阻塞现象程序运行十几分钟后某个 SPI 任务卡住rt_sem_take一直等不到信号量。排查过程打开 RT-Thread 的RT_USING_SEMAPHORE调试信息通过list_sem发现spi.dma_complete的信号量计数值为 0说明确实没有释放。怀疑 DMA 中断没有进来。跟踪HAL_SPI_TxRxCpltCallback在回调里加了 GPIO 翻转测试发现有时候中断确实没有调用。查看 DMA 中断向量是否正常挂在向量表上。发现 CubeMX 生成的 DMA1_Channel2_IRQHandler 和 DMA1_Channel3_IRQHandler 没有在 RT-Thread 的board.c中注册。也就是说中断函数存在但因为没注册到中断控制器DMA 传输完成后 CPU 根本不跳转进去。添加中断注册代码后卡死问题消失。类似的问题在stm32f1xx_hal_msp.c和drv_spi.c集成时特别容易发生。因为 CubeMX 生成的中断处理函数和 RT-Thread 的 BSP 中断管理机制需要手动打通。4.3 带上 D-Cache 的芯片缓存一致性必须自己处理如果你用的是 H7 或者带 D-Cache 的 M7 内核DMA 和 Cache 之间的数据一致性问题会非常隐蔽。现象同样的代码在 F103 上跑得好好的移植到 H743 上后SPI 发送的数据偶尔是旧数据接收的数据也可能是错乱的。原因DMA 传输时绕过 CPU Cache直接从内存总线读写物理内存。如果发送缓冲区的内容还躺在 D-Cache 里没写回物理内存DMA 搬出去的就是老数据。反过来如果接收缓冲区已经被 CPU 预取过DMA 写入了新数据而 CPU 读的是 Cache 里的旧值。解决在启动 DMA 传输前需要对发送缓冲区做SCB_CleanDCache在 DMA 接收完成后对接收缓冲区做SCB_InvalidateDCache。RT-Thread 的drv_spi.c如果支持 H7通常已经有相关宏定义但不同的 BSP 模板处理方式不一样。我当时是直接在spix_transfer里加了这样一段#if defined(SOC_SERIES_STM32H7) if (message-send_buf) { SCB_CleanDCache_by_Addr((uint32_t *)message-send_buf, len); } if (message-recv_buf) { SCB_InvalidateDCache_by_Addr((uint32_t *)message-recv_buf, len); } #endif这段代码加得越靠近 DMA 启动越好。不要放在传输前的其他配置之前太远否则中间有任何 Cache 操作都可能导致行失效。4.4 软件片选在 DMA 下最常见的时序坑这可能是所有问题里最阴魂不散的一个片选拉低后DMA 还没有真正开始搬运数据从设备已经打开听了但 MOSI 线上还是老电平从设备就采样到了垃圾值。这个坑在网络热词里也有很高关注度说明踩的人不少。排查链路是这样的用示波器同时抓 CS 和 MOSI。发现 CS 拉低到 MOSI 第一个有效字节之间有一段毛刺或白电平。看代码rt_pin_write(cs_pin, PIN_LOW)之后立刻启动了HAL_SPI_TransmitReceive_DMA。但 DMA 启动本身有延迟SPI 外设开始产生时钟也有延迟。两个延迟叠加CS 被提前拉低数据线上没东西。进一步检查发现SPI 的 FIFO 在 DMA 模式下默认开启FIFO 阈值如果比较大比如 8 字节芯片会攒够数据才开始往外发这进一步加大了 CS 和数据的间隔。正确做法在拉低 CS 之前先把 DMA 配置好并让 SPI 处于 ready 状态但先不要让 SPI 产生时钟然后在cs_take之后立即触发 DMA 启动。具体到 HAL 库代码顺序应该是/* 第一步配置好 DMA 通道和 SPI但先不发送 */ __HAL_LINKDMA(hspi, hdmatx, spi-dma_tx); __HAL_LINKDMA(hspi, hdmarx, spi-dma_rx); /* 第二步拉低 CS */ rt_pin_write(device-config-cs_pin, PIN_LOW); /* 第三步启动 DMA 传输 */ HAL_SPI_TransmitReceive_DMA(hspi, send_buf, recv_buf, len);但这样做还不够稳因为HAL_SPI_TransmitReceive_DMA内部不只是启动 DMA它还会做状态判断、标志位清零、SPI 使能等操作。如果你把 DMA 启动放在 CS 之后这些内部操作仍然会引入微秒级延迟。最稳的方案在 CS 拉低前先把 SPI 外设使能和数据流配置好拉低 CS 后只启动 DMA 请求。可以把 HAL 库的启动函数拆细。比如使用寄存器操作或者用 HAL 库里的HAL_SPI_DMAStart这类内部函数注意它在不同 HAL 版本里名称可能不同。当然如果你使用的是硬件 NSS 并且从设备能够容忍一点片选提前量那问题会好很多。但既然前面建议用软件片选这层时序控制就得你自己负责。5. DMA 到底带来了多少收益实测对比与适用场景判断说了这么多原理和坑最后用实测数据来收个尾顺便聊聊什么时候不值得用 DMA。5.1 轮询、中断、DMA 三者的实测对比下面这组数据来自我的一次测试。平台是 STM32F103 72MHzSPI1 18MHz传输 4KB 数据用 DWT 计数器统计 CPU 在处理传输上的占用时间。传输方式理论传输时间CPU 介入时间CPU 占用率实测是否符合预期轮询约 1.8ms约 1.8ms100%符合中断约 1.8ms约 0.8ms约 44%略高主要浪费时间在入栈出栈DMA约 1.8ms约 20us约 1.1%符合预期这个 CPU 介入时间不仅包含传输过程中忙等待的时间还包括配置阶段和中断响应阶段。从系统整体表现来看DMA 切换后 RT-Thread 里其他任务的调度最差延迟从 2ms 左右降到了 50us 以下体感是质变。5.2 什么情况下不值得用 DMADMA 不是银弹。如果你遇到的是下面的场景别硬上单次传输只有几个字节比如读一个温度寄存器、写一个配置寄存器。此时 DMA 的配置开销和中断唤醒开销远高于直接轮询。我的经验阈值是 32 字节以下走轮询32 到 128 字节可以看心情128 字节以上才坚定走 DMA。从设备的 SPI 时钟极低比如几百 kHz传输本身不占用太多 CPU总线也完全能支撑。此时为了节省一点点 CPU却付出 DMA 通道和复杂排障的成本不划算。DMA 通道资源紧张如果系统里串口、ADC、定时器都在抢 DMA 通道SPI 加进来可能会导致其他外设无法工作。这时候优先保证紧急模块的 DMA 需求。5.3 针对这套方案的检查清单最后给出一份我在项目里使用的自查清单每次换板子或者换芯片型号时我都会照着过一遍CubeMX 中是否已经为 SPI 添加了 TX 和 RX 两个 DMA 请求DMA 中断是否已经注册到 RT-Thread 的中断管理机制里DMA 中断优先级是否配置合理发送/接收缓冲区是否满足 4 字节对齐如果是带 D-Cache 的芯片是否已经处理缓存一致性问题CS 拉低和 DMA 启动之间的时序是否符合从设备的数据手册要求小数据量是否已经绕过 DMA 分支避免无谓开销多个 DMA 通道争抢总线时是否已经为 SPI 分配了独立的 DMA 控制器根据我个人经验大部分 SPIDMA 调不通的问题都出在第 2 项和第 6 项上。前者是集成遗漏后者是硬件时序理解不到位。任何一个都可以让你白加好几天班。另外如果你用的 RT-Thread 版本较老drv_spi.c的代码结构和现在的版本差异会比较大移植 DMA 时不要照抄先看一下它的spi_ops里transfer函数的具体写法再决定怎么嵌 DMA 分支。毕竟框架是骨架驱动才是肉。骨架对了肉怎么长都行骨架错了肉再厚也白搭。
返回列表