1. 整体设计思路为什么非要 ADC 和 DMA “搭伙干活”1.1 一次完整的电压采样到底经历了什么很多刚入门单片机的朋友第一次接触 ADC 时接触最多的写法是“开定时器中断进中断里调 HAL_ADC_Start()然后等转换完成再读寄存器”。这种方法放在寄存器级别是这样当 ADC 完成一次转换转换结束标志位置位触发中断CPU 停下正在执行的主逻辑跳进中断服务函数把 ADC_DR 里的数字拷贝出来再做一些简单的处理最后清中断标志位返回。这个流程本身没有错但它让 CPU 承担了“每次转换都要亲自搬运”的工作。用 ADC 加 DMA 的方式就不一样了。ADC 转换一旦完成DMA 控制器会自动把结果从 ADC 的数据寄存器搬到内存数组里。整个搬运过程不需要 CPU 介入。CPU 只需要在 DMA 搬运完一批数据后处理一次。我们可以把 ADC 理解成一个“生产工”DMA 是“物流司机”CPU 是“工厂主管”。生产工每产出一个零件物流司机自动把它拉走主管只需要在整批货到了以后清点一次。如果每次出货都要主管亲自跑一趟仓库产量一高主管就什么也干不了了。这一篇我聊的就是这套“生产加物流”的方案核心关键词就三个ADC、DMA、电压采样。如果你在做电源监控、电机控制、电池管理、仪器仪表或者传感器采集这个组合是绝对绕不开的。1.2 协同工作的优势与适用边界ADC 加 DMA 最直接的好处是 CPU 占用率大幅下降。我在一个 8 路电压采样项目里做过对比用中断方式采 8 路规则通道每路采样周期 1.5 个 ADCCLK整轮 8 路转换大概需要几十微秒这期间定时器中断会频繁触发主循环里稍微加几个浮点运算系统响应就会变得非常迟钝。改成 DMA 搬运之后主循环只需要每 10 毫秒扫一次缓冲区8051 时代那种“硬件外设替 CPU 分担压力”的思路又回来了。第二个好处是采样节拍稳定。中断方式有一个很致命的问题中断响应时间受当前正在执行的任务影响CPU 可能正在做一项不能被抢占的操作ADC 转换完成中断若不能及时响应下一次转换就会变慢或者丢数据。DMA 是硬件行为从转换完成到搬运开始之间的延迟极短且固定采样时间点不会因为主程序乱跳。第三个好处是代码更简洁。配好 DMA 后应用层只面对一个看起来一直在更新的数组不需要关心转换完成标志位、不需要在中断里做数据拷贝、不需要处理堆栈保护逻辑清晰很多。但这套方案也不是万能的。如果你只是偶尔读一次 ADC比如开机时检测一次温度、每秒钟测一次按键电压直接用轮询方式就够了犯不上拉一个 DMA 出来。另外在一些低功耗场景里ADC 和 DMA 这种“高吞吐”配合并不适合因为 DMA 会持续请求总线MCU 很难进入深睡眠。这类需求更适合用低功耗定时器唤醒后做单次采样。下表是我自己在选型时常用的对比维度供你参考对比维度中断式 ADCADC DMACPU 占用每次转换都中断占用高一批数据搬运完才通知一次采样节拍稳定性受中断响应抖动影响硬件触发稳定代码复杂度中断服务函数里写逻辑易乱配置完成后面向数组处理适合场景低频、随机、单通道多通道扫描、连续采集、控制回路低功耗可配合停止模式使用一般不适合总线请求太频繁2. 硬件先过关电压采样电路与 PCB 布局的 3 个要点2.1 采样前端电路到底该放什么很多新手犯的第一个错误是把被测电压直接接到 MCU 引脚上。且不说引脚耐压问题很多 MCU 的 ADC 输入通道耐压就是 VDD稍微超一点就可能把内部结构打坏更别说输入阻抗与源阻抗的匹配问题了。一个规范的电压采样电路前端至少要具备分压、滤波、保护和必要的阻抗缓冲。以我常用的 3.3V 参考电压、12 位 ADC 为例如果被测对象是一块 12V 电池直接接肯定不行。我用两个电阻分压上臂 R1 取 100kΩ下臂 R2 取 22kΩ那么在电池电压 12V 时采样点电压是 12 × 22 / (100 22) ≈ 2.16V留出了约 1V 的余量。这个分压比 122/22 约等于 5.545软件里用 ADC 值乘以这个系数再乘以参考电压就能还原出真实电压。计算分压电阻时还要注意输入阻抗R1 加 R2 的总阻值决定了信号源的内阻这个内阻会直接影响后级的采样时间阻值太高会使采样保持电容充不满。如果被测信号的内阻很高比如来自一个高阻传感器我一般会在分压后加一级轨到轨运放做电压跟随器。运放的输出阻抗很低可以给 ADC 内部的采样电容快速充电这个问题在第 3 章还会详细展开。成本不高但能让采样精度和稳定性上一个台阶。2.2 RC 滤波不是越大越好电压采样前端最常见的滤波器就是 RC 低通一个电阻加一个电容截止频率按 1/(2πRC) 算。不少朋友为了把 50Hz 工频噪声滤干净把 R、C 值取得非常大结果 ADC 转换出来的数值反而越来越小甚至通道之间互相串扰。原因在于逐次逼近型 ADC 内部并不是一个无限输入阻抗的测量仪表。它的输入结构里有一个采样开关和一个采样电容每次转换前采样开关闭合一段时间让采样电容充电到输入电压。如果前端 RC 滤波器的电阻太大电容在采样时间内充不满电ADC 采到的就不是真实电压而是电容两端的“残值”。对于 STM32 这类 MCU 的 12 位 ADC通常源阻抗在几 kΩ 以下时用较短采样周期没问题一旦源阻抗超过 10kΩ采样误差就非常明显了。我常用的一个经验值是RC 滤波器的时间常数不超过 ADC 采样周期的一半并且 R 尽量取小比如 100Ω 到 1kΩC 取 10nF 到 100nF。这样既能把高频毛刺滤掉又不至于让采样电容来不及充电。严格一点的做法是用源阻抗、采样保持电容、采样时间三个参数去计算充电误差公式不复杂列出 ADC 输入等效模型就能算出来后面我会展开讲。另外如果你用的是 Σ-ΔSigma-Delta型 ADC前端 RC 设计逻辑又不一样。Σ-Δ 型 ADC 采的是平均值前端 RC 除了滤高频还要抑制混叠但电阻也不能太大否则累计到内部积分器的电荷不足同样会影响精度。这些年我在 Σ-Δ 前端上吃过不少亏总结下来就是不要看见滤波电阻就堆大阻值数据手册里的推荐值往往比拍脑袋的结果靠谱得多。2.3 规避时钟抖动与电源噪声的三个 PCB 布局要点硬件设计阶段能解决掉的问题软件再怎么使劲也弥补不了。ADC 采样特别怕两类干扰电源噪声和时钟抖动。电源噪声会直接叠在参考电压和模拟输入上时钟抖动则会让采样时刻前后晃动导致动态信号采样结果忽大忽小。我在画 PCB 时总结出了三个必须遵守的布局要点。第一ADC 的模拟电源引脚和参考电压引脚要一级一级地滤。很多 MCU 的 VREF 引脚同时又是 VDDA 引脚的一部分如果你直接把数字 3.3V 接上去寄存器切换瞬间的电流尖峰就会通过电源网络耦合到参考电压上ADC 结果会莫名其妙地“抖动”。我习惯在 VDDA 和 VREF 之间串一个磁珠或者小电阻再对地并一个 10μF 钽电容加一个 100nF 陶瓷电容形成 π 型滤波。参考电压的走线尽量短而粗不要和数字走线平行走长距离。第二时钟源要远离模拟输入引脚。晶振工作时会产生有规律的谐波这些谐波如果耦合到模拟输入走线上ADC 结果会周期性跳动。我见过一块板子问题非常隐蔽低速采样一切正常一旦提高采样率结果就跟着系统主频一起“唱歌”。后来拿示波器测模拟输入引脚发现上面叠了一个 8MHz 左右的包络正是主控外部晶振的频率。从那以后我画板子必从晶振到 ADC 输入之间留出足够间距晶振下方不走模拟信号线更不打过孔。第三模拟地平面要完整采样电路要成回路。很多人以为“模拟地”就是铺一块铜皮但其实关键在电流回路面积。ADC 输入信号从分压电阻经过 RC 滤波到 MCU 引脚最终要通过 GND 引脚回流到地这个回路的面积越小被空间电磁场耦合到的噪声就越小。我的做法是把模拟采样电路集中放在板子的一个区域ADC 引脚附近挖掉数字信号走线只留一个完整的模拟地参考面。多通道采样时各路信号走线尽量对称比如 8 路电压采样我把 8 根线梳状排开线间距保持一致通道间串扰会明显降低。2.4 ADC 端口保护电路还有一个很多人忽略的小电路ADC 输入口的保护。正常运行的时候输入电压确实在 0 到 3.3V 之内但上电瞬间、热插拔、静电放电都有可能让引脚电压超过 MCU 的极限范围。一旦超过 VDD 或者低于 VSSMCU 内部的寄生二极管会导通电流灌进芯片内部轻则引脚损坏重则整个芯片闩锁失效。我常用的保护结构是串联电阻加上对地、对电源的钳位二极管。串联电阻放在信号入口通流电阻取值通常几 kΩ它本身可以和后级输入电容组成一个低通滤波同时限制了外部灌入引脚的电流。钳位二极管选小泄漏电流的肖特基管比如 BAT54S它把输入电压钳在 VDD0.3V 到 VSS-0.3V 之间。这不是什么高深电路但能在静电放电或者误接线的时候保住一颗主控值得每个采样通道都加上。3. ADC 关键参数采样周期、分辨率与换算方法3.1 采样周期与转换时间到底怎么算ADC 转换并不是一次完成的数字读出而是分成了“采样保持”和“逐次逼近”两个阶段。采样阶段里内部开关闭合采样电容对输入电压充电逼近阶段里电容两端的电压被逐位比较生成二进制结果。ADC 总转换时间公式很简单T_conv (采样保持周期 转换位数周期) / ADC 采样时钟频率这里的采样保持周期和转换位数周期都以 ADC 时钟周期为单位。拿 STM32F103 举例数据手册里写得很清楚12 位转换模式下采样保持周期可以配置为 1.5、7.5、13.5、28.5、41.5、55.5、71.5、239.5 个 ADCCLK 周期而逐次逼近过程本身大约需要 12.5 个 ADCCLK 周期。如果把 ADCCLK 配到 14MHz采样周期选 13.5那么一次转换需要 13.5 12.5 26 个 ADCCLK时间约 1.86μs对应单通道最大采样率约 538kHz。这个计算方式在不同 MCU 上略有差异比如有些型号会区分右下角得数是几个尾数有些高速 ADC 在并行模式下还会额外加流水线级数但核心思路完全一致。你拿到一款新芯片先把 ADC 时钟树和数据手册里的周期表翻出来套公式算一算就能知道自己这个平台的“理论吞吐上限”是多少再去决定用多少采样率心里有底得多。3.2 采样周期与源阻抗的匹配很多人配置 ADC 采样周期时都是顺手选最大的 239.5 周期觉得慢一点没关系。其实采样周期长有两个代价一是整体采样率下降二是在多通道扫描模式下所有通道都得等最慢那条通道完成采样。正确的做法是根据信号源阻抗去反推需要的采样周期。ADC 内部采样模型的本质是一个 RC 充电回路。采样电容容值可以从数据手册里查到比如某芯片采样电容约 4pF内部采样开关电阻约 1kΩ如果信号源阻抗也是 10kΩ那么总充电时间常数是 (10 1)kΩ × 4pF 44ns。要让 12 位 ADC 的采样误差小于 1/2 LSB充电时间至少需要 8 到 9 个时间常数也就是大约 400ns。如果 ADC 时钟是 14MHz一个周期 71.4ns采样周期只有 1.5 个周期也就是 107ns显然不够必须选 7.5 周期约 536ns以上。反过来如果我在信号源和 ADC 之间加了一级运放跟随器源阻抗降到几十欧姆那么 1.5 周期就能满足充电要求这时候就可以把采样周期设成最短换取更高的采样率。所以我不太建议大家盲目地把采样周期设到最大建议先查一下芯片内部等效模型再按公式算一遍得到的参数既够稳也能兼顾速度。3.3 扫描模式、规则通道与注入通道的配合多通道电压采样一般都会打开扫描模式让 ADC 从通道 0 扫到通道 7然后自动循环。这套逻辑分两组通道规则通道组和注入通道组。规则通道组是排好顺序的队列一次扫描按队列顺序逐个转换转换结果最终都放进同一个规则数据寄存器 ADC_DR。问题就来了如果不开 DMA每转换完一个通道CPU 都要赶在下一个通道完成之前把 ADC_DR 读走否则结果就被覆盖了。这正是 ADC 和 DMA 配合最有价值的地方之一。开启 DMA 后每完成一次规则转换DMA 就把 ADC_DR 里的值搬到内存数组的对应位置8 个通道扫完数组里就按顺序存好了 8 个结果。CPU 完全不用理会中间过程。注入通道组则有点像“插队通道”它可以打断正在进行的规则转换优先采样某个关键信号常用于控制回路里的紧急情况或者需要精准定时的采样点。注意注入通道有自己的数据寄存器什么时候读注入通道结果后面我在第 6 章单独讲那是个容易踩坑的点。另外如果项目里有高级定时器的 PWM 需求很多芯片支持 PWM 中央对齐模式触发 ADC 采样。比如电机控制程序常把 PWM 的载波周期中点设为电压采样时刻这时候 ADC 转换就由硬件事件触发再配合 DMA 把几相电流、电压一次搬干净。软件层面唯一要做的就是在 DMA 完成中断里把数据取走剩下的交给硬件协同。3.4 输入 1V 时采样值是多少很多面试题或者初学者困惑的问题“如果单片机 ADC 输入口电压为 1V采样得到的值是多少”其实答案很简单就是按参考电压和分辨率做比例换算。以 12 位 ADC、3.3V 参考电压为例ADC 值 输入电压 / 参考电压 × 4096输入 1V 时1 / 3.3 × 4096 ≈ 1241换算成十六进制就是 0x4D9。如果是 10 位 ADC满量程是 10241V 时对应 1 / 3.3 × 1024 ≈ 310。8 位 ADC 则是 1 / 3.3 × 256 ≈ 77。这个换算公式贯穿所有 ADC 应用反过来已知 ADC 值时计算电压的公式就是电压 ADC 值 / 满量程 × 参考电压我用 C 语言实现了一个很直白的转换函数#define VREF_MV 3300 #define ADC_FULL_SCALE 4096 float adc_to_voltage(uint16_t raw) { return (float)raw * VREF_MV / ADC_FULL_SCALE; } // 若 raw 1241电压 ≈ 999mV // 如果单片机ADC输入口电压恰好为1V12位满量程3.3V理论值 ≈ 1241这里有一点必须提醒参考电压才是测量的基准。如果芯片的 VREF 引脚被噪声污染或者参考电压本身有漂移那么同一个 1V 输入在不同时刻采到的值就会不同。很多时候 ADC 数值上下跳并不是输入信号真的在跳而是参考基准在抖。4. DMA 协同机制数据搬运与中断配合4.1 DMA 请求与 Continuous Requests 的关系DMA 不会自己主动去搬数据它需要一个触发源。在 ADC 场景里触发源就是“转换完成事件”。ADC 每完成一次转换就向 DMA 控制器发一个请求DMA 响应请求后把 ADC_DR 的数据搬到内存地址搬完一次传输计数器减一。当计数器减到零时DMA 传输结束。这里有一个配置项经常让人困惑DMA Continuous Requests有的芯片叫“连续请求模式”有的叫“循环模式”。简单说如果 ADC 工作在单次转换模式DMA 一次性搬完设定数量的数据后会停止等待下一次软件触发如果打开连续请求ADC 一转换完 DMA 马上再次开始搬运形成一个“流水线”状态。在一段时间内持续采样多路电压的应用里我通常会同时开启 ADC 的连续转换模式和 DMA 的循环模式这样数组会一直刷新软件只要在合适的时间点把数据拿走即可。“连续请求”这个名词还有一个容易被误解的场景有些芯片在低功耗模式下 DMA 请求无法被响应导致 ADC 转换结果停留在寄存器里配置了连续请求也不见得连续。遇到这种问题要先确认总线和 DMA 时钟有没有被关掉再排查请求信号是否真的到了 DMA 控制器。4.2 多通道数据排列与地址宽度多通道扫描模式下DMA 搬运进内存的数据是按扫描顺序排列的。如果我把规则通道配置为通道 0 到通道 7那么内存数组的第 0 个元素对应通道 0 那次转换结果第 1 个元素对应通道 1以此类推。通道顺序一旦改了数组含义就全变了排查起来非常恼火因为所有数据看着都有值就是数字对不上。地址宽度方面ADC_DR 寄存器通常是 16 位但有的 32 位芯片会以 32 位宽度读取。配置 DMA 时外设数据宽度要和内存数据宽度保持一致否则地址增长逻辑会乱掉。我一般把内存数组声明为 uint16_t12 位 ADC 结果右对齐后刚好占半个字。如果你在内存里用了 uint8_t 数组搬进来的数据会被截断高 4 位全部丢失结果永远不对。反过来如果你把 16 位数据搬进 32 位数组却把内存宽度配成 32 位那么 DMA 会按 32 位为单位搬数据数组中每两个元素之间会多出无意义的空洞。4.3 半传输中断与传输完成中断怎么用DMA 的中断类型里除了最常用的传输完成中断还有一个半传输中断这是很多做采集的朋友没有充分利用的。半传输中断在 DMA 传输完一半数据时触发可以用它实现“双缓冲”的思路DMA 正在填充缓冲区后半段时主程序已经在处理缓冲区前半段的数据等 DMA 填充完后半段前半段又变成了可覆盖区间。这样一来数据处理和 ADC 采集可以并行进行不会出现“为了搬数据暂停处理”或者“处理过程中缓冲区被覆盖”的死锁。实践里的做法很简单在 DMA 传输一半和传输完成两个中断回调里各做一个标志位主循环轮询这两个标志位按顺序处理两个半区。注意中断回调里只做标志位或者链表指针切换不要在里面做浮点运算、不要调用慢速外设否则反馈到采样时序上还是会抖动。我常用的伪代码大概是这样的volatile uint8_t adc_half_flag 0; volatile uint8_t adc_complete_flag 0; void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { adc_half_flag 1; } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { adc_complete_flag 1; } while (1) { if (adc_half_flag) { adc_half_flag 0; process_frame(adc_buffer, ADC_BUF_SIZE / 2); } if (adc_complete_flag) { adc_complete_flag 0; process_frame(adc_buffer ADC_BUF_SIZE / 2, ADC_BUF_SIZE / 2); } }4.4 多 ADC 模式与 ADC_CDR 规则数据寄存器有些芯片支持多个 ADC 同时工作比如 STM32 的双 ADC 或者三重模式。它可以把 ADC1、ADC2、ADC3 的转换结果按固定格式放进一个通用规则数据寄存器ADC_CDR然后由 DMA 搬走。这就相当于三路 ADC 共享一次 DMA 搬运任务省下了占用总线的次数。三重模式通常用在需要同步采样三路信号的场景比如三相电压、三相电流采样时刻必须严格对齐软件轮流读寄存器根本做不到硬件同步加 DMA 搬运才是正路。读取 ADC_CDR 时要注意它的位宽比普通 16 位寄存器大。以双 ADC 为例一个 ADC 的结果占低 16 位另一个占高 16 位如果你配置了 DMA 按 16 位宽度搬运很容易只拿到一个 ADC 的数据视觉上就是另一路永远为零。正确做法是内存数组用 32 位宽度接收一整个 CDR 值然后再按通道拆开。不同芯片对 CDR 寄存器和数据位宽的定义略有差异但在读代码之前先确认数据手册里的位域排布能省下大量的调试时间。4.5 PWM 触发采样、串口 DMA 与 SPI 双 DMA 的扩展思路ADC 加 DMA 的组合只是 DMA 应用的冰山一角。我做电机控制项目时经常用到高级定时器的 PWM 中央对齐模式触发 ADC 采样这样每到一个 PWM 周期中点硬件自动启动一轮 ADC 转换DMA 自动搬走数据。整个闭环里CPU 只需要在 DMA 完成中断里做控制算法采样时刻的误差几乎为零。这也是我反复强调“协同”二字的含义。同样的思路也适用于串口和 SPI。串口接收用 DMA 加空闲中断可以做到不定长数据帧接收CPU 只在整帧完成后处理不再一个字节一个字节地挨个响应中断。SPI 是全双工总线发送和接收同时进行如果你希望不占用 CPU 地搬数据通常就需要两个 DMA 通道一个管发送一个管接收配置的时候注意内存增长方向、数据宽度、触发源三者要分开设置。DMA 学到后面你会发现它不只是一个外设的辅助而是一条通用的“数据搬运总线”。5. 数据到手之后滤波、漂移与电压换算5.1 ADC 原始值为什么总是抖把 ADC 和 DMA 调通以后数据虽然源源不断地来但很多人很快就会发现第二个问题数值不稳定。同样的输入电压前后两次采样结果差了十几个 LSB或者在波形上看起来像一层“毛刺”。这部分责任不全是软件问题我们先搞清楚原理。理想 N 位 ADC 本身就存在量化噪声。模拟电压是连续的数字结果是离散的模拟值落在两个相邻量化台阶之间时最终输出只能量化到其中一个这个差异就是量化误差。它的均方根大约等于 1 LSB 除以根号 12。以 12 位、3.3V 参考电压为例1 LSB 3.3V / 4096 ≈ 0.8mV量化噪声均方根约 0.23mV。也就是说哪怕芯片是理想的单次采样的结果也会有约 0.23mV 的不确定性折合成 LSB 大约是 0.29。而真实芯片的噪声远不止量化噪声。参考电压的纹波、前级放大器的宽带噪声、地线上数字电路的回流噪声都会叠加到采样结果里。如果采样率足够高在输入端没有滤波做抗混叠的情况下高频噪声还会“折叠”到低频让波形看起来有一层去不掉的底噪。遇到这种问题我的习惯是先拿示波器看 VDDA、VREF 和输入引脚的纹波再用排除了硬件问题之后再回来优化软件滤波。软件滤波可以起缓冲作用但不能从根本上解决硬件噪声。5.2 几个实用的 C 语言滤波函数接下来我列几个我在做电压采样时用得比较多的滤波函数。注意滤波不是越复杂越好而是要和你的采样率和系统需求匹配。低速电压监控用滑动平均就够了带脉冲干扰的环境用中位值平均更稳需要实时响应的控制回路则尽量用一阶低通避免相位延迟太大。中位值平均滤波是把 N 次采样排序后去掉最大最小再对剩余数据取平均。它同时具备抗脉冲干扰和抑制随机噪声的能力我用得非常频繁#define FILTER_N 8 uint16_t median_average_filter(uint16_t *buf, uint8_t len) { uint16_t tmp[FILTER_N]; uint32_t sum 0; uint8_t i, j; for (i 0; i len; i) { tmp[i] buf[i]; } for (i 0; i len - 1; i) { for (j 0; j len - 1 - i; j) { if (tmp[j] tmp[j 1]) { uint16_t t tmp[j]; tmp[j] tmp[j 1]; tmp[j 1] t; } } } for (i 1; i len - 1; i) { sum tmp[i]; } return (uint16_t)(sum / (len - 2)); }滑动平均则适合噪声小而平稳的场合。它只做连续 N 个点相加后取平均代码短、延迟可控。注意窗口不要开太大否则真实电压变化也会被抹平12 位数据窗口开到 16 或 32 点以上时动态响应已经能感觉到明显滞后了。一阶低通滤波的公式是 y y_prev k × (x - y_prev)k 取 0.1 到 0.3 之间比较平稳适合对实时性要求不高的缓慢变化信号。5.3 数据漂移的三个来源滤波可以解决随机抖动但解决不了系统性漂移。温度变化时电阻值会漂、参考电压源的输出会漂、甚至焊点之间的接触热电势都会影响毫伏级信号。我的经验是数据漂移要从三个方向去查。第一是电阻的温度系数。采样电路里的分压电阻如果用了普通贴片电阻温度系数可能到 ±100ppm/°C环境温度变化 20°C 时电阻值会变化 0.2%。要保证精度关键通道至少用 25ppm/°C 或更好的精密电阻。第二是参考电压的温度漂移。芯片内部 VREF 不一定在所有温度范围内都稳定如果项目工作温度范围很宽建议使用外部精密基准源并把参考电压的滤波做好。第三是连接和焊点。排针、杜邦线、接线端子在长时间使用后会产生氧化层接触电阻变大表现为数值随时间和环境湿度缓慢漂移。排查这类问题最直接的办法是短接输入测量基准零点再用恒压源测满量程做定期校准。5.4 从 ADC 值到实际电压的完整换算软件处理的最后一步是把滤波后的 ADC 值换算成真实物理电压。如果 ADC 直接测的电压就是目标电压公式还比较简单但大多数电压采样电路都有分压所以要把分压比还原回去。以我之前说的 100kΩ 和 22kΩ 分压为例#define R1_KOHM 100.0f #define R2_KOHM 22.0f #define VREF 3.3f #define SCALE 4096.0f float adc_to_input_voltage(uint16_t raw) { float v_adc (float)raw * VREF / SCALE; // 采样点电压 return v_adc * (R1_KOHM R2_KOHM) / R2_KOHM; // 还原输入电压 }如果你还关心 ADC 输入口电压的具体值可以直接用得到的采样点电压。这里提醒一点很多芯片内部有 ADC 偏移校准和增益校准功能校准之后结果寄存器里的值才是真正可以直接换算的。如果开了校准但没生效换算出来的电压整体会偏移这正好引出下一章的排查内容。6. 常见问题排查与避坑实录6.1 Config 工具配好 DMA 却没数据用 CubeMX 或者类似配置工具生成工程后最容易出现的问题就是“DMA 使能了、代码也生成了但 adc_buffer 数组永远是 0”。我排这类问题有一个固定顺序。先查 DMA 的中断是否打开。有些芯片的 DMA 完成事件要靠中断通知如果中断被关掉主循环一直等不到标志位。再查 DMA 方向和数据宽度。方向应该是“从外设到内存”外设地址必须是 ADC_DR 寄存器地址内存地址必须是数组首地址。宽度必须一致ADC 结果寄存器一般是 16 位内存数组也声明成 uint16_t。接着查 DMA 模式连续采集通常选 Circular 循环模式传输数据量还不能为零。最后查 ADC 本身有没有启动连续转换以及启动函数有没有真正被调用过一次。Hal 库里比较常见的写法是 HAL_ADC_Start_DMA()有些朋友代码里还残留着 HAL_ADC_Start()这就会导致普通转换和 DMA 转换互相打架。由于这些步骤在代码里经常一起出现我把检查顺序整理成一个速查表检查项典型错误快速验证方法DMA 中断中断未使能在中断回调里置标志位看主循环能否触发内存地址没有使用数组首地址断点查看 DMA-M0AR 是否等于 buffer[0]数据宽度内存宽度与外设宽度不一致打印 buffer 中前几个元素循环模式使用 Normal 模式导致一轮结束检查 DMA_Config 的模式参数ADC 启动未调用 HAL_ADC_Start_DMA在启动函数后立即看 buffer 是否有值6.2 多通道数据错位问题多通道数据错位的表现是通道 A 的值跑到了通道 B 的位置上或者数组里每隔 N 个值出现一次异常。这种问题多半出在扫描顺序和数组解析顺序不一致上。比如我把规则序列里通道 0 到 7 的扫描顺序改成了 7 到 0但上位机解析软件仍然按照 0 到 7 的顺序读看起来就是“通道数据错位”。排查方法是打印原始数组用万用表量每个通道的电压挨个对应数组下标。如果发现确实错位把规则扫描序列和数组下标重新对齐就行。还有一个隐蔽场景DMA 搬运的数据量大于通道数比如通道数是 8但 DMA 传输长度配的是 16后 8 个元素就是上一轮残留的数据解析时如果不做截断就会频繁出现“最后一个通道和第一个通道互相串”的情况。这里的关键是 DMA 传输长度要和“每次一帧数据的字节数”严格一致。6.3 国产 MCU 上 ADC DMA 数据紊乱国产 MCU 这几年用得很普遍价格香生态也慢慢跟上了但确实会撞上一些“水土不服”的问题。我之前在 GD32E230 上遇到过 ADC 加 DMA 数据紊乱现象很典型单独读一路 ADC 是准的开了多通道循环采集后数据数组里偶尔会出现一个很大的跳变值或者一整轮数据都串位。查阅一段时间之后定位到两个点。一是 DMA 的优先级。当 ADC DMA、串口 DMA、SPI DMA 同时占用总线时如果 ADC 的 DMA 优先级配置太低高优先级外设频繁抢占总线ADC 转换结果寄存器可能被覆盖DMA 拿到的就是旧值或者半新旧值。把 ADC 对应的 DMA 通道优先级调高后问题明显改善。二是部分国产芯片 DMA 在 burst 传输模式下对地址对齐要求比较严格。用连续传输模式时结果正常一旦改成 burst 传输内存数组必须按总线宽度对齐否则数据会错位。遇到这种问题先看参考手册里的 DMA 通道仲裁规则再检查传输模式与内存地址对齐是否匹配比盲目改代码有效得多。6.4 注入通道的正确读取时机注入通道可以打断规则通道的转换但它不是 D M A 搬运的主要对象。如果你用 DMA 持续搬运规则通道结果又想读取注入通道的转换结果很容易犯一个错误在规则通道转换完成的瞬间去读注入通道数据寄存器。注入通道有自己的完成标志位和中断正确的读取时机是“注入转换完成事件发生后”。如果规则通道和注入通道同时触发你需要先判断注入转换是否已经完成拿到注入通道数据寄存器里的值后再继续处理规则通道数组。否则你读到的可能是上一次注入转换的旧值尤其在高频率打断场景里数值看起来偶尔会“卡在一个值上不动”非常误导人。有的芯片允许把注入通道的结果也导入 DMA但那是把两套触发源都接到 DMA 请求上配置要格外仔细。6.5 读到的结果寄存器是未校正原始值这个坑其实挺有意思我在一块带协处理器CLA的芯片上踩过。ADC 配置了偏移校准软件层面的值与目标电压基本对得上但协处理器直接读 ADC 结果寄存器时偶尔会出现整体偏大或偏小的现象。后来查手册才发现某些架构中 ADC 结果寄存器原始值和已经应用偏移裁剪offset trim的值是两回事。CLA 或者其他的总线主控在访问结果寄存器时读到的可能是尚未应用 ADC offset trim 的原始值不同通道之间的一致性就会变差。解决方式是在读取之后做一次软件补偿把偏移校准值加上或者统一使用经过外设层封装过的标准读取接口避免各个主控各自访问原始寄存器。遇到类似现象我的建议是先把时钟树、外设复位、校准标志位全部捋一遍再查一下芯片参考手册里对“原语值”和“校正值”的表述。这类问题往往不是 Bug而是对硬件数据路径理解不够完整。6.6 问题速查表为了平时调试时少翻笔记我把上面几类问题汇总成一个速查表遇到故障先按这个顺序排查症状可能原因排查方向数组全为 0DMA 未启动或方向错误检查 HAL_ADC_Start_DMA 和 DMA 配置数组值有但整体偏小采样周期不够电容未充满增大 ADC 采样周期或减小源阻抗数据按固定周期乱一次DMA 传输长度与通道数不匹配核对帧长度与扫描通道数通道间数据串位扫描顺序与解析顺序不一致打印数组对照硬件测量值偶然出现大跳变电源噪声或参考电压抖动示波器看 VDDA、VREF 纹波协处理器读到偏置值读到了未应用偏移修剪的原始值查数据手册加软件补偿7. 踩过几次坑之后的几点体会先说第一点体会ADC 加 DMA 这套方案真正的难点不在配置代码而在“系统级配合”。配置项无非是通道、周期、缓冲区和中断真正让你抓耳挠腮的往往是硬件上的一点点地弹、一处没滤干净的电源纹波或者手册角落里一句话没看仔细。现在我遇到采样异常第一反应永远是拿示波器去量参考电压和输入引脚而不是先改软件。很多次“DMA 数据紊乱”最终查下来都是参考电压上有毛刺把毛刺处理掉代码一行没改就正常了。第二点体会是DMA 确实是个“用一次就回不去”的东西。它不只是给 ADC 用的串口的收发、SPI 的交换、PWM 波形的产生都可以用 DMA 把 CPU 从数据搬运的琐事里解放出来。早年间我写串口协议时逐字节地处理中断后来用了串口 DMA 加空闲中断接收任意长度帧变得极其清爽。多通道电压采样也是一样配好之后主循环里只需要把 buffer 里的数据按通道解析整个世界都安静了。最后一个小建议在你把 DMA 配置调通之后建议做一次“关机重启测试”把电源重新上电、程序重新复位再观察采样数据是否稳定。很多问题只在刚上电的瞬间出现比如 ADC 校准没完成、DMA 还没拿到时钟、参考电压还没爬升到稳定值这些都会导致首发数据里出现几个怪值。在工程代码里加一个“上电丢弃前几帧”的逻辑是一个成本极低但非常有效的防坑手段。这个项目做完我对数据采集的理解明显进了一层CPU 不应该做苦力让外设各司其职才是嵌入式开发的常态。
