继续咱们 STM32C562 开发连载,这一篇聊 ADC 电压采集。做过嵌入式的人都有体会,ADC 是 MCU 感知外部世界最直接的窗口:单片机只懂 0 和 1,但现实里不管是电池电压、温度传感器、电位器旋钮,还是变频器输出的模拟信号,最终都要被转换成数字量才能参与逻辑判断。STM32C562 内置的 ADC 模块正好承担这个翻译工作,12 位分辨率、支持多通道配置,足以覆盖绝大多数电压采集场景。这篇就围绕电压采集这件事,从原理、参数计算、CubeMX 配置到实际排错,完整走一遍,适合正在用 C 系列做项目,又不想在模拟信号上栽跟头的开发朋友。
1. 电压采集的整体链路:从模拟信号到数字码值
1.1 STM32C562 内置 ADC 的定位与适用场景
STM32C562 的 ADC 是逐次逼近型,也就是常说的 SAR 型。它的工作方式很像小时候用的天平称重:先放最大的砝码试试,重了换小一号,再不行就再小一号,这样逐位逼近真实重量。SAR ADC 里的“砝码”是内部电容阵列,按二进制位加权,12 位分辨率意味着要比较 12 次,每次比较消耗一个 ADC 时钟周期。这个原理决定了它的速度和精度:转换速度可以做到很快,但精度天然受到电容匹配精度和比较器噪声的限制。
很多刚接触的人会纠结一个问题:STM32C562 内置的 ADC 够用吗?我的答案是:大多数电压采集场景,它不但够用,而且相当好用。12 位分辨率对应的码值范围是 0~4095,如果参考电压是 3.3V,那么每个 LSB 大约 0.8mV。电池电压监测、电位器刻度读取、NTC 温度采集、模拟量传感器输出,这些常见应用对精度的需求普遍在毫伏级以上,12 位完全能接得住。相比之下,外接 16 位甚至 24 位的高精度 ADC 虽然能把分辨率做上去,但要多花一颗芯片、多写驱动、多处理通信时序,成本和复杂度都上来了。除非你做的项目是精密称重、微弱电流检测、高精度数据采集仪这类对分辨率有硬性要求的场景,否则先用片内 ADC 是性价比最高的选择。
1.2 采样前先看硬件:源阻抗、参考电压与去耦
ADC 采集从来不只是写代码的事。我见过不少新人,代码写得挺溜,数据照样飘,最后查来查去发现是前端电路出了问题。硬件上第一个要关注的是源阻抗。ADC 内部采样保持电容在采样阶段需要充电,如果信号源的输出阻抗太高,电容充不满,读数自然不准。举个典型例子:用两个电阻分压测 0~25V 的直流电压,R1 取 100kΩ,R2 取 15kΩ,分压系数 15 / (100 + 15) ≈ 0.130,最高输入 25V 时分压输出约 3.26V,刚好在 3.3V 量程内。但问题在于,从 ADC 引脚看进去,信号源等效阻抗是 R1 和 R2 的并联值,大约 13kΩ。对于几 pF 的采样电容来说,这个阻抗偏高了,如果不加漂白处理,采样时间必须拉得很长,否则转换误差肉眼可见。
参考电压和供电同样关键。STM32C562 的 ADC 一般以 VDDA 或者独立的 VREF+ 作为参考,这个电压稍有波动,转换结果就会跟着抖。常规做法是在 VDDA 和 VREF+ 引脚旁边就近放 0.1μF 陶瓷电容加 1μF 或 10μF 电容,而且摆放位置要尽量贴近引脚,走线要粗。另外,模拟电路和数字电路共用电源时,建议用磁珠或小电感把模拟电源单独隔离出来,地平面尽量保持完整,模拟地和数字地单点连接,别让地回流噪声干扰采样。
这里把 PCB 布局的要点总结成三条:第一,ADC 采样走线要尽量短,并且远离时钟线、PWM 输出线这类高速翻转信号,避免容性耦合把噪声串进来;第二,VDDA 和 VREF+ 建议加独立的 LC 或 RC 滤波,采样瞬间电流突变,没有本地储能电容的话电压会被瞬间拉低;第三,ADC 参考电压的去耦电容要紧贴引脚放置,而且模拟地和数字地的连接点最好选在 ADC 附近,形成一个干净的回流路径。做项目时先把这三条落实了,后面就算遇到精度问题,排查方向也会清晰很多。
2. 采样周期、分辨率与滤波:先把参数选明白
2.1 ADC 时钟与采样时间怎么配
STM32C562 的 ADC 时序可以分成两段:采样阶段和转换阶段。转换阶段由 SAR 算法决定,12 位分辨率大约需要 12.5 个 ADC 时钟周期;采样阶段可以通过采样时间寄存器调整,典型档位有 2.5、6.5、12.5、24.5、47.5、92.5 个周期等,具体档位以手册为准。很多人不理解为什么要把采样时间做成可以配置的,其实就是给前端电路一个“充电时间弹性”:信号源阻抗低、走线短,充电快,可以用短一点的采样时间;信号源阻抗高、走线长,充电慢,就得把采样时间拉长。
我们实际算一笔账。假设总线时钟配置为 80MHz,ADC 预分频设置为 4,那么 ADC 时钟就是 20MHz,也就是 1 个周期 50ns。采样时间选 24.5 个周期,总转换周期数就是 24.5 + 12.5 = 37 个周期,单次采样耗时 37 × 50ns = 1.85μs。如果把采样时间拉到 92.5 个周期,耗时变成 (92.5 + 12.5) × 50ns = 5.25μs。对于 100ms 才读一次电池电压的应用来说,5μs 和 1.85μs 差别无所谓;但如果你要做 100kHz 以上的连续采样,就必须用短采样时间。所以参数怎么配,核心取决于两件事:前端的输出阻抗大小,以及系统对采样速率的要求。建议先用短采样时间测量,如果发现码值跳动偏大,再逐步加大采样时间看改善情况。
2.2 码值到电压的换算、LSB 与校准
拿到 ADC 原始码值之后,要换算成实际电压。12 位分辨率,参考电压 Vref,那么 1 个 LSB 等于 Vref / 4096。以 3.3V 参考为例,1 LSB 大约是 0.8059mV。在代码里最常用的换算写法是voltage = (float)raw * 3.3f / 4095.0f,用 4095 是因为满量程码值是 4095,也有人写 4096,两者差异不到 0.03%,大多数工程无感。但如果你做的是精度敏感项目,建议查一下数据手册里满量程对应的精确码值定义,严谨处理。
另外,STM32 系列 ADC 普遍带有校准功能。芯片出厂时,内部电容阵列的匹配误差、比较器的失调电压都是有差异的,校准的目的就是把这些工艺偏差修掉。使用 CubeMX 生成代码后,建议在 ADC 初始化完成之后主动调用校准函数:
HAL_ADCEx_Calibration_Start(&hadc1);校准要在 ADC 上电之后、开始转换之前调用一次。有的工程师新板子数据不准,查来查去是这块漏了,校准前后读数可能差好几个 LSB。如果项目对绝对精度有更高要求,可以考虑外接精密基准源,比如 2.5V 或 3.0V 的基准芯片,把参考电压从 VDDA 上独立出来,这样即使主电源在负载波动时被拉偏,ADC 参考也不会跟着抖。
2.3 软件滤波三板斧:中值、滑动平均、一阶低通
硬件做完了,软件滤波是最后一道保险。ADC 原始值里常见的干扰是电源纹波、电磁辐射和采样瞬间的开关噪声,它们表现为个别异常的毛刺点或者整体小幅抖动。针对不同噪声形态,我一般用三种滤波方案。
第一种是中值滤波。思路很简单,连续采 N 个值,排序后取中间值。N 取 3、5 或 7,能非常有效地剔除孤立毛刺,适合信号本身比较稳定、偶尔出现尖峰的场景。
uint16_t middle_value(uint16_t *buf, uint8_t count) { /* 简单选择排序:只排到中间位置为止 */ for (uint8_t i = 0; i < count - 1; i++) { for (uint8_t j = i + 1; j < count; j++) { if (buf[j] < buf[i]) { uint16_t tmp = buf[i]; buf[i] = buf[j]; buf[j] = tmp; } } } return buf[count / 2]; }第二种是滑动平均,适合信号有轻微随机噪声、变化速率不快的场景。维护一个环形队列,每来一个新值就丢一个最旧的值,然后求平均。窗口长度建议选 2 的幂次,比如 8、16、32,方便用移位代替除法。
第三种是一阶低通滤波,也叫加权递推平均,公式是y_n = alpha * x_n + (1 - alpha) * y_{n-1}。其中 alpha 越大,滤波越迟钝,跟随越慢;alpha 越小,反应越快但噪声也多。电池电压这种慢变信号,alpha 取 0.1~0.2 很合适;如果是快速变化的电流信号,alpha 可能要放到 0.3 以上。这类滤波会引入相位延迟,所以采样频率至少要高于信号带宽的 10 倍,否则测出来的“实时值”会明显滞后。
3. 基于 STM32CubeMX 的 ADC 电压采集实操
3.1 CubeMX 里的关键配置项
用 STM32CubeMX 配置 ADC 其实很快,重点检查几个位置。首先是 ADC1 的设置页,把分辨率选为 12 位。其次是扫描模式和连续转换模式:单通道轮询模式不需要开扫描,多通道采集才需要打开扫描;连续转换模式开了之后,ADC 会不停地把结果写进数据寄存器,配合 DMA 用非常顺手。然后是采样周期,前面已经讲过怎么选,这里按你的阻抗情况填。
时钟树里要确认 ADC 时钟来源和分频系数。有的工程里 ADC 时钟被配置得很高,已经超出了外设允许的最大频率,但编译运行并不报错,表现出来的现象是转换结果不稳定,这种坑很难查。建议直接把 CubeMX 给出的 ADC 时钟值跟数据手册核对一遍。DMA 配置在多通道场景下尤其重要,建议选择循环模式,数据宽度设成 Half Word,因为 STM32 的 ADC 数据寄存器是 16 位的,但有效数据只占低 12 位。
3.2 单通道轮询采样,适合电池电压这类慢信号
对于低速率、单通道的电压采集,最简单的实现是轮询。先初始化 ADC,然后在主循环里启动转换,等待转换完成,读取数据寄存器。参考代码如下:
uint16_t read_adc_single(void) { HAL_ADC_Start(&hadc1); if (HAL_ADC_PollForConversion(&hadc1, 100) == HAL_OK) { return HAL_ADC_GetValue(&hadc1); } return 0xFFFF; }实际使用中,可以在 main 函数里先做一次校准,然后再进入主循环,每 100ms 读一次电压,把码值换算成电压后通过串口打印出来。轮询模式的优点是代码直观、逻辑清楚,缺点是一旦转换开始,CPU 得一直等着结果,转换期间没法干别的。对于读取电池电压这种低频需求,完全够用。
需要注意一点:如果 ADC 引脚配置成模拟输入,CubeMX 会自动把引脚的上下拉关掉,这没问题;但如果是自己手动配置 GPIO,千万别把引脚设置成带上拉或者下拉,否则会改变采样点电压,导致读数偏移。另外,在读取 DMA 数据数组时,如果数组类型是uint32_t但 DMA 数据宽度是 Half Word,也会出现数据错位,务必保持类型和宽度一致。
3.3 多通道 DMA 采集,不阻塞 CPU 的连续采样
多通道场景下,轮询就有点力不从心了。假设要同时采集 4 路模拟量,如果用轮询,你得切换通道、逐个读取、再来回切换,中间 CPU 被拖死,而且切换瞬间数据容易串味。这时用 DMA 自动搬运数据才是正道。
CubeMX 里配置多通道 ADC 时,先把需要使用的通道按顺序加到 Conversion Sequence 里,Rank 顺序就是转换顺序。打开扫描模式,打开连续转换模式,DMA 选择循环模式,数据宽度 Half Word。然后在代码里定义一个环形数组,比如 4 个通道就定义uint16_t adc_buf[4],调用启动函数:
HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, 4);DMA 会自动按扫描顺序把转换结果搬进数组。转换完成后,在回调函数里置一个标志位,主循环检测到这个标志后再处理数据:
volatile uint8_t adc_dma_done = 0; void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { adc_dma_done = 1; } }有一点必须特别提醒:STM32C562 基于 Cortex-M33 内核,如果你的工程启用了 TrustZone 安全隔离,ADC、DMA 这些外设必须在设备配置里被正确分配到安全或者非安全侧,而且 DMA 通道和外设之间的连接要符合安全规则。很多人在非安全应用里访问 ADC 外设,发现寄存器读不到数据或者 DMA 中断不触发,十有八九就是 Safe/Nonsafe 分配没搞对。如果没开 TrustZone,那就忽略这部分,但排查问题时要记得这条线。
多通道 DMA 模式下,有一个很典型的坑是数据错位:ADC 的扫描顺序和 DMA 缓冲区数组的顺序对不上,或者 Rank 配置变化之后,数组看起来还是按旧顺序排列。遇到这种情况,先停下来确认 CubeMX 里的 Rank 顺序和代码里对数组下标的解释是否一致,别动不动就怀疑 DMA 坏了。另一种情况是启动 DMA 采集之后,数组只更新一次就不再变化,多半是把 DMA 配成了单次模式,改成循环模式就好。
4. 踩坑实录:ADC 数据跳动、漂移与串扰的排查思路
4.1 读数像心电图一样乱跳,多半在电源
现象:同一路电压,ADC 读出来的值上下跳动几十到几百个 LSB,用万用表测一下源电压其实很稳。排错第一步,用示波器看 VDDA 引脚旁边的电源纹波:如果纹波大,先把模拟电源的滤波做好。第二步,确认 ADC 时钟是否超标,我见过有人把 ADC 时钟配到 30MHz 以上,转换结果就是跳,降下来立刻稳定。第三步,检查参考电压源,VDDA 和 VREF+ 上如果没有就近的 0.1μF 电容,加载瞬间电压会被拉出毛刺。经验之谈:ADC 数据像心电图一样跳,第一怀疑对象永远是电源和基准,而不是软件。
4.2 码值整体偏高或偏低,考虑分压电阻误差
现象:换算后的电压和万用表读数之间差了一小截,而且差值随输入电压近似线性放大。最常见的原因是分压电阻精度不够。以 1% 精度的电阻为例,R1 和 R2 各自有 ±1% 的偏差,分压比例的综合误差可能到 2% 左右,反映到 3.3V 量程上就是几十 mV 级别的偏差。解决办法是换 0.1% 精密电阻,或者在软件里做两点校准。
两点校准的具体操作是:给系统输入一个已知的低电压 V1,记录 ADC 码值 C1;再输入已知高电压 V2,记录码值 C2。根据这两组数据算出增益和偏移:
float gain = (float)(V2 - V1) / (float)(C2 - C1); float offset = V1 - gain * C1; /* 校准后电压 */ voltage = gain * raw + offset;这个方法能把电阻误差、参考电压偏差一次性修正掉,工程上非常实用。注意校准时输入的参考电压本身要用高精度万用表测量,否则校准基准不准,后面全白搭。
4.3 多通道串扰:切换后第一次读数不能用
现象:多通道扫描模式下,某个通道的读数总是比实际值高或者低,尤其是相邻通道一个悬空、一个接着高阻抗源时,串扰特别明显。原因是采样保持电容在上一次转换时充到了上一个通道的电压,切到新通道后需要时间重新充电,如果采样时间不够,残留电荷还没放完就开始比较了,结果自然不准。
解决手段有三板斧:第一,延长采样时间;第二,软件上把每一轮扫描的第一次结果丢弃,从第二次开始采信;第三,在 Rank 序列最前面加一个内部伪通道或者 GND 通道,专门承担“脏充电”任务,真正要用的通道排在它后面。实操下来,这几招基本能把串扰问题压下去。
4.4 DMA 数据错位、唤醒后不更新的处理
DMA 相关的问题,我整理了一张速查表,调试时可以照着走:
| 问题现象 | 可能原因 | 处理办法 |
|---|---|---|
| DMA 数组里各通道数据顺序错乱 | 扫描顺序和程序中对数组下标的解释不一致 | 核对 CubeMX 里 Rank 顺序,与数组下标对齐 |
| 数组只有前几帧更新,之后再也不动 | DMA 配置成单次模式 | DMA 模式改为循环模式 |
| 低功耗唤醒后 DMA 停止或数据错乱 | 唤醒后 DMA 未重新启动,外设在低功耗下被复位或关闭 | 唤醒后重新调用HAL_ADC_Start_DMA,必要时重新初始化 ADC |
| 高通道采样值偶发为 0 或满量程 | 转换期间缓冲区被 CPU 同时访问 | 增加帧完成标志,主循环判断标志位后再读数组,处理完清除标志 |
| DMA 中断不触发 | TrustZone 开启时外设访问权限配置错误 | 在设备配置里把 ADC/DMA 分配到正确的安全域 |
多通道 DMA 还有一个容易忽略的点:HAL_ADC_Start_DMA之后,ADC 是连续转换,DMA 也是循环搬运,CPU 如果随时去读数组,可能读到一半被更新的数据,导致半个旧值半个新值。主循环里最好先关中断取数据,或者用帧标志位同步,确保读出来的是一帧完整结果。如果项目里 DMA 数据量很大,还需要考虑内存一致性,虽然 C 系列很多型号没有大缓存,但为了稳妥,建议 DMA 缓冲区定义在不会被缓存影响的内存段。
5. 一点个人心得
做 ADC 这些年,我最大的感受是:软件永远只是数据质量的最后一道保险,前面的硬件和参数没做好,后面写再多滤波代码也是治标不治本。经常有人拿着跳动的 ADC 数据来问,我的第一个建议从来不是改滤波算法,而是先拿万用表和示波器去现场测信号、测电源。很多时候,问题最后都出在最基础的电源去耦、参考电压、接地布局上。
如果你刚上手 STM32C562 的 ADC,建议先跑通单通道轮询,确认硬件链路正常,再上多通道 DMA,避免一上来就踩两个坑。等基础链路稳定了,后面还能扩展很多玩法:比如用 NTC 加查表法做温度采集,用 ADC 过采样技术把有效分辨率往上再顶一顶,或者用定时器触发 ADC 实现固定频率的精确采样,这些在电机控制、电源监控项目里都有实战价值。这块内容如果大家感兴趣,后面我继续写。