☰
STM32 SPI从机DMA接收实战:从接线到不定长帧稳定传输
2026/10/17 1:18:55 网站建设 项目流程

1. 从机+DMA这个组合到底解决什么问题

1.1 从机模式的典型应用

很多人做板间通信,下意识就会选主机模式:时钟自己订、片选自己拉,整个流程都在自己手里握着。但实际项目里经常有反向需求,比如一块主控板往上位机、往另一块核心板主动上报数据,或者传感器模组做从设备,等待外部主机来读取采样结果。这时候就必须把SPI配成奴隶端,等外面的时钟信号来驱动收发。

我最早接触SPI1从机DMA,是在一个数据采集小板卡上。板卡本身不带显示和交互,只负责把传感器数据缓存下来,等主控板发起读操作。说白了就是个“被动的数据源”。一开始用中断方式收发,每收一个字节就进一次中断,主机的SCK跑个几百千赫还行,一旦升到几兆赫,CPU的负载立刻变得不可接受。后来把接收改成DMA,才发现从机模式真正舒服的用法是让外设和内存之间直连搬运,CPU只在整帧数据接收完成后介入一次。

1.2 为什么一定要上DMA

SPI从机模式下,数据进来的节奏完全由主机SCK决定,从机没法预测下一秒是零数据还是连续涌入。如果用轮询,主机的时钟快一点,CPU稍微卡顿,RXNE溢出标志就置位了,数据直接丢。如果每字节中断,虽然不容易丢,但每收一个字节都要现场保护和恢复,MCU的时间几乎全耗在这上面。

DMA的好处在于,它替CPU盯住了“数据准备好”这个事件。外设收到一个数据,DMA自动搬去内存;内存腾空了,DMA等待下个数据。整个过程不需要CPU参与,天然适配突发性强的从机输入。实测下来,F1平台主机的SCK跑到4MHz左右,中断方式在没做优化时还能勉强扛住,相同的量一旦交给DMA,CPU占用几乎可以忽略。更重要的是,DMA配合“接收完成中断”,你只关心一帧收齐没有,而不是关心中间每一个字节。

1.3 适用芯片与入门前的门槛

这篇分享以STM32F1系列为基准,比如常见的F103C8T6,原因是我手头这块板子的项目积累最多,而且F1的SPI1 DMA通道映射比较简单,容易讲明白原理。但核心思路放到F4、H7上同样成立,只是DMA的外设请求号、数据流编号要换一套。

入门之前建议至少清楚三件事:SPI的四种时序模式(CPOL/CPHA)到底是怎么配的,DMA的外设地址和内存地址自增分别意味着什么,以及从机模式下NSS片选信号为什么比想象中更重要。这三件事缺一个,后面调试起来会非常痛苦。我见过有人在从机模式下根本不接NSS,以为只要SCK、MOSI、MISO三根线就能通信,结果数据全是乱的,原因后面细说。

2. SPI从机DMA接线的几个关键细节:不止是四根线

2.1 引脚分配与数据方向

先看一下常见的F1芯片上SPI1对应的引脚:

信号引脚方向说明
SCKPA5主机输出,从机输入
MISOPA6从机输出,主机输入
MOSIPA7主机输出,从机输入
NSSPA4主机输出片选,从机输入片选

接线看似简单,但有一个容易搞反的点:从机板上的MISO要接到主机板上的MISO,MOSI接MOSI,SCK接SCK,NSS接NSS,千万别在从机模式下“左右交叉”。SPI不像UART那样需要TX接RX、RX接TX,它是时钟同步总线,主从的同名信号直接相对。凡是记成串口那套交叉逻辑的,第一次上板基本都要烧一炷香才能反应过来。

接完线还有两个细节容易被忽略:共地和电源地阻抗。SPI从机对信号边沿的采样完全依赖时钟边沿,两块板子如果地电位有偏差,SCK边沿会抖,数据就容易采错。建议调试阶段把两块板子的GND用粗短线连好,不要让主机板通过其他设备间接共地。

