STM32F103通用定时器PWM输出原理、配置与实战避坑指南

发布时间:2026/7/29 12:53:59
STM32F103通用定时器PWM输出原理、配置与实战避坑指南 1. 从零开始为什么通用定时器是STM32F103的PWM输出首选如果你刚开始接触STM32想驱动一个舵机、控制电机转速或者做个呼吸灯第一个要搞定的可能就是PWM输出。网上教程很多但很多人一上来就照着代码抄配置了半天示波器上一片寂静或者波形完全不对最后只能对着开发板干瞪眼。我刚开始玩STM32F103的时候也踩过不少坑后来发现问题的根源往往不是代码敲错了而是没真正理解“通用定时器”这个核心模块到底是怎么把内部时钟变成你想要的PWM波的。STM32F103的定时器资源很丰富有基本定时器、通用定时器、高级定时器。对于绝大多数PWM应用比如控制LED亮度、驱动蜂鸣器、舵机、直流电机调速通用定时器TIM2, TIM3, TIM4绝对是你的第一选择。为什么不是基本定时器因为它没有输出比较功能干不了PWM的活儿。为什么不是高级定时器功能确实更强大支持互补输出、死区插入适合驱动电机但配置也更复杂。对于新手甚至大部分常规应用通用定时器在功能、易用性和资源占用上取得了最佳平衡。简单来说通用定时器就像一个可编程的“波形发生器”。它内部有一个计数器从0开始根据你设定的时钟频率不断累加。PWM的本质就是让这个计数器在一个周期内与另一个你设定的“比较值”进行对比根据计数值是高于还是低于这个“比较值”来翻转一个引脚的电平。这个“周期”就是PWM的频率由计数器的重装载值决定而“比较值”就决定了高电平的宽度也就是占空比。STM32的硬件定时器帮你完成了所有计数和比较的脏活累活你只需要配置好几个关键参数它就能稳定、精确地输出波形完全不占用CPU资源。2. 核心原理拆解通用定时器如何“编织”PWM信号要配置好PWM不能只知其然必须知其所以然。你得明白你写的每一个配置参数到底在定时器内部触发了什么动作。我们以最常用的PWM模式1为例结合STM32F103的通用定时器框图来理解。想象一下定时器内部有三个核心角色计数器CNT一个兢兢业业的跑腿小哥在你的时钟驱动下从0开始一步一步往上跑向上计数模式。自动重装载寄存器ARR这是跑道的终点线。当计数器CNT跑到ARR这个值时就会触发一个“到终点”的事件更新事件然后CNT瞬间被重置回0开始下一圈奔跑。ARR的值直接决定了PWM波的周期频率。频率计算公式为PWM频率 定时器时钟源频率 / ((ARR 1) * (PSC 1))。这里的PSC是预分频器用来降低计数时钟的频率。捕获/比较寄存器CCRx x1,2,3,4这是比赛中的“标志点”。每个通用定时器通常有4个独立的通道TIM2/TIM3有4个TIM4有4个每个通道都有自己的CCRx寄存器。你在这个寄存器里设置一个值比如设为500。现在好戏开始了。当计数器CNT这个跑腿小哥开始跑他的当前位置会不断地和终点线ARR、以及各个标志点CCRx进行比较。在PWM模式1下规则是这样的当CNT CCRx时对应的输出通道比如TIM3的通道1对应的GPIO引脚被强制设置为有效电平通常是你设定的高电平。当CNT CCRx时该输出通道被设置为无效电平低电平。这样一来在一个计数周期内0到ARR输出引脚就会产生一段从0到CCRx宽度的高电平然后从CCRx到ARR宽度的低电平。CCRx的值相对于ARR的比值就是占空比。例如ARR设为1000CCRx设为300那么占空比就是30%。通过修改CCRx的值你就能实时、平滑地改变PWM的占空比从而实现调光、调速。这里有一个关键细节影子寄存器。你直接写入的ARR、CCRx等寄存器很多时候并不是立即生效的。定时器为了防止在修改参数时产生“撕裂”的异常波形比如半个周期是高ARR值半个周期是低ARR值引入了影子寄存器的概念。你写入的是预装载寄存器在下一个更新事件计数器溢出或触发发生时这个值才会被真正加载到影子寄存器中生效。在标准库中这通常通过TIM_OCxPreloadConfig()函数来配置预装载使能。理解这一点对于动态调整PWM频率和占空比而不产生毛刺至关重要。3. 手把手配置以TIM3_CH1PA6输出固定PWM为例理论说再多不如动手调一遍。我们以最常见的STM32F103C8T6核心板为例使用TIM3的通道1对应GPIO PA6来输出一个1kHz频率、50%占空比的PWM波。这里我会使用标准外设库StdPeriph_Lib来讲解因为它的代码层次清晰最适合理解底层原理。CubeMX配置是图形化的捷径但知其然也要知其所以然。3.1 硬件与时钟树初始化首先任何外设使用前必须开启它的时钟。这是STM32编程的铁律。// 1. 开启时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 开启GPIOA时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); // TIM3挂载在APB1总线开启其时钟接下来配置GPIO。PA6需要被复用到TIM3_CH1这个功能上而不是作为普通的输入输出。// 2. 配置GPIO为复用推挽输出 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; // 输出速度对PWM输出有影响建议50MHz GPIO_Init(GPIOA, GPIO_InitStructure);注意GPIO_Mode_AF_PP复用推挽输出是关键。如果错配为GPIO_Mode_Out_PP通用推挽输出定时器将无法控制这个引脚自然没有波形。这是新手最常犯的错误之一。3.2 定时器基础参数配置时基单元这是决定PWM频率周期的核心步骤。我们需要配置预分频器PSC和自动重装载寄存器ARR。假设我们使用系统默认的内部8MHz RC振荡器经过PLL倍频到72MHz作为系统时钟SYSCLK。APB1的预分频器默认为2分频所以APB1的时钟是36MHz。但是STM32的定时器如果所在的APB总线预分频系数不为1则定时器时钟会乘以2。因此TIM3的实际时钟是72MHz。我们的目标是1kHz PWM频率。设定预分频器PSC 71。这样计数器时钟CK_CNT 72MHz / (711) 1MHz。设定重装载值ARR 999。这样PWM周期 (ARR1) / CK_CNT 1000 / 1MHz 0.001s 1ms频率即为1kHz。// 3. 配置定时器时基 TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure; TIM_TimeBaseInitStructure.TIM_Period 999; // ARR值决定周期 TIM_TimeBaseInitStructure.TIM_Prescaler 71; // PSC值决定计数时钟频率 TIM_TimeBaseInitStructure.TIM_ClockDivision TIM_CKD_DIV1; // 时钟分频与数字滤波器相关通常为DIV1 TIM_TimeBaseInitStructure.TIM_CounterMode TIM_CounterMode_Up; // 向上计数模式最常用 TIM_TimeBaseInit(TIM3, TIM_TimeBaseInitStructure);3.3 PWM输出通道配置输出比较单元现在配置通道1工作在PWM模式。这里需要设置模式、输出极性、占空比初始值等。// 4. 配置PWM输出模式 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; // 设置初始的脉冲宽度CCR1值500/100050%占空比 TIM_OC1Init(TIM3, TIM_OCInitStructure); // 初始化通道1 TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); // 使能通道1的预装载寄存器关键点解析TIM_OCMode_PWM1如前所述模式1下CNTCCR时输出有效电平。TIM_OCPolarity_High定义“有效电平”为高电平。如果你接的LED是低电平点亮这里可以设为TIM_OCPolarity_Low逻辑就反过来了。TIM_Pulse这就是初始的CCR1值直接决定了占空比。TIM_OC1PreloadConfig非常重要它使能了CCR1的预装载功能。当你后续动态修改TIM3-CCR1寄存器来改变占空比时新值会在下次更新事件时才生效确保波形切换平滑无毛刺。3.4 启动定时器与输出最后我们需要使能定时器的自动重装载预装载ARR的影子寄存器并启动定时器。// 5. 使能ARR预装载和启动定时器 TIM_ARRPreloadConfig(TIM3, ENABLE); // 使能ARR的预装载保证频率切换时也平滑 TIM_Cmd(TIM3, ENABLE); // 启动定时器3至此理论上你应该能在PA6引脚用示波器测量到一个1kHz、50%占空比的方波。如果没有请进入下一章的排查环节。4. 实战避坑没有PWM波形输出的完整排查链路“代码烧进去了引脚也量了就是没波形”——这是嵌入式开发者的日常。别慌按照以下链路像侦探一样一步步排查99%的问题都能解决。4.1 第一步确认硬件连接与电源听起来像废话但却是最高发的“坑”。目标引脚确认你确定代码配置的是PA6而你的示波器探头或LED确实接在芯片的PA6引脚上吗检查原理图确认引脚编号。STM32F103C8T6的PA6是第10脚。共地示波器探头的地线必须和开发板的GND可靠连接。没有共地测量到的电压是浮空的毫无意义。电源与复位芯片供电是否正常复位引脚是否被意外拉低可以用万用表量一下VDD和GND之间的电压是否为3.3V左右。4.2 第二步软件配置的“三重门”检查如果硬件没问题那一定是软件配置有遗漏或错误。第一重门时钟门外设时钟使能了吗再检查一遍RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE)和GPIO的时钟使能。没有时钟整个外设就是“砖头”。GPIO复用功能映射对吗PA6默认是GPIO必须被映射到TIM3_CH1。在标准库中这是通过配置GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP自动完成的。但在一些型号或复杂应用中可能需要额外调用GPIO_PinAFConfig()函数更多用于HAL库和CubeMX生成的代码。对于标准库和基本F103GPIO_Mode_AF_PP通常足够。第二重门定时器门定时器启动了吗TIM_Cmd(TIM3, ENABLE)这条语句执行了吗把它放在配置的最后一步。预装载使能了吗TIM_ARRPreloadConfig和TIM_OC1PreloadConfig都调用了吗它们不影响波形有无但影响动态调整时的稳定性。计数器模式对吗确保是TIM_CounterMode_Up。第三重门输出通道门输出使能了吗TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable。极性设置反了吗如果你设定了TIM_OCPolarity_High但用万用表量引脚一直是3.3V高电平没有变化可以尝试改为TIM_OCPolarity_Low看看。或者用示波器看有没有低电平的脉冲。CCR值设置是否极端如果你把CCR值设为0那么有效电平宽度为0输出可能就是常低极性为高时。如果CCR值大于等于ARR那么有效电平占满整个周期输出就是常高极性为高时。把你的TIM_Pulse设成一个中间值比如ARR的一半。4.3 第三步使用调试器进行“灵魂拷问”如果以上都没问题就该祭出终极武器——调试器ST-Link/J-Link等。连接芯片进入调试模式。查看寄存器在IDE如Keil、IAR的寄存器窗口找到TIM3的相关寄存器。查看TIM3_CR1寄存器确认CEN位Counter ENable是否为1定时器已启动。查看TIM3_CCER寄存器确认对应通道如通道1是CC1E位是否为1输出使能。查看TIM3_CCMR1寄存器对于通道1和2确认OC1M位段是否被正确设置为PWM模式110或111。最关键的一步查看TIM3_CNT寄存器的值。它应该在0到ARR之间循环跳动。如果它是静止的说明定时器根本没跑起来回头检查时钟和使能。如果它在跳动说明定时器核心是好的。查看GPIO寄存器查看GPIOA_CRL寄存器确认PA6引脚的模式CNF6和MODE6位是否被正确配置为复用输出。单步调试在初始化TIM3的代码处设置断点单步执行每执行一步就查看上述关键寄存器的变化看是哪一步配置没有生效。4.4 第四步进阶排查与特殊案例引脚冲突重映射有些定时器的通道默认不在你想象的引脚上。例如TIM3_CH1默认是PA6但它可以被重映射到PB4。检查你的代码或CubeMX配置中是否无意中开启了重映射TIM_RemapConfig。如果开启了你需要把线接到PB4上去量。高级定时器TIM1/TIM8的刹车和互补输出如果你配置的是高级定时器情况更复杂。需要额外使能主输出TIM_CtrlPWMOutputs(TIM1, ENABLE)否则即使配置正确也没有波形这是高级定时器独有的“坑”。库函数版本差异不同版本的StdPeriph库或HAL库函数名或参数可能有细微差别。确保你包含的头文件和链接的库文件版本一致。按照这个链路排查从硬件到软件从外设到内核从静态配置到动态运行基本上没有找不到的PWM。5. 动态控制与进阶应用让PWM“活”起来输出固定占空比的PWM只是第一步。真正的应用需要动态调整。这非常简单只需要在定时器运行后修改对应通道的捕获/比较寄存器CCRx的值即可。// 动态修改TIM3通道1的占空比为75% TIM_SetCompare1(TIM3, 750); // 假设ARR为999750/100075%这个函数会直接写入CCR1寄存器。由于我们之前使能了预装载TIM_OC1PreloadConfig这个新值会在当前PWM周期结束后下一个更新事件才生效从而避免在周期中间切换产生不平滑的波形。基于这个简单的操作我们可以实现很多有趣的应用5.1 呼吸灯效果在主循环或定时器中断中循环地、平滑地改变CCRx的值即可。uint16_t pwmVal 0; uint8_t dir 1; // 方向1为递增0为递减 while (1) { if (dir) { pwmVal; if (pwmVal 1000) { // ARR999 dir 0; } } else { pwmVal--; if (pwmVal 0) { dir 1; } } TIM_SetCompare1(TIM3, pwmVal); Delay_ms(5); // 简单的延时控制呼吸速度 }5.2 舵机控制舵机需要的是周期为20ms50Hz高电平宽度在0.5ms到2.5ms之间的PWM信号。以72MHz时钟1MHz计数频率为例周期ARR 20000 - 1 因为20ms / (1/1MHz) 20000个计数周期0度0.5msCCRx 500180度2.5msCCRx 2500 通过传感器或遥控信号计算出角度再映射到500-2500的CCRx值即可精确控制舵机。5.3 直流电机调速通过H桥驱动使用一个PWM通道控制电机的速度。通过另一个GPIO控制方向。在中断中根据编码器反馈调整PWM占空比即可实现简单的闭环速度控制。这就是许多智能小车的基础。5.4 多通道同步输出一个通用定时器的4个通道共享同一个ARR频率但可以独立设置各自的CCRx占空比。这意味着你可以用1个TIM3同时输出4个频率相同但占空比各自独立的PWM波非常适合控制多个LED或需要同步的场合。6. 从标准库到HAL库与CubeMX配置思路的变迁虽然标准库有助于理解原理但ST官方已转向HAL库和LL库而CubeMX工具极大地简化了配置过程。理解其中的对应关系能让你更灵活。在CubeMX中配置TIM3 PWM输出在Pinout Configuration界面找到TIM3。在模式选择中将Channel1设置为PWM Generation CH1。在参数配置中Prescaler (PSC - 16 bits value)填入71。Counter ModeUp。Counter Period (AutoReload Register)填入999。Internal Clock Division (CKD)No Division。auto-reload preloadEnable这对应使能ARR预装载。在下方的PWM Generation Channel 1配置中ModePWM mode 1。Pulse (16 bits value)填入初始占空比如500。Fast ModeDisable通常。CH PolarityHigh根据你的电路选择。CubeMX会自动帮你生成GPIO复用和时钟初始化代码。生成的HAL库代码其核心逻辑与标准库一致只是函数调用封装得更统一。启动PWM输出的关键函数是HAL_TIM_PWM_Start(htim3, TIM_CHANNEL_1)。动态修改占空比使用__HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, new_pulse)。我个人在实际项目中的体会是对于学习和深度调试从标准库或直接读寄存器开始能打下最坚实的基础。对于快速原型开发和产品迭代CubeMXHAL是无可替代的高效工具。两者并不矛盾底层原理是相通的。当你用CubeMX配置时心里要清楚它背后在帮你设置哪些寄存器这样当出现异常时你才能快速定位到问题所在而不是对着图形界面束手无策。