简介这套基于51单片机的工程以PWM控制舵机转角为核心配合独立按键实现角度调节面向单片机初学者可从中理解定时器生成PWM、占空比与转角的关系以及独立按键的人机交互流程。压缩包共14个文件、约24KB包含Keil工程文件uvproj/uvopt、C源代码main.c、可直接烧录的HEX文件以及编译过程产生的obj、lst、m51等中间文件打开工程即可对照学习。项目主体由主控端完成PWM信号生成按键输入映射到不同占空比从而驱动舵机转到指定角度源码涵盖初始化、按键扫描、中断/轮询更新等基础模块有助于梳理舵机控制链路和PWM调节方法。已有2595人学习浏览适合希望快速上手PWM与舵机控制实践的读者参考。 玩单片机的人迟早会碰到舵机而玩舵机的人十有八九第一课就是PWM。我记得自己第一次拿到SG90时以为只要给它通上电、给个高电平就能转结果舵机纹丝不动研究了半天才发现问题出在脉宽上。后来用独立按键控制舵机角度又踩了按键抖动、供电不足、定时器分配等一系列坑。这篇博文就把单片机舵机PWM独立按键这套组合从原理到代码完整梳理一遍给你一套能直接抄作业的方案也把那些文档里不会写的坑一次说完。这套组合算是单片机入门到进阶之间比较典型的一个项目它用到了GPIO输入、GPIO输出、定时器、中断、PWM这几大核心外设同时涉及信号时序、机械执行、人机交互三个层面。无论你用的是51、STM32还是Arduino思路都是通用的差别只在底层寄存器操作方式不同。1. 舵机不是靠占空比转的它只听脉宽的话先把概念掰扯清楚。很多教程上来就说舵机靠PWM控制这句话没错但容易让人误解成舵机转动的角度由占空比决定。实际上舵机响应的是PWM波形里的高电平持续时间脉宽不是高电平占整个周期的比例。这一点在PWM周期变化时体现得特别明显后面我会细说。标准舵机比如SG90、MG996R的控制协议大致是这样PWM周期20ms也就是频率50Hz脉宽范围0.5ms ~ 2.5ms对应舵机转轴从0°到180°中间值1.5ms脉宽对应90°中立位置也就是说0.5ms脉宽舵机转到最左端1.5ms转中间2.5ms转最右端。脉宽每变化约11.1微秒舵机转动约1度。这个换算关系在做精准角度控制时很有用。我见过有人把脉宽范围写成0.5ms到2.4ms也有写1ms到2ms的这主要是不同品牌舵机的规格差异。SG90的官方手册写的是0.5ms到2.5ms对应0°到180°但实测下来SG90在0.5ms和2.5ms两端会有轻微异响因为舵机内部的电位器反馈到了极限位置。所以实际项目里我一般把脉宽限在0.6ms到2.4ms之间留出一点余量机械结构更安全。这里要重点说一个新手最容易掉的坑PWM周期到底能不能变网上有人说周期必须是20ms有人说只要是50Hz就行还有人说不超过25ms都可以。真实情况是传统模拟舵机对周期有个容忍范围大概在10ms到25ms之间都能工作因为它内部只检测高电平的脉宽并不严格校验周期。但周期一旦太短或太长出问题的方式不一样——周期太短舵机控制信号刷新太快舵机内部电路来不及响应会出现抖动周期太长超过30ms舵机刷新率太低转动力度和保持力矩都会变弱。我实测过用20ms和10ms周期控制同一个SG90脉宽都是1.5ms舵机都能稳定停在90°。但换到用周期2ms的信号去控制时舵机就开始嗡嗡叫并且发热严重。所以能用20ms就老老实实用20ms你的定时器重载值计算也简单。另外说一个新手常做的反向操作有人拿到代码后改占空比比如占空比从5%改到10%发现舵机角度变了就以为占空比控制角度是对的。这是因为固定周期20ms时占空比和脉宽是一一对应的1ms脉宽就是5%占空比1.5ms就是7.5%。可一旦你改了PWM频率占空比对应的角度就全乱了。比如用周期10ms、占空比15%脉宽1.5ms舵机还是90°但如果你按占空比理解15%和之前7.5%差了一倍很容易产生困惑。所以后面写代码时我建议你在程序里直接算脉宽不要用占空比去描述目标角度代码可读性和可维护性都会好很多。2. 让舵机动起来之前先把硬件供电和信号电平的事说清楚舵机项目的硬件连接不难但细节多而且每一细节都能让舵机不听话。2.1 电源才是最关键的外设很多新手直接把舵机的VCC接在单片机开发板的3.3V或者5V引脚上结果舵机一转单片机就重启、乱码、LED闪一脸懵。这里要说清楚一个概念舵机堵转电流。舵机在正常转动时电流在100mA到300mA之间但如果你用手捏住舵机输出轴不让它转堵转电流会瞬间飙到700mA以上SG90的堵转电流能达到1A左右MG996R这种大扭矩舵机堵转电流能到2.5A。而单片机开发板上的稳压芯片比如AMS1117最大输出电流通常只有1A而且还要给主控芯片和外设供电。你把舵机接上去等于让一个只能在0.5A左右稳定输出的电源去带一个峰值1A的负载电压必然被拉垮。正确的供电方式有三种按推荐程度排序独立舵机电源用一块18650锂电池、两节干电池或者单独的5V/2A电源适配器给舵机供电单片机只输出PWM信号线共地连接独立电源和单片机系统必须共地否则PWM信号的电平参考点不一致舵机无法正确识别高电平如果非要共用一块电源至少选输出电流3A以上的DC-DC降压模块并且用一个大容量的电解电容470uF以上并联在舵机电源引脚上做瞬态缓冲共地这件事再说细一点单片机输出的3.3V/5V高电平是相对于自己GND的。如果舵机电源的地和单片机的地不连在一起舵机收到的PWM信号的参考地就飘了可能把3.3V的高电平误判成低电平导致舵机完全不动。所以无论你怎么接GND必须连GND这条线不能省。2.2 信号电平3.3V单片机能不能驱动5V舵机舵机的控制信号线接在单片机GPIO上。大多数舵机内部电路是兼容TTL电平的逻辑高电平阈值为2.0V左右所以3.3V的GPIO输出能正常驱动5V供电的舵机。我实测过用STM32的3.3V引脚直接驱动SG90稳定工作没有问题。但有一个特殊情况要注意如果你的单片机引脚是开漏输出模式并且没有外接上拉电阻那么输出的高电平可能不够舵机会出现抽风式抖动。使用开漏模式时记得在信号线上加一个4.7kΩ到10kΩ的上拉电阻接到单片机供电电压上。2.3 独立按键的硬件接法独立按键这边相对简单但也有人接错。标准接法有两种按键一端接GND另一端接GPIOGPIO内部上拉开启。按键按下时GPIO读到低电平按键一端接VCC另一端接GPIOGPIO内部下拉开启。按键按下时GPIO读到高电平我推荐第一种因为大多数单片机STC的51系列、STM32、GD32等的GPIO上拉能力比下拉强而且外部走线习惯上也是按键接GND居多。如果你用的是内部上拉能力弱的单片机可以在GPIO到VCC之间外接10kΩ上拉电阻效果等同。这里还有一个细节不是所有单片机的GPIO都自带可配置的内部上下拉。比如51单片机的P0口是开漏结构内部没有上拉电阻做按键输入时必须外接上拉电阻。而P1、P2、P3口内部有上拉接了GND就能用。跨平台移植代码时这个差异经常让人踩坑。3. 两种产生PWM的方式我用代码带你走一遍产生PWM信号从实现层面分两派一派是单片机硬件定时器自带的PWM输出功能比如STM32的定时器PWM模式另一派是用软件在GPIO上模拟PWM波形在51单片机上很常见51除了STC8系列以外多数没有硬件PWM。两种方法各有适用场景我分别讲清楚。3.1 软件模拟PWM51单片机的经典做法51单片机上没有硬件PWM外设STC12/STC15/STC8部分型号有但很多人用的STC89C52确实没有所以普遍写法是用定时器中断产生一个基准时间片然后在这个基准上翻转GPIO输出。核心思路是把20ms周期切成若干个时间单位。比如定时器每0.1ms触发一次中断一个PWM周期就需要200次中断。代码里维护一个计数器和一个高电平时间变量计数器小于高电平时输出高大于则输出低。简化后的代码结构如下// 假设使用定时器T012MHz晶振定时0.1ms // 定时器初值计算0.1ms / (12/12MHz) 100个机器周期初值 65536 - 100 65436 #define PWM_PERIOD 200 // 20ms / 0.1ms 200个时间片 #define HIGH_TIME \n // 这个值在运行中由按键设置 unsigned char pwm_cnt 0; unsigned char pwm_high 15; // 1.5ms脉宽对应90度 void timer0_isr() interrupt 1 { TH0 0xFF; TL0 0x9C; // 重载定时初值 if (pwm_cnt pwm_high) { PWM_PIN 1; } else { PWM_PIN 0; } pwm_cnt; if (pwm_cnt PWM_PERIOD) { pwm_cnt 0; } }这个方案的优点是逻辑简单、容易理解移植性好缺点是CPU开销大中断频率高如果主循环里还有别的复杂任务容易互相干扰。在我实际项目中这个方案用于纯教学演示是没问题的但如果要做多路舵机控制建议大家换有硬件PWM的单片机。3.2 硬件PWMSTM32定时器的正统玩法STM32等主流单片机都带硬件PWM输出功能核心原理是定时器的计数器在0到ARR自动重载值之间循环计数然后CNT和CCR比较寄存器进行比较输出高或低电平。硬件电路自动完成比较和翻转不占用CPU中断。以STM32F103的TIM3输出通道CH1PA6为例生成50Hz PWM的配置计算// TIM3挂载在APB1上时钟为72MHz默认复位后 // 目标频率50Hz周期20ms // 分频系数PSC 71计数器时钟 72MHz / (711) 1MHz即每1us计数一次 // 自动重载值ARR 19999则PWM周期 (199991) * 1us 20000us 20ms TIM_TimeBaseStructure.TIM_Prescaler 71; TIM_TimeBaseStructure.TIM_Period 19999; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; // 配置PWM模式输出极性为高 TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse 1500; // 1.5ms脉宽即90度 TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM3, TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM3, TIM_OC1Preload_Enable); TIM_ARRPreloadConfig(TIM3, ENABLE); TIM_Cmd(TIM3, ENABLE);用HAL库的话代码更简洁一些TIM_OC_InitTypeDef sConfigOC {0}; sConfigOC.OCMode TIM_OCMODE_PWM1; sConfigOC.Pulse 1500; sConfigOC.OCPolarity TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(htim3, sConfigOC, TIM_CHANNEL_1); HAL_TIM_PWM_Start(htim3, TIM_CHANNEL_1); // 动态改角度只需修改CCR单位为us __HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, 1500); // 90度有人用STM32的时候会问为什么我改ARR之后PWM频率变了舵机角度也变了这和前面说过的舵机只听脉宽呼应上了——ARR改了之后同样的CCR值对应的脉宽其实变了因为计数器时钟频率没变但重载周期变了角度自然就不一样了。所以在设计时PWM频率确定后就不要动只通过改CCR来调整角度。3.3 定时器参数计算的一个通用方法不管什么单片机产生PWM的定时器参数都逃不开这个公式定时器计数频率 时钟源频率 / (预分频PSC 1) PWM周期 (自动重载ARR 1) / 定时器计数频率 脉宽时间 比较值CCR / 定时器计数频率比如你要产生20ms周期、1.5ms脉宽只需要三个值时钟源频率、PSC、ARR。通常做法是先选一个能让1个计数单位1us的PSC再把ARR设为19999CCR设为1500。这样后续所有时间都以微秒为单位计算思维方式最简单不容易出错。4. 按下按键的那一瞬间硬件抖动和软件消抖的攻防独立按键接好了PWM也能输出了接下来就是把两者联动起来。这个环节最容易翻车的就是按键抖动。按键按下和释放的瞬间由于机械触点的弹跳GPIO电平会在几十微秒到十几毫秒内快速跳变。这个抖动波形不是按下就稳定而是按下-弹起-按下-弹起的衰减振荡。如果不做处理程序可能把一次按下识别成多次操作舵机就会像个喝多了的人一样乱点头。消抖方案从简单到复杂有三档第一档延时消抖。检测到按键电平变化后延时20ms再读一次如果电平仍为按下状态则判定为有效按下。这个方案浪费CPU而且延时期间无法响应其他事件但代码最简单适合学习阶段。第二档定时扫描消抖。用定时器每5ms到10ms进一次中断中断里读按键状态连续读到两次相同状态才认为状态稳定。这个方案不阻塞主循环是我推荐的做法。第三档外部RC硬件消抖。在按键两端并联一个0.1uF的电容和电阻组成RC低通滤波器从硬件层面把毛刺过滤掉。RC时间常数大约1ms到10ms效果不错但会稍微增加信号边沿的延迟对普通按键来说没有影响。我自己的项目里通常采取硬件上电阻下拉 软件定时扫描的组合按键一端接GND一端接GPIOGPIO内部上拉然后在一个10ms定时器的中断里做消抖判断。消抖的核心代码如下// 每10ms被定时器中断调用一次 void Key_Scan(void) { static unsigned char key_last KEY_RELEASE; static unsigned char key_stable KEY_RELEASE; unsigned char key_current; key_current KEY_READ(); // 读取当前GPIO电平 if (key_current key_last) { key_stable key_current; // 连续多次采样结果一致判为稳定 } key_last key_current; // 检测边沿从释放变为按下 if (key_stable KEY_PRESSED key_last_action KEY_RELEASE) { key_last_action KEY_PRESSED; action_counter; } else if (key_stable KEY_RELEASE) { key_last_action KEY_RELEASE; } }这个代码的逻辑里有个关键点只做一个按下边沿的检测不是持续检测按下状态。因为你的需求通常是每按一次角度加一些或者每按一次舵机切换一个位置持续按住不应该导致角度连续跳变。如果你希望长按连续调整那要在边沿检测之外再加一个按住时间超过某个阈值后开始连发的逻辑两者是不同的交互设计。关于按键还要提一个设计考量用轮询还是中断独立按键数量少、按键事件频率低人再快一秒也就按十几次用定时器轮询完全够用不会漏检。除非你的系统对按键响应延迟要求极其苛刻实时操作系统级别的否则没必要用外部中断触发按键。中断的好处是省去了轮询的周期延迟但坏处是中断里不能做耗时操作消抖逻辑放在中断里容易出问题。5. 状态机思维把按键输入映射到舵机角度的正确姿势有了按键输入和PWM输出剩下最后一个问题按键按下去之后舵机应该怎么动很多人写代码时直接在按键判断里写设置PWM比较值为某某某这个写法在简单场景下没问题但到了按键数量增多、控制逻辑变复杂时代码就成了一锅粥。更规范的做法是引入状态机的思想。把整个系统看作一个有限状态机状态代表舵机当前的目标角度事件代表按键被按下每个状态下按键按下后要执行什么动作在状态转移表里定义清楚。举个实际的例子我做过的项目是一个两键控制的舵机摆臂按键1每按一次舵机角度增加30°按键2每按一次舵机角度减小30°角度范围0°到180°如果不加思考很多人会写if (key1_pressed) { angle 30; if (angle 180) angle 0; // 回到0度 set_servo_angle(angle); } if (key2_pressed) { angle - 30; if (angle 0) angle 180; set_servo_angle(angle); }这样做确实能跑但有两个问题一是边界处理逻辑写在了业务逻辑里角度范围一旦变化要改的地方多且容易漏二是如果以后按键数量增加比如4个按键分别控制30°、60°、90°、120°代码会越来越乱。更好的写法是做一个简单的角度映射函数unsigned char angle_to_pulse(unsigned char angle) { // 0度 - 500us180度 - 2500us线性插值 // 用整数运算pulse 500 angle * (2000 / 180) ≈ 500 angle * 11 // 11.11us/度这里取整11误差在180度时约2度可接受 return 500 angle * 11; }void set_servo_angle(unsigned char angle) { if (angle 180) angle 180; unsigned int pulse angle_to_pulse(angle); __HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, pulse); }这样按键处理里只需要维护一个angle变量角度越界在set函数里统一处理。以后要改变角度范围只需要改映射函数的系数不需要动按键逻辑。这种界面层和业务层分离的思想在小项目里看起来有点小题大做但当你的项目从1个舵机扩展到4个舵机、从2个按键扩展到矩阵键盘时就会感谢自己当初这么写了。舵机的转动速度也要考虑。如果你直接从0°跳到180°舵机会以最大速度甩过去机械冲击很大甚至可能带动负载把支架打翻。想让舵机平滑转动可以引入渐进式转动——每次只把当前脉宽往目标脉宽靠近一小步比如每20ms变化10usvoid servo_smooth_toward(unsigned int target_pulse) { int diff target_pulse - current_pulse; if (diff 10) current_pulse 10; else if (diff -10) current_pulse - 10; else current_pulse target_pulse; __HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, current_pulse); }这个函数在主循环里周期调用每次只改变10us舵机的转角速度就会从啪地甩过去变成匀速转过去好看很多也保护机械结构。6. 一个能落地的完整控制示例两键舵机摆臂前面把各个模块的原理都讲透了这里给出一个完整的框架把所有部分串起来。我用的是STM32F103的HAL库写法但逻辑顺序是所有平台通用的。硬件连接模块引脚说明舵机信号线PA6 (TIM3_CH1)PWM输出20ms周期舵机VCC外部5V电源正极独立供电不接开发板5V舵机GND外部电源负极 单片机GND必须共地按键1PB0 接GND内部上拉按下为低按键2PB1 接GND内部上拉按下为低指示灯可选PC13指示当前角度状态主程序的核心逻辑int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_TIM3_Init(); // 初始化为90度 target_angle 90; set_servo_angle(target_angle); HAL_TIM_PWM_Start(htim3, TIM_CHANNEL_1); while (1) { // 按键扫描每10ms执行一次 Key_Scan(); // 如果按键1产生有效按下事件 if (new_key_event KEY1_EVENT) { new_key_event ~KEY1_EVENT; target_angle 30; if (target_angle 180) target_angle 180; } // 如果按键2产生有效按下事件 if (new_key_event KEY2_EVENT) { new_key_event ~KEY2_EVENT; if (target_angle 30) target_angle - 30; else target_angle 0; } // 平滑逼近目标角度 unsigned int target_pulse angle_to_pulse(target_angle); servo_smooth_toward(target_pulse); // 延时约20ms HAL_Delay(20); } }这里要注意一点按键事件标志位我用的是一个全局变量作为主循环和定时器中断之间的通信通道。定时器中断里扫描到按键按下边沿时只设置标志位不在中断里执行角度计算或者PWM设置主循环里再统一处理业务逻辑。这个中断只做标记主循环做业务的设计原则能有效避免中断嵌套和资源竞争问题在小项目中特别实用。7. 实测中遇到的那些坑能绕一个是一个最后把我实际测试时遇到过的问题集中列出来这些坑在文档和教程里很少被提到但几乎每个人都会碰到。舵机通电后一直嗡嗡叫。这个声音通常是PWM信号频率和舵机内部控制电路产生了共振或者是PWM波形本身有抖动。我遇到过两种情况一是用软件模拟PWM时中断里被其他更紧急的中断抢占了导致某些周期高电平时间不均匀二是占用了定时器中断里的时间太长PWM周期被拉长。解决办法是把PWM产生优先级提到最高并且确保中断服务程序尽可能简洁。PWM输出和按键扫描共用定时器时互相干扰。很多人只有一个定时器空闲又想同时产生PWM又想定时扫描按键就用一个定时器中断做了两件事。结果调了按键消抖的扫描时间舵机就开始抖。正确做法是有硬件PWM的单片机让硬件自己输出PWM不需要中断参与定时器中断只干按键扫描没有硬件PWM的51建议把PWM基准时间片定为0.1ms10kHz的中断频率按键消抖另开一个软件计数器而不是把按键检测放在每0.1ms都执行的逻辑里。单片机刚上电舵机猛转一下。这是因为单片机上电初始化时GPIO引脚默认状态不确定PWM输出还没配置前舵机信号线上可能出现了几毫秒的高电平舵机就做出了响应。解决方法是先把GPIO配置为低电平输出或者推挽输出低再初始化定时器和PWM最后启动PWM输出。顺序不能反这个初始化顺序导致的电平毛刺问题在所有嵌入式外设里都会遇到养成先配置引脚到安全状态再使能外设的习惯很重要。舵机角度和设定值对不上。这个多半出在脉宽换算的系数上。我用的SG90是线性映射但有些舵机尤其是大扭矩金属齿轮舵机的输入角度和输出角度并非完美的线性关系中位附近误差小两端可能偏差3到5度。如果在精度要求高的项目里建议实测几个角度点做分段校准把校准表存在Flash里运行时就查表插值。独立按键偶尔失灵或者连击。按键扫描周期太长会漏掉快速短按太短又会把机械抖动当成多次按键。我实测下来10ms扫描间隔配合连续两次相同状态确认的消抖方案对绝大多数人手的按击速度都足够用了短按持续时间一般在50ms以上。如果你发现还是连击先别急着改软件拿示波器看看按键波形很多时候是接触不良导致的多次弹跳换一个质量好点的按键就解决了。本文还有配套的精品资源点击获取
