news 2026/10/3 6:40:43

STM32 HAL库控制SG-5010舵机:PWM原理与工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 HAL库控制SG-5010舵机:PWM原理与工程实践

1. 项目拆解与核心思路

玩嵌入式到一定阶段,几乎都会碰到舵机。舵机是个非常讨喜的执行器——不用编码器、不用驱动板,给对脉冲就转到对应角度,接上电源就能转,属于“性价比极高”的入门级运动控制方案。这篇内容围绕“STM32控制SG-5010舵机转动”这个项目展开,使用HAL库,配合CubeMX图形化配置,从原理到代码再到排坑,尽量一次性讲透。

先说清楚这个项目能做什么、适合谁。如果你正在做机械臂、小车转向、云台、鱼缸喂食器,或者毕业设计里需要“让某个东西转到指定角度”,那么用STM32输出PWM控制舵机几乎是必经之路。SG-5010是标准的模拟舵机,体积比常见的SG90大一圈,扭矩也大很多,非常适合作为学习舵机控制的载体。这篇内容的读者,可以是刚学完GPIO和定时器的初学者,也可以是已经会用标准库、想切到HAL库的开发者,只要你在“用定时器输出PWM”这个环节上有需求,这篇文章的配置思路和代码封装都可以直接抄。

我当时做这个项目的时候,起因是想给一个云台加舵机驱动,但网上搜到的教程大多只贴了一段代码,舵机能转就完了,根本没讲清楚为什么定时器要配成72分频、为什么周期是20000、为什么比较值500对应0度。真换一个定时器、换一块板子,代码就“失灵”了。所以这篇内容我打算把思路拆开讲:SG-5010到底要什么样的信号,STM32怎么用HAL库生成这个信号,以及你拿到代码之后怎么改参数适配自己的板子。

1.1 SG-5010舵机到底是怎样一个器件

舵机的全称是“舵机伺服系统”,内部集成了直流电机、减速齿轮组、电位器位置传感器和控制电路。它对外只有三根线:电源、地、信号。控制电路根据信号线上输入的PWM脉宽,和电位器反馈的实际位置做比较,然后驱动电机往目标位置转,直到误差为零。

SG-5010的详细参数,这里给个速查表:

参数数值说明
工作电压4.8V ~ 6V推荐5V或6V,电压越高扭矩越大
扭矩5.5kg·cm(4.8V)/ 6.5kg·cm(6V)标准舵机里算比较有劲的
工作频率50Hz即PWM周期20ms
脉宽范围0.5ms ~ 2.5ms对应0° ~ 180°
死区≤5us脉宽误差在此范围内舵机不响应
重量约55g比SG90重得多
接线棕/GND、红/VCC、橙/信号具体颜色以实物为准

这里最核心的信息是“0.5ms ~ 2.5ms对应0° ~ 180°”。也就是说,你给舵机信号线上送一个周期为20ms、高电平持续1.5ms的PWM波,舵机就会转到90度位置。脉宽每增加约11us,舵机就大约转1度。这个线性关系是后面所有代码计算的基础。

另外一个容易被忽略的参数是“死区”。舵机内部电路对脉宽的分辨率是有限的,当输入脉宽和目标位置对应的脉宽差值小于死区时,舵机不会动作。这意味着,你不需要把PWM做到纳秒级精确,1us级别的精度已经绰绰有余。这个特性对后面定时器参数的选择非常友好。

还有一个重要认知:模拟舵机需要持续的PWM信号来维持位置。如果PWM信号停止,舵机虽然会因为齿轮自锁而停在当前角度,但外力一推就可能松动。所以代码里启动PWM之后,就不要随便停掉它。

1.2 为什么用HAL库定时器PWM,而不是IO口模拟

有两种常见方式可以让STM32输出PWM信号:一种是用普通GPIO配合定时器中断,在中断里翻转电平;另一种是用定时器的PWM输出模式,硬件自动产生波形。

GPIO模拟的好处是灵活,想输出什么波形都行,但代价是CPU被占死。假设用1us一个tick的节奏去翻转IO口,中断频率就是1MHz,CPU几乎什么都干不了。同时中断响应本身存在抖动,波形精度也不稳定,舵机容易出现“咔咔”的抖动声。

