STM32 ADC 怎么测电池电压?从分压原理到源码实现

STM32 ADC 怎么测电池电压?从分压原理到源码实现

MCU 没有电压表,读电池只能靠 ADC:模拟电压进芯片,出来一个整数,再按分压比例换算回去。这篇先把 ADC 原理、分压公式、数字量怎么还原成电压 讲清楚;后半段以幻尔 MiniBalan 自平衡小车(主控 STM32F401)的工程源码为例,对照 原理图上的电阻 → 公式里的 k → 代码里的常数,把 CubeMX ADC 配置 和 获取电压数值 走一遍。

整条链路可以先建立这样一个印象:


一、ADC 在干什么

1.1 模数转换是什么

ADC(Analog-to-Digital Converter) 干的事很单纯:引脚上是多少伏的模拟电压,它就给你一个对应的整数。

以 STM32F401 内置的 12 位 SAR ADC 为例,参考电压按 3.3V 算时,满量程对应 0~4095(2¹² = 4096 个档位)。HAL_ADC_GetValue() 读到的就是这个整数——下文叫它 ADC 数字量,记作 adc_value(源码里变量名是 adc_raw_value)。

1.2 一次转换里发生了什么

可以粗分为四步:

  1. 采样:抓取某一时刻引脚上的电压;

  2. 保持:放进采样保持电容,转换过程中电压不变;

  3. 量化:对齐到最近的电压档位;

  4. 编码:输出 0~4095 的整数。

CubeMX 里的 Sampling Time(采样时间) 主要影响前两步。被测电压变化越慢,采样时间可以越长,读数越稳;快变信号则要缩短,否则跟不上。

1.3 分辨率与量化误差

12 位 ADC 能分辨的最小电压步进叫 LSB:

LSB = Vref ÷ 2^n = 3.3V ÷ 4096 ≈ 0.806 mV

这是 ADC 引脚上 的分辨率。量化本质是取整,单次结果会有约 ±0.5 LSB 的误差,只能靠更高分辨率或多次平均来缓解。


二、为什么不能把电池直接接到 ADC 引脚

2.1 耐压不够

STM32 的 ADC 引脚只能承受约 0~3.3V(相对 GND)。机器人、小车用的电池往往是 好几伏到十几伏,直接接上去会损坏芯片。

解决办法:在电池和 ADC 引脚之间加 电阻分压,把电压按比例缩小到 3.3V 以内。

2.2 电阻分压公式

经典接法:电源正极 → 电阻 R1 → 分压点(接 ADC) → 电阻 R2 → GND。

Vadc = Vbat × R2 ÷ (R1 + R2)

反推电池电压,定义 分压比(还原系数):

k = (R1 + R2) ÷ R2
​
Vbat = Vadc × k

k 由原理图上的 R1、R2 决定,软件里乘的系数必须和硬件一致;对不上,读数会整体偏高或偏低。


三、从 ADC 数字量到电池电压

ADC 只能看到 引脚上的 Vadc,看不到电池端的 Vbat。软件要做两件事:先把数字量还原成 Vadc,再按分压关系 把缩小的电压乘回去。

3.1 第一步:数字量 → 引脚电压 Vadc

ADC 把 0~Vref 的电压切成 2^n 份,数字量占其中多少份,引脚电压就近似为:

Vadc = adc_value × Vref ÷ 2^n
符号 含义
adc_value HAL_ADC_GetValue() 读到的整数,0~4095
Vref ADC 参考电压,一般用 3.3V(VDDA)
n 分辨率位数,12 位则 2^n = 4096

STM32F401 常见代入:

Vadc = adc_value × 3.3 ÷ 4096

举例:若 adc_value = 1000,则 Vadc ≈ 1000 × 3.3 ÷ 4096 ≈ 0.806 V。
这个 0.806V 是 PA4 引脚上的电压,还不是电池电压。

3.2 第二步:引脚电压 Vadc → 电池电压 Vbat(这里的 k 是什么)

第二节里,分压电路把电池电压 缩小 后才送到引脚:

Vadc = Vbat × R2 ÷ (R1 + R2)

电池端是高压,引脚端是低压,两者差一个固定倍数。把这个倍数记为 k(分压还原系数):

k = (R1 + R2) ÷ R2

k 的物理含义:引脚电压是电池电压的 1/k,所以还原时要 乘回 k。

Vbat = Vadc × k

用一个和具体板子无关的例子帮助理解:设 R1 = 90kΩ、R2 = 10kΩ,则