2.2 NSS片选信号在从机模式中的真实作用

很多人对NSS的印象停留在“主机拉低片选,选中从机”,在主机模式下这根信号确实是纯粹的开关。但在从机模式里,NSS的作用不止于选择,它还参与SPI内部的状态锁定。

F1系列在从机模式下,NSS信号会直接影响收发逻辑的时序边界。尤其当数据宽度是8位时,从机内部的移位寄存器并不清楚一帧到底从哪一位开始,它需要一个外部参考点来对齐字节边界。这个参考点就是NSS的下降沿和上升沿。NSS拉低时,从机进入被选中状态;NSS拉高后,从机退出接收状态,同时锁存已经接收的数据。如果从机模式把这个信号悬空,或者接到了固定电平而没有跟随主机片选变化,经常出现的现象是:单独看二进制数据还行,但字节错位、帧错位,怎么调都调不干净。

在实际项目中,如果从机板的空间足够,优先把主机片的片选引过来。主机每发送一帧完整的命令或数据块,都会在一开始拉低片选、结束时拉高,从机正好用这两个边沿做帧同步的抓手。后面聊不定长接收时,这个信号还会发挥大用场。

2.3 时钟极性与速率上限

SCK的极性和相位必须与主机一致。主机如果配了CPOL=Low、CPHA=1Edge,从机必须一模一样。这个参数不是“差不多就行”的,SPI没有自动协商机制,一方配错,数据的有效窗口就完全错开,其结果要么是全错的乱码,要么是稳定的0xFF。

再一个容易被忽略的是从机模式对时钟频率更敏感。F1上SPI的时钟上限虽然是18Mbit/s,但那是主机模式的数据。从机模式下,外部SCK的信号要经过引脚缓冲、同步逻辑之后才能进移位寄存器,引脚上的毛刺和振铃都会直接影响采样。我的建议是,F1平台做从机,主机的SCK最好不要超过10MHz,调试阶段建议先从1MHz甚至更低的500kHz跑起来,通信稳定后再往上抬。这样可以把“接线问题”和“时序极限问题”分开排查。

3. 从零配置SPI1从机模式和DMA接收通道

3.1 初始化SPI1:HAL库配置要点

以下代码基于HAL库,芯片以STM32F1系列为例。先把SPI1初始化为双线全双工从机模式:

SPI_HandleTypeDef hspi1; void MX_SPI1_Init(void) { hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_SLAVE; hspi1.Init.Direction = SPI_DIRECTION_2LINES; hspi1.Init.DataSize = SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; hspi1.Init.NSS = SPI_NSS_HARD_INPUT; hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_8; hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; hspi1.Init.TIMode = SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation = SPI_CRC_CALCULATION_DISABLE; hspi1.Init.CRCPolynomial = 7; if (HAL_SPI_Init(&hspi1) != HAL_OK) { Error_Handler(); } }

几个关键点单独拎出来说:

  • NSS配成SPI_NSS_HARD_INPUT,让硬件NSS管脚直接控制从机状态,这是从机模式最规范的做法。如果你用软件NSS,从机会认为永远被选中,在这个场景下不仅失去了帧边界的参考基准,还可能出现MISO在异常时间输出数据的问题。
  • 从机模式下BaudRatePrescaler其实不影响外部时钟速率,真正决定速度的是主机,但这个字段不能省,内部时钟域切换还是会用到预分频配置。
  • 第一次调从机不要去开CRC、不要开TI模式,那些都是后话。先把最基本的双线全双工跑通,再加功能。

3.2 配置DMA接收通道

F1的SPI1接收请求固定挂在DMA1的通道2上,这个映射关系每个芯片系列都可能不一样,配置前一定去对应参考手册的DMA请求映射表查一遍。初始化代码:

