资讯详情

STM32 SPI从机+DMA接收配置详解与踩坑记录

📅 2026/10/17 2:59:45 | 华诺云谱 👁 阅读
STM32 SPI从机+DMA接收配置详解与踩坑记录
做从站通信的活儿最容易被坑的就是SPI Slave模式。主设备那边时钟、片选、数据方向全在你的掌控里写起来非常顺手一旦切成Slave时钟由对方给、片选由对方拉、你根本不知道数据什么时候来这时候如果不用DMAMCU基本上就是在空转等数据。这篇文章围绕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 SCKCPU几乎全程被占满稍有一点优先级调整不当或者别的模块抢时间帧尾就会出现错位。而且SPI不像UART那样有FIFO缓冲一个数据寄存器DR空转的时间窗口非常窄。DMA方案的价值就在这里它不经过CPU直接在外设数据寄存器和内存之间搬运数据。主机时钟过来一个bitSPI模块拼好一个字节后触发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的通道2SPI1_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 Size8 Bits最常用的配置First BitMSB FirstClock PolarityCPOL和Clock PhaseCPHA必须和主设备保持一致NSS类型这里有两个选择Hardware NSS还是Software NSSNSS这件事值得单独说。Hardware NSS模式下从机的片选完全由SPI外设内部逻辑接管主设备把NSS拉低后从机才会在SCK边沿采集数据。听起来很合理但实际用起来牵扯一个“MODF”错误和NSS引脚的边沿检测问题尤其是主设备片选信号边沿不干净时从机SPI状态机很容易异常。所以我更推荐Software NSS配合外部GPIO中断的做法。在CubeMX中将NSS设为Software同时把原来的NSS引脚单独配置成普通输入GPIO用来捕捉主设备片选的下降沿和上升沿。这样做的优势是片选信号的解释权完全交给了应用程序你可以精确知道一帧数据的起始和结束位置DMA的启动时机也就完全可控了。CPOL和CPHA的匹配没有商量余地必须和主机完全一致。MODE0CPOL0CPHA1Edge和MODE3CPOL1CPHA2Edge是最常见的组合。在CubeMX里对应的就是Clock Polarity选Low或HighClock Phase选1 Edge或2 Edge。如果这两个参数跟主机不一致数据会明显错位看起来每一帧都有数据但内容永远是乱的而且错位方式非常规律——比如字节内bit整体右移一位。2.3 DMA配置与中断映射CubeMX的DMA Settings页签里为SPI1_RX添加一个DMA请求。以F103为例系统会默认映射到DMA1的Channel2。关键配置项DirectionPeripheral To Memory外设到内存PriorityHigh或Very High根据系统实时性要求选ModeNormal固定长度帧或者Circular环形缓冲Peripheral IncrementDisableMemory IncrementEnablePeripheral Data WidthByteMemory Data WidthByte数据宽度这里我用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为ByteNSS引脚浮空导致状态异常硬件NSS模式下引脚没有上拉使用软件NSSGPIO中断或给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配成WordDMA一次搬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方向、数据宽度、中断回调都没问题再逐步加上片选边沿检测、不定长解析、缓冲区切换这些复杂度。每一步都留一个可验证的中间状态排查问题会轻松很多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