STM32 零基础可移植教程 13:输入捕获入门,怎么测一个方波频率
前面几篇我们一直在让 STM32 "输出"东西:
bash
定时器中断:到点提醒 CPU
PWM 呼吸灯:输出一串高低电平
无源蜂鸣器:输出不同频率的 PWM
这一篇换个方向。
我们让 STM32 去"测量"一个方波。
不过这次不外接函数信号发生器,也不需要第二块开发板。
我们直接用开发板自己的资源做一个自测:
bash
TIM3_CH2 输出 1000 Hz PWM
TIM3_CH4 捕获这个 PWM
串口打印测出来的频率
也就是:
bash
自己输出一个方波
自己测这个方波
这一篇只做一个明确目标:
bash
用 TIM 输入捕获测开发板自己输出的 PWM 频率,并通过串口打印出来
先不做占空比测量,不做多通道,不做高精度校准,也不做溢出多圈计数。
先把"捕获两个上升沿,算出一个周期"跑通。
本篇目标
最终现象:
串口助手里能看到类似输出:
bash
input capture self test start
freq=1000 Hz, period=1000 us
freq=1000 Hz, period=1000 us
freq=1000 Hz, period=1000 us
本篇用到的外设:
bash
TIM PWM Output
TIM Input Capture
USART printf
GPIO Alternate Function
本篇跑通标准:
-
Keil 编译通过;
-
程序能下载到开发板;
-
不需要函数信号发生器;
-
开发板自己输出 1000 Hz PWM;
-
输入捕获能测到接近 1000 Hz;
-
能说清楚输入捕获是"记录边沿到来时的计数器值";
-
能说清楚换 TIM、换通道、换引脚时要改哪里。
准备工作
你需要准备:
|
项目
|
说明
|
| --- | --- |
|
STM32 开发板
|
任意 STM32 开发板
|
|
下载器
|
ST-LINK/V2 或板载 ST-LINK
|
|
串口工具
|
用来查看 printf() 输出
|
|
杜邦线
|
把 PWM 输出脚接到输入捕获脚
|
|
原理图
|
确认 PWM 输出脚和输入捕获脚
|
|
CubeMX 工程
|
可以从第 07 篇串口工程继续改
|
注意,这里说"不外接其他设备",指的是:
bash
不需要函数信号发生器
不需要第二块开发板
不需要示波器
但通常还需要一根杜邦线。
因为大多数 STM32 并不会自动把某个引脚输出的 PWM,在芯片内部直接送到另一个输入捕获通道。
所以最通用、最可移植的做法是:
bash
PWM 输出脚 -> 杜邦线 -> 输入捕获脚
本篇示例使用:
bash
PB5 / TIM3_CH2 输出 1000 Hz PWM
PB1 / TIM3_CH4 输入捕获
PB5 用杜邦线接到 PB1
这根线不是外部设备,它只是把开发板自己产生的信号送回开发板自己测。

输入捕获到底是什么
先别急着看 CubeMX。
输入捕获的本质很简单:
bash
定时器一直在数数
输入引脚出现边沿
硬件把此刻的计数器值保存下来
比如定时器计数器这样一直跑:
bash
0, 1, 2, 3, 4, 5, 6, 7 ...
PB5 上来了一个上升沿。
硬件发现上升沿时,计数器刚好数到:
bash
1000
于是输入捕获寄存器里就记录:
bash
capture1 = 1000
下一次上升沿又来了,计数器数到:
bash
2000
于是记录:
bash
capture2 = 2000
两次上升沿之间相差:
bash
2000 - 1000 = 1000 次计数
如果定时器是 1 MHz 计数,也就是每 1 us 数一次,那么:
bash
周期 = 1000 us
频率 = 1 / 1000 us = 1000 Hz
这就是本篇的全部核心。
输入捕获不是在 while 里疯狂读 GPIO,也不是软件一直盯着电平。
它是硬件帮你在边沿到来的瞬间,把计数器值"拍照保存"下来。

为什么用开发板自己的 PWM 做信号源
新手学输入捕获时,最常见的卡点不是代码,而是没有合适的方波信号源。
有人会问:
bash
我没有函数信号发生器,怎么测?
这篇就用开发板自己来解决。
我们在同一个工程里配置两个定时器:
|
定时器
|
作用
|
示例引脚
|
| --- | --- | --- |
|
TIM3
|
输出 1000 Hz PWM
|
PB5 / TIM3_CH2
|
|
TIM3
|
输入捕获,测频率
|
PB1 / TIM3_CH4
|
这样做有几个好处:
-
不依赖外部仪器;
-
PWM 频率我们自己知道,方便验证;
-
第 11 篇 PWM 的知识能直接复用;
-
输入捕获跑通后,再换外部信号也不慌。
不过要记住:
bash
TIM3 输出 PWM 和 TIM3 输入捕获是两个独立外设
它们不会凭空连在一起。
你需要把:
bash
PB5 接到 PB1
这样 TIM3_CH4 才能真的看到 TIM3_CH2 输出的波形。
为什么要用两个上升沿
测频率,本质上是测周期。
一个完整周期,最直观的办法就是:
bash
从一个上升沿
到下一个上升沿
所以我们配置输入捕获时,先只捕获上升沿:
bash
Rising Edge
第一次捕获,只能知道起点。
第二次捕获,才能算出两次之间相差多少。
代码里会有一个状态:
bash
第一次捕获:保存 capture1
第二次捕获:读取 capture2,计算 capture2 - capture1
然后把结果交给主循环打印。
这和前面中断文章的习惯一样:
bash
中断里只记录数据
主循环里做打印和业务处理

