SMARTWATCH 学习手册有 C 基础 · 嵌入式从零开始
主线 / 从电压到数字

ADC:把电压变成程序能读的数

前面的传感器已经把测量结果放进寄存器,STM32 通过总线取数。电池和模拟麦克风提供的却是电压:程序怎样知道电压是多少,又怎样记录它随时间的变化?

前置:GPIO、整数与数组先理解转换,再读电压项目麦克风在下拉子篇
本篇资料STM32U575 参考手册 RM0456用于查 ADC1 的输入、转换和寄存器;ST AN2834的“ADC internal principle”解释内部采样与比较。项目配置对应 adc.c。本篇先使用单端输入,即测量引脚相对地的电压。

第 1 节 · GPIO 能读高低,为什么还需要 ADC?

把一个变化的电压送到普通数字输入,程序通常只能得到 0 或 1:输入电路把电压按高、低阈值判断。这个结果无法区分 1.0 V、1.1 V、1.2 V,也无法描述一段声音波形。我们希望得到一个整数,用它表示这次测到的电压大小。

ADC 是模数转换器,英文为 Analog-to-Digital Converter。“模拟”在这里指电压可以在一个范围内连续变化;“数字”指结果用有限个整数表示。STM32 内部有这样的转换电路,程序配置它,再读取它的转换结果。

先确定什么时候测,再确定记成几

对一条随时间变化的电压曲线,先选定一些时刻,每个时刻测一次,叫采样。比如每隔 1 毫秒测一次,一秒测 1000 次,采样率就是每秒 1000 次。采样得到的是这些时刻的电压,两个采样时刻之间的变化没有被完整记录。

程序不能用无限精细的数保存电压,于是再把可测范围分成有限的档位,给每个档位编号,把电压记成对应整数,叫量化。例如两个很接近的电压可能落在同一档,得到同一个整数;只看这个整数,就无法再区分它们。

采样时刻与量化编号上方连续曲线上每隔相同时间取一个采样点,下方将八个采样点量化为三位整数4、6、7、5、2、0、1、4。先选采样时刻,再把电压分到有限的档位采样:每隔相同时间,记录一个点时间 →量化:例如用三位整数保存各个采样点41006110711151012010000010014100每一个点只保留一个有限精度的数;不是保存整条曲线。
原创示意:上图横向间隔表示采样节奏,下方有限的整数表示量化结果;用于分清时间和幅度两个方向。

把上图每次采到的电压保持到下一次采样,画出来就会成为一段段水平线:这一段采用同一个值,下一次采样得到新值,再更新到新的高度。用这样的画法,就能看清采样时刻之间没有记录到的变化:

每次采样的值保持到下一次,形成水平段与跳变灰色虚线表示连续输入,橙色圆点表示八次采到的电压。每次采样后,橙色水平线保持这次的值,到下次采样时跳到新值,构成阶梯状图形。把每次采到的值保持住,就得到一段段水平线原来的连续输入按采样值保持的阶梯12345678电压时间 →1、2、3……表示第几次采样;每两个时刻之间保持上一次的值。采到一个新值保持到下次采样新值到来再更新→→
水平段表示把这次的采样值保持到下次采样,竖线标出更新。采样本身取得的是离散的点;这里用保持画法展示这些点,原来的输入电压仍然连续变化。

因此,采样率决定时间上取多密,分辨率决定电压上分多细。提高其中一个,不能自动补上另一个缺失的信息。接下来先把“这些档位怎样划分”弄清楚。

第 2 节 · 参考电压决定尺子的范围,分辨率决定分几档

为了方便计算,先假设一个教学用转换器测量 0~4 V,分辨率为 3 位。3 个二进制位有 2³=8 种组合,所以它只能给出 0~7 八个编号。把 4 V 分成八份,每档宽 0.5 V。(这里的 4 V 只用于说明档位,不是给 STM32 引脚规定允许输入电压。)

输入所在的区间结果编号三位二进制
0 V ≤ 输入 < 0.5 V0000
0.5 V ≤ 输入 < 1.0 V1001
2.5 V ≤ 输入 < 3.0 V5101
3.5 V ≤ 输入 < 4.0 V7111

在这个按等宽区间编号的简化模型里,输入 2.7 V,结果是 5;输入 2.8 V,也还是 5。丢掉的这部分细节就是量化带来的误差。真实转换器的档位转换边界还需要按手册解释,这张表先帮助理解“有限整数不能精确区分所有电压”。

项目中的 12 位是什么意思?

仓库将 ADC1 配成 12 位,单次未叠加处理的结果有 2¹²=4096 档,编号为 0~4095。4096 是档位数量,4095 是最大编号。若参考范围为 0~3.3 V,理想每档约宽 3.3÷4096=0.000806 V,也就是约 0.806 毫伏。

