资讯动态

Nucleo H563ZI SPI+DMA通信故障排查:中断正常但DMA失败的解决指南

发布时间:2026/8/30 12:00:14 来源:尧图企业网站定制
上周调Nucleo H563ZI的SPI时遇到一个特别典型的怪问题用HAL库中断API和ADC芯片通信一切正常换成DMA API就死活读不到数据。我在这个板子上折腾了一整天把CubeMX配置翻了个底朝天最后发现原因居然藏在一个很不起眼的细节里。这篇文章把这次完整排查过程写出来梳理SPIDMA在H5系列上的常见坑给正在用Nucleo H563ZI做ADC采集、或者遇到类似“中断正常但DMA失败”问题的朋友一个可以直接照抄的排障思路。先交代一下测试环境主控是Nucleo H563ZISTM32H5系列Cortex-M33内核SPI外设挂在SPI1上ADC用的是一颗很常见的SPI接口芯片MCP320812位分辨率、8通道采样时需要主机先发命令字节再读数据典型的半双工但全双工时序也能驱动。接线是标准4线SPI加片选SCK、MOSI、MISO、CS板上3.3V供电共地。1. 问题场景中断能通、DMA不通的典型现象1.1 Nucleo H563ZI SPI ADC 的调试环境先用CubeMX生成工程SPI1配置为全双工主机模式速率设成1MHz起步CPOL/CPHA根据MCP3208数据手册设为Mode 0数据宽度8位。片选用软件控制因为后续要连续读多个通道软件CS更好控制。DMA这边给SPI1_RX和SPI1_TX分别分配了通道方向分别是外设到内存、内存到外设模式Normal。这些配置都不算冷门但问题恰恰是在这种“看起来全对”的配置下出现的。为了排除时序干扰我一开始没有加任何滤波和重试逻辑就是最简单的“拉低CS、发命令、收数据、拉高CS”。中断版本一次读回一个12位ADC值数值和万用表实测电压完全对得上换成DMA版本之后收到的数据要么是0x0000要么是0xFFFF偶尔还能读到几次正确值但完全不可靠。1.2 现象不是“完全不工作”而是“时序怪怪的”最迷惑人的地方就在这里不是完全黑屏死机而是“偶尔出数、多数情况下乱码”。如果ADC输出是0或者满量程还可以怀疑是接线问题但它会在正确的值附近跳变这就说明SPI链路大概率是通的DMA配置未必全错但一定在某些环节上没衔接好。我后来拿逻辑分析仪抓了波形才确认DMA模式下SCK照常翻转MOSI上的命令字节也正常发出来了问题出在MISO采样窗口上。MCU的SPI外设本身没有犯错是DMA搬运数据的时机、长度、和FIFO行为跟中断模式不一样导致从MISO上读回来的数据没有按预期排列。至于为什么会这样得从HAL库两种模式的实现差异说起。1.3 先做一轮排除法轮询 vs 中断 vs DMA在深挖之前我先做了一个很关键的三组对照实验这组实验花不了几分钟但对定位问题帮助极大模式调用API实验结果初步结论轮询HAL_SPI_TransmitReceive正常每通道都能读到正确的12位数据SPI物理链路、时序配置没问题中断HAL_SPI_TransmitReceive_IT正常中断驱动路径没问题DMAHAL_SPI_TransmitReceive_DMA乱码/全0/全F问题大概率在DMA配置或DMA与SPI的协同上轮询和中断都正常等于把“ADC芯片坏了”“CS拉错脚了”“SPI主从模式配反了”这些低级问题全部排除。最后问题一定收窄在DMA这条路径上。这也是这篇文章最想传达的排障思路当你遇到“换一种API就不工作”的情况先用第三种方式做交叉验证能帮你省下一大半时间。2. 为什么DMA模式的坑比中断模式多HAL库SPI通信机制拆解2.1 中断模式其实是在“被迫同步”很多朋友以为中断模式“比DMA慢但更稳定”这个说法并不准确。SPI中断模式的本质是每当SPI外设的RXFIFO里出现一个数据、或者TXFIFO空出一个位置硬件就产生一个中断由CPU跑到中断服务函数里把数据搬走。这个过程虽然由中断触发但CPU在每个字节之间做的都是同步操作——读SPI-DR寄存器的时候数据必然已经在FIFO里等着了。反过来看DMA模式CPU在启动DMA之后就撒手不管了。SPI外设通过DMA请求信号告诉DMA控制器“我有数据要搬”或“我需要数据”DMA控制器负责搬运搬运结束产生DMA中断再通知CPU。这个链路上多了一级“DMA请求→DMA仲裁→总线传输”的中间环节任何一环配置错误都会让最终数据对不上。2.2 DMA模式真正干活的不是HAL而是外设事件的搬运工HAL库在DMA模式下做的事情比中断模式少很多。以HAL_SPI_TransmitReceive_DMA为例它的工作大致是检查SPI状态机没有忙、设置TX/RX缓冲区地址和长度、通过__HAL_LINKDMA把DMA句柄和SPI句柄关联起来、使能SPI的TX/RX DMA请求、然后启动发送DMA和接收DMA。之后HAL库就返回了真正的数据流转全部由DMA硬件完成。这里有一个非常关键但容易忽略的点HAL_SPI_TransmitReceive_DMA在启动时会做一次SPI状态检查。如果SPI状态机还停留在上一个操作的BUSY状态比如上一次DMA传输还没结束、或者OVR标志位没清函数会直接返回HAL_BUSY但你的代码如果没检查返回值就会以为DMA已经启动了。实际上DMA根本没跑起来ADC当然读不到数据。中断模式之所以不容易踩到这个坑是因为中断模式在上一轮结束后CPU已经处理了完成回调状态机被复位得更彻底。2.3 H5系列DMA的特殊之处DMAMUX、通道与请求映射STM32H5系列和F4系列在DMA模块上有个很明显的差异。F4的DMA是“流通道”结构外设请求和DMA流之间是固定映射比如SPI1_RX只能在DMA2的Stream0上工作。H5系列改用DMAMUXDMA请求多路复用器之后灵活了很多但也更容易配错。H5的DMA1/DMA2每个通道都可以通过DMAMUX映射到不同的外设请求CubeMX会自动生成类似hdma_spi1_rx.Init.Request DMA_REQUEST_SPI1_RX;的配置。如果你手动改过DMAMUX请求号或者把DMA通道分配给了一个和SPI不匹配的请求SPI的DMA请求信号根本不会触发这个通道的搬运动作。最典型的症状就是SCK正常翻转、MOSI正常发命令、但MISO数据完全没被收进来因为RX DMA根本没有被SPI的RX请求唤醒。2.4 FIFO、突发模式与数据宽度的一致性H5系列SPI外设内置了FIFO默认情况下HAL库会按照你配置的数据宽度8位或16位自动处理FIFO阈值。但如果你手动调整了DMA的突发模式Burst Mode比如设成4字节或8字节突发而SPI的FIFO阈值没有对应匹配DMA控制器会在SPI FIFO未达到突发长度时一直等待造成通信卡死或数据错位。数据宽度也是一个高频翻车点。MCP3208是12位ADC但我用的是8位SPI模式每次读两个字节再拼合。如果你直接把DMA的PeriphDataAlignment和MemDataAlignment都设成16位硬件会按16位边界去搬移数据最终收到的两个字节会交换顺序或者错位。CubeMX默认生成代码通常没问题但很多朋友习惯在DMA配置界面把“Data Alignment”改成16位以获得“更高效率”结果反而踩坑。实测下来和SPI数据宽度保持一致是最稳妥的选择。3. 排查实录从CubeMX配置到DMA中断回调一步步找到根因3.1 第一步检查CubeMX生成代码的初始化顺序CubeMX生成代码的顺序是用户不可直接修改的但手动添加代码时容易破坏初始化顺序。我的工程里DMA初始化在MX_DMA_Init中完成SPI初始化在MX_SPI1_Init中完成CubeMX默认顺序是DMA先于SPI。如果你在main函数里手动添加了自定义的GPIO或外设初始化代码一定要确保它放在DMA初始化之后、SPI通信之前。有一个很容易被忽视的细节MX_DMA_Init里会调用HAL_NVIC_SetPriority(DMA1_Channel1_IRQn, 0, 0)和HAL_NVIC_EnableIRQ(DMA1_Channel1_IRQn)。如果DMA中断没被使能即使DMA搬运完成你也永远不会收到完成回调程序会一直等待。我见过不少朋友在CubeMX里配置了DMA但没勾选DMA中断导致回调永远不执行。3.2 第二步确认实际调用的HAL API不是“读错函数”这个错误听起来很蠢但实际排查中真的很常见。对于MCP3208这类需要主机发送命令才能读取数据的ADC应该使用HAL_SPI_TransmitReceive_DMA同时提供发送缓冲区和接收缓冲区。如果图省事直接调用HAL_SPI_Receive_DMASPI外设只启动接收方向不会向ADC发送任何命令字节ADC就永远不会启动转换MISO线上更不会有数据回来。为什么中断模式用HAL_SPI_Receive_IT却可能“碰巧能用”部分ADC在上电后会自动输出上一次转换结果或者CS引脚被拉低后会在SCK第一个沿输出默认数据。这种情况下即使不发命令也能读到一些看似合理的值但实际数值不是当前通道的实时采样值。所以排查时要先确认你调用的API和ADC的通信协议是否匹配建议两边都用TransmitReceive因为即使芯片不要求发命令多发一个无意义的字节也不会破坏读时序。3.3 第三步检查DMA回调函数名与DMA状态接着把注意力放在DMA传输完成回调上。HAL库在DMA传输完成后会通过SPI的句柄触发回调名字为HAL_SPI_RxCpltCallback或HAL_SPI_TxRxCpltCallback。我见过有人用的函数名是HAL_SPI_RxHalfCpltCallback半传输回调或者SPI的HAL_SPI_DMAStop被误调用导致DMA传输被提前终止。排查方法很简单在回调函数入口打一个断点或者置一个标志位在main loop里轮询。如果DMA搬运正常结束这个回调必然会被触发。如果回调没触发回到HAL_SPI_TransmitReceive_DMA的返回值上你会在调试界面看到返回HAL_BUSY或HAL_ERROR这比抓波形更快定位问题。3.4 第四步检查缓冲区是否放进了DMA访问不到的内存池这是目前最隐蔽、也是最难发现的问题。H5系列和F4一样有DTCM RAM等普通CPU总线可以访问、但DMA无法直接访问的存储区。CubeMX生成的链接脚本通常会把部分默认变量放在DTCM区域如果你用普通的全局数组当DMA接收缓冲区它可能根本不在DMA地址映射范围内。我这次踩的坑就在这里我把ADC接收缓冲区定义成了一个普通全局变量数组结果它被分配到了DMA访问不到的区域。中断模式下CPU自己读SPI-DR不依赖DMA去访问内存所以一切正常。一旦切到DMA模式DMA尝试往这个地址写数据但总线矩阵直接忽略了这次写入接收缓冲区永远是空的。解决方法是给缓冲区加上对齐和段属性强制放到DMA可达的RAM区__attribute__((aligned(4))) uint8_t spi_rx_buffer[2];如果问题依旧可以进一步显式放到SRAM区比如H5的SRAM1__attribute__((section(.sram1), aligned(4))) uint8_t spi_rx_buffer[2];H5系列有多个RAM区域具体哪个DMA能访问要看参考手册里总线矩阵那一章。排查时最直接的办法是看一眼变量的地址如果落在0x20000000这个DTCM/SRAM范围内查一下对应的区域是否支持DMA访问。如果不确定直接把缓冲区定义为全局数组加aligned(4)通常就能避开这个坑。3.5 第五步检查SPI的OVR标志与DMA的Mode设置最后检查的是SPI的溢出标志位。DMA模式下如果DMA请求的优先级比当前SPI的数据速率慢或者CPU和DMA总线争夺太激烈SPI的RXFIFO可能在DMA还没来搬走数据之前就满了硬件会置上OVR标志。一旦OVR置位后续接收到的数据会被丢弃HAL库如果没处理到你拿到的就是一份错位的垃圾数据。排查时用__HAL_SPI_GET_FLAG(hspi1, SPI_FLAG_OVR)看一眼是不是置位。如果是通常要把SPI的FIFO阈值调低、降低SPI时钟频率、或者调高DMA通道优先级。另外DMA的Mode设置也值得检查SPI通信一般用Normal模式如果你设成了Circular模式第一次传输完成后DMA会继续搬运而你第二次手动启动DMA时DMA可能在不需要的时候就开始搬运数据和SPI的时序完全错开。ADC连续采集确实经常用到Circular但那是ADC外设加DMA的场景不是SPI通信的场景。4. 解决与优化让SPIDMA真正跑起来的完整配置方案4.1 一套能直接抄走的CubeMX配置模板以下是我最终确认可用的CubeMX配置组合适用于Nucleo H563ZI SPI1 MCP3208这种“ADC需要先发命令再收数据”的芯片配置项设置值说明SPI1 ModeFull-Duplex Master用全双工模式发命令和收数据SPI1 Clock分频后实际SCK约1MHzMCP3208最高支持1.6MHz SCLK有余量CPOL / CPHALow / 1 EdgeMode 0符合MCP3208时序Data Size8 bit每次收发一个字节两次拼合12位数据SPI1 RX DMADMA1 Channel X方向PeriphToMemoryCubeMX自动生成DMA_REQUEST_SPI1_RXSPI1 TX DMADMA1 Channel Y方向MemoryToPeriphCubeMX自动生成DMA_REQUEST_SPI1_TXDMA ModeNormalSPI通信用Normal不要选CircularDMA IncrementPeriph不变Mem递增收多字节时缓冲区地址自动递增DMA Data AlignmentByte8位和SPI数据宽度保持一致DMA PriorityHigh避免和ADC采样等其他DMA竞争时被饿死NVIC使能DMA1_ChannelX_IRQn必须使能DMA中断否则完成回调不触发这里额外说一句SPI速率不要一开始就拉满。先把SCK降到1MHz左右确认DMA链路通了再往上提。MCP3208这类芯片对SCK时序其实很宽容但H5的SPIDMA在高速下更容易暴露FIFO溢出问题。稳是第一位的性能可以后面慢慢调。4.2 核心代码实现TransmitReceive_DMA 与回调处理CubeMX生成基础代码后我自己补了一个简单的状态机用标志位告诉主循环“这次转换完成”。核心代码长这样/* 定义在文件顶部的全局变量 */ __attribute__((aligned(4))) uint8_t spi_tx_buffer[2]; __attribute__((aligned(4))) uint8_t spi_rx_buffer[2]; volatile uint8_t spi_dma_done 0; /* 从指定通道读取一次12位数据 */ uint16_t mcp3208_read_channel(uint8_t ch) { uint16_t result 0; uint8_t cmd 0; /* 拼装命令字节: 起始位 单端模式 通道号 */ cmd 0x06 | ((ch 0x07) 0); /* 实际写法依据芯片时序微调 */ spi_tx_buffer[0] 0x00; spi_tx_buffer[1] cmd 4; spi_dma_done 0; HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); /* 关键: 全双工模式, 发命令同时收数据 */ HAL_StatusTypeDef status HAL_SPI_TransmitReceive_DMA(hspi1, spi_tx_buffer, spi_rx_buffer, 2); if (status ! HAL_OK) { HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); return 0; } /* 等待DMA完成回调, 加超时保护 */ uint32_t timeout 10000; while (!spi_dma_done timeout--) { /* 等待 */ } HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); /* 拼合12位有效数据 */ result ((spi_rx_buffer[0] 0x1F) 8) | spi_rx_buffer[1]; return result; } /* DMA传输完成回调, CubeMX生成的HAL库里已声明为weak, 这里直接重写 */ void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { spi_dma_done 1; } }这段代码里有两个容易被忽视的点。第一我用的是HAL_SPI_TransmitReceive_DMA而不是HAL_SPI_Receive_DMA因为MCP3208必须收到命令字节才开始采样。第二我在等待时加了超时保护避免DMA异常导致程序卡死在while循环里。实际工程里更推荐用信号量或事件标志组如果跑FreeRTOS可以直接用osSemaphoreRelease和osSemaphoreAcquire替换这个标志位。4.3 优化细节FIFO阈值、突发模式与片选时序SPIDMA跑通了之后可以进一步做几个优化让通信更稳定。先看FIFO阈值。H5的SPI有FIFO默认情况下HAL库会根据数据宽度自动设置阈值但你可以在CubeMX的SPI高级参数里手动调。对于8位数据宽度我建议把FIFO阈值设为1/4即1字节这样DMA请求更频繁但延迟更低不容易溢出。再看突发模式。CubeMX的DMA配置里默认是Single也就是每次传输一个数据单元。如果你改成4字节突发理论上能降低DMA仲裁开销但前提是SPI FIFO能持续为DMA提供足够数据。实测下来在SPI这种低速外设上Single模式完全够用突发模式反而容易在SPI FIFO空窗期卡住。不追求极限吞吐的话保持Single最稳。片选时序值得单独说一句。MCP3208对CS有具体时序要求CS拉低之后要等一小段时间再给SCK转换结束后CS要拉高足够长的时间让芯片复位。如果你的代码里CS和SPI传输之间的时间间隔太短ADC很可能还没准备好就开始接收命令产生偶发错误。排查时可以在逻辑分析仪上量一下CS低电平到第一个SCK沿的时间如果小于ADC数据手册里规定的tCSSU就加一点延时。还有一个被经常问到的扩展点多通道或多ADC同时采集。假如你要用DMA连续扫描多个SPI ADC不能简单地一次性启动一个很长的DMA传输就完事。原因是每个通道的CS需要单独控制DMA本身不会帮你翻转CS。常见做法是启动DMA传输时把CS拉低DMA完成回调里把CS拉高并在回调里切换下一通道的命令字节然后再次启动DMA。如果你需要固定节奏连续采样可以在DMA完成回调里直接发起下一次传输形成一个“DMA链”这样可以做到CPU几乎不参与每通道的数据搬运。5. 常见问题速查表与调试心得5.1 问题排查速查表我把这次调试过程中遇到的所有问题和可能的解决方案整理成一张表留着以后遇到类似情况直接对照症状可能原因排查/解决方法DMA回调不触发DMA中断未使能或NVIC优先级配置错误检查CubeMX的NVIC设置确保DMA通道IRQ被使能接收缓冲区全0缓冲区在DMA访问不到的RAM区域加aligned(4)属性或显式放到SRAM1/.sram1段接收数据错位/字节顺序乱DMA数据宽度与SPI数据宽度不一致统一设为8位或16位不要一个Byte一个HalfWord偶尔能读对多数时间乱码SPI的OVR溢出或FIFO阈值不合适降低SPI时钟调低FIFO阈值提高DMA优先级CPU卡死在等待回调调用的是Receive_DMA但ADC需要先发命令改用TransmitReceive_DMA提供发送缓冲区用F4的配置原样搬到H5失败DMAMUX请求号或DMA通道号配置错误重新用CubeMX自动分配DMA通道检查Init.Request值复位后第一次通信成功第二次失败SPI状态机未复位上次传输仍在BUSY状态检查返回值传输完成后调用__HAL_SPI_CLEAR_OVRFLAG清标志数据看起来正确但ADC值不变只发了读命令但没切换通道号或CS控制失效检查命令字节中的通道号确认每个通道前CS都重新拉低拉高这张表覆盖了SPIDMA组合里80%以上能见到的坑。如果你遇到的情况不在表里建议先用逻辑分析仪抓CS、SCK、MOSI、MISO四根线的波形和ADC数据手册里的时序图对一遍基本都能定位。5.2 实测下来的独家避坑技巧很多经验是普通文档里不会写的。第一个技巧是板子复位之后第一次通信成功、第二次失败的问题90%是状态标志没清。HAL库在DMA模式下的状态机比中断模式更容易残留最稳的办法是在启动DMA传输前显式调用__HAL_SPI_CLEAR_OVRFLAG(hspi1);把溢出标志清零。这个操作成本极低但能消除大量偶发问题。第二个技巧是如果你在多个外设上同时使用DMA比如SPI一个通道、ADC一个通道一定要检查DMA通道优先级。H5的DMA控制器支持事件优先级但不代表优先级高的通道能永远抢占总线。实测下来把ADC采样DMA设为最高优先级、SPI通信DMA设为次高优先级能避免ADC在持续采样时把SPI的DMA请求饿死。如果你想让SPI连续读多个ADC数据最推荐的做法是把SPI的DMA优先级设得比ADC高。第三个技巧非常容易踩但很少有人提在调试打印时不要往DMA正在传输的缓冲区里写数据。我曾经过早地往spi_rx_buffer里塞了调试信息结果DMA正在往这个地址写ADC数据两个写入源冲突最终缓冲区里的数据完全不可用。调试打印一定要复制到另一个临时缓冲区再输出或者等DMA完成回调触发之后再读取。第四个技巧和“DMA continuous requests”这个热词相关。做ADC多通道扫描循环采样时确实会用到DMA的Circular模式加Continuous Requests那是ADC外设的经典用法。但这个经验不能直接搬给SPI通信。SPI通信每次传输涉及CS控制、命令字切换、时序协同用Circular模式很难把状态机理清楚。除非你对DMA和SPI交互非常熟悉否则SPI的DMA老老实实设Normal就好。5.3 后续扩展多通道扫描、多ADC并联与DMA链这篇文章主要讲“中断能通、DMA不通”的修复但把基础打通之后很多人会想继续扩展功能比如用SPIDMA做多通道扫描采样。基于我这次的经验给你一个可行的设计思路不要试图在一个DMA传输里做完所有通道而是每个通道都用一次TransmitReceive_DMA在DMA完成回调里切换到下一个通道的命令字再启动下一次传输。伪代码大概是这样typedef struct { uint8_t tx[2]; uint8_t rx[2]; uint16_t result; } spi_adc_channel_t; spi_adc_channel_t chs[8]; uint8_t current_ch 0; void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { chs[current_ch].result ((chs[current_ch].rx[0] 0x1F) 8) | chs[current_ch].rx[1]; current_ch; if (current_ch 8) { HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); HAL_SPI_TransmitReceive_DMA(hspi1, chs[current_ch].tx, chs[current_ch].rx, 2); } else { current_ch 0; } } }这种“DMA完成回调里启动下一次DMA”的方式本质上就是数据手册里常见的DMA链式传输。CPU只负责切换通道号和CS实际数据搬运全部交给DMA吞吐量比中断模式高一个量级而且不会漏采样。对于多ADC并联的场景也是一样的思路只是每个CS引脚对应一片ADC回调里切换CS引脚和命令字就行。最后再分享一点实战心得这块Nucleo H563ZI的SPIDMA问题调完之后我最大的体会是HAL库把底层寄存器包装得越方便反而越容易让人忘记外设硬件本身的协作过程。很多人以为“配置好CubeMX就能跑”但SPI和DMA之间的握手、FIFO阈值、缓冲区内存归属这些恰恰是CubeMX不会替你自动纠错的环节。中断模式能通、DMA模式不通本质上是两种模式下硬件协作的方式不一样你的代码只需要适配其中一种不代表另一种也能捡现成的。如果你现在正在被类似问题困扰我的建议是准备一个逻辑分析仪把CS、SCK、MOSI、MISO全部抓出来先确认时序对不对再去看DMA通道和缓冲区。时序对的情况下DMA配置一般半小时就能排查完时序不对那就要回到ADC芯片数据手册逐项核对这一步省不得。这板子和HAL库我后面还会继续用这批坑记录下来之后再做多通道ADC采集和DMA链传输就顺畅多了。希望这篇文章能帮你省下我当时花掉的一整天。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价