在嵌入式系统里,SPI 从机配合 DMA 读数据这个需求,比很多人想象中要常见得多。尤其是用 STM32 的 SPI1 做从机,单看标题似乎只是一个外设配置的事,但真正想把数据稳定、不丢字节、不卡死地读出来,里面涉及到 SPI 协议时序、DMA 通道映射、NSS 管理、中断优先级、缓冲区分块等一堆细节。我自己在几个项目里反复踩过这些坑,这篇文章就把整个从零配置到联调排查的过程完整拆开讲一遍,给准备在 STM32 上做 SPI 从机 DMA 接收的朋友一份可以直接抄作业的参考。
先说清楚一个最基础的认知:SPI 从机模式下的数据接收,本质上是一个“外部时钟驱动”的过程。主机的 SCK 不来,从机的移位寄存器就是静止的,CPU 不会收到任何中断,DMA 也不会有任何动作。所以从机端做 DMA 接收的核心思路,不是“主动去读”,而是“提前把 DMA 通道准备好,等主机来敲门的瞬间自动把数据搬走”。这也正是 SPI 从机场景下 DMA 比中断更适合的原因——无论主机何时来数据、一次来多少个字节,只要 DMA 缓冲区和传输长度提前设好,数据搬运不需要 CPU 参与,主循环哪怕在忙别的事也不会丢数据。
1. 整体设计与方案选型思路
1.1 为什么从机模式必须认真对待
在主从通信中,从机往往是被动方,它的响应速度直接决定了整个链路的稳定性。很多开发者一开始用查询方式读 SPI 从机数据,发现主机发一次没问题,连续发几次就开始丢字节,原因就在于 CPU 查询的周期赶不上主机 SCK 的节奏。尤其是当主机的 SPI 时钟在 1MHz 以上时,一个字节 8 bit 传完只需要 8us,如果 CPU 正在处理其他中断或者跑了耗时指令,就很容错过接收窗口。
改用中断方式会好一些,但依然有隐患。SPI 从机接收中断的本质是“每收一个字节进一次中断”,如果主机连续发送 100 个字节,CPU 就要连续进 100 次中断,每一次中断的压栈、出栈、函数调用开销累计起来是非常可观的。当我用逻辑分析仪实测一个 4MHz SPI 从机中断接收场景时,CPU 的占用率接近 40%,而且中断响应延迟稍微抖动一下,就可能出现 RX 溢出——也就是 SPI 的 OVR 标志被置位,导致后续数据全部作废。
DMA 方案则完全不同。DMA 控制器负责把 SPI 数据寄存器里的每一个字节搬到内存缓冲区,整个过程不需要 CPU 介入,搬完整个缓冲区后才触发一次传输完成中断。CPU 只需要在中断里处理整包数据,开销降了一个数量级,也不存在逐字节响应延迟问题。这就是为什么“SPI 从机 + DMA”是高速稳定接收的唯一正解。
1.2 应用场景梳理与选型确认
SPI 从机 DMA 读取在真实项目中出现频率最高的场景包括下面几类:
- 主控板与传感器模组之间的高速数据交互,传感器作为 SPI 从机持续上报采样数据
- 双 MCU 通信,一块板子做主、一块板子做从,从机需要接收来自主机的命令流或固件升级包
- 外扩通信模块,比如把 SPI 从机接口接到外部无线模块的宿主接口上,以从机方式接收下行数据
- 与 FPGA 或其他逻辑器件进行高速数据交换,FPGA 作为主机模拟各种时序
这些场景有一个共同特点:数据量不是单字节级别的,而是一包几十字节甚至几百字节;主机不会等从机准备好再发,而是按照自己的节奏持续输出。这种“突发性强、数据量大、外部时钟驱动”的通信模式,本质上就是为 DMA 量身定做的。
关于具体选型,SPI1 和 SPI2/SPI3 在从机模式下的功能是一样的,但 SPI1 挂载的 DMA 通道通常更“强力”。在 STM32F1 系列上,SPI1 的接收 DMA 在 DMA1 通道 2 上,而 SPI2 的接收 DMA 在 DMA1 通道 4 上;在 STM32F4 系列上,SPI1 的接收 DMA 在 DMA2 的 Stream0 Channel3 上,SPI2 则不同。选择 SPI1 的原因往往不只是因为引脚顺手,而是 DMA 通道的优先级和带宽配置更灵活,尤其在 DMA 资源紧张时 SPI1 的通道分配更容易避开冲突。
1.3 方案优势与代价
这套方案的优势非常明显:CPU 占用率极低、传输稳定不丢字节、代码逻辑简单。但它也不是没有代价。最大的代价是灵活性降低了——DMA 的传输长度在启动时就必须确定,如果主机的数据包长度不固定,从机端的 DMA 配置就要做特殊设计。其次是内存占用,DMA 需要一个固定大小的缓冲区,不能像中断方式那样来一个字节处理一个字节。还有一点容易被忽略:如果 DMA 配置和 SPI 使能顺序不对,可能导致 SPI 从机一收到片选信号就产生错误中断。
所以在动手之前,先要想清楚自己的主机数据包是固定长度还是可变长度。固定长度最简单,DMA 缓冲区大小直接设为数据包长度即可;可变长度则要配合空闲检测或者固定最大长度 + 实际长度解析。这篇文章后面会针对这两种情况分别给出处理思路。
2. 硬件基础与关键信号分析
2.1 SPI 从机模式的协议时序拆解
SPI 从机的工作时序完全由主机决定。从机需要关注四个信号:SCK、MOSI、MISO、NSS。其中 SCK 由主机产生,从机的移位寄存器靠它来采样;MOSI 是主机输出从机输入的数据线;MISO 是从机输出主机输入的数据线;NSS 是片选信号,硬件模式下它必须为低电平,从机的移位寄存器才会工作。
从机模式下最关键的时序参数是 CPOL 和 CPHA 的匹配。CPOL 决定 SCK 空闲时的电平,CPHA 决定数据采样时刻。如果从机的 CPOL/CPHA 配置和主机不一致,结果不是完全收不到数据,而是采集到的每一位都可能错位,最终表现为“收到数据了,但内容完全不对,而且错误模式很规律”——比如每个字节都左移一位或者右移一位。
从机还有一个容易被忽略的点:从机的 SCK 频率不能超过自己数据手册规定的上限。工作在从机模式时,SCK 引脚直接接收外部时钟,主机的波特率预分频器对从机没有任何限制作用。STM32F1 系列在从机模式下 SCK 最高约为 18MHz,F4 系列约为 40MHz(不同封装、不同供电电压有差异),如果主机时钟过快,从机即使配置正确也会采到错误数据。这一点在和 FPGA 或高性能 MCU 主机联调时尤其要留心。
2.2 从机模式下的 NSS 选择:硬件还是软件
NSS 是 SPI 从机最容易出问题的信号,没有之一。STM32 提供两种 NSS 模式:硬件模式和软件模式。
硬件模式下,NSS 引脚由外部主机直接控制。主机把 NSS 拉低时,从机认为自己被选中,开始响应 SCK;主机把 NSS 拉高时,从机释放总线。这种模式的好处是片选时序由硬件自动管理,不需要 CPU 参与;坏处是 NSS 引脚必须正确连接,而且如果主机在传输中途把 NSS 拉高再拉低(比如多字节传输中做了一次片选翻转),从机的状态机可能产生不可预期的行为。
软件模式下,NSS 引脚不参与控制,只需在代码里把 SSI 位置 1,让 SPI 外设认为片选一直有效。这种模式的优点是省掉一根信号线,接线简单;缺点是失去了片选的硬件同步能力。如果主机端在传输间隙把 NSS 拉高,从机软件模式下根本感知不到,依然会继续采样 SCK 上的数据。对于只有一个主机和一个从机的点对点通信,软件 NSS 够用;但如果总线上可能有多个从机,或者主机用片选来标志一帧数据的起始和结束,就必须用硬件 NSS。
我在实际项目里的习惯是:数据包有明确起始和结束标志的场景一律用硬件 NSS,因为 DMA 接收方式本身没法逐字节判断帧边界,NSS 的拉低/拉高恰好可以作为“一帧数据开始/结束”的硬件信号来辅助判断。
2.3 引脚与 DMA 通道映射关系
SPI1 在不同 STM32 系列上的引脚映射有差异,最常见的是 PA4(NSS)、PA5(SCK)、PA6(MISO)、PA7(MOSI)。在 STM32F4 系列上,SPI1 复用功能映射到 GPIOA 的 AF5,代码里要先调用 GPIO_PinAFConfig 把引脚切换到复用功能模式,这一步漏了,后面所有配置都白搭。
DMA 通道映射更需要查数据手册。以 STM32F407 为例,SPI1_RX 对应 DMA2 Stream0 Channel3,SPI1_TX 对应 DMA2 Stream3 Channel3。网上很多例程用的是 DMA2 Stream2,那是 SPI1_TX 或者别的外设的映射,直接套用到 RX 上会导致 DMA 请求根本不触发。这类问题排查起来非常费时间,因为代码编译不报错,运行也不崩溃,就是数据一动不动。最可靠的方式是查芯片参考手册里的 DMA 请求映射表,而不是凭经验或者抄旧工程。
我把几个常见系列的 SPI1_RX DMA 映射整理如下,方便对照:
| STM32 系列 | SPI1_RX DMA 通道 | 说明 |
|---|---|---|
| STM32F1 | DMA1_Channel2 | 外设地址为 SPI1->DR |
| STM32F4 | DMA2_Stream0 Channel3 | 注意 Stream 和 Channel 都要对上 |
| STM32F7 | DMA2_Stream0 Channel3 | 与 F4 类似 |
| STM32H7 | DMA1_Stream0 Request0(或 BDMA 相关) | H7 的 DMA 映射更复杂,务必查手册 |
| STM32L4 | DMA1 Channel2 | 与 F1 相似但寄存器不同 |
这个表不一定覆盖所有型号,但它能帮助建立“先查映射,再写代码”的习惯。踩过一次 DMA 不触发的坑之后,我每换一个芯片型号,第一步就是打开参考手册确认外设到 DMA 的请求线编号。
3. 代码实现:SPI1 从机 DMA 读取的完整配置
3.1 时钟与引脚初始化
代码是工程落地最直接的载体,我把完整配置流程按步骤拆开,每一步都解释为什么这么做。以下代码基于 STM32F4 系列标准外设库,HAL 库版本会在后面的章节单独说明差异。
先做时钟和引脚初始化。SPI1 挂在 APB2 总线上,GPIOA 挂在 AHB1 总线上,DMA2 也挂在 AHB1 总线上。这三个时钟都要打开。
void SPI1_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; // 打开 GPIOA、SPI1、DMA2 的时钟 RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1, ENABLE); RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2, ENABLE); // 配置 PA4(NSS)、PA5(SCK)、PA6(MISO)、PA7(MOSI) 为复用功能 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP; GPIO_Init(GPIOA, &GPIO_InitStructure); // 将 PA4-PA7 的复用功能映射到 SPI1 GPIO_PinAFConfig(GPIOA, GPIO_PinSource4, GPIO_AF_SPI1); GPIO_PinAFConfig(GPIOA, GPIO_PinSource5, GPIO_AF_SPI1); GPIO_PinAFConfig(GPIOA, GPIO_PinSource6, GPIO_AF_SPI1); GPIO_PinAFConfig(GPIOA, GPIO_PinSource7, GPIO_AF_SPI1); }这里有一个细节,MISO 也就是 PA6 在从机模式下是输出引脚,虽然配置成复用推挽没问题,但如果从机还没被使能,MISO 应该保持高阻态。在从机模式下,SPI 外设会自动处理 MISO 的输出使能,前提是 NSS 为低并且 SPI 已经使能。所以代码里可以把 MISO 单独配置为复用开漏并外加上拉,也可以直接复用推挽,实际测试两种方式都能工作。我更习惯复用推挽加上拉,因为开漏模式下 MISO 的高电平驱动能力受上拉电阻影响,主机端如果只开了内部上拉,高电平可能不够干净。
3.2 SPI 参数配置的关键点
接下来是 SPI 外设参数。从机模式下的配置项比主机少,但每一样都不能错。
SPI_InitTypeDef SPI_InitStructure; SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex; SPI_InitStructure.SPI_Mode = SPI_Mode_Slave; SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b; SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low; SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge; SPI_InitStructure.SPI_NSS = SPI_NSS_Hard; // 硬件 NSS,由主机控制 SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_2; SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; SPI_Init(SPI1, &SPI_InitStructure);SPI_Direction 默认选全双工,即使只做接收也建议保持这个设置,因为 SPI 从机在全双工模式下才能正常维护 MISO 的输出状态,改成半双工反而可能引入不必要的麻烦。
SPI_Mode 必须设置为 Slave,这是整个配置的基调。如果这里配置成了 Master,后面再怎么调 DMA 也不可能正确接收外部主机的数据。实际项目中我见过有人把主从两端都配成了 Master,结果两边都没有时钟源,通信完全静默,这种低级错误排查起来往往最浪费时间的。
CPOL 和 CPHA 必须和主机保持完全一致。这里写成 CPOL_Low、CPHA_1Edge 对应 SPI Mode 0,这是最常用的模式。但千万不要默认主机也是 Mode 0,联调前先和主机端确认好,再填到代码里。
SPI_BaudRatePrescaler 在从机模式下并不决定实际波特率,因为 SCK 完全由外部主机产生,但依然要设置一个值。原因是 STM32 的寄存器要求这个字段不能为 0,直接决定了 SPI 内部逻辑的时钟分频基准。实测发现,从机模式下把预分频设为 2 和设为 256,对接收结果没有任何影响,但必须有一个合法值。
SPI_NSS 这里用的是硬件模式。如果你要用软件模式,就把 SPI_NSS_Soft 填进去,同时记得在 SPI_Cmd 之前把 SSI 位通过 SPI_NSSInternalSoftwareConfig 置 1,否则 SPI 外设认为片选无效,任何数据都不会接收。
一个小技巧:在配置从机 SPI 时,设置 CPOL 和 CPHA 的顺序,或者 SPI_Init 后是否有必要重新开关 SPI,这些都不重要。真正重要的是,DMA 初始化必须放在 SPI 使能之前。先使能 SPI 再配置 DMA,从机可能在 DMA 还没准备好的瞬间就收到主机数据,造成溢出错误。稳妥的顺序永远是:GPIO → SPI 参数配置 → DMA 配置 → 使能 DMA → 最后才使能 SPI。
3.3 DMA 初始化与中断配置
SPI1 从机 DMA 接收的初始化代码如下。这里的 DMA 映射是 STM32F4 系列的 DMA2 Stream0 Channel3,主设备地址是 SPI1->DR 的地址,存储器地址是我们的接收缓冲区。
#define SPI1_RX_BUF_SIZE 256 uint8_t spi1_rx_buf[SPI1_RX_BUF_SIZE]; volatile uint8_t spi1_rx_complete = 0; void SPI1_RX_DMA_Init(void) { DMA_InitTypeDef DMA_InitStructure; // 需要在 DMA 配置前先清标志 DMA_ClearFlag(DMA2_Stream0, DMA_FLAG_TCIF0 | DMA_FLAG_FEIF0 | DMA_FLAG_DMEIF0 | DMA_FLAG_TEIF0); DMA_InitStructure.DMA_Channel = DMA_Channel_3; // SPI1_RX 映射通道 DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&SPI1->DR; DMA_InitStructure.DMA_Memory0BaseAddr = (uint32_t)spi1_rx_buf; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralToMemory; DMA_InitStructure.DMA_BufferSize = SPI1_RX_BUF_SIZE; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Normal; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_FIFOMode = DMA_FIFOMode_Disable; DMA_InitStructure.DMA_FIFOThreshold = DMA_FIFOThreshold_HalfFull; DMA_InitStructure.DMA_MemoryBurst = DMA_MemoryBurst_Single; DMA_InitStructure.DMA_PeripheralBurst = DMA_PeripheralBurst_Single; DMA_Init(DMA2_Stream0, &DMA_InitStructure); // 使能 DMA 传输完成中断 DMA_ITConfig(DMA2_Stream0, DMA_IT_TC, ENABLE); // 配置 DMA 中断优先级 NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel = DMA2_Stream0_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0; NVIC_InitStructure.NVIC_IRQSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); // 使能 DMA Stream DMA_Cmd(DMA2_Stream0, ENABLE); }DMA_Mode 这里使用了 Normal 模式而不是 Circular 模式。这是有意为之。原因在于 Circular 模式下,DMA 传输完整个缓冲区后会自动把地址指针重置回起点,可以做到连续不断的接收,但带来的问题是:你很难定位“当前这一帧数据从哪里开始”。如果主机是连续不断地发送数据流,Circular 模式配合乒乓缓冲或者双缓冲才有意义;如果主机是按帧发送、帧与帧之间有间隔,Normal 模式更直观,每收满一帧缓冲区就产生一次完成中断,在中断里处理完数据后再重新启动 DMA 即可。
DMA_PeripheralInc 必须关闭,因为 SPI 的数据寄存器地址固定,不需要自增;MemoryInc 必须打开,因为要依次往缓冲区各个地址写数据。数据宽度这里都设置为 Byte,对应 SPI 8 位数据模式。如果你用了 16 位 SPI 数据模式,这两个字段要改成 HalfWord,否则 DMA 搬运会错位。
DMA 传输完成中断是处理整帧数据的关键。在中断服务函数里,首先清除中断标志,然后把 spi1_rx_complete 置 1,主循环检测到这个标志后再处理数据,并重新启动 DMA。这样做的原因是,DMA 在 Normal 模式下传输完成后,Stream 会自动禁用,必须重新使能才能开始下一次传输。
void DMA2_Stream0_IRQHandler(void) { if (DMA_GetITStatus(DMA2_Stream0, DMA_IT_TCIF0)) { DMA_ClearITPendingBit(DMA2_Stream0, DMA_IT_TCIF0); DMA_Cmd(DMA2_Stream0, DISABLE); spi1_rx_complete = 1; } }主循环的处理逻辑也比较直接:
while (1) { if (spi1_rx_complete) { spi1_rx_complete = 0; // 处理接收到的数据 process_spi1_frame(spi1_rx_buf, SPI1_RX_BUF_SIZE); // 重新启动 DMA 接收下一帧 DMA_SetCurrDataCounter(DMA2_Stream0, SPI1_RX_BUF_SIZE); DMA_Cmd(DMA2_Stream0, ENABLE); } }这里有三个容易错的点。第一,重新启动 DMA 之前要先清除 DMA 的传输完成中断标志,否则再次使能后会立即触发一次假中断。第二,DMA_Cmd(DISABLE) 后不能立刻设置数据计数器再使能,标准外设库的 DMA_SetCurrDataCounter 在某些版本上需要在 DMA 禁用状态下调用才可靠。第三,DMA 重新使能之后,要等 SPI1 外设重新进入可接收状态,建议在重载 DMA 前调用 SPI_I2S_ClearFlag 清掉 OVR 标志,防止上次溢出的残留状态影响新一帧接收。
3.4 SPI 使能与整体初始化顺序
最后把 SPI1 外设本身使能。放在 DMA 全部配置完成之后。
void SPI1_Slave_DMA_Init(void) { SPI1_GPIO_Init(); SPI1_SPI_Init(); // 上文中的 SPI_Init 配置 SPI1_RX_DMA_Init(); // 先配置 DMA SPI_Cmd(SPI1, ENABLE); // 最后使能 SPI }使能 SPI 后,从机就开始监听 NSS 和 SCK。如果 NSS 被主机拉低,并且主机开始产生 SCK 时钟,SPI1 的移位寄存器自动接收数据,每收到一个字节就触发一次 DMA 请求,DMA 把数据搬到缓冲区。整个过程 CPU 零参与,直到 DMA 缓冲区被填满。
如果测试中收到的主机数据比缓冲区短,DMA 的完成中断不会触发,因为 DMA 传输计数器还没减到 0。这种情况下通常配合片选信号来判断帧结束:在硬件 NSS 模式下,可以配置 SPI1 的 NSS 中断或者 EXTI 外部中断来检测 NSS 的上升沿,一旦检测到主机释放片选,就说明当前帧结束,此时再读取 DMA 当前剩余计数器,就能算出来实际收到了多少字节。这个技巧在处理可变长度数据包时特别有用,后面的章节会详细展开。
4. 实测过程与关键性能分析
4.1 联调测试环境搭建
配置写好了,联调阶段才是真正考验方案可靠性的时刻。我在测试时习惯用两块开发板做一主一从的闭环验证,主板上 STM32F407 的 SPI1 作为主机,从板上 STM32F407 的 SPI1 作为从机,两块板子之间连接 SCK、MOSI、MISO、NSS,外加共地。
这种主从环路测试最直接的好处是,主机端完全可控,想发什么数据、发多发少都可以随时调整。为了验证长时间运行稳定性,我通常会让主机循环发送递增计数序列,比如从 0 到 255 循环递增,从机 DMA 收到数据后校验序列是否连续。如果中间有丢字节或者错位,从机端立刻能发现序列断点,问题定位非常方便。
连接时要注意 MISO 的交叉连接。主板的 MISO 要接从板的 MISO,主板的 MOSI 接从板的 MOSI,因为 SPI 是同步全双工,同名单端互连不需要交叉。SCK 接 SCK,NSS 接 NSS。GPIO 配置上,主板的 MISO 是输入,从板的 MISO 是输出;主板的 MOSI 是输出,从板的 MOSI 是输入。如果某根线接反了,现象往往不是完全不通,而是主机发出去的数据在从机端收到的是乱码,尤其是 SCK 和 MOSI 接反后会非常迷惑。
4.2 不同数据模式下的实测对比
我做了三组测试:查询接收、中断接收、DMA 接收,主机 SPI 时钟分别为 1MHz、4MHz、8MHz,每次发送 256 字节数据包。结果非常直观:
| 接收方式 | 1MHz 256字节 | 4MHz 256字节 | 8MHz 256字节 | CPU 占用估算 |
|---|---|---|---|---|
| 查询方式 | 偶尔丢字节 | 频繁丢字节 | 完全不可用 | 高 |
| 中断方式 | 稳定不丢 | 偶发溢出 | 丢字节概率明显上升 | 中高 |
| DMA 方式 | 稳定不丢 | 稳定不丢 | 稳定不丢 | 极低 |
中断方式在 4MHz 时偶发溢出的原因,是 SPI 从机每收一个字节,硬件就会置 RXNE 标志并触发中断。如果主程序此时正好在执行临界区代码或者被更高优先级中断抢占,RXNE 标志还没来得及被读取,下一个字节的 SCK 边沿就到了,数据寄存器被覆盖,硬件置 OVR 标志。从机 SPI 一旦发生溢出,后续数据全部作废,除非软件清掉溢出标志并重新同步。DMA 方式不存在这个问题,因为 DMA 请求比中断更快、更及时——它不是通过 CPU 的异常响应机制,而是直接由硬件事件触发数据搬运,从 RXNE 置位到 DMA 搬走数据只需要几个硬件时钟周期。
4.3 数据吞吐量与缓冲区大小的关系
从机 DMA 接收的数据吞吐量理论上等于主机发送速率,但从机端 DMA 缓冲区大小决定了“多久进一次中断”、“一包数据处理粒度多大”。缓冲区设得太小,比如 16 字节,主机一次发 256 字节,从机要进 16 次完成中断,每次都要重载 DMA,虽然比逐字节中断好得多,但中断频率还是偏高。缓冲区设得太大,比如 4096 字节,RAM 占用就上去了,对资源紧张的芯片可能不合理。
从机 DMA 接收缓冲区大小的选择原则可以总结为一句话:缓冲区不小于主机一帧的最大数据长度,同时尽量把 RAM 占用控制在合理范围。在我做过的项目里,256 字节是一个比较均衡的选择,既能覆盖绝大多数通信帧,又只占 256 字节 RAM。如果主机是多帧连续发送,我会把缓冲区改到 512 或 1024,配合帧头解析逻辑来切分数据。
还有一个实测中容易忽略的细节:DMA 传输完成后,缓冲区里存放的是原始数据,主机在最后一次时钟边沿后可能还要做片选释放等操作,从机端如果立即处理缓冲区数据,理论上存在 MISO 上最后一位还未完全稳定的极端情况。实际测试中,由于 DMA 完成中断是在最后一个字节被 SPI 硬件接收并写入缓冲区后才触发的,缓冲区数据已经完整,不需要额外延时。
5. 常见问题与排查技巧实录
5.1 配置完 DMA 后不触发接收
现象:主机不断发送数据,但 DMA 缓冲区始终是空的,断点调试发现 DMA 的传输计数器没有变化。
排查步骤:
第一步检查 DMA 通道映射。这是一个最容易犯又最难发现的错误。确认你的芯片型号对应的是哪一条 DMA 通道,不要假设和网上例程一样。在 F4 上 SPI1_RX 是 DMA2 Stream0 Channel3,在 F1 上是 DMA1 Channel2。我见过有人在 F4 上照抄 F1 代码,结果 DMA1 根本不响应 SPI1 的请求,查了两天才发现映射表对不上。
第二步检查 SPI 是否真正使能。很多人把 SPI_Cmd 写在 DMA 配置之前,代码逻辑上没什么问题,但在从机模式下,SPI 使能后 NSS 如果为高,外设不会产生任何活动。要确认 SPI 已经进入 Slave 且 NSS 有效。
第三步用调试器手动查看 SPI_SR 寄存器。如果 SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) 一直为 RESET,说明 SPI 硬件根本没收到数据,问题在 SPI 配置或引脚连接;如果 RXNE 一直为 SET,说明数据已经进到数据寄存器,但 DMA 没有搬走,问题在 DMA 映射或使能状态。
5.2 主机发 0x01 从机收到 0x80 之类的数据错位
现象:数据能收到,但每一位都左右移位,比如 0x01 变成 0x80,或者 0xF0 变成 0x0F。
这个问题的原因基本锁定在 CPOL/CPHA 不匹配。主机在 Mode 0 下发送,从机却配置成 Mode 1,采样时刻相差半个时钟周期,数据位就会整体偏移。检查主机和从机的 SPI 模式寄存器,确保 CPOL 和 CPHA 完全一致。
还有一种隐蔽情况:主机和从机都配置成了 Mode 0,但主机代码在初始化 SPI 时先拉高了 SCK,从机在上电瞬间检测到 SCK 空闲电平为高,内部状态机认为当前是 Mode 1 的空闲状态,后续采样时刻就会错误。这种问题在带电复位时特别常见。解决方法是在主机初始化 SPI 之前把 SCK 引脚拉低到确定状态,或者让从机先上电复位,主机后初始化。
5.3 接收一段时间后 DMA 停摆,不再触发完成中断
现象:系统运行正常一段时间后,主机继续发送数据,但从机 DMA 的缓冲区不再更新,或者 DMA 传输完成中断一直不触发。
很多情况下这是 OVR 溢出导致的后遗症。SPI 从机发生溢出时,OVR 标志被置位,SPI 外设会暂停接收行为。如果 DMA 中断服务函数里只清除 DMA 标志,没有清除 SPI 的 OVR 标志,下一次使能 DMA 后 SPI 依然处于不健康状态。我的处理方法是,在每次 DMA 重新启动之前,统一调用一次 SPI_I2S_ClearFlag(SPI1, SPI_I2S_FLAG_OVR),无论当前 OVR 是否置位,顺手清掉没有坏处。
还有一个更隐蔽的坑:DMA 在正常模式下传输完成之后会自动失能,但如果你在中断里没有先 DISABLE 再重新 ENABLE,只是直接 DMA_Cmd(ENABLE),在一些库版本上会导致 DMA 虽然显示使能,但内部状态机没有正确重置,传输计数器也不会重新装载。正确做法是先 DISABLE,等待短暂时间,再 SetCurrDataCounter,再 ENABLE。这个顺序不要乱。
5.4 可变长度数据包的处理思路
前面说过,DMA 方式要求缓冲区长度固定,碰到可变长度数据包就会尴尬。这里分享两个实测有效的方法。
方法一是“固定最大长度 + 实际长度解析”。DMA 缓冲区设为主机最大包长,接收完成后在缓冲区里寻找帧头帧尾来切包。这个方法最简单,但 RAM 浪费较多。
方法二是“DMA 接收 + NSS 中断定界”。将片选信号同时接到 EXTI 引脚上,NSS 下降沿作为帧起始,NSS 上升沿作为帧结束。下降沿到来时,重新配置 DMA 并清零计数器;上升沿到来时,读取 DMA_GetCurrDataCounter,用缓冲区长度减去剩余计数,就得到实际接收字节数。这个方法非常高效,不需要额外解析,但要求主机在每一帧数据之间必须把 NSS 拉高一次。绝大多数 SPI 主机在帧与帧之间本来就会释放片选,所以适用性很广。
5.5 HAL 库版本的关键差异
如果你用的是 HAL 库,配置思路一样,但 API 名称和顺序上有几个关键差异。
HAL 库的 SPI 从机 DMA 接收函数是 HAL_SPI_Receive_DMA,它把 SPI 外设和 DMA 封装在了一起,看起来方便,但有一些隐性问题。HAL 库默认会在接收完成回调 HAL_SPI_RxCpltCallback 里帮你处理一些状态,但不会自动重新开启 DMA,还是需要手动调用 HAL_SPI_Receive_DMA 来启动下一次接收。
HAL 库对 SPI 从机 DMA 接收有个经典 bug:在从机模式下,HAL_SPI_Receive_DMA 内部可能会改变 SPI 的 DMA 使能状态,导致主机在调用后立刻发送数据时,从机还没完全进入 DMA 监听模式。实测中经常遇到“第一次通信正常,第二次开始丢数据”。解决方法是初始化后先延时几个毫秒,再使能 SPI,或者直接在 MX_SPI1_Init 外部手动调用 HAL_SPI_Receive_DMA,并加一个短暂延时。
HAL 库还有一个问题:它默认使能 SPI 的硬件 NSS 可能会和外部主机时序产生冲突。如果你使用软件 NSS,HAL 库里的 HAL_SPI_Init 参数 SPI_NSS 设置为 SPI_NSS_SOFT,但 HAL 库内部会把它映射到 SSI 位。实际使用中我更推荐 HAL 库配合“先调 HAL_SPI_Receive_DMA 再调 HAL_SPI_Enable”这样的手动控制方式,而不是完全依赖 HAL 的自动状态机。
6. 从机 DMA 接收的扩展玩法与优化方向
6.1 双缓冲与乒乓机制
如果主机的数据流连续不断、帧与帧之间没有明显间隔,用一个固定的 DMA 缓冲区接收,处理完一帧前下一帧数据就可能已经覆盖了缓冲区。这时可以用乒乓缓冲:申请两个大小相同的缓冲区,DMA 交替往两个缓冲区搬数据。一个缓冲区被 DMA 写满时触发中断,CPU 处理这块数据的同时,DMA 已经在往另一块缓冲区写入新数据,两个缓冲区交替使用,CPU 和 DMA 并行工作,不互相等待。
实现乒乓缓冲最省力的方式是使用 DMA 双缓冲模式,但 STM32F4 系列的 DMA 双缓冲模式只支持内存到内存或者外设到内存的部分方向,实际使用限制较多。更通用的方式是手动切换 DMA 的内存地址。在 DMA 传输完成中断里,判断当前使用的是缓冲区 A 还是缓冲区 B,然后把 DMA 的 Memory0BaseAddr 切换到另一个缓冲区,再重新启动 DMA。代码量不大,但效果很好。
6.2 与 RTOS 配合的典型模式
在带操作系统的项目中,SPI 从机 DMA 接收的处理逻辑通常是:DMA 中断里不做数据处理,只放一个信号量或者事件标志;接收线程阻塞等待信号量,收到信号量后从缓冲区取数据做业务处理。这种模式的优点是 DMA 中断的开销只有十几条指令,业务逻辑全部在线程上下文完成,不会阻塞中断响应。
需要注意的坑是,如果缓冲区被线程和 DMA 同时访问,必须做同步保护。最稳妥的方式是 DMA 中断里只置标志,线程检测到标志后先把数据拷到线程私有缓冲区,再把 DMA 缓冲区交还给接收任务。不要在线程里直接操作 DMA 正在写入的缓冲区,即使 DMA 此时已经完成中断已经触发,也不能保证硬件层面完全没有后续访问。
6.3 更多优化技巧
如果主机时钟频率很高,从机 DMA 接收的时序余量会被压缩。可以开启 SPI 的硬件 CRC 校验来确保数据完整性,但对 DMA 方式来说 CRC 的计算和校验需要额外代码,一般只在数据可靠性要求极高的场景才用。
另一个优化方向是利用 DMA 的 Half Transfer 中断。在 Normal 模式下,DMA 支持传输一半时触发一次中断。如果主机持续发送的数据超过缓冲区一半长度,可以在半传输中断里先处理前半段数据,完成中断里处理后半段数据,相当于把一次大包分两次处理,减少了主循环的等待时间。这种技巧在处理大文件传输时很实用。
6.4 从机模式的极限性能参考
从机 DMA 接收的极限能力受三个因素约束:SPI 外设本身的最高 SCK 频率、DMA 的搬运速度、缓冲区 RAM 的读写速度。对于绝大多数主流 STM32 芯片,这三个因素都不是瓶颈,真正的瓶颈往往在于主机端的数据组织方式和帧间隔。实测中把主机 SPI 时钟拉到 8MHz、每帧 256 字节、帧间隔 100us 的场景下,从机 DMA 依然稳定工作,CPU 占用不超过 5%。这已经能覆盖绝大多数实际应用。如果你的主机时钟达到 20MHz 甚至更高,建议先认真查看芯片数据手册中 SPI 从机模式下的时序参数,必要时在从机端加一个电平转换或者减小布线寄生电容,保证信号质量。
7. 写在最后的一点经验
SPI 从机 DMA 接收这套东西,配置代码本身不复杂,真正的复杂度全在“时序配合”和“异常恢复”里。我在不同项目里反复遇到相似的问题:不是 DMA 通道映射搞错,就是 OVR 标志没有清干净,或者是 CPOL 不匹配导致数据错位。这些坑单看代码很难发现,只有用调试器盯着寄存器状态,或者用逻辑分析仪抓波形,才能快速定位。
如果你正在做类似的项目,我建议上手时就坚持三个习惯:第一,先查参考手册确认 DMA 映射,不要盲目套用网上代码;第二,SPI 从机使能永远放在 DMA 配置之后;第三,所有接收完成后的恢复流程,必须包含 SPI 溢出标志的清除。把这三点做好了,从机 DMA 接收基本不会出大问题。最后再说一个小技巧:联调阶段让主机发送递增序列而不是随机数据,一旦出现错位,立刻就能通过数值的变化规律判断是位偏移还是字节丢失,排查效率翻倍。