定时器PWM模式则完全不同。定时器在硬件层面自动完成计数、比较、翻转电平的动作,CPU只负责在需要改变角度时修改一个寄存器值。波形由硬件产生,稳定且精度高,CPU可以腾出来跑逻辑、跑通信、跑算法。这也是每次有人问“舵机控制用什么方式好”时,标准答案基本都是“定时器PWM”的原因。

至于为什么用HAL库而不是标准库,我的考虑很简单:CubeMX图形化配置定时器、引脚、时钟,生成代码后直接填逻辑,开发效率高很多。而且HAL库对寄存器的封装更规范,换芯片平台时,只要CubeMX重新生成一下底层,上层业务代码几乎不用改。LL库虽然性能上更接近寄存器操作,但对新手理解门槛更高,而且舵机是50Hz的低频信号,HAL库的函数调用开销完全是零压力的存在。所以这个项目选HAL库,纯粹是“用最合适的工具做最合适的事”。

2. 工程搭建与CubeMX配置

2.1 时钟树和引脚规划:先想清楚再点鼠标

很多人拿到板子直接打开CubeMX,赶紧把定时器配置出来,结果卡在时钟树上一脸懵。我的习惯是先从系统层面想一遍需求:

  • 需要几个通道输出PWM?本案例只需要1个,但我一般会预留几个备用引脚;
  • 定时器时钟源从哪里来?STM32F103系列,定时器时钟默认走APB1总线;
  • 系统主频跑多少?一般是72MHz,CubeMX会根据你选的晶振自动配好。

以最常见的STM32F103C8T6为例,板载8MHz晶振,CubeMX时钟树里,HSE选Crystal/Ceramic Resonator,系统时钟选PLL倍频到72MHz。APB1总线频率配置为36MHz,这里有个关键点:APB1预分频系数如果不等于1,那么挂在该总线上的定时器时钟是APB1频率的两倍,也就是72MHz。这一点很多人容易搞错,导致定时器频率算出来只有实际的一半。CubeMX里你可以直接在时钟树页面看到TIM2/3/4的时钟输入,把它记下来,后面计算要用。

引脚选择上,我习惯用TIM2的CH1,也就是PA0。原因很简单:PA0在绝大多数开发板上都引出来了,而且不占用串口和I2C引脚。如果你的板子PA0被其他功能占了,可以换TIM3_CH1(PA6)、TIM4_CH1(PB6),配置流程完全一样。

引脚的工作模式,CubeMX会自动帮你设成复用推挽输出(AF_PP),不需要手动配置。有人会纠结要不要开上拉电阻,PWM输出模式下不需要,那是输入模式的选项。

2.2 定时器PWM参数计算:为什么是71和19999

在CubeMX里配置TIM2的PWM,核心参数就四个:Prescaler(预分频)、Counter Mode(计数模式)、Counter Period(自动重装值)、Pulse(初始比较值)。每一项的值都需要算,不能瞎填。

首先是预分频。前文说到TIM2的时钟是72MHz,而我们希望计数器的计数频率是1MHz,也就是1us计数一次。公式是:

定时器时钟 /(预分频 + 1)= 计数频率

72MHz /(PSC + 1)= 1MHz,所以 PSC = 71。Counter Mode选向上计数(UP)即可。

然后是自动重装值。舵机要求的PWM周期是20ms,即20000us。计数器从0数到自动重装值需要的时间正好是20000us:

(自动重装值 + 1)× 1us = 20000us,所以自动重装值 = 19999。

Pulse是初始比较值,也就是初始脉宽。为了让舵机上电后先停在中间位置,初始脉宽设成1500us,对应90度,所以在CubeMX的Pulse里填1500。

算完之后你在CubeMX里会看到,生成的代码中TIM2的初始化结构体长这样:

static void MX_TIM2_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; TIM_OC_InitTypeDef sConfigOC = {0}; htim2.Instance = TIM2; htim2.Init.Prescaler = 71; htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 19999; htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; if (HAL_TIM_PWM_Init(&htim2) != HAL_OK) { Error_Handler(); } sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL; if (HAL_TIM_ConfigClockSource(&htim2, &sClockSourceConfig) != HAL_OK) { Error_Handler(); } sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET; sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; if (HAL_TIMEx_MasterConfigSynchronization(&htim2, &sMasterConfig) != HAL_OK) { Error_Handler(); } sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 1500; sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode = TIM_OCFAST_DISABLE; if (HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1) != HAL_OK) { Error_Handler(); } }

