做平衡车绕不开电池电量检测,而电量检测绕不开ADC。STM32的HAL库把ADC封装成了几个简洁的API,新手十分钟就能把代码跑起来,但跑起来和测准是两码事。这篇文章从一个平衡车项目的实际需求出发,围绕“用STM32采集电池电压”这个任务,把ADC测量原理、分压电路、HAL库配置和实测排查串在一起。适合刚接触CubeMX、准备给平衡车加电池电压监测的开发者,也适合想搞懂ADC采样周期、参考电压、软件滤波这些细节的朋友。
1. 为什么平衡车上要单独讲一节ADC
1.1 电池电压不只是电量显示
平衡车动力来源一般是2S或3S锂电池组,电机起步瞬间的电流能达到好几安培,电池电压会在负载时明显下跌。如果只做一个“电量显示”,把电压读出来随便显示一下就够了。但对平衡车来说,电压还有一个更重要的用途:保护锂电池。
锂电池最怕过放。单节电芯电压低于3.2V之后,继续大电流放电会损伤电极材料,甚至导致电池胀气,整组电池报废。3S电池组满电12.6V,空载截止大概10.2V附近;2S组则是8.4V降到6.8V左右。由于平衡车没有机械刹车,每次倒地或急停后电机反向充电,电压会短暂回升。如果代码里只做“低于阈值就断电”,遇到电压瞬间波动很容易反复启停,车和人都不安全。所以电压采集不只是读个值,还要考虑滤波、迟滞和分级保护,这些都属于ADC应用的一部分。
从整个项目的开发顺序看,ADC也是后面很多模块的地基。电池电压、电机电流、陀螺仪偏移校准都用得上相同的采样思路。与其等到调车时再回来补课,不如在课程最开始就把ADC的原理和HAL库调用方式吃透。
1.2 分压电路先算明白,再开工
STM32的ADC输入范围是0到参考电压,F103上参考电压一般直接接3.3V。12V电池不能直接怼到PA0上,超压不仅会让ADC读数永远4095,还可能烧坏引脚内部保护二极管。正确做法是先用电阻分压,把0~12.6V映射到0~3.2V左右,留一点余量。
最简单的分压就是两个电阻串联,中间点接ADC引脚。假设上电阻R1接电池正极,下电阻R2接地,ADC引脚电压就是:
Vadc = Vbat × R2 / (R1 + R2)
我常用R1=30K、R2=10K,分压比0.25。电池最高12.6V时,ADC引脚最高3.15V,低于3.3V,留了0.15V余量;电池放到10V时,引脚只有2.5V左右,读数也不至于太小。静态功耗是12V / 40K = 0.3mA,对一块几千mAh的电池来说可以忽略,也不会让分压电阻发热。
选电阻阻值有个分寸。选太大的话,比如100K级别,分压点输出阻抗很高,而ADC采样瞬间要从这个点抽走一小股电流,100K电阻供电不及时,采样电压就会被拉低,导致读数系统性偏差。选太小也不行,比如100R级别,静态电流120mA,电池两天就被放空了。10K~50K这个区间是我比较推荐的范围,分压点输出阻抗在7.5K左右(两个电阻并联值),配合较长的采样时间,ADC读起来很稳。另外分压电阻建议用1%精度,先用万用表量一下实际阻值,后面校准用得上。
2. ADC测量原理拆透:SAR型ADC到底怎么工作
2.1 从“天平称重”理解逐次逼近
STM32F103内置的ADC是12位逐次逼近型,英文缩写SAR(Successive Approximation Register)。你可以把它理解成一台用二分法称重的天平,只不过称的不是物体,而是电压。
假设参考电压是3.3V,输入电压是2.2V。SAR ADC先拿2.2V和参考电压的一半1.65V比,高于1.65V,所以最高位记1;接着再和1.65 + 0.825 = 2.475V比,低于,所以下一位记0;再和1.65 + 0.4125 = 2.0625V比,高于,记1。这样反复比较12次,每一位都确定下来,最后得到从0到4095之间的一个整数。
这种结构的好处是速度快、功耗低,不需要昂贵的电阻网络。12位精度意味着每1个LSB对应3.3V / 4096 = 0.000806V,约0.8mV。分辨率听起来够用,但注意“分辨率”不等于“精度”,实际精度还受参考电压噪声、采样时间、PCB布局影响,后面会讲。
2.2 采样周期和转换时间,不是随便填的
ADC工作要分两步:采样和转换。采样阶段,内部开关把外部电压接到一个采样电容上给它充电;转换阶段,SAR逻辑开始逐次逼近。F103的采样时间可以通过HAL库配置,常见选项有1.5、7.5、13.5、28.5、55.5、71.5、239.5个ADC时钟周期。转换阶段固定需要12.5个周期。
很多人第一次接触“采样时间”会忽略它的意义。如果外部信号源内阻大、采样电容又不够大,充电时间不足就会导致测量结果偏低且跳动。对于电池电压这种变化缓慢的信号,完全可以用很长的采样时间换稳定性。F103的ADC时钟最大14MHz,系统时钟72MHz时需要分频,比如PCLK2的6分频得到12MHz。总转换时间计算公式是:
t_total = (采样周期 + 12.5) / f_ADC
把ADC时钟设为12MHz,采样时间55.5周期,总时间就是(55.5 + 12.5)/ 12MHz = 5.67微秒。做均值滤波时每次转换间隔几毫秒到几十毫秒,这个转换时间完全不是瓶颈。如果信号源是高内阻,建议直接上239.5周期,21微秒的转换时间,换来的是稳定可靠的结果。
顺便说一句,有些同学配置时习惯把ADC时钟推满,以为越快越好。F103如果超过14MHz,转换结果会出现非线性误差。如果你在CubeMX里看到8分频或10分频的选项,注意计算一下最终ADC时钟是否越界。F4系列虽然ADC时钟可以更高,但同样有上限,这个道理是通用的。
2.3 STM32F103C8T6的ADC通道怎么选
C8T6这颗芯片只有一个ADC1(封装里没有ADC2/ADC3的外部通道,虽然一些型号有多个ADC),共16个通道,但LQFP48封装实际引出了10个外部输入通道。映射关系如下:
| 引脚 | ADC1通道 | 备注 |
|---|---|---|
| PA0 | IN0 | 也可用作WKUP |
| PA1 | IN1 | 也可用作TIM2_CH2 |
| PA2 | IN2 | USART2_TX |
| PA3 | IN3 | USART2_RX |
| PA4 | IN4 | SPI1_NSS |
| PA5 | IN5 | SPI1_SCK |
| PA6 | IN6 | SPI1_MISO |
| PA7 | IN7 | SPI1_MOSI |
| PB0 | IN8 | TIM3_CH3 |
| PB1 | IN9 | TIM3_CH4 |
选引脚时要注意两件事:一是看你在同一工程里是否已经用了该引脚的复用功能,比如PA2如果已经配置成串口发送,就别再拿来当ADC输入;二是避开板载电路已经接好的引脚,比如有的最小系统板会把PA0和板载按键或LED接在一起,你用ADC读出来的电压会受按键影响。
在CubeMX里把需要的引脚点成ADC1_INx,模式选Single-ended,F103没有差分输入模式。配置里Scan Conversion Mode关掉,Continuous Conversion Mode暂时关掉,先做单通道单次转换,逻辑最简单。稍后如果需要多路采集,再开扫描和DMA。
3. 用HAL库把ADC代码跑起来
3.1 CubeMX配置里的关键参数
打开CubeMX,点PA0,在菜单里选ADC1_IN0。左侧ADC1的Mode选Enabled,然后到Parameter Settings里设置几个值:
- Clock Prescaler:Divider 6,让ADC时钟落在12MHz左右
- Resolution:12 bits
- Sampling Time:55.5 Cycles
- Scan Conversion Mode:Disabled
- Continuous Conversion Mode:Disabled
- DMAContinuous Requests:Disabled(暂时不用DMA)
生成代码前检查一下Project Manager里的HAL库版本和Toolchain。生成后打开adc.c,能看到HAL_ADC_MspInit里面已经帮GPIO配成Analog模式和ADC时钟使能。如果没有MspInit,多半是CubeMX版本或者引脚配置没生效,重新生成一次就行。
有些同学喜欢把所有配置都放到MX_ADC1_Init里面,但HAL库把引脚和时钟初始化放在MspInit这种回调里,这是设计习惯问题,不是bug。人工程序复杂以后,这个结构反而清晰,GPIO配置跟随外设走,不会乱。
3.2 轮询模式读取电压与换算公式
最简单的读取方式是轮询。对单次转换模式,每次要读一个新值,先调用HAL_ADC_Start启动一次转换,再调用HAL_ADC_PollForConversion等待转换完成。这个函数第二个参数是超时时间,单位毫秒,建议给10ms。转换完成后调用HAL_ADC_GetValue取寄存器结果。
代码可以这样写:
#define ADC_REF_VOL 3.30f // VDDA实测值 #define ADC_FULL_SCALE 4095.0f // 12位满量程 #define BAT_R1 30.0f // 上分压电阻 KΩ #define BAT_R2 10.0f // 下分压电阻 KΩ uint16_t read_adc_single(void) { uint16_t val = 0; HAL_ADC_Start(&hadc1); if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK) { val = HAL_ADC_GetValue(&hadc1); } HAL_ADC_Stop(&hadc1); return val; } float get_battery_voltage(void) { uint16_t val = read_adc_single(); float volt_adc = val * ADC_REF_VOL / ADC_FULL_SCALE; return volt_adc * (BAT_R1 + BAT_R2) / BAT_R2; }这里有个容易被忽视的细节:为什么每次读完要HAL_ADC_Stop?因为在单次转换模式下,Start只触发一次转换,转换完成后ADC其实已经停了,Stop是让ADC彻底进入状态,避免状态机残留。如果你配置了Continuous Conversion,只需要Start一次,之后每次PollForConversion拿到的都是最近一次转换结果,那样就不需要反复Start/Stop。
换算公式拆开看就两步。第一步把ADC原始值变成引脚电压,公式是 val × 3.3 / 4095,因为在0~4095范围内,ADC值是参考电压的线性映射。第二步乘上分压比倒数,也就是 (R1 + R2) / R2,把引脚电压还原成电池电压。注意R1、R2单位要一致,代码里用KΩ就可以。
调用get_battery_voltage()返回的是float,浮点数运算在Cortex-M3内核上没有问题,但如果你要在中断或者高频控制循环里调用,浮点会占用额外时间。建议主循环里算好,存成整数单位(比如毫伏),控制代码里直接用整数比较,省去浮点开销。
3.3 均值、中值、滑动滤波,哪个适合你
实测里,单次ADC采样值直接拿去用,波动往往很难看。电池电压本来就会因为电机电流变化而起伏,再加上电源纹波,显示值可能每秒跳几十毫伏。这时候软件滤波就很有必要。
最简单的是多次采样取平均值。连续采样N次,累加后除以N。均值滤波对白噪声效果好,但如果某个采样值突然变成毛刺,会直接把均值拉偏。更好的做法是“去极值均值滤波”:采样N次(N建议取奇数),排序后去掉最大值和最小值,再对剩下的N-2个值取平均。对电池电压这种缓慢信号,N取11到21就够了。
#define ADC_FILTER_N 11 uint16_t adc_filter(void) { uint16_t buf[ADC_FILTER_N]; uint16_t sum = 0; for (int i = 0; i < ADC_FILTER_N; i++) { HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 10); buf[i] = HAL_ADC_GetValue(&hadc1); HAL_ADC_Stop(&hadc1); } // 简单排序,这里为了清晰直接全排 for (int i = 0; i < ADC_FILTER_N - 1; i++) { for (int j = 0; j < ADC_FILTER_N - 1 - i; j++) { if (buf[j] > buf[j + 1]) { uint16_t t = buf[j]; buf[j] = buf[j + 1]; buf[j + 1] = t; } } } for (int i = 1; i < ADC_FILTER_N - 1; i++) { sum += buf[i]; } return sum / (ADC_FILTER_N - 2); }如果程序中需要持续监测电压变化,滑动平均更合适:维护一个长度为N的窗口,每采一个新值,就丢掉最旧的值,计算窗口内平均值。滑动平均对实时性影响小,但代码稍复杂。平衡车主循环如果每50ms调用一次,前两种滤波足够;如果你打算把电压用于力矩补偿或动态降功率,建议上滑动平均,滞后感没那么强。
还有一点,采样之间不要完全无间隔地连续Start,给分压电路一点恢复时间。代码里每两次采样之间加HAL_Delay(1)到2ms,对电池这种慢信号非常有效。
4. 实测踩坑记录:数据乱跳、读数不准、引脚烧毁
4.1 数据乱跳的排查顺序
我第一次做平衡车电池检测时,上电后发现ADC值像心电图一样跳,几百毫秒内能跳几十个字。排查花了不少时间,后来总结出一个顺序:先硬件、后软件、再配置。
第一步,用万用表量ADC引脚对地电压,看它稳不稳。如果引脚电压已经在抖动,那就是外部电路问题,重点查分压电阻虚焊、杜邦线接触不良、电源纹波。第二步,如果引脚电压稳定而ADC读数乱跳,那基本是配置问题,优先把采样时间拉长到239.5周期试试。第三步,检查VDDA引脚上的电容。很多最小系统板上VDDA只是简单接了3.3V,纹波大,ADC结果就会跟着漂。在VDDA和VSSA之间就近加一个100nF电容,配合一个1uF到10uF电解电容,效果立竿见影。最后再看软件滤波,滤波能掩盖问题,但别忘了从源头查。
一个很容易被忽略的因素是调试器供电。电脑USB口供的电,纹波通常不小,尤其是给电机驱动供电后,地线上会有毛刺。如果发现接上调试器数值就跳、拔掉调试器数值就稳,别怀疑ADC坏了,先给系统换成电池供电或者加隔离。
4.2 读数偏离万用表,先别急着改代码
用ADC读出的电压和万用表读数对不上,偏差可能来自三个地方:分压电阻误差、参考电压偏差、公式里的常数写错。最笨的办法是直接把公式里的3.3改成别的数硬凑,这不推荐。
正确校准流程是这样的:先用万用表量一下VDDA实测值,多数情况下不是精确3.30V,可能是3.27V或者3.34V。把实测值填进ADC_REF_VOL那个宏。然后用万用表分别量R1、R2的阻值,把实测阻值代入分压比。最后让电池处于某个稳定电压下,对比万用表读数和程序换算值,如果还差几个百分点,再考虑加一个线性校准系数。
float get_battery_voltage_calibrated(void) { float v = get_battery_voltage(); // 线性校准:scale和offset由实测标定 return v * BAT_SCALE + BAT_OFFSET; }注意校准系数不是乱加的。先确认硬件的分压和参考电压没问题,再加软件修正,否则换个板子你的“校准”就失效了。我习惯把校准环节做成分压电阻实测值和VDDA实测值的修正,而不是只记一个总系数,这样可移植性更好。
4.3 引脚烧掉的两个常见原因
ADC输入引脚烧毁这种事,案例不少,大部分是分压电阻没接或者接错。最容易出事故的接法,是把电池正极直接经过一个大电阻接到ADC引脚,忘了接下面的分压电阻。这种接法在空载时可能没事,但一旦触发上下拉保护或模拟开关内部结构,引脚电压就会冲到危险值。
另一个容易踩的坑是引脚复用冲突。比如PA0被设置成ADC输入,但板上还有别的外设把它配置成输出推挽,两边打架。还有一部分开发板PA0默认接了板载按键,按键按下把PA0拉到低电平或者接GND,你读出来的电压被外部强制拉低,还以为是ADC坏了。上电之前,建议先看开发板原理图,确认选中的引脚没有被其他电路占用。
如果某次插错线后,引脚对地电阻变得很小,芯片发热,大概率是内部ESD二极管已经损坏。这种问题基本没法修,只能换芯片。所以强烈建议:接线前先用万用表算一下分压后的最高电压,确认小于3.3V,再上电。
5. 从采集电压到平衡车电源管理
5.1 低压保护阈值和迟滞设计
当你能稳定测出电池电压之后,下一步就是把它真正用在平衡车上,而不只是显示在OLED上。锂电池低压保护一定要用分级策略:先提示,再限功率,最后断电。
比如3S电池组,我通常这样设定:
| 状态 | 电压阈值 | 动作 |
|---|---|---|
| 正常 | >10.8V | 全功率输出 |
| 低压提示 | 10.2V~10.8V | 蜂鸣器报警,限制最大输出功率 |
| 停车保护 | <10.2V持续1s | 关闭驱动,保持姿态回正 |
| 恢复 | >10.8V | 解除保护,重新输出 |
电压阈值附近一定要做迟滞。假设保护阈值是10.2V,电机大电流让电压瞬间跌到10.1V,代码立刻断电;断电后负载消失,电压又马上回升到10.6V,代码又恢复输出。结果就是平衡车一冲一冲地“打嗝”,非常危险。加上迟滞的意思是:触发保护的阈值是10.2V,但要让保护解除,必须等到电压回升到10.5V以上。中间这段区间无论电压怎么波动,都不改变保护状态。
持续时间的判断也很关键。电池在急加速时电压尖峰跌破阈值是正常的,如果要求电压连续低于阈值1秒才触发保护,就能过滤掉绝大多数瞬时波动。可以用一个简单的计数器:电压低于阈值时计数加一,高于阈值时清零,计数超过20次(假设50ms周期)再置保护标志。
5.2 采样节奏和后续ADC扩展
ADC采集代码放在哪个循环里,也值得说道。平衡车控制主频一般跑在1kHz甚至更高,如果每个控制周期都去做ADC采样、滤波、电压换算,会占用不少CPU时间,而且滤波本身会引入相位延迟。我习惯把电池电压采样放到一个慢速任务里,比如每50ms一次,只做电量监测和低压保护判断。电机电流检测如果用ADC,那种是需要快速响应的,另开DMA通道采样。
从课程进度看,这次只是单通道单次转换,后面还有两个明显扩展点:一个是用DMA进行多通道扫描采集,比如电池电压、电机电流、左轮倾角调理电压一起采;另一个是注入组和外部触发,可以配合PWM同步采样。理解好当前这节,后续这些只是配置方式的变化,原理完全一样。
我在实际调车中一个很深的体会是:ADC测量这种基础模块,花时间把原理搞透,比抄一百遍代码都管用。返回去排查问题时,知道采样时间、参考电压、分压电阻各自的角色,一眼就能锁定问题;否则只能靠猜。平衡车项目后面还有电机驱动、姿态解算、控制环路,每一个环节都会回来用到ADC。把这第一步走扎实,后面的路会顺很多。