ADC+DMA缓冲区随机0的排查:从采样时间到DDS配置
2026/9/8 1:44:20 网站建设 项目流程

做过多通道ADC采集的嵌入式工程师,十有八九见过这种场面:波形在示波器上干净利落,数据落到DMA缓冲区之后却隔三差五冒出一个0,而且位置还不固定。我最初是在一套8通道模拟量采集板上栽的跟头,STM32F407的ADC1开了扫描模式,配合DMA循环搬运到内存,统计分析时总有间歇性跳零。起初怀疑传感器坏了,又怀疑前端运放虚焊,把调试器挂在内存窗口里盯了半天,硬件波形一直是好的,缓冲区里却实实在在出现了0。

这个现象在英文社区里有个很经典的问题描述:Random 0's in DMA buffer when using ADC+DMA。它表面上是DMA的锅,但真正查起来会发现,DMA只是个老实巴交的搬运工,问题往往藏在ADC配置、缓冲区尺寸、采样时间这些容易被忽略的细节里。这篇文章就把我在这类问题上的完整排查思路、修复方案和验证手段整理出来,给正被同样现象折磨的朋友一个可复现的参考。

1. 复现现场:先把“随机0”拆成三种情况

1.1 典型的故障配置长什么样

先说一个典型场景,方便对号入座。MCU用的是STM32F407,ADC1配置为4通道扫描模式,软件触发,连续转换开启,DMA设置为Circular循环模式,缓冲区是一个长度为4的uint16_t数组。代码里没有对缓冲区做任何清零操作,启动后直接调用HAL_ADC_Start_DMA开始采集。

运行之后用调试器观察,缓冲区可能长这样:

// 第一次读取 adc_buf[0] = 0x0A2D; adc_buf[1] = 0x0000; // 异常0 adc_buf[2] = 0x0B12; adc_buf[3] = 0x0A2D; // 下一次读取 adc_buf[0] = 0x0A2D; adc_buf[1] = 0x0B12; adc_buf[2] = 0x0000; // 异常0跑到这里来了 adc_buf[3] = 0x0B12;

注意“随机”两个字很关键。如果0固定在某个位置,比如每次都是adc_buf[1]为0,那是通道配置或DMA搬运顺序的问题,比较好定位。但如果0的位置在变,甚至有时候一整个周期都正常,那就说明不是单纯的“某个通道坏了”,而是时序或者配置层面的问题。

1.2 三种“随机”对应的不同排查方向

我后来总结了一个分类方法,遇到随机0先别急着改代码,先分清楚它属于哪一类。

现象特征优先排查方向
0固定出现在缓冲区某个索引位置DMA长度参数、规则序列寄存器配置、通道顺序
0出现在缓冲区不同位置,但概率稳定DMA传输长度与缓冲区大小不匹配、读写竞争
0完全随机,通道和位置都没有规律采样时间过短、信号源阻抗过高、连续DMA请求未开启

第一种情况通常是配置错位,比如SQR寄存器里注册了4个通道,但DMA的Length参数只写了1,那么只有第一个通道的数据会被搬运,剩下的位置保持上电初始值0,看起来就是“固定位置的随机0”——位置固定,但值可能在不同启动批次里略有差别。

第二种情况往往是缓冲区尺寸和通道数不是整数倍关系,或者DMA在循环回绕时产生了写覆盖,导致某些位置读到的数据是旧值残留。

第三种情况最难查,因为它涉及ADC模拟部分的采样过程。SAR型ADC在采样阶段需要对内部采样电容充电,如果充电时间不够,转换结果就会不可靠,轻则数值偏低,重则直接得到0。这个在后面专门展开。

2. 搬运链路排查:DMA模式、缓冲区尺寸与内存一致性

2.1 DMA传输长度和缓冲区循环模式之间的关系