参考电压是转换器判断输入大小时使用的电压尺度。这里用 3.3 V 作名义计算;若实际参考电压与它不同,同一个输入电压对应的读数也会改变。增加位数会缩小理想档宽,但参考电压波动、输入噪声和转换电路本身的误差仍然存在。

改变输入电压,观察整数结果

这个计算器采用便于换算的端点近似:整数 ≈ 输入÷参考电压×最大编号。前面的等宽区间模型用于解释量化;两者不要当成真实芯片的逐点误差模型。

读取结果后,入门时常用电压 ≈ 读数×参考电压÷4095反算 12 位电压。理想档宽则用参考电压÷4096 表示。前一个是便于把两个端点对应起来的换算近似,后一个是在算有多少份;本篇代码使用前一种近似。

第 3 节 · 转换器内部怎样知道电压是多少?

先把当前电压暂时保持住

输入可能一直变化,但判断一个结果需要时间。可以先把内部电路理解为一个开关和一个小电容:开关闭合时,输入通过电路给电容充电,电容电压逐渐接近输入;开关断开后,电容暂时保持这次电压,供后面的转换使用。

输入通过内部开关给采样电容充电;充电需要时间,源阻抗越大,建立越慢
采样时间留给电容接近输入电压;随后保持这次电压,完成转换。

电压变化并不是瞬间完成的。如果信号经过较大的电阻才能给电容充电,留的采样时间太短,电容还没接近输入就开始判断,结果会有偏差。所以 CubeMX 的 Sampling Time 要结合信号源来设置;它并不是“每隔多久采一次”的另一个名字。

再从最高位开始,一位一位比较

这类逐次逼近转换器内部有一个比较器,用于判断保持的输入电压比试探电压高还是低;还有一个把数字变成试探电压的电路,即内部的数模转换器。控制电路先试最高位,再试下一位,逐步缩小范围。

仍用前面的 3 位、0~4 V 模型,保持的输入为 2.7 V。每个编号对应的试探电压按 0.5 V 间隔计算,过程如下:

这一步试什么试探电压与 2.7 V 比较保留的结果
先试最高位:100,即 42.0 V输入更高最高位保留 1
再试中间位:110,即 63.0 V输入更低中间位改回 0
最后试最低位:101,即 52.5 V输入更高最低位保留 1

三位都判断完,得到 101,即整数 5。输入仍然是 2.7 V,数字结果只说明它落在本例的第 5 档。12 位转换按同样的思路确定更多位,具体转换周期查芯片手册。

把两段时间分开

一次结果需要“采样并保持”的时间,加上“比较并确定数字”的时间。ADC 的工作时钟决定这些内部步骤走多快;采样率则表示每秒最终取得多少个样本。开启多次累加等功能后,产生一个输出还可能需要进行多次内部转换。

连续测声音时,还要考虑输入中变化最快的成分。如果采样过稀,不同的快慢波形可能在采样点上留下相同数值,叫混叠。理想带限信号需要采样率高于最高频率的两倍;实际系统还需要在采样前限制过高频率,给滤波留出余量。采完以后再平均,无法找回已经混淆的信息。

第 4 节 · 把原理对应到配置,再读一次电压

先在独立的单次转换实验工程中配置 ADC1。可以从安全范围内的已知电压开始:信号与开发板共地,引脚设为模拟输入、不启用上下拉,选择对应通道。通道编号是 ADC 内部输入编号,不一定等于引脚编号;例如本项目 PA1 对应 ADC1_IN6。

  1. 选择 12 位分辨率。结果按 0~4095 理解。
  2. 选择单通道、单次转换、软件启动。这次先由程序发起一次测量,关闭连续转换与硬件过采样。
  3. 设置采样时间。按输入电路和手册允许范围选择,给采样电容充分建立的时间。
  4. 初始化并校准。初始化配置电路;校准补偿转换器内部的一部分偏差。项目在初始化后、启动采集前调用 HAL_ADCEx_Calibration_Start(&hadc1, ADC_CALIB_OFFSET, ADC_SINGLE_ENDED),返回 HAL_OK 才继续。

四个函数,分别对应哪一步?

函数作用和参数返回什么
HAL_ADC_Start(&hadc1)启动这个 ADC 的常规组转换HAL_OK 表示启动成功
HAL_ADC_PollForConversion(&hadc1, 10)等待转换完成;10 是等待上限,单位毫秒HAL_OK 表示已完成,超时或出错时不能使用新结果
HAL_ADC_GetValue(&hadc1)取出转换寄存器里的整数整数读数;它不会自己启动或等待转换
HAL_ADC_Stop(&hadc1)停止本次常规组转换HAL_OK 表示停止成功

