单片机PWM舵机控制实战:独立按键消抖与定时器配置全解析
2026/9/8 5:56:48 网站建设 项目流程

简介:一套基于51单片机的舵机PWM控制与独立按键交互示例工程,面向单片机初学者及嵌入式开发爱好者,可帮助理解定时器PWM输出、舵机转角控制、按键扫描与人机交互设计。项目以51单片机为主控单元,利用定时器产生可变占空比PWM信号以驱动舵机,代码包含初始化、IO配置、PWM发生、按键扫描等核心模块,完整呈现从底层硬件到应用层的典型开发流程。压缩包内共14个文件,以main.c源码、STARTUP.A51启动文件、ServoMotor.uvproj工程文件、ServoMotor.hex烧录文件为主,附带obj/lst/m51等编译中间文件,整体仅24KB,目录结构清晰。目前已有2595人学习下载,适合课程设计、电子竞赛或入门动手实践。通过学习该工程,可直观掌握通过改变PWM占空比调节舵机角度、按键输入实时更新控制信号、Keil环境下编译与生成hex文件的完整流程,并可直接烧录验证,便于后续扩展LED状态指示、蜂鸣器提示等功能。

1. 项目整体设计与思路拆解

1.1 这个项目到底解决了什么问题

做单片机开发的人应该都有这种感觉:学了一堆外设,单个拿出来都能跑,但一旦想做个像样的综合项目,就不知道从哪儿下手了。“单片机+舵机+PWM+独立按键”这四个关键词组合在一起,恰好就是单片机入门到进阶之间最典型的一道坎。它把GPIO操作、定时器/PWM输出、外部中断或轮询扫描、以及最基础的人机交互全部串联起来,是一套麻雀虽小五脏俱全的嵌入式小系统。

说白了,这个项目的本质是:用独立按键作为输入,通过单片机解析按键动作,产生对应的PWM信号,最终控制舵机旋转到指定角度。听起来简单,但真正做完你会发现,光是“舵机为什么会抖”“按键为什么不灵敏”“供电为什么会把单片机拉复位”这三个问题,就够你排查一整天。

这个项目特别适合正在学51单片机或STM32的初学者作为第一个综合实训,也适合课程设计拿来当底座往上加料(比如加个OLED显示角度、加多个舵机做机械臂)。文章后面涉及的内容,我会按“硬件方案选型→核心原理分析→代码实现→排错实录”的顺序展开,尽量还原我自己当时做这个项目时踩过的坑和最终稳定运行的方案。

1.2 为什么选这四个模块组合

先说舵机。市面上最常见的SG90舵机,控制方式就是PWM,不需要额外的驱动芯片,三根线(电源、地、信号)直接接单片机就能工作。这对初学者来说友好度极高——你不需要像驱动直流电机那样去研究H桥、MOS管、续流二极管,只要PWM信号频率和脉宽正确,舵机就会老老实实转角度。

再说独立按键。键盘扫描方案里有矩阵键盘、AD按键、独立按键几种路线。矩阵键盘省IO但代码逻辑复杂,AD按键省IO但依赖ADC精度和稳定性,独立按键虽然占IO多,但胜在逻辑最直白:读电平、判断变化、消抖、执行。对于一个以“把PWM和舵机搞清楚”为核心目标的项目来说,独立按键是最不喧宾夺主的选择。

PWM则是整条链路的技术核心。你在网上一搜“PWM”能搜出一大堆东西——PWM控制电机、PWM驱动RGB灯、STM32定时器输出PWM、树莓派PWM波——但殊途同归,核心就是两件事:频率对不对,占空比准不准。舵机对PWM的要求是50Hz频率(周期20ms),占空比在2.5%~12.5%之间对应0°~180°,这个范围很窄,稍微算错一点,舵机要么转不到位,要么直接啸叫。

独立按键采集还有一个容易被忽略但必须从设计阶段就考虑的问题:消抖是软件做还是硬件做。我的结论是软件消抖为主,硬件RC滤波为辅。具体怎么做,后面实操部分详细说。

2. 核心细节解析与实操要点

2.1 舵机的PWM信号参数到底怎么算

这是全项目最关键的地方,我必须先把舵机控制原理讲透,否则后面代码你只能照抄,出了问题完全不会调。