DMA出问题的概率其实很低,但配置错误的概率很高。我在多台设备上踩过同一个坑:HAL_ADC_Start_DMA的Length参数写错。这个参数不是“有几个缓冲区”,而是“要搬运多少个半字数据”。在4通道扫描模式下,每完成一个通道的转换就会触发一次DMA请求,搬运一个半字到内存。所以Length应该等于4,而不是1。

如果Length写成1,DMA只搬运第一个通道的转换结果,缓冲区后面三个位置永远保持初始值0。此时如果代码在中断里每隔一段时间读一次缓冲区,你会发现adc_buf[0]一直在变,adc_buf[1]到adc_buf[3]全是0,像是“ADC只采集了第一个通道”。但问题是,ADC本身四个通道都在转,只是后面三个通道的数据没有被搬运到内存而已。

如果把Length写成8,而缓冲区大小只有4,那就更麻烦了。DMA会试图往缓冲区后面写4个半字,直接越过数组边界,写到相邻内存地址去。轻则污染其他变量,重则触发HardFault。缓冲区大小必须和Length严格对应,并且Length最好是通道数的整数倍,这样每一轮转换结果都能被完整搬运。

Circular模式下,DMA写满缓冲区后会重新从头部开始写,相当于环形缓冲。这种模式适合“ADC持续采样、CPU稍后读取”的场景,但有一个隐患:如果你在读取缓冲区的同时DMA正在写入同一个位置,就可能读到半新半旧的数据。好在ADC转换结果是16位,DMA搬运也是半字操作,单次搬运是原子的,不会出现字节撕裂,但可能读到和当前采样周期对不上的旧数据。

2.2 未初始化缓冲区与首次读取的陷阱

还有一种特别容易忽略的情况:缓冲区本身就没初始化。如果定义的数组是全局变量,编译器通常会把它放在BSS段,上电后自动清零。DMA还没开始搬运时,缓冲区里全是0。如果代码在执行HAL_ADC_Start_DMA之后立刻去读缓冲区,读到的一大批0会让你误以为ADC出了问题。

我见过有人用局部数组定义缓冲区,没有加static修饰,结果DMA写入时缓冲区地址已经失效,栈空间被别的函数覆盖,读出来的数据乱七八糟,其中就包含大量0。DMA缓冲区必须确保生命周期覆盖整个采集过程,全局变量或者static修饰的局部变量是最稳妥的选择。

更隐蔽的情况是:DMA已经启动了,但ADC还没有完成第一次转换。ADC从启动到第一个规则通道转换完成需要一点时间,包括校准、稳定、采样、转换这几个阶段。在这段时间里,DMA缓冲区保持初始0。如果代码在首轮转换完成前就对数据做了处理,前面几个样本就是0。解决方法是让ADC完成一次转换后再开始处理数据,具体可以通过DMA传输完成中断来同步。

2.3 带Cache的MCU要处理缓存一致性问题

如果你是STM32H7或者其他带D-Cache的MCU,那“随机0”还有一个完全不同的来源:缓存一致性。

DMA外设直接访问内存,不经过CPU的Cache。如果DMA把数据写到了物理内存,而CPU的Cache里还保留着这一地址的旧数据,CPU读到的就是缓存里的旧值。如果旧值是0,那看起来就是“DMA缓冲区里出现了0”。

这个问题的关键在于缓冲区所在内存区域是否被配置为Cacheable。默认情况下,如果缓冲区定义在普通SRAM,CPU读取时走Cache,DMA写入时不经过Cache,两边数据就对不上。处理方式有两种:一种是把缓冲区放在非Cacheable区域,比如H7的AXI SRAM如果不配置MPU就是非Cacheable;另一种是在读取前主动失效Cache,强制CPU从物理内存重新加载。

// 读取DMA缓冲区前,先失效对应地址的D-Cache SCB_InvalidateDCache_by_Addr((uint32_t *)adc_buf, sizeof(adc_buf));

注意失效的地址要按32字节对齐,否则实际失效范围可能比预期小,反而漏掉一部分数据。缓冲区定义时可以用__attribute__((aligned(32)))强制对齐。