串口里的 freq 和 period 到底是什么
跑通以后,你会在串口里看到类似:
bash
freq=1000 Hz, period=1000 us
这两个值不是凭空来的。
它们就是从 PWM 波形的两个相邻上升沿算出来的。
以本篇自测 PWM 为例:
bash
PWM 频率:1000 Hz
PWM 占空比:50%
波形大概可以这样理解:
bash
一个完整周期 = 1 ms = 1000 us
高电平 500 us 低电平 500 us
┌──────────────┐
│ │
────┘ └──────────────┐
│
└────
↑ ↑
上升沿 1 上升沿 2
捕获点 1 捕获点 2
输入捕获做的事情,就是记录这两个上升沿到来时的定时器计数值。
如果 TIM 输入捕获计数频率是 1 MHz,也就是每 1 us 计一次数:
bash
捕获点 1:counter = 1000
捕获点 2:counter = 2000
计数差值:2000 - 1000 = 1000
那么:
bash
period_us = 1000 us
freq = 1000 Hz
这两个变量可以这样理解:
|
变量
|
含义
|
怎么来的
|
1000 Hz 示例
|
| --- | --- | --- | --- |
| period_us |
周期,相邻两个上升沿之间隔了多久
|
捕获点 2 的计数值减去捕获点 1 的计数值,再换算成微秒
| 1000 us |
| freq |
频率,1 秒内有多少个完整周期
|
计数器频率除以两个上升沿之间的计数差值
| 1000 Hz |
对应到代码里,就是这两行:
bash
s_frequency_hz = APP_IC_COUNTER_CLK_HZ / period_counts;
s_period_us = (period_counts * 1000000u) / APP_IC_COUNTER_CLK_HZ;
如果我们把输入捕获计数频率配成 1 MHz:
bash
APP_IC_COUNTER_CLK_HZ = 1000000
那 period_counts 和 period_us 在数值上刚好一样。
比如:
bash
period_counts = 1000
period_us = 1000 us
freq = 1000000 / 1000 = 1000 Hz
这里还要顺手把 PWM 的 ARR 和 CCR 分清楚。
PWM 的周期主要由 ARR 决定。
PWM 的占空比主要由 CCR 决定。
你可以先这样记:
bash
ARR 决定多久重复一次 -> 影响 period 和 freq
CCR 决定高电平占多久 -> 影响占空比,不改变 period 和 freq
所以如果只是改变占空比,波形会变成这样:
bash
占空比约 20% 占空比约 80%
┌────┐ ┌──────────────┐
│ │ │ │
┘ └──────────────── ┘ └────
period 仍然是 1000 us period 仍然是 1000 us
freq 仍然是 1000 Hz freq 仍然是 1000 Hz
这也是为什么第 11 篇呼吸灯里,LED 亮度在变,但频率可以保持不变。
呼吸灯通常是:
bash
ARR 不变,PWM 频率不变
CCR 变化,占空比变化
所以串口里看到的 freq 和 period 主要反映的是 PWM 的周期和频率。
如果你只是改 CCR,让高电平时间变长或变短,输入捕获测到的 freq 和 period 不会跟着明显变化。
如果你想让串口里的 freq 也变化,就要改变 PWM 的频率,也就是改变 ARR,或者在本篇代码里改:
bash
#define APP_TEST_PWM_DEFAULT_FREQ_HZ 2000u

