STM32 零基础可移植教程 13:输入捕获入门,怎么测一个方波频率

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_countsperiod_us 在数值上刚好一样。

比如:

bash 复制代码
period_counts = 1000
period_us     = 1000 us
freq          = 1000000 / 1000 = 1000 Hz

这里还要顺手把 PWM 的 ARRCCR 分清楚。

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 变化,占空比变化

所以串口里看到的 freqperiod 主要反映的是 PWM 的周期和频率。

如果你只是改 CCR,让高电平时间变长或变短,输入捕获测到的 freqperiod 不会跟着明显变化。

如果你想让串口里的 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 引脚上看到的波形确实变了,但串口打印的 freqperiod 可能还是不变。

不是输入捕获没看到波形,而是我们当前代码只拿了"上升沿时间戳",没有去拿"下降沿时间戳"。

等后面要测占空比时,就要继续扩展:要么捕获上升沿和下降沿,要么用 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 输入捕获脚

注意三件事:

  1. 两个引脚必须都在同一块开发板上能引出来;

  2. PWM 输出电平不能超过输入捕获引脚允许范围;

  3. 如果后面换成外部信号源,仍然必须共地。

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.cUSER 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 |

换板子的推荐顺序:

  1. 找一个能输出 PWM 的引脚,比如 TIMx_CHy

  2. 找一个能输入捕获的引脚,比如另一个 TIMm_CHn

  3. 确认这两个引脚都能在开发板上接线;

  4. CubeMX 配置 PWM 输出;

  5. CubeMX 配置输入捕获;

  6. 用杜邦线把 PWM 输出脚接到输入捕获脚;

  7. 修改 APP_TEST_PWM_*APP_IC_* 宏;

  8. 串口打印验证频率。

【图位置 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. 编译报 htim3htim4 未定义

说明你的工程里没有生成对应定时器句柄。

可能原因:

  • 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 工程。

解决方法:

  1. 右键 Application/User/Core

  2. 选择 Add Existing Files to Group

  3. 添加 Core/Src/app_test_pwm.cCore/Src/app_input_capture.c

  4. 重新编译。

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 发声、输入捕获测频率"串起来了。

后面开始看模拟量,先从最直观的电位器电压开始。

相关推荐
agathakuan1 小时前
從零開始在家開發 IoT: Flash & Run 腳本解析(STM32 + WiFi HaLow)
stm32·mcu·iot
Rocktech_ruixun1 小时前
智慧餐饮新机遇:全场景无人化升级,破解餐饮业降本增效难题
人工智能·嵌入式硬件·ai·机器人
agathakuan1 小时前
從零開始在家開發 IoT: OpenOCD 與 GDB 協作指南
stm32·gnu·rtc
Dillon Dong4 小时前
【风电控制】TI TMS320F28379D 双CPU架构解析与任务分布设计
嵌入式硬件·算法·变流器·风电控制
czy878747511 小时前
vscode编译make命令要修改stm32cubemx生成的STM32F103XX_FLASH.ld文件
ide·vscode·stm32
三易串口屏12 小时前
实验20 自动灭火场景实验
嵌入式硬件·串口屏·三易串口屏·uart 通信
蒸蛋一级爱好者13 小时前
TFTP协议
单片机·嵌入式硬件
优信电子13 小时前
STM32/C51驱动 DHTC11 温湿度传感器
stm32·单片机·嵌入式硬件·c51·温湿度传感器·dhtc11·环境测量
QiLinkOS13 小时前
【从实验室到商业战场:发明专利如何重塑科技与企业的共生生态】
大数据·c语言·数据结构·c++·人工智能·单片机·算法