3. 数据链路排查:ADC序列、采样时间与DMA请求的握手

3.1 SQR规则序列:多配、漏配、顺序错乱

DMA链路查完没有问题时,就该把目光放回ADC本身。规则序列寄存器SQR是排查重点。

ADC扫描模式要求你明确指定“这一轮扫描要转换哪些通道、按什么顺序转换”。在STM32的HAL库中,每个通道通过ADC_ChannelConfTypeDef结构体单独配置,但很多人容易忽略一个细节:sConfig.ChannelsConfig.Rank必须和初始化结构体中的NbrOfConversion对应。比如你要转换4个通道,就必须调用4次HAL_ADC_ConfigChannel,并且Rank分别设为1、2、3、4。如果漏配了一个Rank,或者两个通道的Rank重复,扫描序列就会错位,某些通道的数据不会出现在预期位置。

寄存器层面,多通道扫描时SQR1、SQR2、SQR3共同决定了序列顺序。调试时可以打开调试器的外设寄存器窗口,直接核对SQR寄存器的内容,确认每个Rank对应的通道号是否和代码意图一致。

还有一点容易被忽略:如果配置了扫描模式,但没有在SQR中注册通道就直接启动ADC,某些MCU上转换不会触发DMA请求,缓冲区保持全0。这类问题在F1系列上尤其常见,因为F1的ADC规则通道配置比较死板,漏配任何一个SQR位都可能让整个扫描序列失效。

3.2 采样时间过短:SAR ADC的“接水桶”效应

这一节可能是整篇文章里最能解释“完全随机0”的地方。

SAR ADC的转换过程分为采样和转换两个阶段。采样阶段,内部采样开关闭合,外部信号源通过一个很小的开关电阻向内部采样电容充电。充电时间由采样时间决定,如果采样时间太短,采样电容还没充满就开始转换,比较器的输入电压就不等于真实信号电压。

用生活里的话说,这就像拿一个水桶去接水管的水,接水时间太短,桶里的水位就偏低。ADC转换结果就相当于在“桶里水位”的基础上做量化,水位不够,结果自然偏低。当信号源阻抗较高时,充电回路的RC常数变大,需要更长的充电时间。如果采样时间配置为1.5个ADC时钟周期,而信号源阻抗有几十千欧,那每一次采样都可能处在“充了90%还是99%”的不确定状态,转换结果就会忽高忽低,严重时直接采到接近0的值。

多通道扫描模式下这个问题会更突出,因为每个通道的采样时间由SMPR寄存器统一或分别控制。如果一个通道的信号源阻抗高、另一个通道阻抗低,共用同一套采样时间配置,高阻抗通道就容易出现偶发跳零。

修复方法很直接,把采样时间加大。STM32F4系列的SMPR2寄存器可以设置3到480个周期的采样时间。对于高阻抗信号源,建议至少设置84周期以上。我一般先试84周期,如果还有跳变,直接上480周期。代价是ADC采样率下降,高频率采样场景需要权衡。

sConfig.SamplingTime = ADC_SAMPLETIME_84CYCLES;

3.3 连续转换与DMA请求的握手细节

这是“随机0”的另一个高频根因,而且非常隐蔽。

在STM32F1和F4系列上,ADC的DMA请求通过ADC_CR2寄存器的DMA位使能,每次规则通道转换完成都会产生DMA请求。F1比较简单,只要DMA位使能,DMA请求就一直存在。但F4有一个DDS位(DMA disable selection),如果DDS被清零,那么最后一个规则通道转换结束后,DMA请求会被禁止;如果DDS置1,DMA请求持续产生。

