STM32H563 SPI+DMA读取外部ADC完整调试记录与代码方案

发布时间:2026/8/30 9:01:03
STM32H563 SPI+DMA读取外部ADC完整调试记录与代码方案 如果你手头有一块Nucleo H563ZI开发板想让SPI接口的外部ADC在DMA模式下高效工作却发现中断方式一切正常、DMA方式却死活不通——这个场景我太熟了。这篇文章不是教科书式的API说明而是把我实际调试Nucleo H563ZI SPI ADC HAL中断/DMA的完整过程、踩坑记录和最终可用的代码方案记录下来。无论你是刚开始接触STM32H5系列还是被SPI DMA折腾到怀疑人生这篇内容应该能帮你省下至少一个晚上的排查时间。先说结论SPI用中断方式读ADC能通过说明你的时序配置、引脚连接、ADC寄存器配置大概率都没问题问题几乎都出在DMA请求链路、SPI的DMA使能位、以及全双工模式下TX/RX DMA的配合方式上。这几个坑一旦捋清楚DMA方案其实比中断方案更稳、更省CPU。1. 整体设计与思路拆解为什么SPIDMA读ADC会“看起来很难”1.1 外部ADC与主控的常见连接方式做数据采集系统时主控和外部ADC之间最常用的接口就是SPI。市面上常见的ADC芯片比如ADS1256、AD7606、AD7616、ADS131M04几乎都提供了标准的SPI接口区别只在于寄存器配置方式、转换时钟要求、以及是单端还是差分输入。SPI最大的优势就是引脚少、速率高、时序灵活。和并行接口比SPI只需要4根线SCK、MOSI、MISO、CS却能跑到几十MHz的时钟和I2C比SPI没有地址应答机制硬件上更简单也更适合高速连续采样。在Nucleo H563ZI上使用SPI驱动ADC时H563ZI的SPI外设最高支持很高的时钟频率但实际波特率取决于外部ADC芯片的手册规格。比如ADS1256的SCLK最高支持约1/4的CLKIN频率AD7606的串行接口则支持最高几十MHz的SCLK。因此配置SPI分频时不要一味追求高波特率要以上位芯片的时序要求为准。1.2 为什么HAL中断API能跑通而DMA API不行这是整个调试过程中最值得深挖的问题。SPI中断模式和DMA模式的核心区别在于数据搬运的触发方式不同。中断模式下SPI每收到一个字节就触发一次RXNE中断CPU到ISR里去读DR寄存器然后把数据存到数组里。这个流程的优点是链路短、状态直观出问题容易定位缺点是每接收一个字节CPU都要被中断一次高频采集时CPU占用率非常高。DMA模式则完全不同。SPI收到数据后不再触发CPU中断而是由SPI外设产生一个DMA请求信号DMA控制器收到请求后直接通过总线把DR寄存器里的数据搬运到指定的内存地址。整个搬运过程CPU完全不需要参与搬完一整批数据后才触发一次DMA传输完成中断。链路变长的同时出问题的点也就变多了。我总结了一下SPI DMA收不到数据或数据异常核心原因逃不出这几个方向SPI外设的DMA请求没有使能RXNE/TXE事件产生了但不会触发DMA于是数据堆在DR寄存器里没人管。DMA通道没有正确关联到SPI的请求映射请求信号发出来了但DMA控制器不知道该去找谁。DMA通道配置了但中断优先级、NVIC使能没配置导致DMA传完了但没有进入完成中断回调看起来就像“卡死”。全双工模式下只配置了RX DMA忽略了TX DMA导致没有SCK时钟产生ADC根本不会把数据移出来。DMA的传输宽度、内存递增模式配置错误导致收到数据错位、覆盖或总线错误。其中第4条是很多人忽略的“隐藏知识”。SPI是全双工协议主机的SCK信号要靠发送数据才能产生。ADS1256、AD7606这类ADC读取转换结果时主机必须在MISO线上读取数据的同时通过MOSI线持续发送时钟也就是说如果你只启动了RX DMA没有启动TX DMASCK根本不会翻转MISO上自然也就不会有任何数据。1.3 方案选型为什么最终用TransmitReceive_DMA而不是单独调Transmit和Receive调试过程中我一开始走了弯路分开调用HAL_SPI_Transmit_DMA和HAL_SPI_Receive_DMA结果第二个函数总是返回HAL_BUSY。原因是HAL库的SPI状态机设计成多线程不安全的连续调用两个DMA API会互相干扰句柄状态。后来我改用HAL_SPI_TransmitReceive_DMA一发一收同步启动问题立刻解决。这里的关键点是SPI全双工模式下发送和接收是同时发生的。TX DMA每发一个字节RX DMA就同步接收一个字节两者配合才能产生完整的SCK时钟序列。虽然ADC芯片的MISO数据是“伪随机”的我们根本不在乎主机发了什么但主机必须要“假装发送”才能把时钟转起来。这个心智模型和很多人理解的“SPI是个只读接口”完全不同。2. 核心细节解析与实操要点CubeMX配置和代码实现2.1 CubeMX里的关键配置Nucleo H563ZI使用STM32H563ZI芯片属于STM32H5系列基于Cortex-M33内核。需要特别注意的是H5系列的DMA控制器和F1/F4系列不完全一样它支持可编程的突发传输、FIFO和更灵活的外设请求映射。所以你在CubeMX里看到的DMA请求名称、中断服务函数名称和以前F4系列的习惯不完全一致。CubeMX中SPI配置的主要参数如下SPI模式选Full-Duplex Master这是最常用的配置。硬件NSS可以关闭由GPIO软件控制片选。这里建议用软件控制CS因为不同ADC芯片对片选时序的要求差异很大硬件NSS无法灵活适配。波特率分频根据ADC芯片手册设置比如ADS1256可以选8分频或16分频。CPOL/CPHA根据ADC数据手册大多数ADC支持模式1CPOL0, CPHA1或模式0配置前务必核对。帧格式选8bitMSB First。然后进入DMA Settings给SPI1添加两个DMA请求SPI1_TX和SPI1_RX。方向选MemoryToPeripheral和PeripheralToMemory优先级建议都选High。如果要做连续采样DMA模式选Circular否则Normal即可。CubeMX会自动生成SPI和DMA的初始化代码。生成之后你需要手动检查一个非常关键的地方那就是SPI的DMA使能位。CubeMX生成的代码里通常会主动设置SPI_CFG1寄存器但根据HAL库版本差异有时候需要你手动确认hspi1.Instance-CFG1 | SPI_CFG1_TXDMAEN; // 使能发送DMA请求 hspi1.Instance-CFG1 | SPI_CFG1_RXDMAEN; // 使能接收DMA请求这两个标志位的含义很简单让SPI在发送FIFO为空、接收FIFO非空时主动向DMA控制器发出请求。如果没使能DMA通道配置得再完美也等不到SPI的请求信号。2.2 DMA通道映射与中断配置H563ZI的SPI1外设请求映射到DMA的哪个通道以CubeMX自动生成的代码为准。不要凭经验猜测。在ioc文件中SPI1_TX和SPI1_RX会自动选择可用的DMA请求路由生成后直接查看main.c里的MX_SPI1_Init函数即可。中断配置方面SPI本身的错误中断要打开DMA通道的传输完成中断也要打开。需要注意STM32H5的DMA中断向量名是类似DMA1_Channel1_IRQn的形式具体的通道号要看CubeMX生成结果。两处中断优先级建议DMA更高一些因为数据传输连续性更依赖DMA中断及时响应。如果你在回调函数里加了调试断点却发现程序根本没进DMA中断大概率是NVIC没使能或者优先级分组冲突。检查一下HAL_NVIC_SetPriority和HAL_NVIC_EnableIRQ的调用情况。2.3 完整代码实现HAL_SPI_TransmitReceive_DMA我在实际项目中使用的发送/接收缓冲区是这样的结构#define ADC_FRAME_LEN 4 uint8_t spi_tx_buf[ADC_FRAME_LEN] {0x00, 0x00, 0x00, 0x00}; uint8_t spi_rx_buf[ADC_FRAME_LEN] {0}; volatile uint8_t spi_dma_done 0;启动一次DMA读ADC操作的完整函数如下void ADC_Read_DMA(void) { // 拉低片选准备通信 HAL_GPIO_WritePin(ADC_CS_GPIO_Port, ADC_CS_Pin, GPIO_PIN_RESET); // 清标志位 spi_dma_done 0; // 关键同时启动收发DMA产生SCK时钟 HAL_StatusTypeDef status HAL_SPI_TransmitReceive_DMA(hspi1, spi_tx_buf, spi_rx_buf, ADC_FRAME_LEN); if (status ! HAL_OK) { // 启动失败通常是SPI处于BUSY状态 HAL_SPI_DMAStop(hspi1); HAL_SPI_TransmitReceive_DMA(hspi1, spi_tx_buf, spi_rx_buf, ADC_FRAME_LEN); } }DMA传输完成后HAL库会调用回调函数void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { // 拉高片选一次读取结束 HAL_GPIO_WritePin(ADC_CS_GPIO_Port, ADC_CS_Pin, GPIO_PIN_SET); // 标志位置1主循环处理数据 spi_dma_done 1; } }主循环中的处理逻辑if (spi_dma_done) { spi_dma_done 0; // 解析spi_rx_buf中的ADC数据 uint32_t adc_value 0; adc_value | ((uint32_t)spi_rx_buf[0] 16); adc_value | ((uint32_t)spi_rx_buf[1] 8); adc_value | ((uint32_t)spi_rx_buf[2]); // 根据具体ADC芯片的位宽调整 }2.4 发送缓冲区的隐藏设计为什么是0x00而不是任意值很多第一次做SPI DMA读ADC的人会有个疑问既然主机读ADC时不需要发送有效命令那TX DMA的缓冲区里填什么答案是填0x00的稳定输出。SPI协议下主机的MOSI引脚会跟着TX DMA发送的数据变化同时SCK也会稳定产生。因为ADC芯片的输入在寄存器配置阶段已经完成读取阶段MOSI上的具体电平对ADC没有实际意义但发送0x00可以保证SCK一直存在并且输出波形确定性强方便调试时用逻辑分析仪观察。如果TX DMA缓冲区不填内容或者填了数组初值0xFF也不影响SCK产生但会让MOSI线保持高电平。某些ADC芯片在MOSI为高电平时可能进入特殊模式或产生意外行为所以统一用0x00最稳妥。3. 实操过程与核心环节实现从CubeMX到可运行的工程3.1 完整初始化流程我用STM32CubeMX版本生成工程芯片选STM32H563ZIT6U。下面是最关键的几步操作记录。第一步配置时钟。H563ZI最高主频250MHz外部晶振选24MHz或25MHzCubeMX会自动算出系统时钟。SPI外设时钟来自PLL2或PCLK在Clock Configuration界面里能看到SPI1的时钟源。波特率分频系数按照ADC手册要求设置。我这里选的8分频SPI时钟大约10MHz左右ADS1256完全吃得消。第二步配置GPIO。SPI1_SCK、SPI1_MOSI、SPI1_MISO都选AF5复用功能。CS引脚设为GPIO_Output初始电平为高。注意SPI1的引脚在不同封装上映射不同Nucleo板上常用的是PA5、PA6、PA7但你以实际板子原理图为准。第三步配置DMA。进入DMA Settings添加SPI1_TX和SPI1_RXRX的优先级要高于TX因为接收数据的连续性和完整性更关键。模式方面如果只是单次读取ADC选Normal如果要做连续扫描采集两个DMA都选Circular。第四步生成代码。生成后打开main.c确认MX_SPI1_Init里有以下DMA句柄关联逻辑hspi1.hdmatx hdma_spi1_tx; hspi1.hdmarx hdma_spi1_rx;如果这两个句柄没有关联调用HAL_SPI_TransmitReceive_DMA会直接报错因为HAL库找不到DMA通道配置。第五步检查MX_DMA_Init是否调用了HAL_DMA_Init并且时钟使能顺序是否正确。CubeMX生成的代码一般会自动处理但如果你在代码里手动把DMA初始化移到了SPI初始化之后整个外设请求锁存就会乱套。3.2 中断回调的完整形态H564ZI上的DMA中断回调除了HAL_SPI_TxRxCpltCallback还有一个更常见的方式是HAL_SPI_RxCpltCallback。HAL库的SPI驱动在DMA接收传输完成后会在SPI_DMAReceiveCplt内部调用这个回调。但如果你用的是HAL_SPI_TransmitReceive_DMA最终触发的是TxRx完整回调。我在调试时遇到一个现象单独用HAL_SPI_Receive_DMA时回调是HAL_SPI_RxCpltCallback换成HAL_SPI_TransmitReceive_DMA后回调变成了HAL_SPI_TxRxCpltCallback。这个差异很容易让人摸不着头脑其实HAL库的状态机逻辑就是这样设计的。建议在自己的代码里两个回调都做一层转发统一到一个处理函数void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { ADC_ProcessDMAComplete(); } } void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { ADC_ProcessDMAComplete(); } }这样无论你最终选择哪个API启动传输回调都能落到同一个处理入口。3.3 片选时序的控制细节片选信号的时序是SPI通信中最容易被忽视但又最容易导致ADC误动作的环节。硬件NSS自动片选看似省事但对不同ADC的片选时序适配性差。我始终建议软件控制CS。以ADS1256为例在读取转换结果时CS拉低后需要等待一小段时间从t_CSSCK可以查到然后再启动SPI时钟。但在DMA模式下这个等待时序需要自己控制。我的做法是void ADC_StartConversion_DMA(void) { HAL_GPIO_WritePin(ADC_CS_GPIO_Port, ADC_CS_Pin, GPIO_PIN_RESET); delay_us(1); // CS拉低后的建立时间 spi_dma_done 0; HAL_SPI_TransmitReceive_DMA(hspi1, spi_tx_buf, spi_rx_buf, ADC_FRAME_LEN); }在DMA传输完成回调中CS拉高。如果你遇到读到的第一帧数据全0很可能是CS拉低后立即启动了SCKADC芯片还没来得及把数据准备好。这种情况下要么在启动DMA前增加延时要么在发送缓冲前面补一个哑字节。3.4 DMA连续模式实现无CPU干预的周期采样如果你想让ADC连续采样并且不希望CPU频繁介入可以开启DMA的Circular模式。在Circular模式下DMA会自动循环搬运数据不需要每传完一次就重新启动DMA。这个特性对应了很多人搜索的“DMA continuous requests”——意思就是DMA传输完成后自动重新开始。CubeMX里把DMA的模式选为Circular然后接收缓冲区的数据会不断被覆盖更新#define ADC_SAMPLE_BUF_SIZE 256 uint8_t spi_rx_buf[ADC_SAMPLE_BUF_SIZE]; // 启动后DMA自动循环 HAL_SPI_TransmitReceive_DMA(hspi1, spi_tx_buf, spi_rx_buf, ADC_SAMPLE_BUF_SIZE);此时SPI的TX DMA也必须选Circular因为SCK必须持续产生。这个方案的CPU占用几乎为零适合高频连续采样。有一个细节在Circular模式下HAL_SPI_TxRxCpltCallback会以半传输/全传输两种方式触发HAL库会根据DMA的当前计数状态调用XferCpltCallback或XferHalfCpltCallback。你需要根据项目需求决定是处理全传输还是半传输或者使用DMA双缓冲机制。4. 常见问题与排查技巧实录DMA模式下SPI读ADC的疑难杂症4.1 DMA一直卡住先看SPI的DMA使能位这个点值得单独拿出来说因为遭遇率太高了。我在调试时发现CubeMX生成的代码里SPI_CFG1寄存器中的TXDMAEN和RXDMAEN位不一定被正确设置。如果你的HAL库版本比较老或者你在CubeMX里修改过SPI参数导致代码重新生成这两个使能位可能被覆盖掉。判断方法很简单在启动DMA之前用调试器查看hspi1.Instance-CFG1寄存器的值如果bit14TXDMAEN和bit13RXDMAEN没有置1那么DMA永远等不到SPI请求。解决办法是在初始化后手动设置hspi1.Instance-CFG1 | SPI_CFG1_TXDMAEN | SPI_CFG1_RXDMAEN;需要注意的是要在SPI使能之前配置好这两个位否则可能会产生不可预期的行为。4.2 数据全0xFF或全0x00是什么原因这是DMA模式下一个非常典型的故障现象。全0xFF通常表示MISO线上读到的都是高电平可能的原因有三个片选没有拉低ADC的MISO引脚一直处于高阻或上拉状态。CS时序不对ADC芯片还没进入输出模式。时钟极性配置错误导致MISO上的数据在错误的边沿被采样。全0x00则通常意味着MISO线一直为低电平最常见的原因是ADC芯片的DOUT/DRDY引脚配置错误或者ADC的转换尚未完成你读取的寄存器内容本来就是0。还有一种可能是SPI帧格式配置成了16bit但实际上ADC是24bit数据导致字节错位看起来像0。还有一个容易误导人的情况用逻辑分析仪看MISO波形没问题但代码里读到的却全0。这时要检查DMA的外设地址是否写对了。STM32H5系列SPI数据寄存器是SPI_RXDR地址偏移是0x0C不要写成F1系列的SPI_DR混用。4.3 DMA传输完成中断不触发DMA搬完数据但没有触发中断这个问题的排查思路比较固定。先看DMA通道的传输完成中断是否在NVIC中使能再看DMA的NDTR寄存器是否已经减到0最后看回调函数是否注册到了正确的句柄。一个容易被忽略的设置是DMA通道的优先级抢占和子优先级。如果两个DMA通道的优先级都是0且中断没有正确分组可能导致中断丢失。H5系列的NVIC要确保分组方式统一。还有一个调试技巧在DMA中断服务函数里加一个简单的计数器变量如果确认中断确实触发了但代码没进回调那就是HAL库的状态机没有把SPI句柄状态置为READY。可以在回调里直接调HAL_SPI_DMAStop复位状态机。4.4 时序问题用逻辑分析仪而不是凭感觉我强烈建议在调试SPI DMA时人手一台逻辑分析仪哪怕是几十块钱的24MHz采样率设备也行。SPI时序问题靠眼睛在调试器里盯寄存器效率太低了。用逻辑分析仪重点观察以下几个时间点CS下降沿和第一个SCK上升沿之间的时间间隔是否符合ADC芯片的t_CSSCK要求。SCK频率是否在ADC芯片允许范围内。MISO数据在SCK采样边沿是否稳定。最后一个SCK下降沿和CS上升沿之间的保持时间。这些参数在ADC芯片的数据手册里都有具体数值。比如ADS1256要求CS建立时间约25ns保持时间约25ns而AD7606的串行读取时序略有不同。逻辑分析仪能帮你一眼看出哪个参数不达标。4.5 值得收藏的排查速查表现象可能原因处理办法返回HAL_BUSYSPI状态机未复位先调HAL_SPI_DMAStop再重新启动数据全0xFFCS没拉低/时序错误检查CS引脚增加CS建立延时数据全0x00ADC转换未完成/寄存器值为0确认DRDY信号增加等待时间首帧数据错位缺少前导时钟TX缓冲头部补一个哑字节DMA完成中断不触发NVIC未使能DMA中断检查HAL_NVIC_EnableIRQ调用TX后又调RX失败HAL状态机互斥改用HAL_SPI_TransmitReceive_DMASPI DMA请求不产生CFG1的DMAEN未设置手动设置TXDMAEN和RXDMAEN数据宽度不对DMA PSIZE/MSIZE配置错误确认外设和内存宽度一致4.6 多说一句关于H5与H7的差别STM32H563ZI属于H5系列和H7系列最大的差异之一是H5没有H7那个复杂的D-Cache和I-Cache体系。H7系列经常因为DMA搬运的数据被Cache缓存住导致CPU读到的还是旧数据需要做Cache Clean/Invalidate操作。而H5系列没有这个坑DMA写进内存的数据CPU可以直接看到。这是H5的一个优势也提醒我们排查问题时要针对具体芯片特性不要套用H7的排查思路。不过H5的DMA控制器比F1/F4复杂它支持FIFO、突发传输和数据打包。如果配置错PSIZE和MSIZE还是会出现数据错位。比如外设8bit、内存32bit而没有配置对应打包DMA每次写内存半个字数据就完全乱了。这也是为什么建议直接使用CubeMX默认的8bit对齐不要为了追求性能随意改。5. 一点后续扩展经验项目跑到最后我用SPIDMA的Circular模式实现了ADC的连续采样主循环几乎不用管数据搬运。相比之下中断方案在1kHz采样率时CPU占用率就到了15%左右而DMA方案降到1%以下。这个差距在电池供电或需要实时运行其他算法时非常明显。如果后续你的需求升级到多通道ADC同步采集可以考虑用定时器触发ADC转换转换完成事件再触发SPI DMA读取形成硬件级的采样链路。关于定时器触发外部ADC和DMA协作这部分我后面单独写一篇实测记录这里先提一个思路利用定时器的TRGO事件作为ADC芯片的采样时钟ADC转换完成信号再作为SPI DMA的启动条件这样采样间隔可以做到非常精准而且完全不占CPU。回到这次调试本身我最大的体会是SPI DMA看似复杂实际上只要把“SCK由TX产生”这个模型刻在脑子里再按“先使能DMA请求、再配置通道、最后用TransmitReceive组合启动”的顺序走一遍大部分问题都能迎刃而解。如果你现在正卡在DMA读ADC的问题上按上面几个检查点逐项排查大概率能找到根因。最后再分享一个不起眼但很实用的小技巧调试SPI DMA时把TX缓冲区内容设成0xAA而不是0x00逻辑分析仪上能一眼看出波形形态方便区分“时钟没产生”和“数据线被拉死”这两种完全不同的故障。等到功能调通再改回0x00正式使用。这个习惯帮我省掉了好几次盲猜时间。

相关新闻