SG90这类模拟舵机,内部结构是直流电机+减速齿轮组+电位器反馈+控制电路。控制电路接收到的PWM信号,高电平脉宽决定了舵机期望的角度。标准参数如下:

角度高电平脉宽占空比(50Hz下)
0.5ms2.5%
45°0.75ms3.75%
90°1.5ms7.5%
135°2.25ms11.25%
180°2.5ms12.5%

注意,这个对应关系是“约等于”,不同品牌、不同型号的舵机会有细微差异。比如有些国产SG90在0.5ms时并不是0°,而是稍微偏几度。所以你写代码时最好留一个微调变量,后面校准用。

在实际配置PWM时,51单片机和STM32是两条完全不同的路线。51单片机没有硬件PWM模块,只能靠定时器中断模拟:设定一个20ms的定时周期,在中断里翻转IO电平,再用一个变量控制高电平持续时间。这个方法能工作,但缺点很直观——PWM输出会占用CPU,定时器中断里的代码不能太长,否则影响其他任务。

STM32就舒服多了。以我手头的STM32F103ZET6为例,定时器TIM3挂在APB1总线上,时钟72MHz,配置PWM输出只需要三步:设置预分频PSC、设置自动重装值ARR、设置比较值CCR。具体参数计算如下:

  • 目标频率50Hz,周期20ms。
  • 预分频PSC设为71,则定时器计数频率为72MHz/(71+1)=1MHz,即计数1次是1μs。
  • 自动重装值ARR设为19999,则PWM周期为(19999+1)×1μs=20ms,频率50Hz。
  • 那么0°对应CCR=500,90°对应CCR=1500,180°对应CCR=2500。

这套参数算完后你会发现一个特别舒服的关系:CCR值直接就是微秒数。想要0.5ms脉宽就填500,想要1.5ms就填1500,不需要再做任何换算。这就是把PSC设为71而不是70或72的精妙之处。

注意:如果项目用的不是72MHz主频的芯片(比如用48MHz的STM32F103C8T6),PSC和ARR都要跟着调整。核心思路是:先让定时器计数频率变成1MHz,再让完整周期等于20000次计数。

2.2 独立按键的读取方式和消抖策略

独立按键的电路设计,我一律推荐按键一端接IO口,另一端接地,IO内部启用上拉电阻。这样平时IO读到的是高电平,按下后变成低电平,逻辑清晰而且不需要外部上拉电阻。

初次接触单片机的同学常问:为什么不能把按键另一端接VCC,按下读高电平?当然可以,但需要IO配置为下拉输入,51单片机内部没有下拉电阻,你得外接,PCB面积和BOM成本都不划算。所以“IO内上拉+按键接地”是行业默认做法。

按键抖动是机械结构决定的物理现象。按键内部的金属簧片在闭合瞬间会弹跳,时间大约是5~10ms。如果不对抖动做处理,你按一次键,程序可能检测到3~5次电平跳变,后果就是舵机角度跳变好几个档位。

软件消抖的标准做法是“延时确认法”:

  1. 检测到IO口电平变化。
  2. 延时10~20ms(避开弹跳窗口)。
  3. 再次读取电平,如果状态和之前一致,判定为有效按键事件。
  4. 如果状态恢复原样,判定为干扰或误触,丢弃。

代码实现上,我习惯用状态机而非简单的延时阻塞,因为阻塞延时在项目复杂后会卡住其他任务。核心思路是:在主循环里周期性扫描按键(比如每5ms扫描一次),记录当前电平状态,与上一次状态比较,连续两次都为低电平才判定为一次按下。延时消抖和状态机消抖的区别在于:前者是“时间换准确”,后者是“多状态判断换准确”,实时性更好,不阻塞主流程。

软件消抖之外,硬件上还可以加一个0.1μF的电容并联在按键两端,做RC低通滤波,配合10kΩ上拉电阻形成约1ms的时间常数,能把大部分高频抖动在源头滤掉。我的习惯是两者都做——硬件滤掉大部分毛刺,软件做最终确认,双保险。

2.3 舵机供电——90%新手翻车的重灾区

