简介:面向STM32嵌入式开发者及电子类学生的频率测量代码包,围绕定时器输入捕获实现外部脉冲与时钟信号的频率检测,可服务于课程实验、信号源测试和传感器脉冲读取等场景。压缩包共125个文件,约1.87MB,以c/h源码、o/d编译输出、crf浏览信息以及Keil工程与目标配置文件为主,便于源码阅读、编译复现和工程调试。已有2147人学习下载,具备一定参考热度。内容串联定时器模式与时基配置、输入捕获通道选择、中断服务、捕获值读取、周期与频率换算,并给出初始化与中断处理示例;读者可据此理解捕获链路、搭建测试工程,再通过调整预分频、滤波和多次采样求平均提升测量精度与稳定性。
1. 硬件板子接上霍尔传感器,频率读数从 1 跳到 1000
某开发者拿了块 STM32F103 最小系统板,把一个霍尔转速传感器接到 PA0,想测电机转速。代码照抄例程,串口打印出来的频率值却在 1、30、800、1000 之间毫无规律地跳。他第一反应是中断优先级没配好,反复改 NVIC 折腾了两天,最后发现问题根本不在代码,而在选错了测频路线——低频信号用测周法,1 秒才来一个边沿,程序却按 1 毫秒的节奏去换算,中间还夹着溢出、滤波和浮点截断。
STM32 测频率这件事,难点从来不是敲几行 HAL 库函数,而是先搞清楚信号在什么频段、要求多大精度、手上还剩哪个定时器,然后才决定走测周法还是测频法。下面这份 STM32 测频代码拆解,按方案选型、输入捕获实现、高频段计数器、避坑排查、组件化改造五步走,每步都落到可以抄进工程的代码块上。
2. 方案选型:三条测频路线与它们各自的天花板
STM32 上测频率,常见就三条路线:测周法、测频法、等精度法。选错了不是精度差一点,而是读数直接没法用。这一章先把三种方法的误差模型推导清楚,再对照定时器资源决定用哪个外设。
2.1 测周法与测频法的误差模型
测周法的思路是量一个周期有多长。定时器用标准时钟 f_clk 计数,捕获两个相邻上升沿之间的计数值 N,被测周期 T = N / f_clk,频率 f = f_clk / N。它的相对误差来自量化误差,±1 个计数:
δ_测周 = 1 / N = f_x / f_clk输入频率越低,N 越大,误差越小。所以测周法天然适合低频信号。1MHz 时基下测 1kHz,N = 1000,误差 0.1%;测 10kHz,N = 100,误差就到了 1%。
测频法则反过来,在固定的闸门时间 T_g 内数脉冲个数 N,f = N / T_g,相对误差:
δ_测频 = 1 / N = 1 / (f_x · T_g)频率越高,N 越大,误差越小。1 秒闸门测 1MHz,误差 1ppm;测 100Hz,N 只有 100,误差 1%。
两条曲线会在某一点交叉,令两个误差相等:
f_x / f_clk = 1 / (f_x · T_g) → f_x = sqrt(f_clk / T_g)1MHz 时基、1 秒闸门,分界点正好是 1kHz。这就是为什么很多测频例程会把 1kHz 当切换阈值。实际工程里我会把阈值设成分界点的 2~3 倍,因为交叉点附近两种方法的误差都不小。
| 标准时钟 f_clk | 闸门 T_g | 分界频率 | 1Hz 输入测周误差 | 1MHz 输入测频误差 |
|---|---|---|---|---|
| 1MHz | 1s | 1kHz | 1000ppm(不可用) | 1ppm |
| 1MHz | 0.1s | 3.16kHz | 1000ppm | 10ppm |
| 10MHz | 0.1s | 10kHz | 100ppm | 10ppm |
| 72MHz | 1s | 8.5kHz | 14ppm | 1ppm |
注意最后一行,把定时器时钟 72MHz 直接当标准时钟用,测周法的分辨率能到 14ns,低频端精度会上一个台阶。代价是 16 位定时器在 72MHz 下只能计 0.9ms 就溢出,超过 1.1kHz 的信号必须做溢出扩位,这个后面细讲。
2.2 定时器资源对照:输入捕获、外部计数、从模式
STM32 的通用定时器和高级定时器能干的活不太一样,选方案前先看手上剩什么:
输入捕获(Input Capture)把通道配置成 TIx,信号边沿到来时硬件把 CNT 快照进 CCR,配合中断或 DMA 读取。这是测周法的标准实现,几乎每个定时器都支持,精度取决于定时器时钟和滤波器配置。
外部时钟模式 2(ETR 或 TIx 作为外部时钟)把外部脉冲直接当定时器的计数时钟,CNT 每来一个脉冲加一。这就是硬件计数器,CPU 完全不参与,测频法用它数脉冲最省事,上限能跑到几 MHz 甚至十几 MHz。
从模式(Slave Mode)让一个定时器被另一个定时器或外部信号触发/门控,包括门控模式(Gated)、触发模式(Trigger)、复位模式(Reset)。做硬件闸门和等精度测频全靠它,作用是省掉软件清零带来的丢脉冲问题。
还有一条容易被忽略的路:比较器 + 定时器。STM32G4、L4 系列内置比较器,信号先进比较器整形再送给定时器,对小幅值正弦信号(比如互感器出来的信号)比直接接 GPIO 靠谱得多,避免了输入不到 VIL/VIH 导致的漏计。
2.3 选型表:按信号频率和精度要求对号入座
把上面推导落到一张表上,接线前先对一下:
| 信号频率 | 推荐方案 | 占用资源 | 典型误差 | 备注 |
|---|---|---|---|---|
| 0.1Hz ~ 1kHz | 测周法,输入捕获 | 1 个 TIM 通道 + 1 个定时器 | < 0.1% | 需处理超时,信号断了要清零 |
| 1kHz ~ 200kHz | 测周法 + 溢出扩位 | 1 个 TIM 通道 | 0.01% ~ 0.1% | 16 位定时器必做扩位 |
| 200kHz ~ 5MHz | 测频法,外部计数 + 1s 闸门 | 2 个 TIM | 1ppm ~ 100ppm | 闸门要硬件门控,别软件清零 |
| 5MHz 以上 | 外部计数 + 缩短闸门 | 2 个 TIM | 取决于闸门 | 注意 IO 翻转速度和滤波上限 |
| 全频段高精度 | 等精度法 | 2 个 TIM | 与频率无关 | 闸门时间跟着被测信号走 |
如果只是测个电机转速、风扇转速,信号频率大多在几十 Hz 到几 kHz,输入捕获一条路走到底就够了。要做音频分析、谐振点扫描这种宽频段任务,才值得上等精度法。
3. 输入捕获核心链路:TIM 配置、中断回调与脉宽换算
这一章把测周法从引脚配置写到对外接口,代码基于 HAL 库,型号以 STM32F103 为例,换到 G0、G4 系列只需要改时钟使能和复用编号。
3.1 引脚复用与定时器通道映射
先确认引脚能接到哪个定时器通道。STM32F103C8T6 上 PA0 可以复用为 TIM2_CH1、TIM5_CH1 和 TIM2_ETR,查数据手册的「复用功能映射表」能确认。选 TIM2 是因为它是 32 位定时器(F1 系列上是 16 位,F4/F7/G4 的 TIM2、TIM5 才是 32 位,这点必须按型号确认),32 位能省掉溢出扩位,代码量少一半。
/* 引脚初始化:PA0 复用为 TIM2_CH1,下拉输入防止信号线悬空时乱触发 */ static void Capture_GpioInit(void) { __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitTypeDef gpio = {0}; gpio.Pin = GPIO_PIN_0; gpio.Mode = GPIO_MODE_AF_PP; /* 复用推挽,信号由外部传感器驱动 */ gpio.Pull = GPIO_PULLDOWN; /* 关键:悬空时钳到低电平,避免噪声误触发 */ gpio.Speed = GPIO_SPEED_FREQ_HIGH; /* 高频信号必须选高速档,否则边沿被削平 */ gpio.Alternate = GPIO_AF1_TIM2; /* F1 系列 TIM2 的复用编号是 1,其他系列查手册 */ HAL_GPIO_Init(GPIOA, &gpio); }Pull这一项很多人抄例程时留成GPIO_NOPULL,实验室里信号源一直连着没问题,一拿到现场,传感器线一断读数立刻乱跳。下拉还是上拉取决于信号的空闲电平:霍尔传感器开漏输出一般空闲为高,配下拉;推挽输出空闲为低,配上拉。方向反了不会烧,但边沿检测会多出一倍的毛刺。
3.2 捕获初始化:预分频、滤波系数、极性
定时器配置里最需要想清楚的是三个参数:预分频、输入滤波、捕获极性。
TIM_HandleTypeDef htim2; void Freq_Init(uint32_t tim_clk_hz, uint16_t prescaler, uint8_t ic_filter) { __HAL_RCC_TIM2_CLK_ENABLE(); htim2.Instance = TIM2; htim2.Init.Prescaler = prescaler - 1; /* 计数节拍 = tim_clk_hz / prescaler */ htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 0xFFFF; /* 16 位满量程,靠溢出中断扩位 */ htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; HAL_TIM_IC_Init(&htim2); TIM_IC_InitTypeDef ic = {0}; ic.ICPolarity = TIM_ICPOLARITY_RISING; /* 只测上升沿,量的是完整周期 */ ic.ICSelection = TIM_ICSELECTION_DIRECTTI; /* 信号直连 TI1,不经过交叉开关 */ ic.ICPrescaler = TIM_ICPSC_DIV1; /* 每个边沿都捕获,不分频 */ ic.ICFilter = ic_filter; /* 0~15,越大抗噪越强、延迟越大 */ HAL_TIM_IC_ConfigChannel(&htim2, &ic, TIM_CHANNEL_1); HAL_TIM_IC_Start_IT(&htim2, TIM_CHANNEL_1); }预分频决定了计数节拍。假设定时器时钟 72MHz,prescaler = 72,计数节拍就是 1MHz,一个计数代表 1μs,算频率时直接用 1000000 / ticks 就行,不用记 72 这个数。如果想追求更高分辨率,prescaler = 1,节拍 72MHz,分辨率 13.9ns,但 16 位计数器 0.9ms 就溢出,只适合 1kHz 以上的信号。
输入滤波ICFilter是最容易被忽略又最容易翻车的参数。它的原理是:定时器以 f_DTS 的频率对输入采样,连续 N 次采样一致才认这个边沿。滤波系数 6 对应需要连续 8 个采样点一致,在 72MHz 的 f_DTS 下大约 111ns。如果被测信号周期的一半小于这个时间,边沿就会被吃掉。所以高频信号要么减小滤波系数,要么直接把 f_DTS 降下来(通过 CKD 位)。反过来,如果信号有振铃或叠加了开关噪声,系数就要往上调,常见做法是先配 6,现场再按波形调整。
3.3 溢出扩位:16 位拼 32 位的临界区写法
16 位定时器测低频信号一定会溢出。1MHz 节拍下,65536 个计数只有 65.5ms,测 10Hz 信号(周期 100ms)中间必然跨一次溢出。这时要同时记录「溢出了几次」和「当前计数值」,拼成一个 32 位数。
static volatile uint32_t s_ovf_cnt; /* 溢出次数,由更新中断维护 */ static volatile uint32_t s_last_ext; /* 上一次捕获的 32 位扩展值 */ static volatile uint32_t s_period_ticks; /* 最近一次测得的周期,单位:计数节拍 */ static volatile uint8_t s_have_edge; /* 是否已经捕获到第一个边沿 */ static volatile uint8_t s_timeout; /* 超过预期时间没有新边沿,置位 */ /* 更新中断:每次计数器溢出,溢出次数加一 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { s_ovf_cnt++; } } /* 捕获中断:读 CNT,拼成 32 位,作差得到周期 */ void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance != TIM2) return; if (htim->Channel != HAL_TIM_ACTIVE_CHANNEL_1) return; /* 读两次溢出计数:前后一致才说明读 CNT 期间没被更新中断打断 */ uint32_t ovf_a = s_ovf_cnt; uint32_t raw = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); uint32_t ovf_b = s_ovf_cnt; if (ovf_a != ovf_b) { /* 被打断了,重新读一次,最多重试一次,极端情况下丢弃这次捕获 */ raw = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); ovf_a = ovf_b; } uint32_t now = (ovf_a << 16) | raw; /* 拼成 32 位扩展计数值 */ if (s_have_edge) { uint32_t diff = now - s_last_ext; /* 无符号减法,天然处理回绕 */ /* 上限过滤:超过 2^20 个节拍(1MHz 下约 1 秒)认为信号中断 */ if (diff > 0 && diff < (1UL << 20)) { s_period_ticks = diff; s_timeout = 0; } } s_last_ext = now; s_have_edge = 1; }这里有两个细节值得说。第一,diff = now - s_last_ext用的是无符号减法,即使计数器从 65535 回绕到 0,算出来的差值也是正的,不需要额外判断借位。第二,读溢出计数和读 CNT 之间可能被更新中断插入,导致溢出数多算一次或少算一次,表现就是偶尔跳出一个 65536 倍周期的错误值。上面的读两次比对是最省事的处理方式;要求更高的话,可以在更新中断里给溢出计数打一个序号,捕获时校验序号,或者干脆用 32 位定时器彻底绕开这个问题。
3.4 从计数值到赫兹:定点换算与对外接口
算频率这一步,很多例程直接写freq = 1000000.0f / ticks,Cortex-M3 没有硬件浮点,一次除法加转换要几百个周期,放在 1ms 定时任务里跑,CPU 占用会很难看。用定点运算更合适。
/* 返回 Q16.16 格式的频率值,实际频率 = 返回值 / 65536.0f */ uint32_t Freq_GetHz_Q16(uint32_t tick_hz) { uint32_t ticks; /* 临界区保护:ticks 可能被捕获中断改写,读出半新半旧的值 */ __disable_irq(); ticks = s_period_ticks; __enable_irq(); if (ticks == 0 || s_timeout) return 0; /* f = tick_hz / ticks,左移 16 位保留小数,用 64 位中间量防溢出 */ uint64_t num = ((uint64_t)tick_hz << 16); return (uint32_t)(num / ticks); } /* 整数版本,只要整数赫兹时用,返回 0 表示信号无效 */ uint32_t Freq_GetHz(uint32_t tick_hz) { uint32_t q16 = Freq_GetHz_Q16(tick_hz); return (q16 + 32768U) >> 16; /* 加半个单位做四舍五入 */ }参数tick_hz就是定时器的计数节拍频率,等于定时器时钟除以预分频。把它作为参数传进来而不是写死,是为了同一份代码能复用到不同定时器上——TIM3 跑 1MHz、TIM4 跑 72MHz,调用时传不同值即可。
s_timeout这个标志由外部维护,常见做法是在 1ms 的 SysTick 任务里判断:如果s_have_edge为 1 但连续 500ms 没有新的捕获事件,就置位s_timeout并把s_period_ticks清零。否则信号一断,函数会一直返回最后一次测到的旧值,上位机看到的就是个「卡住的频率」。
对外接口再包一层:
/* 上层应用直接调用,不知道底层用的是捕获还是计数 */ uint32_t Freq_Measure(void) { return Freq_GetHz(1000000U); /* 当前工程用 1MHz 计数节拍 */ }4. 高频段与等精度:外部计数器、闸门与自动切档
输入捕获的上限受限于滤波时间和中断开销,信号跑到几百 kHz 以上就该换测频法了。这一章讲外部计数器怎么配、闸门怎么用硬件产生、以及等精度法怎么把两个频段的精度拉平。
4.1 外部时钟模式 2 的配置
外部时钟模式 2 让定时器把外部脉冲当作计数时钟,每个上升沿 CNT 加一,全程硬件完成。配置要点是通道要先配成输入,再把时钟源切到 ETR 或 TIx。
TIM_HandleTypeDef htim3; void Counter_Init(uint16_t period) { __HAL_RCC_TIM3_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); /* PA6 = TIM3_CH1,外部脉冲从这里进 */ GPIO_InitTypeDef gpio = {0}; gpio.Pin = GPIO_PIN_6; gpio.Mode = GPIO_MODE_IN_FLOATING; gpio.Pull = GPIO_PULLDOWN; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &gpio); htim3.Instance = TIM3; htim3.Init.Prescaler = 0; /* 外部脉冲不分频,来一个计一个 */ htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = period - 1; /* 计满 period 个脉冲触发更新中断 */ htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; HAL_TIM_Base_Init(&htim3); TIM_ClockConfigTypeDef clk = {0}; clk.ClockSource = TIM_CLOCKSOURCE_TI1; /* TI1 作为外部时钟源 */ clk.ClockPolarity = TIM_CLOCKPOLARITY_RISING; clk.ClockPrescaler = TIM_CLOCKPRESCALER_DIV1; clk.ClockFilter = 6; /* 抖动大的信号把滤波调大 */ HAL_TIM_ConfigClockSource(&htim3, &clk); HAL_TIM_Base_Start_IT(&htim3); }Period这里不是固定的 65535,而是故意设成一个较小的值(比如 1000),让定时器每计满 1000 个脉冲就中断一次。这个中断不干别的,只用来读标准时钟,这就是 4.3 节等精度法的核心。
4.2 硬件门控:用 PWM 当闸门,避免软件清零丢脉冲
最朴素的测频法是:开一个 1 秒定时器,中断里读 CNT 然后清零。这个做法有个隐蔽的丢脉冲问题——读 CNT 和写 CNT 之间那几个时钟周期里进来的脉冲会丢,1MHz 信号下每次丢两三个,相对误差 2~3ppm,看着不大,但累积多次就很烦。更麻烦的是中断响应抖动,导致实际闸门时间不是精确的 1 秒。
解决办法是让硬件自己开关闸门。用另一个定时器输出一个占空比 50%、周期 2 秒的 PWM,高电平 1 秒就是闸门。被测计数器配置成门控从模式,只有闸门为高时才累加,全程无软件参与。
/* 闸门信号源:TIM4_CH1 输出 0.5Hz、50% 占空比,高电平 1 秒 */ void Gate_PwmInit(void) { __HAL_RCC_TIM4_CLK_ENABLE(); htim4.Instance = TIM4; htim4.Init.Prescaler = 7200 - 1; /* 72MHz / 7200 = 10kHz */ htim4.Init.Period = 20000 - 1; /* 10kHz / 20000 = 0.5Hz */ htim4.Init.CounterMode = TIM_COUNTERMODE_UP; htim4.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; HAL_TIM_PWM_Init(&htim4); TIM_OC_InitTypeDef oc = {0}; oc.OCMode = TIM_OCMODE_PWM1; oc.Pulse = 10000; /* 高电平 1 秒 */ oc.OCPolarity = TIM_OCPOLARITY_HIGH; HAL_TIM_PWM_ConfigChannel(&htim4, &oc, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim4, TIM_CHANNEL_1); /* 把更新事件映射到 TRGO,供从定时器作为门控源 */ TIM_MasterConfigTypeDef mc = {0}; mc.MasterOutputTrigger = TIM_TRGO_UPDATE; mc.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(&htim4, &mc); } /* 被测计数器:TIM3 外部计数,同时被 TIM4 门控 */ void Gate_CounterInit(void) { TIM_SlaveConfigTypeDef sl = {0}; sl.SlaveMode = TIM_SLAVEMODE_GATED; /* 门控模式:闸门高才计数 */ sl.InputTrigger = TIM_TS_ITR2; /* 内部触发线,TIM3 与 TIM4 的对应关系查参考手册 */ HAL_TIM_SlaveConfigSynchro(&htim3, &sl); }sl.InputTrigger这个参数不能拍脑袋填。每颗芯片的「定时器内部触发连接表」列了 ITR0~ITR3 分别接哪个定时器,F103 上 TIM3 的 ITR2 一般对应 TIM4_TRGO,换型号必须重查。填错的后果是定时器完全不计数,CNT 一直是 0,第一次调试很容易误以为是接线问题。
门控结束后,在闸门下降沿中断里读 CNT,此时硬件已经停止累加,读数稳定,直接就是 1 秒内的脉冲数,数值上等于频率:
/* TIM4 的捕获中断:闸门下降沿,说明 1 秒计数窗口结束 */ void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM4) { s_gate_result = __HAL_TIM_GET_COUNTER(&htim3); /* 1 秒内的脉冲数 = Hz */ __HAL_TIM_SET_COUNTER(&htim3, 0); /* 此时闸门已关,清零不会丢脉冲 */ } }清零动作放在闸门关闭之后做,就没有丢脉冲的问题了。这个思路和软件定时器读后清零的区别,就像用秒表掐时间对比看着墙上时钟数秒——前者起止点由硬件锁死,后者每次都有人的反应延迟。
4.3 等精度测频:让闸门跟着被测信号走
测周法低频准、测频法高频准,等精度法的思路是让闸门时间由被测信号自己决定,再用标准时钟去量这个闸门到底有多长,这样精度只跟标准时钟有关,跟被测频率无关。
原理是:被测信号计满 N 个脉冲作为闸门,同时用标准时钟计出这段时间的计数值 M。实际闸门时间 T = M / f_std,被测频率 f = N / T = N · f_std / M。
实现上用两个定时器接力。TIM2 配成 32 位自由运行、1MHz 节拍当标准时钟;TIM3 外部计数被测脉冲,每计满 1000 个触发一次更新中断,中断里读 TIM2 的值作差。
/* TIM2:1MHz 自由运行,32 位型号上 Period 设 0xFFFFFFFF */ void StdTimer_Init(void) { htim2.Instance = TIM2; htim2.Init.Prescaler = 72 - 1; /* 72MHz / 72 = 1MHz,1 个计数 = 1us */ htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 0xFFFFFFFF; HAL_TIM_Base_Init(&htim2); HAL_TIM_Base_Start(&htim2); } /* TIM3 更新中断:每 1000 个被测脉冲进来一次 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { uint32_t now = __HAL_TIM_GET_COUNTER(&htim2); /* 标准时钟快照 */ uint32_t dt = now - s_last_std; /* 无符号减法,处理回绕 */ s_last_std = now; if (dt > 0) { /* f = N / (dt / 1e6) = 1000 * 1e6 / dt = 1e9 / dt */ s_freq_q16 = (uint32_t)(((uint64_t)1000000000ULL << 16) / dt); } } }这里 N 固定为 1000,dt是这 1000 个脉冲跨越的标准时钟微秒数。1MHz 被测信号,dt ≈ 1000,误差 1/1000 = 0.1%;1Hz 被测信号,dt ≈ 1000000,误差 1/1000000 = 1ppm。两端精度拉平,这就是等精度的意义。
代价是低频时测量时间变长。测 1Hz 信号要等 1000 秒才出一次结果,实际工程里会把 N 做成自适应:先粗测一下频率量级,低频时把 N 降到 10 甚至 1,高频时把 N 提到 10000。
4.4 自动切档与迟滞区间
一个模块要同时覆盖 1Hz 到 5MHz,就得在测周法和测频法之间自动切换。切换逻辑本身不难,难的是加迟滞,否则频率在阈值附近抖动时,两套算法来回切,读数会跳得比不切还厉害。
typedef enum { MODE_CAPTURE = 0, MODE_COUNTER } freq_mode_t; static freq_mode_t s_mode = MODE_CAPTURE; static uint8_t s_switch_cnt; /* 连续越界次数,防抖 */ /* 在 100ms 周期任务里调用 */ void Freq_AutoSwitch(void) { uint32_t f = Freq_GetHz(1000000U); if (s_mode == MODE_CAPTURE) { /* 高于 1.5kHz 连续 5 次才切到计数模式 */ if (f > 1500U) { if (++s_switch_cnt >= 5) { s_switch_cnt = 0; s_mode = MODE_COUNTER; Counter_ModeStart(); } } else { s_switch_cnt = 0; } } else { /* 低于 800Hz 连续 5 次才切回捕获模式 */ if (f < 800U) { if (++s_switch_cnt >= 5) { s_switch_cnt = 0; s_mode = MODE_CAPTURE; Capture_ModeStart(); } } else { s_switch_cnt = 0; } } }切到上档阈值 1500Hz、切回下档阈值 800Hz,中间留了 700Hz 的死区。这段死区就是迟滞区间,信号在 1kHz 附近晃的时候,模式稳定不跳。连续 5 次越界才动作,是为了过滤掉单次异常读数——比如信号线被碰了一下,某一帧读到个 3000Hz,不应该立刻切模式。
5. 避坑与排查:五个让读数乱跳的坑
下面五条都是调试测频代码时反复出现的,按「现象 → 原因 → 解决」写,遇到类似症状可以对号入座。
坑一:低频时读数在 0 和一个大数之间反复跳。
现象:测 5Hz 的霍尔信号,串口时而打印 0,时而打印 47000 多。
原因:测周法算周期时用 1MHz 节拍,5Hz 对应 200000 个计数,远超 16 位定时器的 65535。如果没有做溢出扩位,CNT 回绕后算出来的差值是个小数,倒数一取就成了几万赫兹的假值。而信号周期长的时候,如果主循环读到的s_period_ticks正好是初始值 0,Freq_GetHz里判断ticks == 0返回 0。
解决:确认溢出中断真的在跑。调试时在更新中断里翻转一个空闲 GPIO 用示波器看,低频信号下应该能看到规整的方波。另外把prescaler调大(比如 7200,节拍 10kHz),牺牲分辨率换量程,10kHz 节拍下 16 位计数器能测到 0.15Hz。
坑二:测到 400kHz 以上,读数比实际值低,而且越高掉得越多。
现象:信号源输入 500kHz,模块稳定显示 476kHz 左右;输入 1MHz,显示只有 800 多 kHz。
原因:输入捕获的滤波器把边沿吃掉了。滤波系数 6 需要连续 8 个 f_DTS 采样点保持一致才认边沿,72MHz 下约 111ns。500kHz 信号半周期 1μs,够用;但信号如果有轻微振铃,过零点附近会反复穿越阈值,采样点一致性被破坏,边沿就被判丢。另外 HAL 库的捕获回调本身有中断开销,高频时中断进得太频繁,CPU 来不及处理,也会漏掉捕获事件。
解决:高频段别用输入捕获,改用外部时钟模式 2 让硬件计数,CPU 只在闸门结束时读一次。如果一定要用捕获,把ICFilter降到 0~2,同时确认gpio.Speed配的是HIGH——低速档的驱动能力只有 2MHz 左右,边沿会被明显削平。
坑三:读数偶尔跳出一个恰好是正常值整数倍或几分之一的数。
现象:测 1kHz 信号,大部分时候显示 1000,每隔几秒跳一次 500 或 2000。
原因:捕获中断和更新中断的竞争。读溢出计数和读 CNT 之间如果正好来了一次更新中断,溢出数会多算一次或少算一次,拼出来的 32 位值差 65536,最终频率差 2 倍或 0.5 倍。
解决:按 3.3 节的写法读两次溢出计数做比对;或者把捕获配置成 DMA 模式,让硬件把 CNT 直接搬进内存,中断里只处理数据不读寄存器;最彻底的办法是换 32 位定时器,根本不存回绕问题。
坑四:函数返回值突然变成 0 或一个明显不合理的数。
现象:主循环调用Freq_GetHz,大部分时候正常,偶尔返回 0 或几兆赫兹。
原因:s_period_ticks是 32 位变量,Cortex-M3 对 32 位变量的读写是单指令原子的,但如果编译器优化后把它拆成两次 16 位访问,或者主循环读的时候正好被捕获中断改写,就会读到半新半旧的值。
解决:用临界区保护,__disable_irq()包住读取。注意临界区别包太长,只包住变量拷贝那两行。另外给这个变量加volatile修饰,否则编译器可能把它优化进寄存器,永远读到旧值。
坑五:同一路信号换一块板子读数差千分之几。
现象:两块板子接同一个信号源,一块显示 1000.0Hz,另一块显示 1004.2Hz。
原因:时钟源不同。一块板用的外部 8MHz 晶振,另一块没焊晶振跑了内部 HSI。HSI 出厂校准后精度约 ±1%,全温度范围能到 ±2%,而普通无源晶振精度在 ±20ppm 到 ±50ppm 之间,差了两个数量级。
解决:测频这种对时基敏感的应用必须用 HSE。如果硬件上没有晶振,只能在软件上做单点标定:用一个已知频率的信号源测一次,算出比例系数存进 Flash,每次读数乘这个系数。标定只能修正系统偏差,修不了温漂,宽温环境还是要上晶振或者 TCXO。
6. 二次开发:把测频模块抽成可复用组件
工程里第一次写完测频代码,往往是直接塞在 main.c 里,变量全局,中断回调写死。等第二个项目要用,复制粘贴一遍改引脚,改着改着就乱了。我的做法是抽成三层:freq_port.c只管硬件初始化,freq_core.c管算法和状态机,freq_api.c对外暴露三个函数。
对外接口就这三个:
/* 返回当前频率,Q16.16 格式;0 表示信号无效 */ uint32_t Freq_ReadQ16(void); /* 返回信号是否有效(连续 500ms 内有边沿) */ uint8_t Freq_IsSignalValid(void); /* 注册回调,频率更新时通知上层,避免上层轮询 */ void Freq_SetCallback(void (*cb)(uint32_t freq_q16));最值得说的改造点是自环自测。调试测频代码时,手上不一定有信号发生器,但每块板子都有定时器。让一个定时器输出已知频率的 PWM,用杜邦线接到捕获引脚上,就得到了一个完全
本文还有配套的精品资源,点击获取