
简介这份基于STM32定时器输入捕获实现的频率测量工程代码面向嵌入式初学者及需要测量脉冲或时钟信号频率的开发者覆盖定时器配置、中断处理、频率计算等关键环节。整个压缩包共125个文件大小约1.87MB以29个C源文件与31个头文件为主体同时包含Keil工程配置文件、编译生成的hex/axf/map结果以及文本说明目录结构清晰便于直接打开工程查看与再次编译。目前已有2143人浏览学习。代码基于HAL库封装详细实现了输入捕获通道初始化、上升沿捕获、中断服务函数以及周期与频率换算并附带相关定时器、ADC等底层驱动文件。读者可参照此工程快速搭建测频环境理解库函数调用流程与中断处理机制并根据实际信号特性调整捕获极性、预分频系数与滤波参数提升测量精度与稳定性。 刚做完一个用STM32测量频率的小项目趁热把过程和代码整理出来。网上关于STM32测频率的帖子不少但大多只贴一段输入捕获代码对采样时间、误差来源、引脚选择这些实际工程问题讲得比较浅。我这次把方案选型、硬件接线、代码实现到误差校准完整走了一遍踩了几个坑记录下来希望能帮到正在做类似项目的朋友。先明确一下需求输入是一路TTL电平的方波信号频率范围大约是100Hz到10kHz分辨率要求到个位数驱动一个OLED显示实时频率值。这个场景在电机测速、流量计脉冲输出、信号发生器校准里都很常见属于STM32测量类项目的典型入门任务。1. 频率测量方案怎么选三种主流做法对比STM32测频率的方案我梳理下来无外乎三种定时器输入捕获、外部中断定时器计数、PWM输入模式。这里先做个对比再讲我为什么选了输入捕获。方案原理适用频率范围精度因素资源占用外部中断定时器每个上升沿触发中断在中断里读计数值低频1kHz中断响应延迟占用主循环和中断资源定时器输入捕获硬件自动记录跳变时刻由捕获寄存器锁存中频100Hz~100kHz捕获分辨率取决于定时器时钟一个定时器通道PWM输入模式两个捕获通道联动直接读频率和占空比中频同上两个捕获通道外部中断方案最直观信号每来一个上升沿就跳进中断服务函数用另一个定时器记录时间差。但这里有个致命问题中断响应本身有延迟从硬件触发到进入中断服务函数通常需要十几个时钟周期如果中断里还做了其他事情延迟会更不稳定。频率高了以后误差会明显变大。我之前在低频段几十Hz用它做过按键扫描勉强能用但频率稍高就开始飘。输入捕获方案完全绕开了中断延迟问题。STM32的定时器输入捕获通道在检测到指定边沿时会把当前计数器的值硬件锁存到捕获寄存器同时置位标志位。这个过程由纯硬件完成不依赖软件响应速度。CPU只需要在中断里把捕获寄存器读走就行读走的时刻哪怕晚一些也不影响捕获值的准确性因为值在触发瞬间已经锁存好了。这比外部中断方案稳太多了。PWM输入模式是输入捕获的一个特殊用法需要占用同一个定时器的两个通道一个测周期一个测占空比。因为方波的频率就是周期的倒数所以PWM输入模式天然适合测频率。不过它要求信号必须是完整的PWM波形而且需要两个通道都配置好对于只测频率来说稍微有点浪费。我后面如果测占空比会考虑它这次就只用单通道输入捕获。我做项目时选了定时器输入捕获最终选它的核心原因有两个一是硬件锁存机制决定了它精度上限更高二是代码逻辑直观比PWM双通道模式更省资源。2. 硬件接线里容易踩的坑电压匹配和信号整形方案定了以后先别急着写代码。硬件接线这里有两个坑是我实际调试中踩过的值得展开说说。2.1 电压匹配5V信号直连3.3V引脚的风险很多信号源输出的是5V TTL电平而STM32的GPIO是3.3V耐压。直接接上去短时间可能没事长时间运行可能会损坏引脚。保险起见信号进单片机之前先串一个1kΩ电阻做限流或者用电平转换芯片如TXB0104。如果你确定信号源是3.3V逻辑那可以跳过这步。另外要注意共地问题。信号源和STM32开发板必须共地否则测量结果会完全乱掉。我之前用过USB供电的STM32和单独供电的信号发生器没共地的时候读到的频率值毫无规律检查了半天才发现是地电位不一致导致的。2.2 信号整形边沿不陡峭会触发多次捕获如果被测信号是传感器直接输出的脉冲比如光电编码器或流量计的脉冲输出边沿可能不够陡峭甚至带有毛刺。STM32的捕获通道对边沿的判定靠的是施密特触发器但这个触发器的阈值范围有限如果信号上升沿缓慢可能在阈值附近来回穿越导致一个周期内多次触发捕获。解决办法是加一个RC低通滤波或者在信号进单片机前用74HC14施密特触发反相器整形。74HC14的好处是能把缓慢变化的边沿变成陡峭的方波边沿同时具备一定抗毛刺能力。这个芯片一块钱左右对测量的稳定性提升非常明显。如果信号质量本身很好比如信号发生器输出的标准方波可以不加。2.3 引脚选择留意定时器通道映射STM32的每个定时器通道都映射到固定引脚选错了引脚代码配置得再对也白搭。以常见的STM32F103C8T6为例TIM2的通道1映射到PA0通道2映射到PA1TIM3的通道1映射到PA6通道2映射到PA7。如果用了重映射功能引脚还会变化。接线之前一定先去查数据手册或参考手册里的引脚复用表确认自己用的定时器通道对应哪个引脚。我用的是PA0对应TIM2_CH1这个组合在最小系统板上也方便引出。硬件这块总结一句话先用示波器或万用表确认信号幅值和边沿再看准引脚复用表最后再上电调试。3. 核心代码实现从定时器初始化到频率计算硬件确认没问题下面进入正题。我用的开发环境是Keil MDK标准外设库Standard Peripheral Library芯片是STM32F103C8T6。如果你用的是HAL库思路完全相同只是API名称不一样。3.1 定时器初始化配置配置分三步打开时钟、设置时基、配置捕获通道。void TIM2_Capture_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_ICInitTypeDef TIM_ICInitStructure; NVIC_InitTypeDef NVIC_InitStructure; // 1. 开启时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); // 2. PA0配置为浮空输入输入捕获不需要上下拉 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); // 3. 定时器时基72MHz计数时钟不分频 TIM_TimeBaseStructure.TIM_Period 0xFFFF; // 计数上限65535 TIM_TimeBaseStructure.TIM_Prescaler 0; // 预分频为0 TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); // 4. 配置输入捕获通道1上升沿触发直接映射到TI1 TIM_ICInitStructure.TIM_Channel TIM_Channel_1; TIM_ICInitStructure.TIM_ICPolarity TIM_ICPolarity_Rising; TIM_ICInitStructure.TIM_ICSelection TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter 0x0F; TIM_ICInit(TIM2, TIM_ICInitStructure); // 5. 使能捕获中断 TIM_ITConfig(TIM2, TIM_IT_CC1, ENABLE); // 6. 中断优先级配置 NVIC_InitStructure.NVIC_IRQChannel TIM2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); // 7. 启动定时器 TIM_Cmd(TIM2, ENABLE); }这里有几个参数值得细说。TIM_Period设成0xFFFF意味着计数器从0数到65535再回绕。如果被测频率较低周期超过了65535个计数时钟就会发生溢出这时需要配合TIM_Update溢出中断做计数扩展。我这次的信号频率最低100Hz周期是10ms72MHz计数下10ms对应720000个时钟周期远超65535所以必须处理溢出后面会详细讲。TIM_ICFilter 0x0F是输入滤波器的采样配置。这一位段可以配置数字滤波器的采样频率和采样次数。设成0x0F表示对输入信号连续采样N次具体N值要看参考手册只有多次采样结果一致才认为边沿有效。这个滤波对抑制毛刺很有帮助但对高频信号会增加捕获延迟。100kHz以上的信号建议设小一点或者设成0。TIM_ICPolarity_Rising是上升沿触发。注意STM32的极性配置反直觉——虽然名字叫Rising但在标准库里配置时捕获的边沿可以随时通过TIM_OC1PolarityConfig动态切换。测频率用上升沿没问题如果信号占空比特别小上升沿不明显可以改用下降沿。3.2 溢出扩展让量程覆盖低频信号上面提到100Hz信号周期10ms远大于计数器65535个时钟周期如果不处理溢出捕获值全是错的。解决方法是开启更新中断同时给溢出次数计数。volatile uint16_t capture_value 0; volatile uint16_t last_capture 0; volatile uint32_t overflow_count 0; volatile uint32_t freq_result 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { // 计数器回绕溢出次数加1 overflow_count; TIM_ClearITPendingBit(TIM2, TIM_IT_Update); } if (TIM_GetITStatus(TIM2, TIM_IT_CC1) ! RESET) { uint16_t current_capture TIM_GetCapture1(TIM2); uint32_t total_ticks 0; if (current_capture last_capture) { total_ticks current_capture - last_capture; } else { // 跨越了溢出点 total_ticks 0x10000 - last_capture current_capture; } // 加上溢出时累积的计数 total_ticks overflow_count * 0x10000; // 时钟频率72MHz周期倒数即频率 if (total_ticks 0) { freq_result 72000000UL / total_ticks; } last_capture current_capture; overflow_count 0; TIM_ClearITPendingBit(TIM2, TIM_IT_CC1); } }这段代码是测量逻辑的核心我把关键点拆开解释。current_capture last_capture这个分支处理的是两次捕获之间没有发生计数器回绕的情况。比如第一次捕获在1000第二次在5000周期就是4000个时钟。else分支处理的是跨越回绕点的情况。比如第一次捕获在60000第二次在3000说明计数器走过了60000到655355535个时钟再从0到30003000个时钟总共8535个时钟。公式0x10000 - last_capture current_capture算的就是这个。total_ticks overflow_count * 0x10000这一行很关键。因为捕获中断和更新中断是独立触发的有可能在两次捕获之间计数器回绕了好几次这时溢出计数字段里累积的值就要折算成时钟周期加进去。最后72000000 / total_ticks就是频率值。72MHz是STM32F103的主频8MHz晶振x9倍频如果你的系统时钟不同这里要改成对应的值。另外注意我做的是整数除法频率分辨率在低频段会差一些改进方法后面会说。3.3 主循环里的显示和刷新测量在中断里已经完成了主循环只需要读取freq_result并刷新显示。频率值会每秒更新很多次但OLED刷新不需要那么快我加了500ms的刷新间隔避免屏幕闪烁。int main(void) { OLED_Init(); TIM2_Capture_Init(); while (1) { delay_ms(500); OLED_ShowNum(0, 0, freq_result, 6); } }这里有个细节建议freq_result是在中断里修改的主循环里读取时最好关一下中断再读或者用volatile关键字声明。虽然对ARM Cortex-M3来说32位整数的读取在单次指令内是原子的但为了代码健壮性养成保护共享变量的习惯总没错。4. 误差从哪来量化误差、中断开销和滤波策略代码能跑起来只是第一步测量精度才是关键。我实测中发现误差主要来自三个地方计数器量化误差、捕获中断本身的时钟抖动、以及显示刷新引入的视觉误差。4.1 量化误差和测周期法的极限输入捕获本质是测周期用周期换算频率。周期测量存在天然的量化误差计数器是离散的相邻两次计数之间差1个时钟周期因此周期测量的绝对误差是±1个时钟周期换算成频率误差就是Δf f² / 72000000。这意味着频率越高误差越大。在1kHz时理论误差约0.014Hz在10kHz时约1.4Hz在72MHz时误差达到72kHz——当然没有人会用测周期法去测接近定时器时钟频率的信号。降低量化误差的方法只有一个多测几个周期求平均。比如测量10个连续上升沿之间的总时间再除以10算平均周期量化误差就摊薄到原来的1/10。实现上可以在捕获中断里设定一个计数器累加N个周期后再算频率。我实测中把N设为10100Hz信号的抖动明显减小。4.2 捕获中断里别做多余的事捕获中断服务函数里除了读取捕获值、计算频率尽量不要做其他操作。像OLED刷新、串口打印这类耗时操作一旦放在中断里会阻塞后续中断触发在高频信号下会漏掉捕获事件。我的做法是中断里只更新共享变量显示和通信全部放主循环。还有一个容易忽略的细节TIM_GetCapture1()的返回值最好在读取后立刻保存到局部变量不要在计算过程中重复调用。因为捕获寄存器在读取后可能会被后续硬件事件更新多次读取可能出现不一致。4.3 低通滤波让显示数值更稳定如果被测信号本身带噪声计算出来的频率会跳来跳去。我加了一个一阶低通滤波在中断里对频率做滑动平均或者更简单——在主循环里对显示值做滤波。一阶低通公式是display_freq display_freq (freq_result - display_freq) / 8;每次读到的新的频率值只贡献1/8的变化量其余7/8保持旧值。这个算法写起来三行但对稳定显示的帮助巨大。注意滤波系数不要太小否则响应太慢频率真实变化了屏幕要一秒钟才跟上来。5. 实测效果与调参记录从乱跳到稳定的全过程硬件接线PA0接信号发生器输出、代码烧录完成后我开始逐步测试记录了一组数据。5.1 低频段100Hz~1kHz的实测信号发生器输出100Hz方波OLED显示99偶尔跳到100。这个结果符合预期因为100Hz对应周期10ms即720000个时钟周期量化误差只有0.014Hz但由于信号发生器本身有微小频率漂移显示值会在99和100之间跳变。把滤波系数调大从8改成32后显示稳定在100不再跳动。代价是频率真实变化后显示要1秒左右才能跟上。对于静态频率显示场景这个响应速度完全可以接受。5.2 中频段1kHz~10kHz的实测1kHz时显示稳定在1000~1001误差在0.1%以内。10kHz时显示在10008~10013之间波动误差约0.1%。这个精度水平已经能满足大多数工程需求。如果要求更高精度有两个改进方向一是前面说的多周期平均我试过把N设成1610kHz的抖动范围缩小到3个数字以内二是改用更高分辨率的定时器时钟源STM32的TIM2挂载在APB1总线上如果APB1分频为1定时器时钟就是72MHz如果开了分频需要检查实际定时器时钟是多少。5.3 高频段失败记录40kHz以上的异常测试到40kHz时显示值开始乱跳偶尔显示0。排查发现两个原因一是信号源的方波边沿在40kHz下开始出现振铃导致捕获通道误触发二是捕获中断服务函数执行时间超过了信号周期导致漏捕获。第一个问题通过加74HC14整形解决第二个问题优化了中断代码把浮点运算改成整数运算但还是不够。最终我看了下参考手册TIM2的输入捕获在40kHz时其实还有足够时间处理但中断里72000000 / total_ticks是32位整数除法在72MHz主频下执行约需几百个时钟周期。信号周期25微秒对应1800个时钟周期本不应有问题但加上中断进出开销和滤波处理时间有点紧。我把除法运算移到了主循环中断里只计算并记录total_ticks再让主循环做除法。这个方法对高频应用很有效。6. 常见错误和调试手段遇到问题先查哪里最后这部分是排查经验汇总希望能帮你节省调试时间。6.1 频率永远显示0先查有没有进捕获中断。在中断里加个GPIO翻转用示波器看引脚波形确认中断是否触发。如果没有波形说明捕获配置有问题——大概率是引脚复用错了或者定时器时钟没开。如果中断有触发但频率为0检查total_ticks是否为0。这通常发生在捕获值恰好相等时比如信号频率是72MHz的整数倍连续两次捕获值相同。可以在捕获中断里如果total_ticks 0就设成1避免除零。6.2 频率值是真实值的一半或两倍这是触发边沿问题。如果信号的实际占空比导致存在额外毛刺可能出现一个周期触发两次的情况读数是真实频率的两倍。反过来如果边沿太陡峭导致漏触发读数就成了真实频率的一半。我用示波器观察PA0引脚的信号质量后发现是振铃问题加RC滤波后解决。如果没有示波器可以临时在PA0和GND之间并一个100pF电容试试。6.3 频率值低但不为0且与真实值有明显偏差大概率是系统时钟频率设置不对。很多开发板外部晶振是8MHz但如果你用的板子是12MHz晶振倍频配置不一样定时器时钟就不是72MHz。在Debug模式下查看RCC_GetClocksFreq()的返回值确认PCLK1和TIM2CLK然后把测量代码里的72000000改成实际值就行。6.4 使用HAL库时的坑如果你是用STM32CubeMX生成的项目记得在TIM_IC_Init之后调用HAL_TIM_IC_Start_IT(htim2, TIM_CHANNEL_1)并实现HAL_TIM_IC_CaptureCallback回调函数。HAL库的回调函数在中断上下文执行同样要遵守中断里不做耗时操作的原则。CubeMX生成的代码默认开了定时器更新中断但有时会漏掉__HAL_TIM_ENABLE_IT(htim2, TIM_IT_UPDATE)导致溢出计数永远为0。检查一下CubeMX里的NVIC配置和中断使能状态少了这步低频测量就会完全错误。7. 进一步扩展多通道测量和自适应量程项目到这里已经能稳定测一个通道的频率。如果你的需求是测多个信号或者频率范围特别宽可以考虑下面两个方向。7.1 多通道测量STM32F103中TIM2有4个捕获通道理论上可以同时接4路信号。但要注意多个通道共用一个定时器的溢出中断捕获中断里需要区分是哪个通道触发的还要分别维护各自的last_capture和overflow_count。另外效率上如果每个信号频率都很高中断处理时间会增加整体吞吐能力受限。我实践下来2通道同时测10kHz信号没问题4通道同时测就有点勉强。7.2 自适应量程本文用的是固定72MHz时钟的测周期法。如果信号频率极低比如0.1Hz周期定位秒级total_ticks会溢出32位变量这时可以动态把预分频系数调大牺牲一点高频精度换取低频量程。或者反过来如果信号极高到了MHz级别需要改用测频法——固定时间窗口内数上升沿个数再除以时间得到频率。测频法和测周期法是一对互补方案前者适合高频后者适合低频。真正的通用频率计会同时实现两种方法根据输入频率自动切换这可以作为进阶目标。这就是我用STM32测量频率的完整过程。虽然方案不复杂但从方案选型、硬件处理到误差分析和调试排错每一步都有值得留意的细节。这套基于定时器输入捕获的代码我已经用到电机测速项目里了稳定性可靠后续准备扩展成双通道频率计。有问题欢迎交流。 p a hrefhttps://download.csdn.net/download/baidu_23000255/8126379 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p