注意AutoReloadPreload被使能了,意思是自动重装值在更新事件时才生效。这是CubeMX默认配置,通常保持默认就好。OCMode是PWM1,含义是:计数器值小于比较值时输出高电平,大于比较值时输出低电平,这正好是我们需要的脉宽波形。

配置完成后,记得在CubeMX的Project Manager里勾选“Generate peripheral initialization as a pair of .c/.h files”,然后生成代码。这样工程里会自动出现tim.c和tim.h,写代码时更方便。

3. 核心代码实现与角度控制

3.1 让舵机动起来:启动PWM与设置脉宽

CubeMX生成的代码只是完成了初始化,PWM通道默认是没有启动的。要在main函数里显式调用启动函数:

HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);

这行代码执行后,PA0引脚就开始输出周期20ms、初始脉宽1.5ms的PWM波形了。如果此时舵机已经接好电源和信号线,它会立刻转到90度位置。

启动之后,改变角度就是修改比较寄存器。HAL库对此提供了宏定义:

__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 500); // 转到0度 __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 1500); // 转到90度 __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 2500); // 转到180度

这个宏的本质是设置TIM2的捕获比较寄存器CCR1的值。定时器每个计数周期都会拿CCR1和当前计数值做比较,超过计数值时拉低电平,从而改变脉宽。修改这个寄存器的操作是原子的,执行一条指令就完成了,不需要担心在PWM周期中途修改会产生毛刺——因为比较是在硬件层面实时进行的,下一个脉宽立刻就会变化。

一个值得注意的点:__HAL_TIM_SET_COMPARE接收的单位是“计数个数”,因为我们前面配的是1us计数一次,所以500就是500us脉宽、1500就是1500us脉宽。如果你的预分频不是71,单位就不是us,换算关系也会变。这也是我反复强调要理解参数计算的原因——不懂原理,换个配置就废。

3.2 封装角度换算函数:直接用角度说话

直接填500、1500、2500这种“魔法数字”虽然能用,但代码可读性太差。更优雅的做法是写一个角度换算函数,把0到180度映射到500到2500的脉宽值。

映射关系是线性的:

pulse = 500 + angle × 2000 / 180

用整数运算实现就是:

void Servo_SetAngle(TIM_HandleTypeDef *htim, uint32_t channel, uint8_t angle) { if (angle > 180) { angle = 180; } uint16_t pulse = 500 + (uint16_t)angle * 2000 / 180; __HAL_TIM_SET_COMPARE(htim, channel, pulse); }

这个函数把角度限制在180度以内,防止误操作把非法数据写进寄存器。2000乘以角度再除以180,先乘后除能避免整数除法截断导致的精度损失。

有了这个封装,主循环里的逻辑就非常清爽了:

int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_TIM2_Init(); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1); while (1) { Servo_SetAngle(&htim2, TIM_CHANNEL_1, 0); HAL_Delay(1000); Servo_SetAngle(&htim2, TIM_CHANNEL_1, 90); HAL_Delay(1000); Servo_SetAngle(&htim2, TIM_CHANNEL_1, 180); HAL_Delay(1000); Servo_SetAngle(&htim2, TIM_CHANNEL_1, 90); HAL_Delay(1000); } }

跑起来的效果就是:舵机在0度、90度、180度之间循环摆动。如果你的SG-5010实测结果略有偏差,比如0度时舵机还差5度才到机械限位,可以在函数里加一个偏移量做修正,比如脉冲值整体加上20us,这在实际项目中非常常见。

3.3 平滑转动与多目标控制

直接改比较值时,舵机会以最快速度冲过去,这在很多场景下看起来不够自然,也会带来机械冲击。更高级的玩法是让舵机以可控速度平滑过渡到目标角度。

最简单的实现是步进式逼近:每次只修改一小段脉宽,间隔几毫秒再修改下一段,直到到达目标值。

void Servo_SetAngleSmooth(TIM_HandleTypeDef *htim, uint32_t channel, uint8_t angle, uint16_t step, uint16_t delay_ms) { uint16_t current_pulse = __HAL_TIM_GET_COMPARE(htim, channel); uint16_t target_pulse = 500 + (uint16_t)angle * 2000 / 180; if (current_pulse < target_pulse) { for (uint16_t pulse = current_pulse; pulse <= target_pulse; pulse += step) { __HAL_TIM_SET_COMPARE(htim, channel, pulse); HAL_Delay(delay_ms); } } else { for (uint16_t pulse = current_pulse; pulse >= target_pulse; pulse -= step) { __HAL_TIM_SET_COMPARE(htim, channel, pulse); HAL_Delay(delay_ms); } } }

