STM32定时器输入捕获驱动设计:从PWM测量到多路信号处理实战
2026/8/28 9:39:34 网站建设 项目流程

1. 项目概述:PWM输入捕获在蓝桥杯嵌入式赛题中的核心地位

如果你正在准备蓝桥杯嵌入式组的比赛,那么“PWM输入捕获”这个功能点,绝对是你绕不开、必须啃下的硬骨头。它不像简单的GPIO点灯那样直观,也不像串口通信那样有现成的轮子,PWM输入捕获往往隐藏在赛题对“信号测量”、“电机测速”、“脉冲计数”等需求背后,是区分选手水平的关键技术点之一。我参加过也指导过多次比赛,发现很多同学在实现一路PWM测量时还能勉强应付,但一旦题目要求同时测量两路甚至更多路信号(比如同时测量两个电机的转速),程序就立刻变得混乱不堪,要么测量不准,要么互相干扰,最终导致整个系统功能崩溃。

这个项目标题“一路PWM输入捕获和两路输入捕获驱动快速编写及常见问题”,精准地戳中了备赛过程中的两大痛点:效率稳定性。“快速编写”意味着我们需要一套清晰、模块化的驱动框架,避免每次比赛都从头造轮子;而“常见问题”则直指那些让程序跑飞、数据跳变的隐蔽陷阱。本文将基于STM32平台(蓝桥杯嵌入式竞赛指定平台),深入拆解PWM输入捕获的原理,分享一套经过实战检验的、可同时稳定驱动一路标准PWM输入和两路通用输入捕获的驱动代码框架,并附上那些在官方手册里找不到的“踩坑”实录与解决方案。无论你是初次接触定时器捕获功能,还是想优化自己的代码结构,这篇文章都能提供直接的、可“抄作业”的参考。

2. 核心思路与方案选型:为什么是定时器输入捕获模式?

在开始写代码之前,我们必须搞清楚“输入捕获”到底是什么,以及为什么它是测量PWM波形的首选方案。PWM(脉冲宽度调制)信号的核心参数是频率和占空比。测量它的方法有很多,比如外部中断配合普通定时器计时,或者使用输入捕获模式。

注意:对于蓝桥杯赛题,强烈建议一律使用定时器的输入捕获模式,而不是外部中断+软件计时。后者在测量高频信号、多路信号时,会大量占用CPU资源,并极易因中断嵌套和响应延迟导致测量误差巨大,属于事倍功半的方案。

输入捕获模式的精妙之处在于,它将“检测边沿”和“记录时刻”这两件事,全部交给了定时器硬件自动完成。其工作原理可以概括为:当指定的输入引脚上发生预设的边沿事件(如上升沿)时,定时器硬件会瞬间将当前计数器的值(CNT)锁存到专用的捕获/比较寄存器(CCRx)中,并可以产生中断。我们只需要在中断服务函数中,读取两次捕获到的计数器值,它们的差值就代表了两个边沿之间的时间间隔,再乘以计数器的时钟周期,就能得到高电平或低电平的脉宽。

对于一路标准PWM输入捕获,STM32的定时器提供了一个“PWM输入模式”,这实际上是输入捕获模式的一个特殊应用。它利用了两个捕获通道(通常是CH1和CH2),一个捕获上升沿,一个捕获下降沿,并且自动将两个通道关联到同一个输入引脚。这样,在一次捕获周期内,硬件自动完成了周期和占空比的测量,非常高效。但它的限制是:一个定时器只能测量一路PWM信号。

当赛题要求同时测量两路输入信号(可以是两路PWM,也可以是两路频率信号)时,PWM输入模式就不够用了。这时,我们需要回归到基础的两路独立输入捕获方案。通常有两种配置思路:

  1. 单定时器双通道:使用一个定时器的两个捕获通道(如CH1和CH2),分别捕获两路不同的输入信号。这种方法硬件资源利用率高,但两路信号共享同一个计数器基准,需要注意中断逻辑和资源竞争。
  2. 双定时器:使用两个独立的定时器,各自负责一路信号的捕获。这种方法逻辑清晰,互不干扰,但消耗的定时器资源较多。

在蓝桥杯竞赛中,CT117E开发板资源有限,我们需要精打细算。我的实战经验是:如果两路信号需要非常精确的同步测量,或者频率较高,优先考虑方案2(双定时器)。如果资源紧张,且两路信号测量要求相对独立,方案1(单定时器双通道)经过精心设计后也能稳定工作。下文将分别给出这两种场景下的驱动实现。