DMA_HandleTypeDef hdma_spi1_rx; void MX_DMA_Init(void) { __HAL_RCC_DMA1_CLK_ENABLE(); hdma_spi1_rx.Instance = DMA1_Channel2; hdma_spi1_rx.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_spi1_rx.Init.PeriphInc = DMA_PINC_DISABLE; hdma_spi1_rx.Init.MemInc = DMA_MINC_ENABLE; hdma_spi1_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_spi1_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_spi1_rx.Init.Mode = DMA_NORMAL; hdma_spi1_rx.Init.Priority = DMA_PRIORITY_HIGH; if (HAL_DMA_Init(&hdma_spi1_rx) != HAL_OK) { Error_Handler(); } __HAL_LINKDMA(&hspi1, hdmarx, hdma_spi1_rx); }

PeriphDataAlignment和MemDataAlignment都设成BYTE,对应8位SPI数据。如果你配送16位数据,这里就都要改成HALFWORD,别只改SPI的DataSize忘了DMA的宽度。

模式上我用DMA_NORMAL而不是循环模式。循环模式虽然可以免去“每帧重新启动DMA”的麻烦,但存在覆盖风险:主机发来超出缓冲区大小的数据时,DMA会回到头部继续写,你根本不知道前后帧在哪交错。从机接收场景里,我更倾向NORMAL模式配“一次性收N字节”。等后面讲到不定长接收,再结合NSS中断来重新武装DMA,效果比循环模式干净得多。

3.3 启动接收的正确姿势

初始化完成后,启动一次DMA接收:

uint8_t spi_rx_buf[64]; HAL_SPI_Receive_DMA(&hspi1, spi_rx_buf, 64);

这条调用看起来简单,但背后的一个原理必须理解:在从机模式下,HAL_SPI_Receive_DMA只是把DMA通道和外设绑定好,并不会主动触发任何数据传输。真正让数据流动起来的是主机打过来的SCK。换句话说,从机调完这条函数后,应该立刻进入等待状态;如果主机在你调这条函数之前就开始了帧传输,那么前面那些字节直接丢失。

所以从机和主机的启动顺序要设计好。最简单的方法是在主从之间约定一个握手信号:从机初始化完成后拉高某个GPIO,主机检测到该引脚有效后再拉低NSS并发送数据。没有握手信号时,可以退而求其次,主机固定延时,比如上电后等100ms再发首包。这类方法不优雅但很实用,至少能把启动时序问题一次性排掉。

4. 定长与不定长接收:从机的“怎么算一帧结束”

4.1 定长接收:最稳妥的方案

如果主机每次发给从机的数据长度是固定且已知的,比如每帧都是32字节命令,那从机的工作就非常简单:初始化全缓冲长度,启动DMA,然后在接收完成回调里处理数据。

void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { process_frame(spi_rx_buf, 32); HAL_SPI_Receive_DMA(&hspi1, spi_rx_buf, 32); } }

这段代码有一个容易被忽略的点:DMA传输完成后,SPI外设的RXNE事件已经被清掉,但下一次接收开始前DMA必须重新发起,否则外设收进来的数据找不到“搬运工”,又会溢出。回调里重新调用HAL_SPI_Receive_DMA,就是在为下一帧做准备。

定长方案要做到稳,前提是主机不能耍赖。偶尔多发或少发一个字节,从机的DMA计数就对不上,造成整帧错位。主机在启动通信之前,最好对帧长度做硬性约定,并且在帧格式里加同步头或者固定校验字段,用来在从机侧校验这一帧是不是真的对齐了。

4.2 不定长接收:用NSS信号做帧边界

现实中的主机行为往往没那么规矩,它可能根据命令不同发来不同长度的数据。定长DMA就失效了:DMA不知道什么时候算一帧结束,如果主机的某条指令只发了3字节,而DMA配置要收16字节,那从机只会傻等剩下的13字节。

这时候就要回到前面讲的NSS信号。主机在每一帧通信开始拉低NSS,结束后拉高NSS。对从机来说,NSS的上升沿正好是“主机已经放开了SCK”的信号,意味着当前帧的数据已经全部到位。只要在这个边沿上读取DMA剩余计数,就可以算出实际收了多少字节。