在HAL库中,这两个配置一般由ContinuousConvModeDiscontinuousConvMode控制。如果开启了连续转换模式,DDS通常也会被置位,DMA请求就是持续状态。但如果你用的是库函数的低级配置,可能只开了DMA位,没设置DDS,就会出现一个诡异现象:第一轮扫描的4个通道数据正常搬运到缓冲区,之后ADC虽然在继续转换,但DMA请求被禁用了,缓冲区里的数据不再更新。下一轮读取时,只有前4个位置有值,后面全是0。

在F0、G0、L4这些较新的系列上,ADC_CFGR寄存器里的DMACFG位明确区分了两种模式:DMACFG=1是连续DMA请求,每一次规则通道转换都会触发DMA请求;DMACFG=0是单次模式,只有整个序列转换完成后才触发一次DMA请求。如果这里的配置和代码期望不一致,缓冲区就会出现“只更新一部分位置,其他位置全是0”的现象。

从现象上看,这种0不是完全随机,而是“一开始有数据,后面全是0”,或者在缓冲区的前半部分有数据、后半部分是0。但如果代码在多个地方读取缓冲区,读取时机不一样,看到的0的位置也会不同,容易误判成随机。

调试这类问题有一个很实用的技巧:在调试器里观察DMA的NDTR寄存器(剩余传输次数寄存器)。如果NDTR持续在减小,说明DMA一直在搬运;如果NDTR固定在0,说明DMA已经完成了一轮传输,而且没有循环启动。这个信息能帮你快速判断是ADC请求问题还是DMA配置问题。

4. 修复代码:从HAL到寄存器级,以及GD32适配

4.1 STM32 HAL标准修复示例

如果用的是STM32CubeMX生成工程,建议手动核对这几个关键配置。我贴一下F407上的标准配置,可以作为对照模板。

ADC初始化部分:

hadc1.Instance = ADC1; hadc1.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV4; hadc1.Init.Resolution = ADC_RESOLUTION_12B; hadc1.Init.ScanConvMode = ADC_SCAN_ENABLE; // 扫描模式必须开启 hadc1.Init.ContinuousConvMode = ENABLE; // 连续转换,配合DMA循环 hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.NbrOfDiscConversion = 0; hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START; // 软件触发,简单可靠 hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion = 4; // 4个规则通道

DMA初始化部分:

hdma_adc1.Instance = DMA2_Stream0; hdma_adc1.Init.Channel = DMA_CHANNEL_0; hdma_adc1.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_adc1.Init.PeriphInc = DMA_PINC_DISABLE; hdma_adc1.Init.MemInc = DMA_MINC_ENABLE; hdma_adc1.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; hdma_adc1.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; hdma_adc1.Init.Mode = DMA_CIRCULAR; // 循环模式,持续搬运 hdma_adc1.Init.Priority = DMA_PRIORITY_HIGH;

启动部分:

uint16_t adc_buf[4]; // 缓冲区长度必须等于NbrOfConversion HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, 4);

这段配置里最容易被改错的就是最后的Length参数。很多人在CubeMX里生成代码后,习惯性把Length改成缓冲区大小,值填了数组字节数而不是半字数,结果DMA一直搬运超出范围的数据,缓冲区后面被写满垃圾值。正确做法是填半字个数,也就是ADC转换结果个数,对应通道数乘以采样轮数。

4.2 寄存器级排查关键点

HAL库封装了太多细节,排查问题时容易抓瞎。这时候直接看寄存器更直观。

ADC方面,重点检查ADC1->CR1的SCAN位和ADC1->CR2的DMA位、CONT位。F4系列注意CR2的DDS位,必须为1,确保DMA请求连续产生。

DMA方面,F4每个DMA流都有独立的NDTR寄存器,比如DMA2_Stream0的NDTR在地址0x40026410附近,CubeMX生成的工程可以在调试器外设窗口里直接查看。NDTR的值表示还有多少个数据要传,如果它在不断变化,说明DMA正常工作;如果固定为0,说明传输已经完成,并且没有重新启动。

如果NDTR停在0,而ADC还在转换,那就要检查DMA请求是否被阻断。常见原因包括DMA配置成了Normal模式而非Circular模式,或者ADC的DDS位被清零导致请求被禁止。把DMA改成Circular,把DDS置1,问题通常就解决了。

