1. 项目概述为什么是STM32F103C8T6的PWM如果你手头有一块“蓝色药丸”Blue Pill或者任何基于STM32F103C8T6的最小系统板那么你几乎不可避免地要和PWM打交道。这块芯片以其极高的性价比和丰富的片上资源成为了无数嵌入式开发者、电子爱好者和学生项目的首选核心。而PWM脉冲宽度调制作为连接数字世界与模拟世界的桥梁是驱动电机、控制舵机、调节LED亮度、乃至实现简易DAC功能的基石。可以说掌握了STM32F103C8T6的PWM就等于拿到了操控物理世界的一把万能钥匙。我接触过很多初学者他们照着教程配置好了PWM引脚上也能用示波器看到波形但一旦项目需求稍微复杂一点比如要同时控制多个舵机、生成特定频率的互补PWM、或者实现带死区时间的电机驱动就立刻陷入迷茫。问题的根源往往不在于代码怎么写而在于对STM32定时器这个“PWM发生器”的底层工作机制理解不透。STM32F103C8T6内部集成了多达3个通用定时器TIM2、TIM3、TIM4和1个高级定时器TIM1它们都能产生PWM但能力和配置复杂度天差地别。选择哪个定时器、如何计算参数、怎样避免常见的“无输出”陷阱这些才是从“点亮LED”到“做出产品”的关键跨越。这篇文章我就以一名老嵌入式工程师的视角带你彻底拆解STM32F103C8T6的PWM功能。我们不只讲CubeMX怎么点点鼠标更要深挖寄存器层面的逻辑弄明白每一个参数背后的物理意义。我会分享一些标准库和HAL库中都不会写的“骚操作”和踩坑实录目标是让你看完后不仅能复现一个PWM呼吸灯更能独立设计出满足复杂需求的PWM应用方案。2. 核心硬件解析定时器与引脚映射在动手写代码之前我们必须像熟悉自己的手掌一样熟悉手中的武器——STM32F103C8T6的定时器资源和其对应的引脚。盲目配置是失败的开端。2.1 定时器资源全景图STM32F103C8T6的定时器系统是一个分工明确的团队高级控制定时器 (TIM1)这是功能最强大的“队长”。它是一个16位定时器支持向上、向下、中央对齐计数。最关键的是它能产生多达4路互补输出的PWM并且自带死区时间插入功能这是驱动H桥电路如控制直流有刷电机或步进电机防止上下管直通炸管的必备特性。TIM1还支持刹车功能用于紧急关断PWM输出。通用定时器 (TIM2, TIM3, TIM4)这是项目中最常用的“主力队员”。它们也是16位定时器可以向上、向下计数。每个通用定时器能产生4路独立的PWM输出但没有互补输出和死区时间功能。对于控制舵机、LED调光、蜂鸣器发声等绝大多数应用通用定时器绰绰有余。基本定时器 (TIM6, TIM7)它们是纯粹的“计时员”没有输出比较功能因此不能直接产生PWM。主要用于触发DAC、产生基础时基等。对于PWM应用我们的焦点集中在TIM1、TIM2、TIM3和TIM4上。选择原则很简单需要驱动电机特别是要用H桥就用TIM1其他情况优先使用通用定时器因为配置更简单资源也足够。2.2 关键引脚功能映射与陷阱STM32的引脚功能是复用的一个引脚可能对应多个外设GPIO、定时器通道、USART等。为PWM功能正确配置引脚是第一步也是最容易出错的一步。以最常用的TIM2_CH3定时器2通道3产生PWM为例它可能映射到PA2、PB10等引脚。你必须在芯片数据手册或CubeMX的引脚分配图中确认你打算使用的那个物理引脚是否支持你想要的定时器通道。这里有一个我踩过的大坑STM32F103C8T6的某些引脚在重映射Remap后定时器通道才会出现。例如TIM3的通道1和通道2默认在PA6和PA7但如果你想把TIM3_CH1重映射到PB4就需要开启AFIO复用功能IO的重映射功能。在标准库中你需要调用GPIO_PinRemapConfig函数在HAL库配合CubeMX时需要在Pinout视图里直接选择“TIM3_CH1”功能CubeMX会自动帮你生成重映射代码。很多初学者手动配置代码时忘了这一步导致明明配置正确却没有波形输出查半天才发现引脚功能没映射对。提示强烈建议在项目初期使用ST官方的CubeMX工具进行图形化引脚配置。它能直观地显示冲突并自动生成底层初始化代码能避免90%的硬件连接错误。下表整理了STM32F103C8T6最常用的几个PWM输出引脚基于默认映射未开启完全重映射定时器通道默认引脚常见应用场景TIM1_CH1PA8高级应用如电机驱动需互补输出CH1NTIM1_CH2PA9同上TIM2_CH1PA0通用PWM注意PA0也可能是WKUP引脚TIM2_CH2PA1通用PWMTIM2_CH3PA2通用PWM也可能是USART2_TXTIM2_CH4PA3通用PWM也可能是USART2_RXTIM3_CH1PA6通用PWM常见于舵机控制TIM3_CH2PA7通用PWMTIM4_CH1PB6通用PWM也可能是I2C1_SCLTIM4_CH2PB7通用PWM也可能是I2C1_SDA3. PWM原理与关键参数深度计算PWM不是什么黑科技它的本质就是通过调节一个周期固定方波的“高电平时间”占整个周期的比例即占空比来等效地输出不同的“平均电压”。3.1 从计数器到PWM波形核心寄存器联动STM32的定时器是如何实现这一点的关键在于三个寄存器自动重装载寄存器ARR、预分频器寄存器PSC和捕获/比较寄存器CCRx。以向上计数模式为例时钟源系统时钟比如72MHz经过PSC预分频得到定时器的实际计数时钟CK_CNT。CK_CNT 主时钟 / (PSC 1)。计数计数器CNT从0开始每个CK_CNT周期加1。比较CNT的值会不断与CCRx寄存器的值进行比较。输出动作当CNT CCRx时输出一种电平通常为高当CNT CCRx且CNT ARR时输出另一种电平通常为低。周期重置当CNT计数到ARR的值时产生一个更新事件UEVCNT清零重新开始同时PWM输出也回到初始状态开始下一个周期。所以ARR决定了PWM的周期频率CCRx决定了PWM的占空比。3.2 参数计算从需求到配置值这是理论到实践的关键一步。假设我们的需求是生成一个频率为1kHz占空比为50%的PWM波。系统主时钟为72MHz。步骤1确定定时器时钟和计数模式我们使用通用定时器TIM2它的时钟来源于APB1总线。在默认配置下如果APB1预分频系数为1则TIM2时钟为72MHz如果APB1预分频系数不为1比如为2时钟36MHzSTM32有一个倍频机制会使定时器时钟翻倍变回72MHz。为简化我们通常认为TIM2时钟是72MHz。步骤2计算ARR和PSCPWM频率公式为Fpwm CK_CNT / ((ARR 1) * (PSC 1))其中CK_CNT 72MHz / (PSC 1)。这个公式可以简化为Fpwm 72MHz / ((ARR 1) * (PSC 1))我们的目标是Fpwm 1kHz 1000Hz。 所以(ARR 1) * (PSC 1) 72MHz / 1000Hz 72000ARR和PSC都是16位寄存器最大值65535。我们需要找两个整数因子乘积等于72000。同时为了获得精细的占空比调节能力ARR不宜过小。一个常见且合理的分配是令PSC 1 72 则PSC 71那么ARR 1 72000 / 72 1000 则ARR 999步骤3计算CCRx占空比 CCRx / (ARR 1) 50%占空比则CCRx (ARR 1) * 0.5 1000 * 0.5 500配置总结PSC 71ARR 999CCRx 500(以通道1为例即TIM2-CCR1 500)这样我们就得到了一个频率精确为1kHz占空比为50%的PWM波。通过动态修改CCRx的值比如在中断里就能实现呼吸灯效果通过接收指令修改CCRx就能控制舵机角度或电机转速。实操心得ARR的值决定了占空比调节的分辨率。ARR999时占空比最小调节步进是0.1%。如果你需要更精细的控制比如高级灯光调光可以增大PSC从而获得更大的ARR值。但记住ARR增大会降低PWM频率需要在分辨率和频率之间权衡。4. 两种实战配置从CubeMX到代码理解了原理我们来实战。我将分别用STM32CubeMXHAL库和标准库两种方式演示如何配置TIM2_CH3PA2引脚产生上述1kHz的PWM。4.1 基于STM32CubeMX与HAL库的快速配置系统与时钟配置在CubeMX中新建工程选择STM32F103C8Tx。在“Clock Configuration”标签页将HSE外部高速时钟设置为Crystal/Ceramic Resonator并将系统时钟树配置为72MHz通常PLL倍频后得到。引脚配置在“Pinout Configuration”标签页的芯片图上找到PA2引脚。点击它选择“TIM2_CH3”。CubeMX会自动将PA2模式设置为“Alternate Function Push Pull”。定时器参数配置在左侧导航栏找到“Timers” - “TIM2”。Clock Source选择“Internal Clock”。Channel3选择“PWM Generation CH3”。切换到“Parameter Settings”子标签Prescaler (PSC - 16 bits value)填入71Counter Mode选择“Up”Counter Period (AutoReload Register - 16 bits value)填入999Internal Clock Division (CKD)保持“No Division”auto-reload preload建议Enable可以避免更新参数时产生毛刺。再切换到“GPIO Settings”子标签可以确认PA2的配置。生成代码在“Project Manager”标签设置好工程名、路径、IDEMDK-ARM V5将“Code Generator”中的“Generated files”设置为“Generate peripheral initialization as a pair of ‘.c/.h’ files per peripheral”。点击GENERATE CODE。用户代码编写在生成的Keil工程中打开main.c。在/* USER CODE BEGIN 2 */后启动PWM输出HAL_TIM_PWM_Start(htim2, TIM_CHANNEL_3);在需要改变占空比的地方例如在while循环中调用__HAL_TIM_SET_COMPARE(htim2, TIM_CHANNEL_3, 新的CCR值); // 例如设置为30%占空比__HAL_TIM_SET_COMPARE(htim2, TIM_CHANNEL_3, 300);编译下载用示波器测量PA2引脚就能看到稳定的1kHz PWM波。4.2 基于标准库的底层配置对于想深入了解机制或维护旧项目的开发者标准库的配置更有教育意义。#include stm32f10x.h void TIM2_PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; // 1. 开启时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); // 2. 配置GPIO PA2 为复用推挽输出 GPIO_InitStructure.GPIO_Pin GPIO_Pin_2; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; // 复用推挽输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // 3. 配置定时器时基 TIM_TimeBaseStructure.TIM_Period 999; // ARR TIM_TimeBaseStructure.TIM_Prescaler 71; // PSC TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); // 4. 配置PWM输出比较模式 TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; // 模式1 TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse 500; // 初始CCR值决定初始占空比 TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; // 输出极性高电平有效 TIM_OC3Init(TIM2, TIM_OCInitStructure); // 初始化通道3 // 5. 使能定时器的预装载寄存器ARPE TIM_OC3PreloadConfig(TIM2, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM2, ENABLE); // 6. 启动定时器 TIM_Cmd(TIM2, ENABLE); } int main(void) { TIM2_PWM_Init(); uint16_t pulse 0; uint8_t dir 0; // 方向标志 while(1) { // 一个简单的呼吸灯效果 if(dir 0) { pulse; if(pulse 1000) dir 1; } else { pulse--; if(pulse 0) dir 0; } TIM_SetCompare3(TIM2, pulse); // 修改通道3的CCR值 Delay_ms(1); // 需要一个简单的延时函数 } }这段代码完全从寄存器层面实现了PWM配置。TIM_OCMode_PWM1和TIM_OCMode_PWM2的区别在于输出电平极性需要根据实际电路需求选择。TIM_OCPolarity可以再次翻转这个极性。5. 高级应用与特殊场景剖析掌握了基础PWM后我们可以挑战一些更复杂的应用场景这也是区分新手和老手的地方。5.1 多路同步PWM与舵机控制控制多个舵机是机器人项目的常见需求。一个舵机通常需要周期为20ms50Hz高电平宽度在0.5ms到2.5ms之间的PWM信号。如果用多个定时器分别产生既浪费资源又难以同步。最佳实践是使用一个定时器的多个通道。例如用TIM3的四个通道CH1-CH4分别控制四个舵机。因为它们共享同一个ARR和PSC所以频率绝对同步只有CCRx不同从而控制不同舵机的角度。配置方法与单通道类似只需初始化多个通道即可。在HAL库中对每个通道调用HAL_TIM_PWM_Start在标准库中分别调用TIM_OCxInit。注意事项舵机对PWM信号的脉宽要求比较严格。计算CCRx时要确保精度。例如对于72MHz时钟50Hz频率ARR71999 PSC19那么0.5ms脉宽对应的CCRx (0.5ms / 20ms) * (719991) 1800。2.5ms对应9000。确保你的CCRx值在这个范围内。5.2 互补PWM与死区时间插入基于TIM1这是驱动直流有刷电机或BLDC电机的关键。H桥电路需要两路互补的PWM信号高侧和低侧来控制电流方向。如果互补信号同时为高会导致电源短路直通烧毁MOS管。TIM1的高级功能可以完美解决这个问题配置互补通道在CubeMX中为TIM1的某个通道如CH1选择“PWM Generation CH1”和“PWM Generation CH1N”。CH1N就是CH1的互补通道。配置死区时间在TIM1的参数设置中找到“Dead Time”选项。死区时间单位可以是微秒或时钟周期数。你需要根据你所使用的MOS管或驱动芯片的“开通延迟时间”和“关断延迟时间”来计算。一个经验值是几百纳秒到几微秒。在72MHz时钟下一个时钟周期约13.9ns。设置Dead Time 10则死区时间约为139ns。通常通过示波器观察和实际测试来调整到最佳值。刹车功能TIM1的刹车Break输入引脚通常是PB12重映射后可能是PA6可以接一个急停开关或过流检测信号。一旦刹车信号有效硬件会立即强制所有PWM输出进入安全状态通常为固定电平响应速度比软件中断快得多。配置完成后你只需要像控制普通PWM一样修改主通道CH1的CCR值互补通道CH1N会自动生成反相且带有死区时间的波形极大地简化了电机驱动软件的复杂度并提高了安全性。5.3 使用PWM实现数模转换DACSTM32F103C8T6没有内置DAC但我们可以用PWM加一个简单的RC低通滤波器来模拟DAC功能。PWM波是数字方波其平均电压与占空比成正比。用一个电阻和电容组成一阶低通滤波器可以滤除高频的开关成分留下平滑的直流电压。实现步骤生成一个频率较高的PWM比如20kHz以上高频有助于滤波器设计能获得更平滑的输出和更快的响应。根据所需的模拟电压分辨率选择合适的ARR值。例如ARR设为255则CCRx有256级可以模拟一个8位DAC。在输出引脚如PA1和地之间连接一个RC滤波器。截止频率f_c 1 / (2πRC)应远小于PWM频率比如1/10到1/100。例如PWM频率为20kHz可以选择f_c 500Hz。取R1kΩ则C 1 / (2π * 1000 * 500) ≈ 0.32μF取标准值0.33μF。通过软件改变CCRx的值在滤波器输出端就能得到变化的模拟电压。这种方法成本极低但缺点也很明显输出阻抗高、带载能力弱、电压精度和线性度受电源纹波和元器件精度影响。适用于对性能要求不高的场景如LED缓亮缓灭、生成简单的控制电压等。6. 调试心法与常见问题实录即使原理和步骤都清楚实际调试中还是会遇到各种“妖魔鬼怪”。下面是我总结的常见问题清单和排查思路。6.1 问题没有PWM波形输出这是最高频的问题。请按照以下清单逐项排查99%的问题都能解决时钟树检查定时器的时钟开启了吗这是最根本的。检查RCC_APB1PeriphClockCmd标准库或CubeMX的时钟配置。用示波器测量一下定时器相关的引脚如果完全没有信号首先怀疑时钟。引脚配置检查GPIO模式是否正确必须配置为复用推挽输出AF_PP。如果用成了普通推挽输出GPIO_Mode_Out_PP定时器无法控制引脚。引脚复用/重映射检查你用的这个引脚真的映射到了这个定时器通道吗查阅数据手册的“Alternate function mapping”表格。如果需要重映射AFIO的时钟和重映射配置是否开启定时器使能检查调用TIM_Cmd()或HAL_TIM_PWM_Start()了吗前者使能计数器后者不仅使能计数器还使能了输出比较通道。输出使能检查在标准库中TIM_OCInitStructure.TIM_OutputState是否设置为TIM_OutputState_Enable在HAL库生成的代码中检查htim2.Init.OCxMode等参数。参数合理性检查CCRx的值是否大于ARR如果CCRx ARR输出将保持有效电平根据极性设置你可能误以为没有输出。确保0 CCRx ARR。硬件连接检查示波器探头接地夹是否接好测量点是否准确开发板的供电是否稳定有时问题就出在一条接触不良的杜邦线上。6.2 问题PWM频率或占空比不对计算错误重新核对PSC、ARR、CCRx的计算公式。特别注意系统时钟频率是否正确。如果你的代码里系统时钟不是72MHz所有计算都要重来。寄存器未生效在标准库中修改了CCRx后如果未启用预装载TIM_OCxPreloadConfig(TIMx, TIM_OCPreload_Enable)新值会立即更新可能在任意时刻生效导致产生一个长短不一的异常PWM脉冲。最佳实践是始终启用预装载然后在所有参数修改完毕后通过设置TIMx-EGR寄存器的UG位或调用TIM_GenerateEvent函数来产生一个更新事件让新参数在下一个周期统一生效。HAL库的__HAL_TIM_SET_COMPARE宏是安全的它会在设置后处理更新。中断干扰如果PWM的频率非常高比如上百kHz而系统中断又很频繁可能会影响定时器的计数导致频率微小的不稳定。优化中断服务程序或者考虑使用DMA来搬运PWM数据。6.3 问题高级定时器TIM1的互补输出不正常主输出使能MOETIM1的互补输出需要开启一个总开关。在标准库中调用TIM_CtrlPWMOutputs(TIM1, ENABLE)。在HAL库中使用HAL_TIM_PWM_Start启动主通道时通常会自动使能MOE但最好确认一下。刹车输入状态检查刹车BRK和刹车输入BRKIN引脚的电平。如果它们被意外拉高有效会强制关闭所有输出。确保这些引脚处于无效状态或正确配置。死区时间设置过大如果死区时间设置得比有效脉冲宽度还大会导致整个周期内都没有有效电平输出。用示波器同时观察主通道和互补通道调整死区时间参数。调试PWM示波器是必不可少的工具。它不仅能告诉你“有没有”还能告诉你“对不对”——频率、占空比、上升沿时间、死区时间都一目了然。养成“代码未动示波器先行”的习惯能节省大量猜测和折腾的时间。当你看到屏幕上那条干净、标准的方波时那种成就感就是嵌入式开发最朴素的快乐。