k = (90k + 10k) ÷ 10k = 10

电池 10V 时,引脚约 1V(10 ÷ 10);ADC 算出引脚 1V 后,软件乘 10 得回 10V。
k 不是魔法常数,就是原理图上两个电阻的比值;换电阻,k 就变。

3.3 两步合并成一行(代码里常见写法)

把 3.1 的 Vadc 代入 3.2:

Vbat = Vadc × k
     = (adc_value × Vref ÷ 2^n) × k
     = adc_value × Vref × k ÷ 2^n

对应到 C 代码,往往写成:

Vbat = adc_value * Vref * k / (1 << n);   // 或除以 4096.0f

每个符号从哪来,捋一遍:

符号 来源
adc_value ADC 采样结果
Vref 芯片参考电压,通常 3.3f
n / 4096 ADC 位数,12 位固定除 4096
k 原理图上 R1、R2 算出:(R1+R2)/R2

所以读电池电压的套路就固定了:采样 → 代入上式 → 得到 Vbat。
后面我们会用 MiniBalan 小车 的工程把 k 和源码对上号。


四、做电池电压检测时,几个容易踩的坑

坑 说明
分压比要和原理图一致 代码里的 k 必须等于 (R1+R2)/R2。电阻有误差时,可用万用表标定。
参考电压别当死数 3.3V 是 VDDA 标称值,实际会波动,高精度场合要测 VDDA 或做校准。
采样时间要匹配信号 电池电压变化慢,适合长采样;快信号用短采样。
多路共用一个 ADC 读电池前要切通道,读完最好切回去。
别在 main 死循环里狂读 PollForConversion 会阻塞;慢变量放在定时逻辑里即可。
单次采样会跳 末位可能抖,可加滑动平均。

五、对照实例:MiniBalan 小车(STM32F401)

前面是通用原理。从这里开始,用幻尔 MiniBalan 自平衡小车 的实测工程做对照——主控 STM32F401

后面的 CubeMX 配置、get_battery_volt() 和采样逻辑,都来自这辆车的固件源码。

MiniBalan 用 3S 锂电池供电(满电约 12.6V),电压远高于 STM32 引脚耐压,板子上用电阻分压把电池电压缩到 ADC 可测范围,分压输出接到 PA4(ADC1 通道 4)。读取函数在 Core/Src/adc.c 的 get_battery_volt()。

5.1 分压比 k 从哪来?看 MiniBalan 原理图

打开 MiniBalan 主板原理图,找到电池正极到 PA4 之间的分压网络

读出 R1(上臂) 和 R2(下臂,到 GND),代入第三节的公式:

k = (R1 + R2) ÷ R2

该板原理图上标注为 R1 = 100kΩ、R2 = 10kΩ(以你手里的图为准):

k = (100k + 10k) ÷ 10k = 11

也就是 11:1 分压——电池端电压缩小 11 倍才进 ADC 引脚。满电 12.6V 时引脚约 1.15V,远低于 3.3V 上限。

对应到 MiniBalan 源码,11.0f 就是把原理图算出来的 k 写进固件:

adc_raw_value * 3.3f * 11.0f * 100.0f / 4096.0f

换板子、换电阻,先改原理图上的 k,再改这个常数。要更准,用万用表量真实电池电压,和程序读数对比微调——这叫标定。

5.2 代入实算(MiniBalan 上的数)

在 MiniBalan 上,若某次读到 adc_value = 1300,k = 11:

Vbat = 1300 × 3.3 × 11 ÷ 4096 ≈ 11.52 V

对 3S 锂电的典型工作电压来说合理。满量程验算:4095 × 3.3 × 11 ÷ 4096 ≈ 36.3 V,量程余量充足。
这里的 11、1300、11.52V 都是 MiniBalan 这块板子 上的数;换项目换电阻,k 和算例要跟着变。


六、MiniBalan 里 ADC1 怎么配的

以下配置来自 MiniBalan 工程 Core/Src/adc.c 的 MX_ADC1_Init()。

hadc1.Instance = ADC1;
hadc1.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV8;
hadc1.Init.Resolution = ADC_RESOLUTION_12B;
hadc1.Init.ScanConvMode = DISABLE;
hadc1.Init.ContinuousConvMode = DISABLE;      // 单次转换:读一次,转一次
hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START;
hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT;
hadc1.Init.NbrOfConversion = 1;
字段 取值 含义
Resolution 12 位 数字量 0~4095
ContinuousConvMode DISABLE 软件触发,适合偶尔采样
ExternalTrigConv 软件启动 HAL_ADC_Start() 手动开转
NbrOfConversion 1 每次只转一个通道

