1. 项目背景
在最近的平衡车项目中,为了防止电池过放,需要实时监控 8.4V 航模电池的电压。我的方案是:使用 STM32 的TIM2 定时器,每隔 10ms 触发一次ADC1 的注入通道(Channel 8, PB0)进行采样,并在 ADC 转换完成中断(JEOC)中计算实际电压,最后通过串口 2 发送给电脑端的Vofa+上位机绘制波形。
逻辑听起来很简单,但在实际手写代码测试时,却接连踩了好几个隐蔽的坑。这篇文章就来复盘一下整个硬件触发链条是如何设计的,以及我在测试代码中踩过的那些坑。
下面是这套系统的整体数据流与控制流示意图:
图注:图中上半部分为 STM32 内部的硬件联动链路——TIM2 定时器作为“节拍器”,通过 TRGO 触发 ADC1 注入通道采样,采样完成后由 JEOC 中断完成电压换算并刷新全局变量vbat;应用层通过App_Bat_Get()读取电压,再经 USART2 发送到电脑端的 Vofa+ 绘制波形。整个采样过程由硬件自动完成,主循环无需等待 ADC 转换。
2. 核心原理解析:数据的“层层加码”与传递
为了实现高效、不阻塞主程序的电压采集,这套代码在硬件触发和软件架构上做了一套“层层加码”的联动机制。具体的数据流和控制流如下:
第一层:TIM2 定时器(幕后节拍器)
首先配置 TIM2,使其每隔 10ms 产生一次更新事件。关键在于,我们不让 TIM2 触发中断,而是把它的更新事件通过TIM_SelectOutputTrigger映射到TRGO(触发输出)上。此时,TIM2 就成了一个纯硬件的“节拍器”。
第二层:ADC 注入通道(硬件联动采样)
我们将 ADC1 开启注入通道。在配置中,将它的外部触发源选定为ADC_ExternalTrigInjecConv_T2_TRGO。这样一来,每当 TIM2 的 10ms 节拍一打响,硬件层面就会自动“悄悄”触发一次 ADC 采样,全程不需要占用 CPU 资源。
第三层:JEOC 中断(数据换算中心)
当 ADC 注入通道完成一次转换后,会触发 JEOC(注入转换完成)中断。CPU 响应中断进入ADC1_2_IRQHandler,读取寄存器里的原始 12 位 ADC 值,并通过公式将其换算为实际的浮点数电压,最后刷新到全局变量vbat中。
第四层:应用层封装(业务逻辑调用)
底层硬件在后台有条不紊地工作,而应用层只需要“坐享其成”。我们在外层封装了一个简单的App_Bat_Get()函数,直接 return 全局变量vbat。在主循环Bat_Test()中,轻松拿到最新电压并通过串口打印给 Vofa+。
通过这种外设联动的架构,主循环不用死等 ADC 转换,为后续平衡车的姿态解算和电机 PID 控制留出了宝贵且充足的 CPU 算力。
下面是这套“层层加码”架构的分层示意图,可以更直观地看到每一层各司其职、环环相扣的关系:
图注:第一层 TIM2 定时器只负责产生 10ms 节拍并通过 TRGO 输出;第二层 ADC 注入通道在硬件层面自动响应 TRGO 完成采样;第三层 JEOC 中断负责把原始 ADC 值换算成电压并刷新全局变量;第四层应用层只需调用App_Bat_Get()即可拿到最新电压。每一层只依赖上一层的输出,职责单一、耦合清晰。
3. 踩坑与排错记录
理清了上面“层层加码”的架构,实际敲代码时却状况百出,导致串口死活收不到数据,或者收到的全是0.000。以下是三大常见排雷记录:
❌ 坑1:中断里的“阅后即焚”局部变量(C语言基础坑)
为了在不同文件中获取电压,我定义了一个全局变量static volatile float vbat = 0.0f;。但是在中断服务函数里,我却顺手加了个float:
voidADC1_2_IRQHandler(){if(ADC_GetFlagStatus(ADC1,ADC_FLAG_JEOC)==SET){ADC_ClearFlag(ADC1,ADC_FLAG_JEOC);uint16_tjdr1=ADC_GetInjectedConversionValue(ADC1,ADC_InjectedChannel_1);// ❌ 错误代码:画蛇添足的 floatfloatvbat=jdr1/4095.0f*8.4f;}}原因分析:加上float关键字相当于重新声明了一个局部变量。这个局部变量屏蔽了外部的全局变量,导致算出的电压值一出中断就被系统销毁了,外界拿到的永远是初始值0.000。
正确写法:去掉float,直接给全局变量赋值:vbat = jdr1 / 4095.0f * 8.4f;
❌ 坑2:注入通道的触发使能函数写错(最容易忽视的库函数坑)
解决了变量问题后,测试发现数据依然是0.000。经过排查,发现是外设的外部触发根本没开启(联动链条的第二层断了):
// ❌ 错误代码:错用了规则通道的使能函数ADC_ExternalTrigConvCmd(ADC1,ENABLE);原因分析:我配置的一直是注入通道 (Injected Channel),但ADC_ExternalTrigConvCmd这个函数是用来开启规则通道 (Regular Channel)外部触发的。名字里少了一个Injected,导致注入通道无法接收 TIM2 的 TRGO 触发信号。
正确写法:必须使用注入通道专属的使能函数:
ADC_ExternalTrigInjectedConvCmd(ADC1,ENABLE);❌ 坑3:外设时钟总线张冠李戴
在测试阶段配置 USART2 时,时钟使能代码随手一敲:
// ❌ 错误代码:USART2 挂载在 APB1,却用了 APB2 的函数RCC_APB2PeriphClockCmd(RCC_APB1Periph_USART2,ENABLE);原因分析:GPIOA挂在 APB2 上,但USART2挂载在APB1总线上。把 APB1 的外设宏传给 APB2 的时钟使能函数,时钟根本打不开,串口直接装死。
正确写法:RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE);
💡 优化项:不要忘记 ADC 校准
STM32F1 系列的 ADC 在每次上电开启总开关(ADC_Cmd)后,强烈建议进行一次复位和校准,否则电压值会存在较大的固定偏差。建议补充以下标准四步曲:
ADC_ResetCalibration(ADC1);// 复位校准寄存器while(ADC_GetResetCalibrationStatus(ADC1));// 等待复位完成ADC_StartCalibration(ADC1);// 开始ADC校准while(ADC_GetCalibrationStatus(ADC1));// 等待校准完成最后,把整个排错过程整理成一张流程图,方便以后遇到类似问题时按图索骥:
图注:排错时先看现象——数据全是0.000通常指向中断变量或触发使能问题;串口完全无数据则优先检查时钟总线。按流程图逐层排查,能快速定位到具体坑位。
4. 正确的整合测试代码 (Bat_Test)
经过以上排雷,最终在主程序的测试代码如下:
#include"Bat_Test.h"#include"App_Bat.h"#include"App_Usart2.h"#include"delay.h"// @简介:电池电压监控模块的测试程序// 通过串口2把电压发送给电脑,用Vofa+显示曲线voidBat_Test(void){delay_init();// 别忘了初始化SysTick延时函数!App_USART2_Init();// 初始化串口2App_Bat_Init();// 电池电压检测初始化(TIM2 + ADC注入通道)while(1){// 获取电压并打印,Vofa+ 接收到换行符会解析为一个数据点floatvolt=App_Bat_Get();My_USART_Printf(USART2,"%.3f\n",volt);// 延时10ms,与Vofa+的接收刷新频率配合Delay(10);}}5. 总结
打开 Vofa+,连接串口并设置波特率 115200,终于看到了稳定在8.090V左右的真实连续电压数据流和直观的波形图!
写在最后:写底层代码,尤其是操作外设寄存器和库函数时,一点点拼写错误或逻辑疏忽(比如错用总线、搞错 Regular 和 Injected)都会导致硬件直接罢工,且不会有任何报错提示。仔细核对官方参考手册,理清外设触发链条的每一个层级,是嵌入式 Debug 的必修课。
(完)