STM32 中断 · 定时器 · PWM 学习笔记
适合已了解 GPIO、时钟、上拉下拉等基础概念的同学(参考你的《STM32嵌入式常见名词解释.md》)。
本文用 STM32F103 + 标准外设库 V3.5.0 写法示例。笔记顺序:先补一点数电/模电基础 → 中断 → 定时器 → PWM → 综合实战。
学习建议:不要一口气读完。每读完一个部分,就打开工程把示例代码敲一遍、改一改,再进入下一部分。
第零部分:前置基础(数电 / 模电小补课)
你是跨专业进来的,没有数电模电基础没关系。但理解中断、定时器、PWM 这三个东西,本质上只需要补 4 个概念:数字信号、方波、边沿、平均电压。下面用最直白的方式讲。
0.1 数字信号与模拟信号
- 模拟信号(Analog):电压连续变化,比如温度、声音,任何中间值都可能出现。
- 数字信号(Digital):只有离散的几个状态。在单片机世界里,基本就两个状态——高电平(逻辑 1)和低电平(逻辑 0)。
STM32 的引脚内部其实就是一个非常快的"开关",它要么输出高(接近 3.3V),要么输出低(接近 0V)。它天生只会说 0 和 1。理解这一点是后面所有内容的基础:单片机所有外设,本质上都是"在 0 和 1 的电压波形上做文章"。
0.2 方波、周期与频率
- 方波(Square Wave):电压在高、低两个状态之间来回跳变的波形,画出来像一堵墙的横截面。
- 周期(T):波形重复一次需要的时间,单位秒(s)。
- 频率(f):一秒内波形重复多少次,单位赫兹(Hz)。频率 = 1 / 周期,即
f = 1/T。
| 频率 | 含义 |
|---|---|
| 1 Hz | 一秒跳变 1 个完整周期 |
| 1 kHz | 一秒跳变 1000 个周期 |
| 72 MHz | 一秒跳变 7200 万个周期 |
记忆:
1 MHz = 1,000,000 Hz,1 kHz = 1,000 Hz。M(兆)→ k(千)→ 基本单位,每次差 1000 倍。
0.3 时钟信号
时钟(Clock)就是一个频率固定的方波,是单片机的"节拍器"。CPU 和外设每"嗒"一下,就执行一步操作。72 MHz 的时钟,就是每秒有 7200 万个"嗒"。定时器也是靠数这些"嗒"的次数来计时的。
0.4 边沿(Edge)
方波从低跳到高的那一下叫上升沿,从高跳到低的那一下叫下降沿。
高 ——\ /‾‾‾‾\ /‾‾‾‾
| | | |
低 \______/ \______/
↑上升沿 ↑上升沿
\___/
↑下降沿
中断很喜欢用边沿:比如"按键按下的瞬间"就是一条下降沿。单片机可以配置成"看到下降沿就停下手里的事去处理按键"。这个"看着边沿动作"的电路叫边沿检测,是数字电路的基础之一。
0.5 平均电压(理解 PWM 的关键)
这是整个文档里"模电感"最强的一个概念,但很好懂:
假设一个灯泡,我们让它在 1 秒内:0.5 秒接 3.3V,0.5 秒断电。虽然它是断续通电,但人眼和大多数负载感受到的,是一段时间里的平均电压:
平均电压 = 高电平时间 ÷ 总时间 × 高电平电压
- 3.3V 通电 50% 时间 → 平均 1.65V → 灯泡变暗一半
- 3.3V 通电 90% 时间 → 平均 2.97V → 灯泡接近全亮
- 3.3V 通电 10% 时间 → 平均 0.33V → 灯泡几乎不亮
这就是 PWM 的全部原理:数字信号只能输出 0 和 3.3V,但通过控制"高电平占多长时间的比例",就能"骗过"负载,得到任意平均电压。调 LED 亮度、调电机转速,都是在调这个比例。这个概念(脉宽调制)学名就叫 PWM(Pulse Width Modulation,脉冲宽度调制)。
注意:PWM 的频率不能太低。比如 LED 用 10 Hz 的 PWM,人眼能看到明显的闪烁;用 1 kHz 以上就完全看不出来了。所以 PWM 的频率和占空比两个参数都要关心,后面定时器部分再展开。
0.6 本部分小结
| 概念 | 一句话 |
|---|---|
| 数字信号 | 只会输出 0 / 1 两态电压 |
| 方波 | 高低来回跳的波形 |
| 周期 / 频率 | 重复一次的时间 / 每秒重复的次数,f = 1/T |
| 边沿 | 电压跳变的瞬间:上升沿、下降沿 |
| 平均电压 | 长时间看,断续通电 ≈ 连续的低电压(PWM 的基础) |
第一部分:中断(Interrupt)
1.1 什么是中断——先打一个比方
想象你在写作业(这是主程序 main 的循环)。这时候电话响了——电话响就是一次"中断请求"。
- 你放下笔(保存现场:记住写到哪一页哪一题)
- 去接电话,把电话里说的事处理完(执行中断服务函数 ISR)
- 挂电话,回到书桌前继续写刚才那题(恢复现场,回到主程序)
电话铃响本身不占用你写作业的时间,只有接电话那几秒才打断你。中断也是这个道理:平时 CPU 专心跑主循环,某个事件发生时硬件强行暂停主循环,跳去执行一段专门的小函数,处理完再跳回来。 这个"跳过去"的过程,对主循环几乎是无感的。
1.2 轮询 vs 中断
| 轮询(Polling) | 中断(Interrupt) | |
|---|---|---|
| 方式 | CPU 在循环里不停"问"某个条件到了没有 | 事件发生,硬件主动"通知"CPU |
| 实时性 | 差(要循环转一圈才轮到检查) | 好(事件立刻响应) |
| CPU 占用 | 一直占着(检查期间什么都干不了) | 平时完全不占,只有事件才打断 |
| 典型场景 | 简单延时、低速查询 | 按键、串口收到数据、定时器到点 |
一句话:轮询是"我隔一会儿看一眼",中断是"你到了叫我"。中断是嵌入式高效编程的核心。
1.3 一次中断的完整流程
以"按键按下触发外部中断"为例:
① 事件发生:按键按下,引脚电平从高变低(产生下降沿)
② 硬件置位标志位:EXTI 检测到下降沿,把"中断标志位"置 1
③ 请求打断:EXTI 向 NVIC 发出中断请求
④ NVIC 裁决:如果这个中断使能了,且优先级允许,就通知 CPU 内核
⑤ 保存现场:CPU 自动把当前正在执行的指令地址、寄存器等压栈保存
⑥ 跳转:根据中断向量表,跳到对应的中断服务函数 ISR 执行
⑦ 处理:ISR 里执行你的代码(比如翻转 LED)
⑧ 清标志:程序里必须把步骤②的标志位清掉(否则会立刻再进一次)
⑨ 恢复现场:CPU 弹栈恢复现场,回到主程序刚才的地方继续跑
这里出现了几个名词,逐个说清楚:
- 标志位(Flag):外设内部的一个"1 bit 记事本",事件发生就记 1,等你读走并清零。
- NVIC:Cortex-M 内核里的"中断总管家",负责决定"哪些中断使能、来了先处理谁"。
- 中断向量表(Vector Table):一张表格,里面写着每个中断服务函数的入口地址。第⑤步的"跳转"就是查这张表。这张表由启动文件(startup_stm32f10x_hd.s)建立。
- ISR(Interrupt Service Routine):中断服务函数,就是"接电话时做的事"。
1.4 中断服务函数(ISR)的铁律
ISR 虽然看起来只是普通函数,但它有几个纪律,违反就会出诡异 bug:
- ISR 要短。在 ISR 里停留越久,主程序被耽误越久。复杂的处理应该只在 ISR 里"记个标记/保存数据",回主循环再慢慢做。
- ISR 里不要用延时。特别是几百毫秒的延时,会把整个系统卡死。
- ISR 和主程序共享的变量,要加
volatile。否则编译器可能把变量优化进寄存器缓存,ISR 改了主程序看不到。volatile就是告诉编译器"这个变量随时可能被外部改变,每次用都重新去内存读"。 - 退出 ISR 前,一定要清除中断标志位(大部分外设)。忘了清 = 标志一直为 1 = 立刻又进中断 = 死循环卡在中断里。
- 中断优先级、使能配置要一次做对,特别是优先级分组(见 1.5),工程开头设一次,不要中途改。
1.5 NVIC 与中断优先级
STM32F103 用的是 ARM Cortex-M3 内核,NVIC 管理所有可屏蔽中断。
优先级分组(Priority Grouping):Cortex-M 把"优先级"拆成两部分——抢占优先级(Preemption)和从/子优先级(Sub)。整个系统用什么比例拆分,由 NVIC_PriorityGroupConfig() 决定,工程里在 main 开头调用一次:
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); // 2 位抢占 + 2 位子优先级
| 分组 | 抢占优先级位数 | 子优先级位数 | 抢占范围 | 子优先级范围 |
|---|---|---|---|---|
| Group_0 | 0 | 4 | — | 0~15 |
| Group_1 | 1 | 3 | 0~1 | 0~7 |
| Group_2 | 2 | 2 | 0~3 | 0~3 |
| Group_3 | 3 | 1 | 0~7 | 0~1 |
| Group_4 | 4 | 0 | 0~15 | — |
规则(记住这条就够了):
- 抢占优先级:决定能不能"插队"。抢占优先级高的中断,可以打断正在执行的、抢占优先级低的中断。
- 子优先级:只在抢占优先级相同时起决定作用,决定谁先执行,但不能互相打断。
- 数值越小,优先级越高。 0 比 3 更高优先级。
典型配置:用 Group_2,让需要实时响应的重要中断(比如定时器更新)用抢占 0,普通外设中断用抢占 1,子优先级用来细分同级别的先后。
1.6 STM32F103 常见中断源
| 中断 | 触发来源 | 对应 IRQn | 典型用途 |
|---|---|---|---|
| 外部中断 EXTI0~15 | GPIO 引脚电平变化 | EXTI0_IRQn ~ EXTI15_10_IRQn | 按键、传感器信号 |
| 定时器更新 | 计数器溢出/重装 | TIM2_IRQn、TIM3_IRQn... | 定时任务、心跳 |
| 串口 USART | 收到数据/发送完成 | USART1_IRQn... | 通信 |
| ADC | 转换完成 | ADC1_2_IRQn | 采样完成通知 |
中断向量表里每个中断源都有一个固定名字的 ISR,你只要定义同名函数,启动文件里的向量表就会自动指向它。名字错了 = 编译不报错但永远进不去,这是新手最常见的坑(见 1.10)。
1.7 外部中断 EXTI
GPIO 引脚怎么产生中断?靠 EXTI(External Interrupt,外部中断控制器)。
- STM32F103 有 16 条 EXTI 线(EXTI0 ~ EXTI15),每条线对应一个引脚编号。
- PA0、PB0、PC0... 都共用 EXTI0 这一条线,同一时间只能选其中一个引脚接到 EXTI0。这就是为什么"每个引脚都能产生中断,但同编号的引脚不能同时用"。
- 配置步骤里那行
GPIO_EXTILineConfig()就是干这个"把 PA0 接到 EXTI0"的活。
EXTI 的触发方式(这就是 0.4 节讲的边沿的用武之地):
| 触发方式 | 触发条件 | 用途 |
|---|---|---|
| 上升沿触发 | 电平从低跳高 | 松手瞬间、正沿信号 |
| 下降沿触发 | 电平从高跳低 | 按键按下(通常按下接地) |
| 上升/下降沿都触发 | 只要跳变就触发 | 电平翻转计数 |
1.8 标准库配置中断的"四步曲"
几乎所有中断的配置都是这个套路:
① 开启对应外设的时钟
② 配置外设本身(GPIO / 定时器 / 串口...)
③ 把外设的事件源接入 NVIC(NVIC_Init)
④ 在外设里使能"允许产生中断"(如 EXTI_LineCmd、TIM_ITConfig)
⑤ 编写对应名字的 ISR
注意 ③ 和 ④ 是两件事:NVIC 只管"内核愿不愿意受理",外设自己的使能开关管"这个事件发不发出去"。两个都要打开,缺一个中断都不会触发。
1.9 示例:按键触发外部中断
场景:按键接 PA0,按下接地(低电平有效,需要上拉)。每次按下,中断里翻转一次 LED(假设 LED 接 PB5,低电平点亮)。
// ============ main.c ============
#include "stm32f10x.h"
void EXTI_Config(void);
int main(void)
{
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); // ① 优先级分组,全局只设一次
EXTI_Config();
// 配置 PB5 为推挽输出(控制 LED)
GPIO_InitTypeDef gpio;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
gpio.GPIO_Pin = GPIO_Pin_5;
gpio.GPIO_Mode = GPIO_Mode_Out_PP;
gpio.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &gpio);
GPIO_SetBits(GPIOB, GPIO_Pin_5); // 初始熄灭(低电平点亮,先置高)
while (1)
{
// 主循环什么也不做,就等按键中断来打断
}
}
void EXTI_Config(void)
{
GPIO_InitTypeDef gpio;
EXTI_InitTypeDef exti;
NVIC_InitTypeDef nvic;
// ① 开启 GPIOA 时钟;EXTI 要选引脚,必须同时开 AFIO 时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE);
// ② 配置 PA0 为上拉输入(按键按下接地)
gpio.GPIO_Pin = GPIO_Pin_0;
gpio.GPIO_Mode = GPIO_Mode_IPU; // Input Pull-Up
gpio.GPIO_Speed = GPIO_Speed_50MHz; // 输入模式时 Speed 无实际意义,习惯写法
GPIO_Init(GPIOA, &gpio);
// ③ 把 PA0 映射到 EXTI0(关键!同编号引脚只能选一个)
GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0);
// ④ 配置 EXTI0:下降沿触发(按键按下的瞬间)
exti.EXTI_Line = EXTI_Line0;
exti.EXTI_Mode = EXTI_Mode_Interrupt;
exti.EXTI_Trigger = EXTI_Trigger_Falling;
exti.EXTI_LineCmd = ENABLE;
EXTI_Init(&exti);
// ⑤ 把 EXTI0 接到 NVIC,使能该中断
nvic.NVIC_IRQChannel = EXTI0_IRQn;
nvic.NVIC_IRQChannelPreemptionPriority = 1;
nvic.NVIC_IRQChannelSubPriority = 0;
nvic.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&nvic);
}
// ============ stm32f10x_it.c ============
// 中断服务函数的名字必须和向量表一致:EXTI0_IRQHandler
void EXTI0_IRQHandler(void)
{
if (EXTI_GetITStatus(EXTI_Line0) != RESET) // ① 确认是 EXTI0 触发的
{
// ② 翻转 LED:读出当前输出,取反写回
if (GPIO_ReadOutputDataBit(GPIOB, GPIO_Pin_5) == Bit_SET)
GPIO_ResetBits(GPIOB, GPIO_Pin_5); // 亮
else
GPIO_SetBits(GPIOB, GPIO_Pin_5); // 灭
EXTI_ClearITPendingBit(EXTI_Line0); // ③ 必须清标志,否则反复进中断
}
}
1.10 中断部分的常见坑
- ISR 名字拼错:
EXTI0_IRQHandler少个字母,编译不报错,中断却永远不进来。检查:函数名和中断向量表里写的一字不差。 - 忘了清标志位:卡死在中断里。任何外设中断,处理完都要清对应标志。
- 忘了开 NVIC 或忘了开外设中断使能:第 1.8 节说的两个开关缺一不可。
- EXTI 引脚映射冲突:
PA0和PB0不能同时用 EXTI0。另一个口想用中断就得换别的编号引脚。 - 共享变量不加
volatile:ISR 改、主循环读的变量必须volatile。 - 电平触发方式的抖动:机械按键按下瞬间电平会"弹跳"多次(抖动),一次按下可能触发好几次中断。简单处理:加几十毫秒软件消抖(见 4.2 综合实战的消抖思路)。
第二部分:定时器(Timer)
2.1 定时器是什么
定时器是一个会自动数数的硬件计数器。你告诉它"时钟每跳 N 下就报一次到",它就闷头数,数满了就产生一个更新事件(Update Event)——可以触发中断、或翻转引脚等。数数不占用 CPU,这是它比"软件延时"强的地方。
类比:闹钟。你设好"5 分钟后响",闹钟自己走针,到点"叮"一声(更新事件)。你该干嘛干嘛,不用盯着钟表。
2.2 定时器内部结构(一句话版)
时钟源(Timer Clock)
│ 分频
▼
预分频器 PSC ──→ 计数器 CNT(每来一个时钟 +1)
│
▼
和"重装载值 ARR"比较
│ 相等
▼
产生更新事件 / 更新中断
三个参数决定一切:
| 参数 | 作用 | 类比 |
|---|---|---|
| PSC(预分频器) | 把进来的时钟频率分低 | 闹钟的"秒针速度" |
| CNT(计数器) | 实际数的那个数 | 秒针走到的位置 |
| ARR(自动重装载值) | 计到多少就算"到点" | 闹钟的"响铃时间点" |
STMF103 通用定时器(TIM2~TIM5) 的结构就是上面这个。高级定时器(TIM1、TIM8)在此基础上多了刹车、互补输出等功能,先不管。
2.3 定时器的时钟从哪里来(容易算错的地方!)
定时器不是直接吃系统时钟,中间经过总线分频。STM32F103 的时钟树简化如下:
外部晶振 8MHz ──PLL 倍频──> SYSCLK 72MHz ──> AHB(72MHz)
├──> APB1 分频器(÷2)──> APB1 36MHz(挂 TIM2~TIM5 等)
└──> APB2 分频器(÷1)──> APB2 72MHz(挂 TIM1 等)
关键规则:定时器时钟 = 所在总线时钟,但如果所在总线的分频器 ≠ 1,则定时器时钟 = 总线时钟 × 2。
典型 72MHz 配置下:
| 定时器 | 所在总线 | APB 分频 | 定时器实际时钟 |
|---|---|---|---|
| TIM2~TIM5 | APB1 | ÷2 | 36 × 2 = 72 MHz |
| TIM1 | APB2 | ÷1 | 72 × 1 = 72 MHz |
所以无论通用还是高级定时器,典型配置下都是 72 MHz。但你必须懂这个规则:如果哪天你改了 APB1 分频,通用定时器时钟就可能不是 72 MHz,公式就会算错一倍。
新手最容易犯的错:看到 APB1 = 36 MHz,就直接用 36 MHz 套公式,结果定时时间慢一倍。通用定时器要按 72 MHz 算(APB1 分频 ≠ 1 时 ×2)。
2.4 定时时长计算公式(核心公式)
每个时钟周期的时间 = 1 ÷ 定时器时钟
一个计数周期的时间 = PSC + 1 个时钟周期 (CNT 每 PSC+1 下才加 1)
计满一轮的时间 = (PSC + 1) × (ARR + 1) 个时钟周期
定时时间 T = (ARR + 1) × (PSC + 1) ÷ 定时器时钟
为什么是
PSC+1、ARR+1?因为计数器从 0 开始数:ARR=999 表示数 0~999 共 1000 个值。初学者先记公式,理由后面 2.5 会再次出现。
例:用 TIM2(时钟 72 MHz)定时 1 秒。
1 秒 = 72,000,000 个时钟周期。把它拆成两个整数:
- 先分频 7200:
PSC = 7199,得到 72M ÷ 7200 = 10 kHz(每 0.1 ms 计数一次) - 再计 10000 次:
ARR = 9999,10 kHz ÷ 10000 = 1 Hz = 1 秒
验证:T = (9999+1) × (7199+1) ÷ 72,000,000 = 10000 × 7200 ÷ 72,000,000 = 1 s ✓
推荐分两步想(这也写代码时的思路):
第一步:PSC 把时钟分到一个"好算的中间频率"
第二步:ARR 决定数多少次 = 多少个中间频率的周期
2.5 示例:定时器中断,LED 每秒翻转一次
#include "stm32f10x.h"
void TIM2_Config(void);
int main(void)
{
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
// LED 引脚 PB5,推挽输出
GPIO_InitTypeDef gpio;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
gpio.GPIO_Pin = GPIO_Pin_5;
gpio.GPIO_Mode = GPIO_Mode_Out_PP;
gpio.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &gpio);
GPIO_SetBits(GPIOB, GPIO_Pin_5);
TIM2_Config();
while (1)
{
// 主循环闲着,靠 TIM2 更新中断驱动
}
}
void TIM2_Config(void)
{
TIM_TimeBaseInitTypeDef tim;
NVIC_InitTypeDef nvic;
// ① 开 TIM2 时钟(TIM2~TIM5 在 APB1 上)
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
// ② 配置时基:72MHz / 7200 = 10kHz,数 10000 下 = 1 秒
tim.TIM_Prescaler = 7199; // PSC,分频 7200
tim.TIM_CounterMode = TIM_CounterMode_Up; // 向上计数
tim.TIM_Period = 9999; // ARR
tim.TIM_ClockDivision = TIM_CKD_DIV1; // 滤波用,暂用 DIV1
tim.TIM_RepetitionCounter = 0; // 仅高级定时器(TIM1/8)有意义,这里填 0
TIM_TimeBaseInit(TIM2, &tim);
// ③ 打开 TIM2 的"更新中断"开关
TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE);
// ④ 接入 NVIC
nvic.NVIC_IRQChannel = TIM2_IRQn;
nvic.NVIC_IRQChannelPreemptionPriority = 0; // 定时器优先级设高一点
nvic.NVIC_IRQChannelSubPriority = 0;
nvic.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&nvic);
// ⑤ 启动定时器(容易忘!)
TIM_Cmd(TIM2, ENABLE);
}
// 中断服务函数,名字必须和向量表一致
void TIM2_IRQHandler(void)
{
if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET)
{
TIM_ClearITPendingBit(TIM2, TIM_IT_Update); // 清标志
// 翻转 LED
if (GPIO_ReadOutputDataBit(GPIOB, GPIO_Pin_5) == Bit_SET)
GPIO_ResetBits(GPIOB, GPIO_Pin_5);
else
GPIO_SetBits(GPIOB, GPIO_Pin_5);
}
}
2.6 定时器的其他计数模式(了解即可)
| 模式 | 计数方向 | 特点 |
|---|---|---|
| 向上计数(Up) | 0 → ARR → 回到 0 | 最常用,本笔记全用它 |
| 向下计数(Down) | ARR → 0 → 回到 ARR | 偶有用到 |
| 中心对齐(Center-aligned) | 0 → ARR → 0 来回 | PWM 特殊场合、电机控制常用 |
2.7 定时器 → PWM 的桥梁
请记住:PSC 决定 PWM 的频率,ARR 决定 PWM 的周期(频率),CNT 用来和 CCR 比较产生 PWM 波形。 定时器不是只用来"定时"的——它同时是"波形发生器"。第三部分讲的就是:定时器把"数数"变成"输出高低电平比例可调的方波"。
第三部分:PWM(脉冲宽度调制)
3.1 复习概念
- PWM 频率:方波一秒重复多少次(0.2 节讲过)。
- 占空比(Duty Cycle):一个周期里高电平占的比例。50% 占空比 = 一半时间高电平。
占空比 20%(频率 1kHz,周期 1ms):
高 ‾‾‾‾ ‾‾‾‾ ‾‾‾‾ ‾‾‾‾
| | | |
低 |_________| |__________|
|<0.2ms>| 高电平 0.2ms
|<----- 1ms ------->|
靠 0.5 节的平均电压原理:占空比高 → 平均电压高 → LED 亮 / 电机快。
3.2 为什么要用 PWM
| 应用 | 为什么用 PWM |
|---|---|
| LED 调光 / 呼吸灯 | 数字引脚只能亮/灭,用占空比骗出"亮度" |
| 电机调速 | 调占空比 = 调平均电压 = 调转速 |
| 舵机控制 | 特定频率的 PWM 脉宽决定转动角度 |
| 蜂鸣器音调 | PWM 频率决定音高 |
| 电源 / 开关电源 | 调占空比调输出功率 |
3.3 定时器怎么"产生" PWM
还记得定时器的 CNT 是闷头往上数的吗?PWM 就是再给它一个对比值 CCR(Capture/Compare Register,捕获/比较寄存器):
CNT 每来一个时钟 +1,和 CCR 比较:
CNT < CCR → 输出高电平
CNT ≥ CCR → 输出低电平
CNT 数满 ARR → 溢出回 0,一个周期结束,重新开始
CNT: 0 1 2 3 4 5 6 7 8 9 0 1 ...
CCR=3: 高 高 高 低 低 低 低 低 低 低 高 高 ...
|<---- 3 个高 ---->|<------ 7 个低 ------>|
所以:
- ARR 决定 PWM 周期(频率):
频率 = 定时器时钟 ÷ (PSC+1) ÷ (ARR+1) - CCR 决定占空比:
占空比 = CCR ÷ (ARR+1)(近似写法CCR ÷ ARR,ARR 较大时差别可忽略)
PWM 模式:上面"CNT<CCR 输出高"就是 PWM 模式 1;反过来(CNT<CCR 输出低)是 PWM 模式 2。绝大多数场景用模式 1 就够了。
3.4 PWM 频率与占空比计算
PWM 频率 = 定时器时钟 ÷ (PSC + 1) ÷ (ARR + 1)
占空比 = CCR ÷ (ARR + 1) (约等于 CCR ÷ ARR)
例如:定时器 72MHz,PSC=71,ARR=999,CCR=500
PWM 频率 = 72M ÷ 72 ÷ 1000 = 1 kHz
占空比 = 500 ÷ 1000 = 50%
设计 PWM 的思路也是两步:先用 PSC 把频率"挤"到想要的量级,再用 ARR 定频率、CCR 定占空比。
3.5 示例 1:TIM3 输出固定占空比的 PWM(LED 半亮)
用 TIM3_CH1,引脚 PA6,输出 1 kHz、50% 占空比。
#include "stm32f10x.h"
void TIM3_PWM_Config(void);
int main(void)
{
TIM3_PWM_Config();
while (1)
{
// 主循环什么都不做,PWM 由定时器硬件持续输出
}
}
void TIM3_PWM_Config(void)
{
GPIO_InitTypeDef gpio;
TIM_TimeBaseInitTypeDef tim;
TIM_OCInitTypeDef oc;
// ① 开时钟:TIM3 在 APB1;PA6 是复用功能引脚,开 GPIOA + AFIO
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE);
// ② 配置 PA6 为"复用推挽输出"——把引脚交给 TIM3 的通道1
gpio.GPIO_Pin = GPIO_Pin_6;
gpio.GPIO_Mode = GPIO_Mode_AF_PP; // 复用推挽(区别于普通输出的 Out_PP)
gpio.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &gpio);
// ③ 配置时基:72M / 72 = 1MHz,ARR=999 → PWM 1kHz
tim.TIM_Prescaler = 71;
tim.TIM_CounterMode = TIM_CounterMode_Up;
tim.TIM_Period = 999;
tim.TIM_ClockDivision = TIM_CKD_DIV1;
tim.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM3, &tim);
// ④ 配置通道1为 PWM 模式1,CCR=500 → 50% 占空比
oc.TIM_OCMode = TIM_OCMode_PWM1; // PWM 模式1
oc.TIM_OutputState = TIM_OutputState_Enable; // 使能 CH1 输出(容易忘!)
oc.TIM_Pulse = 500; // 就是 CCR1
oc.TIM_OCPolarity = TIM_OCPolarity_High; // 高电平有效
TIM_OC1Init(TIM3, &oc);
// ⑤ 使能预装载:中途改 CCR 时,等当前周期结束才生效,避免毛刺(见 3.7)
TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable);
TIM_ARRPreloadConfig(TIM3, ENABLE);
// ⑥ 启动定时器
TIM_Cmd(TIM3, ENABLE);
}
随时调整亮度:运行时只要一行 TIM_SetCompare1(TIM3, 新值); 就能改占空比(0~999)。LED 越亮还是越暗,取决于你的开发板 LED 是"高电平点亮"还是"低电平点亮",看原理图确认,必要时把 TIM_OCPolarity 设为 TIM_OCPolarity_Low。
3.6 示例 2:呼吸灯(定时器 + 中断 + PWM 三合一)
LED 从灭慢慢变亮、再慢慢变暗,循环往复。亮度 = 占空比,所以周期性地把 CCR 从 0 调到最大再调回来即可。
这里体现一个工程上的矛盾(很有价值):
- PWM 频率要够高(1kHz 以上)人才看不出闪烁 → 希望 ARR 小、频率高
- 呼吸速度要慢(1~2 秒一个来回)→ 希望"改 CCR"的动作频率要低
同一个定时器的 ARR 同时决定 PWM 频率和更新中断频率,没法两全。解决办法:用两个定时器分工——TIM3 专门出 PWM,TIM2 专门定时,在 TIM2 的更新中断里改 TIM3 的 CCR。
// TIM3:如上配置,输出 1kHz PWM(CCR 初始为 0 = 灭)
// TIM2:PSC=7199, ARR=999 → 10kHz/1000 = 每秒 10 次更新中断(即每 0.1s 步进一步)
// 组合效果:200 步 × 0.1s = 20s 一个来回?太慢。想快就把 TIM2 的 ARR 调小。
volatile uint16_t brightness = 0; // 当前亮度 0~999
volatile uint8_t direction = 1; // 1 变亮,0 变暗
// 上面两个变量被中断修改、被中断使用,加 volatile 保险
void TIM2_IRQHandler(void)
{
if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET)
{
TIM_ClearITPendingBit(TIM2, TIM_IT_Update);
// 每 0.1 秒步进 20,一个来回约 (999/20)*0.1*2 ≈ 10 秒,按需调整
if (direction)
brightness += 20;
else
brightness -= 20;
if (brightness >= 999) direction = 0;
if (brightness <= 0) direction = 1;
TIM_SetCompare1(TIM3, brightness); // 改 TIM3 的 CCR
}
}
调呼吸速度:改
20(步长)或改 TIM2 的ARR(更新频率),两者二选一。注意 TIM2 的更新中断频率不能太低,否则呼吸会"一顿一顿"。
3.7 PWM 常见坑
- 引脚模式配错了:输出 PWM 的引脚要配
GPIO_Mode_AF_PP(复用推挽),不是普通Out_PP。 - 忘了使能输出:
TIM_OutputState = TIM_OutputState_Enable漏了,引脚永远没波形。高级定时器(TIM1/8)还要额外TIM_CtrlPWMOutputs(TIM1, ENABLE)开主输出,通用定时器不用。 - CCR 不能超过 ARR:CCR 大于 ARR 时恒高电平(占空比视为 100%),CCR 为 0 时恒低(0%)。
PSC和ARR搞混:PSC 分的是"输入时钟",ARR 定的是"计数上限/周期"。写代码时对照公式逐项检查。- 极性搞反:LED 亮度反了(越调越暗),换
TIM_OCPolarity_Low/High。 - 看不到波形先怀疑频率:PWM 频率低于约 100Hz 时人眼和示波器都能看出明显闪烁,别急着调亮度,先查频率数量级对不对。
第四部分:综合实战——把三者串起来
项目需求:一个按键(中断)控制呼吸灯的速度,LED 亮度用 PWM(TIM3),呼吸节奏由定时器中断(TIM2)驱动。按键每次按下,切换快 / 慢两种呼吸速度。
为什么这个项目能检验你学懂了没有:中断(按键响应 + 定时器更新)、定时器(TIM2 定时)、PWM(TIM3 输出)三样东西全都用上了,还涉及 ISR 里共享变量、优先级分组这些细节。
功能设计:
| 模块 | 用什么 | 干什么 |
|---|---|---|
| 按键 PA0 + EXTI0 中断 | 中断 | 按下切换呼吸速度 |
| TIM2 更新中断 | 定时器 | 每隔固定时间步进一步亮度 |
| TIM3_CH1 / PA6 | PWM | 输出亮度对应的占空比 |
| LED 接 PA6(或经驱动电路) | 硬件 | 接受 PWM,显示呼吸 |
代码骨架(把前面 1.9 / 2.5 / 3.5 三份配置函数拼起来,核心逻辑如下):
volatile uint16_t brightness = 0;
volatile uint8_t direction = 1;
volatile uint16_t step = 5; // 按键切换的变量:快=20,慢=5
void EXTI0_IRQHandler(void) // 按键中断:切换速度
{
if (EXTI_GetITStatus(EXTI_Line0) != RESET)
{
EXTI_ClearITPendingBit(EXTI_Line0);
if (step == 20) step = 5; else step = 20; // 快慢切换
}
}
void TIM2_IRQHandler(void) // 定时器中断:步进亮度
{
if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET)
{
TIM_ClearITPendingBit(TIM2, TIM_IT_Update);
if (direction) brightness += step;
else brightness -= step;
if (brightness >= 999) direction = 0;
if (brightness <= 0) direction = 1;
TIM_SetCompare1(TIM3, brightness);
}
}
动手建议:
- 先把 1.9、2.5、3.5 三个示例分别调通,再合体成这个项目。
- 按键消抖:机械按键按下瞬间会弹跳,一次按下可能进好几次中断。简单的办法:EXTI 里翻转 LED 的同时,用 TIM2 延时或记时间戳做软件消抖。学有余力再研究。
- 改成"按键一次切换亮度"、"长按加速"等,都是很好的练习。
第五部分:调试与自查清单
现象:定时器不工作 / 时间不对
-
时钟开了吗?(TIM2~TIM5 用
RCC_APB1PeriphClockCmd) -
用了 72 MHz 还是 APB1 的 36 MHz?APB1 分频 ≠ 1 时通用定时器是 72 MHz。
-
TIM_Cmd(TIMx, ENABLE)启动了吗? -
公式代入验算一遍:
T = (ARR+1)×(PSC+1) ÷ 时钟。
现象:中断不触发
-
NVIC 使能了吗(
NVIC_Init)? -
外设自己的中断开关开了吗(
TIM_ITConfig/EXTI_LineCmd)? -
ISR 函数名和向量表一致吗?
EXTI0_IRQHandler不是EXTI0_IRQ_Handler。 -
EXTI 记得做引脚映射
GPIO_EXTILineConfig吗? -
别忘了开 AFIO 时钟。
现象:中断反复进入 / 卡死
- 清标志位了吗?(每个外设都有对应的
xxx_ClearITPendingBit)
现象:PWM 没有输出 / 波形不对
-
GPIO 配的是
GPIO_Mode_AF_PP吗? -
TIM_OutputState = TIM_OutputState_Enable了吗?(TIM1/8 还要TIM_CtrlPWMOutputs) -
引脚和定时器通道对得上吗?(如 PA6 = TIM3_CH1,PA0 不是 TIM3_CH1)
-
CCR 在 0 ~ ARR 之间吗?
通用排查顺序:先看时钟 → 再查引脚复用/模式 → 再查外设使能开关 → 最后查中断/NVIC。嵌入式 80% 的问题出在"配置没配齐",按这个顺序逐项打勾通常能找到。
附录:本笔记用到的标准库函数速查
| 函数 | 作用 |
|---|---|
RCC_APB1PeriphClockCmd(periph, ENABLE) |
开 APB1 总线上的外设时钟(TIM2~TIM5 等) |
RCC_APB2PeriphClockCmd(periph, ENABLE) |
开 APB2 总线上的外设时钟(GPIO、AFIO、USART1 等) |
NVIC_PriorityGroupConfig(group) |
设置优先级分组,main 里设一次 |
NVIC_Init(&nvic) |
把某个中断源接入 NVIC 并使能 |
GPIO_EXTILineConfig(port, pin) |
把某个引脚映射到 EXTI 线 |
EXTI_Init(&exti) |
配置 EXTI 触发方式 |
EXTI_GetITStatus(line) |
读某个 EXTI 线的中断标志 |
EXTI_ClearITPendingBit(line) |
清某个 EXTI 线的中断标志 |
TIM_TimeBaseInit(tim, &tb) |
配置 PSC / ARR / 计数模式 |
TIM_ITConfig(tim, it, ENABLE) |
使能定时器某类中断(如更新) |
TIM_GetITStatus(tim, it) / TIM_ClearITPendingBit(tim, it) |
读 / 清定时器中断标志 |
TIM_Cmd(tim, ENABLE) |
启动定时器 |
TIM_OCxInit(tim, &oc) |
配置输出比较通道(PWM 模式) |
TIM_SetComparex(tim, value) |
运行时改第 x 通道的 CCR(调占空比) |
TIM_OCxPreloadConfig / TIM_ARRPreloadConfig |
使能预装载,避免中途改动产生毛刺 |
学习路线建议(接在本文之后)
- 串口 USART:把变量用
printf打印到电脑,从此"看得见"程序在干什么。学中断收发。 - ADC:用中断 + 定时器把模拟量(电位器)读进来看,体会"数字系统接模拟世界"。
- 输入捕获:用定时器测外部信号的频率/脉宽,反过来用中断和 PWM 的知识。
- DMA:让外设自己搬运数据,把 CPU 彻底解放出来。
别忘了你的工具书:《STM32F10x参考手册_中文版》里"定时器"和"EXTI"两章,配合本笔记边查边看,印象更深。