单次转换实验 · 已包含 adc.h,完成上述初始化和校准

if (HAL_ADC_Start(&hadc1) == HAL_OK) {
    if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK) {
        uint32_t raw = HAL_ADC_GetValue(&hadc1);
        float voltage = raw * 3.3f / 4095.0f;
        /* 在这里观察 raw、voltage,或用已学过的串口发送。 */
    }
    HAL_ADC_Stop(&hadc1);
}

比如读数接近 2048,按名义参考 3.3 V 换算约为 1.65 V。先检查接地时读数是否接近零,再观察一个已知电压。只有等待成功的分支里才取结果,避免把等待失败当成测到了零伏。

当前手表 ADC1 已配为麦克风的连续转换。上面是单独实验的配置和代码;在项目里测电池,应该沿用下一节的注入通道接口,让声音采集继续运行。

第 5 节 · 用它测电池,再记录不断变化的声音

电池电压为什么要先分压?

电池满电时可能高于 ADC 的参考范围,不能直接用前面的比例式处理。板上用两个 100 kΩ 电阻串联分压,ADC 读取中点电压。两个电阻相等,中点约为电池电压的一半;因此读到 2.0 V 时,估计电池约为 4.0 V。

本项目中,PA2 对应 ADC1_IN7,PA4 控制分压支路是否接通。沿 battery.c 看 battery_read_mv():接通支路 → 等待电压稳定 → 启动转换并等完成 → 读取整数 → 关闭支路 → 按分压比例换算毫伏。较大的分压电阻使输入建立更慢,所以代码为这个通道选了较长采样时间。

和麦克风共用 ADC1,怎样分别读取?

ADC1 有常规转换组和注入转换组,两组可以各自配置通道与转换序列。仓库用常规组持续采麦克风,电池使用注入组,在需要时请求一次电压测量。两组仍共用同一转换器;注入转换可以插入常规转换过程,所以它也会占用转换时间。

battery_init() 无参数,配置分压控制脚和注入通道,应在 MX_ADC1_Init() 之后、麦克风启动之前执行一次。battery_read_mv() 无参数,接通支路并完成一次读取,返回以毫伏表示的电池电压;当前实现用 0 表示读取失败。它在内部使用 HAL_ADCEx_InjectedStart()、HAL_ADCEx_InjectedPollForConversion()、HAL_ADCEx_InjectedGetValue() 和 HAL_ADCEx_InjectedStop(),依次对应启动、等待、取值和停止注入转换。

/* 包含 battery.h;MX_ADC1_Init() 之后、麦克风启动之前执行一次。 */
battery_init();

/* 后续需要读电池时调用。 */
uint16_t battery_mv = battery_read_mv();
if (battery_mv != 0) {
    /* battery_mv 是电池电压,单位毫伏。 */
}

电阻、参考电压和测量本身都有误差,这个读数是估计值。源码还提供电量百分比换算,但它采用指定电压区间的粗略线性映射;先把电压测准,再讨论电量估算。

下一步:从一个电压,到一段声音

电池变化慢,按需测一次即可。麦克风电压一直随声音变化,需要连续保存很多样本。接着到 项目麦克风:从模拟电压到声音数据,完成接线、连续采集、缓冲和声音强弱判断。

接下来 · 把采集到的数据发出去

学完本篇和下拉目录中的项目麦克风,按钮、屏幕、串口、传感器、电池与声音采集这些主要外设入口就能串起来了。接下来可以把重点转向:怎样把这些数据发到手机,手机又怎样向手表发送指令?

按手表项目这条路线,可以直接跳到无线通信。

先读无线通信:把串口消息送到手机,接通 STM32、无线芯片与手机之间的链路;再读手写应用协议:从字节流得到可靠消息,规定数据怎样打包、识别和校验。

无线通信的下拉子篇会接着讲手写协议和项目实现,再进入 DMA,完成项目所需的数据搬运知识。时钟与定时器放在后面的嵌入式拓展中。当前项目用 TIM6 提供 HAL 的毫秒计时,支持延时和超时判断;传感器由水位中断通知取数,麦克风采用 ADC 连续转换。因此,继续无线通信之前,不需要先完成脉宽调制、输入捕获等定时器实验。

扩展 · 数字怎样变回电压?

前面逐次比较时,内部用数字产生试探电压。独立的 DAC,即数模转换器,也提供数字到模拟电压的方向:程序写入一个编号,输出电路产生对应电压。它可以用来生成变化的模拟信号,输出范围、负载和建立时间要按手册确认。

手表当前没有使用独立 DAC 输出,本篇先完成 ADC 采集。需要波形输出时,可以进一步学习 定时器与脉宽调制,比较直接数模输出和脉宽调制经过滤波后的电压。

资料与源码