
1. 项目缘起从“知道”到“测准”的鸿沟搞嵌入式开发尤其是玩STM32的谁没调过PWM、测过频率呢看教程、读数据手册都知道输入捕获Input Capture这个功能——不就是用定时器抓取外部信号的边沿然后记录下当时的计数器值嘛原理听起来简单直接。但真到了自己动手想把一个未知频率的方波信号或者一个舵机的PWM控制信号给精准地测量出来时坑就一个接一个地来了。你可能遇到过这种情况代码照着例程写了中断也开了但读出来的周期值飘忽不定时而正常时而离谱或者想测量高频率信号结果数值溢出得一塌糊涂又或者想同时捕获上升沿和下降沿来计算占空比程序结构一下子就变得复杂难调。这些正是“知道”和“会做”、“做稳”之间的差距。输入捕获功能本身是单片机定时器模块里一个非常经典且实用的功能它不仅仅是记录一个时间点更是我们感知外部数字世界“时间尺度”的桥梁无论是电机测速、超声波测距、红外解码还是通信波特率自适应底层都离不开它。网上很多教程只给了个基本框架对于实际工程中必须面对的溢出处理、噪声滤波、高精度测量方案以及代码的健壮性设计往往一笔带过。这次我就结合自己多次在电机控制、传感器调试中踩过的坑把STM32输入捕获从原理到稳定可用的实操代码掰开揉碎了讲清楚。我们不追求花哨就追求一个“稳”字让你看完就能用用了就不容易出错。2. 核心原理深潜定时器如何“抓住”那一瞬间在开始写代码之前我们必须把STM32定时器在输入捕获模式下的工作机制吃透这能帮你从根本上理解后续所有配置和代码逻辑的由来出了问题也知道该往哪里查。2.1 定时器的基础时钟链路一切始于时钟。STM32的通用定时器如TIM2, TIM3, TIM4等的计数器CNT其每一次递增的“心跳”来源于哪里它来自APBx总线时钟经过一个可编程的预分频器PSC。这里有个关键点当APBx的预分频系数不为1时例如系统时钟72MHzAPB1时钟36MHz定时器会有一个x2的倍频器。所以我们常说的“定时器时钟”可能比它所挂载的APB总线时钟快一倍。计算定时器实际计数频率的公式是Timer_CLK APBx_CLK * (APBx_Prescaler 1 ? 1 : 2) / (PSC 1)假设我们系统主频72MHzAPB1总线时钟36MHz即APB1预分频系数为2TIM2挂在APB1上。那么TIM2的时钟源频率 36MHz * 2 72MHz。如果我们设置PSC71那么计数器CNT的计数频率就是 72MHz / (711) 1MHz。这意味着CNT每1微秒增加1。这个1MHz就是我们时间测量的“尺子”的最小刻度。2.2 捕获通道的“触发-锁存”机制每个定时器有多个独立的捕获/比较通道CH1-CH4。当配置为输入捕获模式时我们需要将一个GPIO引脚映射到该通道上。这个GPIO引脚上的信号跳变可配置为上升沿、下降沿或双边沿会通过一个数字滤波器防止毛刺误触发后产生一个触发信号。这个触发信号是输入捕获功能的灵魂。它同时做两件至关重要的事情锁存计数器值在触发信号到来的那个精确时刻硬件会自动将当前计数器CNT的值拷贝到该通道对应的捕获/比较寄存器CCRx中。这是一个原子操作不受任何中断延迟的影响保证了时间戳的绝对精确。产生中断/DMA请求如果使能了捕获中断硬件会置位相应的中断标志位如CC1IF如果中断已开启CPU就会跳转到中断服务程序。这里必须理解“影子寄存器”的概念。我们代码中读写的CCRx实际上是一个用户可访问的“预装载寄存器”。在捕获事件发生时硬件是将CNT的值锁存到了该通道的“捕获影子寄存器”中然后这个值才会在下次更新事件时传递到我们可读的CCRx寄存器。但在输入捕获模式下这个传递通常是立即或很快的我们可以近似认为读CCRx就是读到了捕获值。2.3 测量周期与占空比的策略单次捕获测周期这是最基本的方法。将通道配置为上升沿捕获。第一次捕获到上升沿记录下CCRx1并清除标志位等待下一个上升沿到来再次记录CCRx2。那么信号的周期T (CCRx2 - CCRx1) * 计数周期。这里隐含一个风险如果CCRx2 CCRx1说明在这两个上升沿之间CNT计数器发生了溢出归零。因此必须开启定时器的更新中断溢出中断并维护一个溢出次数计数器在计算时加上溢出部分的时长。双边沿捕获一次测周期和占空比这是更高效的方法。将一个通道配置为上升沿和下降沿都捕获。通常的流程是先等待一个上升沿捕获值CCR_rise紧接着到来的必然是下降沿捕获值CCR_fall再下一个是上升沿捕获值CCR_rise2。高电平时间脉宽 (CCR_fall - CCR_rise) * 计数周期周期 (CCR_rise2 - CCR_rise) * 计数周期占空比 高电平时间 / 周期 这种方法只需要两次捕获上升、下降就能计算占空比三次捕获计算周期效率高且能避免两次单次捕获间信号频率变化带来的误差。但编程逻辑稍复杂需要用一个状态机来记录当前是等待上升沿还是下降沿。3. 硬件设计与软件配置全解析理论清楚了我们开始动手。以STM32F103C8T6的TIM3_CH1对应PA6引脚为例测量一个外部方波信号的频率和占空比。3.1 硬件连接与GPIO配置首先确认你的信号源。可以是另一个定时器产生的PWM函数发生器或者是传感器如红外接收头的输出。将信号线连接到PA6。在软件中配置PA6为复用功能上拉输入模式。为什么是“复用功能”而不是普通的输入因为该引脚需要连接到定时器内部的捕获模块而不是直接通向GPIO输入数据寄存器。上拉电阻可以帮助在信号悬空时保持确定电平避免误触发。// GPIO初始化 GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; // 上拉输入 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // 将PA6引脚复用到TIM3_CH1 GPIO_PinRemapConfig(GPIO_PartialRemap_TIM3, ENABLE); // 注意TIM3_CH1默认是PA6有些重映射会改变这里根据实际芯片手册来3.2 定时器与时基单元配置这是核心配置决定了测量的精度和量程。TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); // 假设系统时钟72MHzAPB1时钟36MHzTIM3时钟为72MHz // 预分频器PSC设置为72-1计数器时钟为 72MHz / 72 1MHz (1us计数一次) TIM_TimeBaseStructure.TIM_Period 65535; // ARR自动重装载值16位定时器最大值 TIM_TimeBaseStructure.TIM_Prescaler 72 - 1; // PSC预分频器 TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; // 时钟分频与数字滤波器相关通常DIV1 TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; // 向上计数模式 TIM_TimeBaseInit(TIM3, TIM_TimeBaseStructure);关键参数解析TIM_Prescaler (PSC) 它和定时器实际输入时钟共同决定了CNT的计数频率即我们时间测量的“尺子”的精度。1MHz的计数频率对应1微秒的分辨率对于测量几十Hz到几十KHz的信号足够用。如果想测更高频率可以提高计数频率减小PSC但会缩短最大可测量周期因为CNT溢出更快。TIM_Period (ARR) 这是计数器的上限CNT计到ARR值后在下一个时钟就会溢出归零并产生更新事件。设置成6553516位定时器满量程是为了获得最大的计数范围减少溢出次数。但请注意在输入捕获模式下ARR的值通常不影响捕获本身它只定义溢出周期。捕获寄存器CCRx是独立于ARR的可以捕获到0-65535之间的任何值即使ARR设置得更小。TIM_ClockDivision 这个参数不是用于分频定时器时钟而是用于控制数字滤波器的采样时钟频率。TIM_CKD_DIV1表示滤波器的采样时钟和定时器输入时钟一致。在信号有噪声时可以增大这个分频配合滤波器长度可以有效滤除毛刺但会略微增加捕获延迟。3.3 输入捕获通道配置配置TIM3的通道1工作于输入捕获模式。TIM_ICInitTypeDef TIM_ICInitStructure; TIM_ICInitStructure.TIM_Channel TIM_Channel_1; TIM_ICInitStructure.TIM_ICPolarity TIM_ICPolarity_BothEdge; // 双边沿捕获 TIM_ICInitStructure.TIM_ICSelection TIM_ICSelection_DirectTI; // 直接映射到TI1输入 TIM_ICInitStructure.TIM_ICPrescaler TIM_ICPSC_DIV1; // 输入捕获预分频每次边沿都捕获 TIM_ICInitStructure.TIM_ICFilter 0x0; // 滤波器值0为不滤波 TIM_ICInit(TIM3, TIM_ICInitStructure);关键参数解析TIM_ICPolarity 这是关键我们选择TIM_ICPolarity_BothEdge意味着上升沿和下降沿都会触发捕获。这是实现单通道测占空比的基础。如果只想测频率可以只选TIM_ICPolarity_Rising。TIM_ICSelection 指定输入源。TIM_ICSelection_DirectTI表示通道1的信号直接来自TI1引脚即PA6。还有交叉模式例如可以将TI2引脚信号映射到通道1用于一些特殊测量。TIM_ICPrescaler 捕获预分频。DIV1表示每个有效边沿都捕获。可以设置为DIV2、DIV4、DIV8表示每2、4、8个边沿捕获一次可用于测量频率很高的信号避免中断过于频繁。TIM_ICFilter 数字滤波器长度。值从0到15代表需要连续多少个采样点一致才认为边沿有效。对于有噪声的信号设置为4或8可以很好地稳定捕获但会引入几个时钟周期的延迟。对于干净的数字信号设为0即可。3.4 中断配置与开启我们需要开启两种中断捕获中断处理边沿事件和更新中断处理计数器溢出。// 使能捕获/比较1中断和更新中断 TIM_ITConfig(TIM3, TIM_IT_CC1 | TIM_IT_Update, ENABLE); // 配置NVIC嵌套向量中断控制器 NVIC_InitTypeDef NVIC_InitStructure; NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); // 设置优先级分组 NVIC_InitStructure.NVIC_IRQChannel TIM3_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; // 抢占优先级 NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; // 子优先级 NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); // 最后启动定时器计数器 TIM_Cmd(TIM3, ENABLE);注意中断优先级的设置需要根据你系统中其他中断的重要性来统筹安排。输入捕获对实时性要求高应给予较高的优先级较低的抢占优先级数值。4. 中断服务程序状态机与溢出处理的艺术这是整个输入捕获程序最核心、也最容易出错的逻辑部分。我们将实现一个状态机来优雅地处理双边沿捕获和计数器溢出。首先定义一些全局变量来保存状态和数据volatile uint8_t g_capture_state 0; // 状态机0-等待上升沿1-已捕获上升沿等待下降沿2-已捕获下降沿等待上升沿(计算周期) volatile uint32_t g_capture_rise_val 0; // 上升沿捕获值 volatile uint32_t g_capture_fall_val 0; // 下降沿捕获值 volatile uint32_t g_last_capture_val 0; // 上一次捕获值用于周期计算 volatile uint32_t g_timer_overflow_count 0; // 定时器溢出次数计数器 volatile uint32_t g_signal_period 0; // 计算出的信号周期单位us volatile uint32_t g_signal_pulse_width 0; // 计算出的高电平脉宽单位us volatile uint8_t g_calc_ready_flag 0; // 数据就绪标志然后编写TIM3的中断服务函数void TIM3_IRQHandler(void) { // 1. 处理更新中断溢出 if (TIM_GetITStatus(TIM3, TIM_IT_Update) ! RESET) { g_timer_overflow_count; // 溢出次数加1 TIM_ClearITPendingBit(TIM3, TIM_IT_Update); // 清除更新中断标志 } // 2. 处理捕获/比较1中断 if (TIM_GetITStatus(TIM3, TIM_IT_CC1) ! RESET) { uint16_t current_capture TIM_GetCapture1(TIM3); // 读取当前的捕获值 switch (g_capture_state) { case 0: // 初始状态等待第一个上升沿 // 通常我们通过极性配置来确保第一次触发的是上升沿或者不关心第一次是上升还是下降 // 这里我们假设第一次捕获到的是上升沿通过配置和信号保证 g_capture_rise_val current_capture; g_last_capture_val current_capture; g_capture_state 1; // 下一个边沿应该是下降沿 break; case 1: // 已捕获上升沿现在捕获到的是下降沿 g_capture_fall_val current_capture; // 计算高电平脉宽需要考虑溢出 g_signal_pulse_width Calc_PulseWidth(g_last_capture_val, current_capture, g_timer_overflow_count); g_last_capture_val current_capture; g_timer_overflow_count 0; // 为下一个测量周期重置溢出计数器 g_capture_state 2; // 下一个边沿应该是上升沿用于计算周期 break; case 2: // 已捕获下降沿现在捕获到的是上升沿一个新的周期开始 g_capture_rise_val current_capture; // 计算整个信号周期从上一个上升沿到这一个上升沿 g_signal_period Calc_PulseWidth(g_last_capture_val, current_capture, g_timer_overflow_count); g_last_capture_val current_capture; g_timer_overflow_count 0; // 重置溢出计数器 g_calc_ready_flag 1; // 设置数据就绪标志主循环可以读取g_signal_period和g_signal_pulse_width了 g_capture_state 1; // 状态回到1等待下一个下降沿用于计算下一个脉宽 break; default: g_capture_state 0; break; } TIM_ClearITPendingBit(TIM3, TIM_IT_CC1); // 清除捕获中断标志 } }上面的中断服务程序依赖于一个关键的函数Calc_PulseWidth它负责正确处理计数器溢出计算两个捕获值之间的真实时间差。/** * brief 计算两个捕获值之间的时间差考虑计数器溢出。 * param last_val: 上一次捕获的计数器值 * param curr_val: 当前捕获的计数器值 * param overflow_cnt: 在这两次捕获之间发生的溢出次数 * retval 时间差单位与定时器计数周期相同本例中为微秒 */ static uint32_t Calc_PulseWidth(uint16_t last_val, uint16_t curr_val, uint32_t overflow_cnt) { uint32_t pulse_width; // 计算基础差值 if (curr_val last_val) { // 没有发生溢出或者溢出发生在差值之外对计算无影响 pulse_width (uint32_t)(curr_val - last_val); } else { // 在两次捕获之间CNT从last_val计数到ARR65535然后溢出归零再到curr_val。 // 所以时间 (ARR - last_val 1) curr_val pulse_width (uint32_t)(65535 - last_val 1 curr_val); // 注意此时overflow_cnt至少为1但我们在下面的公式中统一处理overflow_cnt // 实际上因为curr_val last_val已经隐含了一次溢出所以这里计算出的pulse_width // 已经包含了这一次溢出。为了逻辑统一我们让overflow_cnt表示“额外的”溢出次数。 // 更清晰的写法是 // pulse_width (uint32_t)(65535 - last_val 1 curr_val); // overflow_cnt - 1; // 减去已经计入的这一次溢出 } // 加上溢出部分的时间。每次溢出代表计数器走了65536个计数。 pulse_width overflow_cnt * 65536UL; // 将计数值转换为时间微秒。因为我们配置的计数频率是1MHz1个计数1us。 // 如果你的分频不同这里需要乘以实际的计数周期。 // 例如如果计数频率是10MHz0.1us一个计数则需要return pulse_width * 0.1; return pulse_width; // 单位微秒 }重要心得Calc_PulseWidth函数是输入捕获稳定性的基石。很多初学者测不准问题就出在溢出处理上。上述代码中if (curr_val last_val)的分支处理是经典做法。务必理解curr_val last_val时pulse_width (ARR - last_val 1 curr_val)这个公式。它计算了从last_val到溢出点再从0到curr_val的总计数。而overflow_cnt记录的是在这整个过程中计数器完整的溢出轮数。在中断里我们每次捕获后重置overflow_cnt就是为了让这个计数器只记录当前测量间隔内的溢出。5. 主程序逻辑与测量结果处理中断服务程序完成了繁重的实时捕获和计算工作主程序则相对轻松主要负责初始化和轮询读取结果。int main(void) { // 系统时钟、GPIO、定时器、中断初始化调用前面章节的函数 SystemInit(); GPIO_Configuration(); TIM3_Configuration(); NVIC_Configuration(); // 初始化状态变量 g_capture_state 0; g_timer_overflow_count 0; g_calc_ready_flag 0; while (1) { if (g_calc_ready_flag) { g_calc_ready_flag 0; // 清除标志 uint32_t period_us g_signal_period; // 周期单位微秒 uint32_t pulse_width_us g_signal_pulse_width; // 高电平脉宽单位微秒 float frequency_hz 0.0f; float duty_cycle 0.0f; if (period_us 0) { frequency_hz 1000000.0f / (float)period_us; // 计算频率 Hz 1e6 / period_us duty_cycle (float)pulse_width_us / (float)period_us * 100.0f; // 计算占空比百分比 } // 现在你可以使用这些数据了通过串口打印、屏幕显示、或者参与控制算法 // printf(Freq: %.2f Hz, Duty: %.1f%%\n, frequency_hz, duty_cycle); // 注意在中断和主循环中共享的全局变量如果主循环读取时间较长 // 可以考虑关中断读取或使用临时变量拷贝防止数据被中断更新一半。 // 本例中因为是一次性读取几个32位变量在32位ARM平台上通常是原子的但为了严谨可以 // __disable_irq(); // period_us g_signal_period; // pulse_width_us g_signal_pulse_width; // __enable_irq(); } // 这里可以添加其他任务如LED闪烁、按键扫描等 Delay_ms(10); } }6. 高频信号测量与精度提升技巧上述方案对于中低频信号比如几Hz到几百KHz测量是稳定可靠的。但当信号频率接近或超过定时器计数频率时就会遇到问题。问题1捕获值几乎不变。如果信号频率是500KHz周期2us。我们的计数器频率是1MHz每个计数1us。那么一个周期内CNT只增加2。两次捕获值的差可能就是1或2任何微小的计数器抖动或噪声都会导致巨大的相对误差。解决方案提高定时器计数频率。将PSC改小例如从71改为0这样定时器时钟直接是72MHz计数频率72MHz分辨率约13.9纳秒。测量2us的周期计数器差值约为144相对误差就小了很多。但代价是计数器溢出更快65535 / 72MHz ≈ 0.91ms对于低频信号溢出中断会非常频繁增加CPU负担。问题2如何测量更高频率比如10MHz的方波周期100ns。即使72MHz的计数频率一个周期内计数器也只增加7.2量化误差太大。解决方案1使用输入捕获预分频TIM_ICPSC。之前我们设置为DIV1每个边沿都捕获。可以设置为DIV2每2个边沿捕获一次。这样我们实际测量的是2个周期的时间然后再除以2得到周期。这相当于以时间换精度。可以设置为DIV8甚至更高来测量更高频率但会降低更新率。解决方案2使用定时器的“脉冲计数”或“外部时钟模式”。这不是标准的输入捕获。可以将外部信号作为定时器的时钟源配置从模式让CNT对外部信号进行计数。在固定时间窗口内比如用另一个定时器产生1秒的闸门时间读取CNT的值这个值就是信号的脉冲数即频率。这是测频法适合高频低频时精度差。解决方案3使用高分辨率定时器HRTIM或更高主频的芯片。对于STM32部分系列有HRTIM计数频率可达纳秒级。或者选用主频更高的STM32系列。通用精度提升建议多次平均在主循环中连续读取多个周期值然后求平均可以平滑随机误差。优化中断中断服务函数尽可能短小只做必要的读写和状态切换复杂的计算如浮点运算放到主循环。避免在中断中调用printf等耗时函数。信号调理在信号进入单片机引脚前确保其干净。对于长线传输可以考虑使用施密特触发器整形或加入RC低通滤波滤除毛刺但要考虑对边沿速度的影响。时钟源选择使用高精度外部晶振作为系统时钟源能从根本上提高定时器的计时精度。7. 避坑指南与调试心得踩过坑才能记得牢。下面是我在实际项目中总结的几个典型问题和解决方法。坑1测量值偶尔出现巨大跳变现象大部分时间测量正常但偶尔会读出比实际值大很多倍或小很多倍的数。排查首先检查溢出处理这是最常见的原因。确认g_timer_overflow_count是否在每次有效的周期计算后被正确清零在状态2中。确认Calc_PulseWidth函数中对于curr_val last_val情况下的计算是否正确是否正确处理了overflow_cnt。检查中断标志清除是否在中断函数末尾清除了对应的中断标志TIM_IT_CCx和TIM_IT_Update如果忘记清除会导致中断连续触发打乱状态机。检查状态机变量确保g_capture_state等全局状态变量被定义为volatile防止编译器优化导致意外。检查状态机的逻辑是否覆盖了所有可能的边沿顺序特别是上电后第一个边沿不确定的情况。解决在中断服务程序开头可以加一句if (g_capture_state 2) g_capture_state 0;作为状态机的容错复位。使用调试器观察在出现跳变时last_val,curr_val,overflow_cnt这三个关键值是什么反向推导计算过程。坑2无信号输入时程序卡死或频率显示异常高现象当输入引脚悬空或信号断开时程序可能不响应或者显示一个非常高的频率。原因引脚悬空电平不确定可能因噪声产生大量虚假的边沿触发导致中断风暴CPU被完全占用。解决硬件上拉如我们之前配置的GPIO_Mode_IPU在悬空时引脚被拉到高电平。软件滤波增大TIM_ICFilter的值例如设为8或15要求连续多个采样点一致才确认边沿能有效抑制噪声。超时机制在主循环中增加一个“信号丢失检测”。记录最后一次成功捕获的时间戳可以用SysTick。如果超过一定时间比如100msg_calc_ready_flag都没有被置位则认为信号丢失将频率和占空比显示为0或特殊值并复位状态机g_capture_state 0。坑3想用两个通道分别捕获上升沿和下降沿以简化编程想法通道1设为上升沿捕获通道2设为下降沿捕获这样每个通道的中断里只需要处理一种边沿逻辑简单。现实需要确保两个通道的输入是同一个TI信号通过TIM_ICSelection配置并且两个通道的捕获是同步的。但即便如此由于中断响应延迟的微小差异两个通道捕获的时间戳可能存在几个时钟周期的偏差对于非常精确的占空比测量特别是窄脉冲可能引入误差。而使用单通道双边沿捕获时间戳来自同一个硬件通道绝对同步精度更高。所以除非有特殊需求更推荐单通道双边沿方案。调试技巧利用翻转引脚在中断服务函数的入口和出口用一条GPIO_WriteBit(GPIOx, GPIO_Pin_x, (BitAction)(1-GPIO_ReadOutputDataBit(GPIOx)n 1));语句来翻转一个测试引脚然后用示波器观察这个引脚的电平。你可以直观地看到中断的触发频率和持续时间判断是否发生了中断风暴。串口打印关键变量在状态切换或计算完成时通过串口将g_capture_state,last_val,curr_val,overflow_cnt等变量打印出来。这是分析逻辑错误最直接的方法。注意打印函数本身很耗时可能会影响中断时序所以仅用于调试正式发布时要移除。使用逻辑分析仪这是最强大的工具。可以同时抓取输入信号、测试引脚以及通过软件模拟输出的“事件标记”信号将软件状态和硬件时序完美对应起来复杂问题一目了然。