简介:面向STM32嵌入式开发者的TIM1高级定时器PWM中断输出示例工程,重点演示通过更新中断与计数器配合,实现指定个数PWM脉冲的精确控制,适合需要掌握高级定时器单脉冲/多脉冲输出、中断标志处理及占空比配置的中级开发者。压缩包为完整Keil工程,共374个文件,包含C源程序(如stm32f10x_tim.c、os_core.c、lib_mem.c)、汇编启动文件、HAL库头文件、链接脚本及uvprojx工程配置,编译配置、调试记录和日志文件也一应俱全,整体约8.34MB,结构清晰,便于直接打开工程对照学习。资源已有657人浏览下载,代码中涵盖TIM1通道初始化、PWM周期与占空比设置、中断服务例程编写及脉冲计数终止逻辑,并附带uC/OS-II相关文件可供参考;对于想理解高级定时器中断方式输出受限场景的读者,可结合工程内库函数调用和寄存器操作快速上手,减少环境搭建和排错时间。
1. 从「数脉冲」到「配个数」:一个寄存器解决指定数量 PWM 输出
台子上那台步进驱动器,控制板一上电就用 PWM 给方向脉冲,跑起来之后我发现一个问题——明明只想发 30 个脉冲,示波器上却数出 31 个,停下来之后还带一个毛刺。后来查到原因,问题出在一开始就把定时器的更新中断当成计数器用,每溢出一次就在中断里做一次自减,时序稍有抖动,个数就偏了。这不是玄学,是 PWM 输出模型的边界没理清。这个项目要解决的,就是用 TIM1 高级定时器的重复计数寄存器(RCR),配合更新中断的方式,让 MCU 输出指定个数 PWM。它面向的是步进电机驱动、传感器激励、红外载波这类需要「发够 N 个就停」的场景,适合已经在用定时器做 PWM、但被脉冲个数精度和中断开销卡住的开发者。
2. 方案先导:高级定时器与 RCR 重复计数的配合逻辑
2.1 为什么是 TIM1 而不是通用定时器
手头这块 MCU 里,通用定时器(比如 TIM2、TIM3)和高级定时器(TIM1)最大的区别不是通道多不多,而是有没有重复计数寄存器。通用定时器的更新事件在每个计数周期都会产生,想输出 N 个 PWM 就得靠软件在中断里自减计数,中断频率等于 PWM 频率,1kHz 的 PWM 就是每秒 1000 次中断,处理器相当一部分时间花在进出中断上。
TIM1 这类高级定时器多了一个 RCR 寄存器,它的作用是把「更新事件」和「计数溢出」解耦。RCR 不为 0 时,计数器要溢出 RCR+1 次才会产生一次更新事件。这意味着可以把软件里的 N 次中断压缩成 1 次,CPU 只在最后收尾时被唤醒一次。这个差异在脉冲个数大、PWM 频率高的场景特别明显——我做过一个 20kHz 的超声激励信号,用通用定时器的方式做,中断每秒 2 万次,主循环几乎被饿死;换成 RCR 之后,中断每秒只有几十次,负载直接降了两个数量级。
注意,高级定时器的 RCR 在不同系列上有效位宽不一样,老一代 M3/M4 内核的型号上只用了低 8 位,也就是一次最多配 255。后面写代码时会单独处理这个限制。
2.2 三种实现路线对比
「输出指定个数 PWM」在工程里大体有三条路,网上能找到的例程基本都逃不出这三类。
| 方案 | 实现思路 | CPU 占用 | 个数精度 | 适用场景 |
|---|---|---|---|---|
| A. 软件计数 | 每周期溢出进中断,软件自减到 0 停止 | 高,中断次数 = PWM 个数 | 受中断延迟影响 | 临时验证、个数很少 |
| B. RCR 重复计数 | 更新事件被稀释为 N 次溢出一次,中断收尾 | 低,中断次数 = 1 | 精确到完整周期 | 常规项目首选 |
| C. 主从定时器级联 | 用另一个定时器的更新事件驱动/门控 TIM1 | 极低,几乎零中断 | 纯硬件行为,最稳定 | 个数大、要求无软件干预 |
这个项目用的是方案 B。方案 A 的问题是中断延迟不可控,我在 2.1 里说的「30 个脉冲变成 31 个」就是这么来的:最后一次自减到 0 之后,定时器还在跑,下次比较匹配已经把高电平拉起来了,停都来不及。方案 C 很干净,但对硬件连接有要求,做复杂序列的时候才值得上,后面第 5 章会展开。
2.3 RCR 的行为细节
RCR 是影子寄存器,这个细节直接影响调试方向。在标准外设库的初始化结构体里叫 TIM_RepetitionCounter,运行中直接写 TIM1->RCR 是改影子值,真正生效要等下一次更新事件把预装载值搬进工作寄存器。也就是说,改 RCR 的时机和清更新标志的时机是有讲究的。
具体行为可以记成这个对照关系:
| RCR 配置值 | 计数溢出次数 | 产生更新事件的时机 | 实际输出完整 PWM 周期数 |
|---|---|---|---|
| 0 | 1 | 第 1 次溢出(默认每周期中断) | 1 个 |
| 1 | 2 | 第 2 次溢出 | 2 个 |
| N-1 | N | 第 N 次溢出 | N 个 |
所以要输出 N 个 PWM,RCR 写的是 N-1。这个「减一」很容易记反,我在项目里第一次就写成了 N,结果每个批次多了 1 个脉冲,逻辑分析仪上一看包络长度永远多一个周期。这个坑后面避坑章节还会再提。
提示:RCR 的预装载特性对运行时改参数有影响。如果定时器正在跑,直接写 TIM1->RCR,要等当前这一轮计数完成才生效,不是立即生效。
3. 核心实现拆解:初始化、启停接口与中断回调的完整代码
3.1 时基与 GPIO 初始化:先把工程骨架搭起来
下面是标准外设库风格的初始化代码,工程里我习惯按「时钟 → 引脚 → 时基 → 输出比较 → 中断」的顺序写,每一步都对应芯片参考手册里的一个寄存器组,排查时也按这个顺序倒着查。
static void tim1_gpio_init(void) { GPIO_InitTypeDef gpio; // TIM1_CH1 默认在 PA8,如果目标板引到了其他引脚,查重映射表 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); gpio.GPIO_Pin = GPIO_Pin_8; gpio.GPIO_Mode = GPIO_Mode_AF_PP; // 复用推挽,PWM 输出必须用 AF 模式 gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); }PA8 的模式必须配成 GPIO_Mode_AF_PP,我在早期把这里配成 GPIO_Mode_Out_PP,结果引脚电平始终是死的,因为定时器的输出通路根本没有连到引脚上。这个错误很隐蔽,GPIO 初始化本身不会报错,示波器上也看不出异常,就是没有波形。
接着是时基初始化。注意 TIM_TimeBaseInitTypeDef 里有个 TIM_RepetitionCounter 字段,它就是 RCR 的初始化入口。这里先填 0,表示默认每周期溢出都产生更新事件,具体个数在启动函数里动态改。
static void tim1_base_init(void) { TIM_TimeBaseInitTypeDef tb; RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM1, ENABLE); tb.TIM_Prescaler = 71; // 72MHz / 72 = 1MHz 计数频率 tb.TIM_CounterMode = TIM_CounterMode_Up; tb.TIM_Period = 999; // 1MHz / 1000 = 1kHz PWM tb.TIM_ClockDivision = TIM_CKD_DIV1; tb.TIM_RepetitionCounter = 0; // RCR 初值:每个计数周期产生一次更新事件 TIM_TimeBaseInit(TIM1, &tb); }PSC 和 ARR 的计算关系是:PWM 频率 = 定时器时钟 / (PSC+1) / (ARR+1)。上面配置里 72MHz 经过 PSC=71 分频后,计数频率是 1MHz,再除以 ARR+1=1000,得到 1kHz。占空比则由输出比较值 Pulse 决定,Pulse 写 500 就是 50%,写 250 就是 25%。
3.2 输出比较通道与更新中断配置
PWM 的输出模式、极性和占空比在输出比较结构体里配置。这里用 PWM1 模式,向上计数时计数小于 Pulse 输出有效电平,大于 Pulse 输出无效电平,这样改变 Pulse 就能线性调占空比,直觉上最好用。
static void tim1_ch1_pwm_init(void) { TIM_OCInitTypeDef oc; oc.TIM_OCMode = TIM_OCMode_PWM1; oc.TIM_OutputState = TIM_OutputState_Enable; // 主通道输出使能 oc.TIM_Pulse = 500; // 占空比 50% oc.TIM_OCPolarity = TIM_OCPolarity_High; // 高电平为有效电平 TIM_OC1Init(TIM1, &oc); TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable); // 更新中断,优先级分组设为抢占 1、子优先级 0 NVIC_InitTypeDef nvic; nvic.NVIC_IRQChannel = TIM1_UP_IRQn; nvic.NVIC_IRQChannelPreemptionPriority = 1; nvic.NVIC_IRQChannelSubPriority = 0; nvic.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&nvic); TIM_ITConfig(TIM1, TIM_IT_Update, ENABLE); }TIM_OCPolarity_High 这个字段决定了输出无效电平是低还是高。很多人只关注占空比,忽略了这个极性,后面会出现「停止之后引脚停在低电平还是高电平」这种问题。做步进电机脉冲输出时,通常希望停止后引脚回到低电平,所以极性选 High,这样 PWM 周期内高电平是有效区间,停止后自然回到低电平。
3.3 启动函数:动态配置 RCR 与主输出使能
核心的启动函数是项目里最该抄的一段。它的职责是:清零计数器 → 写入 RCR → 清残留中断标志 → 启动定时器 → 打开主输出。注意顺序,尤其最后两步。
void pwm_send_burst(uint16_t count) { if (count == 0) return; TIM_SetCounter(TIM1, 0); // 计数器清零,保证从完整周期起点开始 TIM1->RCR = count - 1; // 关键:N 个脉冲写 N-1 TIM_ClearFlag(TIM1, TIM_FLAG_Update); // 清掉上一次可能残留的更新标志 TIM_Cmd(TIM1, ENABLE); // 先启动计数 TIM_CtrlPWMOutputs(TIM1, ENABLE); // 再打开高级定时器主输出 }这里有两个点需要解释。第一,TIM1->RCR 直接写寄存器而不是重新调用 TIM_TimeBaseInit,因为后者会把 PSC、ARR 全部重写一遍,副作用太大。运行中改 RCR 就该直接操作寄存器,这也是标准外设库时代最常见的写法。第二,为什么要先 TIM_Cmd 再 TIM_CtrlPWMOutputs?因为高级定时器的主输出使能位(MOE)默认是关闭的,如果先开 MOE 再启动定时器,部分芯片在极端时序下会在引脚上蹦出一个异常电平。先启动计数、再放行输出,能保证引脚上看到的第一个沿是正常 PWM 周期的一部分。
count 参数的类型是 uint16_t,但前面提过老型号 RCR 有效位只有 8 位,所以传超过 256 的值会被截断。项目里我加了一层保护:count 超过 255 时,拆成多段发送,或者改用主从定时器方案。这一点看目标芯片,新系列普遍已经把 RCR 扩成 16 位,可以放宽。
3.4 中断服务函数与主程序调用
中断里做的事很少:确认更新标志 → 清标志 → 关闭主输出 → 停止计数 → 置完成标志。这里只做收尾,不做任何耗时操作。
volatile uint8_t burst_done = 0; volatile uint32_t burst_count = 0; // 记录已完成的批次,用于自测 void TIM1_UP_IRQHandler(void) { if (TIM_GetITStatus(TIM1, TIM_IT_Update) == RESET) return; TIM_ClearITPendingBit(TIM1, TIM_IT_Update); // 能走到这里,说明 RCR 已经倒数到 0,N 个 PWM 全部输出完毕 TIM_CtrlPWMOutputs(TIM1, DISABLE); // 先封锁输出,停在无效电平 TIM_Cmd(TIM1, DISABLE); // 再停计数,省电 burst_done = 1; burst_count++; }逻辑上要注意一点:中断触发的那一瞬间,第 N 个完整周期已经结束,计数器其实已经溢出并开始进入第 N+1 个周期。但因为先关了 MOE,引脚不会再产生新的有效电平,所以波形在包络层看是严格 N 个周期。如果反过来先停定时器再关 MOE,中间几微秒的间隙里引脚状态不受控,负载端就可能多收一个脉冲。
主程序里的调用方式很简单,启动后轮询 burst_done,完成后延时再做下一批:
int main(void) { tim1_gpio_init(); tim1_base_init(); tim1_ch1_pwm_init(); while (1) { pwm_send_burst(50); // 输出 50 个 1kHz PWM while (!burst_done) ; // 等待批次完成 burst_done = 0; delay_ms(200); // 间隔 200ms 再发下一批 } }delay_ms 只是示意,实际工程里可以用状态机或者 RTOS 的信号量替代轮询。中断里把 burst_done 置 1,主循环清 0,这个模式简单但可靠,只要别在中断里清标志就行——清晚了会丢掉下一次完成事件。
4. 实战避坑记录:高级定时器 PWM 输出最容易翻车的五个细节
4.1 现象:高级定时器完全没有波形,引脚电平一动不动
这是 TIM1 和通用定时器最大的区别。通用定时器初始化完就能输出,高级定时器多了一道「主输出使能」的闸门,库函数对应 TIM_CtrlPWMOutputs。忘了调用或者调用了 DISABLE,输出级电路直接断开,示波器上只有直流电平。
原因:高级定时器的 MOE 位默认是 0,OC 信号被封锁。解决:初始化完成后调用一次 TIM_CtrlPWMOutputs(TIM1, ENABLE),后续每次启动也都要带上这一句。我见过有人只在初始化里调一次,然后通过 TIM_Cmd 启停定时器,结果第二次启动后依然没波形——因为停止时把 MOE 关了。统一的写法是:启动函数里先 TIM_Cmd 再 TIM_CtrlPWMOutputs,停止函数里先 TIM_CtrlPWMOutputs 再 TIM_Cmd,两个函数永远配对出现。
4.2 现象:脉冲个数总是比设定值多 1 或少 1
早期版本我在启动函数里写 TIM1->RCR = count,发 50 个脉冲,示波器数出来 51 个。原因很简单,RCR 的工作方式是「溢出 RCR+1 次产生一次更新事件」,所以 N 个脉冲对应 RCR=N-1,写 N 自然就多了一个。
反过来少 1 的情况也有,一般出现在极性配置不对时:PWM 极性配成 High,第一个周期开始前引脚已经是高电平,负载把这一小段高电平也算成了一个脉冲。解决:用逻辑分析仪看整个波形的包络,别靠示波器肉眼数高频脉冲;再检查 TIM_OCPolarity 和刹车/空闲电平配置。严格来说,中断关闭 MOE 的动作有几微秒延迟,停止点与理想边界之间存在不超过一个周期的相位差,这在绝大多数负载下可忽略,但做计量类应用时要用方案 C 的硬件联动来彻底消除。
4.3 现象:第一批波形正常,第二批开始第一个脉冲变窄或者畸变
复现路径:第一批发完后,定时器停在随机计数值上,下一次启动时没有清零计数器,直接启动,于是第一个 PWM 周期从半截开始,看起来就是一个窄脉冲。
原因:停止时计数器的 CNT 停在哪里是不确定的。解决:pwm_send_burst 函数里第一行就做 TIM_SetCounter(TIM1, 0),保证每次都是完整周期起点。同理,如果需要批次间隔完全相同,停止之后手动把 ARR 或比较值复位,避免上一次遗留的配置污染下一次波形。
4.4 现象:运行中改了 RCR,但输出个数没有任何变化
RCR 是影子寄存器,预装载值要等一次更新事件才进入工作寄存器。如果定时器正跑着,写完 TIM1->RCR 后计数器还没溢出,新值就一直躺在预装载里,自然不生效。
解决:改 RCR 必须在定时器停止时进行。标准顺序是:TIM_Cmd(DISABLE) → 写 TIM1->RCR → TIM_ClearFlag 清更新标志 → TIM_SetCounter 清零 → TIM_Cmd(ENABLE) → TIM_CtrlPWMOutputs(ENABLE)。我习惯把这个顺序封装进启动函数,不让外部随意改 RCR,避免使用者把「运行时动态改个数」想得太简单。
4.5 现象:中断触发频率远超预期,主程序卡顿明显
如果你把 RCR 写成 0 或者没配 RCR,TIM1 的更新中断就是每个计数周期触发一次。1kHz 的 PWM 每秒 1000 次中断,每个中断里还要清标志、置标志,虽然单次耗时短,但频繁打断会让主循环的实时性变得很难看。
原因有两类:一是 RCR 没有按需配置,中断没有被「稀释」;二是中断服务函数里忘了清标志,导致中断反复进入。解决:确认 TIM1->RCR = count - 1 之后,再在完成批次后调用 TIM_ITConfig(TIM1, TIM_IT_Update, DISABLE) 关掉更新中断,下次启动时重新使能。这样整个批次运行期间中断只来一次,收尾时关中断,彻底消除风暴。
注意:上面几条坑有很强的关联性,第 4.1 和第 4.4 往往同时出现。排查时先确认 MOE,再确认 RCR,最后看中断标志,按这个顺序能少走很多弯路。
5. 二次开发思路:从固定个数到动态序列的三种改造路径
这个项目的骨架搭好之后,扩方向基本就三个。第一个是彻底摆脱软件收尾,用另一个定时器做硬件联动:把 TIM2 配置为产生指定个数的更新事件,通过内部触发线接到 TIM1 的从模式,让 TIM1 工作在外部时钟模式 1,TIM2 每产生一个事件 TIM1 就计一个数。这样脉冲个数完全由硬件决定,中断只做完成通知,个数上限也从 255 扩展到 TIM2 的 ARR 位宽。配置核心就两行:
TIM_SelectInputTrigger(TIM1, TIM_TS_ITR1); // ITR 编号以参考手册为准 TIM_SelectSlaveMode(TIM1, TIM_SlaveMode_External1);第二个改造方向是用 DMA 批量更新 RCR,做出「一串脉冲流」而不是单一脉冲批。比如步进电机的 S 形加减速曲线,需要连续修改脉冲周期或者脉冲个数,DMA 可以先把一组 RCR 值放在数组里,TIM1 每次更新事件触发一次 DMA 搬运,硬件自动切换下一批的个数。这个技巧适合做激励信号序列或者电机测试,省掉主循环逐段切换的时延。
第三个方向是验证手段。我每次改完都会在中断里加一个 GPIO 翻转,用示波器同时看 PWM 输出和翻转引脚,确认中断只在批次结束时来一次;再用逻辑分析仪统计每一批的上升沿数量,和设定值比对。这套自测流程让我后面做多通道同步输出时,能快速定位是通道配置问题还是 RCR 共享导致的问题——毕竟 RCR 在 TIM1 里是全局的,两个通道只能共用同一个个数。
从那以后,我每次做定时器输出类需求,都会强制走一遍「先定方案,再算寄存器,最后用逻辑分析仪数包络」的流程,特别是 RCR 这个「减一」的细节,已经刻进肌肉记忆了。希望这篇拆解能帮你少踩几个我已经踩过的坑。
本文还有配套的精品资源,点击获取