STM32定时器输入捕获测频率:原理、CubeMX配置与HAL代码实战
把信号发生器夹到板子上输出一路25 kHz的方波我用最笨的外部中断思路去数上升沿结果CPU占用直接飙到90%串口打印开始丢数据。这个场景我相信很多朋友都遇到过——测频率看起来是个简单需求但选错方案后面全是坑。用STM32C542R这颗Cortex-M33内核的芯片做定时器输入捕获来测量频率是我最近调电机驱动板时最省心的一件事。硬件自动记录边沿时刻CPU几乎不用出力精度还高。这篇就把我从原理、CubeMX配置到HAL库代码、再到实测踩坑的完整链路分享出来给正在用C5系列或者从F1老工程迁移过来的朋友一个参考。1. 为什么测频率不能只靠外部中断硬扛1.1 外部中断方案的死穴很多人第一反应是把信号接到一个GPIO上配成外部中断每次上升沿触发一次中断在中断里计数器加1然后每秒算一次频率。这个方法不是不能跑但它有几个问题很致命。首先是中断开销。你算一下20 kHz的方波每秒就是2万次中断。每次中断要压栈、保存现场、跳转执行、恢复现场这些CPU周期累积起来非常可观。我实测过20 kHz信号下外部中断方案CPU占用轻松超过60%如果主循环里还要跑显示、通信、控制算法基本就喘不过气了。第二个问题是精度。外部中断从事件发生到进入中断处理函数中间有硬件响应时间、中断仲裁时间、代码执行时间这些不确定性会让相邻两次中断的间隔忽长忽短。你以为是均匀的20 kHz脉冲实际上测出来每个周期都差几个微秒算出来的频率自然不准。1.2 定时器输入捕获为什么是正解输入捕获的思路完全不同定时器内部有一个自由运行的计数器每个时钟周期加一。当检测到引脚上的边沿时硬件会自动把当前计数器的值锁存到捕获寄存器里同时置一个中断标志位。整个过程CPU完全不参与也不受中断延迟影响。CPU要做的事只有一件等到中断标志置位后把捕获寄存器里的值读出来。上一次捕获值和本次捕获值相减就是信号一个周期的时钟计数个数再换算成频率。这样一来就算信号频率是1 MHz每次捕获只产生一次中断CPU占用率极低。精度取决于定时器时钟频率跟中断延迟彻底解耦。1.3 从F1迁移到C5系列要注意什么STM32C542R属于STM32C5系列外设设计和F1/G4是一脉相承的定时器的基本架构、捕获通道、寄存器布局都很相似。但从F1老工程迁移过来有几个点要留意一是CubeMX生成的HAL代码版本差异比较大早期F1工程用的HAL库函数名可能变了二是时钟树配置方式不同C5系列的系统时钟最高250 MHz定时器时钟来源需要重新核对三是GPIO复用功能编号和F1不一样不能照搬F1的AF配置表。不过好消息是标准库时代那种繁琐的寄存器级配置已经被HAL库封装掉了核心的捕获逻辑代码在F1、F4、C5系列上几乎可以通用。这也是我推荐直接用CubeMX生成基础工程的原因能把迁移成本降到最低。2. 输入捕获测频率的数学本质把频率问题变成时间差问题2.1 捕获就是记录CNT的瞬间值很多初学者一上来就背公式却不知道这个公式是怎么来的。我打个比方假设你站在路边手里有一个秒表每有一辆车经过你按下一次按钮记录当前时间。那么两辆车经过的时间差就是一辆车的间隔时间也就是周期。输入捕获干的就是这件事——定时器里的计数器CNT就是那个秒表捕获寄存器就是记下的时间点。具体到硬件动作定时器检测到设定好的边沿比如上升沿时硬件瞬间把CNT的值复制到CCR寄存器里然后触发中断。你在中断里读走CCR的值和上一次读到的值做差。2.2 频率计算的公式与边界假设定时器时钟频率是 ( f_{timer} )预分频系数是PSC1那么计数器每增加1所代表的实际时间是[ t_{tick} \frac{PSC 1}{f_{timer}} ]两次捕获的计数值差为 ( \Delta CNT )则信号周期为[ T \Delta CNT \times \frac{PSC 1}{f_{timer}} ]频率就是周期的倒数[ f \frac{f_{timer}}{(PSC 1) \times \Delta CNT} ]举个例子定时器时钟125 MHzPSC设为0捕获差值 (\Delta CNT 5000)那么频率就是[ f \frac{125{,}000{,}000}{1 \times 5000} 25{,}000 \text{ Hz} ]但如果 (\Delta CNT) 很小比如只有5那算出来就是25 MHz——问题是这时一个周期只占5个计数周期频率分辨率非常粗糙信号稍微抖动一下结果就飘得厉害。所以PSC的选择要看目标频率范围目标频率范围PSC建议计数分辨率说明1 kHz以上08 ns高频用细粒度计数10 Hz~1 kHz99800 ns低频用粗粒度避免溢出1 Hz以下999980 us极低频必须配合溢出计数2.3 溢出计数解决低频测量还有一个关键问题很多定时器的计数器是16位的最大只能数到65535。如果信号频率太低一个周期的计数值超过65535计数器就会回绕捕获差值就变成了一串乱码。比如定时器时钟125 MHzPSC0测50 Hz信号周期20 ms需要计数250万个远超65535。这时候必须开启定时器更新中断每当CNT从65535回绕到0时在更新中断里给一个溢出计数器加1。最终的时间差公式变成[ total_cnt overflow_count \times (ARR 1) (current_capture - last_capture) ]溢出次数加上当前的捕获差值才能还原出真实周期。这个公式在后面代码部分我会详细展开它是低频测量精度的核心。3. 从引脚到寄存器输入捕获信号链路上的每个环节3.1 输入捕获的硬件链路滤波、边沿与锁存输入捕获从引脚到寄存器中间经过几个环节每个环节都对应一个配置项搞懂这条链路你就不会在CubeMX里看着一堆选项发懵。第一站是输入滤波器。信号进入定时器引脚后先经过一个可编程的数字滤波器作用是把窄毛刺过滤掉。滤波器会按设定的时钟周期采样连续采样到N次相同电平才认为信号有效。滤波器参数越大抗干扰越强但对高频信号就越不友好。第二站是边沿检测器。这里决定捕获上升沿、下降沿还是双边沿。对应寄存器位是TIMx_CCER里的CC1P位0是上升沿1是下降沿。这个看似简单的选择实际中很容易配反后面踩坑部分会细讲。第三站是捕获预分频器。注意这个和定时器的PSC不是一回事这里是ICxPSC可以对捕获事件本身预分频也就是捕获每1次、每2次、每4次才触发一次中断。一般用不到但如果信号频率极高可以用它来降低中断频率。最后一站就是捕获寄存器CCR。边沿到来时硬件把CNT的值锁存进来同时置位CC1IF标志触发中断或DMA请求。3.2 定时器时钟来源与分频定时器时钟是频率计算的基础如果这个数不对后面算出来的频率全是错的。C5系列里定时器挂在不同总线上TIM1、TIM8这类高级定时器通常在APB2上TIM2、TIM3、TIM4这类通用定时器通常在APB1上。CubeMX的Clock Configuration页面会直接显示Timer clocks这是最可靠的参考不需要背分频公式。我实际配置时会把系统主频设到250 MHz然后调整APB1预分频让定时器时钟落在125 MHz。为什么选125而不是250因为APB1外设时钟有上限定时器时钟翻倍机制在不同分频系数下表现不同直接在时钟树页面看结果最稳妥。3.3 捕获中断和更新中断的关系输入捕获用到两类中断捕获中断和更新中断。捕获中断在CCR锁存时触发更新中断在计数器回绕时触发。这两类中断对应两个不同的回调函数捕获中断走HAL_TIM_IC_CaptureCallback更新中断走HAL_TIM_PeriodElapsedCallback。在代码里要分别处理尤其是在低频测量场景下如果只开捕获中断不开更新中断溢出次数永远为0换来的就是错误的频率值。另外要注意捕获中断和更新中断可能几乎同时发生。比如信号周期刚好是65536的整数倍时捕获边沿和计数器回绕发生在同一个时刻附近两个中断标志位同时置位。NVIC仲裁后只能先响应一个另一个要等一会儿。这个时序竞争问题在要求极高精度的场合需要注意简单场景下误差在一个计数值以内通常可以接受。4. CubeMX配置那几个下拉框背后都有说法4.1 时钟树里的定时器时钟核对打开CubeMX第一步不是急着配定时器而是先把时钟树搞定。我见过太多人直接跳过时钟树结果定时器时钟根本不是自己以为的那个值频率算出来差一倍还找不到原因。进入Clock Configuration页面先选好HSE等时钟源把系统主频配到250 MHz然后专门去看APB1和APB2的Timer clocks。以我用的配置为例APB1经过分频后TIM2/TIM3/TIM4的Timer clocks显示为125 MHz。这个数值一定要记住后面代码计算频率要用。4.2 TIM3初始化参数表我这边用TIM3的通道1来做输入捕获选择引脚是PA6。在Pinout Configuration里找到TIM3按下面的参数配置参数项取值含义Clock SourceInternal Clock使用内部定时器时钟Channel1Input Capture direct mode通道1直接作为捕获输入Prescaler0定时器不预分频计数时钟125 MHzCounter Period6553516位最大计数值auto-reload preloadEnable更新事件时预装载ARRInput Filter0不做额外滤波保证高频响应Input PrescalerNo division每个边沿都捕获这里重点解释一下Input Filter和Input Prescaler。Input Filter设置为0意味着不做数字滤波任何边沿都会被捕获。如果你的信号环境比较恶劣有抖动和毛刺可以适当调到1到3但代价是高频信号可能被滤掉。Input Prescaler一般保持No division只有在信号频率太高、中断太频繁时才需要增大。4.3 NVIC与GPIO复用定时器配置好后别忘了到NVIC Settings里勾选TIM3 global interrupt否则中断回调永远不会执行。CubeMX会根据你在定时器里启用的通道自动使能对应的中断源但NVIC这一层通常需要手动确认。GPIO部分CubeMX会自动把PA6配置为复用功能TIM3_CH1引脚模式、速度、上下拉都会自动生成。不用手动去GPIO页面再配一遍反而容易把CubeMX自动生成的配置覆盖掉。生成代码后打开main.c你会发现MX_TIM3_Init()已经写好了但启动捕获的函数需要自己添加。这一步是最容易被忽略的CubeMX只负责初始化外设不会自动启动捕获流程。正确姿势是在main函数里调用MX_TIM3_Init()之后加上HAL_TIM_IC_Start_IT(htim3, TIM_CHANNEL_1)捕获才真正开始工作。5. HAL库实现捕获与溢出计数的完整代码5.1 启动捕获的两行代码CubeMX生成工程后在main函数的外设初始化部分找到MX_TIM3_Init()的位置紧接着添加启动代码MX_TIM3_Init(); HAL_TIM_IC_Start_IT(htim3, TIM_CHANNEL_1); __HAL_TIM_ENABLE_IT(htim3, TIM_IT_UPDATE);第一行启动输入捕获中断第二行使能定时器更新中断。更新中断的作用是累计溢出次数只要测低频就必须开高频时开不开都行但为了代码统一我通常是两个一起开省得后面改。别忘了在main循环之前定义几个全局变量volatile uint16_t g_capture_value 0; // 最近一次捕获的CCR值 volatile uint32_t g_ovf_counter 0; // 定时器溢出次数 volatile uint8_t g_capture_flag 0; // 捕获完成标志5.2 回调函数与全局变量HAL库的中断处理是框架式的TIM3_IRQHandler里调用HAL_TIM_IRQHandler然后框架根据中断源分发到不同的回调函数。你需要重写以下几个回调void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM3 htim-Channel HAL_TIM_ACTIVE_CHANNEL_1) { g_capture_value __HAL_TIM_GET_COMPARE(htim, TIM_CHANNEL_1); g_capture_ovf g_ovf_counter; g_ovf_counter 0; g_capture_flag 1; } } void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM3) { g_ovf_counter; } }这里有一个细节很多人没注意在捕获中断里我把g_ovf_counter的值先存到另一个变量g_capture_ovf然后再清零。这样做是为了保证“本次捕获发生时对应的溢出次数”是准确的。如果所有处理都放在主循环里统一读取可能已经过了好几个周期溢出次数早就变了。5.3 频率计算函数主循环里轮询g_capture_flag置位后就执行频率计算void Calculate_Frequency(void) { static uint16_t last_capture 0; static uint8_t first_capture 1; if (!g_capture_flag) return; g_capture_flag 0; if (first_capture) { first_capture 0; last_capture g_capture_value; return; } int32_t diff (int32_t)g_capture_value - (int32_t)last_capture; int32_t total_cnt (int32_t)g_capture_ovf * (int32_t)(htim3.Init.Period 1) diff; last_capture g_capture_value; float freq (float)TIMER_CLOCK_HZ / ((float)(htim3.Init.Prescaler 1) * (float)total_cnt); printf(freq %.2f Hz\r\n, freq); }有几个地方说说为什么这样写first_capture标志是必须的。第一次捕获之前没有上一次的值做差值没有意义需要先跳过一次。用int32_t而不是uint32_t做减法是因为current小于last时结果为负数这个负值表示计数器跨越了回绕点后面的total_cnt计算需要这个负数参与运算。TIMER_CLOCK_HZ这个宏要在工程里定义成125000000对应CubeMX时钟树里看到的Timer clocks。不同工程这个值可能不一样务必核对。5.4 完整流程说明整个运行流程是这样的定时器自由运行从0计数到65535后回绕到0这个过程反复进行。信号边沿到来时CCR锁存当前CNT值捕获中断触发计数器回绕时更新中断触发g_ovf_counter加1。主循环里看到g_capture_flag置位就知道新的边沿到了。把本次捕获值减去上次捕获值得到周期内的计数值再加上溢出次数对应的完整计数周期就是实际总计数。最后用定时器时钟除以总计数和PSC1的乘积得到频率。这套代码在7段数码管、OLED、串口屏上都能直接复用核心逻辑完全一致。6. 实测数据与踩坑清单这些坑我已经替你踩过了6.1 实测记录表用信号发生器输出不同频率的方波固定PSC0、定时器时钟125 MHz实测结果如下信号发生器输出溢出次数捕获差值周期总计数计算频率误差100 Hz19约48301,250,014100.001 Hz0.001%1 kHz1约59464124,9991000.01 Hz0.001%2 kHz06250062,5002000.00 Hz0100 kHz012501250100.000 kHz05 MHz025255.000 MHz0可以看到低频段由于溢出次数的参与公式依然成立误差主要来自信号发生器自身的精度和计数截断。高频段因为一个周期只占25个计数分辨率是8 ns量级波形抖动会直接反映到结果里这属于物理极限不是代码问题。6.2 坑1捕获沿配置反了中断一个不来有一次我把信号发生器接到板子上以为配置成上升沿捕获就能收到中断结果等了好几秒什么事都没发生。用示波器一看信号本身是低电平有效平时为高干活的时候拉低也就是“下降沿才是有效边沿”。而CubeMX里默认配的是上升沿捕获自然一个边沿都触发不了。排查方法很简单先看信号极性再用调试器读TIM3的CCER寄存器确认CC1P位bit 1的值。0代表上升沿1代表下降沿。按照实际信号极性重新配置就好了。这个坑虽然小但现象迷惑性很强容易让人怀疑是硬件问题。6.3 坑2低频测量数值乱跳原因是没有开更新中断低频测试时我把输入设为5 Hz结果计算频率在4到7 Hz之间乱跳完全没规律。查了半天发现__HAL_TIM_ENABLE_IT(htim3, TIM_IT_UPDATE)这行代码没加定时器溢出事件根本没有中断产生g_ovf_counter永远是0。但捕获中断还在触发所以主循环每次拿到的都是一个“残缺”的差值——真实周期被截断了。解决办法就是补上更新中断使能同时在HAL_TIM_PeriodElapsedCallback里对TIM3做溢出计数。只要这两处都到位低频测量数据马上就稳定了。6.4 坑3滤波器把高频信号滤没了测2 MHz方波时我发现捕获中断完全消失了但用示波器看信号明明好好的。排查到最后是CubeMX里Input Filter被之前一个项目设成了10。数字滤波器的原理是连续采样多个点后才认为边沿有效高频信号的单个脉冲宽度太窄还没采到足够的点脉冲就过去了于是边沿被当作毛刺过滤掉。这个问题在低频场景下不容易暴露因为信号宽度足够滤波效果正常。一旦切到高频问题立刻显现。我的经验是测频率的场景Input Filter先设0等确认信号有干扰再逐步加大不要一上来就设个大值。6.5 坑4定时器时钟没核实频率差整数倍有一次我算出来的频率是实际值的整整两倍代码逻辑怎么查都没问题。最后点开CubeMX时钟树才发现APB1分频器设置导致Timer clocks只有62.5 MHz代码里却用的是125 MHz。这种错误很隐蔽因为误差是整数倍关系看上去像是代码里的系数问题。排查建议频率计算宏TIMER_CLOCK_HZ一定以CubeMX时钟树页面的实际显示值为准不要凭经验写。HAL库里有HAL_RCC_GetPCLK1Freq这类函数但定时器时钟和PCLK1不一定相等最直观的还是看时钟树页面。6.6 坑5printf进中断导致卡死最开始我在捕获中断回调里直接调printf往串口发频率值低频时看起来一切正常一旦频率升到1 kHz以上系统就开始卡顿甚至进入HardFault。原因很简单printf是阻塞的串口发送一个字节要等待移位寄存器腾空波特率115200时发送一个字节大约87微秒一行字符串十几个字节就是1毫秒以上。中断服务函数里耗这么长时间后续中断全部排队整个系统自然卡死。正确做法是回调函数里只置标志位、存数据主循环里再处理打印。中断服务函数越短越好这是嵌入式开发的铁律但很多新手会在这里栽跟头。7. 进阶思路低频、高精度、占空比一次说清楚7.1 自动切换预分频兼顾宽频段测量前面提到固定PSC0时测量低频需要依赖溢出计数虽然公式可行但计数值很大而且高频场景下计数粒度太粗精度有限。反过来固定PSC9999时高频信号一个周期只占1个计数根本测不了。一个实用的思路是启动时先用较大PSC粗测频率得到一个大致范围然后根据这个范围自动切换到最合适的PSC重新测量。比如检测到频率低于100 Hz就加大PSC高于10 kHz就减小PSC。这个过程可以由代码自动完成用户无感。7.2 双通道占空比测量输入捕获不仅能测频率还能测占空比方法是用两个捕获通道配合。以TIM3为例通道1捕获上升沿通道2捕获下降沿。上升沿到下降沿的时间差就是高电平脉宽而两个上升沿之间就是完整周期两者相除就是占空比。配置上通道1和通道2都要开启Input Capture direct mode通道2的捕获极性选下降沿。代码里分别读CH1和CH2的CCR值做差即可。这个方案在电机调速、开关电源控制里非常实用一套硬件就能同时拿到频率和占空比两个关键参数。7.3 DMA批量捕获把CPU占用降到零如果信号频率很高比如几十kHz以上平均每个周期都要中断一次CPU还是有负担。这时可以用DMA把CCR寄存器的值周期性搬运到内存数组里等DMA传输完成再批量计算频率。配置思路是定时器开启DMA请求DMA的源地址设为TIM3的CCR1寄存器地址htim3.Instance-CCR1目的地址设为一个uint16_t数组传输长度设为100循环模式开启。这样每捕获100个边沿才触发一次中断CPU开销直接降到原来的1/100。需要注意一个坑CCR是16位寄存器DMA搬运的时候数据宽度要配成半字Half Word不然高字节会错位。这个细节在高频场景下尤其重要一旦错了数组里全是错乱值。7.4 一点个人体会输入捕获看起来是STM32外设里最简单的一档真正用稳会发现边界问题全在细节里极性配置、溢出计数、滤波参数、时钟核对。我原本以为半天能调完的东西实际花了差不多一个周末。但这套代码一旦跑通以后凡是测频率、测脉宽、测占空比的需求都是十分钟就能出结果的事。希望这篇笔记能帮你把那一个周末省下来。