文章目录
-
- 一、为什么测个 PWM 也要认真选型
- 二、输入捕获测频原理与方案对比
-
- 2.1 硬件是怎么"抓住"边沿的
- 2.2 三种测频方案怎么选
- 2.3 计算公式与误差来源
- 2.4 占空比为什么必须双沿捕获
- 三、硬件选型与信号源搭建
-
- 3.1 为什么用板载 TIM3 当信号源
- 3.2 器件清单与接线
- 四、CubeMX 关键配置与易错点
- 五、核心代码实现
-
- 5.1 捕获数据结构与中断入口
- 5.2 捕获回调:周期与高电平的合成
- 5.3 超时检测与中值滤波
- 5.4 结果计算与信号源初始化
- 六、测试验证
-
- 6.1 功能验证
- 6.2 全量程频率测试
- 6.3 占空比测试
- 6.4 理论值与实测值对照
- 七、一次完整的干扰排查记录
- 八、故障排查手册
- 九、总结
摘要:电机调速、开关电源、红外遥控解码等应用都需要精确测量 PWM 信号的频率与占空比,软件轮询方式在中高频段误差大、还会阻塞主循环。本文基于 STM32F103C8T6 与 HAL 库,用 TIM2 输入捕获的 PWM 输入模式实现全硬件测频:1 MHz 计数时钟配合溢出计数与中值滤波,量程覆盖 10 Hz~100 kHz。实测结果显示,10 Hz~50 kHz 区间频率误差 ≤0.01%,100 kHz 时误差约 0.9%;占空比最大偏差 0.3%。文中记录了浮空输入拾取噪声导致读数跳动的完整排查过程,并给出测频法对比与预分频选择依据,附完整工程代码。
一、为什么测个 PWM 也要认真选型
做电机闭环或者电源反馈的时候,"读一下 PWM 频率"听起来是个五分钟的小活。真动手才发现坑不少:用延时轮询 GPIO 翻转来计时,在 1 kHz 以下还凑合,一上 10 kHz 误差就奔着 20% 去了,而且主循环被卡住,别的任务全停;用外部中断记两个上升沿的时间差,频率能测出来,占空比还得再加一路中断,代码越写越乱。
定时器的输入捕获(Input Capture)就是为这件事设计的:信号边沿到达的瞬间,硬件自动把当前计数值锁存进捕获寄存器,CPU 只需要在中断里读一下差值。整个过程由外设完成,不占主循环时间,边沿时刻也不会因为中断响应延迟而失真。
读完本文,你可以得到这些东西:
前置条件:了解 STM32 基本工程创建和中断机制,熟悉 C 语言基础;软件使用 STM32CubeIDE(HAL 库开发),硬件只需要一块 STM32F103C8T6 最小系统板和一根杜邦线。本文完整工程代码可在 CSDN 下载频道 获取(VIP 免费)。
二、输入捕获测频原理与方案对比
2.1 硬件是怎么"抓住"边沿的
定时器内部有一个自由运行的计数器(CNT),按计数时钟不停累加,到 ARR 自动重装回零。输入捕获通道把某个 GPIO 映射到定时器内部:当设定极性的边沿到来时,CNT 当前值被瞬间拷贝到 CCR 寄存器,同时置捕获标志、触发中断。于是"两个上升沿之间 CNT 走了多少"就是信号周期,"上升沿到下降沿走了多少"就是高电平时间——这一切不需要 CPU 掐着时间等。
整个信号链可以画成下面这张图:
#mermaid-svg-hpHEeCp08JjsRULd{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-hpHEeCp08JjsRULd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hpHEeCp08JjsRULd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hpHEeCp08JjsRULd .error-icon{fill:#552222;}#mermaid-svg-hpHEeCp08JjsRULd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hpHEeCp08JjsRULd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hpHEeCp08JjsRULd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hpHEeCp08JjsRULd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hpHEeCp08JjsRULd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hpHEeCp08JjsRULd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hpHEeCp08JjsRULd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hpHEeCp08JjsRULd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hpHEeCp08JjsRULd .marker.cross{stroke:#333333;}#mermaid-svg-hpHEeCp08JjsRULd svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hpHEeCp08JjsRULd p{margin:0;}#mermaid-svg-hpHEeCp08JjsRULd .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-hpHEeCp08JjsRULd .cluster-label text{fill:#333;}#mermaid-svg-hpHEeCp08JjsRULd .cluster-label span{color:#333;}#mermaid-svg-hpHEeCp08JjsRULd .cluster-label span p{background-color:transparent;}#mermaid-svg-hpHEeCp08JjsRULd .label text,#mermaid-svg-hpHEeCp08JjsRULd span{fill:#333;color:#333;}#mermaid-svg-hpHEeCp08JjsRULd .node rect,#mermaid-svg-hpHEeCp08JjsRULd .node circle,#mermaid-svg-hpHEeCp08JjsRULd .node ellipse,#mermaid-svg-hpHEeCp08JjsRULd .node polygon,#mermaid-svg-hpHEeCp08JjsRULd .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-hpHEeCp08JjsRULd .rough-node .label text,#mermaid-svg-hpHEeCp08JjsRULd .node .label text,#mermaid-svg-hpHEeCp08JjsRULd .image-shape .label,#mermaid-svg-hpHEeCp08JjsRULd .icon-shape .label{text-anchor:middle;}#mermaid-svg-hpHEeCp08JjsRULd .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-hpHEeCp08JjsRULd .rough-node .label,#mermaid-svg-hpHEeCp08JjsRULd .node .label,#mermaid-svg-hpHEeCp08JjsRULd .image-shape .label,#mermaid-svg-hpHEeCp08JjsRULd .icon-shape .label{text-align:center;}#mermaid-svg-hpHEeCp08JjsRULd .node.clickable{cursor:pointer;}#mermaid-svg-hpHEeCp08JjsRULd .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-hpHEeCp08JjsRULd .arrowheadPath{fill:#333333;}#mermaid-svg-hpHEeCp08JjsRULd .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-hpHEeCp08JjsRULd .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-hpHEeCp08JjsRULd .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hpHEeCp08JjsRULd .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-hpHEeCp08JjsRULd .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hpHEeCp08JjsRULd .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-hpHEeCp08JjsRULd .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-hpHEeCp08JjsRULd .cluster text{fill:#333;}#mermaid-svg-hpHEeCp08JjsRULd .cluster span{color:#333;}#mermaid-svg-hpHEeCp08JjsRULd div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-hpHEeCp08JjsRULd .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-hpHEeCp08JjsRULd rect.text{fill:none;stroke-width:0;}#mermaid-svg-hpHEeCp08JjsRULd .icon-shape,#mermaid-svg-hpHEeCp08JjsRULd .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hpHEeCp08JjsRULd .icon-shape p,#mermaid-svg-hpHEeCp08JjsRULd .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-hpHEeCp08JjsRULd .icon-shape .label rect,#mermaid-svg-hpHEeCp08JjsRULd .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hpHEeCp08JjsRULd .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-hpHEeCp08JjsRULd .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-hpHEeCp08JjsRULd :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
上升沿
下降沿
溢出计数
1s无新数据
外部PWM信号
GPIO输入上拉/下拉配置
TIMx_CH1 边沿检测
极性路由
IC1: CCR1 ← CNT
IC2: CCR2 ← CNT
捕获中断回调
更新中断
周期/高电平静态合成
中值滤波
频率/占空比输出
超时判定: 信号丢失
2.2 三种测频方案怎么选
测频在教科书里分 M 法(测频法)和 T 法(测周法),落到 STM32 上有三种典型实现:
| GPIO 轮询计时 | 软件查询电平翻转,HAL_GetTick 计时 | <1 kHz | 极高,阻塞主循环 | 高频误差大,无法同时干别的 |
| EXTI 外部中断测周期 | 两个上升沿中断里记时间差 | 10 Hz~10 kHz | 中 | 边沿密集时中断延迟引入误差,占空比要另搭一路 |
| 定时器输入捕获(本文) | 硬件锁存边沿时刻 | 10 Hz~100 kHz(可扩) | 低,只在捕获时进中断 | 高频时计数分辨率受限于计数时钟 |
这里有个关键认知:输入捕获的"复位模式"其实就是硬件实现的 T 法——每个有效边沿把 CNT 清零重新计数,CCR 里存的直接就是一个周期内的计数值,连减法都省了。T 法的精度只取决于计数时钟频率,所以把预分频调到让计数时钟尽量高(本例 1 MHz),低速段精度自然有保障。
相关阅读:《stm32学习总结:定时器(2)通用定时器之输入捕获(IC)》 — 输入捕获寄存器级的完整梳理
2.3 计算公式与误差来源
设计数时钟频率为 f_cnt(本例 1 MHz,即每计数 1 μs),N 为两次上升沿之间的计数差(含溢出折算),n_high 为上升沿到下降沿的计数差,则:
周期 T = N / f_cnt (s)
频率 f = f_cnt / N (Hz)
占空比 D = n_high / N × 100%
低频时周期超过 ARR 容量,需要把更新中断的溢出次数折算进去:
N = (256 × ovf_cnt + cur_IC1 − last_IC1) mod 65536
量化误差是这类方法的固有来源:每个边沿的锁存时刻有 ±1 计数的不确定性,周期测量经过两个边沿,最大误差是 ±2 计数。于是相对误差近似为:
δf/f ≈ 2 × f / f_cnt
代入本例参数:5 kHz 信号在 1 MHz 计数时钟下,理论误差上限约 2×5000/1000000 = 1%?不对——注意这里是 2 个计数、每计数 1 μs,周期 200 μs,所以是 2/200000×10⁶ 的写法容易搞混,直接用计数表达更清楚:±2 计数 ÷ 周期计数 2000 = 0.1%?我在实测对照表里按这个口径核对过,实际结论以第九章的理论对照表为准(量化误差随频率线性上升,这正是 100 kHz 时误差抬头的根本原因)。
2.4 占空比为什么必须双沿捕获
只捕上升沿能算出周期,但高电平时间必须知道下降沿时刻。两种做法:单通道在回调里反复翻转捕获极性(代码紧凑,但极性切换期间可能漏掉紧邻的边沿);PWM 输入模式,硬件把同一引脚同时路由到 IC1(上升沿)和 IC2(下降沿)两个通道(本文采用)。后者的代价是多占一个捕获通道,好处是两个沿的捕获互不干扰,50 kHz 以内工作稳定。
三、硬件选型与信号源搭建
3.1 为什么用板载 TIM3 当信号源
验证测频方案,首先得有一个"已知准确"的信号源。备选方案对比如下:
| 板载 TIM3 输出 PWM 自检(本文) | 0 | 与测量共用同一晶振,理论误差趋近 0 | 只能验证板上链路 |
| 外部函数发生器 | 数百元 | 高 | 两台设备晶振不同,对比时引入额外误差 |
| 另一块开发板 | 一块板子 | 中 | 仍有晶振偏差,接线多 |
自检方案最大的好处是:TIM3 和 TIM2 共用同一个 8 MHz 晶振倍频出来的 72 MHz 时钟,发出来的频率和测出来的频率理论上应当严格相等,任何偏差都暴露的是测量链路的问题而不是信号源的问题。这是做精度验证时很划算的一个技巧。
3.2 器件清单与接线
| 主控板 | STM32F103C8T6 最小系统板(蓝丸) | 捕获测量 + 信号源 |
| 信号输出 | PA6(TIM3_CH1,PWM 输出) | 自产测试信号 |
| 捕获输入 | PA0(TIM2_CH1) | 被测信号输入 |
| 调试串口 | PA9/PA10,USB 转 TTL,115200 8N1 | 打印测量结果 |
接线只有三根:PA6 → PA0 直连(同为 3.3 V 电平,无需电平转换)、USB 转 TTL 的 TX/RX 交叉接 PA10/PA9、双方 GND 互联。
⚠️ 两个容易忽视的点:一是测量外部真实信号时必须与信号源共地,否则捕获引脚参考电平漂移,边沿识别会乱;二是被测信号幅值超过 3.3 V 时必须先分压或加电平转换,直接接入可能损坏 IO 口(PA0 非 5V 容忍引脚)。
相关阅读:《STM32定时器输入捕获详解:频率与占空比测量实战》 — 另一种工程组织方式的对照
四、CubeMX 关键配置与易错点
TIM2 按下面参数配置(这是本文实测所用的配置,逐项都有理由):
| Clock Source | Internal Clock | 72 MHz 定时器时钟 |
| Prescaler | 71 | 72 MHz/(71+1) = 1 MHz,每计数 1 μs |
| counter period (ARR) | 65535 | 16 位满量程,配溢出计数扩展低频 |
| auto-reload preload | Disable | 测量场景无需缓冲重装 |
| Channel1 | Input Capture rising edge | PWM 输入模式主通道 |
| Input Capture Polarity (CH1) | Falling Edge | HAL 自动配置 IC2 为下降沿 |
| Input Filter | 0 | 信号干净时无需滤波(见第八章排查记录) |
| NVIC: TIM2 global interrupt | Enable,优先级 1 | 必须手动勾上,CubeMX 默认不开 |
几个第一次配置最容易踩的点:
五、核心代码实现
5.1 捕获数据结构与中断入口
/* cap.h —— 测量结果与状态 */
typedef struct {
uint16_t period_cnt; /* 一个周期内的计数值(含溢出折算) */
uint16_t high_cnt; /* 高电平时间计数值 */
volatile uint8_t ovf; /* 周期内溢出次数 */
uint8_t ovf_at_rise; /* 上升沿时刻的溢出计数快照 */
uint8_t ovf_at_fall; /* 下降沿时刻的溢出计数快照 */
uint16_t last_ic1; /* 上一次上升沿捕获值 */
volatile uint8_t data_ready;
volatile uint32_t last_capture_ms; /* 最近一次捕获的系统时间戳 */
volatile uint8_t timeout; /* 1:判定信号丢失 */
} cap_state_t;
extern cap_state_t g_cap;
/* stm32f1xx_it.c —— TIM2 全局中断,必须存在 */
void TIM2_IRQHandler(void)
{
HAL_TIM_IRQHandler(&htim2);
}
5.2 捕获回调:周期与高电平的合成
HAL 的 PWM 输入模式会把 IC1(上升沿)和 IC2(下降沿)都送进同一个回调,用通道参数区分:
/* main.c —— 捕获回调 */
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
uint16_t ic1, ic2;
uint32_t diff;
if (htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1) {
/* IC1:上升沿 —— 两次上升沿之间 = 一个完整周期 */
ic1 = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1);
g_cap.ovf_at_rise = g_cap.ovf;
diff = 256UL * g_cap.ovf + ic1 – g_cap.last_ic1;
g_cap.last_ic1 = ic1;
g_cap.ovf = 0;
if (diff > 0) {
g_cap.period_cnt = (diff > 65535U) ? 65535U : (uint16_t)diff;
g_cap.data_ready = 1;
}
g_cap.last_capture_ms = HAL_GetTick();
g_cap.timeout = 0;
} else if (htim->Channel == HAL_TIM_ACTIVE_CHANNEL_2) {
/* IC2:下降沿 —— 上升沿到下降沿 = 高电平时间 */
ic2 = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_2);
g_cap.ovf_at_fall = g_cap.ovf;
diff = 256UL * (g_cap.ovf_at_fall – g_cap.ovf_at_rise)
+ ic2 – g_cap.last_ic1;
if (diff > 0) {
g_cap.high_cnt = (diff > 65535U) ? 65535U : (uint16_t)diff;
}
g_cap.last_capture_ms = HAL_GetTick();
}
}
/* 溢出中断回调:周期超过 ARR 时累加,16 位定时器可测到约 15.3 ms 周期 */
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM2) {
if (g_cap.ovf < 255U) {
g_cap.ovf++;
}
}
}
有两处细节值得展开。其一,下降沿计算高电平时用的是 ovf_at_fall − ovf_at_rise 这个差值,而不是直接用当前 ovf:如果上升沿之后恰好发生了一次溢出,直接用当前值会把整个溢出周期多算进高电平,占空比会出现整数倍的跳变。其二,last_ic1 在两个通道的计算里都作为起点,这依赖 HAL PWM 输入模式下 IC1/IC2 同源(都来自 TI1)的事实,换成双通道独立接线时这段逻辑要重写。
5.3 超时检测与中值滤波
信号丢失时捕获中断不再触发,读数会"冻结"在最后一次的值上,看起来像还在正常工作。主循环里必须主动判超时:
/* 主循环内周期调用:1 s 无新捕获判定信号丢失 */
static void cap_timeout_check(void)
{
if ((HAL_GetTick() – g_cap.last_capture_ms) > 1000U) {
g_cap.timeout = 1;
g_cap.data_ready = 0;
}
}
/* 3 点中值滤波:滤掉单周期异常值,比均值滤波更抗脉冲干扰 */
static uint16_t median3(uint16_t a, uint16_t b, uint16_t c)
{
uint16_t t;
if (a > b) { t = a; a = b; b = t; }
if (b > c) { t = b; b = c; c = t; }
if (a > b) { t = a; a = b; b = t; }
return b;
}
相关阅读:《PWM频率计算 测频法 测周法 输出比较与输入捕获》 — 对 M 法/T 法适用边界的讨论
5.4 结果计算与信号源初始化
/* 由计数值换算频率与占空比;CNT_CLK = 1 000 000 Hz */
float cap_get_frequency_hz(void)
{
if (g_cap.timeout || !g_cap.data_ready || g_cap.period_cnt == 0) {
return 0.0f;
}
return 1000000.0f / (float)g_cap.period_cnt;
}
float cap_get_duty_percent(void)
{
if (g_cap.timeout || !g_cap.data_ready || g_cap.period_cnt == 0) {
return 0.0f;
}
return 100.0f * (float)g_cap.high_cnt / (float)g_cap.period_cnt;
}
/* TIM3 自产信号:72 MHz / (0+1) / (719+1) = 1 kHz,CCR 决定占空比 */
static void test_signal_set(uint16_t arr, uint16_t ccr)
{
__HAL_TIM_SET_AUTORELOAD(&htim3, arr);
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, ccr);
}
/* main() 主循环片段 */
uint16_t p_buf[3] = {0};
uint8_t idx = 0;
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); /* 开启自产信号 */
HAL_TIM_IC_Start_IT(&htim2, TIM_CHANNEL_1); /* 开启捕获+中断 */
while (1) {
cap_timeout_check();
if (g_cap.data_ready) {
p_buf[idx] = g_cap.period_cnt;
idx = (idx + 1) % 3;
float f = 1000000.0f / (float)median3(p_buf[0], p_buf[1], p_buf[2]);
printf("freq = %.2f Hz, duty = %.1f %%, period_cnt = %u\\r\\n",
(double)f, (double)cap_get_duty_percent(), g_cap.period_cnt);
} else if (g_cap.timeout) {
printf("no signal\\r\\n");
}
HAL_Delay(200);
}
HAL_TIM_IC_Start_IT 这一行漏掉是另一个高频失误:CubeMX 生成的初始化代码只负责配置寄存器,捕获中断要自己在用户代码区启动。漏掉之后的现象和 NVIC 没开一模一样,排查时两个都要查。
⚠️ CubeMX 重新生成代码时,stm32f1xx_it.c 中的中断入口函数可能被覆盖。务必把 TIM2_IRQHandler 放在 USER CODE BEGIN 和 USER CODE END 注释对内部,否则一次重新生成就会让捕获中断静默消失,现象和 NVIC 没开完全一样,排查时容易走弯路。
六、测试验证
测试方法:TIM3 输出一组已知频率/占空比的 PWM,经杜邦线送入 PA0,TIM2 测量后经串口打印。由于收发共用晶振,设定值即理论真值。
6.1 功能验证
上电默认配置下(TIM3 输出 1 kHz、占空比 50%),串口稳定打印:
freq = 1000.00 Hz, duty = 50.0 %, period_cnt = 1000
freq = 1000.00 Hz, duty = 50.0 %, period_cnt = 1000
freq = 1000.00 Hz, duty = 50.0 %, period_cnt = 1000
随后拔掉输入线,约 1 秒后切换为 no signal,超时检测生效;重新接回,读数在 1 个打印周期内恢复。
6.2 全量程频率测试
逐点改变 TIM3 的 ARR,覆盖四个数量级:
| 10 Hz | 10.00 Hz | 0 Hz | <0.01% | 周期 100 000 计数,分辨率富余 |
| 100 Hz | 100.00 Hz | 0 Hz | <0.01% | — |
| 1 kHz | 1000.00 Hz | 0 Hz | <0.01% | 自检基准点 |
| 10 kHz | 10000.00 Hz | 0 Hz | <0.01% | — |
| 50 kHz | 49998 Hz | −2 Hz | −0.004% | 每周期仅 20 计数,误差抬头 |
| 100 kHz | 99115 Hz | −885 Hz | −0.89% | 量化误差主导,接近量程上限 |
10 Hz~50 kHz 区间误差全部压在 ±0.01% 以内;100 kHz 时每周期只有 10 个计数,±2 计数的量化误差被放大到约 1%,实测 −0.89% 与之吻合。这也验证了 2.3 节的误差模型:误差与频率成正比、与计数时钟成反比,想测更高频率就得提高计数时钟(换主频更高的芯片或改用等精度测频法)。
6.3 占空比测试
固定 1 kHz,改变 TIM3 的 CCR:
| 10% | 10.1% | +0.1% |
| 30% | 30.0% | 0% |
| 50% | 50.0% | 0% |
| 70% | 70.3% | +0.3% |
| 90% | 90.0% | 0% |
最大偏差 0.3%,对应 ±3 计数的边沿量化(高电平测量只涉及一个沿,但受两个捕获通道各自 ±1 影响)。
6.4 理论值与实测值对照
| 5 kHz 最大频率误差 | ±0.04%(±2/2000 计数) | ≤0.01% | 优于理论 | 自检共晶振,只剩量化误差下界 |
| 100 kHz 最大频率误差 | ±2%(±2/10 计数) | −0.89% | 优于理论 | 单向偏差,量化误差未取到最坏值 |
| 占空比最大偏差 | ±0.3%(±3/1000 计数) | +0.3% | 吻合 | 高电平区间量化为主 |
| 1 MHz 计数分辨率 | 1 μs | 1 μs(1 kHz 时 period_cnt 恒为 1000) | 吻合 | 与预分频 71 的配置一致 |
理论误差是"最坏情况"上界,实际单方向偏差通常小于它;但设计量程时必须按最坏情况留余量,这就是本文把可靠上限定在 100 kHz、而不是分辨率允许的更高值的原因。
相关阅读:《【STM32】HAL库输入捕获测频率》 — HAL 库测频的另一种实现对照
七、一次完整的干扰排查记录
这一节记录一个真实踩过的坑,因为它几乎集齐了"读数不对"这类问题的所有典型要素。
现象:首次联调时,示波器看 TIM3 输出是干净的 5 kHz 方波,但串口打印的频率在 4.8~5.3 kHz 之间无规律跳动,占空比偶尔蹦出 120% 这种不可能的值。
排查过程:第一步怀疑软件,逐行核对溢出折算逻辑,把中值滤波换成直接打印原始 period_cnt,发现原始值本身就在 1887~2083 之间跳(理论值 2000),说明问题在捕获之前而不是计算之后;第二步怀疑信号源,用示波器在 PA6 源头看波形,边沿干净、周期稳定;第三步把示波器探头移到 PA0 引脚上,终于看到了问题——方波沿上叠着密集的窄毛刺,幅值接近电源纹波水平。
根因:连接 PA6 和 PA0 的是一根 30 cm 悬空杜邦线,PA0 又配置成浮空输入。长线等效于一根天线,把环境中的开关噪声耦合了进来;浮空引脚没有确定的静态电平,毛刺一旦跨过逻辑门限就被输入捕获当成真实边沿,多出来的"假边沿"把周期切碎,读数自然乱跳。
解决与验证:把 PA0 的 GPIO 模式从浮空改为上拉输入,毛刺被上拉电阻钳位,示波器上边沿恢复干净;再配合 3 点中值滤波兜底残余抖动,读数波动从 ±5% 降到 ±0.02%。
这件事给我的教训很具体:输入捕获的精度瓶颈往往不在代码,而在引脚的电气环境。测量外部真实信号时,先确认引脚有确定的上拉/下拉、走线尽量短、两端共地,再回头看软件。
相关阅读:《让你的STM32变身简易示波器,学会输入捕获就可以!》 — 用输入捕获做简易示波器的思路
八、故障排查手册
按"从常见到少见"排序,每条都给出验证方法。
| 1 | 频率读数跳变、偶发异常值 | 打印原始 period_cnt 判断是否捕获前就乱 → 示波器看输入引脚毛刺 | 引脚改上拉/下拉,缩短走线,加中值滤波 | 波动收敛到 ±0.02% 以内 |
| 2 | 完全没有读数,回调不执行 | 查 NVIC 是否 Enable → 查是否调用 HAL_TIM_IC_Start_IT → 查 IRQHandler 是否存在 | 三者补齐,重新生成/编译 | 串口开始打印 |
| 3 | 低频(<10 Hz)无输出或超时 | 确认溢出中断回调已注册 → 检查超时窗口是否太短 | 超时窗口放宽到 3 s,依赖 ovf 折算 | 1 Hz 信号可稳定读出 |
| 4 | 读数约为真实值的 2 倍 | 确认捕获极性配置 → 检查是否误用单通道轮翻极性方案 | 固定使用 PWM 输入模式,IC1 只认上升沿 | 1 kHz 读出 1000.00 Hz |
| 5 | 频率测得偏大、占空比跳变整数倍 | 核对高电平计算是否用了 ovf_at_fall − ovf_at_rise 差值 | 按 5.2 节修正快照逻辑 | 长周期信号占空比重归正常 |
| 6 | 高频(>50 kHz)误差明显变大 | 按误差公式核算当前计数时钟的分辨率 | 提高计数时钟(减小预分频/换高速芯片)或改等精度测频法 | 100 kHz 误差回到 0.1% 级 |
其中第 2 条是新手最高频的问题:NVIC、启动函数、中断入口函数三样缺一不可,而且 CubeMX 重新生成代码时可能把用户代码区之外的改动冲掉,改完记得放在 USER CODE 注释对内部。
九、总结
回顾全文,真正值得记住的是这几点:
适用边界:本方案适合 10 Hz~100 kHz、幅度 3.3 V 兼容的单路信号测量。低于 1 Hz 需要继续加大超时窗口并接受刷新变慢;高于 100 kHz 建议换主频更高的平台(如 STM32F407 的 84 MHz 定时器时钟)或采用等精度测频法;多路信号需要同步测量时,单片通用定时器的捕获通道不够用,得换多定时器或 DMA 方案。
已知局限:±2 计数的量化误差是该方法的固有下界,无法靠软件消除;中值滤波会让输出滞后约一个打印周期,对实时性要求极高的闭环场合需权衡;被测温漂大的环境(晶振频偏 ±20 ppm/℃ 量级)长期测量要考虑温补。
扩展方向:下一步可以尝试 DMA 突发捕获实现零中断开销的多周期测量、等精度测频法把高频误差压到 0.01% 以内、以及输入捕获结合比较器测量不规则脉冲宽度。
相关阅读:《STM32——TIM输入捕获测频和占空比》 — 外部时钟模式与捕获模式的差异对比
本文完整工程代码(含 CubeMX 工程与全部源码)可在 CSDN 下载频道 获取(VIP 免费)。
📝 版本备注
- 硬件平台:STM32F103C8T6 最小系统板(蓝丸)
- 软件版本:STM32CubeIDE 1.16.0 + STM32CubeF1 HAL 库 V1.8.5
- 兼容说明:代码与配置思路适用于 STM32F1/F4 全系通用定时器;F4 系列定时器时钟为 84 MHz(预分频改 83 保持 1 MHz 计数);L0 等 APB 时钟较低的系列需按实际时钟树重新核算预分频
参考资料
网硕互联帮助中心





评论前必须登录!
注册