PB5 输出的是波形,输入捕获得到的是什么
这里还有一个很容易想混的问题:
bash
PB5 输出的是 PWM 方波。
那 PB6 输入捕获读到的,是不是也是一个占空比变化的高低电平?
先说答案:
bash
PB6 引脚上看到的,确实是 PB5 送过来的同一个 PWM 方波。
但代码通过输入捕获取到的,不是整段波形,而是一串边沿时间戳。
如果你实际接的是 PB5 -> PB1,道理完全一样。
本文正文用的是:
bash
PB5 -> PB6
你可以把下面的 PB6 换成你实际使用的输入捕获引脚来理解。
先看 PB5 输出的东西。
PB5 是 PWM 输出脚,它会输出一串高低电平。
如果 PWM 频率保持 1000 Hz,也就是周期保持 1 ms,那么改变占空比时,波形大概是这样:
bash
占空比约 20% 占空比约 80%
┌──┐ ┌──────────────┐
│ │ │ │
┘ └──────────────── ┘ └──
一个周期仍然是 1 ms 一个周期仍然是 1 ms
也就是说,PB5 物理引脚上输出的是:
bash
占空比可能变化的 PWM 方波
但输入捕获不是示波器。
它不会把这一整段高低电平波形采样下来,也不会自动告诉你"高电平持续了多久、低电平持续了多久"。
我们当前这版代码只配置了:
bash
Rising Edge
也就是只捕获上升沿。
它做的事情更像这样:
bash
PB5 上出现一个上升沿 -> 记录一次当前定时器计数值
PB5 又出现一个上升沿 -> 再记录一次当前定时器计数值
两个计数值相减 -> 得到一个周期
所以,代码拿到的不是:
bash
高、低、高、低、高、低......
而是类似:
bash
第 1 次上升沿:capture1 = 1000
第 2 次上升沿:capture2 = 2000
第 3 次上升沿:capture3 = 3000
这些 capture 值本质上是"时间戳"。
再说得直白一点:
|
问题
|
答案
|
| --- | --- |
|
PB5 物理输出的是什么
|
PWM 方波,高低电平按占空比变化
|
|
PB1 物理看到的是什么
|
通过杜邦线传过来的同一个 PWM 方波
|
|
输入捕获代码拿到的是什么
|
上升沿到来那一瞬间的定时器计数值
|
|
当前代码能算什么
|
周期 period_us 和频率 freq
|
|
当前代码不能直接算什么
|
占空比
|
为什么当前代码算不出占空比?
因为占空比需要知道高电平持续时间:
bash
上升沿 下降沿 下一个上升沿
↓ ↓ ↓
┌──────────────────┐
│ 高电平 │ 低电平
──┘ └────────────────────┐
└──
高电平时间 = 下降沿时间 - 上升沿时间
周期时间 = 下一个上升沿时间 - 本次上升沿时间
占空比 = 高电平时间 / 周期时间
而本篇为了先把"测频率"讲清楚,只捕获了上升沿。
所以它能算:
bash
上升沿 -> 上升沿 = 周期
但不能算:
bash
上升沿 -> 下降沿 = 高电平时间
这就是为什么你改变 PWM 占空比时,PB6 引脚上看到的波形确实变了,但串口打印的 freq 和 period 可能还是不变。
不是输入捕获没看到波形,而是我们当前代码只拿了"上升沿时间戳",没有去拿"下降沿时间戳"。
等后面要测占空比时,就要继续扩展:要么捕获上升沿和下降沿,要么用 PWM Input 模式一次性测周期和高电平时间。

占空比在变,为什么频率还是固定的
你可能还会继续想:
bash
既然 PWM 的占空比在变化,
那上升沿的位置是不是也在变化?
如果上升沿在变化,为什么输入捕获测到的频率还是固定的?
这个问题非常典型。
真正容易混的点在这里:
bash
占空比变化,不等于上升沿在移动。
以本篇的 PWM 配置为例,我们用的是常见的:
bash
边沿对齐
向上计数
PWM mode 1
高电平有效
定时器计数器大概这样跑:
bash
0 -> 1 -> 2 -> ... -> 999 -> 回到 0 -> 1 -> 2 -> ...
如果 ARR = 999,那么一个周期就是:
bash
ARR + 1 = 1000 次计数
计数频率是 1 MHz 时,这个周期就是:
bash
1000 us = 1 ms
所以 PWM 的重复节奏由 ARR 决定。
只要 ARR 不变,每隔 1 ms 就会开始一个新周期。
在这种配置下,可以先粗略理解成:
bash
上升沿:出现在新周期开始的位置
下降沿:出现在 CNT 计数到 CCR 的位置
也就是说,呼吸灯里不断变化的其实是 CCR。
CCR 变了,移动的是下降沿位置,不是周期起点。
比如 ARR = 999 不变。
当 CCR = 200 时:
bash
CNT = 0 CNT = 200 CNT = 999/0
↑ ↑ ↑
上升沿 下降沿 下个上升沿
┌──────────────┐
│ 高电平 │ 低电平
──┘ └──────────────────────────┐
└──
高电平约 200 us,周期仍然 1000 us
当 CCR = 800 时:
bash
CNT = 0 CNT = 800 CNT = 999/0
↑ ↑ ↑
上升沿 下降沿 下个上升沿
┌────────────────────────────────┐
│ 高电平 │ 低电平
──┘ └────────────┐
└──
高电平约 800 us,周期仍然 1000 us
你会发现:
bash
CCR 从 200 变成 800
高电平时间变长了
下降沿往后移动了
但下一个上升沿还是等到下一个周期开始才出现
所以:
|
项目
|
主要由谁决定
|
占空比变化时会不会变
|
| --- | --- | --- |
|
上升沿间隔
| ARR |
不变
|
|
下降沿位置
| CCR |
会变
|
|
高电平时间
| CCR |
会变
|
|
周期 period
| ARR + 1 |
不变
|
|
频率 freq
| 1 / period |
不变
|
用一句话总结就是:
bash
占空比变化,移动的是下降沿;
频率变化,移动的是下一个周期的起点。
所以输入捕获只抓上升沿时,它看到的是:
bash
上升沿 -> 上升沿 -> 上升沿
而这些上升沿之间仍然隔着固定的 1 ms。
因此串口打印的:
bash
period = 1000 us
freq = 1000 Hz
保持不变是正常现象。
这里再补一个细节,防止你后面看捕获值时又疑惑。
如果 PWM 输出和输入捕获用的是同一个定时器,比如你实际接的是:
bash
PB5 / TIM3_CH2 -> PB1 / TIM3_CH4
那从 TIM3 自己的计数器视角看,上升沿通常就在 CNT = 0 附近。
但本文推荐的写法是:
bash
PB5 / TIM3_CH2 -> PB6 / TIM4_CH1
也就是 TIM3 负责输出,TIM4 负责捕获。
TIM4 的计数器不会自动和 TIM3 的计数器从同一个 0 点开始。
所以 TIM4 捕获到的绝对值可能是:
bash
capture1 = 1234
capture2 = 2234
capture3 = 3234
不一定每次都是 0。
但真正用来算频率的是差值:
bash
2234 - 1234 = 1000
3234 - 2234 = 1000
只要这个差值稳定,算出来的周期和频率就是稳定的。