实现思路是让NSS管脚同时复用为外部中断输入:NSS下降沿时表示帧开始,此时打开一个新的接收窗口;NSS上升沿时表示帧结束,读取DMA计数得到实际长度。PA4对应外部中断线EXTI4,在STM32F1上,这个管脚既能做SPI1的NSS硬件功能,也能触发EXTI中断,两者不冲突。

4.3 读取DMA剩余计数器

定位到接收完成的核心代码:

void EXTI4_IRQHandler(void) { if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_4) != RESET) { if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_4) == GPIO_PIN_RESET) { // NSS下降沿:主机开始通信,启动接收 rx_max_len = sizeof(spi_rx_buf); HAL_SPI_Receive_DMA(&hspi1, spi_rx_buf, rx_max_len); } else { // NSS上升沿:当前帧结束,获得实际长度 uint32_t remain = __HAL_DMA_GET_COUNTER(&hdma_spi1_rx); uint32_t actual_len = rx_max_len - remain; if (actual_len > 0) { process_frame(spi_rx_buf, actual_len); } } __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_4); } }

注意上升沿处理时,DMA的计数器表示“还剩多少个数据没传完”。用最初配置的接收长度减去剩余计数,就是SCK实际打过来的数据个数。这个数字在主机发送结束、SCK停掉的瞬间是稳定的,可以在NSS上升沿后正常读取。

这个方案有一个注意点:如果主机在NSS拉高后马上又拉低开始下一帧,从机在下降沿重新启动DMA时,可能遇到的是上一帧数据尚未完全“安定”的情况。稳妥做法是上升沿中断里先关掉SPI接收或先清一下RXNE,再做处理。也可以加极短的延时,保证程序不会在同一个中断周期里同时处理上升沿和下降沿。

对于H7这类新一代芯片,SPI带有接收FIFO和更完善的事件标志,有些型号还带RXDNE事件可以用于接收空闲判断,做不定长接收会简单一些。但没有这些新特性时,NSS中断加DMA计数的方法就是最通用的方案,换个封装换个系列,思路不变。

5. 从“偶发错帧”到“稳定跑通”的完整排查记录

5.1 现象:100包里有几包错位

调试过程中遇到一个非常典型的偶发错帧问题,值得完整记录一下。当时主机板每50ms发送一包16字节数据,从机用SPI1从机模式DMA接收。从机硬件上有两个板子,外观和接线完全一样,跑同一个固件,一块板子一天不出错,另一块板子平均运行几十分钟后就会出现一包错位,错的内容永远是前后字节互换。

这类问题是整篇博文里最折磨人的:它不固定复现、不规律出现、一旦出现又会在下一帧自动恢复。很多人第一反应是改软件超时、加重传,但真正定位后发现,根子往往在硬件或者配置细节上。

5.2 排查链路:从硬件到DMA配置

我的排查顺序是:先查NSS信号,再量时钟波形,最后才盘DMA计数和配置。

第一步,用示波器挂在NSS管脚上,抓主机片选的释放时刻。结果发现出问题的那块板子,NSS释放时存在明显的抖动,也就是上升沿不是干脆利落的一次拉高,而是在临界区来回跳了几次。SPI从机在这种抖动下,内部状态机可能把一个字节锁存在半途,导致后续数据错位。这是硬件层面的问题,靠软件增加延时、降低时钟,只能减轻不能根除。

不过在确认硬件之前,我还做过两个软件侧实验:一是把主机SCK从2MHz降到500kHz,错帧概率明显下降;二是用示波器同时抓SCK和MOSI,发现数据信号本身并没有太大毛刺,纯粹是片选信号在作怪。

第二步,检查DMA配置在异常情况下是否会被破坏。打开调试器看DMA计数器,发现错帧发生时__HAL_DMA_GET_COUNTER读到的剩余数据长度和预期不符,说明DMA记录的中途停止位置被打断了。结合NSS抖动,基本可以推断:NSS抖动导致主机和从机对“帧结束”的判断时间点不一致,主机认为已经释放总线,从机还在做最后一两次时钟采样,多吞了一个字节。

也排查过PE/SPE时序问题。F1的从机模式在使能SPI时,如果BSY位没有完全清除,会影响后续帧接收。当时在SPE置位之后加了一次软件清BSY操作,等同解决了一部分初始化过程导致的偶发问题,但最终起决定作用的还是把NSS硬件抖动控制在阈值之内。

5.3 修正后的稳定方案

最终修正分了三步:

  1. 硬件上调整主机板NSS的驱动方式,从开漏输出改成推挽输出,同时把上拉电阻位置挪近从机侧,NSS上升沿的抖动问题基本消失。
  2. 软件上,在NSS上升沿中断里增加“先读SPI的DR寄存器清掉残留数据,再启动下一帧接收”的逻辑。这一步等于给从机加了一次防御:即使硬件临界区偶有残留,也会被主动清掉,不给下一帧留麻烦。
  3. 在协议层给每帧增加一个两字节的CRC校验。从机收到帧先做校验,校验不过就丢弃,不回ACK也不影响主机的正常发送。降低了偶发错帧的危害,从“可能导致整段业务错乱”变成“单包丢弃,下一包恢复”。

经过这三层修正,那块一直出错的板子连续跑了48小时,没有再出现过一次错帧。这件事给我的教训很深:DMA把CPU解放出来之后,硬件的信号质量反而成了短板。做从机DMA接收,接线和信号完整性出现问题时的危害,往往比软件逻辑错误更难发现。

6. 验证跑稳后的几个边界问题

6.1 稳定性测试怎么做

很多人配置完从机DMA,开机看到数据能打印出来就宣告完成。但从机模式因为所有时序都依赖外部输入,验证一定比主机模式更严苛。我建议至少做三组测试:

第一组是长期测试。主机按实际业务速率持续发送数据,从机全程记录接收帧数和校验错误次数。建议至少连续跑12小时以上,因为从机这种模式最容易出“偶发问题”,短时间测试根本暴露不了。

第二组是变速测试。把主机SCK从慢到快再回慢,反复变化跑一段时间。这一步能验证从机在不同信号速率下能否保持DMA采样稳定,同时也能帮你看清外部时钟在极限频率下还能不能可靠工作。

第三组是空帧和短帧测试。人为制造NSS只拉低几百微秒、不发任何数据就拉高的空帧,或者只发1字节、3字节这类不齐整的短帧,观察从机的NSS中断和DMA计数是否能给出正确长度。这个测试最容易暴露“DMA还在等收足数据、但NSS已经结束”的边界情况。

6.2 不同系列的差异

F1的DMA请求映射很简单,但迁到F4就变了:SPI1_RX挂在DMA2的Stream0上,SPI1_TX挂在DMA2的Stream3上,而且每个Stream还要选对Channel编号。F4的DMA多了FIFO,可配置阈值和突发传输,从机接收时如果不熟悉,先跳过FIFO直连模式,等基础功能跑通再开。

H7系列更要注意从机模式下的时钟域和初始化顺序。新一代系列内部时钟树更复杂,SPI的接收FIFO也更大,但好处是对异常时序的容错能力更强。调这类芯片时,先用主机模式做一个回环写测试,确认SPI本身没问题,再切换到从机模式,能省大量时间。

6.3 收尾建议

代码层面最终要交付的话,建议把SPI初始化、DMA初始化、NSS中断处理都分开成独立模块,方便后续移植和调参。设置一个全局状态机来记录从机当前的接收状态,比如“等待帧头、接收中、帧完成、处理中”四态,可以避免NSS中断和DMA完成回调“打架”的时序问题。

从机DMA接收说穿了就这么点东西:SPI负责把外部时钟变成数据,DMA负责把数据搬进内存,NSS负责告诉从机“哪一段算是一帧”。理顺这三个角色,F1上能跑通,其他芯片也就只是换个编号、换个DMA通道映射的事。这套方案我已经在两个模拟项目上重复用过,从传感器采集到上位机下发都有实践验证,希望这篇分享也能让你的从机DMA一次跑稳。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询