我认真说,这个项目里十个人做完,可能有一半人遇到单片机复位重启、舵机乱抖、舵机完全不动的问题都出在供电上。舵机是机械负载,启动瞬间电流会突然增大。SG90堵转电流可能到500~800mA,正常转动时也有100~200mA。如果直接用开发板的USB 5V供电带舵机,USB口电压瞬间被拉低到4V以下,单片机的稳压芯片直接进入欠压保护,复位重启。

正确的供电方案有两个:

一是独立电源给舵机供电。用一块18650锂电池或4节AA电池,电压5~6V,地线必须和单片机的地共地,信号线直接连单片机的PWM输出引脚。

二是在5V主供电和舵机电源引脚之间串一个大电容。具体做法是:在舵机电源引脚旁边并联一个470μF~1000μF的电解电容,再加一个0.1μF的陶瓷电容。电解电容储能,应对瞬时大电流;陶瓷电容滤高频噪声。这个方法能解决大部分轻微供电不足的问题。

如果使用STM32开发板,开发板上的AMS1117稳压芯片在输入电压低于某个阈值时输出会掉电,所以千万别把舵机直接接在3.3V引脚上——SG90正常工作电压4.8V~6V,3.3V带不动。

注意:舵机信号线是TTL电平,如果单片机是3.3V的STM32,SG90的信号线直接接PA0这类引脚是没问题的,因为舵机内部电路只读取信号高电平的脉宽,不严格要求高电平必须达到5V。实测3.3V高电平能稳定驱动SG90。

3. 实操过程与核心环节实现

3.1 硬件准备和接线方案

我用的是STM32F103ZET6最小系统板 + SG90舵机 + 两个独立按键,板子上自带LED方便调试。

接线清单:

模块引脚说明
舵机VCC外部5V电源正极不要占用开发板的5V引脚
舵机GND外部电源负极 + 单片机GND必须共地
舵机信号线PA6TIM3_CH1重映射或直接使用默认映射
按键1PB0接GND,内部上拉
按键2PB1接GND,内部上拉
调试LEDPC13按键事件触发时翻转

这里有个细节值得说明。STM32F103的TIM3默认通道1是PA6,但如果引脚被其他功能占用,可以选择重映射到PB4。用CubeMX配置时直接选默认的PA6就行,省事。不需要做引脚重映射,除非你板子上的PA6被占用了。

3.2 STM32的PWM配置实操(基于标准库)

如果你用STM32CubeMX生成初始化代码,PWM配置其实已经在图形界面里完成了,但我要强调一点:一定要在NVIC设置里使能定时器更新中断,同时在PWM配置里把初始脉冲宽度设为0或500,避免上电瞬间舵机猛转一下。

如果手写标准库代码,核心初始化函数如下:

void TIM3_PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; // 1. 开启时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 2. 配置PA6为复用推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 3. 定时器时基配置:PSC=71,ARR=19999 TIM_TimeBaseStructure.TIM_Period = 19999; TIM_TimeBaseStructure.TIM_Prescaler = 71; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); // 4. PWM模式配置 TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 1500; // 初始90度 TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM3, &TIM_OCInitStructure); // 5. 使能TIM3 TIM_Cmd(TIM3, ENABLE); }

代码里有几个点值得解释。首先是为什么PWM模式选PWM1而不是PWM2。PWM1模式下,计数值小于CCR时输出高电平,大于CCR时输出低电平。对于舵机控制,我们需要的是“高电平脉宽=CCR对应的微秒数”,PWM1正好满足。如果选PWM2,高低电平反了,舵机大概率直接反向满偏,很多人调半天调不出来的问题往往就出在这里。

其次是TIM_Period填19999而不是20000。因为定时器是从0开始计数到ARR的,一共计数ARR+1次,也就是20000次,正好20ms。这个“加1”的细节困扰过很多初学者,一定要注意。

设置舵机角度时,直接改CCR的值就行:

void Servo_SetAngle(uint16_t angle_us) { TIM_SetCompare1(TIM3, angle_us); }

比如转到90°就调用Servo_SetAngle(1500),转到0°就调用Servo_SetAngle(500)。如果发现舵机角度有偏差,就在这些值上加个偏移量,比如Servo_SetAngle(1500 + 30),实际校准到机械90°为准。

3.3 按键扫描逻辑与舵机控制代码

按键扫描我用状态机方式,主循环每10ms调用一次扫描函数。核心思路:记录按键当前电平,和上一次电平比较。如果这次是低电平、上次是高电平,说明刚按下,进入消抖确认状态;连续扫描两次都确认是低电平,才触发事件。

#define KEY1_PIN GPIO_Pin_0 #define KEY2_PIN GPIO_Pin_1 uint8_t key1_last_state = 1; uint8_t key1_confirm_state = 1; uint8_t key2_last_state = 1; uint8_t key2_confirm_state = 1; void Key_Scan(void) { uint8_t key1_now = GPIO_ReadInputDataBit(GPIOB, KEY1_PIN); uint8_t key2_now = GPIO_ReadInputDataBit(GPIOB, KEY2_PIN); // 按键1消抖:连续两次读到低电平才认为是按下 if (key1_now == 0 && key1_last_state == 0) { if (key1_confirm_state == 0) { Servo_SetAngle(500); // 0度 key1_confirm_state = 1; } } else if (key1_now == 1) { key1_confirm_state = 0; } // 按键2消抖 if (key2_now == 0 && key2_last_state == 0) { if (key2_confirm_state == 0) { Servo_SetAngle(2500); // 180度 key2_confirm_state = 1; } } else if (key2_now == 1) { key2_confirm_state = 0; } key1_last_state = key1_now; key2_last_state = key2_now; }

这段代码的精髓是把“消抖确认”和“事件触发”分离。key1_confirm_state是一个标志位,只有连续两次读到低电平后才从0变1,同时触发角度切换。如果没有这个标志位,你按一次键,主循环可能执行了好几次按键扫描,舵机会被重复设置同一个角度。虽然设置同一个角度看起来没影响,但如果后续改成角度累加或模式切换,重复触发就是灾难。

你也可以在这个基础上扩展更多功能。比如:按键1短按+1°,按键1长按连续转动,按键2切换正反转,或者加一个“回到90°中位”的功能。自由度很高,看你想把项目做成什么深度。

3.4 51单片机版本的实现差异

如果用的是51单片机(STC89C52之类),没有硬件PWM,需要用定时器中断模拟。这里给一套我调通的参数:

  • 定时器0工作在方式1(16位定时器),设置初值使中断周期为0.1ms。
  • 中断计数器cnt,每10次中断(1ms)更新一次PWM周期状态。
  • pwm_high_cnt变量记录当前高电平需要持续的0.1ms个数。

核心代码如下:

unsigned int cnt = 0; unsigned int pwm_period = 200; // 20ms / 0.1ms = 200 unsigned int pwm_high_cnt = 15; // 默认1.5ms,即90度 void Timer0_ISR() interrupt 1 { TH0 = 0xFF; // 0.1ms中断重装初值 TL0 = 0x9C; cnt++; if (cnt <= pwm_high_cnt) { P1_0 = 1; // 舵机信号线接P1.0 } else { P1_0 = 0; } if (cnt >= pwm_period) { cnt = 0; } }

0°对应pwm_high_cnt=5,90°对应15,180°对应25。

这里有个需要注意的地方:51单片机定时器中断里执行代码需要时间,重装初值后实际中断间隔会比0.1ms略长。实测下来会有微小的频率偏移,但对舵机控制来说完全在可接受范围内,因为舵机内部对频率不敏感,它只看高电平脉宽。真正不能错的是高电平持续时间和整个周期的比例,只要比例正确,舵机角度就是准的。

4. 常见问题与排查技巧实录

4.1 舵机抖动、啸叫、不动作的排查

问题一:舵机一直啸叫,嗡嗡响。这是占空比脉冲频率不对或者信号不稳定导致的。最常见的原因是PWM频率设错了,比如刷成200Hz或500Hz。舵机内部电路对50Hz是最友好的,高频信号会导致电机不断来回微调,产生啸叫。排查方法:用示波器看PWM波形,数一下周期是不是20ms。

问题二:舵机到指定角度后依然发出轻微抖动。这种情况通常是电源波纹太大或者舵机扭矩不足。SG90的扭矩是1.8kg/cm,如果带动的机械结构太重,舵机在目标角度附近会不停修正,表现就是抖。解决思路是减轻负载,或者换MG995这类大扭矩舵机,注意MG995工作电流更大,必须独立供电。

