STM32输入捕获测量市电频率:从原理到实战避坑指南 1. 项目概述用STM32捕获市电频率的“坑”与“道”最近在做一个需要精确监测市电频率的项目核心思路很直接把220V/50Hz的交流电通过硬件电路整形成干净的方波然后接到STM32的一个IO口上利用定时器的输入捕获功能来测量方波的周期从而反推频率。我选择了STM32F1系列用了它的高级定时器TIM8的通道1对应PC6引脚来做这件事。理论上72MHz的系统时钟去测量20ms的周期精度应该绰绰有余。但实际调试时我掉进了一个大坑中断里读到的捕获值要么是一个固定的非零数后面全是零要么就是完全抓不到边沿。这个问题折腾了我整整两天几乎崩溃。今天就把这个从“崩溃”到“通透”的全过程包括原理、配置细节、代码里的“雷”以及最终稳定的方案毫无保留地分享出来。如果你也在用STM32做高精度频率测量、转速测量或者任何脉冲信号的时间计量这篇内容或许能帮你省下不少弯路。2. 核心思路与方案选型背后的考量2.1 为什么选择输入捕获测量一个周期性信号的频率最经典的方法就是测周期。输入捕获功能是定时器外设的“标配”它能在输入引脚发生指定边沿比如上升沿时瞬间锁存当前定时器计数器的值到一个专用的捕获/比较寄存器CCRx中。通过连续捕获两个上升沿对应的计数器值它们的差值乘以计数器的时钟周期就是信号的周期。对于50Hz的市电周期是20ms这个方法直观且精度高。注意输入捕获适合测量中低频信号的周期或脉宽。对于高频信号周期接近或小于一次捕获中断处理时间则需要考虑使用定时器的PWM输入模式或编码器接口等更高效的方法。2.2 为什么是TIM8而不是TIM2我手头的芯片是STM32F103系列它拥有多个通用定时器TIM2-TIM5和高级定时器TIM1, TIM8。选择TIM8的通道1PC6主要有几个考虑引脚分配我的硬件电路已经将整形后的方波引到了PC6引脚而PC6恰好是TIM8通道1的默认复用功能之一无需重映射硬件连接最简洁。功能冗余高级定时器TIM1/TIM8拥有通用定时器的全部功能并且增加了互补输出、刹车等高级特性。虽然本项目用不到这些高级功能但用它也完全没问题。有时候通用定时器可能被其他功能如PWM驱动电机占用了高级定时器就成了一个很好的备选。滤波与分辨率所有定时器的输入捕获单元都配有数字滤波器这对于从市电转换来的、可能带有毛刺的方波信号非常有用可以有效防止误触发。2.3 时钟树与定时器预分频的算计这是精度的基础也是最容易算错的地方。我的主频是72MHz这是通过外部8MHz晶振经过PLL倍频得到的。TIM8挂载在APB2总线上如果APB2的预分频系数不为1定时器的时钟还会被倍频。在我的系统里APB2预分频是1所以TIM8的时钟CK_INT直接就是72MHz。接下来是定时器自身的预分频器PSC。我最初的配置是TIM_PrescalerConfig(TIM8, 71, TIM_PSCReloadMode_Immediate)。这里71是写入预分频寄存器TIMx_PSC的值。定时器实际的分频系数是PSC 1。所以71对应的分频系数是72。因此定时器计数器CNT的计数时钟频率是72MHz / 72 1MHz。这意味着计数器每1微秒1us计数一次。对于20ms20000us的周期计数器需要计数的个数理论上是 20000us / 1us 20000。这是一个16位定时器计数范围0-65535完全可以轻松容纳的值看起来非常合理。但问题就出在我只关注了周期测量却忽略了另一个关键模式从模式的配置。3. 配置深水区从模式与触发源的致命误解我提供的初始代码中最大的问题集中在TIM_SelectInputTrigger、TIM_SelectSlaveMode和TIM_SelectMasterSlaveMode这几个函数调用上。这涉及STM32定时器一个强大但容易混淆的功能从模式管理。3.1 我最初错误的配置逻辑我当时是这么想的也是很多新手容易产生的误解我想用输入捕获来测周期。为了“更准确”或者“同步”我启用了定时器的从模式并设置为“复位模式”Reset Mode。我把触发源TRGI选择为TIM_TS_TI2FP2意思是“经过滤波和边沿检测后的TI2输入”。这里就是第一个致命错误我的信号接在通道1TI1但我却选择通道2TI2作为触发源。这相当于告诉定时器“请用另一个我没接信号的引脚上的事件来复位计数器。” 计数器当然不会被正确复位。由于触发源错误计数器永远不会被复位。它从0开始以1MHz的速度累加计到65535后溢出归零如此循环。当我的PC6出现一个上升沿时输入捕获单元会动作把当前的CNT值比如687锁存到CCR1寄存器。由于计数器一直在自由运行下一个上升沿到来时CNT值可能已经溢出过好几次了。而我的中断服务程序ISR里只是简单地读取了CCR1并没有计算两次捕获的差值。更糟糕的是由于从模式配置混乱整个捕获机制可能处于一种未定义的状态。这就是为什么我读到的IC1[0]是一个看似随机的固定值687而后续全是0。687可能是在代码启动后、第一个上升沿到来时自由运行计数器的瞬时值。后续为0则可能是因为从模式配置冲突导致捕获未能持续触发中断或者中断逻辑有问题。3.2 输入捕获与从模式的关系澄清对于简单的周期测量根本不需要配置复杂的从模式从模式通常用于一些特定场景PWM输入模式自动测量周期和占空比此时需要用到复位从模式但触发源应选择正确的TIx输入。外部时钟模式让定时器用另一个引脚的外部信号作为时钟源。门控模式用另一个信号来控制定时器的启停。对于最基本的“测量两个上升沿之间的时间”我们只需要配置定时器基础时钟预分频PSC。配置输入捕获通道选择边沿、滤波器、分频器。开启该通道的捕获中断。在中断里读取CCRx值并与上一次的值相减得到差值。这个差值就是这段时间内计数器的计数个数。用差值 * (1 / 计数器时钟频率)得到时间进而算出频率。从模式在这里是多余的而且配置不当会直接导致功能失效。4. 重构稳定可靠的输入捕获实现方案下面我彻底重构代码去掉错误的从模式配置实现一个稳定测量市电周期的程序。4.1 硬件与时钟初始化这部分是基础必须正确。// 假设使用STM32标准外设库 void GPIO_TIM8_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC | RCC_APB2Periph_TIM8, ENABLE); // PC6 作为 TIM8 通道1的输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; // 浮空输入前提是外部信号已有上拉/下拉。更稳妥可用 GPIO_Mode_IPU上拉输入 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; // 输入模式下此参数对数字输入影响不大 GPIO_Init(GPIOC, GPIO_InitStructure); // 这里不需要重映射PC6是TIM8_CH1的默认功能引脚之一部分型号请查数据手册 }4.2 定时器与输入捕获通道配置这是核心配置我们只做必要设置。// 定义全局变量用于计算 volatile uint32_t g_capture_period 0; // 计算得到的周期计数次数 volatile uint32_t g_last_capture 0; // 上一次捕获值 volatile uint8_t g_is_first_capture 1; // 是否是第一次捕获的标志 void TIM8_CH1_Capture_Init(uint16_t arr, uint16_t psc) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_ICInitTypeDef TIM_ICInitStructure; NVIC_InitTypeDef NVIC_InitStructure; // 1. 时基单元初始化 TIM_TimeBaseStructure.TIM_Period arr; // 自动重装载值决定计数器溢出周期 TIM_TimeBaseStructure.TIM_Prescaler psc; // 预分频系数决定计数时钟 TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; // 时钟分频与数字滤波器相关通常设为DIV1 TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; // 向上计数模式 TIM_TimeBaseInit(TIM8, TIM_TimeBaseStructure); // 2. 输入捕获通道初始化 TIM_ICInitStructure.TIM_Channel TIM_Channel_1; TIM_ICInitStructure.TIM_ICPolarity TIM_ICPolarity_Rising; // 捕获上升沿 TIM_ICInitStructure.TIM_ICSelection TIM_ICSelection_DirectTI; // 直接映射到TI1输入 TIM_ICInitStructure.TIM_ICPrescaler TIM_ICPSC_DIV1; // 每个有效边沿都捕获 TIM_ICInitStructure.TIM_ICFilter 0x04; // 设置滤波器值越大滤波时间越长抗干扰能力越强但会牺牲边沿分辨率 // 对于50Hz方波0x04或0x08是比较合适的选择能滤除大部分毛刺。 TIM_ICInit(TIM8, TIM_ICInitStructure); // 3. 清除从模式控制器配置确保定时器处于独立运行模式。 // 标准库中不调用TIM_SelectSlaveMode等函数默认就是从模式禁用。 // 但为了绝对清晰可以显式禁用 TIM_SelectSlaveMode(TIM8, TIM_SlaveMode_Disable); // 或者更彻底地操作寄存器TIM8-SMCR 0; // 4. 使能捕获/比较中断 TIM_ITConfig(TIM8, TIM_IT_CC1, ENABLE); // 5. 配置NVIC嵌套向量中断控制器 NVIC_InitStructure.NVIC_IRQChannel TIM8_CC_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); // 6. 使能定时器 TIM_Cmd(TIM8, ENABLE); }关键参数计算与选择TIM_Period (arr)设为655350xFFFF。因为我们是16位定时器测量20ms周期对应约20000个计数远小于65535所以不用担心溢出。设置为最大值可以让我们在信号频率意外降低时也有足够的测量范围。TIM_Prescaler (psc)设为71。如前所述分频系数 psc 1 72。计数器时钟 72MHz / 72 1MHz计数周期为1us。TIM_ICFilter设为0x04。这个参数需要根据输入信号的质量调整。它定义了以f_CK_INT为采样时钟时连续多少个采样点一致才认为是一个有效的边沿。f_CK_INT是定时器输入时钟72MHz经过TIMx_CR1.CKD分频后的频率。我们CKDDIV1所以采样频率极高。设置ICFilter0x04意味着需要连续4个采样点状态一致能有效滤除窄毛刺。对于市电整形后的方波通常毛刺不多这个值足够。4.3 中断服务程序正确的处理逻辑中断里的逻辑是获取测量结果的关键。void TIM8_CC_IRQHandler(void) { if (TIM_GetITStatus(TIM8, TIM_IT_CC1) ! RESET) { TIM_ClearITPendingBit(TIM8, TIM_IT_CC1); // 必须清除中断标志 uint32_t current_capture TIM_GetCapture1(TIM8); // 读取当前的捕获值 if (g_is_first_capture) { // 第一次捕获只记录值不计算 g_last_capture current_capture; g_is_first_capture 0; } else { // 第二次及以后的捕获计算与上一次的差值 // 注意处理计数器溢出的情况 if (current_capture g_last_capture) { g_capture_period current_capture - g_last_capture; } else { // 发生溢出差值 (ARR - last) current 1 // 因为ARR是65535但计数器从0到65535计数值是65536个。 // 更通用的写法period (0xFFFF - g_last_capture) current_capture 1; g_capture_period (TIM8-ARR - g_last_capture) current_capture 1; } // 更新上一次捕获值为下一次计算做准备 g_last_capture current_capture; // 此时g_capture_period 就是两个上升沿之间计数器的差值。 // 周期(us) g_capture_period * 1us (因为计数器时钟是1MHz) // 频率(Hz) 1 / (周期(秒)) 1 / (g_capture_period * 1e-6) 1e6 / g_capture_period // 可以在这里计算频率或者在主循环中读取g_capture_period后计算。 } } }4.4 主函数中的调用与频率计算int main(void) { SystemInit(); // 系统时钟初始化配置为72MHz GPIO_TIM8_Init(); // 初始化定时器ARR65535, PSC71 TIM8_CH1_Capture_Init(0xFFFF, 71); while (1) { // 主循环可以定期读取并计算频率 uint32_t period_cnt; float frequency_hz; // 为了避免中断中读取变量时被编译器优化或产生非原子访问可以临时关闭中断 __disable_irq(); period_cnt g_capture_period; __enable_irq(); if (period_cnt 0) { // 计算频率计数器时钟1MHz计数值period_cnt对应的时间是 period_cnt 微秒 // 频率 1 / (period_cnt * 1e-6) 1e6 / period_cnt frequency_hz 1000000.0f / (float)period_cnt; // 现在你可以通过串口打印 frequency_hz或者用它做其他控制。 // 对于50Hz市电period_cnt应该在20000左右frequency_hz在50.0左右。 } // 添加一些延时避免过于频繁地计算和打印 Delay_ms(500); } }5. 调试过程中遇到的典型问题与排查实录即使按照上面的“正确”流程做了在实际焊接调试中依然会遇到各种问题。下面是我踩过的坑和解决方法。5.1 问题一捕获不到任何中断CCR1值永远不变现象程序运行后g_capture_period始终为0在调试器里观察CCR1寄存器其值不随输入信号变化。排查步骤检查硬件信号用示波器或逻辑分析仪直接测量PC6引脚确认是否有预期的50Hz方波电压幅值是否在STM32的识别范围内通常0-3.3V这是第一步也是最容易忽略的一步。我曾遇到过因为前端整形电路三极管工作点不对输出方波高电平只有2V在临界状态导致STM32无法稳定识别。检查GPIO配置确认GPIO模式是否正确。GPIO_Mode_IN_FLOATING是浮空输入要求外部电路必须有确定的上拉或下拉电阻否则引脚电平不确定。更稳妥的做法是使用GPIO_Mode_IPU内部上拉输入这样在无外部信号时引脚为高电平。检查定时器时钟确认RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM8, ENABLE)已被调用。没有时钟定时器就是一块“石头”。检查中断控制器NVIC确认中断通道TIM8_CC_IRQn已使能并且优先级配置合理。检查TIM_ITConfig(TIM8, TIM_IT_CC1, ENABLE)是否执行。检查中断服务程序ISR确认函数名完全正确TIM8_CC_IRQHandler对于标准库。在启动文件startup_stm32f10x_xx.s中查找该中断向量的确切名称。务必清除中断标志TIM_ClearITPendingBit否则中断会连续触发一次后卡死。检查滤波器ICFilter设置如果ICFilter值设置得过大例如0xF而信号边沿质量很好可能会引入不必要的延迟甚至滤掉正常的边沿。对于干净的方波可以从0x00开始测试。5.2 问题二测量值波动巨大极不稳定现象计算出的频率在40Hz到60Hz之间乱跳远超出市电的正常波动范围±0.5Hz。排查步骤首要怀疑对象信号毛刺。再次用示波器仔细查看PC6上的方波将时基调小看上升/下降沿附近是否有振铃或毛刺。市电经过变压器、整流桥、比较器后很容易引入噪声。解决方法调整硬件滤波在比较器输出端增加一个小的RC低通滤波如100Ω 100pF可以平滑边沿。调整软件滤波器TIM_ICFilter增大这个值如从0x04调到0x08或0x0A牺牲一点响应速度来换取稳定性。这是最有效的软件手段。检查中断处理时间如果中断服务程序执行时间过长可能会丢失后续的捕获事件。确保ISR尽量短小精悍只做必要的读取、计算和赋值。将复杂的频率计算、数据打印等操作移到主循环中。处理计数器溢出我的示例中断代码中已经包含了溢出处理。如果不处理当current_capture g_last_capture时直接相减会得到一个巨大的错误数值。请务必确认你的溢出处理逻辑正确。电源噪声如果MCU的电源纹波较大可能会影响IO口电平判断和定时器计数的稳定性。确保电源部分有足够的去耦电容如100nF陶瓷电容靠近MCU电源引脚。5.3 问题三测量值有固定偏差现象测出的频率稳定在49.8Hz或50.2Hz存在固定的系统误差。排查步骤校准系统时钟72MHz是由外部8MHz晶振通过PLL倍频9倍得到的。外部晶振本身有精度误差通常±20ppm到±50ppm。可以使用高精度的频率计测量一个已知的定时器输出如PWM来反推实际的系统时钟频率然后在计算中引入校准系数。实际频率 计算频率 * (标称晶振频率 / 实测系统时钟频率)定时器预分频误差预分频器PSC是整数分频可能会引入一个时钟周期的量化误差。对于1MHz的计数时钟1个计数误差对应1us在20ms周期里引入的相对误差是 1us / 20000us 0.005%可以忽略不计。中断响应延迟从边沿触发到进入ISR读取CCR1存在一段微小且不固定的延迟中断响应时间ISR入口代码执行时间。但这个延迟对于测量周期没有影响因为输入捕获是硬件在检测到边沿的瞬间自动将CNT锁存到CCR1的与CPU是否响应中断无关。中断延迟只会影响你得知这个测量结果的时间不会影响测量结果本身。这是输入捕获相比外部中断软件读定时器的巨大优势。5.4 一个进阶技巧使用PWM输入模式简化双沿捕获如果你需要同时测量周期和占空比或者追求更高的可靠性STM32的“PWM输入模式”是更好的选择。它本质上是将定时器配置为复位从模式并同时使用两个捕获通道通常TI1映射到IC1和IC2。一个通道捕获上升沿另一个通道捕获下降沿硬件自动完成复位和捕获只需要一个引脚。其配置更复杂但软件逻辑更简单抗干扰能力也更强。对于单纯的周期测量我上面介绍的基本输入捕获模式已经足够高效和简洁。6. 硬件设计注意事项与抗干扰心得软件调通了硬件不过关一切白搭。尤其是在工频环境测量中抗干扰是重中之重。信号隔离是黄金法则强烈建议在220V市电与STM32的IO口之间使用光耦如TLP521-4进行电气隔离。这不仅能保护昂贵的MCU免受高压窜入损坏还能极大地切断地线环路避免共模噪声干扰。光耦输出侧接一个上拉电阻到3.3V即可得到MCU兼容的方波。整形电路要可靠如果不用比较器而使用运放或三极管整形要确保其输出方波的上升/下降沿尽量陡峭并且高低电平稳定在0V和3.3V。缓慢的边沿会增加定时器捕获的不确定性。电源去耦在MCU的每个电源引脚VDD/VSS附近放置一个100nF的陶瓷电容。在板子的电源入口处增加一个10uF以上的电解电容或钽电容。干净的电源是数字电路稳定工作的基石。PCB布局布线模拟部分市电采样、比较器和数字部分MCU的走线尽量分开。晶振及其负载电容要紧贴MCU的OSC_IN和OSC_OUT引脚走线短而粗下方铺地屏蔽。关键信号线如捕获输入线远离时钟线、高频开关信号线。最后我想说调试嵌入式程序尤其是涉及定时器和中断的逻辑分析仪和示波器是你的左膀右臂。不要只盯着代码看多看看信号的实际波形很多问题都会迎刃而解。我当初就是太相信自己的代码忽略了硬件信号的质量才白白浪费了两天时间。希望我的这些经验和教训能让你在STM32的输入捕获之路上走得更顺畅一些。