ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

STM32 PWM驱动舵机实战:从定时器配置到调试与保护

STM32 PWM驱动舵机实战:从定时器配置到调试与保护 简介面向STM32单片机初学者与嵌入式开发者提供基于STM32F103C8T6利用PWM输出驱动舵机的完整工程示例帮助理解定时器配置、占空比调节与舵机角度控制方法。压缩包共86个文件以37个C源码和35个H头文件为主辅以启动文件、Keil工程文件、调试配置等整体仅317KB目录划分清晰便于直接打开学习。目前已有424人学习下载。工程不仅包含PWM生成与舵机驱动代码还集成按键输入、OLED显示、串口通信等模块可在实际板卡上验证0度至180度舵机定位并掌握预分频器、自动重载寄存器、捕获比较寄存器的配合方法。对于希望快速上手STM32定时器PWM应用或完成舵机控制类课设、竞赛项目的读者这份资源具有较高参考价值也可作为进一步实现多舵机同步控制的基础。1. 用STM32F103C8T6驱动舵机先搞懂“PWM控制舵机”这条路的边界把舵机接到STM32F103C8T6最小系统板上很多人第一反应是直接用Arduino的Servo库几行代码就转起来了。但真正拿到这个标准库工程时会发现PWM.c、Servo.c、Key.c、OLED.c散落成一堆文件并没有一个统一的一站式接口。原因是PWM只是单片机的硬件资源舵机只是PWM的一种消费场景中间还隔着占空比换算、定时器分频和电源设计。下面把“STM32用PWM驱动舵机”这条链路完整拆开从定时器寄存器的PSC/ARR/CCR换算到标准库初始化代码再到按键、串口调试和上电保护。STM32F103C8T6的定时器资源足够驱动多个舵机但这不代表直接把示例抄过来就能用。如果你正在用STM32CubeMX配HAL库下面的寄存器计算方式同样有参考价值因为底层定时器原理完全一致。2. 舵机角度与PWM参数换算占空比计算必须先于写代码2.1 从1ms/1.5ms/2ms说起舵机角度不是占空比直接给的舵机内部不是“占空比50%就转中间”的简单比例关系。标准模拟舵机的控制信号是周期20ms、高电平脉宽1ms到2ms的脉冲。内部电路把输入脉冲宽度与一个基准脉冲宽度做比较差值驱动电机直到角度反馈电压与输入脉宽对应的期望值相等。因此决定舵机角度的是绝对的“高电平持续时间”而不是高电平时间与周期的百分比。以SG90为例1ms对应0°1.5ms对应90°2ms对应180°超过这个范围容易触发堵转或异响。在代码里直接把脉宽转换成定时器的比较值CCR比计算百分比更直观。CCR表示在一个计数周期内从0开始计数到输出翻转位置的步数。只要知道定时器时钟频率就能把“微秒”换算成“计数值”。这也是为什么很多标准库教程里会看到PSC71、ARR19999而不是直接写占空比0.1——前者把计数器变成1MHz每个计数单位正好1usCCR1000就是1ms省去浮点运算。2.2 Timer2的PWM分辨率怎么选PSC、ARR与CCR的关系STM32F103C8T6的TIM2是16位通用定时器挂在APB1总线。当APB1预分频系数为2时TIM2的输入时钟是72MHz这是初学者最容易踩的坑直接根据RCC_APB1PeriphClockCmd开了时钟就以为定时器时钟是36MHz其实在标准库默认配置里它会被倍频到72MHz。无论实际是多少都要以外设时钟配置寄存器为准。关键公式如下f_cnt f_timer / (PSC 1)T (ARR 1) / f_cntt_high CCR / f_cnt。其中f_timer在F103C8上通常是72MHz。PSC是预分频系数ARR是自动重载值CCR是比较值。为了让每个计数单位等于1us令PSC71f_cnt1MHz。要输出50Hz周期20ms则ARR120000即ARR19999。这样CCR允许的范围是0~19999而舵机需要的1000~2000正好落在中间误差不超过1us。如果希望半微秒步进可以把PSC改为35得到2MHz计数频率这样CCR直接等于1us脉宽的2倍。比如1.5ms对应CCR3000角度分辨率提高到0.09°。但要注意ARR也要跟着改周期20ms对应的ARR39999仍在16位范围内。下面表格列出几种组合方便直接采用PSCf_cntARR20ms周期分辨率每步对应时间舵机满量程步数711MHz199991us1000352MHz399990.5us2000154.5MHz89999超出16位不可用--072MHz1439999超出16位不可用--这个表想说明16位定时器的ARR最大65535想获得更高分辨率就得接受更粗的时基周期。舵机控制并不需要极致分辨率PSC71是最稳妥的通用选择。如果你做的是总线舵机机械臂那种高精度场景就要考虑换用32位定时器或更高主频的芯片而不是硬调PSC。2.2.1 为什么不能用“直接调CCR到999”来凑周期有一种常见误用是把PSC设成0想用72MHz直接数出20ms但ARR必须达到1,439,999远超65535所以即使往TIM_Period写入也会被截断实际输出频率完全不对。反过来有人为了获得0.1°分辨率把PSC设得很小导致ARR溢出。遇到舵机乱抖或“嗡嗡”叫时第一反应应该是查ARR有没有溢出以及实测波形频率是不是50Hz。2.3 标准库初始化代码TIM2_CH1输出1MHz计数、20ms周期下面这段代码符合工程里PWM.c的写法使用标准外设库目标引脚是PA0TIM2_CH1默认映射。整个初始化分成三部分GPIO时钟和引脚模式、定时器时基、输出比较通道。void PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); /* PA0 作为TIM2_CH1复用推挽输出 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); /* 定时器时钟 72MHz / (711) 1MHz计数周期1us */ TIM_TimeBaseStructure.TIM_Prescaler 71; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period 19999; /* 20ms */ TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse 1500; /* 初始1.5ms90度 */ TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM2, TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM2, ENABLE); TIM_Cmd(TIM2, ENABLE); }代码逻辑先打开GPIOA和TIM2的时钟注意TIM2的时钟挂在APB1上用RCC_APB1PeriphClockCmd写错成APB2会导致配置无效。GPIO_Mode_AF_PP表示复用推挽这是PWM输出引脚的必要模式不是GPIO_Mode_Out_PP。TIM_OCMode_PWM1约定计数器CNT小于CCR时输出有效高电平大于等于CCR时输出低电平所以设置TIM_Pulse1500后PA0上就是一个周期20ms、高电平1.5ms的波形。TIM_OC1PreloadConfig和TIM_ARRPreloadConfig打开影子寄存器避免在周期运行中修改CCR/ARR时直接改变输出产生毛刺。最后TIM_Cmd使能定时器PWM才开始输出。如果项目里把PWM改成TIM2_CH1但用的是PA0而引脚被复用成JTAG或别的功能需要检查重映射。默认没有重映射时PA0就是TIM2_CH1所以这段代码不调用GPIO_PinRemapConfig。2.4 角度到CCR的线性映射函数在Servo.c里一般不会直接暴露CCR给应用层而是提供一个角度接口。下面函数把0~180度线性映射到1000~2000#define SERVO_CCR_MIN 1000 #define SERVO_CCR_MAX 2000 void Servo_SetAngle(uint16_t angle) { uint16_t ccr; if (angle 180) angle 180; ccr SERVO_CCR_MIN (uint16_t)((uint32_t)angle * (SERVO_CCR_MAX - SERVO_CCR_MIN) / 180); TIM_SetCompare1(TIM2, ccr); }这里的计算顺序很重要。先转成uint32_t再乘除是为了避免angle1000在16位乘法下溢出也避免先除再乘的截断误差。比如angle45451000/180250结果刚好1250。如果写成angle/180*100045/1800CCR永远是1000舵机永远停在0度。目标角度推荐CCR实际脉宽0°10001.000ms45°12501.250ms90°15001.500ms135°17501.750ms180°20002.000ms需要注意的是不同厂家舵机的脉宽范围未必完全对称。比如某些金属舵机是0.5ms~2.5ms对应0~180度这个映射就变成500~2500同时ARR保持不变仍然输出20ms周期。有一种做法是把SERVO_CCR_MIN和SERVO_CCR_MAX放在一个结构体里做成可配置项方便换舵机类型而不是每次改代码。这个方法在总线舵机机械臂项目里尤其常用因为不同关节的限位参数不一样。如果按2.2里PSC35的分辨率这两个宏也要改成2000和4000。3. 按键调角度、串口看波形调试PWM的三个层次3.1 用按键改变CCR验证舵机是否跟随拿到工程后第一步不是直接写复杂逻辑而是先让舵机能手动动起来。工程里的Key.c是现成的常见接法是把一个独立按键接到PA1按下拉低。下边是标准的轮询扫描写法带软件消抖#define KEY_GPIO_PORT GPIOA #define KEY_GPIO_PIN GPIO_Pin_1 void Key_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin KEY_GPIO_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(KEY_GPIO_PORT, GPIO_InitStructure); } uint8_t Key_Scan(void) { static uint8_t key_last 1; uint8_t key_now GPIO_ReadInputDataBit(KEY_GPIO_PORT, KEY_GPIO_PIN); if (key_last 1 key_now 0) { Delay_Ms(20); if (GPIO_ReadInputDataBit(KEY_GPIO_PORT, KEY_GPIO_PIN) 0) { key_last 0; return 1; } } key_last key_now; return 0; }GPIO_Mode_IPU是上拉输入按键另一端接地。Key_Scan检测下降沿并做20ms延时消抖返回1表示一次有效按下。如果只判断电平而不判断边沿按住按键期间会连续触发舵机就会来回扫动。在main里把角度和按键事件绑起来uint16_t angle 90; while (1) { if (Key_Scan() 1) { angle 10; if (angle 180) angle 0; Servo_SetAngle(angle); } }按下一次角度增加10度超限回卷到0度。能看到舵机“咔哒”一声转到对应位置说明PWM通道、GPIO、舵机供电都正常。如果舵机只抖不动或者声音不对先别查代码用万用表量舵机供电电压常见故障是USB口3.3V撑不住舵机启动电流需要外部5V供电并与STM32共地。3.2 串口打印观察角度与CCR的实际值按键只能验证大致动作要确认软件计算是不是和预期一致建议开通USART1打印。工程里一般已经写好usart.c关键是把printf的重定向加上#include stdio.h int fputc(int ch, FILE *f) { USART_SendData(USART1, (uint8_t)ch); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); return ch; }然后在main循环里把当前角度和实际CCR值打出来printf(angle:%d ccr:%d\r\n, angle, TIM_GetCapture1(TIM2));TIM_GetCapture1(TIM2)读取的是TIM2的捕获/比较寄存器1在输出比较模式下就是当前PWM的比较值。这个值应该等于Servo_SetAngle里计算出的CCR。如果打印出来是0检查是否配置了TIM_OC1Init如果打印出来的CCR能变但舵机不动问题多半在GPIO复用模式或外部电路。串口数据还可以用来核对线性映射的量化误差。比如设定123度时虽然理想脉宽是1.683ms但整数运算后CCR可能是1683实际误差不到1us对应角度误差约0.18度。对普通舵机完全够用但如果要做视觉伺服就得把映射改成查表并标定舵机实际零点。3.3 用示波器/逻辑分析仪看PA0波形代码和打印都正常不代表波形正常。PWM这类时基信号最终要拿示波器或逻辑分析仪看物理波形的频率和脉宽。把探头夹在PA0和GND之间观察波形应该是50Hz的方波高电平宽度随角度变化。如果没有示波器几十块钱的逻辑分析仪配合PulseView也可以测频率采样率10MHz足够分辨1us的脉宽。测量时重点看两个指标周期和脉宽。周期要稳定在19.98ms~20.02ms附近脉宽要和CCR/定时器时钟一致。如果周期误差超过1ms先怀疑APB1时钟不是72MHz或PSC配置不对。如果周期对而脉宽不对检查CCR是否超出ARR范围以及是否使用了预装载寄存器。常见的波形异常和排查点可以按下面的表格来现象可能原因检查方法PA0无输出GPIO模式配置错误或定时器时钟未使能检查GPIO_CFG和RCC寄存器周期只有10msARR设成了9999重新计算20ms对应ARR19999脉宽固定为0或者满周期CCR越界打印TIM_GetCapture1舵机随负载抖动舵机电源跌落舵机供电并100uF电解电容4. 工程里的Servo.c与PWM.c怎么分工把控制逻辑和硬件配置解耦4.1 文件结构里为什么会同时有PWM.c和Servo.c拿到ZIP解压后Hardware目录里同时出现PWM.c、Servo.c、Key.c第一次接触会觉得冗余。其实这是很典型的分层PWM.c只解决“定时器配置和输出”不关心信号是给舵机还是给LEDServo.c只解决“角度到CCR换算”服务对象是舵机。工程里还有TIM2.c和PWM.c并列也属于这种分层TIM2.c管时基PWM.c管输出通道读者不必纠结文件名关键是看include关系。把这两个文件分开最大的好处是换舵机时不用碰定时器代码换定时器时也不用动角度映射。标准库工程的组织方式一般是PWM.c封装TIM2的时基、输出比较通道提供PWM_SetDuty接口Servo.c调用PWM_SetDuty把角度换算成CCR同时定义舵机脉宽限位Key.c按键扫描纯IO逻辑与PWM无关main.c把按键、舵机、OLED/串口串起来对应到代码上PWM.h里声明void PWM_Init(void); void PWM_SetDuty(uint16_t ccr);Servo.c包含PWM.h但反过来不让PWM.c包含Servo.h避免循环依赖。角度换算写在Servo.c里即使以后把标准库换成HAL库修改范围也只在PWM.c和头文件Servo_SetAngle接口保持不变。4.2 写一个带斜坡限速的Servo_SetAngle直接调用Servo_SetAngle把CCR瞬间从1000改成2000舵机会以内部允许的最大速度猛甩过去。在机械臂或仿生手上这样会造成抖动和齿轮撞击。一个常见的改进是每次只把CCR朝目标方向移动一小格达到平滑过渡。下面代码用两个全局变量保存当前CCR和目标CCRstatic uint16_t cur_ccr 1500; static uint16_t tgt_ccr 1500; void Servo_MoveTo(uint16_t angle) { if (angle 180) angle 180; tgt_ccr 1000 (uint16_t)((uint32_t)angle * 1000 / 180); } void Servo_Poll(void) { if (cur_ccr tgt_ccr) { cur_ccr 1; TIM_SetCompare1(TIM2, cur_ccr); } else if (cur_ccr tgt_ccr) { cur_ccr - 1; TIM_SetCompare1(TIM2, cur_ccr); } }在main的while循环里每隔2ms调用一次Servo_Poll那么每2ms只增加1us脉宽从0度走到180度需要约1000步总计2秒。如果想调节速度改成每5ms调用一次或者每次步进2个单位效果类似。有一点需要注意定时器周期是20ms但Servo_Poll的执行频率和PWM周期无关它只是改变下一个PWM周期的比较值。所以即使每2ms更新一次CCR舵机实际能追踪的步进还是要看舵机自身惯量步进间隔太短反而可能让舵机来不及到达就又收到新指令。4.3 多舵机扩展TIM2的四个通道与重映射STM32F103C8T6最常用的舵机控制方案是用TIM2输出四路PWM分别接PA0~PA3控制四路舵机。初始化时只需要在PWM_Init里对OC1~OC4分别调用TIM_OCxInit时基部分保持不变然后增加一个通道选择参数void Servo_SetAngle(uint8_t ch, uint16_t angle) { uint16_t ccr 1000 (uint16_t)((uint32_t)angle * 1000 / 180); switch (ch) { case 0: TIM_SetCompare1(TIM2, ccr); break; case 1: TIM_SetCompare2(TIM2, ccr); break; case 2: TIM_SetCompare3(TIM2, ccr); break; case 3: TIM_SetCompare4(TIM2, ccr); break; default: break; } }四个通道输出同一频率因为共用ARR和PSC。但每个通道的CCR独立所以舵机可以停在不同角度。需要注意的是TIM2四个通道的默认引脚PA0~PA3如果这些引脚还接了别的外设可以用重映射把TIM2映射到PA15、PB3、PB10、PB11。一旦重映射要同时禁用JTAG因为PA15默认是JTDI引脚GPIO_PinRemapConfig(GPIO_Remap_SW_JTAGDisable, ENABLE); GPIO_PinRemapConfig(GPIO_FullRemap_TIM2, ENABLE);然后还要把对应GPIO配成复用推挽。常见对照表TIM2通道默认引脚完全重映射引脚CH1PA0PA15CH2PA1PB3CH3PA2PB10CH4PA3PB11如果只是控制一到两个舵机不建议启用重映射因为要牺牲JTAG而且重映射后的引脚还可能和其它板载外设冲突。多舵机项目真正要优先考虑的应该是电源每个SG90正常工作电流约150mA堵转可能到500mA以上四个舵机同时堵转电流接近2A普通AMS1117稳压器带不动。给舵机单独接5V供电并且把电源地线和STM32地线连在一起控制信号线不能代替地线回路。5. 给PWM加保护上电瞬间舵机乱摆的问题与对策舵机上电瞬间乱摆是很多项目中第一个暴露出来的问题。原因在于MCU的GPIO在复位期间是浮空输入PA0上的电平不确定而此时舵机已经供电会把不确定电平当成控制脉冲的一部分导致舵机快速甩到一个随机角度。等到PWM_Init执行到TIM_Cmd时舵机已经动了一大截。最直接的办法是把舵机电源用一个MOS管或三极管开关控制MCU上电后先延时等所有GPIO和定时器配置完成再打开舵机电源。如果硬件已经固定无法改软件里也可以先禁止定时器等系统完成初始化再启动PWM。下面这段代码是初始化顺序的示范int main(void) { Delay_Init(); Key_Init(); OLED_Init(); USART_Config(); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_Cmd(TIM2, DISABLE); PWM_Init(); Servo_SetAngle(90); Delay_Ms(100); TIM_Cmd(TIM2, ENABLE); ... }这段代码里有一个容易绕晕的点PWM_Init最后会执行TIM_Cmd(TIM2, ENABLE)如果又提前DISABLE然后再ENABLE顺序要清楚。更稳妥的做法是把PWM_Init拆成“配置”和“使能”两步或者直接把PWM_Init末尾的TIM_Cmd去掉由main统一控制。这样能保证从复位到舵机开始受控PA0始终处于确定状态。软件看门狗也值得加。舵机堵转时电流很大长时间通电会烧舵机。开启独立看门狗后一旦程序跑飞MCU会在几百毫秒内复位PWM停止输出防止持续堵转。标准库的IWDG配置如下IWDG_WriteAccessCmd(IWDG_WriteAccess_Enable); IWDG_SetPrescaler(IWDG_Prescaler_64); IWDG_SetReload(625); IWDG_ReloadCounter(); IWDG_Enable();预分频64时IWDG的时钟约40kHz除以64得到625Hz左右重载625对应约1秒溢出。在while主循环里每次循环结尾调用IWDG_ReloadCounter()。注意喂狗周期必须小于溢出时间否则程序正常运行也会被复位。如果你要的是硬件级PWM故障保护需要TIM1/TIM8的break输入而TIM2没有这个功能所以舵机项目里更多是用看门狗和电源开关兜底。最后还有一个容易被忽视的细节舵机控制信号的地线不要和舵机电源地线共用一根细长的杜邦线。舵机启动瞬间地线压降会让PWM信号参考点漂移出现奇怪的抖动。把舵机电源地线单独用粗线接到5V电源的地端再从同一地端引一根线到STM32的GND让逻辑地单点连接能减少很多莫名其妙的现象。本文还有配套的精品资源点击获取
返回列表