计数频率怎么选
为了让计算简单,这一篇继续建议把 TIM4 的计数频率配成:
bash
1 MHz
也就是:
bash
1 次计数 = 1 us
这样输入捕获得到的差值,就可以直接理解成微秒。
比如:
bash
period_counts = 1000
就表示:
bash
period_us = 1000 us
frequency = 1000000 / 1000 = 1000 Hz
如果你的 TIM4 时钟是 72 MHz:
bash
Prescaler = 72 - 1
如果你的 TIM4 时钟是 8 MHz:
bash
Prescaler = 8 - 1
目标都是一样:
bash
把 TIM4 计数器频率变成 1 MHz
本篇代码里也会有一个宏:
bash
#define APP_IC_COUNTER_CLK_HZ 1000000u
这个宏必须和 CubeMX 实际配置出来的 TIM4 计数频率一致。
如果它写错了,捕获能触发,串口也能打印,但频率会算错。

本篇测量范围先别贪大
这篇是入门篇,所以先做一个简单版本:
bash
捕获相邻两个上升沿
计算两次捕获值差值
支持一次计数器回绕
不处理很慢信号的多次溢出
假设我们把 TIM4 的 Counter Period 配成:
bash
65535
也就是 16 位定时器最大值。
如果计数频率是 1 MHz,那么定时器从 0 数到 65535,大约是:
bash
65.536 ms
对应最低频率大概是:
bash
15.26 Hz
所以入门测试时先测 1000 Hz 很合适。
如果后面要测 1 Hz、0.5 Hz 这种很慢的信号,就要进一步处理定时器溢出次数。
这部分后面可以单独做进阶篇。
硬件连接
本篇不接外部信号源。
只用开发板自己输出的 PWM。
连接方式:
|
PWM 输出
|
输入捕获
|
| --- | --- |
|
PB5 / TIM3_CH2
|
PB6 / TIM4_CH1
|
也就是:
bash
PB5 用杜邦线接到 PB6
如果你换了别的引脚,也按同样思路:
bash
某个 TIMx_CHy PWM 输出脚
接到
另一个 TIMm_CHn 输入捕获脚
注意三件事:
-
两个引脚必须都在同一块开发板上能引出来;
-
PWM 输出电平不能超过输入捕获引脚允许范围;
-
如果后面换成外部信号源,仍然必须共地。
CubeMX 配置步骤
1. 先保证 USART printf 能用
本篇需要把测量结果打印出来,所以建议先复用第 07 篇串口打印工程。
如果你的工程里已经能:
bash
printf("Hello STM32\r\n");
并且串口助手能收到,就可以继续。
如果串口还没跑通,建议先回到第 07 篇。
输入捕获本身不依赖串口,但没有串口打印,你不容易看到测量结果。

2. 配置 PWM 输出引脚
先配置开发板自己产生的测试方波。
本篇示例使用:
bash
PB5 -> TIM3_CH2
在 CubeMX Pinout 页面点击 PB5,选择:
bash
TIM3_CH2
然后进入:
bash
Timers -> TIM3
把 Channel2 设置为:
bash
PWM Generation CH2
TIM3 基础参数建议先这样:
|
配置项
|
推荐值
|
说明
|
| --- | --- | --- |
|
Prescaler
| 72 - 1 |
TIM3 计数频率 1 MHz,假设 TIM3 时钟 72 MHz
|
|
Counter Mode
|
Up
|
向上计数
|
|
Counter Period
| 1000 - 1 |
1000 个计数,对应 1 kHz
|
|
Clock Division
|
No Division
|
入门先不分频
|
PWM 通道参数:
|
配置项
|
推荐值
|
说明
|
| --- | --- | --- |
|
Mode
|
PWM mode 1
|
常用 PWM 模式
|
|
Pulse
|
500
|
50% 占空比
|
|
CH Polarity
|
High
|
高电平有效
|
这样 PB5 会输出一个约 1000 Hz、50% 占空比的 PWM。
如果你的 TIM3 时钟不是 72 MHz,就按实际时钟重新算 Prescaler。

3. 配置输入捕获引脚
本篇示例输入捕获引脚使用:
bash
PB1 -> TIM3_CH4
在 CubeMX Pinout 页面点击 PB1,选择:
bash
TIM3_CH4
然后进入:
bash
Timers -> TIM3
把 Channel4 设置为:
bash
Input Capture direct mode
这个意思是:TIM3_CH4 直接捕获来自 PB5 的边沿。

