STM32 PWM输入捕获:从原理到实战,解决蓝桥杯嵌入式竞赛高频考点
发布时间:2026/8/29 21:32:54 作者:尧图编辑部 阅读量:1,286

1. 项目背景与核心需求解析在蓝桥杯嵌入式赛事的备赛过程中PWM输入捕获是一个高频且关键的考点。它不仅是测量外部信号频率和占空比的基础更是后续实现电机测速、舵机控制、通信协议解码等复杂功能的基石。很多同学在初次接触时往往被STM32CubeMX的配置界面和HAL库的函数调用所困扰要么代码写得冗长低效要么在调试时遇到各种“玄学”问题比如捕获值跳变、中断进不去、测量结果不准等白白浪费了宝贵的备赛时间。这个标题点出了两个核心场景“一路PWM输入捕获”和“两路输入捕获”。这不仅仅是数量上的区别其背后的硬件资源配置、中断逻辑处理以及代码架构的设计思路都有显著差异。一路捕获通常用于测量单个信号的参数而两路捕获则可能用于测量两个独立信号的参数或者更常见的是用于测量同一信号的两个边沿如上升沿和下降沿以实现高精度的占空比测量这恰恰是蓝桥杯真题中曾出现过的难点。因此快速、准确地编写出稳定可靠的输入捕获驱动并深刻理解其背后的原理和常见陷阱是备赛路上必须跨过的一道坎。本文将从一个有多年嵌入式开发与竞赛指导经验的视角出发不仅带你快速搭建驱动框架更会深入剖析那些数据手册和标准例程里不会细说的“坑”让你在赛场上面对此类题目时能够从容应对。2. 一路PWM输入捕获从原理到稳定实现输入捕获的本质是利用定时器的计数器在输入信号发生特定事件如上升沿时将当前计数器的值锁存到专用的捕获/比较寄存器中。通过计算两次捕获事件之间的计数器差值再结合定时器的计数频率我们就能推算出信号的周期或脉宽。2.1 硬件与CubeMX配置要点首先我们需要选定一个定时器通道。对于STM32G431蓝桥杯嵌入式竞赛板常用型号几乎所有通用定时器TIM2, TIM3, TIM4...和高级定时器TIM1, TIM8都支持输入捕获功能。选择时需注意引脚分配避免与板上其他已用资源如LED、按键冲突。在STM32CubeMX中的配置远不止勾选“Input Capture direct mode”那么简单。以下是几个极易忽略但至关重要的配置点时钟源与分频定时器的时钟源必须正确。通常来自APB总线。关键参数是Prescaler预分频器。它决定了定时器计数器的实际计数频率Fcnt Fclock / (PSC 1)。PSC设置过大计数器跑得慢测量高频信号时精度低PSC设置过小计数器跑得快但测量低频信号时容易溢出。一个实用的技巧是根据你预期测量的最高信号频率来设置。例如系统时钟80MHz预期测量最高10kHz的信号我们希望一次计数的时间远小于信号周期可以设定计数器频率为1MHz则PSC 80 - 1 79。计数器周期Counter Period即ARR寄存器决定了计数器的最大值。当测量低频信号时如果ARR太小可能在两个捕获边沿之间计数器就溢出了导致计算错误。通常在测量频率时ARR可以设置为最大值65535对于16位定时器。但在测量脉宽时如果信号周期很长就需要结合溢出中断来扩展测量范围。输入滤波与边沿选择IC Filter输入捕获滤波器可以设置数字滤波的采样次数用于消除信号线上的毛刺。对于实验室环境或稳定的信号源可以设为0。对于可能受到干扰的电机编码器等场景可以适当增加如4或8。IC Polarity选择捕获边沿测量周期通常选择Rising Edge测量脉宽则需要分别捕获上升沿和下降沿。中断与DMA务必勾选Capture/Compare Interrupt。这是驱动能够工作的核心。是否使用DMA需视情况而定对于单路捕获中断方式代码更简单直观对于多路或超高频率信号DMA可以减轻CPU负担。2.2 驱动代码的快速编写框架CubeMX生成代码后我们需要在工程中补充应用层逻辑。一个健壮的一路PWM捕获驱动应包含以下部分变量定义// 在合适的位置定义全局变量 volatile uint32_t g_capture_value1 0; // 第一次捕获值 volatile uint32_t g_capture_value2 0; // 第二次捕获值 volatile uint8_t g_capture_stage 0; // 捕获阶段标志0:等待第一个上升沿1:已捕获第一个上升沿等待下降沿2:已捕获下降沿等待第二个上升沿... volatile uint32_t g_period_ticks 0; // 周期计数器刻度 volatile uint32_t g_pulse_width_ticks 0; // 脉宽计数器刻度 volatile uint32_t g_overflow_count 0; // 计数器溢出次数用于扩展测量中断回调函数重写这是HAL库的精髓我们需要在stm32g4xx_it.c中找到定时器中断服务函数但更规范的做法是在主程序文件中重写HAL库提供的弱定义回调函数。// 在 main.c 或专门的捕获驱动文件中 void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { // 首先判断是哪个定时器产生的中断 if (htim-Instance TIMx) { // TIMx 替换为你实际使用的定时器如 TIM2 switch(g_capture_stage) { case 0: // 第一次捕获上升沿 g_capture_value1 HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_y); // 读取捕获值 __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_y, TIM_INPUTCHANNELPOLARITY_FALLING); // 改为捕获下降沿 g_capture_stage 1; break; case 1: // 第二次捕获下降沿 g_capture_value2 HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_y); // 计算脉宽注意计数器溢出处理 if (g_capture_value2 g_capture_value1) { g_pulse_width_ticks g_capture_value2 - g_capture_value1; } else { // 发生了溢出脉宽 (ARR - value1) value2 1 g_pulse_width_ticks (__HAL_TIM_GET_AUTORELOAD(htim) - g_capture_value1) g_capture_value2 1; } __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_y, TIM_INPUTCHANNELPOLARITY_RISING); // 改回捕获上升沿 g_capture_stage 2; // 或者0取决于你是否需要连续测量周期 break; case 2: // 第三次捕获下一个上升沿用于计算周期 g_capture_value1 HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_y); // 计算周期同样需要考虑溢出 // ... 计算逻辑与脉宽类似但使用的是本次值与前一个上升沿捕获值的差值 // 计算完成后可以将g_capture_stage重置为0或1开始新一轮测量 g_capture_stage 0; // 重新开始 break; } } } // 定时器溢出中断回调如果需要 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIMx) { g_overflow_count; // 溢出次数加1 } }主程序中的初始化与启动// 在main函数的初始化部分启动定时器和输入捕获 HAL_TIM_Base_Start_IT(htimx); // 启动定时器基础计数并开启更新溢出中断 HAL_TIM_IC_Start_IT(htimx, TIM_CHANNEL_y); // 启动输入捕获并开启捕获中断测量结果计算在主循环或需要的地方将捕获的计数器刻度值转换为实际的频率和占空比。float get_frequency_hz(void) { if (g_period_ticks 0) return 0.0f; float timer_freq SystemCoreClock / (htimx.Init.Prescaler 1); // 定时器计数频率 float period_sec g_period_ticks / timer_freq; // 周期秒 return 1.0f / period_sec; } float get_duty_cycle(void) { if (g_period_ticks 0) return 0.0f; return ((float)g_pulse_width_ticks / (float)g_period_ticks) * 100.0f; }这个框架清晰地分离了中断服务快、只做关键数据记录和主循环计算慢、进行浮点运算是嵌入式实时系统的典型设计模式。3. 两路输入捕获的两种模式与驱动设计“两路输入捕获”这个需求通常有两种实现模式其驱动复杂度和应用场景截然不同。3.1 模式一两个独立通道测量两个独立信号这是最直观的模式。例如用TIM2的通道1测量一个编码器的A相频率用通道2测量B相频率。CubeMX配置上只需为同一个定时器的两个通道分别配置为输入捕获模式即可。驱动设计要点中断区分两个通道的中断会共用同一个定时器中断服务函数。在回调函数HAL_TIM_IC_CaptureCallback中必须通过htim-Channel来判断是哪个通道产生的中断。void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { if (htim-Channel HAL_TIM_ACTIVE_CHANNEL_1) { // 处理通道1的捕获逻辑 // 需要为通道1单独维护一套状态变量g_stage_ch1, g_value1_ch1... } else if (htim-Channel HAL_TIM_ACTIVE_CHANNEL_2) { // 处理通道2的捕获逻辑 // 需要为通道2单独维护一套状态变量 } } }变量隔离必须为两个通道定义完全独立的全局状态变量组避免数据竞争和逻辑混乱。这是最容易出错的地方之一新手常常会共用变量导致测量结果互相覆盖。资源考量两个通道共享同一个定时器计数器这意味着它们的计时基准是同步的有利于比较两个信号的相位差等。但如果两个信号频率相差巨大需要注意定时器Prescaler和ARR的设定要能同时满足两者的测量范围。3.2 模式二单通道双沿捕获实现高精度占空比测量这是蓝桥杯赛题中更常见也更考验理解的模式。题目可能要求使用一个定时器的一个通道同时测量PWM波的频率和占空比。这无法通过一次捕获完成需要利用定时器的“PWM输入模式”或者手动实现双沿捕获。方法A使用定时器的“PWM输入模式”硬件自动连接这是STM32提供的一个强大功能。以TIMx为例你需要将输入信号同时连接到通道1和通道2通常是内部映射具体引脚参考数据手册。在CubeMX中将通道1配置为“PWM Input direct mode”或“PWM Input indirect mode”。CubeMX会自动将通道2配置为互补的捕获模式。原理信号从通道1输入。通道1捕获上升沿通道2捕获下降沿并且硬件自动将两个通道的捕获事件关联起来。配置CubeMX会自动设置从模式控制器、触发源等复杂寄存器大大简化了软件逻辑。读取在中断中你只需要分别读取通道1和通道2的捕获寄存器它们的差值就是脉宽而通道1的连续两次捕获值之差就是周期。代码逻辑比手动双沿捕获简单很多。方法B手动实现单通道双沿捕获这就是我们在“一路PWM输入捕获”章节中展示的代码框架。通过在中途动态切换捕获边沿使用__HAL_TIM_SET_CAPTUREPOLARITY来实现上升沿和下降沿的交替捕获。这种方法更灵活但软件逻辑稍复杂且对中断响应时间有要求不能错过边沿。注意在手动切换边沿时务必在读取本次捕获值之后再切换边沿极性。如果先切换边沿再读取在高速信号下可能新的边沿已经到来并覆盖了捕获寄存器导致读取到错误的值。4. 输入捕获实战中的八大常见“坑”与解决方案理论懂了代码写了一上板子就出问题。以下是多年调试经验总结出的高频问题点。4.1 捕获值读取为0或固定值现象进入中断后HAL_TIM_ReadCapturedValue读回来的值一直是0或某个不变的数。排查引脚复用检查首先确认CubeMX中该引脚是否确实配置为对应定时器的复用功能AF而不是普通的输入或输出。时钟使能确认定时器外设的时钟已经使能CubeMX一般会自动生成。信号源问题用示波器或逻辑分析仪检查信号是否真的到达了MCU引脚。可能是杜邦线接触不良、信号电平不匹配STM32是3.3V CMOS电平或信号本身有问题。中断未正确触发检查CubeMX中是否勾选了捕获中断以及主程序中是否调用了HAL_TIM_IC_Start_IT。4.2 测量结果偶尔正确大部分时间错误或跳变现象测量值不稳定在正确值附近乱跳。排查变量未加volatile在中断和主循环中共享的全局状态变量如g_capture_stage,g_capture_value1必须用volatile关键字修饰防止编译器优化导致数据不一致。中断嵌套与优先级如果系统中有其他高优先级中断长时间执行可能会丢失输入捕获中断。检查并合理配置中断优先级NVIC确保输入捕获中断的响应足够及时。软件滤波不足对于有噪声的信号CubeMX中配置的IC Filter可能不够。可以在软件中增加简单的滤波算法例如连续测量N次然后取中值或平均值。计数器溢出处理遗漏这是最隐蔽的坑当信号周期较长时定时器计数器可能在两个捕获边沿之间发生了溢出。如果你的计算逻辑没有考虑溢出那么当溢出发生时计算结果会突然变小因为计数器从0重新开始。解决方案就是引入“溢出计数器”g_overflow_count。在计算时间间隔时公式应为间隔 (本次溢出计数 - 上次溢出计数) * (ARR1) (本次捕获值 - 上次捕获值)。如果本次捕获值小于上次捕获值说明在两次捕获间至少发生了一次溢出需要加上一个完整的周期(ARR1)。4.3 占空比测量接近100%或0%时异常现象当PWM波的占空比极大或极小时测量出的频率或占空比会出错。原因与解决这通常发生在“手动双沿捕获”模式。当高电平或低电平时间极短时MCU可能来不及在第一次边沿中断中处理完包括切换边沿极性第二个边沿就已经到来并被错过了。优化中断服务函数确保HAL_TIM_IC_CaptureCallback中的代码尽可能精简只做必要的读取、切换和状态更新复杂的计算放到主循环。提高定时器时钟减小Prescaler让计数器跑得更快这样即使很窄的脉冲也能被多个计数器刻度捕获到提高分辨率和可靠性。考虑使用PWM输入模式硬件模式的响应速度和可靠性远高于软件切换。4.4 同时使用更新溢出中断和捕获中断的冲突现象开启了溢出中断用于扩展测量范围但发现捕获逻辑有时会乱掉。排查在溢出中断回调函数和捕获中断回调函数中如果都操作了同一个状态变量如g_overflow_count需要考虑临界区保护。虽然中断服务函数本身是原子的但一个中断可能打断另一个中断。更安全的方法是在操作共享变量时临时关闭全局中断__disable_irq()或使用更精细的锁机制但要注意关闭中断的时间要极短。4.5 CubeMX配置复位导致代码被覆盖现象修改代码后重新用CubeMX生成工程自己写的回调函数代码消失了。解决这是CubeMX的使用规范问题。用户代码必须写在/* USER CODE BEGIN xx */和/* USER CODE END xx */注释对之间。CubeMX重新生成时只会覆盖这些注释对之外的代码。务必养成习惯将自定义的函数、变量声明和定义都放在USER CODE区域。4.6 测量低频信号精度差现象测量几十Hz以下的信号时误差很大。原因定时器计数器位数有限通常16位最大65535。对于低频信号两个边沿之间间隔的计数器刻度值很大可能接近甚至超过ARR值导致溢出处理变得频繁且复杂精度下降。解决增大预分频PSC降低计数器频率让计数器“跑慢点”这样在信号周期内计数值变小溢出风险降低。但这会牺牲对高频信号的分辨率。使用32位定时器如果MCU支持如某些系列的LPTIM或通用定时器组合模式使用32位计数器可以获得极大的测量范围。软件扩展配合溢出中断进行软件计数将测量范围扩展到32位甚至64位。这是最通用的方法。4.7 HAL库函数调用顺序错误现象无法启动捕获或中断不触发。正确顺序HAL_TIM_Base_Init(htim)HAL_TIM_IC_Init(htim)HAL_TIM_Base_Start_IT(htim)//先启动定时器基础计数HAL_TIM_IC_Start_IT(htim, TIM_CHANNEL_x)//再启动输入捕获务必先启动定时器Base_Start再启动捕获通道IC_Start。如果顺序反了计数器没有运行捕获事件自然无法产生。4.8 对HAL库机制理解不足导致的超时现象程序运行一段时间后似乎卡死了。排查HAL库的许多函数内部有超时机制HAL_MAX_DELAY。如果在中断服务函数中错误地调用了某些可能阻塞的HAL函数如某些带轮询等待的函数而条件永远不满足就会导致超时卡死。在中断中应只使用那些明确标注为__weak的、用于重写的回调函数或者直接操作寄存器。5. 竞赛场景下的优化策略与代码模板在蓝桥杯这种时间有限的竞赛中代码的效率和可靠性至关重要。这里提供一个经过优化的、用于单通道双沿捕获的模板它考虑了溢出处理并且将计算部分剥离出中断。capture.h#ifndef __CAPTURE_H #define __CAPTURE_H #include “main.h” typedef struct { uint32_t frequency_hz; float duty_cycle_percent; uint8_t data_valid; // 数据有效标志 } PWM_Measure_Result_t; void PWM_Capture_Init(TIM_HandleTypeDef *htim, uint32_t channel); void PWM_Capture_Start(void); PWM_Measure_Result_t PWM_Capture_GetResult(void); #endifcapture.c// 私有全局变量仅在本文件内使用 static TIM_HandleTypeDef *s_htim; static uint32_t s_channel; static volatile uint32_t s_rise_val 0, s_fall_val 0, s_last_rise_val 0; static volatile uint32_t s_overflow_cnt 0, s_last_overflow_cnt 0; static volatile uint8_t s_cap_stage 0; // 0:等待上升沿1:已捕获上升沿2:已捕获下降沿 static volatile uint8_t s_new_data_ready 0; static uint32_t s_arr_max; void PWM_Capture_Init(TIM_HandleTypeDef *htim, uint32_t channel) { s_htim htim; s_channel channel; s_arr_max __HAL_TIM_GET_AUTORELOAD(htim); // 可以在这里初始化其他变量 } void PWM_Capture_Start(void) { s_cap_stage 0; s_new_data_ready 0; s_overflow_cnt 0; __HAL_TIM_SET_CAPTUREPOLARITY(s_htim, s_channel, TIM_INPUTCHANNELPOLARITY_RISING); HAL_TIM_Base_Start_IT(s_htim); HAL_TIM_IC_Start_IT(s_htim, s_channel); } // 在定时器溢出中断中调用 void Capture_TIM_Overflow_Callback(void) { if(s_htim-Instance TIMx) { // 替换为你的定时器 s_overflow_cnt; } } // 在输入捕获中断中调用 void Capture_TIM_IC_Callback(void) { uint32_t current_cap_val HAL_TIM_ReadCapturedValue(s_htim, s_channel); switch(s_cap_stage) { case 0: // 捕获到第一个上升沿 s_rise_val current_cap_val; s_last_overflow_cnt s_overflow_cnt; __HAL_TIM_SET_CAPTUREPOLARITY(s_htim, s_channel, TIM_INPUTCHANNELPOLARITY_FALLING); s_cap_stage 1; break; case 1: // 捕获到下降沿 s_fall_val current_cap_val; __HAL_TIM_SET_CAPTUREPOLARITY(s_htim, s_channel, TIM_INPUTCHANNELPOLARITY_RISING); s_cap_stage 2; // 下一个上升沿用于计算周期 break; case 2: // 捕获到第二个上升沿计算周期 s_last_rise_val s_rise_val; s_rise_val current_cap_val; s_new_data_ready 1; // 标记有新数据可用 s_cap_stage 1; // 为下一个下降沿捕获做准备 break; } } PWM_Measure_Result_t PWM_Capture_GetResult(void) { PWM_Measure_Result_t result {0}; if(s_new_data_ready) { uint32_t overflow_diff, period_ticks, pulse_ticks; float timer_clk SystemCoreClock / (s_htim-Init.Prescaler 1); // 计算周期两个上升沿之间 overflow_diff s_overflow_cnt - s_last_overflow_cnt; if(s_rise_val s_last_rise_val) { period_ticks overflow_diff * (s_arr_max 1) (s_rise_val - s_last_rise_val); } else { period_ticks (overflow_diff - 1) * (s_arr_max 1) (s_arr_max - s_last_rise_val s_rise_val 1); } result.frequency_hz (period_ticks 0) ? (timer_clk / period_ticks) : 0; // 计算脉宽上升沿到下降沿 // 注意这里假设下降沿发生在紧接着的上升沿之前未跨越多重溢出。复杂场景需额外记录下降沿时的溢出计数。 if(s_fall_val s_last_rise_val) { pulse_ticks s_fall_val - s_last_rise_val; } else { pulse_ticks (s_arr_max - s_last_rise_val s_fall_val 1); } result.duty_cycle_percent (period_ticks 0) ? ((float)pulse_ticks / period_ticks * 100.0f) : 0; s_new_data_ready 0; // 清除标志 result.data_valid 1; } return result; }这个模板将复杂的溢出计算封装在GetResult函数中中断服务只做最简单的记录和切换极大提高了系统的实时性和可靠性。在竞赛中你可以直接移植这个框架根据题目要求稍作修改即可。6. 调试技巧与验证方法写好的驱动如何验证光看串口打印的数据是不够的。使用示波器对比这是最权威的方法。用示波器测量信号的实际频率和占空比与MCU计算出的结果对比。可以系统地改变信号发生器的参数频率从低到高占空比从10%到90%观察测量值是否始终准确。利用板载资源输出验证信号用另一个定时器产生一个已知频率和占空比的PWM波例如1kHz50%输出到另一个引脚然后用你的捕获驱动去测量这个引脚。这样你就有了一个绝对正确的参考源。软件模拟信号测试在开发初期可以暂时不接外部信号。在定时器溢出中断里手动翻转一个GPIO引脚来模拟一个方波然后用捕获功能去测这个GPIO。这样可以隔离硬件问题专注调试软件逻辑。关键变量可视化将g_capture_stage、g_capture_value1/2、g_overflow_count这些关键变量的值实时显示在LCD屏幕上。通过观察它们的变化规律可以非常直观地判断你的状态机是否在正确运行溢出处理是否生效。逻辑分析仪抓取时序如果你有逻辑分析仪可以同时抓取输入信号、捕获中断触发信号、以及用于指示不同捕获阶段的GPIO引脚。通过分析波形时序可以精确判断中断响应是否及时边沿切换逻辑是否正确。最后我个人在带学生备赛时反复强调的一点是理解优先于记忆。不要满足于把代码调通要一步步跟踪寄存器值的变化画一画状态转换图搞清楚每一个if...else判断是处理什么边界情况。只有这样当赛场上的题目稍加变化比如要求测量相位差、要求同时捕获三路信号你才能从容地从基本原理出发快速推导出新的解决方案。输入捕获这个模块吃透了原理竞赛中相关的分数基本就是稳拿的。