
1. 项目概述从“定时”到“输出”的精准控制如果你玩过STM32或者哪怕只是听说过那“定时器”这个词你肯定不陌生。它就像是单片机里的一个“心脏起搏器”或者一个精准的“节拍器”负责产生固定时间间隔的脉冲驱动着各种需要定时、计数的功能。但很多人对定时器的理解可能就停留在“定时中断”上——比如让一个LED灯每隔1秒闪烁一次。这确实是定时器最基础的应用但它的能力远不止于此。今天我们要聊的“定时器输出比较”就是定时器从“内部计时”走向“对外控制”的关键一步。简单来说它让定时器从一个只会“自己数数”的会计变成了一个能根据“数数结果”去“动手操作”的指挥官。这个“动手操作”最常见的形式就是产生PWM脉冲宽度调制波形。你看到的舵机转动、电机调速、LED呼吸灯效果背后几乎都是输出比较在默默工作。我刚开始接触STM32时也以为配置个定时器中断就万事大吉了直到需要驱动一个舵机才发现事情没那么简单。光有定时中断我只能知道时间到了但无法精准地控制一个引脚输出特定宽度的高电平或低电平。而输出比较单元正是硬件上专门干这个事的它持续比较定时器的当前计数值CNT和一个我们预先设定好的“目标值”CCR一旦两者相等硬件就会自动触发一系列动作比如让某个GPIO引脚的电平翻转。这个过程完全由硬件完成不占用CPU资源精度极高且稳定可靠。所以这篇内容我们就彻底拆解STM32的定时器输出比较功能。我会结合最常见的STM32F1系列也是很多教程包括江科大老师课程常用的平台从原理到寄存器再到库函数操作一步步带你实现从生成一个简单方波到输出标准PWM再到驱动舵机和电机的全过程。你会发现理解了输出比较你对定时器的认识会上一个全新的台阶。2. 核心原理拆解比较匹配与输出模式的硬件逻辑要玩转输出比较死记库函数调用顺序是没用的必须搞清楚硬件是怎么工作的。我们以STM32F103的通用定时器TIM2, TIM3, TIM4为例它的核心是一个16位的向上/向下计数器CNT这个计数器由内部时钟驱动每来一个脉冲就加1或减1。输出比较单元是这个定时器的一个“外设模块”。每个定时器通常有多个独立的通道比如TIM2有CH1-CH4每个通道都有一套完整的输出比较逻辑主要包括以下几个部分捕获/比较寄存器CCRx这是“目标值”。我们程序员要做的就是往这个寄存器里写一个数。比如如果定时器是向上计数到1000ARR值就溢出重来那么你往CCR1里写入300就设定了一个“比较点”。比较器这是一个硬件电路它一刻不停地对比当前计数器值CNT和CCRx里的值。输出模式控制器这是决定“匹配时干什么”的大脑。当CNT CCRx时也就是“比较匹配”事件发生时这个控制器会根据我们设定的“输出比较模式”去改变对应输出通道引脚比如TIM2_CH1对应PA0引脚的电平状态。STM32提供了8种输出比较模式但最常用、最核心的是以下两种冻结模式匹配时啥也不干。这个模式主要用于产生精确的定时中断通过捕获比较中断而不影响引脚输出。PWM模式这是重中之重。它又分为两种PWM模式1在向上计数时当CNT CCRx通道输出有效电平可设为高或低当CNT ≥ CCRx输出无效电平。向下计数时逻辑相反。PWM模式2与模式1的逻辑完全相反。这里的“有效电平”是另一个关键概念由**输出极性CCxP**位控制。你可以设定匹配后输出高电平为有效也可以设定低电平为有效。这给了我们极大的灵活性以适配不同外设的需求比如有的舵机高电平驱动有的电机驱动芯片低电平使能。一个生活化的比喻想象一个沙漏定时器CNT和一把尺子CCRx。沙漏里的沙子匀速流下代表计数增加。你在这把尺子的300毫升刻度CCR300处贴了个标签。你的任务输出模式是当沙子流到300毫升刻度时匹配事件就按一下墙上的电灯开关GPIO引脚。PWM模式1的规则就是沙子低于300毫升时让灯亮着有效电平沙子达到或超过300毫升时就关灯无效电平。如果你改变贴标签的位置CCR值灯亮的时间长短就会变化这就是占空比的调节。如果你改变的是沙漏的总容量ARR值就改变了沙子流完一轮的总时间也就是频率的调节。所以PWM波形的频率由定时器的时钟和自动重载值ARR决定而占空比则由捕获比较值CCR决定。公式如下假设时钟不分频PWM频率 定时器时钟频率 / (ARR 1)占空比 CCR / (ARR 1)理解了这个硬件逻辑再看库函数配置你就会明白每一行代码都是在设置这些硬件寄存器而不是在背魔法咒语。3. 关键配置步骤详解从寄存器视角到库函数实现知道了原理我们来看如何用STM32标准外设库StdPeriph_Lib来实现它。我会把库函数和它背后操作的寄存器关联起来讲这样即使你以后用HAL库或者LL库也能触类旁通。我们以TIM3的通道1对应PA6引脚输出一个1kHz占空比50%的PWM波为例。3.1 第一步时钟与GPIO使能任何外设使用前必须先给它上电打开时钟。// 1. 开启TIM3和GPIOA的时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);RCC_APB1PeriphClockCmd这个函数实际上是在操作RCC复位和时钟控制模块的APB1ENR寄存器中的对应位。STM32F1的定时器TIM2-TIM7挂在APB1总线上。GPIO配置是关键必须将引脚复用到对应的定时器通道功能上。// 2. 配置PA6为复用推挽输出 GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; // 复用推挽输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure);这里GPIO_Mode_AF_PP复用推挽输出是必须的。如果错配为GPIO_Mode_Out_PP通用推挽输出你将无法通过定时器控制这个引脚只能手动写GPIO寄存器控制那就不是硬件PWM了。GPIO_Speed配置为50MHz是为了让引脚电平翻转速度跟上PWM的频率对于1kHz来说其实10MHz也够但高速一点没坏处。3.2 第二步定时器时基单元初始化这一步是设定定时器的“心跳节奏”。// 3. 初始化定时器时基单元 TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure; TIM_TimeBaseInitStructure.TIM_Period 999; // 自动重载值 ARR TIM_TimeBaseInitStructure.TIM_Prescaler 71; // 预分频器 PSC TIM_TimeBaseInitStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInitStructure.TIM_CounterMode TIM_CounterMode_Up; // 向上计数模式 TIM_TimeBaseInit(TIM3, TIM_TimeBaseInitStructure);TIM_Prescaler (PSC): 预分频器。定时器时钟假设72MHz先经过(PSC1)分频。这里71表示71172分频得到1MHz的计数时钟。为什么是71因为系统时钟常为72MHz72分频后得到1MHz方便计算。计数频率 72MHz / (711) 1MHz。TIM_Period (ARR): 自动重载值。计数器从0数到这个值就溢出归零。结合计数频率PWM周期 (ARR1) / 计数频率。这里ARR999所以周期 (9991) / 1MHz 1000us 1ms对应频率1kHz。TIM_CounterMode: 计数模式。输出PWM常用向上计数逻辑清晰。注意TIM_ClockDivision与输入捕获的滤波有关与输出比较的基本定时无关通常设为TIM_CKD_DIV1即可。这是一个容易混淆的点。3.3 第三步输出比较通道初始化核心这是配置输出比较模式、极性等关键参数的地方。// 4. 初始化输出比较通道 TIM_OCInitTypeDef TIM_OCInitStructure; TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; // 选择PWM模式1 TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; // 使能输出 TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; // 输出极性高电平为有效电平 TIM_OCInitStructure.TIM_Pulse 500; // 设置比较值CCR决定占空比 TIM_OC1Init(TIM3, TIM_OCInitStructure); // 初始化通道1TIM_OCMode: 选择TIM_OCMode_PWM1。这就是前面原理部分讲的PWM模式1。TIM_OutputState: 必须使能Enable否则信号不会输出到引脚。TIM_OCPolarity: 极性。High表示我们定义“有效电平”为高电平。在PWM模式1下CNTCCR时输出有效电平高CNT≥CCR时输出无效电平低。TIM_Pulse: 这就是要写入捕获比较寄存器CCR1的值。它直接决定了高电平的宽度。占空比 TIM_Pulse / (ARR1) 500 / 1000 50%。TIM_OC1Init: 注意是OC1代表通道1。如果是通道2就要用TIM_OC2Init。这个函数会帮我们配置CCMR1通道1和2的模式寄存器、CCER捕获比较使能寄存器等。3.4 第四步使能定时器所有配置完成后最后启动定时器。// 5. 使能定时器的预装载寄存器ARR和CCR TIM_ARRPreloadConfig(TIM3, ENABLE); // 使能ARR预装载 TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); // 使能CCR1预装载 // 6. 使能定时器 TIM_Cmd(TIM3, ENABLE);预装载功能这是一个非常重要的高级功能。如果不使能预装载当你修改ARR或CCR值时新值会立即生效这可能导致当前输出的PWM周期出现“撕裂”现象一个周期内新旧参数混合。使能预装载后新值会先写入一个缓冲寄存器等到当前计数周期结束更新事件发生时才真正生效从而保证PWM输出的完整性和平滑性特别是在动态调整频率或占空比时。对于电机控制等应用务必使能。至此PA6引脚上就应该能用一个示波器或者逻辑分析仪测量到稳定的1kHz、50%占空比的PWM方波了。如果没有请进入下一章的排查环节。4. 实战进阶动态调整PWM与驱动外设生成一个固定PWM只是开始真正的威力在于动态控制。比如实现呼吸灯动态改变占空比或者控制舵机角度对应特定脉宽。4.1 动态改变占空比呼吸灯实现呼吸灯的本质是让PWM的占空比CCR值随时间平滑变化。我们可以在主循环或定时器中断里修改CCR值。// 在某个定时器中断服务函数或主循环中 static uint16_t pwmVal 0; static int8_t dir 1; // 方向1为增加-1为减少 // 每10ms执行一次 if (dir 1) { pwmVal 10; if (pwmVal 900) { // 接近最大占空比90% dir -1; } } else { pwmVal - 10; if (pwmVal 10) { // 接近最小占空比1% dir 1; } } // 关键更新CCR1寄存器 TIM_SetCompare1(TIM3, pwmVal);TIM_SetCompare1这个库函数就是用来动态设置TIM3通道1的CCR1值的。因为它操作的是已经使能了预装载的缓冲寄存器所以修改是安全的会在下一个PWM周期生效不会造成毛刺。4.2 驱动舵机SG90为例舵机控制需要的是周期为20ms50Hz高电平脉宽在0.5ms到2.5ms之间的PWM信号。脉宽对应舵机角度通常0.5ms对应0°2.5ms对应180°。我们需要重新计算ARR和CCR。目标产生50Hz PWM即周期T20ms 20000us。已知定时器计数时钟已设为1MHz每计数一次1us。计算ARRARR T * 计数频率 - 1 20000us * 1MHz - 1 19999。但ARR是16位寄存器最大值65535完全够用。计算CCR以让舵机转到90度脉宽1.5ms为例CCR 脉宽 * 计数频率 1500us * 1MHz 1500。配置代码只需修改时基初始化和OC初始化中的参数// 时基初始化 TIM_TimeBaseInitStructure.TIM_Period 19999; // ARR 19999 TIM_TimeBaseInitStructure.TIM_Prescaler 71; // 保持1MHz计数时钟 // ... 其他不变 // OC初始化 TIM_OCInitStructure.TIM_Pulse 1500; // 初始位置90度 // ... 其他不变然后你可以通过TIM_SetCompare1(TIM3, angle_to_pulse(angle))函数来动态控制舵机角度其中angle_to_pulse是一个将角度0-180线性映射到脉冲值500-2500的函数。重要经验驱动舵机时确保电源功率足够。单片机IO口的驱动能力有限最好用外部电源给舵机供电并将地线与单片机共地。同时避免在程序初始化完成前就让引脚输出PWM可能导致舵机抖动。4.3 驱动直流电机通过H桥或驱动模块对于直流电机调速PWM频率选择有讲究。频率太低如几十Hz电机会听到明显的啸叫声频率太高如几十kHz开关损耗会增大且可能超出某些驱动芯片的响应能力。常用的范围是5kHz到20kHz。假设我们选择10kHz。周期T 1/10kHz 100us 0.1ms。计数频率仍用1MHz1us一次计数。ARR 100us * 1MHz - 1 99。CCR 50则占空比为50%。配置好后通过改变CCR值0到99之间即可无级调速。配合使用定时器的两个通道输出互补的PWM带死区控制可以驱动H桥电路实现电机的正反转和刹车这就是更复杂的“高级定时器”应用了。5. 深度排查当PWM没有输出时怎么办按照步骤做了但用示波器在引脚上测不到任何信号或者信号不对这是最让人头疼的。别慌按照以下链路系统性排查99%的问题都能解决。5.1 硬件连接与仪器检查这是最基础也最容易被忽略的。确认引脚你真的把示波器探头或逻辑分析仪接对引脚了吗TIM3_CH1是PA6但你的板子PA6可能连着其他东西。查一下原理图。共地示波器探头的地线夹子必须接到开发板的GND上。不共地测量到的信号是浮空的毫无意义。负载问题引脚后面是否接了太大的负载如直接驱动电机单片机IO口驱动电流有限通常20mA以内直接驱动大负载会导致电压被拉低波形畸变甚至看不到。用示波器直接测MCU引脚不要测负载后端。引脚复用冲突PA6除了是TIM3_CH1还可能被默认配置为其他功能比如SPI1_MISO。确保你的程序中没有其他地方包括库的默认初始化重新配置了PA6的模式。5.2 软件配置逐项核对如果硬件没问题就深入代码。建议使用调试器在初始化完成后查看相关寄存器的值是否与预期一致。时钟使能寄存器RCC-APB1ENR查看TIM3的时钟使能位是否被置1。GPIO配置寄存器GPIOA-CRL查看PA6对应的位是否被正确配置为复用推挽输出模式位为‘10’ CNF位为‘10’。定时器使能寄存器TIM3-CR1CEN位是否为1这是总开关。捕获/比较模式寄存器1TIM3-CCMR1对于通道1OC1M位段是否被设置为‘110’PWM模式1OC1PE位预装载使能是否为1捕获/比较使能寄存器TIM3-CCERCC1E位输出使能是否为1CC1P位极性是否正确自动重载寄存器TIM3-ARR和捕获比较寄存器1TIM3-CCR1它们的值是否是你设定的999和500或对应值预分频器寄存器TIM3-PSC值是否为71一个非常常见的错误是忘记调用TIM_Cmd()或者在初始化时TIM_OCInitStructure结构体变量没有全部重新赋值。如果你之前用这个结构体变量配置过输入捕获残留的字段会影响输出比较的配置。安全的做法是在初始化前用TIM_OCStructInit()函数将其全部赋为默认值。TIM_OCInitTypeDef TIM_OCInitStructure; TIM_OCStructInit(TIM_OCInitStructure); // 先全部复位为默认值 // 然后再设置我们需要的字段 TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; // ... 其他设置5.3 高级定时器的特殊配置如果你用的是高级定时器如TIM1, TIM8输出PWM步骤会多一些因为它们通常用于驱动电机需要互补输出和死区插入。必须使能主输出高级定时器的输出有一个总开关在BDTR寄存器的MOE位。库函数是TIM_CtrlPWMOutputs(TIM1, ENABLE)。忘记这一步高级定时器的PWM绝对没有输出这是新手最容易栽跟头的地方。刹车和死区控制如果不用也需要将BDTR寄存器相关位配置好通常设为0。5.4 使用调试工具辅助逻辑分析仪几十块钱的USB逻辑分析仪是调试数字信号的神器。它可以同时抓取多个引脚的时序直观地看到PWM的频率、占空比是否准确比示波器更轻便。Keil/IAR的Peripheral View在调试模式下可以实时查看和修改所有外设寄存器的值对于验证配置是否正确无比方便。排查就像破案从电源、时钟、引脚、配置寄存器这条链路上顺藤摸瓜耐心地一个个环节确认问题总能定位。我个人的习惯是每写一个功能模块就立刻用逻辑分析仪抓一下波形确认基础信号正确再进行后续复杂的逻辑开发这样可以避免底层bug堆积到后期难以排查。6. 输出比较的其他应用模式探索除了最常用的PWM模式输出比较模式还有其他玩法在某些特定场景下非常有用。6.1 强制输出模式TIM_ForcedAction这种模式下你可以通过软件强制让输出通道立即变为有效或无效电平而不依赖于计数器的比较匹配。它分为“强制高”和“强制低”。应用场景在系统故障、紧急刹车Brake时需要立即将电机控制PWM拉低或拉高以确保安全。你可以通过配置刹车电路或软件直接触发“强制输出”动作响应速度比中断服务程序更快。库函数操作通过设置CCMRx寄存器中的OCxM位为‘100’强制高或‘101’强制低来实现。标准库似乎没有直接对应的便捷函数通常需要直接操作寄存器TIMx-CCMR1 | TIM_CCMR1_OC1M_0;示例具体位需查手册。6.2 翻转模式TIM_OCMode_Toggle当比较匹配发生时输出引脚的电平自动翻转一次。应用场景生成一个固定占空比50%的方波且其频率是定时器更新频率的一半。假设定时器每计数N次产生一次更新中断那么在翻转模式下引脚输出的方波周期就是2N个计数周期。这种方法可以用一个简单的比较匹配来产生频率可调的方波比用PWM模式更节省一个CCR寄存器因为占空比固定50%。配置示例TIM_OCInitStructure.TIM_OCMode TIM_OCMode_Toggle; TIM_OCInitStructure.TIM_Pulse N; // 匹配值 // 输出极性在此模式下通常无效或意义不同 TIM_OC1Init(TIM3, TIM_OCInitStructure);这样每当CNT计数到NPA6引脚电平就翻转一次。如果定时器从0计数到ARR假设ARRN后溢出清零那么一个周期内会翻转两次一次在CNTN时一次在溢出更新时这里需要仔细设计ARR和N的关系以避免混乱从而产生对称方波。6.3 主动与被动电平在输出比较配置中除了极性CCxP还有一个“输出使能/失能”状态CCxE。当通道被失能CCxE0时引脚的行为由“空闲状态”或“互补通道”等更复杂的寄存器控制特别是在高级定时器中。理解这些状态对于设计安全的关断逻辑很重要。例如在电机驱动中通常希望PWM输出失能时所有控制引脚都处于确定的“安全”电平全低或全高而不是高阻态这需要通过配置BDTR或CCER寄存器的OISx输出空闲状态位来实现。7. 从标准库到HAL库的思维转换现在ST主推HAL库虽然代码看起来更“黑盒”但底层原理完全一样。了解标准库的寄存器级操作能让你更好地理解HAL库在做什么。以同样的TIM3 CH1输出PWM为例HAL库的步骤初始化句柄htim3.Instance TIM3;配置时基htim3.Init.Prescaler 71; htim3.Init.Period 999; ...调用HAL_TIM_Base_Init(htim3)。配置PWM通道sConfig.OCMode TIM_OCMODE_PWM1; sConfig.Pulse 500; ...调用HAL_TIM_PWM_ConfigChannel(htim3, sConfig, TIM_CHANNEL_1)。启动PWMHAL_TIM_PWM_Start(htim3, TIM_CHANNEL_1);HAL库将“GPIO复用配置”和“定时器时钟使能”等步骤通过__HAL_RCC_TIM3_CLK_ENABLE()和HAL_TIM_PWM_MspInit()回调函数你需要重写它来配置GPIO进行了封装。HAL_TIM_PWM_Start()函数内部其实一次性完成了使能预装载、使能通道输出、使能定时器计数器等多个操作。从标准库过渡到HAL库的关键不要被复杂的结构体和回调函数吓到。你心里要始终清楚最终它还是在配置ARR、PSC、CCMR、CCER、CR1这些寄存器。当HAL库函数出错时查看其源码往往能追溯到某个寄存器位没有设置正确。理解原理就能驾驭任何库。