做嵌入式这几年,ADC采样这件事本身不算难,难的是怎么把采样做得既快又不拖累主流程。我最近在调一块基于GD32的电源控制板,三相电压采样要求每个工频周期都能拿到足够多的采样点来做有效值计算,CPU还得同时跑PID控制算法和通信协议。最开始我用中断逐通道读取ADC结果,通道一多、采样频率一高,中断服务函数频繁抢占主流程,控制波形直接出现抖动,实时性完全没法保证。后来把ADC和DMA组合起来干活,终于把这块硬骨头啃下来了,采样效率上去了,CPU占用大幅下降,数据也更稳定。这篇博客就把这套基于ADC-DMA协同工作的高效电压采样方案,从硬件设计到软件配置再到排坑实录,完整梳理一遍。
1. 为什么非要把ADC和DMA绑在一起
1.1 一次ADC采样的完整过程
很多人写ADC程序时只是对着例程抄,对内部机制并不清楚。先从最基本的讲起。
单片机上最常见的ADC是逐次逼近型(SAR),核心是一个比较器、一个DAC网络和一个逐次逼近寄存器。可以说它就是在"猜"输入电压到底是多少,每次猜一位,用二分法逐步逼近。12位ADC就是猜12次,从最高位到最低位,每猜一次就把结果寄存器里对应的位置0或置1。到12次猜完,结果就是一个和输入电压成正比的数字量。
一次完整的ADC转换分为两个阶段:采样阶段和转换阶段。采样阶段里,内部采样开关闭合,采样电容通过引脚外部阻抗充电,充电电压要尽可能接近输入电压。转换阶段开关断开,片上DAC和比较器开始逐位比较。这两个阶段都需要时间,时间长短由ADC时钟和配置的采样周期决定。
这里有个关键概念叫采样周期。ADC时钟频率定了之后,每次转换需要的时间 = (采样周期 + 转换周期)/ ADC时钟频率。比如STM32F103的ADC时钟最高14MHz,配置采样周期为13.5个周期,加上12.5个转换周期,那么一次转换要26个ADC时钟周期,换算下来大约是1.86微秒。也就是说,理论上每秒能采样大约50万次。但这是纯ADC的极限,不涉及CPU读数据的时间。
还有一点必须注意:ADC时钟频率不是越高越好。因为SAR架构里比较器需要一定建立时间,时钟太快会导致比较结果不准。所以芯片手册都会给出ADC时钟上限,比如F103是14MHz,GD32F1系列是12MHz。配置时钟分频时,要保证实际ADC时钟不超过上限,否则采样精度打折。
1.2 没有DMA时CPU都在忙什么
中断方式读取ADC结果,流程是这样的:ADC转换完成,硬件置位EOC标志,触发中断;CPU跳进中断服务函数,从ADC数据寄存器把结果读出来,存入数组,然后清标志、恢复现场。每个通道每次转换都要重复一遍,通道越多,中断越频繁。
问题就出在这个"每转换一次就打断一次"上。假设我用3通道扫描采样,每个通道采样周期配置得比较短,整体采样率达到每秒钟20万次。那意味着CPU每5微秒就要被打断一次,中断服务函数就算只做"读寄存器、存数组、清标志"这几件事,也要二三十个时钟周期。CPU主频72MHz的话,就是每5微秒里有0.4微秒在响应中断,大约8%的CPU时间耗在搬数据上。再加上保存现场和恢复现场的开销,实际可能超过15%。
如果这时候主程序还在跑PID运算、跑Modbus协议、处理按键显示,频繁的中断抢占会让所有任务的实时性变差。更难受的是,采样频率越高,中断越密集,CPU越忙,最后形成一个"为了采样而牺牲控制性能"的死循环。这就是我一开始遇到的状况,中断一多,控制波形都出毛刺了。
1.3 DMA介入后带来的变化
DMA(Direct Memory Access,直接存储器访问)本质是外设和内存之间的搬运工。CPU只需告诉DMA控制器"把ADC数据寄存器里的内容,每次转换完成后自动搬到这个数组里,搬完N次通知我一下",剩下的事就完全交给DMA硬件。
DMA最大的价值不是快,而是"不打扰"。ADC转换完成信号直接触发DMA搬运请求,数据从外设数据寄存器搬到内存缓冲区,整个过程不需要CPU参与,也不会打断CPU执行指令。CPU只在缓冲区存储到设定长度或指定数量时收到一次中断,可以一次性批量处理所有数据。
一个很直观的对比:中断方式每采样一个点打断CPU一次,采样10万个点就要打断10万次;用DMA方式,我可以设置缓冲区长度为1000,采样10万个点只用进中断100次,CPU被打断的频次减少了两个数量级。而且DMA支持循环模式,缓冲区存满后自动从头开始覆盖写,相当于一个硬件实现的环形缓冲区,软件永远能拿到最近一段时间的采样数据。
我用一个生活化的类比来解释:中断方式相当于领导每签一个文件都要秘书跑进办公室汇报一次,一天跑几百趟;DMA相当于秘书把文件攒成一摞,全部签完再拿进去盖章,效率完全不一样。
2. 动手前的硬件细节:采样电路和PCB布局
软件写得再漂亮,硬件底子不行,采样数据照样是一堆废数。ADC-DMA协同只能解决"数据搬运效率"的问题,解决不了"采样值本身不准"的问题。这一节把硬件上最容易踩坑的点逐个说清楚。
2.1 电压采样电路的基本形态
最常用的电压采样电路就是电阻分压加RC滤波。被采样电压经过两个电阻分压,把幅度缩放到ADC的输入范围内,再经过一个RC低通滤波器滤除高频噪声,最后送入ADC引脚。
分压电阻取值要根据实际电压范围和ADC参考电压计算。举一个最典型的例子:被测电压范围0~24V,ADC参考电压3.3V,那么分压比要保证最高24V输入时,ADC脚电压不超过3.3V。为了留一点安全余量,按最高25V来算,分压比就是25V:3.3V,也就是大约7.58:1。
分压电阻的选择有讲究。阻值太小,比如几百欧,分压电路自己就会消耗较大电流,发热明显,还会拉低被测信号;阻值太大,比如几兆欧,配合后级RC和ADC内部采样电容,又会让信号建立时间过长。工程上常用几十K到几百K级别。比如我用的是47K和6.8K组合,两者串联后约53.8K,分压比为47/(47+6.8)≈0.873?等下,分压比算反了。输出是下端电阻分压:Vout = Vin × R2/(R1+R2),R2是下端电阻。如果R1=47K、R2=6.8K,分压比是6.8/53.8≈0.126,最高输入24V时输出约3.03V,正好符合3.3V系统。
分压电路之后必须加RC滤波。这个电容的作用是和分压电阻共同构成低通滤波器,截止频率按f=1/(2πRC)计算。假设R等效值约6K(分压网络从ADC引脚看进去的等效电阻约为R1//R2),配一个100nF电容,截止频率大约265kHz。如果配1nF电容,截止频率约26.5kHz。具体怎么选,看被测信号的频率成分。采样工频50Hz的电压,截止频率设到1kHz就够了,没必要太高,太高反而把高频毛刺都放进来。
2.2 采样周期、源阻抗与采样电容的匹配
ADC内部采样电容在采样阶段必须充电到接近输入电压,充电时间由采样周期决定。如果外部源阻抗太大,充电时间常数RC很大,采样时间又不够,电容上的电压还没充到位采样就结束了,转换结果必然偏低。这是很多人遇到"ADC测电压总是偏低"的重要原因。
芯片手册一般会给一个最大源阻抗值。以常见MCU为例,ADC采样时间配置为1.5个ADC时钟周期时,允许的外部源阻抗通常只有1~2K欧姆;改到13.5个周期或更长的采样时间,允许的源阻抗可以放宽到几十K欧姆。这里有个取舍:想提高采样率就要缩短采样时间,缩短采样时间就要求外部电路阻抗必须足够低,否则误差变大。
我的实践经验是,在采样率允许的范围内,尽量把采样周期配得大一点。比如GD32E230的ADC,我配置ADC时钟为8MHz,采样周期配到最大,换来的是更好的精度和稳定性。毕竟DMA已经把搬运数据的时间省下来了,采样周期稍微长那么零点几个微秒,对整体吞吐率的影响远小于数据不准带来的麻烦。
RC滤波中串联电阻的存在还会加剧充电不足的问题。所以在选择RC参数时,不能只看滤波效果,还要评估它对ADC采样精度的影响。一个相对保险的做法是:分压网络之后接一个小阻值电阻(比如100欧姆),再接对地电容,这样既保持了滤波功能,又把ADC引脚看到的源阻抗压低了。这也是很多工业板卡上常见的做法。
2.3 PCB布局的3个要点:绕开时钟抖动和电源噪声
PCB布局直接决定ADC采样数据的实际质量。这一块我交过不少学费,总结出3个最重要的布局要点。
第一个要点是模拟区域和数字区域必须做到物理隔离。ADC输入通道、参考电压、模拟电源属于模拟信号路径,晶振、时钟线、数字总线属于数字信号路径。在空间允许的情况下,把ADC输入走线和对地的参考平面放在一起,数字信号线尽量远离模拟输入线。如果必须在同一层走,模拟线与数字线之间要保持足够间距,最好中间插入地线做隔离。这里有个常见误解,以为整板铺铜就万事大吉,实际上数字信号回流会在地平面上形成噪声,如果ADC输入走线横跨数字噪声区域,采样数据很容易出现随机跳动。
第二个要点是ADC参考电压引脚(VREF+)必须单独去耦。参考电压直接决定ADC的量化基准,基准上有纹波,量化结果就会跟着波动。VREF+引脚附近要放两个去耦电容,典型组合是10uF钽电容并联100nF陶瓷电容,电容地端用独立的过孔直接打到模拟地层,不要和数字电路共用地过孔。很多板的ADC数据漂移,查到最后就是VREF+去耦电容离引脚太远,或者地过孔路径太长,参考电压上叠加了来自数字电路的噪声。
第三个要点是ADC输入走线要短、直、粗,并做好包地处理。ADC输入属于高阻敏感节点,走线太长就变成了一根天线,容易耦合周围时钟线的噪声。时钟信号跳变沿通过寄生电容和互感耦合到ADC输入线上,会导致采样值在特定时间点出现规律性跳变,这种问题用软件滤波都很难完全消掉。所以布局时尽可能把采样电路靠近MCU放置,输入走线尽量短,两侧铺地铜并打上地过孔形成屏蔽,相当于给模拟信号做了一件"防护服"。
另外说一个和电源噪声相关的小细节:模拟电源AVDD和数字电源DVDD通常会用磁珠或小电感隔离,但很多人只在电源入口加了一个磁珠,忽略了MCU模拟电源引脚本身也要单独去耦。AVDD引脚附近至少放一个100nF陶瓷电容到模拟地,这是保证ADC内部模拟电路工作稳定的底线。
3. 配置实操:STM32/GD32的ADC+DMA完整流程
硬件准备好之后,软件这一块的配置路径就清晰了。STM32和GD32的ADC/DMA外设框架基本类似,我以GD32E230为例,这套流程在STM32F1、F4上也通用,原理是一样的。
3.1 时钟、引脚和基础参数的确定
第一步是配置ADC时钟。ADC时钟来自APB2时钟分频,不能直接等于系统时钟。以GD32E230跑72MHz系统时钟为例,ADC时钟分频系数我一般选6到8,得到9到12MHz的ADC时钟,既不超过芯片上限,又能保证采样速度。
第二步配置GPIO引脚为模拟输入模式。这里有一个容易忽略的坑:引脚模式必须设为模拟输入,而不是浮空输入。普通输入模式下引脚内部有施密特触发器,会引入额外电流和噪声,影响模拟信号精度。设置成模拟输入后,施密特触发器被旁路,引脚呈现高阻态,适合接收模拟信号。
第三步配置ADC工作模式。我需要做三相电压采样,三个通道要持续循环采集,所以开启扫描模式和连续转换模式。扫描模式下ADC会自动从规则通道组第一个通道依次转换到最后一个;连续转换模式让ADC转换完一轮后自动开始下一轮,不需要软件反复触发。
这里要特别注意"连续请求"这个配置。GD32的ADC连续转换模式和DMA的Continuous Requests是两个不同层次的配置。ADC侧要开启连续转换,DMA侧也要把DMA的循环模式(Circular Mode)或连续请求模式打开,两个配合好才能实现"自动转、自动搬、不断流"的效果。很多人配置完发现只搬了一次数据就停了,多半就是DMA侧没有开循环模式。
3.2 多通道扫描的DMA缓冲区设计
DMA缓冲区的大小和ADC通道数直接相关,这一点必须算清楚。
假设我用ADC的通道0、通道1、通道2采样三相电压,所有通道都配置为规则通道,扫描模式打开。DMA会按照通道顺序,把转换结果依次写入缓冲区。也就是说,缓冲区第一个单元存通道0的结果,第二个存通道1的结果,第三个存通道2的结果,完成后触发一次DMA传输完成事件,如果配置了循环模式,下一轮又开始从缓冲区第一个单元覆盖写。
缓冲区大小怎么定?直观做法是设置成通道数×2字节(12位分辨率需要2字节存储)。如果开了DMA循环模式,还可以把缓冲区设置成更长的倍数,比如每个通道对应缓冲区放4个元素,相当于用DMA自动做了4轮采样,这样软件读一次缓冲区就能拿到4组三相电压数据,做均值滤波非常方便。缓冲区用uint16_t数组声明,因为12位ADC结果在16位宽度内存储。
配置DMA时,数据宽度必须和外设寄存器、内存地址匹配。ADC数据寄存器是16位,所以我用半字(Half Word)传输。如果配置成字节传输,数据会被截断,采样值明显错误。这一点在从8位单片机切换到32位平台时特别容易被坑。
3.3 启动顺序与代码骨架
完整代码骨架大致如下,关键步骤都加了注释。
// 缓冲区定义,3通道,每通道缓存4轮 #define ADC_CH_NUM 3 #define ADC_BUF_DEPTH 4 uint16_t adc_buf[ADC_CH_NUM * ADC_BUF_DEPTH]; volatile uint8_t adc_dma_done = 0; void adc_dma_init(void) { // 1. GPIO配置为模拟输入 gpio_mode_set(GPIOA, GPIO_MODE_ANALOG, GPIO_PUPD_NONE, GPIO_PIN_0); gpio_mode_set(GPIOA, GPIO_MODE_ANALOG, GPIO_PUPD_NONE, GPIO_PIN_1); gpio_mode_set(GPIOA, GPIO_MODE_ANALOG, GPIO_PUPD_NONE, GPIO_PIN_2); // 2. ADC配置:扫描模式、连续转换、软件触发 adc_clock_config(ADC_ADCCK_PCLK2_DIV_8); adc_conversion_mode_enable(ADC_SCAN_MODE); adc_special_function_config(ADC_CONTINUOUS_MODE, ENABLE); adc_data_alignment_config(ADC_DATAALIGN_RIGHT); adc_channel_length_config(ADC_REGULAR_CHANNEL, ADC_CH_NUM); // 3. 规则通道配置,采样周期尽量长一些 adc_regular_channel_config(0, ADC_CHANNEL_0, ADC_SAMPLETIME_55POINT5); adc_regular_channel_config(1, ADC_CHANNEL_1, ADC_SAMPLETIME_55POINT5); adc_regular_channel_config(2, ADC_CHANNEL_2, ADC_SAMPLETIME_55POINT5); // 4. DMA配置 dma_parameter_struct dma_para; dma_deinit(DMA0, DMA_CH0); dma_para.direction = DMA_PERIPHERAL_TO_MEMORY; dma_para.periph_addr = (uint32_t)(&ADC_RDATA); // ADC数据寄存器地址 dma_para.mem_addr = (uint32_t)adc_buf; // 内存缓冲区地址 dma_para.periph_width = DMA_PERIPHERAL_WIDTH_16BIT; // 半字 dma_para.mem_width = DMA_MEMORY_WIDTH_16BIT; dma_para.number = ADC_CH_NUM * ADC_BUF_DEPTH; // 传输总长度 dma_para.periph_inc = DMA_PERIPH_INCREASE_DISABLE; // 外设地址不递增 dma_para.memory_inc = DMA_MEMORY_INCREASE_ENABLE; // 内存地址递增 dma_para.priority = DMA_PRIORITY_HIGH; dma_init(DMA0, DMA_CH0, &dma_para); // 5. 开启DMA循环模式,关键! dma_circulation_enable(DMA0, DMA_CH0); dma_interrupt_enable(DMA0, DMA_CH0, DMA_INT_FTF); dma_channel_enable(DMA0, DMA_CH0); // 6. 开启ADC DMA请求并启动ADC adc_dma_mode_enable(); adc_enable(); // 等ADC稳定后再触发第一次转换 delay_us(10); adc_software_trigger_enable(ADC_REGULAR_CHANNEL); } // DMA传输完成中断处理 void DMA0_Channel0_IRQHandler(void) { if (dma_interrupt_flag_get(DMA0, DMA_CH0, DMA_INT_FLAG_FTF)) { dma_interrupt_flag_clear(DMA0, DMA_CH0, DMA_INT_FLAG_FTF); adc_dma_done = 1; } }主循环里拿到adc_dma_done标志后,直接批量处理adc_buf数组里的数据。注意DMA是循环覆盖写的,所以处理速度必须快过一轮DMA写满缓冲区的时间,否则数据会被覆盖掉。这其实也是配置缓冲区深度的意义,深度越大,软件处理的时间窗口越宽。
3.4 双缓冲思路:半传输中断的妙用
如果系统里ADC采样率很高,软件又希望在采样的同时进行其他耗时运算,可以用DMA的半传输中断(Half Transfer Interrupt)做双缓冲。原理很简单:DMA传输前半段时,软件处理后半段缓冲区;DMA传输后半段时,软件处理前半段缓冲区。这样缓冲区永远有满半区可以读,也有空半区让DMA写。
具体实现就是把缓冲区长度设置成2的整数倍,开半传输中断和全传输中断两个中断,分别处理前一半和后一半。这个做法我常用于高速信号采集场景,比如电机驱动器里需要同时采样相电流和母线电压。对于一般的电压采样,缓冲区深度调到8或者16,配合全传输中断就够了,不必强制上双缓冲,免得代码复杂度上升。
4. 采样值换算、滤波与校准
DMA把一堆原始ADC值搬进内存后,真正的数据处理才刚开始。原始值和真实电压之间不是简单的比例关系,中间还有量纲转换、噪声抑制和校准问题。
4.1 ADC原始值怎么换算成真实电压
12位ADC的输出范围是0到4095(也有的芯片标称0到4096,这个差别后面再讲),对应输入电压从0到参考电压VREF。换算公式:
Vmeasured = ADC_raw × VREF / 4095
以3.3V参考电压为例,如果ADC引脚输入1V电压,理论转换值是1 × 4095 / 3.3,约等于1241。这就是网上常有人问的"单片机ADC输入口电压为1V,采样得到的值是多少"的标准答案。不过要注意,如果直接用满量程4096做分母,结果是约1288,和1241有明显差异。两种算法谁对,取决于MCU手册怎么定义满量程。多数12位SAR型ADC的满量程对应值是4095,因为最大输出值就是12位全1。但有些高分辨率ADC会采用满量程对应4096(FS+1)的编码方式,使用前一定要先看手册。
如果是电阻分压采样的电压,还需要把分压比乘回来。还是用前面的例子,分压比0.126,那么实际电压:
V_real = ADC_raw × 3.3 / 4095 / 0.126
也可以先算分压后电压,再除以分压比。这一步不要用浮点数在每次中断里算,嵌入式环境里浮点运算慢,更推荐把换算系数预先算成定点数或者用浮点乘一次存好,每次采样直接用乘法得出结果。
4.2 软件滤波:均值、滑动平均、中值的取舍
ADC采样数据不可避免有噪声,来源可能是电源纹波、参考电压波动、电磁干扰,甚至热噪声。硬件滤波能削弱一部分,但软件滤波几乎是必需品。不同应用场景选不同的滤波算法,不要一上来就上卡尔曼滤波,那是过度设计。
电压采样这个场景,我一般按噪声特性分两类处理。一类是白噪声和高频毛刺,适合用滑动平均或算数平均。滑动平均就是在DMA缓冲区里直接对每个通道的N个值求平均,N可以设成4、8、16,和缓冲深度对应。平均的窗口越大,平滑效果越好,但响应速度越慢。母线电压采样这种变化缓慢的信号,窗口设16完全没问题;如果需要快速响应电压跌落,窗口就不能太大,4到8比较合适。
另一类是脉冲性干扰,比如电机的换向噪声、继电器吸合瞬间产生的尖峰,这类干扰会在某个采样点造成一个极端值,滑动平均窗口大了会被污染,窗口小了压不住。中值滤波对这种脉冲干扰非常有效,思路是把连续的N个值排序,取中间值输出。不过中值滤波的计算量比较大,嵌入式里很少做全排序,一般用"去最大最小求平均"的替代方案:把N个值找出最大值和最小值,去掉这两个极值后对剩余的值求平均。这个算法对脉冲干扰的抑制能力接近中值滤波,计算量却小得多。
我实际项目里用的是"冒泡+去极值平均"的组合:先把N个采样值做两趟冒泡找到最大最小值,然后累加求平均。把这段代码直接嵌入到DMA中断处理里,整个耗时也就几百个时钟周期,对主循环几乎无感。
4.3 采样数据漂移与校准
电压采样最常见的两个精度问题,一个是零点偏移,一个是增益误差。零点偏移表现为输入接地时ADC还输出几十甚至几百的原始值,增益误差表现为输入标准电压时换算结果整体偏大或偏小。
零点偏移主要来自ADC自身失调和运放电路失调。消除手段很简单:软件上做两点校准。系统上电后先通过模拟开关或继电器把ADC输入端接地,读取一组零点原始值记录下来;然后输入一个标准电压源,读一组标准值;根据这两组数据算出增益系数和偏移量,后续测量时套用线性修正公式。
很多MCU内置ADC偏移校准功能,比如STM32的AdcCalibration,上电后按手册流程跑一遍校准,能显著降低内部失调。GD32系列同样有类似校准寄存器,千万别漏掉。还有一类高端MCU会内置温度传感器,可以用来做温度补偿,但对于一般的电压采样需求,做好两点校准已经足够了。
还有个小技巧:把校准系数存到Flash或EEPROM里,上电时自动加载。这样每块板子出厂前标定一次,用户使用过程中即使环境温度变化比较大,精度也能维持在一定范围内。实践下来,经过两点校准后,12位ADC的电压采样精度稳定在±0.5%以内是完全可以做到的。
5. 常见问题与排查实录
这一节整理我在ADC-DMA采样调试过程中真实遇到过的典型问题,每一类都给出排查思路和解决措施,就当是一份速查表。
5.1 典型问题一:DMA初次启动后只搬运一次数据
现象是采样程序运行后,缓冲区里只有第一轮数据是有效的,后面就再也不更新了。排查后基本是两大类原因。一类是ADC连续转换模式没有开启,ADC转换完一轮规则通道后停止输出转换完成事件,DMA自然不再搬运。检查ADC配置里是否开启了连续转换模式或外部定时器触发模式。
另一类是DMA循环模式没有使能。DMA传输完设定长度的数据后,如果不开启循环模式,就会进入传输完成状态并不再响应后续请求,需要软件重新使能DMA通道才能继续传输。解决方法是调用DMA循环模式使能接口,让DMA搬运完一轮后自动回到起始位置,继续等待下一次传输请求。
5.2 典型问题二:多通道数据错乱
现象是通道0的数据出现在通道1的位置上,或者某通道数据固定偏移一个位置。最直接的原因是规则通道配置顺序和物理扫描顺序不匹配。ADC扫描模式按规则通道索引顺序扫描,不是按寄存器地址顺序。比如通道1配置在规则通道索引0,通道0配置在规则通道索引1,DMA搬运回来的第一个数据就是通道1的。所以要检查adc_regular_channel_config()函数里索引参数和通道号的对应关系。
GD32系列的ADC DMA地址递增方向也有讲究。外设地址固定指向ADC数据寄存器,不能递增;内存地址必须递增,否则所有数据都覆盖在缓冲区第一个元素上。这个配置我见过不止一次写反的,改过来就好了。
5.3 典型问题三:采样值整体偏低或最后一位跳动
采样值整体偏低,优先检查外部源阻抗和采样周期是否匹配。前面讲过,采样电容充电时间不够会导致转换结果偏低。解决思路是把采样周期调到更大档位,同时把RC滤波电路的等效源阻抗降低。还有一个容易被忽略的因素是ADC参考电压偏低,VREF+如果用了普通稳压芯片而没有独立去耦,负载波动会让参考电压下降,导致同样输入电压下采样值变低。
最后一位跳动则是量化精度到极限的正常表现,不必追求完全没有波动。12位ADC的理论分辨率是3.3V/4096,约0.8mV,实际受噪声影响,最后1到2个LSB跳动很常见。软件上做均值滤波就能把跳动压下来。如果跳动超过4到5个LSB,那就不是量化问题,而是参考电压或者输入信号本身有较大噪声,需要回到硬件布局上找问题。
5.4 典型问题四:串口DMA发送和ADC DMA互相干扰
系统里同时用了串口DMA发送监控数据和ADC DMA采样,有时ADC数据出现中断丢失或者串口发送数据卡住。本质是多个DMA通道竞争总线和中断资源。排查思路是先确认两个DMA通道是否挂在同一个DMA控制器上,DMA控制器内部有固定的优先级仲裁规则。把ADC的DMA通道优先级调高,串口的调低,再配合DMA中断里减小处理时间,问题一般就能缓解。
使用HAL库时还要注意一个坑:串口DMA发送不能连续发送,很多人遇到"第一次发送正常,第二次就卡住"。原因是HAL库的DMA发送流程依赖传输完成中断来解除忙标志,如果在发送完成回调里没有及时释放,或者在上一次发送还没结束时又调用了发送函数,就会失败。对策是先查询上一次发送是否完成,再启动下一次发送。
5.5 问题速查表
| 现象 | 可能原因 | 解决措施 |
|---|---|---|
| DMA只搬运一次就停 | ADC连续模式未开或DMA循环未使能 | 开启连续转换和循环模式 |
| 多通道数据对不上号 | 规则通道配置顺序与预期不符 | 核对索引和通道号对应关系 |
| 采样值整体偏低 | 源阻抗太大、采样周期太短 | 增大采样时间,降低源阻抗 |
| 采样值跳变超过几个LSB | 参考电压去耦不良或输入噪声过大 | 优化VREF去耦,检查PCB布局 |
| DMA和其他外设冲突 | DMA通道优先级配置不当 | 调整优先级,减少中断处理耗时 |
| 串口DMA发送第二次卡住 | 上一次发送未完成就启动下一次 | 检查发送完成标志后再发送 |
| 首次上电数据异常 | ADC未校准或未等待稳定 | 上电后执行校准并延时数微秒 |
实际调试中还有一个通用技巧:不要一上来就对着代码改来改去,先在主循环里把adc_buf原始数据通过串口打印到PC端,用串口绘图工具实时绘制数据曲线。波形长什么样,问题出在哪里,一眼就能看出来。比盯着内存窗口猜要高效得多。
6. 这套方案还能怎么延伸
ADC-DMA协同不仅适用电压采样。三相电流采样、温度多路采集、音频信号采集,只要是"多通道、连续采集、不想占用CPU"的场景,这套框架都可以直接套用。
以电机控制为例,三相电流采样需要和PWM触发时刻精确同步,可以把ADC的外部触发源设为定时器事件,每次PWM周期中间点触发一次三通道采样,DMA把结果搬到内存后,中断里直接做Clark变换和Park变换,整个过程CPU开销极小。这里的关键是把触发配置从软件触发改成硬件触发,顺序是先配置定时器输出比较或更新事件,再去配置ADC外部触发源,最后使能DMA。触发源选择不同,PWM和采样的对齐精度完全不同。
另外,如果MCU有多个DMA控制器,可以考虑把不同外设的DMA划分到不同的DMA控制器上,避免单控制器通道拥堵。比如ADC采样的DMA挂在DMA0,串口通信的DMA挂在DMA1,数据吞吐大时两者几乎互不影响。
这套方案我调通之后,基本上成了个人工程里的标准模板。遇到新的采样需求,改改通道数、缓冲区尺寸和中断处理逻辑就能复用。踩过不少坑之后,最大的体会是:ADC采样看着简单,但要做到"数据准确"和"系统不卡"同时成立,硬件、DMA配置、软件滤波每一层都要做到位。尤其是DMA循环模式和ADC连续转换模式的配合,这个点想通了,整个方案就通了一半。至于剩下的,都是些细心活,照着上面的套路排查一遍,基本都能顺利解决。