009、定时器与计数器PWM生成与精确延时实现一、一个让我熬夜到凌晨三点的bug去年做一款四轴飞行器的电调驱动PWM输出频率设定为50Hz占空比5%到10%之间调节油门。板子焊好程序烧进去电机死活不转。示波器一挂——PWM波形像得了帕金森周期忽长忽短占空比抖动得跟心电图似的。查了两天最后发现是定时器溢出中断里干了不该干的事——我在中断服务函数里调了一个printf。对就是那个用来调试的串口打印。printf执行时间长达几百微秒而我的PWM周期才20毫秒中断频繁触发时CPU被串口阻塞定时器寄存器更新被严重延迟。从那以后我给自己定了个规矩定时器中断里只做寄存器级别的操作printf这种东西打死也不放进去。这个教训让我重新审视了定时器这个看似简单的外设——它远不止是“数数”那么简单。二、定时器的本质一个会自己加1的寄存器抛开芯片手册那些晦涩的术语定时器的核心就是一个自由运行的计数器配合一个比较器和一个自动重装载寄存器。以STM32的通用定时器为例内部结构大致是这样时钟源 → 预分频器(PSC) → 计数器(CNT) → 比较器 → 输出控制 ↑ ↓ 自动重装载(ARR) 输出比较寄存器(CCR)CNT从0开始每个时钟脉冲加1加到ARR的值后归零重新开始——这就是定时器的基本循环。预分频器是个好东西。假设系统时钟72MHz直接给计数器用CNT从0加到65535只需要不到1毫秒。通过预分频器分频比如设成7172分频计数器时钟就变成1MHz每个计数步进1微秒精度就出来了。这里有个坑预分频器的值要加1。比如你想分频72倍PSC寄存器要写71。别问我为什么问就是硬件设计如此第一次用的时候我写72结果频率不对查了半天手册才反应过来。三、PWM生成让硬件自己干活PWM的本质就是让定时器的输出引脚在“计数到达设定值”时翻转电平。整个过程完全由硬件完成CPU只需要在初始化时配置好参数之后就可以去干别的事。3.1 配置流程以STM32F103为例// 定时器3通道1PA6引脚输出PWMvoidPWM_Init(void){// 1. 使能定时器时钟RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3,ENABLE);RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE);// 2. 配置GPIO为复用推挽输出GPIO_InitTypeDef GPIO_InitStructure;GPIO_InitStructure.GPIO_PinGPIO_Pin_6;GPIO_InitStructure.GPIO_ModeGPIO_Mode_AF_PP;// 复用推挽别写成普通推挽GPIO_InitStructure.GPIO_SpeedGPIO_Speed_50MHz;GPIO_Init(GPIOA,GPIO_InitStructure);// 3. 配置定时器时基TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;TIM_TimeBaseStructure.TIM_Period999;// ARR 999PWM周期 (9991)/1MHz 1msTIM_TimeBaseStructure.TIM_Prescaler71;// PSC 7172MHz/(711) 1MHzTIM_TimeBaseStructure.TIM_ClockDivision0;TIM_TimeBaseStructure.TIM_CounterModeTIM_CounterMode_Up;TIM_TimeBaseInit(TIM3,TIM_TimeBaseStructure);// 4. 配置PWM模式TIM_OCInitTypeDef TIM_OCInitStructure;TIM_OCInitStructure.TIM_OCModeTIM_OCMode_PWM1;// PWM模式1CNTCCR时输出有效电平TIM_OCInitStructure.TIM_OutputStateTIM_OutputState_Enable;TIM_OCInitStructure.TIM_Pulse500;// CCR 500占空比50%TIM_OCInitStructure.TIM_OCPolarityTIM_OCPolarity_High;// 有效电平为高TIM_OC1Init(TIM3,TIM_OCInitStructure);// 5. 使能定时器TIM_Cmd(TIM3,ENABLE);}这段代码生成的是1kHz、50%占空比的PWM。注意ARR和PSC的计算关系PWM频率 时钟频率 / ((PSC1) * (ARR1)) PWM占空比 CCR / (ARR1) * 100%别这样写把ARR设成0。ARR0意味着计数器永远停在0PWM输出要么全高要么全低不会翻转。我见过有人为了省事把ARR写0然后抱怨PWM不工作。3.2 动态调节占空比实际应用中经常需要在线调节占空比比如电机调速、LED调光。直接修改CCR寄存器即可// 设置占空比duty范围0-1000voidPWM_SetDuty(uint16_tduty){if(duty999)duty999;// 限幅防止超过ARRTIM_SetCompare1(TIM3,duty);}这里有个性能要点如果PWM频率很高比如几十kHz以上每次调用TIM_SetCompare1都会产生一次总线访问开销。对于需要频繁更新占空比的应用比如数字电源的PID调节建议直接操作寄存器TIM3-CCR1duty;// 直接写寄存器比库函数快一个数量级四、精确延时别再用for循环数数了新手最爱写这种延时voiddelay_ms(uint32_tms){uint32_ti,j;for(i0;ims;i)for(j0;j1000;j);// 这个1000怎么来的猜的}这种延时的问题在于编译器优化等级一变延时时间就变。Debug版和Release版跑出来的时间能差好几倍。更别说换一颗主频不同的芯片所有延时都得重新调。4.1 基于定时器的精确延时用定时器做延时的思路很简单让定时器计数我们查询计数值。// 使用定时器2做微秒级延时voiddelay_us(uint32_tus){TIM_SetCounter(TIM2,0);// 计数器清零while(TIM_GetCounter(TIM2)us);// 等待计数到目标值}前提是定时器2已经配置成1MHz的计数频率72MHz时钟PSC71。这样每个计数步进就是1微秒。这里踩过坑如果us的值很大比如超过65535而定时器是16位的计数器会溢出。解决方案有两个使用32位定时器STM32F4以上有分多次延时每次不超过65535微秒voiddelay_us_safe(uint32_tus){while(us0){uint32_tdelay(us60000)?60000:us;// 留点余量别卡在65535TIM_SetCounter(TIM2,0);while(TIM_GetCounter(TIM2)delay);us-delay;}}4.2 中断方式的延时查询方式会占用CPU如果延时期间CPU还有别的事要做可以用中断volatileuint32_tdelay_tick0;voiddelay_ms_interrupt(uint32_tms){delay_tickms;while(delay_tick0);// 等待中断把delay_tick减到0}// 定时器中断服务函数假设定时器1ms中断一次voidTIM2_IRQHandler(void){if(TIM_GetITStatus(TIM2,TIM_IT_Update)!RESET){if(delay_tick0)delay_tick--;TIM_ClearITPendingBit(TIM2,TIM_IT_Update);}}注意delay_tick要加volatile关键字否则编译器可能优化掉while循环里的读取操作——它觉得这个变量在循环里不会变干脆不读了。五、PWM与延时的组合应用呼吸灯把PWM和延时结合起来就能做出呼吸灯效果——LED亮度从暗到亮再到暗循环变化。voidbreath_led(void){uint16_tduty;uint8_tdirection1;// 1: 变亮, 0: 变暗while(1){// 渐变过程每次改变占空比后延时if(direction){duty;if(duty999)direction0;}else{duty--;if(duty1)direction1;}PWM_SetDuty(duty);delay_us_safe(2000);// 2ms改变一次整个周期约4秒}}这个实现有个问题占空比变化是线性的但人眼对亮度的感知是对数的。线性变化看起来会感觉“中间亮得很快两端变化很慢”。要做得自然应该用指数曲线或查表法。六、高级技巧定时器的同步与级联当需要多个PWM通道严格同步时比如三相电机驱动单个定时器的多个通道可能不够用。这时可以用定时器的主从模式主定时器输出触发信号TRGO从定时器接收触发信号作为时钟或复位配置示例伪代码// 定时器1为主定时器2为从TIM1-CR2|TIM_CR2_MMS_1;// 主模式更新事件作为TRGO输出TIM2-SMCR|TIM_SMCR_TS_0|TIM_SMCR_TS_1;// 从模式ITR0作为触发源TIM2-SMCR|TIM_SMCR_SMS_2;// 从模式触发模式这样配置后TIM1和TIM2的计数器完全同步启动输出的PWM波形相位差精确可控。七、个人经验总结定时器中断里别干重活。中断服务函数应该短小精悍只做寄存器操作和标志位设置。数据处理、通信协议解析这些事放到主循环里做。PWM频率的选择有讲究。电机驱动一般用10kHz-20kHz人耳听不到这个频段的噪音。LED调光要高于100Hz避免闪烁但太高了1kHz会影响调光分辨率。伺服舵机标准是50Hz别乱改。精确延时的替代方案。如果只是需要简单的延时用SysTick系统滴答定时器比用通用定时器更省资源。SysTick是ARM内核自带的所有Cortex-M芯片都有配置简单不占用外设定时器。调试PWM时必备示波器。逻辑分析仪只能看高低电平看不出占空比的微小变化。一个便宜的示波器几百块的国产货就行能帮你省下大量排查时间。寄存器版本比库函数更可靠。库函数封装了一层方便是方便但出了问题很难定位。真正做产品时我倾向于直接操作寄存器至少关键路径上的代码要这样写。定时器这东西看着简单用好了能玩出花来。下一篇会讲输入捕获——用定时器测量外部信号的频率和脉宽实现遥控器解码和超声波测距。