3. 驱动框架设计与模块化思想

直接写一堆混杂在main.c里的中断服务函数是备赛大忌。一个好的驱动框架应该达到以下目标:接口清晰、配置灵活、与业务逻辑解耦。我们将驱动分为三个层次:

  1. 硬件抽象层(HAL/LL库配置):利用STM32CubeMX生成初始化代码,或直接编写寄存器/标准库代码,完成GPIO、定时器、中断的底层配置。
  2. 驱动接口层:封装核心的捕获逻辑,提供诸如PWM_In_Start()PWM_In_GetFreq()PWM_In_GetDuty()等函数。这一层负责处理原始计数器值,计算频率和占空比,并管理捕获状态机。
  3. 应用层:调用驱动接口层的函数获取测量结果,用于屏幕显示、逻辑判断或控制输出。

这里重点分享驱动接口层的设计。我们需要定义几个关键的结构体和全局变量来管理捕获状态。

// PWM输入捕获结构体 (用于标准PWM输入模式,一路) typedef struct { TIM_HandleTypeDef *htim; // 定时器句柄 uint32_t IC_Channel; // 输入捕获通道 volatile uint32_t period; // 周期值 (单位: 定时器计数 ticks) volatile uint32_t pulse; // 高电平脉宽值 (单位: 定时器计数 ticks) volatile float freq_hz; // 计算后的频率 (Hz) volatile float duty_cycle; // 计算后的占空比 (%) volatile uint8_t is_updated; // 数据更新标志 } PWM_In_Capture_t; // 通用输入捕获结构体 (用于两路独立捕获模式) typedef struct { TIM_HandleTypeDef *htim; uint32_t IC_Channel_Rising; // 上升沿捕获通道 uint32_t IC_Channel_Falling; // 下降沿捕获通道 (若只测频率,可只用一个) volatile uint32_t last_capture_rise; // 上一次上升沿捕获值 volatile uint32_t last_capture_fall; // 上一次下降沿捕获值 volatile uint32_t period_ticks; // 周期计数 volatile uint32_t pulse_ticks; // 脉宽计数 volatile uint8_t capture_stage; // 捕获阶段状态机 (0:等待上升沿, 1:已捕获上升沿等下降沿...) volatile uint8_t is_valid; // 本次测量是否有效 } Dual_Input_Capture_t;

使用结构体的好处是,当需要管理多路捕获时(比如一路PWM输入+两路独立捕获),你可以轻松定义多个实例,例如PWM_In_Capture_t pwm1;Dual_Input_Capture_t cap1, cap2;,代码结构非常清晰。

4. 一路标准PWM输入捕获的快速实现

我们以STM32的TIM2定时器,通道1(PA0引脚)为例,实现标准PWM输入模式。使用STM32CubeMX配置最为快捷:

  1. 将PA0配置为TIM2_CH1。
  2. 打开TIM2,选择“PWM Input Mode”。你会发现,CH1和CH2会被自动配置。CH1被映射到TI1输入,用于触发;CH2被用于捕获。
  3. 配置预分频器(PSC)和自动重载值(ARR)。这是关键!ARR的值决定了你能测量的最大周期。例如,定时器时钟为72MHz,PSC设为71,则计数器时钟为1MHz(1us计数一次)。若ARR设为65535,则最大可测周期为65.535ms,对应最小频率约15.26Hz。你需要根据赛题信号的可能范围来调整这两个参数。
  4. 开启定时器更新中断和捕获/比较中断。

生成的初始化代码后,我们编写中断服务函数和驱动接口。核心逻辑在HAL_TIM_IC_CaptureCallback()这个回调函数中。