4. 配置 TIM3 基础参数
假设 TIM3 时钟是 72 MHz,可以这样配:
|
配置项
|
推荐值
|
说明
|
| --- | --- | --- |
|
Prescaler
| 72 - 1 |
让计数频率变成 1 MHz
|
|
Counter Mode
|
Up
|
向上计数
|
|
Counter Period
| 65535 |
16 位最大值,方便处理回绕
|
|
Clock Division
|
No Division
|
入门先不分频
|
如果你的 TIM3 时钟是 8 MHz:
bash
Prescaler = 8 - 1
Counter Period = 65535
目的还是:
bash
TIM3 计数器每 1 us 加 1

5. 配置 Input Capture 参数
Channel1 的 Input Capture 参数建议先这样:
|
配置项
|
推荐值
|
说明
|
| --- | --- | --- |
|
Polarity Selection
|
Rising Edge
|
捕获上升沿
|
|
IC Selection
|
Direct
|
直接输入
|
|
Prescaler
|
No division
|
每个有效边沿都捕获
|
|
Input Filter
|
0
|
入门先不滤波
|
我们自己输出的是干净 PWM,所以第一遍先不加滤波。
后面如果测外部传感器脉冲,输入信号有毛刺,再考虑 Input Filter。

6. 打开 TIM3 中断
进入:
bash
NVIC Settings
勾选:
bash
TIM3 global interrupt
输入捕获要用中断通知我们:
bash
边沿来了,捕获值已经保存好了
TIM3 PWM 输出不需要中断。

7. 生成 Keil 工程
配置完成后点击:
bash
GENERATE CODE
打开 Keil 后先编译一次。

Keil 工程生成和编译
打开 Keil 后,先编译:
bash
Build / F7
确认输出里没有错误:
bash
0 Error(s)