step参数控制步长,step越大速度越快;delay_ms控制步间延时,延时越大越平滑。这个函数虽然简单,但已经完全能满足云台预置位、机械臂分度动作这类需求。

如果想控制多个舵机,每个舵机对应一条PWM通道,把设备句柄和通道号作为参数传进去,上面封装天然支持多实例。比如你想让PA0上的舵机转到30度、PA1上的舵机转到120度,只需要:

Servo_SetAngle(&htim2, TIM_CHANNEL_1, 30); Servo_SetAngle(&htim2, TIM_CHANNEL_2, 120);

但要注意,同一时间多个舵机同时大角度快速转动时,瞬时电流会是单个舵机的数倍,电源余量一定要留足,否则电压跌落会导致所有舵机都出现抖动。

4. 电源、调试与常见问题排查

4.1 舵机供电:最容易翻车的环节

舵机项目里,十有八九的问题最终都能归结到电源上,而不是代码上。SG-5010正常工作电流在几百毫安,启动瞬间和堵转瞬间电流可以冲到1A以上。如果用开发板的USB口直接供电,USB口的500mA限流根本扛不住,表现就是:舵机一开始转动,板子上的LED闪一下,系统重启,串口掉线,什么异常都来了。

正确做法是单独给舵机供电,用一个5V、至少2A输出的电源适配器或者锂电池降压模块。接线方式为:舵机电源正极接5V电源正极,舵机电源负极接5V电源负极,同时舵机负极要和STM32的GND连在一起,也就是“共地”。信号线接STM32的PA0。

共地这一条特别重要。信号线上传输的是STM32的3.3V电平,参考地是STM32的GND;如果舵机电源地不和STM32共地,信号电平就没有统一的参考点,舵机接收到的脉宽是乱码,表现就是疯狂抖动或者完全不动。

另外,如果舵机电源线比较长或者线径太细,线上的压降会导致舵机输入电压不足。扭转力矩不够、转不到位、抖舵,都可能是电源线太细导致的。建议舵机电源线用至少0.5mm²的线,尽量短一些。我实测过,同一套系统,换一根粗短的电源线,抖动问题直接消失。

4.2 常见问题排查表

下面把实际项目中遇到的高频问题整理成表格,方便遇到问题时对照排查:

现象可能原因解决思路
舵机完全不动没启动PWM确认执行了HAL_TIM_PWM_Start
舵机完全不动信号线接错确认信号线接对引脚,颜色区分
舵机完全不动没共地把舵机电源负极与STM32 GND短接
舵机抖动并有滋滋声电源电流不足换大电流电源,或单独给舵机供电
舵机抖动并有滋滋声PWM频率不对确认周期是20ms,不是2ms
舵机只转一个方向脉宽超出限定范围检查angle是否限制在180以内
舵机转到尽头一直嗡嗡响脉宽超出机械行程用示波器或串口检查实际脉宽
板子一转动就重启舵机电流导致电压跌落独立供电,共地,不用USB直接供电
改比较值后无反应修改了错误定时器的CCR检查句柄和通道是否与启动时一致

这里特别说一下“嗡嗡响”这种情况。当脉宽对应角度超过机械限位时,舵机会一直往限位方向使劲,电机堵转,电流迅速上升,齿轮箱发出高频噪音。短时间内问题不大,但长时间堵转会明显缩短舵机寿命,甚至烧毁电机驱动管。代码里要做角度限幅,就是上面那个if (angle > 180) angle = 180;,这个小细节能救舵机一命。

4.3 实战排查:用示波器还是用逻辑分析仪

排查PWM信号问题时,有条件的话优先用逻辑分析仪。一根杜邦线夹在PA0上,能看到完整的PWM波形,周期、脉宽一目了然。没有也没关系,可以用串口打印辅助判断:在修改比较值后,把脉宽值通过串口发出来,确认软件层面算出的脉宽是否正确。

