资讯详情

资讯详情

STM32定时器寄存器实战:PSC/ARR配置与PWM呼吸灯实现

兄弟到第八章了。这一章我忍了很久《STM32F103 寄存器白话手册》里如果把定时器落下后面聊 PWM、输入捕获、编码器全都不好使。所以咱这一章直接啃通用定时器用寄存器把它从头配一遍目标特别具体一个 1ms 的定时中断再把它扩成一个 PWM 呼吸灯。你会看到 PSC、ARR、CNT 这些寄存器到底是什么逻辑关系也会明白为什么库里封了一层你反而容易踩坑。这一章适合理清 GPIO 和时钟树之后、想摆脱库函数的人建议手边开一份参考手册边看边试。1. 在动手之前定时器的三个“为什么”1.1 为什么不继续用库函数寄存器操作到底好在哪很多人在开发板上习惯了点灯点得飞快一旦要定时就调用TIM_TimeBaseInit完全不管它填的什么参数。麻烦在于这样用三个月你还是说不清“PSC 为什么要减一”“ARR 为什么要减一”“APB1 预分频之后定时器时钟到底是 36M 还是 72M”。寄存器操作逼你把原理弄清楚。TIM2-PSC 71这句话写得明摆着预分频值是 72不是 71。TIM2-ARR 999也不会被“封装”成一个装模作样的“自动重载值”它真的就是计数目标值 999一个周期过 1000 个时钟。代码里没有中间商手册写什么你就写什么。而且调试的时候库里函数一层套一层看反汇编非常痛苦寄存器版本就几行赋值逻辑一目了然。这才是做嵌入式该有的基本功。以后跳到别的 MCU 平台不管用哪个厂家的库能看懂寄存器层的人永远不吃亏。1.2 为什么偏偏把定时器拎出来写一整章STM32F103 内部最常用的外设里定时器应该是信息密度最高、最容易“以为会了但还是错”的一个。它不只是用来延时还能输出 PWM、做输入捕获、编码器接口甚至完成多路电机控制。这些功能本质都是同一套时基单元在跑只是不同的外围寄存器参与进来。你只有把时基单元那点事彻底搞明白后面才不会被霍尔传感器、超声波测距、无刷电机控制之类的东西卡住。另一个原因是库函数在这里更容易掩盖错误。比如TIM_Cmd到底改了哪个寄存器TIM_TimeBaseInit有没有帮你触发 UG 更新事件这些细节全封进去了。你根本不知道影子寄存器有没有生效测出来中断乱跳只能干瞪眼。所以我这一章坚持零库函数全部寄存器直写让问题在你面前暴露出来。2. 先认识通用定时器别急着写代码2.1 定时器家族你得先分清谁是谁STM32F103 里面定时器分三类很多人上来就是一句“用定时器”但你先得知道选哪个定时器类别特点典型用途TIM1、TIM8高级控制定时器有互补输出、刹车输入、死区控制电机 PWM、全桥驱动TIM2、TIM3、TIM4、TIM5通用定时器有输入捕获、输出比较能做标准 PWM延时、呼吸灯、测频、编码器TIM6、TIM7基本定时器只能做时基没有输入输出引脚系统时基、触发 DAC我们这章拿 TIM2 开刀。它是通用定时器挂在 APB1 总线上引脚也灵活功能正好覆盖定时中断和 PWM 两个核心需求。够用了。这章聊的是时基单元也就是预分频器 PSC、计数器 CNT、自动重装载寄存器 ARR 这三个家伙。CNT 就像秒表PSC 把高速时钟切成慢节奏脉冲ARR 设定一个终点秒表走到终点就归零重跑。理解这三个角色后面全是顺水推舟。2.2 PSC、ARR、CNT 三个寄存器是怎么回事先说预分频器 PSC。它输入是定时器时钟输出是计数脉冲。公式只有一个计数脉冲频率 定时器时钟频率 / (PSC 1)写 PSC 的时候寄存器里存的是“分频系数减一”。比如你想把 72MHz 分成 1MHz那就是 72MHz / 72所以写 PSC 71。为什么是 71因为计数器从 0 数到 71 正好过了 72 个时钟脉冲才产生一个输出。这个“减一”逻辑贯穿所有分频和重装计算。CNT 就是计数器本身。它每个脉冲加一或者减一具体看计数方向。我们默认向上计数从 0 开始加到 ARR 的瞬间一个计数周期结束。这里又来了“ARR 为什么也要减一”。因为周期数是ARR 1。你想让计数器数 1000 次之后归零ARR 就得写 999。你要是写 1000实际数了 1001 个脉冲时间就多了千分之一低频你可能觉察不到做高精度频率计就露馅了。把两者合起来算一条完整公式定时器中断频率 定时器时钟 / (PSC 1) / (ARR 1)要 1ms也就是 1000Hz我们站在 72MHz 时钟上72MHz / 72 / 1000 1000Hz所以 PSC 71、ARR 999。这套参数在开发板里出现频率极高你最好一眼能背出来。2.3 APB1 那个坑为什么定时器时钟不是 36M新手最容易在这里爆雷。STM32F103 最常见的系统时钟配置是 72MHz但 APB1 总线最高只能跑 36MHz所以系统启动代码会把 APB1 预分频设成 2 倍分频。这时候你看时钟树会看到 APB1 上挂着 36MHz。于是有人想当然TIM2 挂在 APB1所以定时器时钟是 36MHz。错。这里有个隐藏规则如果 APBx 预分频系数是 1定时器时钟才等于 APBx 频率如果 APBx 预分频系数大于 1定时器时钟直接翻倍等于 APBx 频率的 2 倍。也就是说APB1 36MHz 且分频系数为 2那么 TIM2 的时钟其实还是 72MHz。为什么这样设计因为定时器内部往往要输出高精度 PWM给它低时钟就浪费了硬件能力。很多串口波特率不正常、定时时间偏一倍的案例根子都在这。你直接改RCC-APB1ENR打开 TIM2 时钟然后按 72MHz 算参数就对了。3. 几个核心寄存器直接“白话翻译”一遍3.1 CR1 控制寄存器启动开关、自动重装开关、更新开关CR1 是控制寄存器16 位但我们现阶段抓四个重要的位就够位名字白话含义Bit0CEN计数器使能。写 1 开始跑写 0 停止。Bit1UDIS更新禁止。写 1 后更新事件被屏蔽影子寄存器不刷新一般我们不设。Bit2CMS中心对齐模式选择做普通 PWM 和定时中断时保持 00。Bit4DIR计数方向。0 向上计数1 向下计数向上计数最常见。Bit7ARPE自动重装载预装载使能。设 1 时ARR 值要等更新事件才真正生效设 0 时写 ARR 立刻生效。ARPE 特别值得说。你给 ARR 写了一个新值寄存器里确实写进去了但硬件不一定会立刻用。如果 ARPE 1那这个值只是一个“预装载”要等更新事件发生才跳到“影子寄存器”影子寄存器才是实际参与计数的那个。如果你初始化时忘了触发更新事件可能启动半天了用的还是复位值定时时间完全不对。新手建议第一步先弄清楚这个概念因为它比普通库写着“Autoreload preload”要有意义得多。CR1 本身直接操作我们也就写一行TIM2-CR1 | (1 7); // ARPE1使用预装载特性 TIM2-CR1 | (1 0); // CEN1计数器开始跑3.2 DIER 和 SR中断怎么开标志怎么清DIER 是中断使能寄存器。我们开定时器更新中断只动一个位TIM2-DIER | (1 0); // UIE1更新中断使能SR 是状态寄存器。这里有个 STM32 的特色很多标志位是“写 0 清除”。UIF 这个更新标志就是硬件置 1、软件写 0 清除。很多人第一次清标志会想“我把这个位置 1 不就行了吗”于是写TIM2-SR | (1 0)结果发现中断死循环。因为对写 0 清除的位来说写 1 根本没有任何动作你等于没清。正确写TIM2-SR (uint16_t)~(1 0); // 把 UIF 写 0清除更新标志还有个细节清除动作放在中断服务函数末尾或者一进来先清都行。我个人习惯先判断再清除然后把业务逻辑放后面省得后续代码时间长了漏掉标志导致莫名其妙多进一次。3.3 EGR 寄存器为什么需要软件触发一个更新EGR 只要认识 Bit0也就是 UG 位。软件写一下这个位硬件立刻产生一次更新事件。更新事件会做三件事把预装载值搬到影子寄存器、让 CNT 归零、置位 UIF。很多人初始化的时候PSC 和 ARR 都写好了但总感觉定时不对。原因就是没让更新事件生效。正确顺序是先把参数写进去再通过TIM2-EGR | (1 0)触发一次更新让参数真正被硬件接收。这一步在库函数TIM_TimeBaseInit里面也有只是你看不见。我强烈建议你每次改完 PSC 或 ARR都主动写一次 UG别指望“系统会自动处理”。系统确实会处理但什么时候处理不确定靠运气写代码早晚出事。3.4 为 PWM 准备的寄存器CCMR、CCER、CCRPWM 输出其实是在时基单元之上再加一套“输出比较”电路。CCMR 寄存器用来选择输出比较模式CCER 用来使能某个通道的输出CCR 用来设定比较值。三者配合起来计数器 CNT 和 CCR 一比较引脚电平就会翻转从而输出不同占空比的方波。CCMR1 里面 OC1M 位是输出模式选择PWM 模式 1 是 110PWM 模式 2 是 111。CCER 里面 CC1E 是输出使能CC1P 是极性选择。这些字面看起来抽象后面直接上代码就能看懂我先点个名免得你看参考手册时发蒙。4. 实操一用寄存器配出一个 1ms 定时中断4.1 先做计算再写代码我们的核心是72MHz / (PSC1) / (ARR1) 1000Hz。前面已经选好了 PSC 71、ARR 999。实际写代码前请确认你的系统时钟。大多数开发板使用官方启动文件SysTick 和时钟配置在SystemInit里默认是 72MHz。如果你不知道自己的主频先在你手上板子的时钟配置代码里找到 PLL 倍频系数。我见过有人拿着 8MHz 晶振把 PLL 配置成 9 倍结果主频是 72MHz也见过有人配错成全速 108MHz本来想 1ms实际变 0.75ms。所以“算”之前先“确认”。4.2 初始化代码全部寄存器操作void TIM2_Init(void) { // 1. 打开 TIM2 时钟位于 APB1 RCC-APB1ENR | (1 0); // 2. 设置预分频和自动重装载值 TIM2-PSC 71; // 72MHz / (711) 1MHz TIM2-ARR 999; // 1MHz / (9991) 1kHz 1ms // 3. 计数器清零并软件触发更新让 PSC/ARR 真正生效 TIM2-CNT 0; TIM2-EGR | (1 0); // UG 1 // 4. 清除更新标志避免一开中断立刻进一次 TIM2-SR (uint16_t)~(1 0); // 5. 使能更新中断 TIM2-DIER | (1 0); // UIE 1 // 6. 配置 NVIC让中断能到达 CPU NVIC_SetPriority(TIM2_IRQn, 1); NVIC_EnableIRQ(TIM2_IRQn); // 7. 使能计数器启动定时 TIM2-CR1 | (1 0); // CEN 1 }这段顺序有讲究。先写参数再用 UG 触发更新然后清标志、开中断、使能计数。如果你把 CEN 提前计数器已经开始跑了但 UIE 还没开那第一秒内的若干次更新你可能都不知情如果你先开中断再清标志中间插进来的更新事件可能让 UIF 重新置位导致刚使能就进入中断。所以顺序建议固定成这个流程省得排查无头绪。4.3 中断服务函数怎么写中断服务函数的名字必须和启动文件里一致TIM2 的就是TIM2_IRQHandler不能自己随便起名。名字错了编译器不会报错链接器也不会提醒但中断永远不会执行。volatile uint32_t g_tick_count 0; void TIM2_IRQHandler(void) { if (TIM2-SR (1 0)) // 确认是更新中断 { TIM2-SR (uint16_t)~(1 0); // 清除 UIF g_tick_count; } }我在g_tick_count前面加了volatile。这很关键。中断里修改的全局变量主循环里要读取编译器一开优化很可能把读取操作直接优化成寄存器里的旧值怎么用都觉得“没变”。加 volatile 后每次读取都从内存拿这是嵌入式开发者容易忽视的一个深坑。主函数里怎么用就随你了比如while (1) { if (g_tick_count 1000) { g_tick_count 0; // 你想要的每 1 秒动作 } }注意不要在中断里做耗时操作。中断里放个标志位主循环里再处理业务这个习惯能救你很多次。4.4 这段代码的几个关键注意点第一PSC 和 ARR 都是 16 位寄存器最大只能写 65535。你算出来的分频值超过这个范围就必须调整策略比如把定时器时钟再降一档或者级联定时器。 72MHz 下取极限值PSC 65535、ARR 65535最大定时周期大约 60 秒想要几分钟级别的定时就得在中断里用软件计数器继续累加。第二如果修改 ARR 想在运行中动态调整频率别直接写完就跑。先暂停定时器或者等当前周期结束然后写新值再触发一次 UG。否则新值什么时候生效不可控输出波形会出现毛刺。第三有人习惯在定时中断里同时做多个任务比如每 1ms 翻转一次 LED、每 500ms 扫描一次按键。我的建议是在同一个中断里只做“计数”把业务拆到主循环判断计数器次数别让中断函数膨胀。这能让你的系统响应更稳。5. 实操二把定时器变成 PWM 输出做呼吸灯5.1 需求与引脚速查说完了定时中断接下来搞 PWM。呼吸灯是个非常经典、且能验证寄存器的例子LED 亮度从暗到亮再从亮到暗循环。本质就是让 PWM 的占空比周期性变化。TIM2 的 PWM 输出通道很多我用 CH1也就是引脚 PA0。如果你要用其他通道映射关系不一样建议翻一下数据手册避免查错引脚把 AFIO 配置也带偏。5.2 GPIO 和定时器寄存器配置想要 PA0 输出 PWMGPIO 必须配成“复用推挽输出”。这里非常坑它不是“推挽输出”这么简单而是“复用”功能。只配置 MODE 位不够CNF 位也要对。RCC-APB2ENR | (1 2); // 打开 GPIOA 时钟位于 APB2 // 清掉 PA0 原来的配置再设置MODE11(50MHz)CNF10(复用推挽) GPIOA-CRL ~(0xFUL 0); GPIOA-CRL | (0xBUL 0);接着配置 TIM2。注意PWM 如果不加时基结果可能要么输出恒高要么恒低。时基部分和上一段一样PSC 71、ARR 999这样 PWM 频率就是 1kHz。输出比较部分按这个来// 继续用 TIM2打开时钟 RCC-APB1ENR | (1 0); TIM2-PSC 71; TIM2-ARR 999; TIM2-EGR | (1 0); // 让时基参数生效 // 配置输出比较模式PWM 模式 1OC1M 110并使能输出比较预装载 TIM2-CCMR1 ~(0x3UL 8); // 清 CC1S输入/输出方向设为输出 TIM2-CCMR1 | (0x6UL 4); // OC1M 110PWM 模式 1 TIM2-CCMR1 | (1 3); // OC1PE 1输出比较预装载使能 // 使能通道输出且不反相CC1P 0比较输出直接到引脚 TIM2-CCER | (1 0); // CC1E 1 // 初始占空比50% TIM2-CCR1 500; // 使用自动重装载预装载并启动计数器 TIM2-CR1 | (1 7); // ARPE 1 TIM2-CR1 | (1 0); // CEN 1这段代码里CCMR1的 CC1S 位在低两位是选择“输入捕获”还是“输出比较”的。如果不先把这两位清零残留配置可能让外设以为你是要做输入捕获结果 CCR1 怎么设都没反应。写代码时不惯着自己上来先清掉后面好查。5.3 通过 CCR 调整占空比PWM 模式 1 配合 CC1P 0高电平有效时逻辑是这样的计数器 CNT 小于 CCR1输出高电平CNT 大于等于 CCR1输出低电平。所以高电平时间为 CCR1 个计数脉冲占空比就是 CCR1 / (ARR 1)。比如 CCR1 500ARR 1 1000占空比就是 50%。呼吸灯就是周期性改 CCR1。我推荐把定时中断和 PWM 配合起来在中断里每几个毫秒左右移动 CCR1实现平滑变化。比如volatile uint8_t dir_up 1; volatile uint16_t brightness 0; void TIM2_IRQHandler(void) { if (TIM2-SR (1 0)) { TIM2-SR (uint16_t)~(1 0); if (dir_up) { brightness 5; if (brightness 999) dir_up 0; } else { brightness - 5; if (brightness 0) dir_up 1; } TIM2-CCR1 brightness; } }这样呼吸周期大约是 999/5 × 1ms ≈ 200ms 一个单程加上一点节奏调整视觉上挺自然的。你当然可以在主循环里改 CCR1但直接在中断里改会平滑很多这正是“中断里只做短小工作”的典范改一个寄存器完全没问题。6. 我踩过的坑定时器寄存器操作排查实录6.1 定时中断根本不进、或者进得乱七八糟先把现象排开。第一种中断完全进不去。检查三件事RCC-APB1ENR对应位是否打开TIM2-DIER的 UIE 是否置 1NVIC_EnableIRQ(TIM2_IRQn)是否调用。顺序上我的建议是先查 NVIC 再查 DIER因为很多人只配了外设中断使能忘开 NVIC 总闸中断被 CPU 挡在外面。第二种进了中断后死循环。九成原因是 UIF 没清干净。仔细看你是不是用了TIM2-SR | (1 0)这操作是写 1不是写 0清不掉。我第一次就栽在 STM32 的“写 0 清除”上后来才长记性碰到状态寄存器先看手册标志清除方法。第三种中断服务函数名字被写错。检查启动文件TIM2 的正确函数名是TIM2_IRQHandler少个字母、多一个字母编译器不会错。还有不要在中断函数里用TIM2_IRQHandler和void TIM2_IRQHandler(void)之外再多包一层别的函数名有些编译器优化后符号对不上也会失效。6.2 定时时间不对误差超过预期如果你算出来 1ms实测是 2ms 或者 0.5ms严重怀疑 APB1 预分频。你要是知道 APB1 36MHz就把定时器时钟当成 36MHz认为 PSC 35、ARR 999 是 1ms结果实际慢了接近一倍。正确理解是 APB1 分频系数为 2 时定时器时钟就是 72MHz不是 36MHz。还有一种看起来“差不多”但一累计就偏。检查你的优化等级。高优化下如果你读取的计数变量没加 volatile可能读到的是寄存器缓存旧值导致软件计数偏少。把变量声明加上 volatile问题基本解决。还有一个低级的但发生频率很高PSC 或 ARR 喜欢少减一。你希望 1000 分频写 PSC 1000结果实际上是 1001 分频希望数 1000 下写 ARR 1000结果数 1001 下。低频定时你感觉不到一旦做频率计1% 的误差就来了。6.3 PWM 输出没波形、占空比不对PWM 没波形我见过最多的原因是 GPIO 配置不对。有人配成普通推挽输出PA0 只是被当普通 IO 用了定时器比较结果根本送不到引脚上。必须配成 MODE 11、CNF 10 的复用推挽输出。改完 GPIO 后记得重新初始化外设别只改一边。还有CCER 的 CC1E 没置 1。这个位相当于“通道输出开关”不开就是死灯。如果 PA0 悬空没接负载用示波器能看到波形如果接了 LED可能因为极性配置反了LED 反着接导致看起来永远是灭的。你可以在 CCER 里把 CC1P 改 1 试试极性反过来看 LED 亮暗是否对调。占空比不对多半是 CCR 和 ARR 的关系没搞对。CCR 是指“高电平持续的计数次数”不是“百分比”。你要 30% 占空比在 ARR 999 的前提下CCR1 大约就是 300而不是 30。刚接触 PWM 的人最容易在这里犯迷糊。6.4 改了 ARR 或者 CCR运行中波形没有及时变这基本就是“预装载 vs 影子寄存器”的问题。CR1 的 ARPE 1 时ARR 要等更新事件才生效。改完 ARR 建议手动TIM2-EGR | (1 0)触发一次更新让新值进入影子寄存器。CCR 也一样设置了 OC1PE 1 之后CCR 的新值也要等更新事件。这不是 bug是硬件特性和库函数封装后不透明的地方。寄存器操作的价值就在这里你能精准控制它更新的时机。最后再分享一个小习惯我每次初始化完定时器第一件事一定是拿逻辑分析仪看引脚波形而不是直接接业务逻辑。确认周期对不对、占空比是不是预期值再往下走。寄存器操作最怕“看着像对其实参数没生效”一句波形验证比盯半天代码高效得多。七八个外设玩下来你会慢慢发现定时器就像是整个芯片的“节拍器”一切都围着它转。这一章把时基单元和输出比较讲透了后面遇到编码器接口、输入捕获那些功能你只需要在这套肋骨架上再挂肌肉而已。希望这篇白话手册能让你少踩我当年那几个坑。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →