☰
单片机舵机PWM控制:从入门到避坑的完整指南
2026/10/15 16:57:08 网站建设 项目流程

简介:这份资源面向嵌入式入门与进阶学习者,围绕51单片机、舵机、PWM与独立按键展开,帮助读者理解如何用定时器产生可变占空比信号,并通过按键实时调整舵机转角,适合课程设计、机器人模型制作及单片机实验场景。压缩包共14个文件,约24KB,包含C源码、A51启动代码、hex烧录文件、uvproj与uvopt工程配置、lst与m51编译列表、obj目标文件及htm构建日志等,覆盖从源码到可烧录固件的完整工程结构。目前已有2597人学习下载。读者可从中获得PWM占空比与舵机角度对应关系的实现思路、按键扫描与占空比更新的联动逻辑,以及定时器配置、IO口输入输出分配和编译排错参考,便于快速复现并在此基础上扩展LED指示或蜂鸣器提示等交互功能。

1. 单片机+舵机+PWM+独立按键:为什么你的舵机一上电就抖成筛子

很多人第一次把舵机接到单片机上,代码烧进去,舵机不是纹丝不动,就是像抽风一样来回抖,甚至发出「滋滋」的堵转声。这个组合——单片机、舵机、PWM、独立按键——看起来是嵌入式入门里最经典的四个词,但它恰好把三个容易翻车的点叠在了一起:PWM 频率和占空比的换算、舵机供电与信号共地、按键抖动与实时响应的冲突。你如果只是想让舵机转到指定角度,或者用两个按键控制它左右扫动,那这套方案完全够用,成本也就十几块钱。但它不适合直接驱动大扭矩舵机做机械臂负载,也不适合要求毫秒级响应的闭环场景。下面我按「先跑通最小系统,再调参数,最后排坑」的顺序,把这块讲透。

2. 先搞懂舵机要的 PWM 到底长什么样:周期 20ms 不是随便写的

2.1 舵机控制信号的本质:不是调频率,是调高电平时间

舵机内部有一个比较器,它不关心你给的 PWM 频率是 50Hz 还是 300Hz,它只关心每 20ms 左右出现一次的高电平脉宽。标准模拟舵机的要求是:周期 20ms(对应 50Hz),高电平时间 0.5ms 对应 0 度,1.5ms 对应 90 度(中位),2.5ms 对应 180 度。有些数字舵机可以接受更宽的脉宽范围,比如 0.5ms 到 2.5ms 对应 0 到 270 度,但周期仍然建议保持在 20ms 附近。

这里有一个新手最容易踩的坑:把占空比当成角度。比如 50Hz 下,1.5ms 高电平对应的占空比是 1.5/20 = 7.5%,而不是 50%。如果你直接写PWM_SetDuty(50)指望它转到中位,舵机要么不动,要么直接冲到极限位置堵转。所以第一步不是写代码,而是先算清楚你的单片机定时器怎么产生这个脉宽。

2.2 用定时器算 0.5ms~2.5ms:以 1MHz 计数频率为例

假设你的单片机定时器计数频率是 1MHz,也就是每 1us 计数一次。那么 0.5ms 对应 500 个计数,1.5ms 对应 1500 个计数,2.5ms 对应 2500 个计数。周期 20ms 对应 20000 个计数。如果你用 16 位定时器,最大计数值 65535,完全够用。

常见做法是:定时器配置为 1us 分辨率,自动重装载值设为 20000-1,然后在 PWM 输出比较寄存器里写 500 到 2500 之间的值。下面是一段伪代码形式的初始化逻辑,具体寄存器名根据你用的单片机型号替换:

// 假设使用某款 8 位单片机,定时器1 产生 PWM // 系统时钟 8MHz,预分频 8,计数频率 1MHz void Servo_PWM_Init(void) { // 1. 设置定时器1为 PWM 模式,周期 20ms T1_PERIOD = 20000 - 1; // 20000 个计数 = 20ms // 2. 初始脉宽 1.5ms,对应中位 90 度 T1_COMPARE = 1500; // 1500 个计数 = 1.5ms // 3. 使能 PWM 输出引脚,具体引脚映射查数据手册 PWM_PIN_ENABLE(); // 4. 启动定时器 T1_START(); } // 角度转脉宽:0度->500,180度->2500 void Servo_SetAngle(uint8_t angle) { uint16_t pulse; if (angle > 180) angle = 180; // 线性映射:pulse = 500 + angle * (2000 / 180) pulse = 500 + (uint16_t)((uint32_t)angle * 2000 / 180); T1_COMPARE = pulse; }

这段代码里有两个关键点:第一,T1_PERIOD决定了周期是不是 20ms,写错这个值舵机会抖或者不动;第二,Servo_SetAngle里的映射公式用了uint32_t强制转换,防止 8 位单片机在angle * 2000时溢出。很多人在 51 单片机上直接写angle * 2000 / 180,结果角度超过 32 度就乱转,就是溢出导致的。

2.3 不同单片机的 PWM 外设差异:别硬套寄存器

如果你用的是 STM32 这类带高级定时器的芯片,可以直接用库函数配置:

// STM32 HAL 库示例:TIM2 通道1 输出 50Hz PWM TIM_HandleTypeDef htim2; TIM_OC_InitTypeDef sConfigOC = {0}; htim2.Instance = TIM2; htim2.Init.Prescaler = 72 - 1; // 72MHz / 72 = 1MHz htim2.Init.Period = 20000 - 1; // 20ms 周期 htim2.Init.CounterMode = TIM_COUNTERMODE_UP; HAL_TIM_PWM_Init(&htim2); sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 1500; // 初始 1.5ms sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);

这里Prescaler和Period的计算逻辑和上面 8 位单片机是一样的,只是寄存器换成了结构体。注意 STM32 的Pulse值直接写 1500 就对应 1.5ms,因为计数频率是 1MHz。如果你把预分频改成 72-1 以外的值,比如 720-1,那计数频率变成 100kHz,Pulse就要写 150 才是 1.5ms。这个换算关系搞错,舵机行为会完全不对。

3. 独立按键怎么接才不干扰舵机:上拉、消抖、状态机三件事

3.1 硬件接法:按键一端接地,另一端接引脚并上拉

独立按键最常见的接法是:按键一端接单片机引脚,另一端接 GND,引脚内部或外部上拉电阻拉到 VCC。这样按键不按下时引脚读到高电平,按下读到低电平。如果你用的是 51 单片机,P1、P2、P3 口内部有弱上拉,可以直接接按键到地;如果是 STM32,需要配置内部上拉或者外接 10k 电阻。

这里有一个血泪经验:不要把按键接在舵机信号线同一个端口上,更不要共用电源。舵机启动瞬间电流可能达到 1A 以上,如果按键上拉电阻和舵机共用一条 5V 走线,按键读取会随机跳变。我一般会把舵机电源单独走一路,至少并联一个 470uF 电解电容在舵机电源附近。

3.2 软件消抖:20ms 定时器轮询比 delay 靠谱

按键抖动时间一般在 5ms 到 10ms 之间。新手常用delay_ms(20)然后再读一次,但这样会阻塞主循环,导致舵机 PWM 更新不及时。更稳的做法是用一个 2ms 或 5ms 的定时器中断,在中断里做按键扫描和状态机:

// 5ms 定时器中断里调用 typedef enum { KEY_IDLE, KEY_DEBOUNCE, KEY_PRESSED, KEY_RELEASE } KeyState; KeyState key_state = KEY_IDLE; uint8_t key_press_cnt = 0; void Key_Scan(void) { uint8_t pin = KEY_PIN_READ(); // 读按键引脚,按下为 0 switch (key_state) { case KEY_IDLE: if (pin == 0) { key_state = KEY_DEBOUNCE; key_press_cnt = 0; } break; case KEY_DEBOUNCE: if (pin == 0) { key_press_cnt++; if (key_press_cnt >= 4) { // 4 * 5ms = 20ms 消抖 key_state = KEY_PRESSED; Key_Event(); // 触发按键动作 } } else { key_state = KEY_IDLE; } break; case KEY_PRESSED: if (pin != 0) { key_state = KEY_RELEASE; } break; case KEY_RELEASE: if (pin != 0) { key_state = KEY_IDLE; } break; } }

这段状态机的核心是:只有连续 4 次(20ms)读到低电平才确认按下,松手也要确认一次。这样既不会误触发,也不会因为主循环里其他任务卡住而丢按键。注意Key_Event()里不要放耗时操作,只置一个标志位,让主循环去处理角度变化。

3.3 按键控制舵机的逻辑:步进还是直接跳转

用两个按键控制舵机,常见有两种逻辑:一种是按一下加 10 度,另一种是按住连续转动。前者实现简单,后者需要检测长按。我一般会做成「短按步进 10 度,长按 500ms 后连续步进」:

// 主循环里处理按键事件 int16_t target_angle = 90; // 当前目标角度 void Key_Event(void) { if (KEY_LEFT_PRESSED()) { target_angle -= 10; if (target_angle < 0) target_angle = 0; } else if (KEY_RIGHT_PRESSED()) { target_angle += 10; if (target_angle > 180) target_angle = 180; } Servo_SetAngle(target_angle); }

这里target_angle用int16_t而不是uint8_t,是因为减到 0 以下时如果无符号会回绕到 255,舵机会直接冲到极限。这个坑我在早期项目里踩过,舵机「啪」一声打到头,齿轮都扫了。

4. 避坑与排查:舵机抖动、按键失灵、角度不准的 5 个真实原因

4.1 舵机一上电就抖,或者发出「滋滋」声

现象:给单片机通电,舵机没有收到任何角度指令,但自己来回抖动,或者停在某个位置发出持续噪音。

原因:最常见的是 PWM 周期不对。如果你把周期设成了 10ms 或者 5ms,舵机内部比较器会误判脉宽,导致它不断修正位置。另一个原因是电源功率不够,舵机启动瞬间把 5V 拉低到 3V 以下,单片机复位或者 PWM 输出异常。

解决:先用示波器或者逻辑分析仪看 PWM 波形,确认周期是 20ms 左右,高电平在 0.5ms 到 2.5ms 之间。如果没有仪器,用单片机另一个定时器做一个 1ms 中断,在中断里翻转一个 IO 口,用万用表测电压,1.5ms 高电平对应 7.5% 占空比,万用表应该读到 0.375V 左右(5V 系统)。电源方面,给舵机单独供 5V,至少 2A 能力,和单片机共地。

4.2 按键按下去没反应,或者一次按下触发多次

现象:按一下按键,舵机转了 30 度而不是 10 度;或者按住不放,角度乱跳。

原因:消抖没做,或者消抖时间太短。机械按键在按下和松开瞬间会产生 5ms 到 10ms 的抖动,如果主循环跑得很快,一次按下会被读到多次。

解决:用上面 3.2 的状态机,消抖计数至少 4 次(5ms 定时器下就是 20ms)。另外检查按键引脚是否配置了上拉,浮空输入会随机读到噪声。

4.3 舵机角度和预期差很多,比如写 90 度却转到 120 度

现象:代码里写Servo_SetAngle(90),但舵机实际停在 120 度位置。

原因:脉宽映射公式错了,或者舵机本身的中位不是 1.5ms。有些舵机(尤其是大扭矩金属齿舵机)的中位可能在 1.4ms 或 1.6ms,行程也不是标准的 0.5ms 到 2.5ms。

解决:先写一个校准程序,让舵机在 500、1000、1500、2000、2500 这几个脉宽值上各停 2 秒,记录实际角度。然后根据实测结果调整映射公式的斜率和截距。如果舵机中位偏了,可以在Servo_SetAngle里加一个偏移量:

#define SERVO_OFFSET 50 // 根据实测调整,单位 us pulse = 500 + (uint16_t)((uint32_t)angle * 2000 / 180) + SERVO_OFFSET;

4.4 按键和舵机同时工作时,单片机复位或者程序跑飞

现象:单独测试按键正常,单独测试舵机也正常,但两者一起跑,单片机偶尔复位。

原因:舵机电源和单片机电源共用一条线,舵机启动瞬间电流冲击导致单片机供电跌落。或者舵机信号线和按键线捆在一起,PWM 信号串扰到按键引脚。

解决:舵机电源和单片机电源分开走线,只在电源入口处共地。舵机电源附近并联 470uF 电解电容和 0.1uF 陶瓷电容。按键线尽量远离舵机信号线,如果必须并行,中间加地线隔离。

4.5 用 STM32 时 PWM 没输出,或者输出频率不对

现象:代码编译下载都正常,但舵机不动,用示波器看引脚没有波形。

原因:STM32 的 PWM 输出需要配置 GPIO 复用功能,很多人只初始化了定时器,忘了把引脚设为AF_PP模式。另外,如果用的是高级定时器(TIM1、TIM8),还需要调用HAL_TIM_PWM_Start之前使能主输出__HAL_TIM_MOE_ENABLE。

解决:检查 GPIO 初始化代码里有没有GPIO_InitStruct.Mode = GPIO_MODE_AF_PP,以及对应的GPIO_PinAFConfig有没有把引脚映射到正确的定时器通道。高级定时器加上__HAL_TIM_MOE_ENABLE(&htim1)。

5. 进阶技巧:用软件 PWM 驱动多路舵机,以及一个验证角度的小工具

如果你需要控制 4 路以上的舵机,而单片机硬件 PWM 通道不够,可以用软件 PWM。思路是用一个 20ms 的定时器,在中断里维护多个计数变量,每个变量对应一路舵机的高电平时间。下面是一个 8 路软件 PWM 的简化实现:

// 假设 1us 定时器中断,20ms 周期 = 20000 次中断 #define SERVO_NUM 8 uint16_t servo_pulse[SERVO_NUM] = {1500, 1500, 1500, 1500, 1500, 1500, 1500, 1500}; uint16_t tick = 0; void Timer_1us_ISR(void) { tick++; if (tick >= 20000) tick = 0; // 20ms 周期 for (uint8_t i = 0; i < SERVO_NUM; i++) { if (tick < servo_pulse[i]) { SERVO_PIN_SET(i); // 高电平 } else { SERVO_PIN_CLR(i); // 低电平 } } }

这段代码在 1us 中断里执行 8 次 IO 操作,对 8 位单片机来说负担不轻,但跑 4 路以内没问题。注意servo_pulse数组的值要在 500 到 2500 之间,并且每次修改时最好关中断,防止更新到一半被中断打断导致某一路输出异常脉宽。

验证角度是否准确,除了用眼睛看,还可以做一个简单的反馈装置:在舵机臂上粘一个小电位器,用单片机 ADC 读电位器电压,反推实际角度。虽然精度不高,但用来验证脉宽映射是否线性足够了。我一般会在调试阶段用这个方法,确认 0 度、90 度、180 度三个点的误差在 3 度以内,再拆掉电位器。

最后说一个我自己的习惯:每次调舵机之前,先把Servo_SetAngle里的映射公式单独拿出来,用 Excel 或者计算器算一遍 0、45、90、135、180 对应的脉宽值,确认没有溢出和截断。这个动作花不了两分钟,但能省掉很多「舵机为什么乱转」的后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询