做从站通信的活儿,最容易被坑的就是SPI Slave模式。主设备那边时钟、片选、数据方向全在你的掌控里,写起来非常顺手;一旦切成Slave,时钟由对方给、片选由对方拉、你根本不知道数据什么时候来,这时候如果不用DMA,MCU基本上就是在空转等数据。这篇文章围绕SPI1从机模式配合DMA接收主机下发数据这件事,把配置思路、代码骨架和我在实测中踩过的几个坑一次性讲清楚。适合已经跑通过SPI Master模式、想切换到Slave模式,或者正在被Slave模式丢数据、错位问题折磨的开发者参考。
1. 整体思路拆解:为什么SPI从机挂DMA是必然选择
1.1 从机模式数据时序的特殊性
SPI从机模式下,通信的主动权完全在主设备一侧。主设备决定SCK何时翻转、翻转多快、片选什么时候拉低、什么时候释放。从机唯一能做的,就是把自己的接收通道提前准备好,等着外部时钟把数据推进来。
这句话看着简单,实际操作中很多问题都出在这里。你如果写一个while循环去等待RXNE标志位,主机时钟一快,数据就追着你跑,反应稍微慢一点,RXNE还没被清掉,下一个字节已经落进来了,直接触发溢出错误(OVR)。更麻烦的是,SPI从机没有属于自己的“数据到达预告机制”——它不像UART那样有起始位可以自动对齐字节边界,也不像I2C那样有地址匹配可以知道“这数据是发给我的”。只要时钟一荡,SPI的移位寄存器就开始工作了,数据到底是从哪个bit边界开始对齐,从机自己说了不算。
所以,Slave模式下最核心的工程问题不是“怎么读取数据”,而是“如何在不可预知的时刻把数据及时收走”。这个问题靠普通中断轮询很难优雅解决,几乎只能交给DMA去处理。
1.2 轮询与中断的局限性
我简单算一笔账。假设主设备用18MHz的SCK跑SPI通信(这是F1系列从机模式的典型上限),一个字节8个bit,两个字节之间的间隔非常短,理论上每秒可以传2.25MB的数据。折算下来,一个字节的搬运时间大约是0.44微秒。在72MHz主频的STM32F103上,一次普通中断光进入、压栈、执行完回调、退出,怎么也得几百个周期,等CPU反应过来,下一个字节早就到了。
这不是夸张,我自己做过实测。用中断方式接收16字节的数据帧,主机跑10MHz SCK,CPU几乎全程被占满,稍有一点优先级调整不当或者别的模块抢时间,帧尾就会出现错位。而且SPI不像UART那样有FIFO缓冲,一个数据寄存器(DR)空转的时间窗口非常窄。
DMA方案的价值就在这里:它不经过CPU,直接在外设数据寄存器和内存之间搬运数据。主机时钟过来一个bit,SPI模块拼好一个字节后触发DMA请求,DMA在几个总线周期内就把DR里的字节搬到内存缓冲区。CPU只在整帧传输完成之后收到一次中断,做一次数据解析即可。
1.3 用SPI1做从机时的工程考虑
为什么很多项目选SPI1而不是SPI2、SPI3?主要是因为SPI1挂载在APB2总线上,外设时钟频率更高。以F103为例,APB1上的SPI2最高只能跑36MHz的输入时钟,而SPI1的输入时钟可以达到72MHz,即使在从机模式下,对高速主设备留下的余量也更大一些。
另外一个实际原因是引脚位置。SPI1的默认引脚PA4/PA5/PA6/PA7与很多调试接口、普通GPIO复用不冲突,在板卡上容易布线。而且STM32的DMA映射里,SPI1_RX对应DMA1的通道2,SPI1_TX对应DMA1的通道3,映射关系固定,用CubeMX配置时不容易误选。
选SPI1还有一个隐藏好处:它和USART1一样,都在APB2这条高速总线上。如果后续项目需要同时使用DMA搬运串口数据、SPI数据,DMA通道分配和优先级规划会轻松很多。
2. 硬件连接与CubeMX关键配置
2.1 引脚连接:从机视角的收发方向
一提到SPI就有人搞混方向。在Master模式下,你的MOSI是输出,MISO是输入;在Slave模式下这两个方向会反过来。具体说,从机的MOSI引脚仍然是数据输入口,它接收主设备MOSI发送过来的数据;从机的MISO引脚则是数据输出口,往主设备的MISO方向回传数据。连接时各SPI引脚同名相连:主设备SCK接从机SCK,主设备MOSI接从机MOSI,主设备MISO接从机MISO,主设备NSS接从机NSS。
我在实际调试中见过不止一次这样的情况:有人把主设备的MOSI接到了从机的MISO上,结果数据全乱,查了半天都找不到原因。SPI本质上是一个环形移位结构,主设备通过MOSI把数据写入从机的移位寄存器,同时从机通过MISO把自身移位寄存器里的旧数据送出。你要是把收发交叉了,环就断了。
从机模式下NSS引脚的处理也很关键。NSS低电平时从机被选中,高电平时从机释放总线。如果用的是硬件NSS模式,建议在NSS引脚上加一个10kΩ上拉电阻,防止主设备未驱动时引脚浮空导致从机状态错乱。如果主机那边是通过GPIO模拟片选,建议主机用开漏输出配合上拉,这样释放片选时电平整定。
2.2 SPI1参数配置与NSS模式选择
CubeMX里把SPI1的Mode选成Slave,然后往下看参数:
- Data Size:8 Bits,最常用的配置
- First Bit:MSB First
- Clock Polarity(CPOL)和Clock Phase(CPHA):必须和主设备保持一致
- NSS类型:这里有两个选择,Hardware NSS还是Software NSS
NSS这件事值得单独说。Hardware NSS模式下,从机的片选完全由SPI外设内部逻辑接管,主设备把NSS拉低后,从机才会在SCK边沿采集数据。听起来很合理,但实际用起来牵扯一个“MODF”错误和NSS引脚的边沿检测问题,尤其是主设备片选信号边沿不干净时,从机SPI状态机很容易异常。
所以我更推荐Software NSS配合外部GPIO中断的做法。在CubeMX中将NSS设为Software,同时把原来的NSS引脚单独配置成普通输入GPIO,用来捕捉主设备片选的下降沿和上升沿。这样做的优势是:片选信号的解释权完全交给了应用程序,你可以精确知道一帧数据的起始和结束位置,DMA的启动时机也就完全可控了。
CPOL和CPHA的匹配没有商量余地,必须和主机完全一致。MODE0(CPOL=0,CPHA=1Edge)和MODE3(CPOL=1,CPHA=2Edge)是最常见的组合。在CubeMX里对应的就是Clock Polarity选Low或High,Clock Phase选1 Edge或2 Edge。如果这两个参数跟主机不一致,数据会明显错位:看起来每一帧都有数据,但内容永远是乱的,而且错位方式非常规律——比如字节内bit整体右移一位。
2.3 DMA配置与中断映射
CubeMX的DMA Settings页签里,为SPI1_RX添加一个DMA请求。以F103为例,系统会默认映射到DMA1的Channel2。关键配置项:
- Direction:Peripheral To Memory(外设到内存)
- Priority:High或Very High,根据系统实时性要求选
- Mode:Normal(固定长度帧)或者Circular(环形缓冲)
- Peripheral Increment:Disable
- Memory Increment:Enable
- Peripheral Data Width:Byte
- Memory Data Width:Byte
数据宽度这里我用Byte而不是Word。虽然SPI的DR寄存器是16位宽,但8位数据模式下有效的只有低8位,Byte宽度下的DMA传输效率更高,而且缓冲区管理更直观。
中断方面需要打开DMA1 Channel2的全局中断(DMA1_Channel2_IRQn),用来接收传输完成的通知。同时SPI1自己的全局中断(SPI1_IRQn)也建议打开,但不是为了数据接收,而是为了捕获SPI错误中断,比如溢出错误、模式错误。HAL库的HAL_SPI_IRQHandler会自动处理这些错误标志位,如果不打开,一旦出现OVR错误,后续的数据接收就卡死了,而你在调试器里看程序,却怎么都找不到卡住的原因。
3. 代码实现:从固定长度接收到不定长帧
3.1 固定长度DMA接收:最稳的起步方案
对于一个首次接SPI从机+DMA的方案,我强烈建议第一版先从固定长度接收入手。原因很简单:DMA传输完成中断对应着明确的数据边界,回调函数里直接处理数据,逻辑清晰,出问题时也容易排查。
CubeMX生成工程后,核心代码其实非常少。
#define FRAME_LEN 8 uint8_t g_rx_buf[FRAME_LEN]; volatile uint8_t g_frame_ready = 0; /* 启动SPI1 DMA接收 */ void SPI1_RX_DMA_Start(void) { HAL_SPI_Receive_DMA(&hspi1, g_rx_buf, FRAME_LEN); } /* DMA传输完成回调 */ void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { g_frame_ready = 1; /* 这里先只置标志位,不要做协议解析 */ } }主循环里的处理逻辑:
while (1) { if (g_frame_ready) { g_frame_ready = 0; /* 解析处理 g_rx_buf 中的数据 */ /* 处理完毕后再启动下一次接收 */ SPI1_RX_DMA_Start(); } }这段代码在工作频率低、帧间隔长的场景下足够用。但必须注意一个问题:从HAL_SPI_RxCpltCallback执行完到主循环处理完数据并重新调用SPI1_RX_DMA_Start,这中间存在着一段“接收空窗期”。如果主机在此期间又发来新的一帧,从机SPI没有DMA接管,数据会全部堆在DR寄存器里然后被下一个字节冲掉,最终触发OVR错误。
所以,固定长度单缓冲方案有个隐含前提:主机每发一帧后必须等待从机处理完,才能发下一帧。这在很多协议设计中并不成立。
3.2 乒乓缓冲:避免主机连续发帧丢数
为了解决上面的问题,最简单的做法就是上双缓冲,俗称乒乓缓冲。两个缓冲区轮流供DMA使用:DMA正在往里写A缓冲区时,CPU可以安心处理B缓冲区的数据;等下一帧到来,DMA切到B缓冲区,CPU再处理A缓冲区。从机的DMA接收通道从头到尾没有关闭过,主机连续发多少帧都不怕。
#define FRAME_LEN 8 uint8_t g_rx_buf[2][FRAME_LEN]; uint8_t g_rx_idx = 0; volatile uint8_t g_ready_idx = 0xFF; void SPI1_RX_DMA_Start(void) { HAL_SPI_Receive_DMA(&hspi1, g_rx_buf[g_rx_idx], FRAME_LEN); } void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { g_ready_idx = g_rx_idx; g_rx_idx ^= 1; /* 切换到另一个缓冲区 */ SPI1_RX_DMA_Start(); /* 立刻挂上下一次接收 */ } }回调里直接重新调用HAL_SPI_Receive_DMA是安全的,因为这个回调本身就是在DMA传输完成中断里触发的,前一次DMA已经结束,可以马上重新配置。这一点和UART的DMA接收还不一样,UART那边经常需要在空闲中断里判断一帧是否结束,而SPI从机只需要关注传输长度。
主循环侧只需要处理g_ready_idx指向的缓冲区:
while (1) { if (g_ready_idx != 0xFF) { uint8_t idx = g_ready_idx; g_ready_idx = 0xFF; /* 处理 g_rx_buf[idx] 这一帧 */ } }这个结构可以持续稳定接收,不需要担心DMA空窗期。缓冲区数量也不需要超过2个,两片轮换已经足够覆盖CPU处理时间。
3.3 不定长帧的做法:NSS边沿+DMA
固定长度方案有个明显的限制:主机要发给从机的数据不一定每个帧都一样长。比如协议里有短指令也有长数据块,这种时候如果还固定长度接收,要么缓冲区留得很长导致响应不及时,要么长度不够直接截断。
我的做法是用GPIO外部中断配合DMA。把NSS引脚配置成外部中断输入,下降沿代表主机开始通信,上升沿代表一帧结束。下降沿触发时,启动DMA接收;上升沿触发时,停止DMA并统计收到了多少字节。
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == NSS_PIN) { if (HAL_GPIO_ReadPin(NSS_GPIO_Port, NSS_PIN) == GPIO_PIN_RESET) { /* 片选拉低:主机开始发数据,启动DMA */ HAL_SPI_Receive_DMA(&hspi1, g_rx_buf, MAX_FRAME_LEN); } else { /* 片选拉高:主机释放总线,停止DMA并统计长度 */ uint32_t remaining = __HAL_DMA_GET_COUNTER(&hdma_spi1_rx); g_rx_len = MAX_FRAME_LEN - remaining; HAL_SPI_DMAStop(&hspi1); g_frame_ready = 1; } } }这里用了一个HAL库里不那么起眼的宏:__HAL_DMA_GET_COUNTER。它读取的是DMA当前剩余传输字节数,用最大长度减去剩余值,就得到了实际接收的长度。这个宏是我调试不定长接收时的救星,很多从事嵌入式开发的人用了很多年HAL库都不知道有这个用法。
需要注意,NSS上升沿到来的时候,SPI的移位寄存器里可能还有最后一个字节没有被搬进内存,这是DMA计数器和SPI状态寄存器之间天然存在的时序差。所以这个方案更适合数据块较大、最后几个字节允许通过长度字段自行截断的场景。如果每帧数据的最后一个字节特别关键,可以在DMA停止后多读一次SPI的数据寄存器,把残余的字节补回来。
3.4 环形DMA缓冲区的高阶玩法
如果主机不拉片选,而是长期保持通信状态、仅仅在SCK线上传送数据流,那上面所有基于片选边沿的方案都失效。最后一个方案:DMA循环模式(Circular),配合软件主动维护的读索引。
把DMA的Mode设为Circular,这样DMA会持续把SPI来的数据写入内存缓冲区。每当DMA循环写完一整圈,传输完成中断会触发一次,你可以在中断里记录当前DMA写到了缓冲区哪个位置。应用程序则在任意时刻读取DMA的CNDTR寄存器,对照上一次记录的写位置,计算出新数据落在缓冲区里的区间并去消费。
这个方案我一般不建议新手直接用,因为它把“帧”的概念完全抹掉了,所有数据都变成了一根连续的数据流。如果主机的通信协议本身不是面向流的,比如要求按指令-应答方式交互,环形缓冲容易把两条指令的数据混在一起。但它对高吞吐、连续数据的场景是最优解,CPU占用极低,缓冲区利用率也高。
4. 实测问题与排查技巧
4.1 常见故障速查表
我把这些年SPI从机+DMA的调试问题整理成了一张速查表,排查时可以直接从表格对照:
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| DMA接收回调永远不进入 | DMA通道映射错误或SPI1_RX没有使能 | 核对CubeMX生成的DMA映射,确认DMA1 Channel2已挂到SPI1_RX |
| 数据整体错位、内容乱 | CPOL/CPHA与主机不一致 | 示波器同时抓SCK和MOSI,比对采样边沿 |
| 第一个字节丢失或帧内少字节 | DMA启动晚于主机时钟到来 | 改为上电即挂DMA,或者用NSS下降沿提前启动 |
| 连续发多帧后从机停止接收 | SPI溢出错误OVR没有被清除 | 打开SPI1全局中断,或在启动DMA前读取并清SR |
| 接收数据出现0x00夹杂 | DMA数据宽度与外设数据宽度不匹配 | 确认Peripheral Data Width为Byte |
| NSS引脚浮空导致状态异常 | 硬件NSS模式下引脚没有上拉 | 使用软件NSS+GPIO中断,或给NSS加10kΩ上拉 |
| 从机SPI一直处于忙状态 | 收到错误帧后SPI总线被挂起 | 调用HAL_SPI_Abort后再重新初始化 |
4.2 踩坑实录:一次真实调试记录
第一次调试SPI从机时,我用的是单缓冲+固定长度的简陋方案。主机用10MHz SCK每10ms发一帧8字节数据,看似时间很充裕,但我在回调里做了很多解析工作,导致整个主循环被数据处理拖慢,结果就是主机连续两帧间隔稍短时,从机的DMA还没重新挂上,第二帧数据就丢了。
后来我把回调里的解析工作全部搬到了主循环,回调里只做缓冲区索引切换,而且马上调用下一次HAL_SPI_Receive_DMA。实测下来,哪怕主机以最高频率连发几十帧,从机也能一帧不落。这个经验告诉我:回调函数瘦身带来的性能提升,比你想象中要大得多。
另一次调试中遇到的现象是:从机单独通信时一切正常,但只要主设备那边重新上电,从机的第一次接收就是空数据。查了半天才发现,主机上电后会先拉高片选,再从机侧NSS电平抖动触发了一次下降沿中断,DMA提前启动并接收了一堆垃圾。因为在软件NSS方案中,NSS边沿中断和SPI数据线之间是独立的,主机上电瞬间的GPIO状态不稳定,很容易产生虚假的下降沿。
解决办法是在NSS中断里加了一个软件去抖:检测到下降沿后,延时一小段时间(比如几十微秒)再检查NSS引脚电平,如果确实为低才启动DMA。这样虽然牺牲了一点响应速度,但换来了稳定性。
4.3 排查SPI从机问题时的必查项
综合所有踩坑经验,SPI从机+DMA出问题时,我习惯按这个顺序排查:
先看波形。SCK、MOSI、NSS三条信号线用示波器抓下来,确认主机确实发了数据,并且CPOL/CPHA边沿符合预期。大部分错位问题的根源是波形和配置对不上,肉眼一看就知道。
再看DMA配置。在调试器里查看hdma_spi1_rx结构体,重点看Init.Direction、Init.Mode、Init.PeriphDataAlignment、Init.MemDataAlignment四个字段。很多人数据传输宽度配错,Byte配成Word,DMA一次搬4字节,数据全串了。
再看SPI状态。读SPI1->SR寄存器,如果OVR标志已经被置位,说明之前发生过溢出,必须先清掉这个标志才能继续接收。这个标志位非常容易被忽略,但触发后SPI外设的DMA请求会被阻塞,数据就再也进不来了。
5. 性能优化与稳定性建议
5.1 中断优先级和回调精简
一个容易被忽略的细节是中断优先级。DMA传输完成中断的优先级不能太低。SPI从机的数据特征是突发性强,一帧几字节在一个很短的时间窗内到来,DMA需要在完成一帧后立刻重新准备好接收下一帧。如果优先级太低,等别的中断处理完再回来,主机可能又发了下一帧。我的经验是给DMA通道中断分配一个较高优先级,比系统滴答定时器高、比普通外设中断高即可。
再就是回调精简,这是老生常谈但真没几个人做到。HAL_SPI_RxCpltCallback里只允许做两件事:切换缓冲区和记录状态。所有的协议解析、数据校验、业务逻辑全部放主循环。
5.2 从机模式频率上限与分频设计
SPI从机模式能跑多快,很多人不知道是有限制的。不是说主机给多少SCK从机就能吃下多少SCK。STM32F103的SPI从机模式下,SCK频率上限比主控自身的APB时钟要低不少,具体数值要参考数据手册。以F103为例,从机模式下SCK最高大约18MHz,超过这个值数据可能出现采样不稳。
如果从机本身的主频不高,比如跑到48MHz、36MHz,这个上限还得往下调。所以我一般建议从机侧的SPI接收不要试图去吃满主机的最大SCK速度,宁可让主机侧把SPI时钟分频降低一点,换稳定。嵌入式项目第一追求永远是确定性,而不是跑满理论速率。
5.3 验证方法
SPI从机+DMA的调试方式比主机模式要麻烦一点,因为你不容易控制主机什么时候发数据。我推荐两种验证手段。
第一种,用一个固定的虚拟主机协议:每次上电后主机连续发送递增计数的字节流,从机DMA收到后校验数据是否连续。如果现场有逻辑分析仪,直接挂在SCK和MOSI上,观察数据流是否连续,同时看从机回传是否正常。
第二种,在从机代码里增加一个“接收计数”变量:每次DMA回调时将全局计数加一,用空闲GPIO翻转电平输出到示波器。这样即使不打断程序,也能直观看到接收频率和稳定性。这个做法在长期跑机测试时特别好用。
6. 一些个人经验
如果只让我总结一句话:SPI从机+DMA接收这件事,难点不在DMA本身,而在“从机怎么知道主机什么时候发数据”以及“接收完成后如何无缝衔接下一次接收”。把这两个问题解决好,整个方案就立住了。
具体操作上,我个人最推荐的组合是软件NSS配合GPIO边沿中断,加上乒乓缓冲或双缓冲区。这个组合能覆盖绝大多数主从机通信场景,无论是固定帧长还是不固定帧长,调试起来都有明确的边界信号。
另外建议第一次做SPI从机的朋友不要急着上环形缓冲,先老老实实跑通固定长度接收。先把一条最简单的数据流打通,确认DMA方向、数据宽度、中断回调都没问题,再逐步加上片选边沿检测、不定长解析、缓冲区切换这些复杂度。每一步都留一个可验证的中间状态,排查问题会轻松很多。