ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

RT-Thread SPI+DMA驱动开发全解析:关键代码与避坑指南

RT-Thread SPI+DMA驱动开发全解析:关键代码与避坑指南 写嵌入式驱动这些年RT-Thread上手过不少外设SPIDMA这个组合真是典型的“看起来简单用起来扎心”。SPI协议本身不难无非是SCK、MOSI、MISO、CS几条线按时序折腾可一旦把时钟拉到几十兆、数据量上到几十KB再让CPU一个字节一个字节去搬效率就完全没法看了。这篇文章我把在RT-Thread上把SPIDMA跑通的全过程、关键代码和踩过的坑一次性讲透。对于正准备从轮询模式转到DMA模式、或者刚开始在RT-Thread上做高速数据采集和存储的工程师这篇应该能帮你少走不少弯路。1. RT-Thread的SPI设备框架与DMA的底层联动逻辑1.1 SPI设备框架是怎么分层的先说点背景不然下面代码看得懵。RT-Thread的设备框架把SPI拆成了两层rt_spi_bus和rt_spi_device。简单理解bus就是硬件SPI外设本身device就是挂在这条总线上的从设备。我们在应用层拿着rt_spi_device的指针调rt_spi_transfer_message框架层会顺着rt_spi_ops里的函数指针把请求下发给BSP里的驱动。这套分层的好处在于业务代码可以完全不关心底层用的是轮询、中断还是DMA。驱动层需要实现的回调主要是两个configure和xfer。configure负责设置波特率、数据位宽、模式这些参数xfer负责真正搬数据。DMA要接入核心就是在xfer这个回调里做文章。我在旧版本RT-Thread上还见过直接把SPI驱动写在application里的骚操作短平快没问题但代码没法复用后面项目一多就要返工。正经做法还是走设备框架驱动里预留DMA接口应用层通过统一的API调用。1.2 DMA在RT-Thread里是怎么被抽象出来的RT-Thread从v5.0开始有了一套DMA设备框架但老实说在SPI这类外设强相关场景里DMA请求线跟外设是绑死的抽象收益并不大。我在实际项目里更倾向于在SPI驱动内部直接调HAL库的DMA接口这样代码路径最短调试也最直接。DMA传输的完成通知通常用rt_completion这个信号量机制。调用HAL_SPI_TransmitReceive_DMA之后线程不是死等而是挂起在rt_completion_wait上。等DMA传输完成中断回调里调用rt_completion_done把线程唤醒。这一套机制在RT-Thread上很成熟几乎没有额外开销。提示如果用的是RT-Thread v5.x可以关注一下dma-engine框架的进度但目前BSP适配参差不齐项目里求稳直接在驱动层操作HAL的DMA API更可靠。2. 动手前先想清楚几个关键设计决策2.1 硬件片选还是软件片选和DMA配合时差别很大SPI片选有硬件NSS和软件GPIO两种控制方式。普通轮询模式下软件片选完全够用发送前把CS拉低发送完拉高毫秒级的时间粒度不会出问题。但上了DMA之后数据传输由DMA控制器独立搬运CPU没法在精确的时刻去翻转CS软件片选很容易出现CS还没拉低、DMA已经开始发数据的情况从设备看到一堆乱时钟直接丢数据。我建议在DMA模式下优先考虑硬件片选让外设自己管理NSS信号。STM32的SPI可以配置为硬件NSS输出模式片选时序由外设保证和DMA请求配合得很默契。如果硬件上实在没法用NSS那就保留软件片选但片选的控制必须在rt_spi_message的cs_take和cs_release阶段处理好而不是在DMA回调里临时抱佛脚。RT-Thread的消息结构体里自带cs_take和cs_release字段驱动在开始传输前会拿这两个字段判断要不要拉CS。软件片选方案下务必保证先拉CS再启动DMA传输完成后等DMA完全停止再拉高CS。顺序反了低速外设可能还看不出问题高速Flash和SD卡一抓一个准。2.2 TX和RX各用一路DMA还是共用一路STM32的SPI外设有独立的发送DMA请求和接收DMA请求可以分别映射到不同DMA控制器、不同通道上。全双工场景比如读写W25Q64这类Flash时主机要同时发命令和收数据我建议TX和RX各分配一个DMA通道这样带宽利用率最高。那是不是所有场景都要两路DMA也不是。写Flash时我们先发写使能、页编程命令然后只往SPI的TX寄存器灌数据接收方向的数据是垃圾不需要保留。这时候只开TX DMA就够了节省一个通道也省去接收缓冲区的管理。但有一种情况我特别提醒一下用HAL_SPI_Transmit_DMA纯发送时硬件会同时产生接收数据这些数据虽然不读但RXFIFO如果满了会阻塞传输。所以纯发送场景要在驱动里把RX方向的数据及时清掉或者干脆用HAL_SPI_TransmitReceive_DMA把接收缓冲接到一个垃圾数组上省心得多。2.3 DMA连续请求在SPI场景下的取舍DMA连续请求模式简单说就是DMA控制器在传输期间持续向外设发出请求信号直到配置的传输计数归零。SPI主机模式下时钟由主机产生DMA必须持续不断地给外设供数否则时钟就会出现空洞从设备就懵了。所以SPI主机的DMA通常要工作在连续请求模式保证一次传输中数据流是连续的。有一些DMA控制器还支持突发burst模式每次请求搬运多个数据。对SPI来说因为硬件FIFO容量有限突发长度太长反而容易造成FIFO溢出或下溢。我在实际项目里一般把突发长度配置为4个或8个数据单元再大的收益不明显反而增加调试难度。还有一个经验性结论数据量太小时没必要用DMA。我一般以32字节为界小于这个值轮询反而更快因为DMA初始化、中断响应也要花时间。这个阈值跟主频有关Cortex-M4 168MHz下32字节是一个比较合理的参考值。3. 在RT-Thread上把SPIDMA跑起来的完整实操3.1 环境准备CubeMX和RT-Thread Studio的配置联动我用的是STM32F407 RT-Thread Studio的开发环境。先打开STM32CubeMX把SPI1配成Master模式时钟分频根据实际外设决定然后切到DMA Settings页面为SPI1添加RX和TX两个DMA请求。DMA通道选择有一个原则RX优先用DMA2因为DMA2的通道映射对SPI的RX支持更好TX可以放在DMA1上。传输方向分别选PeripheralToMemory和MemoryToPeripheral模式选Normal外设地址和内存地址都设为增量模式数据宽度按SPI的数据位宽来8位数据位就是Byte。配完CubeMX后在RT-Thread Studio里重新生成代码注意rtconfig.h需要确认几个宏是否已经打开BSP_USING_SPI1以及对应DMA的使能宏。不同芯片BSP宏名称有差异F4系列一般在board.h里配置。文件里没有的话手动加上然后重新编译。3.2 驱动层改造把DMA能力挂到SPI驱动上改造drv_spi.c是核心想办法在stm32_spi_xfer回调里区分三种情况有发送有接收、只发送、只接收。下面是驱动层的关键代码片段static rt_err_t spi_dma_transfer(struct rt_spi_device *device, struct rt_spi_message *message) { struct stm32_spi_device *stm32_spi rt_container_of(device, struct stm32_spi_device, spi_device); SPI_HandleTypeDef *hspi stm32_spi-handle; rt_uint32_t len message-length; rt_uint8_t *send_buf (rt_uint8_t *)message-send_buf; rt_uint8_t *recv_buf (rt_uint8_t *)message-recv_buf; rt_completion_init(stm32_spi-completion); if (send_buf ! RT_NULL recv_buf ! RT_NULL) { /* 全双工收发同时进行 */ HAL_SPI_TransmitReceive_DMA(hspi, send_buf, recv_buf, len); } else if (send_buf ! RT_NULL) { /* 只发送接收数据丢弃到临时缓冲区 */ HAL_SPI_TransmitReceive_DMA(hspi, send_buf, spi_dummy_rx_buf, len); } else if (recv_buf ! RT_NULL) { /* 只接收发送端持续发送0xFF提供时钟 */ HAL_SPI_TransmitReceive_DMA(hspi, (uint8_t *)spi_dummy_tx_buf, recv_buf, len); } else { return RT_EINVAL; } /* 挂起当前线程等待DMA传输完成 */ rt_err_t ret rt_completion_wait(stm32_spi-completion, RT_WAITING_FOREVER); if (ret ! RT_EOK) { return RT_ERROR; } return RT_EOK; }注意代码里我刻意没有用HAL_SPI_Transmit_DMA和HAL_SPI_Receive_DMA单方向接口而是统一用TransmitReceive_DMA。这么做有两个原因一是规避了RXFIFO阻塞问题二是HAL库在全双工DMA模式下的状态机更稳定半双工模式在部分芯片上有状态残留的bug。DMA传输完成后的回调长这样void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi spi1_handle) { struct stm32_spi_device *stm32_spi stm32_spi_dev; rt_completion_done(stm32_spi-completion); } }记得在驱动初始化时对rt_completion做一次初始化否则第一次使用可能卡死在wait上。这一步容易被忽略我第一次移植时就栽在这里。3.3 应用层调用以W25Q64 SPI Flash为例完整跑通驱动层改完应用层的调用方式就和轮询模式一模一样了。先挂载设备再配置参数然后直接读写#include rtthread.h #include rtdevice.h #include spi_flash.h static int spi_flash_demo(void) { struct rt_spi_device *spi_flash_dev; struct rt_spi_configuration cfg; rt_uint8_t cmd[4] {0x9F, 0x00, 0x00, 0x00}; rt_uint8_t rx[4] {0}; /* 挂载flash设备到spi1总线上作为设备spi10 */ spi_flash_dev (struct rt_spi_device *)rt_device_find(spi10); if (!spi_flash_dev) { rt_kprintf(spi10 device not found!\n); return -RT_ERROR; } cfg.data_width 8; cfg.mode RT_SPI_MASTER | RT_SPI_MODE_0 | RT_SPI_MSB; cfg.max_hz 40 * 1000 * 1000; /* 40MHz */ rt_spi_configure(spi_flash_dev, cfg); /* 读JEDEC ID: 0xEF 0x40 0x18 0x00 */ struct rt_spi_message msg; msg.send_buf cmd; msg.recv_buf rx; msg.length 4; msg.cs_take RT_TRUE; msg.cs_release RT_TRUE; rt_spi_transfer_message(spi_flash_dev, msg); rt_kprintf(Flash ID: %02x %02x %02x %02x\n, rx[0], rx[1], rx[2], rx[3]); return RT_EOK; } INIT_APP_EXPORT(spi_flash_demo);这里有一个DMA场景下的硬性要求缓冲区必须是可写的内存。常量字符串和Flash常量区地址不能作为DMA缓冲区因为DMA可能读不到常量区的数据或者某些总线根本没映射。实际项目里我会定义RT_ALIGN宏对齐的全局缓冲区来放收发数据这样既满足对齐要求也方便Cache操作。我实测对比过在40MHz时钟下用DMA读4KB Flash数据CPU占用率几乎可以忽略DMA传输期间CPU去跑其他任务而轮询模式下一路死等CPU占用率接近100%。对实时性要求高的系统DMA基本是唯一选择。4. 实战中的坑与排查实录4.1 数据错位首字节不翼而飞现象读W25Q64的ID理论上应该收到0xEF 0x40 0x18结果总是0x00 0xEF 0x40第一个字节变成0x00后面整体错位。排查过程让人头大逻辑分析仪挂上去看MOSI信号完全正确MISO上第一个字节确实是0x00说明是接收端的问题。根子在SPI的时序特性发送命令字0x9F时主机时钟驱动数据发出但MISO线上对应的是上一次传输的残留数据Flash需要等到第二个字节的时钟才开始返回有效ID。轮询模式下我们发送完命令后再读天然规避了这个时序窗口但DMA模式下收发同时启动第一个字节就被采样成了垃圾。解决方式主要有两种一是像3.3节代码那样在发送命令时同时开始接收接收缓冲的第一个字节直接丢弃从第二个字节开始分析二是先发一个哑字节0x00作为前导时钟再发真正的命令。我习惯用第一种因为代码改动最小而且对DMA模式更友好。注意CS片选的释放时机也很关键。DMA传输完成后CS要立刻拉高如果CS释放晚了从设备可能把多出来的时钟当成有效传输导致状态机错乱。所以DMA回调里除了唤醒线程还要确保HAL库已经完成CS释放动作。4.2 Cache一致性DMA和CPU的数据对不上这是Cortex-M7和M33这类带D-Cache内核上特有的坑。现象很诡异第一次读写数据正常第二次从同一份缓冲区读出来的是旧数据或者读Flash读到一半校验失败。原因就是CPU在DMA搬完数据后读的是Cache里的旧数据而DMA已经把新数据写到了物理内存里。解决办法是在DMA传输开始前对发送缓冲区做Cache Clean操作确保DMA看到的是最新数据传输结束后对接收缓冲区做Cache Invalidate操作确保CPU读的是DMA写入后的数据。代码示例如下/* 发送前确保数据从Cache刷到内存 */ SCB_CleanDCache_by_Addr((uint32_t *)send_buf, len); /* DMA传输完成接收完成后 */ SCB_InvalidateDCache_by_Addr((uint32_t *)recv_buf, len);在RT-Thread上也可以用rt_hw_cpu_dcache_ops相关接口效果一样。另外DMA缓冲区尽量定义成全局数组用RT_ALIGN宏对齐到32字节这样Cache line对齐后操作更高效也避免跨越两个Cache line带来的复杂问题。4.3 中断优先级太低导致偶发丢数据另一个常见的坑是DMA中断被其他高频中断抢占。现象是跑demo一切正常跑到完整应用时偶发丢数据而且丢的数据量随机。排查这个问题的过程让我长了不少记性。问题本质是DMA传输完成后中断响应不及时导致SPI外设的FIFO数据在读取前被覆盖。DMA的搬运速度和FIFO深度有限中断响应越慢越容易溢出。解决方法是合理配置NVIC中断优先级。我的经验是DMA中断优先级配置为1或2数值越小优先级越高具体看芯片比系统Tick高但比最紧急的硬件错误中断低。这样既能保证DMA传输及时响应又不会把系统Tick饿死。同时中断回调里只做rt_completion_done这种轻量操作绝对不要放耗时处理耗时逻辑放在被唤醒的线程里做。4.4 DMA缓冲区越界和总线冲突还有一个容易踩的坑DMA缓冲区越界。DMA控制器不像CPU那样会触发内存保护异常它只会机械地按地址搬运如果缓冲区长度算错DMA就会越界写坏相邻内存而且编译期和运行期都不报错最后表现为莫名其妙的全局变量被篡改。我踩过一次特别深的坑在一个模块里定义了接收缓冲数组长度本来是512字节结果传参时把长度写成了sizeof(ptr)而不是sizeof(array)指针在64位环境下是8字节DMA就把数据写到别处去了。排查了两天才定位到过程极其痛苦。所以所有DMA缓冲区的长度参数我都建议用宏定义或者ARRAY_SIZE这类安全宏不要直接裸传数字。5. 还能这么玩SPIDMA的扩展场景5.1 高速采集场景SPI ADC配合双缓冲SPI接口的ADC芯片如AD7124、ADS1256数据率一高轮询读取很浪费CPU。配合DMA双缓冲机制可以实现连续采集不丢点。具体做法是申请两个大小相等的缓冲区DMA采集完第一块触发半传输中断应用处理第一块数据同时DMA继续往第二块搬数据。等第二块搬完触发传输完成中断应用程序处理第二块DMA又回头搬运第一块。两个缓冲区交替使用形成一个流水线。这个思路在电机控制、电网监测这类需要连续采样的场景非常实用RT-Thread的任务调度配合DMA双缓冲能达到数据采集和处理完全并行。5.2 协议转接芯片的大数据量吞吐SPI转CAN、SPI转以太网这类协议转换芯片比如MCP2515、W5500内部都有较大的收发FIFO。以W5500为例它的收发缓冲区高达16KB如果要用SPI从里面一次性读取一整个以太网帧数据量也有几百字节这时候DMA就很有意义。有人说CAN报文才8字节用DMA不是多此一举吗但别忘了如果是批量接收多帧CAN报文芯片内部的FIFO会把多帧数据连续拼接一次性读出来就是一串数据块DMA搬运整个数据块的效率和轮询逐字节读取完全不是一个级别。我做过一次实测批量读取32帧CAN数据DMA比轮询速度快了近20%CPU占用率更是从100%降到了20%以下。5.3 SPI模式的TF卡批量读写TF卡走SPI模式单次读写一个扇区就是512字节大文件读写时连续几十个扇区数据量轻松破几十KB。这种情况下DMA的价值体现在两个方面一是读卡期间CPU可以去做别的事比如处理FATFS的文件系统逻辑二是DMA的搬运效率本身比CPU逐字节快得多。不过TF卡对时序比较敏感尤其是初始化阶段主机要发送至少74个时钟周期的特定序列让卡进入SPI模式这段初始化过程我建议用普通轮询稳定可靠。等卡稳定工作在SPI模式之后再切换成DMA进行块数据传输。驱动层做好这个切换可以兼顾稳定性和效率。6. 写在最后的几句实在话我个人在实际项目中的体会是SPIDMA这件事难的不是把代码写出来而是把边界条件想清楚。轮询模式先跑通逻辑分析仪确认时序没问题再上DMA一步到位往往是在给自己埋雷。另外想分享一个小技巧调试DMA问题时用逻辑分析仪同时抓CS、SCK、MOSI、MISO四根线。看波形比看寄存器值直观多了尤其是CS和SCK的相对时序基本一眼就能定位问题。测到数据错乱时先把DMA模式退回到轮询模式对比一下往往能快速缩小排查范围。最后建议大家在工程里做好DMA缓冲区隔离管理。定义一个统一的DMA缓冲区池所有外设的DMA传输都用这个池子里的内存避免外设之间因为缓冲区地址冲突互相踩踏。这个经验是我在多个外设同时开DMA的项目里总结出来的前期设计多花十分钟后期省下几天的定位时间。
RELATED READING

延伸阅读

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