news 2026/9/12 4:49:18

STM32 SPI从机DMA输出0xFF问题根因与解决

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 SPI从机DMA输出0xFF问题根因与解决

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模式下,一个正常字节的完整流动路径:

  1. CPU或者主循环调用HAL_SPI_Transmit_DMA,把内存中的发送缓冲区地址和长度告诉DMA。
  2. DMA控制器往SPI的发送数据寄存器TXDR写入第一个字节。
  3. TXDR里的数据被硬件自动装载到发送移位寄存器。
  4. 主机的SCK时钟脉冲到达从机,发送移位寄存器里的8个bit,逐位从MOSI引脚输出。
  5. 发送移位寄存器空了之后,TXDR也空了,硬件置位TXE标志。
  6. TXE置位触发DMA请求,DMA搬运第二个字节到TXDR。
  7. 重复第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 ModeSlave注意不是Slave with CRC
Direction2 Lines Full Duplex全双工,避免只发不收
Data Size8 Bit与主机保持一致
CLKPolarityLow按主机配置来
CLKPhase1 Edge按主机配置来
NSSHardware使用硬件片选
DMA TX DirectionMemoryToPeripheral内存到SPI发送
DMA RX DirectionPeripheralToMemorySPI回收到内存
DMA ModeNormal单次传输,不要用Circular
DMA Data WidthByte和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的所有相关寄存器全部读出来对比数据手册,那个时刻的寄存器快照会告诉你最直接的答案。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/5 17:33:53

鱼龙吃翼龙又被吃:化石定格捕食与尸食双重事件

这块化石的含金量,不在“鱼龙吃翼龙”,也不在“鱼龙又被吃”,而在于两个事件同时被保存在同一块石板上:胃容物记录了一次捕食,骨骼上的痕迹记录了另一次死亡。等于把古生物学里两件极难单独保存的证据,打包…

作者头像 李华
网站建设 2026/9/4 14:26:07

OpenAI企业智能体战略转向:开发者的落地路径与避坑指南

这次我们不聊某个开源模型,而是看一组值得注意的信号:OpenAI 在企业智能体方向上的战略动作正在变密。如果你平时关注 AI 智能体开发、企业级 AI 落地、Codex、Dify、Coze 这类关键词,会明显感觉到 OpenAI 的目标已经不只是一个“对话模型供应…

作者头像 李华
网站建设 2026/9/4 14:29:10

基于Qt的UDP网络通信工具开发:从原理到实践

简介:本资源是一个基于Qt框架实现UDP网络通信的完整示例工程,面向Qt初学者与嵌入式/物联网方向开发者,解决跨平台实时数据传输中轻量级无连接通信的实践问题。压缩包共18个文件,包含3个头文件(.h)、2个源码…

作者头像 李华
网站建设 2026/9/4 15:18:46

grep 转了半分钟?rg 搜正则到底快在哪

grep 转了半分钟?rg 搜正则到底快在哪 【免费下载链接】ripgrep ripgrep recursively searches directories for a regex pattern while respecting your gitignore 项目地址: https://gitcode.com/GitHub_Trending/ri/ripgrep 打开一个三万行的老仓库&#…

作者头像 李华
网站建设 2026/9/4 8:22:50

Mermaid流程图代码化:让流程图不再重复手工重绘

很多团队的流程图,一直停留在“画一遍、改一遍、再重画一遍”的状态。产品逻辑变了,流程图要重画;需求文档更新了,架构图要重画;评审会上大家对着图争论,回头发现图又落后于代码。真正的问题不是画图的技巧…

作者头像 李华