电池引脚在 MSP 里配成模拟输入:

GPIO_InitStruct.Pin = BATTERY_Pin;     // PA4 → ADC1_IN4
GPIO_InitStruct.Mode = GPIO_MODE_ANALOG;

MiniBalan 的 ADC1 还接了巡线等其它模拟通道,默认不是电池通道;读电压时在函数里 临时切到 CHANNEL_4,读完再切回。

main.c 里虽有 HAL_ADC_Start_DMA(),但 ADC 未配 DMA、缓冲区也未使用,测电池实际走轮询,可忽略。


七、MiniBalan 源码:get_battery_volt() 拆解

返回值是 厘伏(0.01V):1152 表示 11.52V。按执行顺序分四块。

7.1 切到电池通道,拉长采样时间

sConfig.Channel = ADC_CHANNEL_4;
sConfig.Rank = 1;
sConfig.SamplingTime = ADC_SAMPLETIME_480CYCLES;
if (HAL_ADC_ConfigChannel(&hadc1, &sConfig) != HAL_OK) {
    return -1;
}

电池电压几乎不变,用长采样让采样电容充饱;配置失败返回 -1,避免把 0 当电压。

7.2 启动转换,阻塞等待

HAL_ADC_Start(&hadc1);
if (HAL_ADC_PollForConversion(&hadc1, 100) == HAL_OK) {
    adc_raw_value = HAL_ADC_GetValue(&hadc1);
}

超时 100ms 仍未完成则数字量保持 0。

7.3 切回默认通道

sConfig.Channel = ADC_CHANNEL_10;
sConfig.SamplingTime = ADC_SAMPLETIME_15CYCLES;
HAL_ADC_ConfigChannel(&hadc1, &sConfig);

多路共用 ADC 时的常规做法:用完恢复,别影响别的通道。

7.4 代入公式,整数带回厘伏

const int volt = (int)(adc_raw_value * 3.3f * 11.0f * 100.0f / 4096.0f);
return volt;

3.3f 是 Vref,11.0f 是原理图上的 k,4096 是 12 位分辨率,* 100.0f 把伏特换成厘伏——返回 int 才能保留两位小数。

完整函数:

int get_battery_volt(void)
{
    ADC_ChannelConfTypeDef sConfig = {0};
    uint16_t adc_raw_value = 0;
​
    sConfig.Channel = ADC_CHANNEL_4;
    sConfig.Rank = 1;
    sConfig.SamplingTime = ADC_SAMPLETIME_480CYCLES;
    if (HAL_ADC_ConfigChannel(&hadc1, &sConfig) != HAL_OK) {
        return -1;
    }
​
    HAL_ADC_Start(&hadc1);
    if (HAL_ADC_PollForConversion(&hadc1, 100) == HAL_OK) {
        adc_raw_value = HAL_ADC_GetValue(&hadc1);
    }
​
    sConfig.Channel = ADC_CHANNEL_10;
    sConfig.Rank = 1;
    sConfig.SamplingTime = ADC_SAMPLETIME_15CYCLES;
    HAL_ADC_ConfigChannel(&hadc1, &sConfig);
​
    const int volt = (int)(adc_raw_value * 3.3f * 11.0f * 100.0f / 4096.0f);
    return volt;
}

上层调用时除以 100.0f 即得伏特。adc.h 里的 Get_ADC_Value() 能读任意通道,但 不会自动切回默认通道,调试可以用,量产建议用封装好的接口。


八、MiniBalan 里电压在哪更新?为什么不在 main 里读

get_battery_volt() 会阻塞等待,不适合在 while(1) 里每圈调用。MiniBalan 把它放在 Hiwonder/src/control.c 的姿态中断回调里,并做 100ms 节流:

if (1 == flag_target) {
    if (HAL_GetTick() - last_volt_read_time > 100) {
        voltage = get_battery_volt() / 100.0f;
        last_volt_read_time = HAL_GetTick();
    }
}

原因很直接:电池电压是慢变量,100ms 读一次足够;PollForConversion 别占满主循环;跟控制任务同节拍,不额外拆线程。


标签:#STM32 #STM32F401 #ADC #电池电压 #电阻分压 #MiniBalan #平衡小车 #HAL库 嵌入式 #模数转换