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 Hz1 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:

  1. ISR 要短。在 ISR 里停留越久,主程序被耽误越久。复杂的处理应该只在 ISR 里"记个标记/保存数据",回主循环再慢慢做。
  2. ISR 里不要用延时。特别是几百毫秒的延时,会把整个系统卡死。
  3. ISR 和主程序共享的变量,要加 volatile。否则编译器可能把变量优化进寄存器缓存,ISR 改了主程序看不到。volatile 就是告诉编译器"这个变量随时可能被外部改变,每次用都重新去内存读"。
  4. 退出 ISR 前,一定要清除中断标志位(大部分外设)。忘了清 = 标志一直为 1 = 立刻又进中断 = 死循环卡在中断里。
  5. 中断优先级、使能配置要一次做对,特别是优先级分组(见 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 中断部分的常见坑

  1. ISR 名字拼错EXTI0_IRQHandler 少个字母,编译不报错,中断却永远不进来。检查:函数名和中断向量表里写的一字不差。
  2. 忘了清标志位:卡死在中断里。任何外设中断,处理完都要清对应标志。
  3. 忘了开 NVIC 或忘了开外设中断使能:第 1.8 节说的两个开关缺一不可。
  4. EXTI 引脚映射冲突PA0PB0 不能同时用 EXTI0。另一个口想用中断就得换别的编号引脚。
  5. 共享变量不加 volatile:ISR 改、主循环读的变量必须 volatile
  6. 电平触发方式的抖动:机械按键按下瞬间电平会"弹跳"多次(抖动),一次按下可能触发好几次中断。简单处理:加几十毫秒软件消抖(见 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+1ARR+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 常见坑

  1. 引脚模式配错了:输出 PWM 的引脚要配 GPIO_Mode_AF_PP(复用推挽),不是普通 Out_PP
  2. 忘了使能输出TIM_OutputState = TIM_OutputState_Enable 漏了,引脚永远没波形。高级定时器(TIM1/8)还要额外 TIM_CtrlPWMOutputs(TIM1, ENABLE) 开主输出,通用定时器不用。
  3. CCR 不能超过 ARR:CCR 大于 ARR 时恒高电平(占空比视为 100%),CCR 为 0 时恒低(0%)。
  4. PSCARR 搞混:PSC 分的是"输入时钟",ARR 定的是"计数上限/周期"。写代码时对照公式逐项检查。
  5. 极性搞反:LED 亮度反了(越调越暗),换 TIM_OCPolarity_Low/High
  6. 看不到波形先怀疑频率: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. 先把 1.9、2.5、3.5 三个示例分别调通,再合体成这个项目。
  2. 按键消抖:机械按键按下瞬间会弹跳,一次按下可能进好几次中断。简单的办法:EXTI 里翻转 LED 的同时,用 TIM2 延时或记时间戳做软件消抖。学有余力再研究。
  3. 改成"按键一次切换亮度"、"长按加速"等,都是很好的练习。

第五部分:调试与自查清单

现象:定时器不工作 / 时间不对

  • 时钟开了吗?(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 使能预装载,避免中途改动产生毛刺

学习路线建议(接在本文之后)

  1. 串口 USART:把变量用 printf 打印到电脑,从此"看得见"程序在干什么。学中断收发。
  2. ADC:用中断 + 定时器把模拟量(电位器)读进来看,体会"数字系统接模拟世界"。
  3. 输入捕获:用定时器测外部信号的频率/脉宽,反过来用中断和 PWM 的知识。
  4. DMA:让外设自己搬运数据,把 CPU 彻底解放出来。

别忘了你的工具书:《STM32F10x参考手册_中文版》里"定时器"和"EXTI"两章,配合本笔记边查边看,印象更深。