// 全局PWM捕获结构体实例 PWM_In_Capture_t g_pwm_in; void PWM_In_Init(TIM_HandleTypeDef *htim) { g_pwm_in.htim = htim; g_pwm_in.is_updated = 0; // 启动输入捕获(使用HAL库,通道2用于捕获) HAL_TIM_IC_Start_IT(htim, TIM_CHANNEL_2); // 启动从模式下的触发输入(通道1) HAL_TIM_IC_Start(htim, TIM_CHANNEL_1); } // 定时器输入捕获中断回调函数 void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == g_pwm_in.htim->Instance) { // 读取捕获值 uint32_t capture = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_2); if (__HAL_TIM_GET_FLAG(htim, TIM_FLAG_CC2OF)) { // 发生了捕获溢出,说明两次中断之间CPU太忙没来得及处理,数据可能不可靠 __HAL_TIM_CLEAR_FLAG(htim, TIM_FLAG_CC2OF); // 可以在这里设置一个错误标志 } // 判断是上升沿还是下降沿捕获?在PWM输入模式下,硬件自动设置。 // 通常,我们直接利用两次连续捕获值来计算周期和脉宽。 // 更稳健的做法是结合定时器溢出次数来计算长周期。 static uint32_t last_capture = 0; uint32_t diff = 0; if (last_capture != 0) { // 计算差值,注意计数器溢出 if (capture > last_capture) { diff = capture - last_capture; } else { diff = (htim->Instance->ARR + 1) - last_capture + capture; } // 判断是周期还是脉宽?在PWM输入模式下,硬件会将TI1的上升沿和下降沿分别路由到CH1和CH2。 // 但HAL库的回调不区分边沿。一个更清晰的方案是:在CH1和CH2的中断里分别处理。 // 这里提供一个简化的、在CH2中断中处理的计算思路(假设信号稳定): // 我们连续记录三次捕获值,差值1是脉宽,差值2是周期-脉宽。 static uint32_t cap_buf[3] = {0}; static uint8_t idx = 0; cap_buf[idx++] = capture; if (idx >= 3) idx = 0; // 当缓冲区有3个有效值时,进行计算 if (cap_buf[0] != 0 && cap_buf[1] != 0 && cap_buf[2] != 0) { uint32_t diff1 = (cap_buf[1] > cap_buf[0]) ? (cap_buf[1] - cap_buf[0]) : (htim->Instance->ARR + 1 - cap_buf[0] + cap_buf[1]); uint32_t diff2 = (cap_buf[2] > cap_buf[1]) ? (cap_buf[2] - cap_buf[1]) : (htim->Instance->ARR + 1 - cap_buf[1] + cap_buf[2]); // 通常,较小的差值是脉宽(高电平),较大的是低电平宽度 g_pwm_in.pulse = (diff1 < diff2) ? diff1 : diff2; g_pwm_in.period = diff1 + diff2; // 一个完整周期 // 转换为实际物理量 float timer_clk = 72000000.0f / (htim->Instance->PSC + 1); // 计算定时器计数频率 g_pwm_in.freq_hz = timer_clk / g_pwm_in.period; g_pwm_in.duty_cycle = (g_pwm_in.pulse * 100.0f) / g_pwm_in.period; g_pwm_in.is_updated = 1; // 设置更新标志 } } last_capture = capture; } } // 应用层获取数据的函数 uint8_t PWM_In_GetValues(float *freq, float *duty) { if (g_pwm_in.is_updated) { *freq = g_pwm_in.freq_hz; *duty = g_pwm_in.duty_cycle; g_pwm_in.is_updated = 0; // 清除标志 return 1; // 成功获取新数据 } return 0; // 数据未更新 }

实操心得:PWM输入模式虽然方便,但其中断回调逻辑容易混淆。上述代码中利用环形缓冲区计算周期和脉宽的方法比较稳健。更推荐的做法是直接使用寄存器级别操作,在TIMx_IRQHandler中根据捕获标志位(如TIM_FLAG_CC1、TIM_FLAG_CC2)来分别处理上升沿和下降沿,这样逻辑最清晰,效率也最高。HAL库的通用回调函数有时会掩盖这些细节。

5. 两路独立输入捕获的驱动实现

当需要测量两路独立信号时,我们采用单定时器双通道的方案(例如TIM3的CH1和CH2),演示如何测量频率。如果需要占空比,则需要每个信号占用两个捕获通道。

配置要点(以TIM3_CH1->PA6, TIM3_CH2->PA7为例)

  1. 配置PA6、PA7为复用功能,映射到TIM3_CH1和CH2。
  2. 配置TIM3,设置合适的PSC和ARR。
  3. 将两个通道都配置为输入捕获模式,边沿选择为“上升沿”。
  4. 开启两个通道的捕获中断以及定时器更新中断。

驱动层实现关键:我们需要两个独立的结构体实例,并在中断中根据通道号来区分和处理。