如果这一步还没写自己的代码就报错,先检查 CubeMX 工程和芯片 Pack。
完整代码
这一篇有两部分应用代码:
bash
Core/Inc/app_test_pwm.h
Core/Src/app_test_pwm.c
Core/Inc/app_input_capture.h
Core/Src/app_input_capture.c
app_test_pwm 负责输出测试 PWM。
app_input_capture 负责捕获 PWM 并计算频率。
1. Core/Inc/app_test_pwm.h
bash
#ifndef APP_TEST_PWM_H
#define APP_TEST_PWM_H
#include "main.h"
#include <stdint.h>
void App_TestPWM_Init(void);
HAL_StatusTypeDef App_TestPWM_Start(void);
void App_TestPWM_Stop(void);
void App_TestPWM_SetFrequency(uint32_t frequency_hz);
#endif
2. Core/Src/app_test_pwm.c
bash
#include "app_test_pwm.h"
/*
* Default test PWM output is TIM3 Channel 2.
* If your board uses another PWM pin, change these macros.
*/
#ifndef APP_TEST_PWM_HANDLE
#define APP_TEST_PWM_HANDLE htim3
#endif
#ifndef APP_TEST_PWM_CHANNEL
#define APP_TEST_PWM_CHANNEL TIM_CHANNEL_2
#endif
/*
* Timer counter clock after prescaler.
* Example: TIM clock 72 MHz, Prescaler = 72 - 1, counter clock = 1 MHz.
*/
#ifndef APP_TEST_PWM_COUNTER_CLK_HZ
#define APP_TEST_PWM_COUNTER_CLK_HZ 1000000u
#endif
#ifndef APP_TEST_PWM_DEFAULT_FREQ_HZ
#define APP_TEST_PWM_DEFAULT_FREQ_HZ 1000u
#endif
#ifndef APP_TEST_PWM_DUTY_PERMILLE
#define APP_TEST_PWM_DUTY_PERMILLE 500u
#endif
#ifndef APP_TEST_PWM_MAX_ARR
#define APP_TEST_PWM_MAX_ARR 0xFFFFu
#endif
extern TIM_HandleTypeDef APP_TEST_PWM_HANDLE;
void App_TestPWM_Init(void)
{
App_TestPWM_SetFrequency(APP_TEST_PWM_DEFAULT_FREQ_HZ);
}
HAL_StatusTypeDef App_TestPWM_Start(void)
{
return HAL_TIM_PWM_Start(&APP_TEST_PWM_HANDLE, APP_TEST_PWM_CHANNEL);
}
void App_TestPWM_Stop(void)
{
HAL_TIM_PWM_Stop(&APP_TEST_PWM_HANDLE, APP_TEST_PWM_CHANNEL);
}
void App_TestPWM_SetFrequency(uint32_t frequency_hz)
{
uint32_t period_counts;
uint32_t arr;
uint32_t ccr;
uint32_t duty_permille = APP_TEST_PWM_DUTY_PERMILLE;
if (frequency_hz == 0u)
{
App_TestPWM_Stop();
return;
}
if (duty_permille > 1000u)
{
duty_permille = 1000u;
}
period_counts = APP_TEST_PWM_COUNTER_CLK_HZ / frequency_hz;
if (period_counts < 2u)
{
period_counts = 2u;
}
arr = period_counts - 1u;
if (arr > APP_TEST_PWM_MAX_ARR)
{
arr = APP_TEST_PWM_MAX_ARR;
period_counts = arr + 1u;
}
ccr = (period_counts * duty_permille) / 1000u;
__HAL_TIM_SET_AUTORELOAD(&APP_TEST_PWM_HANDLE, arr);
__HAL_TIM_SET_COMPARE(&APP_TEST_PWM_HANDLE, APP_TEST_PWM_CHANNEL, ccr);
__HAL_TIM_SET_COUNTER(&APP_TEST_PWM_HANDLE, 0u);
__HAL_TIM_GENERATE_EVENT(&APP_TEST_PWM_HANDLE, TIM_EVENTSOURCE_UPDATE);
}
如果你想把测试 PWM 改成 2000 Hz,只需要改:
bash
#define APP_TEST_PWM_DEFAULT_FREQ_HZ 2000u
然后输入捕获打印的结果也应该接近 2000 Hz。
3. Core/Inc/app_input_capture.h
bash
#ifndef APP_INPUT_CAPTURE_H
#define APP_INPUT_CAPTURE_H
#include "main.h"
#include <stdint.h>
typedef struct
{
uint32_t frequency_hz;
uint32_t period_us;
} App_InputCaptureResult;
void App_InputCapture_Init(void);
HAL_StatusTypeDef App_InputCapture_Start(void);
void App_InputCapture_OnCallback(TIM_HandleTypeDef *htim);
uint8_t App_InputCapture_GetResult(App_InputCaptureResult *result);
#endif
4. Core/Src/app_input_capture.c
bash
#include "app_input_capture.h"
/*
* Default input capture is TIM4 Channel 1.
* If your project uses another timer/channel, change these macros.
*/
#ifndef APP_IC_HANDLE
#define APP_IC_HANDLE htim4
#endif
#ifndef APP_IC_CHANNEL
#define APP_IC_CHANNEL TIM_CHANNEL_1
#endif
#ifndef APP_IC_ACTIVE_CHANNEL
#define APP_IC_ACTIVE_CHANNEL HAL_TIM_ACTIVE_CHANNEL_1
#endif
/*
* This is the timer counter clock after prescaler.
* Example: TIM clock 72 MHz, Prescaler = 72 - 1, counter clock = 1 MHz.
*/
#ifndef APP_IC_COUNTER_CLK_HZ
#define APP_IC_COUNTER_CLK_HZ 1000000u
#endif
/*
* TIM4 is a 16-bit timer on STM32F103.
* Keep this value consistent with CubeMX Counter Period.
*/
#ifndef APP_IC_COUNTER_MAX
#define APP_IC_COUNTER_MAX 0xFFFFu
#endif
extern TIM_HandleTypeDef APP_IC_HANDLE;
static volatile uint8_t s_has_first_capture = 0u;
static volatile uint32_t s_last_capture = 0u;
static volatile uint32_t s_frequency_hz = 0u;
static volatile uint32_t s_period_us = 0u;
static volatile uint8_t s_result_ready = 0u;
void App_InputCapture_Init(void)
{
s_has_first_capture = 0u;
s_last_capture = 0u;
s_frequency_hz = 0u;
s_period_us = 0u;
s_result_ready = 0u;
}
HAL_StatusTypeDef App_InputCapture_Start(void)
{
return HAL_TIM_IC_Start_IT(&APP_IC_HANDLE, APP_IC_CHANNEL);
}
void App_InputCapture_OnCallback(TIM_HandleTypeDef *htim)
{
uint32_t current_capture;
uint32_t period_counts;
if (htim->Instance != APP_IC_HANDLE.Instance)
{
return;
}
if (htim->Channel != APP_IC_ACTIVE_CHANNEL)
{
return;
}
current_capture = HAL_TIM_ReadCapturedValue(htim, APP_IC_CHANNEL);
if (s_has_first_capture == 0u)
{
s_last_capture = current_capture;
s_has_first_capture = 1u;
return;
}
if (current_capture >= s_last_capture)
{
period_counts = current_capture - s_last_capture;
}
else
{
period_counts = (APP_IC_COUNTER_MAX - s_last_capture) + current_capture + 1u;
}
s_last_capture = current_capture;
if (period_counts == 0u)
{
return;
}
s_frequency_hz = APP_IC_COUNTER_CLK_HZ / period_counts;
s_period_us = (period_counts * 1000000u) / APP_IC_COUNTER_CLK_HZ;
s_result_ready = 1u;
}
uint8_t App_InputCapture_GetResult(App_InputCaptureResult *result)
{
uint8_t ready;
if (result == 0)
{
return 0u;
}
__disable_irq();
ready = s_result_ready;
if (ready != 0u)
{
result->frequency_hz = s_frequency_hz;
result->period_us = s_period_us;
s_result_ready = 0u;
}
__enable_irq();
return ready;
}
这里 __disable_irq() 的作用,和番外 06-1 讲的一样:
bash
中断和主循环共享变量时,读写过程要避免被打断
临界区很短,只是复制几个变量,不会长时间关中断。
5. 把 .c 文件加入 Keil 工程
手动新建 .c 文件后,Keil 不一定会自动编译。
在 Keil 工程树里右键:
bash
Application/User/Core
选择:
bash
Add Existing Files to Group 'Application/User/Core'
添加:
bash
Core/Src/app_test_pwm.c
Core/Src/app_input_capture.c

