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 一次转换里发生了什么
可以粗分为四步:
-
采样:抓取某一时刻引脚上的电压;
-
保持:放进采样保持电容,转换过程中电压不变;
-
量化:对齐到最近的电压档位;
-
编码:输出 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库 嵌入式 #模数转换






