简介:STM32频率测量代码包,面向嵌入式开发者和电子爱好者,解决利用STM32定时器输入捕获测量外部脉冲或时钟信号频率的需求。代码基于HAL库编写,示范了定时器配置、捕获通道选择、中断处理及周期换算等完整流程。资源共125个文件、1.87MB,以31个h头文件和29个c源文件为主,附带Keil工程配置(uvproj)、编译生成的hex、axf、map文件及少量调试辅助文件,解压后即可在MDK环境下打开工程阅读或烧录验证。目前已有2145人学习下载。包内除频率测量核心逻辑外,还包含定时器、ADC、LCD等外设驱动源文件,以及工程备份和说明文档,便于对照HAL库API理解硬件初始化细节;同时提供捕获值读取、中断清除和多次采样求平均等思路,用户可直接参考示例代码移植到自己的STM32项目,也可作为学习定时器输入捕获功能的配套资料。
1. 拆这个标题:STM32测频率到底解决什么问题、适合谁做
很多人拿到"STM32 测量频率代码"这个标题,第一反应是找一段现成的代码往工程里一贴就跑。实际上频率测量是个典型的软硬结合问题,一段代码占三成,信号链路、定时器参数和边界处理占七成。我见过某开发者把输入捕获代码抄进项目,结果用信号发生器给 1kHz 方波,读数在 987Hz 和 1013Hz 之间乱跳,最后发现是输入引脚悬空时电平抖动,硬件上串个电阻加个上拉就稳了。这种场景在转速测量、PWM 检测、信号发生器校准、电量计量里天天遇到。本文不打算给一份"万能代码",而是把频率测量的原理、定时器输入捕获的配置、参数计算和排障路径一次讲透。适合正在做嵌入式测量类项目、需要把频率读数做准做稳的开发者,也适合刚接触定时器输入捕获、想搞明白中断里到底在算什么的新手。
2. 原理先立住:测频法、测周法和输入捕获的选型依据
2.1 测频法(M法)与测周法(T法)的适用边界
频率测量最朴素的做法是固定一个闸门时间,比如 1 秒,数这个时间内来了多少个脉冲,频率就等于计数值除以闸门时间。这就是测频法,也叫 M 法。它的核心误差来自闸门起点和被测信号上升沿不对齐,最坏情况下会差一个脉冲,所以高频信号用 M 法很舒服,1MHz 信号测出来 1.000MHz 很容易;但测 1Hz 信号时,1 秒闸门内可能只数到 0 或 1 个脉冲,误差直接是 100% 级别。
反过来,测周法(T 法)是测量一个完整周期的时间长度,再用 1 除以周期得到频率。低频信号周期长,定时器计数多,分辨率高;高频信号周期短,可能只计到几个数,误差就大了。这就是为什么"低频率用测周法、高频率用测频法"是教科书里的基本判断。
但实际工程里很少只面对单一频率段。一个做电机转速测量的场景,启动时转速可能只有几十转每分钟,对应频率不到 10Hz;高速运行时可能到几千转每分钟,对应频率几百赫兹。如果只用一个方案,很难两头兼顾。这时就得引入 M/T 法:在固定闸门时间内同时记录脉冲个数和标准时钟计数,用脉冲数除以实际经过的时间。这个方案在变频率范围场景里精度最高,但代码复杂度也最高。
对大多数 STM32 应用来说,输入捕获方案实现的其实是"测周法"的变体:测量相邻两个上升沿之间的时间间隔。它的优势是单个定时器就能完成,不需要额外的闸门逻辑,而且在信号频率不高不低(几十 Hz 到几十 kHz)时精度足够。如果被测信号频率很高,比如超过定时器最大计数分辨率能覆盖的范围,才需要考虑切到纯 M 法,用外部计数或定时器从模式。
2.2 定时器输入捕获的工作机制与关键寄存器
STM32 的定时器输入捕获,本质上做的事是:把引脚上的边沿信号作为触发源,在边沿到来时把当前 CNT 计数器的值锁存到捕获寄存器 CCRx 里。代码要做的事就两件:配置好触发边沿(上升沿或下降沿),然后在中断或 DMA 里把相邻两次 CCRx 值做差,差值就是信号周期对应的计数个数。
理解这套机制有几个关键寄存器要清楚。CNT 是自由运行的计数器,它会从 0 数到 ARR(自动重装载值),然后归零重新开始。PSC 是预分频器,它决定计数器每计一个数需要多少个时钟周期,这直接决定了测量的分辨率。CCR1 是捕获寄存器,硬件在边沿到来瞬间锁存 CNT 当前值,这个动作不需要软件参与,所以捕获的时间精度很高,能达到一个定时器时钟周期级别。ICF 是输入捕获滤波器,它可以对输入信号做数字滤波,用来抑制毛刺和抖动。
用 STM32F103 的具体数值来算一笔账。定时器挂在 APB1 上,典型时钟是 72MHz。如果把 PSC 设为 71,计数器时钟就是 72MHz / (71+1) = 1MHz,也就是每计一个数代表 1 微秒。此时如果两次捕获的差值是 1000,说明周期是 1000 微秒,频率就是 1kHz。这个分辨率在大多数转速测量场景里完全够用。
提示:预分频器 PSC 和自动重装载 ARR 的设置,决定了你能测的最大周期。16 位定时器的 CNT 最大只能数到 65535,如果 PSC=71(计数时钟 1MHz),最大可测周期是 65.535ms,对应最低频率约 15.26Hz。频率比这更低时,定时器会在两次捕获之间溢出,这一问题会在第 4.3 节专门处理。
3. 动手前的硬件准备:信号整形、电平匹配和引脚选择
3.1 信号预处理:从毛刺信号到标准方波
很多人在调频率测量代码时忽略了一个事实:STM32 的输入捕获检测的是引脚电平边沿,但现实世界里的"频率信号"很少是干净的方波。电机转速传感器的输出可能是幅值很小的正弦波,光电编码器的输出可能带着严重的抖动和毛刺,继电器触点产生的信号更是充满了弹跳。如果你的输入信号不是边沿干净的标准方波,那么代码层面的滤波做得再好,读出来的频率也会乱跳。
常见的做法是先做信号整形。最简单的方案是用一个施密特触发器芯片,比如 74HC14,把缓慢变化的正弦波或者带噪声的信号整形成边沿陡峭的方波。施密特触发器的回差特性决定了它对噪声有天然的抑制能力:输入电压超过正向阈值才输出高电平,低于负向阈值才输出低电平,中间的过渡区不会触发翻转。用示波器观察整形前后的波形差异是最直观的验证方式。
如果信号幅值本身没问题、只是带毛刺,也可以用 STM32 输入捕获自带的数字滤波功能。配置 ICF 参数可以设定采样频率和连续有效次数,比如 ICF=0b0011 就表示以 fDTS/8 的采样频率连续采样 8 次,8 次都确认是有效电平才认这个边沿。这个滤波器对窄毛刺的效果很好,但要注意它也会引入一定的边沿延迟,对高频信号测量会有影响,需要在高频场景里折中。
3.2 3.3V与TTL电平的三种可靠接法
STM32 的 GPIO 引脚耐压通常是 3.3V,如果被测设备输出的是 5V TTL 电平,直接接上去有烧引脚的风险,但很多工控传感器和旧式仪器确实是 5V 输出。这里不能偷懒,必须做电平转换,常见的有三种做法。
第一种是电阻分压,两个电阻把 5V 分到 3.3V 附近。5V 经过 2.2kΩ 串到引脚,引脚对地接 3.3kΩ,分压结果约 3.0V,这个电平在 STM32 的高电平阈值之上,能可靠识别,同时不会过压。这个方案的缺点是分压电阻会抬升输入阻抗,信号源驱动能力弱时边沿会变缓,所以只适合信号源输出阻抗较低的场景。
第二种是二极管钳位。5V 信号经过一个 1kΩ 限流电阻进引脚,同时引脚对 VCC3.3 接一个肖特基二极管(阳极接引脚,阴极接 3.3V),这样引脚电压被钳在 3.3V 加一个二极管压降以内。这个方案的好处是简洁,缺点是二极管漏电流和反向恢复时间对高频信号有一点影响,测几百 kHz 以上的信号时要慎用。
第三种最省心,直接用一路双向电平转换模块,比如 TXS0102 这类芯片。模块内部有自动方向判断和上拉电阻,推挽输出和开漏输出都能适配。我一般会建议手头常备几个这种模块,调试时先用模块把信号链路打通,确认频率测量逻辑没问题之后,再根据实际产品需求决定要不要用分压电阻方案来替代。GPIO 引脚的选择上,优先用定时器通道的默认映射引脚,比如 TIM2_CH1 对应 PA0,这样可以省去重映射配置,少一个变量。
4. 用输入捕获实现频率测量:HAL库配置与完整代码
4.1 定时器时钟与捕获参数的计算
进入代码环节之前,先把参数计算这一步做扎实。以 STM32F103 的 TIM2 为例,它挂在 APB1 总线上,当系统时钟为 72MHz、APB1 分频系数为 2 时,TIM2 的输入时钟是 72MHz,而不是 36MHz,这是新手最容易理解错的地方。
参数计算的核心是确定 PSC 和 ARR。PSC 决定计数分辨率,ARR 决定最大可测周期。通常优先确定分辨率:如果被测信号最高频率是 100kHz,周期是 10 微秒,那么计数分辨率至少要比 10 微秒小一个数量级才够用,也就是 1 微秒,对应 PSC = 71。此时计数时钟 1MHz,16 位定时器的最大计数周期 65.535ms,频率下限约 15.26Hz。如果被测信号频率范围是 1Hz 到 1kHz,那么 1 微秒分辨率反而会导致低频时计数溢出,PSC 应该调大,比如 PSC = 7199 得到 10kHz 计数时钟,最大计数周期 6.5535 秒,频率下限约 0.15Hz,但分辨率只有 0.1 毫秒,测 1kHz 信号时误差变大。
所以参数设置的顺序应该是:先明确被测信号的频率范围,再决定分辨率,最后反推 PSC。ARR 一般保持 0xFFFF 不调整,除非你想让定时器溢出事件产生一个固定的时间基准。频率计算的完整公式是:计数时钟频率 = 定时器输入时钟 / (PSC + 1),信号频率 = 计数时钟频率 / (两次捕获的计数差值 + 溢出次数 × 65536)。这个公式要刻在脑子里,后面所有排障都围绕它展开。
4.2 完整代码:初始化、回调、周期转频率
下面是一份基于 HAL 库的完整实现,代码目标是:TIM2_CH1 捕获输入信号的上升沿,两次捕获差值即为信号周期,在回调函数里把频率值存入全局变量。
/* timer.h 中声明外部变量 */ extern volatile uint32_t g_signal_freq_hz; extern volatile uint32_t g_capture_period_cnt; /* timer.c 中定义变量 */ volatile uint32_t g_capture_period_cnt = 0; volatile uint32_t g_signal_freq_hz = 0; static uint32_t s_last_ccr = 0; static uint32_t s_last_cnt = 0; static uint16_t s_overflow_cnt = 0;/* 定时器输入捕获初始化 */ static void MX_TIM2_Init(void) { TIM_IC_InitTypeDef sConfigIC = {0}; /* TIM2 时钟源来自 APB1,72MHz,PSC=71 得到 1MHz 计数时钟 */ htim2.Instance = TIM2; htim2.Init.Prescaler = 71; htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 0xFFFF; /* 16位定时器,最大计数范围 */ htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_IC_Init(&htim2); /* 捕获配置:上升沿触发,直接通道,无预分频,关闭滤波 */ sConfigIC.ICPolarity = TIM_INPUTCHANNELPOLARITY_RISING; sConfigIC.ICSelection = TIM_ICSELECTION_DIRECTTI; sConfigIC.ICPrescaler = TIM_ICPSC_DIV1; sConfigIC.ICFilter = 0; HAL_TIM_IC_ConfigChannel(&htim2, &sConfigIC, TIM_CHANNEL_1); /* 启动捕获,使能中断 */ HAL_TIM_IC_Start_IT(&htim2, TIM_CHANNEL_1); /* 使能定时器更新中断,用于溢出计数 */ __HAL_TIM_ENABLE_IT(&htim2, TIM_IT_UPDATE); }/* 回调函数:两次上升沿之间的计数差值就是周期 */ void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { uint32_t ccr_val; static uint32_t s_prev_ccr = 0; if (htim->Instance != TIM2) { return; } ccr_val = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); /* 处理定时器溢出:周期 = (当前CCR - 上次CCR) + 溢出次数 * 65536 */ g_capture_period_cnt = ccr_val + s_overflow_cnt * 65536 - s_prev_ccr; s_prev_ccr = ccr_val; s_overflow_cnt = 0; /* 转换为频率,计数时钟1MHz,单位Hz */ if (g_capture_period_cnt > 0) { g_signal_freq_hz = 1000000 / g_capture_period_cnt; } } /* 定时器溢出中断回调:每溢出一次,累加溢出计数 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { s_overflow_cnt++; } }这个实现里有一个关键设计:溢出计数在捕获回调里被清零,而溢出中断在每次 CNT 从 0xFFFF 回到 0 时递增。只有当计数器溢出发生在两次捕获之间时,溢出计数才会被计入周期。这个设计保证低频信号测量时不会因为 CNT 回绕而算出错误的周期。
代码里的逻辑链是这样的:第一步,上升沿到来时,硬件自动把 CNT 值锁存到 CCR1;第二步,中断服务程序读到 CCR 值,结合溢出计数算出完整周期;第三步,用预分频后的计数时钟频率除以周期得到信号频率。即使发生了溢出,周期公式也成立,因为溢出一次代表 CNT 多走了 65536 个数。
注意:如果不需要处理低于 15Hz 的信号,可以把溢出计数相关代码去掉,只保留最简单的差值计算,代码会更简洁。低频场景才需要溢出处理。
4.3 定时器溢出处理:把低频信号测稳的关键一步
定时器溢出是测周法实现里最隐蔽的坑。假设 PSC=71,计数时钟 1MHz,被测信号是 10Hz,周期是 100 毫秒。16 位计数器从 0 数到 65535 只需要 65.535 毫秒,所以从上一个上升沿到下一个上升沿之间,CNT 已经完整溢出了一次,甚至可能溢出多次。如果你只简单地做 CCR 差值计算,得到的结果会远小于真实周期,频率读数虚高。
处理溢出有两条路径。第一条是上节代码里的方案:在溢出中断里递增溢出计数,计算周期时把溢出部分乘以 65536 加回来。这个方案逻辑清晰,但要求溢出中断优先于捕获中断处理,否则可能出现在捕获中断发生时溢出计数还没来得及更新。第二条是在 DMA 模式下读取 CNT 和 CCR 的组合值,配合定时器从模式让硬件自动处理,工程复杂度高一些,适合更追求性能的场景。
判断是否需要溢出处理的公式很简单:最大可测周期 = ARR / 计数时钟频率。如果被测信号的最小频率对应周期大于这个值,就必须处理溢出。还有个细节是溢出中断和捕获中断的嵌套问题,在 HAL 库里两者都会回调到不同的函数,理论上没有嵌套关系,但在极低频率场景,两次捕获间隔时间内中断可能触发多次,溢出计数变量在 main 和中断之间共享时要加 volatile 限定,防止编译器优化导致读取到旧值。
另外注意一个容易翻车的地方:HAL_TIM_IC_CaptureCallback 在每次捕获都会触发,如果信号频率很高,比如 100kHz,中断每秒触发 10 万次,每次中断里的取模和除法运算会占用不少 CPU。这种场景下要么提高中断优先级但缩短中断服务程序时间,要么改用 DMA 方式批量读取捕获值,把计算挪到主循环或 DMA 传输完成中断里做。优先级配置建议:捕获中断优先级设为 2,溢出中断设为 1,溢出比捕获"更急",因为溢出计数错一次,周期就全错了。
5. 频率测量代码的5个常见坑:现象、原因、解决办法
5.1 高频读数乱跳?先查预分频和定时器位数
现象是给 500kHz 方波,读数在 470kHz 到 530kHz 之间毫无规律地跳。原因有两个:第一,PSC=71 时计数时钟只有 1MHz,500kHz 信号周期是 2 微秒,计数器只数到 2 个,任何一个计数误差都是 50% 级别,无法用软件"滤掉";第二,ICFilter 如果开了滤波,边沿会被延迟几个采样周期,而延迟时间与采样时钟周期不是整数关系,造成每次捕获偏差不一致。
解决思路是提升计数分辨率。把 PSC 改为 0,计数时钟就是 72MHz,500kHz 信号周期对应 144 个计数,分辨率达到 0.7%。这是 16 位定时器在 72MHz 时钟下能达到的上限。如果信号频率更高,就要评估用 32 位定时器,或者切换到 M 法测频。另一个实际经验是,高频测量时中断处理本身的延迟也会影响读数,可以用逻辑分析仪对比输入波形和软件输出的频率值来确认是不是中断延迟在捣鬼。
5.2 越低频越不准?溢出计数没清零
现象是测 5Hz 信号时,频率读数在 4.2Hz 到 6.8Hz 之间跳变,而且没有规律。原因是我见过的最常见翻车姿势:溢出计数在捕获回调里递增了,但没在正确的位置清零。如果溢出计数在上一次捕获时没有归零,当前周期计算会把上一次的溢出次数也叠加进来,导致周期偏大、频率偏小。
解决要点是:溢出计数只服务于"当前这次捕获"的周期计算,因此在读到 CCR 值之后必须立即清零。正确写法见 4.2 节的代码,在算完周期后执行 s_overflow_cnt = 0。如果你在多个地方读取这个变量,还要防止编译器优化造成读取顺序错乱,建议用 volatile 修饰。另外检查一下你的捕获回调是否在溢出中断之前触发,因为如果捕获先于溢出,清零动作会把尚未计入周期的溢出次数吞掉。
5.3 边沿毛刺导致计数翻倍
现象是测量 1kHz 方波,偶尔读到 500Hz 或者 333Hz,读数是信号真实频率的整分数倍。原因是上升沿附近存在毛刺,触发了一次额外的捕获,把一次周期分成了两段,差值变短,频率翻倍;反之,如果毛刺导致某次边沿没有被识别,两次捕获间隔变长,频率变成了原来的 1/2 或 1/3。
解决办法分硬件和软件两层。硬件上,串一个小电阻到引脚,配合引脚电容形成低通滤波,把窄毛刺削掉;或者用 74HC14 这类施密特触发器芯片做整形。软件上,打开输入捕获的 ICF 数字滤波,ICFilter 配置为 0b0011(8 次采样确认)级别即可,大部分宽度在微秒级别的毛刺都会被滤掉。要注意的是滤波打开后边沿会有延迟,如果信号本身频率就很高,延迟导致的误差会超过你能接受的阈值,这时必须靠硬件整形解决。
5.4 输入引脚读不到信号?施密特触发器和上下拉没弄对
现象是信号源明明有输出,示波器在输入端能看到波形,但定时器就是捕获不到边沿。常见原因有三个:第一,GPIO 没有配置为输入模式,或者复用功能没有使能,导致引脚内部连接的不是定时器输入;第二,信号是开漏输出,需要在引脚上加外部上拉电阻才能产生正常的上升沿;第三,信号电平刚好落在 STM32 输入阈值附近,受噪声影响时而识别为高、时而识别为低。
排障顺序很重要。先用 STM32CubeMX 检查 GPIO 配置有没有勾选输入捕获对应的复用功能,再用万用表量引脚静态电平是否接近 0V 或 3.3V,最后用示波器看边沿上升时间是否过长。如果是上升沿太缓,可以先把引脚的上下拉电阻打开,让静态电平偏置到确定一侧,再用整形电路处理。三个地方都查完,基本能定位问题。
5.5 HAL_TIM_IC_Start_IT 之后没反应?中断优先级和使能时机
现象是代码写完了,信号也接了,但频率值永远是 0。可能的原因集中在中断配置上:定时器中断在 NVIC 里没有使能,或者优先级配置成 15(被其他中断屏蔽),或者 HAL_TIM_IC_Start_IT 在初始化之后又在其他代码里被 HAL_TIM_IC_Stop 停掉了。
建议检查顺序如下:第一,确认调用了 HAL_TIM_IC_Start_IT,而不是 HAL_TIM_IC_Start——后者只使能捕获,不使能中断;第二,确认在启动函数之后调用了 HAL_NVIC_EnableIRQ 或 CubeMX 生成的配置已生效;第三,确认中断回调函数有正确实现,并且函数名拼写没有写错。调试时还可以在回调函数入口放一个翻转 GPIO 的操作,用示波器看有没有触发,快速判断中断路径是否通畅。
6. 进阶:多通道测频、编码器测速和精度验证方法
6.1 用编码器模式测正交信号转速
测频率代码的常见进阶需求是测量旋转编码器的转速。增量式编码器输出 A 相和 B 相两路正交方波,每转一圈产生固定数量的脉冲,转速等于单位时间内的脉冲数除以每转脉冲数。用定时器编码器模式可以让硬件自动处理 A/B 相的相位关系,CNT 在每对边沿自动增减,代码只需要定时读取 CNT 值就能得到位置和方向。
/* 定时器编码器模式配置:TIM3_CH1 + TIM3_CH2 */ static void MX_TIM3_Encoder_Init(void) { TIM_Encoder_InitTypeDef sEncoderConfig = {0}; htim3.Instance = TIM3; htim3.Init.Prescaler = 0; htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 0xFFFF; htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_Encoder_Init(&htim3, &sEncoderConfig); /* 计数模式:A和B两相交替计数,4倍频 */ sEncoderConfig.EncoderMode = TIM_ENCODERMODE_TI1FP1; HAL_TIM_Encoder_Init(&htim3, &sEncoderConfig); HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL); }编码器模式下代码量比输入捕获更少,因为硬件处理了相位和计数细节。转速计算需要在固定时间基准上读取 CNT 差值,比如用 SysTick 每 100ms 读一次 CNT,差值乘以 10 再除以每转脉冲数就得到每秒转数。这个方案在电机控制里很常用,比纯输入捕获的抗抖动能力强,计数器自动处理 A/B 相边沿的时序关系,不需要在中断里做任何边沿逻辑。
6.2 用信号发生器和数据对比验证测量精度
代码写完先别急着接实际传感器,用信号发生器先做一轮精度验证是最省时间的做法。我习惯的流程是:信号发生器从 1Hz 到 100kHz 按 1-2-5 步进输出方波,每个频率点记录 STM32 读数和信号发生器设定值的对比。这个验证的意义在于找出系统误差的分布规律,而不是只看单个点是否准。
对比时注意三类偏差的区分:固定比例偏差、固定绝对值偏差和无规律偏差。固定比例偏差通常是计数时钟不准,需要用外部高精度时钟源校准或者接受这个系统误差;固定绝对值偏差来自于滤波器的延迟或者中断响应时间;无规律偏差才需要排查噪声和毛刺。记录数据时用表格整理,频率点、设定值、读数、偏差、偏差百分比,五列数据足够定位绝大多数问题。
验证完成后,如果测量范围内误差都控制在应用可接受的阈值内,代码就可以投入使用。如果高频段偏差超限,我的经验是优先检查计数时钟是否溢出、中断响应时间是否不一致;低频段偏差超限则优先查溢出计数和清零时机。这套排查逻辑比盲目调参数管用得多。
最后一个习惯分享:我在频率测量这类代码里一定会留一个调试模式,用一个定时器输出已知频率的方波,自测输入捕获链路是否正常。这样每次改完代码都能在十几秒内确认测量逻辑没有回归问题。这个自检流程帮我在一次改 PSC 之后迅速发现了频率值整体偏移的问题,省下了大量排障时间。希望这个习惯对你也有用。
本文还有配套的精品资源,点击获取