main.c 调用方式
1. Includes 区域添加头文件
找到:
bash
/* USER CODE BEGIN Includes */
/* USER CODE END Includes */
改成:
bash
/* USER CODE BEGIN Includes */
#include "app_test_pwm.h"
#include "app_input_capture.h"
#include <stdio.h>
/* USER CODE END Includes */
如果你的 printf() 重定向需要包含 app_uart.h,也按第 07 篇写法加进去。
2. 初始化区域启动 PWM 和输入捕获
确保这些初始化已经执行:
bash
MX_USART1_UART_Init();
MX_TIM3_Init();
然后在 USER CODE BEGIN 2 里添加:
bash
/* USER CODE BEGIN 2 */
App_TestPWM_Init();
App_TestPWM_Start();
App_InputCapture_Init();
App_InputCapture_Start();
printf("input capture self test start\r\n");
/* USER CODE END 2 */
建议先启动 PWM 输出,再启动输入捕获。
这样捕获端启动后,很快就能收到上升沿。
3. 添加输入捕获回调
在 main.c 的 USER CODE BEGIN 4 区域添加:
bash
/* USER CODE BEGIN 4 */
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
App_InputCapture_OnCallback(htim);
}
/* USER CODE END 4 */
CubeMX 生成的 stm32xx_it.c 里会有类似:
bash
void TIM4_IRQHandler(void)
{
HAL_TIM_IRQHandler(&htim4);
}
中断发生后,调用关系大概是:
bash
TIM4_IRQHandler()
-> HAL_TIM_IRQHandler(&htim4)
-> HAL_TIM_IC_CaptureCallback()
-> App_InputCapture_OnCallback()
如果你的工程里已经有 HAL_TIM_IC_CaptureCallback(),不要再写第二个同名函数。
把:
bash
App_InputCapture_OnCallback(htim);
合并到已有函数里即可。

4. while 循环里打印结果
在 USER CODE BEGIN 3 区域添加:
bash
/* USER CODE BEGIN 3 */
App_InputCaptureResult ic_result;
if (App_InputCapture_GetResult(&ic_result) != 0u)
{
printf("freq=%lu Hz, period=%lu us\r\n",
ic_result.frequency_hz,
ic_result.period_us);
}
HAL_Delay(200);
/* USER CODE END 3 */
中断里只计算并记录结果。
主循环里负责打印。
这样串口打印慢一点,也不会卡在中断里面。
编译、下载和验证
代码加完后,先编译:
bash
Build / F7
没有错误后下载:
bash
Download
接好这根线:
bash
PB5 -> PB1
打开串口助手,正常输出类似:
bash
input capture self test start
freq=1000 Hz, period=1000 us
freq=1000 Hz, period=1000 us
实际可能会有一点点跳动,比如:
bash
freq=999 Hz
freq=1001 Hz
这通常是正常的,和时钟配置、整数除法、串口打印时刻都有关系。

你还可以做一个验证:
把 app_test_pwm.c 里的:
bash
#define APP_TEST_PWM_DEFAULT_FREQ_HZ 1000u
改成:
bash
#define APP_TEST_PWM_DEFAULT_FREQ_HZ 2000u
重新编译下载。
如果串口打印接近:
bash
freq=2000 Hz, period=500 us
说明输入捕获链路是对的。
移植到其他板子的修改点
这篇有两组移植点。
一组是 PWM 输出端。
|
要改的地方
|
为什么要改
|
在哪里改
|
| --- | --- | --- |
|
PWM 输出引脚
|
不同板子可用 PWM 引脚不同
|
CubeMX Pinout
|
|
PWM TIM 实例
|
TIM3/TIM4/TIM1 不同
| APP_TEST_PWM_HANDLE |
|
PWM 通道
|
CH1/CH2/CH3/CH4 不同
| APP_TEST_PWM_CHANNEL |
|
PWM 计数频率
|
用来生成目标频率
| APP_TEST_PWM_COUNTER_CLK_HZ |
|
测试频率
|
想输出 1 kHz 还是 2 kHz
| APP_TEST_PWM_DEFAULT_FREQ_HZ |
另一组是输入捕获端。
|
要改的地方
|
为什么要改
|
在哪里改
|
| --- | --- | --- |
|
输入捕获引脚
|
不同板子信号接入引脚不同
|
CubeMX Pinout
|
|
IC TIM 实例
|
TIM2/TIM3/TIM4 不同
| APP_IC_HANDLE |
|
IC 通道
|
CH1/CH2/CH3/CH4 不同
| APP_IC_CHANNEL |
|
Active Channel
|
HAL 回调用它区分通道
| APP_IC_ACTIVE_CHANNEL |
|
IC 计数频率
|
用来把计数差值换成频率
| APP_IC_COUNTER_CLK_HZ |
|
Counter Period
|
决定回绕范围
| APP_IC_COUNTER_MAX |
换板子的推荐顺序:
-
找一个能输出 PWM 的引脚,比如
TIMx_CHy; -
找一个能输入捕获的引脚,比如另一个
TIMm_CHn; -
确认这两个引脚都能在开发板上接线;
-
CubeMX 配置 PWM 输出;
-
CubeMX 配置输入捕获;
-
用杜邦线把 PWM 输出脚接到输入捕获脚;
-
修改
APP_TEST_PWM_*和APP_IC_*宏; -
串口打印验证频率。
【图位置 6:输入捕获自测移植检查表】
常见问题排查
1. 串口没有任何输出
先别急着看输入捕获。
先确认第 07 篇串口打印还正常:
bash
printf("hello\r\n");
如果这个都收不到,优先检查:
-
USART 是否初始化;
-
TX/RX/GND 是否接对;
-
串口助手波特率是否一致;
-
printf()是否已经重定向; -
Keil 是否勾选了 MicroLIB。
2. 只打印 input capture self test start,没有频率
说明程序跑起来了,串口也能打印,但输入捕获没有拿到有效边沿。
重点检查:
-
PB5 有没有用杜邦线接到 PB6;
-
PB5 是否真的配置成
TIM3_CH2 PWM Generation; -
PB6 是否真的配置成
TIM4_CH1 Input Capture; -
是否调用了
App_TestPWM_Start(); -
是否调用了
App_InputCapture_Start(); -
是否勾选 TIM4 global interrupt;
-
HAL_TIM_IC_CaptureCallback()是否写了。
3. 编译报 htim3 或 htim4 未定义
说明你的工程里没有生成对应定时器句柄。
可能原因:
-
CubeMX 没启用 TIM3 或 TIM4;
-
你用的是其他 TIM;
-
应用代码里的宏没改。
如果输入捕获没有用 TIM4,而是换成了 TIM2,就把:
bash
#define APP_IC_HANDLE htim4
改成:
bash
#define APP_IC_HANDLE htim2
如果 PWM 输出用 TIM1,就把:
bash
#define APP_TEST_PWM_HANDLE htim3
改成:
bash
#define APP_TEST_PWM_HANDLE htim1
4. 编译报 undefined symbol App_TestPWM_Start
通常是 app_test_pwm.c 没加入 Keil 工程。
如果报:
bash
undefined symbol App_InputCapture_Start
通常是 app_input_capture.c 没加入 Keil 工程。
解决方法:
-
右键
Application/User/Core; -
选择
Add Existing Files to Group; -
添加
Core/Src/app_test_pwm.c和Core/Src/app_input_capture.c; -
重新编译。
5. 频率明显不对
优先检查两个计数频率宏。
PWM 输出端:
bash
#define APP_TEST_PWM_COUNTER_CLK_HZ 1000000u
输入捕获端:
bash
#define APP_IC_COUNTER_CLK_HZ 1000000u
这两个值都要和 CubeMX 里对应 TIM 的实际计数频率一致。
正确计算方式:
bash
计数频率 = TIM 时钟 / (Prescaler + 1)
如果 TIM 时钟是 72 MHz,Prescaler 是 72 - 1:
bash
计数频率 = 1 MHz
6. 频率偶尔跳动
小幅跳动一般正常。
可能原因:
-
时钟不是理想精确;
-
串口打印看到的是整数除法结果;
-
杜邦线接触不稳定;
-
输入边沿有毛刺。
可以尝试:
-
线短一点;
-
确认 PB5 和 PB6 接触可靠;
-
在 Input Capture 参数里适当加 Input Filter;
-
后续做多次平均。
本篇先不做平均,避免把入门逻辑写复杂。
7. 不接 PB5 到 PB6,能不能测到
一般测不到。
因为 TIM3_CH2 输出在 PB5 引脚上,TIM3_CH4 捕获的是 PB5 引脚上的电平变化。
如果 PB5 和 PB1 没有连接,PB1 就看不到 PB5 的 PWM。
有些高级芯片或高级定时器玩法可以用内部触发、主从模式、互联矩阵等方式做内部连接。
但这不适合作为零基础入门的第一版。
本系列先用最直观、最可移植的方式:
bash
一根杜邦线把输出接回输入
8. 回调函数不进
重点检查这条链路:
bash
TIM3 global interrupt 是否勾选
stm32xx_it.c 里是否有 TIM3_IRQHandler()
TIM4_IRQHandler() 里是否调用 HAL_TIM_IRQHandler(&htim3)
main.c 里是否实现 HAL_TIM_IC_CaptureCallback()
是否调用 HAL_TIM_IC_Start_IT()
少任何一步,回调都可能不进。
本篇小结
这一篇我们完成了 TIM 输入捕获的第一个实验:
bash
用开发板自己输出的 PWM,测试输入捕获测频率
你现在至少应该知道:
-
输入捕获是硬件记录边沿到来时的计数器值;
-
测频率可以捕获相邻两个上升沿;
-
两次捕获值差值就是一个周期的计数数;
-
计数器 1 MHz 时,1 次计数就是 1 us;
-
frequency = counter_clk / period_counts; -
HAL_TIM_PWM_Start()用来启动测试 PWM; -
HAL_TIM_IC_Start_IT()用来启动输入捕获中断; -
HAL_TIM_IC_CaptureCallback()是捕获事件的回调入口; -
中断里记录数据,主循环里打印结果;
-
不用信号发生器也能学输入捕获,但通常需要一根线把 PWM 输出接回输入;
-
换板子时重点检查两组资源:PWM 输出 TIM/通道/引脚,以及输入捕获 TIM/通道/引脚。
下一篇我们进入 ADC:
STM32 ADC 单通道采样:读一个电位器电压。
到这里,定时器这一阶段已经把"定时提醒、PWM 输出、PWM 发声、输入捕获测频率"串起来了。
后面开始看模拟量,先从最直观的电位器电压开始。