Nucleo-H753ZI做SPI从机、配好DMA之后,用逻辑分析仪怼在MOSI引脚上,看到一整片整齐划一的 0xFF 0xFF 0xFF 0xFF。任你往DMA发送缓冲区里写什么,主机读回来的都是这一副“我没数据可发”的嘴脸。这个问题标题我猜不少人都搜索过,我第一次遇到时也折腾了一整个晚上。想清楚之后发现,SPI从机加DMA这套组合,坑不在“怎么配”,而在于“从机到底什么时候才算是准备好”。
这篇文章直接把从现象到根因的完整链路拆开,把最终能跑通的配置方法、代码骨架和排查顺序都写出来,给同样被0xFF折磨的开发者一条捷径。
1. 先从现象说起:0xFF到底是谁发出来的
1.1 用逻辑分析仪确认第一手的波形
排查这种问题,第一步永远是抓波形,不是看代码。拿一个逻辑分析仪,或者干脆上示波器,把SCK、MOSI、MISO、CS这四根线全部接上,触发条件设成CS下降沿,然后让主机发起一次SPI传输。
我当时抓到的结果是:CS正常拉低,SCK正常输出8个时钟脉冲,但从机的MOSI线上从头到尾都是高电平。主机把收到的数据打出来,清一色0xFF。主机端看起来一切正常,时钟也有、片选也有,就是从机那边“不说话”。
这里要特别强调一下:MOSI线上全是高电平,和“从机发了0xFF”是两回事。高速连续的高电平只能说明一件事——从机的发送数据线上,压根没有任何有效数据被移位出来。
1.2 0xFF的本质:空闲电平和空寄存器
SPI是同步串行协议,从机的数据输出完全靠主机的SCK时钟一点一点“挪”出来。从机的发送移位寄存器里有什么,SCK一来,就串行地把这些bit送出去。
问题在于:如果从机发送移位寄存器里什么都没有,那么SCK来了之后,MOSI引脚会保持一个默认的空闲电平。这个空闲电平由CPOL决定:
| CPOL配置 | 空闲电平 | 主机读到的字节 |
|---|---|---|
| CPOL=0 | 低电平 | 0x00 |
| CPOL=1 | 高电平 | 0xFF |
大部分默认配置CPOL=0,但STM32的HAL库默认初始化时,如果你没仔细看时钟极性,或者主机的配置和从机不统一,从机输出高电平空闲态太常见了。再说了,就算CPOL=0,如果发送寄存器里有个残留的0xFF,同样会输出0xFF。
所以,看到0xFF,先别急着怀疑“数据被谁加密了”,本质就是发送端没有任何有效数据可供移位。
1.3 认清一个事实:从机没有自己的时钟
SPI从机是一个完全被动的角色。它不能主动发起传输,它只能等主机的SCK来了,才把当前发送移位寄存器里的内容推出去。
如果你理解了这一点,就会明白一个关键结论:从机的数据必须“预装载”。也就是说,在主机的SCK到达从机引脚之前,从机的发送数据寄存器TXDR里必须已经有一个完整的字节在待命。否则,SCK第一拍打过来的时候,移位寄存器里是空的,产生了第一个0xFF。
这个“预装载”概念,就是整篇文章的核心。后面所有关于DMA启动时机的坑,都源于此。
2. 从机DMA传输的完整链路,以及链路上的断点
2.1 一个字节是怎么从内存走到MOSI引脚的
要排查0xFF,必须先清楚从机在DMA模式下,一个正常字节的完整流动路径:
- CPU或者主循环调用HAL_SPI_Transmit_DMA,把内存中的发送缓冲区地址和长度告诉DMA。
- DMA控制器往SPI的发送数据寄存器TXDR写入第一个字节。
- TXDR里的数据被硬件自动装载到发送移位寄存器。
- 主机的SCK时钟脉冲到达从机,发送移位寄存器里的8个bit,逐位从MOSI引脚输出。
- 发送移位寄存器空了之后,TXDR也空了,硬件置位TXE标志。
- TXE置位触发DMA请求,DMA搬运第二个字节到TXDR。
- 重复第2到第6步,直到全部数据发完。
这条链路里,任何一环断了,最终表现都是MOSI线上没有有效数据,主机读到0xFF。
2.2 这张链路上最常见的五个断点
根据我自己的经验,以及群里帮人看过的类似问题,断点基本集中在五个地方:
第一,DMA压根没启动。很多人以为SPI初始化完成之后DMA就会自动工作,实际上从机侧必须显式调用发送函数,DMA才会被使能。CubeMX生成的初始化代码里,DMA通道只是配置好了,但并没有启动传输。
第二,DMA请求映射错误。STM32H7的DMA通过DMAMUX把外设请求映射到具体的DMA通道。如果在CubeMX里把SPI1的TX请求错配到别的外设,或者生成代码后手动改过DMA配置,DMA永远收不到TXE触发。
第三,DMA方向配置反了。发送要用MEMORY_TO_PERIPH,也就是从内存搬到外设。如果配成PERIPH_TO_MEMORY,方向反过来,DMA会把SPI收到的数据写到内存里去,发送线上自然什么都没有。
第四,NSS片选没把从机“选中”。使用硬件NSS模式时,如果主机的CS引脚没有接到从机的NSS,或者电平逻辑不对,从机在内部就没有进入被选中状态,SCK来了也不干活。
第五,初始化顺序问题。DMA还没启动,主机就已经开始发SCK了。第一批时钟全部打在空寄存器上,从机输出的就是连续0xFF。等你后面启动DMA,数据是能发了,但第一批数据已经丢了。
2.3 为什么“先启动DMA”是从机模式的铁律
前面提到的“预装载”,落实到代码里就是一句话:从机侧必须在主机发起传输之前,先把DMA传输函数调用起来。
具体过程是这样的:调用HAL_SPI_Transmit_DMA之后,DMA会立即把第一个字节写入TXDR。此时TXDR满,TXE不再置位,DMA进入等待状态,数据就“挂”在发送寄存器里待命。主机的SCK一到,第一个字节就能正常发出。发出后TXE置位,DMA继续搬运下一个字节,环环相扣。
如果反过来,等主机SCK已经开始跑了才调HAL_SPI_Transmit_DMA,那么从SCK开始到DMA写TXDR这段空窗期,发送移位寄存器里什么都没有,输出就是0xFF。而且这个0xFF会占据第一个字节的位置,后续数据整体错开一个字节。
我之前用过一个月饼模子的类比:主机是压模机,SCK是压模的动作,从机的TXDR是模具里的馅料。你要在压模机压下来之前就把馅料放进去,压出来才是完整的月饼。压完了才放馅,压出来的只能是空壳。
3. 我的实际排查过程:从CubeMX到寄存器逐级验证
3.1 第一步:核对CubeMX里的SPI和DMA配置
当时我先把CubeMX配置一项一项过了一遍。SPI1初始化里Mode选择的是Slave,Direction是2 Lines Full Duplex,Data Size是8 Bit,CPOL和CPHA都设成了Low和1 Edge。这几个都是常规配置,看起来没什么问题。
DMA配置那页才是重点。我打开DMA Settings,看到SPI1_TX和SPI1_RX两个请求都加上了。TX的DMA方向是MemoryToPeripheral,RX的DMA方向是PeripheralToMemory,模式都是Normal,数据宽度是Byte。到这里还是没什么异常。
但有一个细节当时没注意,后来才发现问题:CubeMX里DMA请求下拉框里选择的通道,如果在生成代码之后被手动改过,或者选择时没注意是否匹配SPI1的DMAMUX请求,很容易出现DMA通道配置了但请求信号对不上的情况。这个在后面用寄存器验证时暴露出来了。
3.2 第二步:确认NSS到底有没有把从机“选中”
NSS是个容易被忽略的环节。在CubeMX里,SPI的NSS配置有两种:Hardware和Software。如果选了Hardware,从机就会监控NSS引脚的电平来决定是否接受SCK。
我当时的接线是:从机的NSS引脚接到了主机的CS引脚,理论上CS拉低的时候从机应该被选中。但示波器一量,发现从机NSS引脚上的电平确实被拉低了,所以问题不在这里。
不过多提一句:如果你的硬件NSS没有接对线,或者主机的CS配置成了软件控制而不是硬件自动拉低,那么从机的NSS可能一直处于高电平。在STM32H7上,硬件NSS模式下NSS为高意味着从机内部就认为“没被选中”,SCK来了也白来。这种情况下你看到的现象同样是MOSI持续0xFF。
如果你不想依赖外部片选信号,可以改用软件NSS模式,让从机始终保持被选中状态。前提是你的SPI总线上只有一个从机,或者你有其他方式管理片选。我这里最终用的还是硬件NSS,因为主机端的片选控制更直观。
3.3 第三步:用寄存器现场判断DMA有没有被触发
软件配置看完了,接下来就得看运行时的实际状态。我当时把调试器连上,在主机发起传输的同时,暂停从机程序,读几个关键寄存器。
第一个看的是SPI的状态寄存器SR。重点看两个位:TXP(发送数据包等待)和EOT(传输结束)。如果TXP是1,说明发送数据寄存器已经准备好接收新数据,但没有数据写进去;如果EOT有过置位记录,说明传输已经发生过但被卡住了。
第二个看的是DMA的NDTR寄存器,也就是剩余传输计数。这个寄存器非常直观:如果DMA被触发过,NDTR会随着数据传输逐步递减。如果NDTR一直保持初始值,说明DMA压根没有收到任何请求信号,链路在DMA这一环就断了。
我读了一下DMA通道的NDTR,果然一动不动。这时候基本可以断定:DMA从未被SPI的TXE信号触发过。
第三个确认点是DMA中断状态寄存器。我看了LISR和HISR,没有任何传输完成标志,也没有错误标志。这说明DMA连一次搬运都没发生过,连搬错方向的机会都没有。
然后回头再去查DMAMUX配置,果然——CubeMX生成的代码里,SPI1的TX DMA请求映射到了DMAMUX的某个输入通道,但那个输入通道对应的不是SPI1_TX,而是另一个外设的请求。这种问题在H7上特别容易出,因为H7的DMA不再是F1那种固定的“DMA1的通道3等于SPI1_TX”,而是要通过DMAMUX做一层请求映射,配置错了表面上完全看不出来。
3.4 第四步:最终定位到的根因
问题到这里就清楚了:DMA请求映射错误导致SPI发送寄存器空了之后,TXE置位,但DMA根本不知道要干活,TXDR一直没有新数据写入,发送移位寄存器只能持续输出空闲电平,主机读到的自然就是0xFF。
修好DMAMUX映射之后,问题立刻消失。从机DMA正常搬运数据,主机读到的字节和发送缓冲区完全一致。
如果你排查时发现DMA其实能触发,NDTR也在跑,那我建议你把注意力放到NSS和初始化顺序上。这三个方向——DMA映射、NSS未选中、DMA未提前启动——基本覆盖了90%的SPI从机0xFF问题。
4. 能跑通的配置和代码,直接抄
4.1 CubeMX中需要确认的配置项
下面的表格是我最终确定的一套能正常工作的配置,基于Nucleo-H753ZI,SPI1接口。你可以对照着检查自己的工程。
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| SPI Mode | Slave | 注意不是Slave with CRC |
| Direction | 2 Lines Full Duplex | 全双工,避免只发不收 |
| Data Size | 8 Bit | 与主机保持一致 |
| CLKPolarity | Low | 按主机配置来 |
| CLKPhase | 1 Edge | 按主机配置来 |
| NSS | Hardware | 使用硬件片选 |
| DMA TX Direction | MemoryToPeripheral | 内存到SPI发送 |
| DMA RX Direction | PeripheralToMemory | SPI回收到内存 |
| DMA Mode | Normal | 单次传输,不要用Circular |
| DMA Data Width | Byte | 和SPI数据宽度匹配 |
DMA的优先级可以设High,尤其在SPI速率比较高、系统还有其他DMA任务时,避免TXE信号来了之后DMA被其他任务抢占导致数据断流。
4.2 从机侧的完整代码骨架
CubeMX生成的基础代码我就不贴了,重点说几个必须手动确认和修改的关键点。
第一个是DMA请求映射。在H7上,生成代码后要打开main.c或者其他初始化文件,找到MX_DMA_Init()函数,检查DMA通道的请求ID是否和SPI1匹配。具体可以参考STM32H7参考手册中DMAMUX的请求映射表。如果不匹配,要修改DMA_HandleTypeDef的Init.Request参数。
第二个是启动DMA的时机。下面这段代码推荐放在main函数里,所有外设初始化完成之后、主循环开始之前:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_SPI1_Init(); // 从机发送缓冲区,数据可以来自任何地方 uint8_t spi_tx_buf[8] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}; uint8_t spi_rx_buf[8] = {0}; // 关键:在主机SCK到来之前,先把DMA跑起来 if (HAL_SPI_TransmitReceive_DMA(&hspi1, spi_tx_buf, spi_rx_buf, 8) != HAL_OK) { Error_Handler(); } while (1) { // 主循环不需要处理SPI,DMA会自己搬 // 可以在这里处理其他任务 } }注意这里我用的是HAL_SPI_TransmitReceive_DMA,而不是HAL_SPI_Transmit_DMA。原因后面专门讲,先记住从机侧尽量用收发一体的函数。
第三个是传输完成回调。当主机发完预期的字节数后,从机的DMA传输完成,会触发回调:
void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { // 这一轮传输结束,可以处理spi_rx_buf里的数据 // 如果需要持续服务,可以在这里再次调用HAL_SPI_TransmitReceive_DMA // 注意:如果是单次传输需求,不需要重复调用 } }如果你需要从机反复响应主机的多次传输,就在回调里重新调用一次DMA启动函数,让数据始终保持“预装载”状态。如果只做单次应答,回调里处理完数据就够了。
4.3 主机侧最简单的验证程序
没有主机侧的配合,很难确认从机到底有没有发对。我当时用了另一块Nucleo当主机,写了一段最朴素的验证代码:
// 主机侧,SPI1配置为主模式 uint8_t host_rx_buf[8] = {0}; uint8_t host_tx_buf[8] = {0xA5, 0x5A, 0xA5, 0x5A, 0xA5, 0x5A, 0xA5, 0x5A}; // 片选拉低 HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); // 主机发送8个字节(全双工,同时接收从机返回的8个字节) HAL_SPI_TransmitReceive(&hspi1, host_tx_buf, host_rx_buf, 8, 1000); // 片选拉高 HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); // 打印host_rx_buf,如果从机工作正常,应该收到0x11 0x22 0x33 0x44 0x55 0x66 0x77 0x88主机发送的是什么无所谓,关键是持续给SCK,从机的DMA才有机会把数据搬出去。
4.4 故意制造相同的0xFF来验证排查结果
我调试的时候有个习惯:确定了根因之后,会故意把故障条件重新触发一遍,确认现象一致,再从反方向验证修复有效。
当时我做了三个对照实验:
第一个实验:注释掉主循环前的那行HAL_SPI_TransmitReceive_DMA,让从机不启动DMA。主机立刻读回来全部的0xFF。这说明“DMA未启动”确实是0xFF的充分条件。
第二个实验:把NSS引脚强制拉高,即使DMA跑着,主机同样读回0xFF。这说明NSS未选中也可以独立导致同样的现象。
第三个实验:恢复NSS拉低,恢复DMA提前启动,主机读到了预期数据。三个实验做完,整个因果链就闭环了。
如果你也遇到0xFF,不妨照这个思路做对照实验。找到那个能稳定复现0xFF的操作,你就找到了根因。
5. 这几个连环坑,建议一次看完
5.1 数据宽度与FIFO阈值的组合
STM32H7的SPI带FIFO,数据宽度和FIFO阈值对DMA请求的行为有直接影响。如果你SPI配了8位数据宽度,DMA也配Byte,通常没问题。但如果你把SPI配成16位,DMA还是Byte,或者反过来,就会出现一个字节拆成两次搬、数据错位的情况。
H7 SPI的FIFO阈值通过SPI_CFG1寄存器里的TXFTH和RXFTH配置。当数据宽度是8位时,FIFO阈值保持默认即可;改成16位时,要注意DMA的数据宽度必须同步改成HalfWord,否则每次DMA搬运的字节数和SPI期望的数据格式对不上,数据流会乱。
如果数据流乱掉了,主机读到的不一定是连续的0xFF,而可能是第一个字节正常、后面的字节全乱。这种错位现象和0xFF的成因不同,千万别混在一起排查。
5.2 溢出错误OVR会让一切卡死
从机如果只调用了HAL_SPI_Transmit_DMA,也就是只发送不接收,主机那边发过来的数据会被SPI硬件接收,填入RXDR。如果RXDR满了你又不去读,硬件会置位溢出标志OVR。
OVR一旦置位,SPI外设会进入错误状态,需要软件清标志才能恢复。在HAL库中,溢出后SPI的收发都会停摆,这时候主机看到的同样是0xFF或者干脆没有数据。
避免这个问题最省事的方案,就是我从一开始就强调的:从机侧用HAL_SPI_TransmitReceive_DMA,把接收通道同时打开。哪怕你不关心主机发来的数据,也要开一个接收缓冲区和接收DMA,让RXDR及时被读走,防止溢出。
5.3 Circular模式的滥用
DMA的Circular模式适合无限循环的数据流,比如ADC连续采样、音频播放。但在SPI从机应答这种“发完一批就停”的场景里,Circular模式会带来一个隐藏问题:传输结束后DMA会自动从头开始搬运,如果发送缓冲区里的数据恰好都是0xFF,看起来就像MOSI在持续不断地输出0xFF。
有些开发者为了图省事,把DMA设成Circular以为可以少管一些启动逻辑,结果就是莫名其妙多出一堆0xFF。
我的建议是:从机应答场景老老实实用Normal模式,配合DMA传输完成回调来管理下一轮传输。这样每一轮的数据边界都清晰可控。
5.4 时钟极性和相位的潜移默化
CPOL和CPHA配置错误不会直接导致0xFF,但会影响数据在SCK的哪个边沿被采样。如果主从两边的极性相位不一致,主机采样到的bit可能全部偏移,看起来就像收到了一串乱码或全1。
尤其注意一点:CPOL=1时SPI总线空闲电平是高。当你的从机发送寄存器为空时,MOSI保持高电平,主机按位采样读出的字节自然就是0xFF。这种0xFF和“完全没数据”的0xFF在外观上是一样的,但根因不同。
如果抓波形发现MOSI确实有数据,只是主机采错边沿读成全1或全0,优先检查两边CPOL/CPHA是否一致。
5.5 复位后SPI寄存器的默认状态
STM32H7的SPI外设复位之后,SPE默认是0,外设处于关闭状态。如果你的初始化顺序有误,比如DMA配置好了,但SPI的SPE位没有置1,那么即使DMA开始搬运,数据也进不了移位寄存器。
在HAL库中,HAL_SPI_Init调用后SPE的置位由后续的传输函数完成。这意味着在第一次调用HAL_SPI_TransmitReceive_DMA之前,SPI从机实际上并没有处于完全工作状态。所以在CubeMX生成的MX_SPI1_Init函数之后,最好先确认SPE已经被使能,再启动DMA。
你可以直接读一下SPI1的CR1寄存器,SPE位应该是1。如果不是,就要检查初始化流程里是否有报错,或者hspi的State状态是不是进入了异常分支。
6. 最后再做一轮实测收尾
6.1 用示波器看波形
修复之后,我重新抓了一轮波形作为验证。这次MOSI线上不再是连续高电平,而是能清楚看到0x11、0x22、0x33这些数据字节对应的电平翻转。CS拉低期间,每个SCK时钟周期都对应一个有效的输出bit,CS拉高之后,MOSI回到空闲电平。
这个波形和之前的0xFF波形形成了非常鲜明的对比,所有关于“从机坏没坏”的疑虑在看到这个波形的一瞬间全部打消了。
6.2 最终的工作状态描述
从机程序最终的状态是这样的:上电初始化之后,发送缓冲区就通过HAL_SPI_TransmitReceive_DMA“预装载”到了SPI发送寄存器。主机任意时刻发起传输,从机都能第一时间响应,无需额外握手。每次传输完成之后,DMA回调会把接收缓冲区里的数据做一次拷贝或标记,然后重新启动下一轮DMA。
用这套方案,我在H753ZI的SPI1上跑通了从机DMA的完整收发,主机频率从1MHz一路拉到几十MHz,数据都稳定一致。
6.3 一套一页纸的排查顺序
最后分享一个我自己总结的排查顺序,遇到SPI从机DMA发不出数据,按这个顺序走一遍,大部分问题都能定位:
第一,抓波形。确认MOSI线上是持续高电平还是偶尔有翻转。要是不抓波形直接改代码,很容易白忙活。
第二,查NSS。用示波器或万用表确认从机NSS引脚的电平确实被拉低,硬件NSS模式下从机没有选中就不会干活。
第三,查DMA映射。核对DMAMUX请求是否对应SPI的TX和RX。这一步最容易出错,因为H7的DMA请求不是固定通道,配置错误表面看不出任何异常。
第四,查DMA方向。发送是MemoryToPeripheral,接收是PeripheralToMemory,反了就废了。
第五,查启动时机。确认在主机SCK开始之前,从机已经调用了HAL_SPI_TransmitReceive_DMA。
第六,查溢出标志。看看SPI的SR寄存器里OVR有没有置位,置位了就清掉再说。
这六步走完,不敢说100%解决所有问题,但“从机输出0xFF”这个现象背后绝大多数原因,都逃不出这些检查项。如果你照着排查完还是碰到0xFF,大概率是某个寄存器状态和预期不一致,这时候建议把调试器挂上,单步停在主机发数据的那一刻,把SPI和DMA的所有相关寄存器全部读出来对比数据手册,那个时刻的寄存器快照会告诉你最直接的答案。