Dual_Input_Capture_t g_cap_ch1, g_cap_ch2; void Dual_Input_Capture_Init(TIM_HandleTypeDef *htim) { // 初始化结构体 g_cap_ch1.htim = htim; g_cap_ch1.IC_Channel_Rising = TIM_CHANNEL_1; g_cap_ch1.capture_stage = 0; g_cap_ch1.is_valid = 0; g_cap_ch2.htim = htim; g_cap_ch2.IC_Channel_Rising = TIM_CHANNEL_2; g_cap_ch2.capture_stage = 0; g_cap_ch2.is_valid = 0; // 启动两个通道的输入捕获中断 HAL_TIM_IC_Start_IT(htim, TIM_CHANNEL_1); HAL_TIM_IC_Start_IT(htim, TIM_CHANNEL_2); // 启动定时器 HAL_TIM_Base_Start(htim); } // 输入捕获中断回调 void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { // 判断是哪个通道的中断 if (__HAL_TIM_GET_FLAG(htim, TIM_FLAG_CC1) != RESET) { __HAL_TIM_CLEAR_FLAG(htim, TIM_FLAG_CC1); _process_capture(&g_cap_ch1, TIM_CHANNEL_1); } if (__HAL_TIM_GET_FLAG(htim, TIM_FLAG_CC2) != RESET) { __HAL_TIM_CLEAR_FLAG(htim, TIM_FLAG_CC2); _process_capture(&g_cap_ch2, TIM_CHANNEL_2); } } } // 定时器更新中断(溢出中断)回调,用于处理长周期测量 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { // 为每个通道的溢出计数加一(如果结构体里有这个字段的话) // g_cap_ch1.overflow_count++; // g_cap_ch2.overflow_count++; } } // 内部处理函数 static void _process_capture(Dual_Input_Capture_t *cap, uint32_t channel) { uint32_t current_capture = HAL_TIM_ReadCapturedValue(cap->htim, channel); switch (cap->capture_stage) { case 0: // 第一次捕获到上升沿 cap->last_capture_rise = current_capture; cap->capture_stage = 1; break; case 1: // 第二次捕获到上升沿,计算周期 if (current_capture > cap->last_capture_rise) { cap->period_ticks = current_capture - cap->last_capture_rise; } else { // 发生了计数器溢出,需要结合溢出次数计算,这里简化处理 cap->period_ticks = (cap->htim->Instance->ARR + 1) - cap->last_capture_rise + current_capture; } cap->last_capture_rise = current_capture; // 更新为新的上升沿 // 计算频率 float timer_clk = 72000000.0f / (cap->htim->Instance->PSC + 1); cap->freq_hz = timer_clk / cap->period_ticks; cap->is_valid = 1; // 标记数据有效 // 状态机可以保持在1,等待下一个上升沿继续计算周期 break; } } // 应用层获取通道1频率 uint8_t Dual_Input_GetFreq_CH1(float *freq) { if (g_cap_ch1.is_valid) { *freq = g_cap_ch1.freq_hz; g_cap_ch1.is_valid = 0; // 读取后清除标志 return 1; } return 0; } // 同理实现GetFreq_CH2

注意事项:上述两路捕获代码共享同一个定时器计数器。这意味着如果两路信号频率相差很大,高频信号的中断可能会频繁打断低频信号的处理,但测量值本身(捕获的CNT值)是硬件锁存的,所以频率测量值仍然是准确的。主要影响是CPU中断负荷。如果两路信号频率都很高,需要考虑使用DMA进行捕获,或者使用两个定时器来彻底隔离。

6. 常见问题、调试技巧与避坑指南

在实际编写和调试过程中,你会遇到各种各样的问题。下面是我总结的“血泪”清单:

问题1:测量值跳动非常大,完全不准。

  • 可能原因A:定时器时钟和分频设置错误。这是最常见的问题。务必确认HCLKAPB1/APB2的时钟频率,以及定时器所在的总线。STM32中,如果APBx预分频系数不为1,挂载在其上的定时器时钟会倍频。最稳妥的方法是在代码中打印出SystemCoreClock以及htim->Instance->PSC的值,手动计算计数频率。
  • 可能原因B:ARR值设置过小。如果输入信号的周期大于定时器计数器溢出一次的时间,就会发生溢出。你需要确保ARR设置得足够大,使得(ARR+1) / 计数器频率 > 待测信号最大周期。或者,你必须开启定时器更新中断,在中断中记录溢出次数,在计算时结合溢出次数和捕获值。
  • 可能原因C:GPIO引脚复用错误。检查原理图,确认你使用的引脚确实支持该定时器的该通道。使用CubeMX检查引脚配置冲突。

问题2:只能捕获到第一次边沿,之后再也进不了中断。

  • 可能原因:中断标志未清除。在中断服务函数中,读取捕获值后,必须清除对应的捕获/比较中断标志位。使用HAL库时,HAL_TIM_IRQHandler会帮你清除,但如果你像上面示例那样直接操作标志位,就必须手动清除TIM_FLAG_CCx。忘记清标志是导致中断只触发一次的经典错误。

问题3:两路捕获互相干扰,一路有信号另一路就没数据。

  • 可能原因:中断优先级冲突或处理时间过长。确保两路捕获中断的优先级设置正确(默认都是0)。如果在一路捕获的中断处理函数中进行了非常耗时的操作(如浮点计算、LCD刷新),可能会导致另一路中断被延迟响应甚至丢失。解决方案是:中断里只做最必要的事(记录捕获值、设置标志),将复杂的计算(如换算为频率、占空比)放到主循环或低优先级任务中。

问题4:测量低频信号(如1Hz以下)误差大。

  • 原因与解决:低频信号周期长,ARR必须设置得非常大,这会降低计数分辨率。更严重的是,计数器可能会溢出多次。必须开启定时器更新中断,并维护一个“溢出计数器”。计算周期时:周期 = 溢出次数 * (ARR+1) + 本次捕获值 - 上次捕获值。这是测量低频信号的标配操作。

问题5:使用HAL库的HAL_TIM_IC_CaptureCallback,无法区分是哪一路通道的中断。

  • 解决:这正是HAL库封装带来的一个小麻烦。推荐的方法是不完全依赖这个通用回调。你可以重写定时器的全局中断服务函数TIMx_IRQHandler,在里面先调用HAL_TIM_IRQHandler(&htimx),然后立即检查具体的中断标志位进行后续处理。或者,像本文示例一样,在HAL_TIM_IC_CaptureCallback中再通过__HAL_TIM_GET_FLAG来主动判断是哪个通道触发的。

调试技巧:

  1. 利用示波器:这是最直接的调试工具。用示波器观察输入引脚的波形,确认信号是否真的到达单片机,其电压、频率、占空比是否在预期范围内。
  2. 软件仿真:在Keil或STM32CubeIDE的调试模式下,使用逻辑分析仪功能(Signal Trace),可以模拟输入PWM信号,并观察捕获寄存器的变化,非常适合验证底层驱动逻辑。
  3. 打印调试信息:通过串口将关键的中间变量打印出来,比如捕获的原始计数器值、计算出的周期ticks等。这能帮你定位问题是发生在硬件捕获阶段,还是软件计算阶段。

7. 进阶优化与资源管理策略

当赛题功能越来越复杂,系统资源(定时器、中断)变得紧张时,我们需要更精细的策略。

策略一:DMA+定时器捕获对于需要极高频率、连续不断采集脉冲的应用(如高速编码器),可以使用DMA将捕获寄存器的值自动搬运到内存数组中。CPU只需要在数组半满或全满时处理一批数据,大大降低了中断频率。这在蓝桥杯高级应用中可能会遇到。

策略二:输入捕获与输出PWM共用定时器一个定时器有多个通道,可以同时用于输入捕获和输出PWM。例如,用TIM2的CH1做PWM输出控制电机,用CH2做输入捕获测量另一路信号。关键点在于:PWM输出和输入捕获的时钟源、预分频、计数模式必须一致。通常配置为向上计数,PWM输出模式1/2。这样做可以节省一个定时器资源。

策略三:低功耗下的捕获考虑如果设备有低功耗要求,在测量间歇性信号时,可以让定时器在无信号时停止,由外部中断唤醒并启动定时器进行捕获。这需要对定时器的从模式、触发源有更深的理解。

资源分配建议(针对CT117E板)

  • TIM2/TIM3/TIM4:通用定时器,功能齐全(PWM、输入捕获、编码器接口等),是主力。
  • TIM1/TIM8:高级定时器,功能更强(带死区控制等),适合复杂的电机控制,但资源更宝贵。
  • 基本定时器TIM6/TIM7:只能用于基本定时,不能做输入捕获。 在规划时,优先把需要输入捕获的功能分配到通用定时器上,并提前规划好各通道的用途,避免冲突。

最后,驱动代码的稳定性和效率,最终要靠大量的测试来验证。建议你建立一个简单的测试用例:用另一个定时器产生已知频率和占空比的PWM信号,输入到你的捕获引脚,然后验证测量结果是否准确。通过反复的“产生-测量-比对”循环,你能最快地定位和解决驱动中的问题。把这些模块调试稳定后,封装成独立的.c/.h文件,它们就会成为你应对蓝桥杯嵌入式赛题的可靠武器库。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询