还有一个我个人踩过的小技巧:如果舵机在某个角度附近来回抖动,排除电源问题后,可以用手指轻触舵机臂感受力度——如果感觉舵机在“硬顶”,说明信号已经给了,只是机械负载太大,需要降低目标角速度;如果舵机“松垮”,说明信号根本没进去,问题在信号路径上。

5. 扩展玩法与进阶思考

5.1 从单舵机到多舵机:一体化的控制方案

实际项目中很少只用一个舵机。比如机械臂要同时控制肩、肘、腕三个关节,云台要同时控制水平和俯仰两个轴。多舵机的实现方案有三种常见选择:

第一种是直接用STM32的多个定时器通道。STM32的定时器资源很多,TIM2、TIM3、TIM4各有4个通道,理论上可以同时产生12路PWM,对绝大多数舵机应用绰绰有余。缺点是每路占一个引脚,走线会多一些。

第二种是用I2C接口的PCA9685舵机驱动板。它用两线I2C扩展出16路PWM输出,每路都可以独立设置周期和脉宽,而且板载电源端子可以直接接大电流电源。这种方案适合舵机数量多、主控引脚紧张的项目。HAL库对I2C有现成支持,移植官方驱动也不算难。

第三种是用串行总线舵机,比如LX-16A、总线舵机这类,通过UART指令控制和读取角度,还能回传温度和电压。这已经超出SG-5010模拟舵机的范畴了,但当舵机数量多、实时性要求高的时候,总线舵机是更合适的方向。

考虑到本项目是单舵机打基础,前面封装的Servo_SetAngle函数天然支持多通道扩展,这一点在后续项目复用时会发现很省心。

5.2 舵机与上位机联动:串口控制角度的实现

一个非常实用的扩展是:让PC或者手机通过串口发送指令,控制舵机转到任意角度。这本质上是把串口接收到的字符串解析成一个数值,然后调用封装函数。

用HAL库的中断接收方式,代码也不复杂。在main中启动串口中断接收:

uint8_t rx_buffer[16]; uint8_t rx_index = 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { rx_buffer[rx_index++] = rx_data; if (rx_data == '\n') { int angle = atoi((char *)rx_buffer); Servo_SetAngle(&htim2, TIM_CHANNEL_1, angle); rx_index = 0; } HAL_UART_Receive_IT(&huart1, &rx_data, 1); } }

上位机发一个“120\n”,舵机就转到120度。串口控制的好处是调试效率极高,不用重新编译烧录就能测试不同角度下的机械动作。把这条链路打通之后,后续接蓝牙模块、ESP8266、WiFi透传,原理都一样,只是把串口对端从PC换成了无线模块。这些热词里提到的ESP8266通信,实际上就是在这个串口联动基础上加一步透传而已。

5.3 死区校准与位置闭环的思路

前面提到SG-5010有死区参数,不同舵机的死区略微不同。实际使用时,如果发现舵机对微小角度变化不敏感,可以增大角度步长来避免死区问题。比如脉宽每次至少变化20us,也就是角度步长约1.8度,这样舵机每一次动作都在死区之外,响应更干脆。

如果项目需要精确的位置反馈,光靠PWM开环还不够。因为舵机内部电位器反馈的位置信号没有引出,你无法得知舵机是否真正抵达目标。进阶做法是在输出轴上加外部电位器或磁编码器,把角度采样值通过STM32的ADC读取,再用PID去修正脉宽。这也是很多低成本机械臂项目做闭环的标准路线。热词里提到的“stm32 lqr”、数字电源的反馈思路,本质上都是一样的:检测、计算、修正,只是被控对象不同。

最后分享一点个人体会

这个项目做下来,我最大的收获不是学会了调PWM,而是建立了一个非常实用的认知:嵌入式开发的很多所谓“难点”,剥开之后都是一层窗户纸,核心在于参数背后的物理意义。定时器的预分频为什么是71,自动重装值为什么是19999,这些数字不是随便填的,每一处都对应一个时间单位。把时间单位理解透了,换芯片、换定时器、换舵机型号,你都能从容应对。做舵机控制,先慢后快,先在本子上把计算公式写明白,再动手改代码,这个顺序能帮你避开绝大多数新手会踩的坑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 6:36:42

周三上线|OpenRouter 算了笔 GPT 成本账,海外 DeepSeek 爱好者召唤鲸鱼兄弟;Claude 开始查代码漏洞,TaoToken 统一 Key 通道怎么接

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华