问题三:舵机完全不动,信号线也接对了。先用万用表量信号引脚的电压,如果一直是0V或3.3V(无PWM波形),说明单片机没输出PWM。这时检查定时器有没有使能,GPIO是不是配置成了复用推挽。如果是51单片机,检查中断有没有开总开关EA和定时器中断ET0。

问题四:上电瞬间舵机会猛地转到一边。这是因为定时器刚初始化时CCR值为0,PWM输出低电平,但舵机内部检测到脉宽异常会进入一种不确定状态。解决方法是先设置CCR为安全角度(比如1500,对应90°),再使能PWM输出。

4.2 按键不灵敏和误触发的处理

按键按下没反应,最常见的原因是扫描间隔太长。如果你主循环里有很多延时函数(比如LED闪烁用了delay(500)),按键扫描函数的调用频率会很低,刚好错过按键按下窗口。解决办法是把按键扫描放在定时器中断里做,或者把延时全部改成非阻塞式(用系统tick计数替代)。

按键被误触发,大多数是供电不稳导致IO电平跳变。舵机转动时电流变化会在地线上产生噪声,如果按键的地线走线过长且没有滤波电容,噪声可能耦合到按键IO口。处理办法:按键IO口加一个10nF~100nF的电容到地,在按键两端并联电容也有帮助。

还有一个小技巧:按键事件触发后加一个“释放检测”条件——只有按键从按下状态恢复到释放状态后,才允许下一次按键触发。这样即使按键按住不放,也只触发一次。上面代码里的key1_confirm_state就兼任了这个功能。

4.3 实际调试中必须养成的习惯

调试PWM这类时序相关的项目,示波器是刚需。如果手头没有示波器,逻辑分析仪也可以,淘宝几十块钱的24MHz逻辑分析仪配合PulseView软件,足够数PWM波形的高电平脉宽了。

调试步骤建议这样:先把舵机作为唯一外设,单独固定一个角度(比如90°),看舵机能不能稳定保持。再测试其他角度,通过设不同CCR值验证角度是否正确。确认PWM链路通了之后,再加按键控制逻辑。最后加供电相关的电路优化。每一步验证通过后再进行下一步,能极大减少排查问题的范围。

另一个经验是,建议你把关键变量的值通过串口打印出来。比如按键扫描触发了多少次、当前CCR值是多少、PWM周期是高电平多少微秒。串口打印虽然会消耗一些系统资源,但对调试点位非常有帮助。

4.4 扩展思路:如何把这个项目玩出花

基础版做完之后,我建议尝试这几个方向:

一是加LCD或OLED显示当前角度。把舵机角度实时显示在屏幕上,人机交互会很直观,代码上只需要把当前CCR值换算成角度值,再用字符串格式化输出。

二是控制多个舵机,做一个简化的机械臂。两个舵机分别控制左右和上下方向,用矩阵键盘或独立按键切换当前控制的舵机。这就在原项目基础上增加了通道切换的逻辑,技术难度提升不大,但项目档次高很多。

三是加入PWM闭环反馈的思路。在舵机臂末端加电位器或陀螺仪,读取实际位置,和期望位置做PID闭环控制。这个方向就比较深了,但做出来之后你对控制系统的理解会上一个台阶。

四是把PWM控制的对象从舵机换成LED灯的RGB调光、蜂鸣器音量调节、甚至直流电机的调速。原理完全一样,改一下频率和占空比范围就能适配。

分享一个我自己的感受:做这个项目时最不值钱的环节其实是焊接和连线,最值钱的环节是理解“为什么PWM的周期必须是20ms、为什么占空比范围只有2.5%~12.5%、为什么按键必须要消抖”。这些原理性的东西搞明白了,以后你做的任何嵌入式项目都会非常顺手。纸上得来终觉浅,建议你拿块开发板,按文章里的参数亲手调一遍——如果一把就调通了,说明你对PWM的掌控已经基本合格;如果调不通,恭喜你,踩坑的过程才是最值钱的学习。最后再分享一个小技巧:调完一个舵机后,记得把CCR的校准偏移量写成一个宏定义或者EEPROM存储量,这样换舵机或者换批次时不用改代码逻辑,直接改一个数就行。

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

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

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

立即咨询