4.3 GD32同源问题的适配修改

GD32的ADC和DMA架构与STM32非常相似,但库函数接口有差异,网上搜到GD32 ADC DMA的帖子也很多。这里说两个重点。

第一个是DMA请求模式。GD32F30x系列中,使能ADC的DMA后,还要配置DMA请求是“单次”还是“多次”。对应函数是adc_dma_request_after_transfer,参数可以传ADC_DMA_REQUEST_ONCEADC_DMA_REQUEST_MULTIPLE。如果用循环DMA并且希望ADC持续产生DMA请求,必须选MULTIPLE。选成ONCE的话,第一次转换完成后DMA请求就停了,缓冲区后面全是0,现象和STM32F4上DDS没置位一模一样。

adc_dma_mode_enable(ADC0); adc_dma_request_after_transfer(ADC0, ADC_DMA_REQUEST_MULTIPLE);

第二个是DMA配置的细节。GD32的DMA初始化结构体里,方向、地址递增、数据宽度这些字段和STM32很接近,但外设基地址和内存基地址的类型不一样,有些固件库要求显式赋值,不能依赖默认值。尤其是内存地址递增这一项,如果忘了使能,DMA会反复往同一个地址写,缓冲区其他位置始终保持0。

GD32不同型号的固件库函数名有差异,如果找不到adc_dma_request_after_transfer,就在adc库头文件里搜一下dma_request,通常能找到对应的配置接口。

5. 验证与防复发:用数据说话

5.1 数据验证方法

修复之后不能只看一两眼就收工,要用数据验证确认问题真正解决。

我的做法是写一个简单的统计函数,采集连续N个样本,统计0值出现的次数和位置。N取10000,如果修复前0值出现几十次,修复后应该一次都没有。

uint32_t zero_count = 0; uint32_t zero_positions[100]; for (uint32_t i = 0; i < 10000; i++) { if (adc_buf[i % 4] == 0) { zero_count++; if (zero_count < 100) { zero_positions[zero_count - 1] = i % 4; } } }

如果0值完全消失,说明数据链路的时序问题已经解决。如果还有零星0,重点看它的位置。固定位置说明DMA配置还有问题,随机位置则更可能是采样时间不足或信号源阻抗问题。

如果有信号发生器,可以输入一个已知幅值的正弦波,观察DMA缓冲区里的数据是否连续平滑。正弦波数据里如果出现明显的凹陷到0,基本可以确定是采样或时序问题。

5.2 防复发检查清单

踩过几次坑之后,我总结了一个检查清单,每次写ADC+DMA的初始化代码都会过一遍。

检查项正确配置错误后果
DMA Length参数等于半字个数,即通道数乘以轮数缓冲区部分位置保持0
DMA工作模式CircularNormal模式下传输完成后停止
ADC扫描模式开启只有最后一个通道有数据
ADC连续转换开启只能采集一轮
F4的DDS位置1DMA请求在首轮后停止
F0/G0/L4的DMACFG置1只有单次DMA请求
缓冲区生命周期全局或static局部数组地址失效
Cache一致性失效Cache或配置Non-CacheableCPU读到旧值或0
采样时间根据信号源阻抗调整偶发跳变到0

这个清单不敢说覆盖所有MCU型号,但在STM32和GD32这两大系列上,能解决绝大多数“ADC+DMA缓冲区随机0”的问题。

调试这类问题,我的习惯是先把DMA排除掉,再查ADC配置,最后才怀疑模拟前端。因为DMA的配置项少,寄存器一目了然,查起来快;ADC配置项多,时序复杂,需要慢慢梳理。每次看到“随机0”,提醒自己一句话:DMA不会凭空造数据,0要么来自没被更新的内存,要么来自没充满电的采样电容。顺着这个思路往下查,方向就不会歪。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询