
1. 从一个电位器旋钮说起为什么SG90配电位器是入门控制的最佳组合如果你手头有一个SG90舵机、一个10kΩ电位器和一块开发板那么你手里其实握着一套最经典、最直观的闭环控制演示系统。转动电位器旋钮舵机臂跟着转到对应角度——这个看似简单的动作背后涵盖了模拟信号采集、模数转换、脉宽调制输出、舵机内部闭环控制等一整套嵌入式核心概念。很多教程一上来就丢代码结果读者烧录完发现舵机要么不动要么抖动得像得了帕金森要么转到底就卡死发出齿轮摩擦声。问题往往不在代码本身而在于对SG90的工作特性、供电要求和PWM信号细节缺乏理解。SG90是市面上最常见的微型舵机之一重量约9克工作电压典型值4.8V到6V堵转扭矩大约1.8kg·cm4.8V时。它内部有一套完整的闭环系统直流电机、减速齿轮组、位置反馈电位器和控制电路板。你给它的PWM信号本质上是一个“目标位置指令”舵机内部的控制电路会不断比较目标位置和当前反馈电位器的分压值驱动电机正转或反转直到误差归零。这个过程完全在舵机内部完成你不需要写任何PID代码。那为什么用电位器来控制因为电位器提供了一个连续可变的模拟电压经过ADC转换成数字量后可以线性映射到舵机的角度范围。这比用按键控制更直观比用串口指令更即时而且成本极低。整个系统的信号链路是电位器分压→ADC采样→数值映射→PWM占空比调整→舵机角度变化。每一环都有坑每一环也都值得深挖。这篇文章面向的是刚接触嵌入式控制、想用最少的元件跑通一个完整控制回路的读者。无论你用的是Arduino、ESP32、STM32还是其他带ADC和PWM外设的MCU核心原理完全一致。我会从SG90的电气特性讲起把PWM信号的时序要求拆开揉碎再讲电位器分压电路的设计细节最后给出可直接复现的代码和调试方法。中间会穿插我在实际项目中踩过的坑比如为什么舵机一接上就复位、为什么角度总是不准、为什么电位器转到底舵机却只转了一半。2. SG90的PWM时序不是“占空比”而是“脉宽”2.1 20ms周期与0.5ms-2.5ms脉宽的真正含义很多初学者第一次看到SG90的控制代码时会困惑为什么设置的是脉宽而不是占空比因为舵机的控制协议是脉宽调制但信息编码在脉冲的高电平持续时间上而不是占空比。标准SG90要求周期为20ms即50Hz高电平持续时间在0.5ms到2.5ms之间对应输出轴从0度到180度。0.5ms对应0度1.5ms对应90度2.5ms对应180度。这个映射是线性的每度对应约(2.5-0.5)/180≈0.0111ms也就是11.1微秒。为什么是20ms这是早期模拟舵机时代留下的行业惯例。20ms周期意味着每秒钟发送50个位置指令舵机内部的控制电路有足够的时间响应每个指令并稳定到位。如果周期太短舵机内部电路来不及处理会出现抖动或失控如果周期太长舵机会因为长时间收不到有效信号而进入保护状态或者缓慢漂移。我实测过SG90对周期的容忍度大约在10ms到30ms之间但偏离20ms越远抖动越明显。脉宽精度直接决定角度精度。以常见的8位定时器为例如果MCU主频是16MHz预分频设为64那么定时器计数频率是250kHz每个计数周期是4微秒。要产生0.5ms脉宽需要125个计数2.5ms需要625个计数。这意味着角度分辨率大约是4微秒/11.1微秒≈0.36度。看起来够用但如果你用的定时器分辨率更低比如预分频设得太大导致计数周期是10微秒那角度分辨率就降到约0.9度舵机会出现明显的台阶感。2.2 不同MCU平台生成PWM的实操差异Arduino平台最方便直接用Servo库底层已经封装好了定时器和中断。但要注意Arduino Uno的Servo库会占用Timer1导致引脚9和10无法再用analogWrite()输出PWM。如果你同时需要控制舵机和调节LED亮度就得换引脚或者用其他定时器。ESP32更灵活LEDC外设可以生成任意频率和分辨率的PWM但需要手动配置通道、定时器和引脚映射。STM32的通用定时器功能最强但配置步骤也最繁琐需要设置预分频器、自动重装载值、捕获比较模式等。我在ESP32上做过一个对比测试用LEDC生成50Hz PWM分辨率设为14位计数范围0-16383那么0.5ms对应的占空比是16383×0.5/20≈4102.5ms对应约2048。这样角度分辨率可以达到180/(2048-410)≈0.11度比8位定时器精细得多。但分辨率越高对电位器ADC噪声越敏感舵机会因为ADC读数的微小波动而持续微调发出“滋滋”声。所以实际项目中我通常会在ADC读数上做滑动平均滤波或者故意降低PWM分辨率来换取稳定性。注意SG90的标称角度范围是180度但实际个体差异很大。我手头几个SG90有的0.5ms能到-5度有的2.5ms只能到175度。不要用机械限位去硬顶否则齿轮会很快磨损。2.3 供电最容易翻车的地方SG90在空载时电流约100mA但在启动瞬间或者堵转时电流可以飙升到500mA甚至更高。如果你直接从开发板的5V引脚取电而开发板又通过USB供电那么舵机启动瞬间的电流冲击会导致USB端口电压跌落MCU直接复位。这个现象非常常见代码烧录后舵机偶尔动一下然后开发板重启串口监视器不断打印启动信息。正确的做法是给舵机单独供电。可以用4节镍氢电池4.8V或者一个5V/2A的独立电源适配器。关键是要把舵机电源的地和MCU的地连在一起否则PWM信号没有参考地舵机会乱转。我习惯在舵机电源正负极之间并联一个470μF到1000μF的电解电容用来吸收启动瞬间的电流尖峰。这个电容的位置要尽量靠近舵机引线越短越好。还有一个隐蔽的坑有些开发板的5V引脚在USB供电时实际电压只有4.6V左右舵机接上后扭矩明显不足转到一半就卡住。如果你发现舵机无力先用万用表量一下舵机端的电压低于4.8V就考虑换供电方案。3. 电位器分压电路看似简单细节决定精度3.1 10kΩ电位器的选型与接线电位器本质上是一个可变电阻三端器件两端接电源和地中间抽头输出分压电压。当旋钮转动时抽头在电阻体上滑动输出电压在0V到VCC之间线性变化。选10kΩ是因为这个阻值对大多数MCU的ADC输入阻抗都友好同时静态电流只有0.5mA5V/10kΩ功耗可以忽略。接线时要注意电位器的两个固定端分别接VCC和GND中间抽头接MCU的ADC输入引脚。如果你把抽头接错到固定端旋钮就变成了一个通断开关要么输出VCC要么输出GND没有中间状态。我见过有初学者把电位器当可变电阻用只接两端结果ADC读数要么是0要么是满量程完全没法线性控制。另外电位器的机械角度范围通常是270度或300度但电气有效范围可能只有240度左右。两端的死区会导致ADC读数在接近0和接近满量程时变化很小。软件上可以把ADC读数映射到舵机角度时留出一点余量比如ADC最小值设为50最大值设为4050假设12位ADC范围0-4095这样旋钮两端也能覆盖舵机的0度和180度。3.2 ADC参考电压与噪声抑制ADC的参考电压决定了测量精度。如果你用开发板的默认参考电压通常是VCC而VCC又来自USB供电那么USB电压的波动会直接反映到ADC读数上。比如USB电压从5.0V降到4.8V同样的电位器位置ADC读数会变化约4%。舵机角度就会跟着漂移。解决办法有两个一是用外部基准电压芯片如TL431提供稳定的2.5V或3.3V参考二是软件上做校准在启动时读取电位器两端的ADC值作为最小和最大参考。ADC噪声是另一个问题。电位器的滑动触点接触电阻会引入噪声MCU内部的数字电路也会耦合噪声到模拟输入。我实测过不做任何滤波时ADC读数在±5个LSB范围内跳动对应舵机角度约±0.5度舵机静止时会发出轻微的“嗡嗡”声。加一个0.1μF的陶瓷电容从ADC输入引脚到地可以滤掉大部分高频噪声。软件上再用一个简单的滑动平均滤波比如连续读10次取平均值舵机就完全安静了。提示如果你用的是ESP32注意它的ADC2在WiFi开启时不可用必须用ADC1的引脚。这个坑我踩过调试了半天以为是电路问题结果是外设冲突。3.3 映射算法从ADC读数到舵机角度的线性变换假设ADC是12位读数范围0-4095舵机角度范围0-180度脉宽范围0.5ms-2.5ms。最直接的映射是angle (adc_value / 4095.0) * 180.0 pulse_width 0.5 (angle / 180.0) * 2.0 // 单位ms但实际中电位器的有效旋转角度可能不到300度ADC读数可能只覆盖200到3900。如果直接用0-4095映射旋钮转到底舵机也只转到一半。所以要先校准把电位器旋到最小记录ADC_min旋到最大记录ADC_max。然后angle ((adc_value - ADC_min) / (ADC_max - ADC_min)) * 180.0这样旋钮的整个机械行程都能对应到舵机的全角度范围。校准值可以硬编码在代码里也可以在启动时自动检测——上电后让用户把旋钮从一端转到另一端MCU记录最小值和最大值。自动校准更灵活但会增加启动步骤。还有一个细节ADC读数不是完全线性的电位器本身的线性度也有误差。如果你需要高精度角度控制可以用查表法或者分段线性插值。但对于大多数入门项目线性映射已经足够肉眼几乎看不出偏差。4. 代码实现从Arduino到ESP32的完整方案4.1 Arduino Uno版本最简代码与库的取舍Arduino Uno上最省事的方案是用Servo库。代码大概长这样#include Servo.h Servo myServo; const int potPin A0; void setup() { myServo.attach(9); } void loop() { int adc analogRead(potPin); int angle map(adc, 0, 1023, 0, 180); myServo.write(angle); delay(15); }这段代码能跑但有几个问题。第一map()函数用的是整数运算1023到180的映射会有台阶舵机转动时能感觉到一顿一顿的。第二delay(15)加上analogRead()的时间实际刷新率大约50Hz和舵机PWM周期接近偶尔会出现指令冲突导致抖动。第三没有滤波ADC噪声直接传到舵机。改进方案用浮点运算做映射加滑动平均滤波去掉delay()改用millis()做非阻塞定时。Servo库的write()函数内部会把角度转换成脉宽但它的分辨率有限对于180度舵机write(0)到write(180)对应的脉宽是544μs到2400μs和标准0.5ms-2.5ms略有偏差。如果你追求精确可以用writeMicroseconds()直接指定脉宽。#include Servo.h Servo myServo; const int potPin A0; const int numReadings 10; int readings[numReadings]; int readIndex 0; int total 0; unsigned long lastUpdate 0; void setup() { myServo.attach(9); for (int i 0; i numReadings; i) readings[i] 0; } void loop() { total total - readings[readIndex]; readings[readIndex] analogRead(potPin); total total readings[readIndex]; readIndex (readIndex 1) % numReadings; if (millis() - lastUpdate 20) { lastUpdate millis(); float avg total / (float)numReadings; float angle (avg / 1023.0) * 180.0; int pulse 500 (angle / 180.0) * 2000; myServo.writeMicroseconds(pulse); } }这段代码的刷新率稳定在50Hz滤波后的ADC读数平滑脉宽直接控制精度更高。实测舵机转动非常顺滑静止时几乎没有抖动。4.2 ESP32版本LEDC外设与ADC的配合ESP32没有Servo库的原生支持虽然有第三方库但用LEDC外设更灵活。LEDC可以生成任意频率和分辨率的PWM而且不占用CPU时间。配置步骤如下#include driver/ledc.h #define POT_PIN 34 #define SERVO_PIN 18 #define LEDC_CHANNEL LEDC_CHANNEL_0 #define LEDC_TIMER LEDC_TIMER_0 #define LEDC_RESOLUTION LEDC_TIMER_14_BIT void setup() { ledc_timer_config_t timer_conf { .speed_mode LEDC_LOW_SPEED_MODE, .duty_resolution LEDC_RESOLUTION, .timer_num LEDC_TIMER, .freq_hz 50, .clk_cfg LEDC_AUTO_CLK }; ledc_timer_config(timer_conf); ledc_channel_config_t ch_conf { .gpio_num SERVO_PIN, .speed_mode LEDC_LOW_SPEED_MODE, .channel LEDC_CHANNEL, .intr_type LEDC_INTR_DISABLE, .timer_sel LEDC_TIMER, .duty 0, .hpoint 0 }; ledc_channel_config(ch_conf); analogReadResolution(12); } void loop() { int adc analogRead(POT_PIN); float angle (adc / 4095.0) * 180.0; float pulse_ms 0.5 (angle / 180.0) * 2.0; uint32_t duty (uint32_t)((pulse_ms / 20.0) * 16383); ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL, duty); ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL); delay(20); }这里14位分辨率下20ms周期对应16383个计数0.5ms对应约4102.5ms对应约2048。注意ledc_set_duty和ledc_update_duty要成对调用否则占空比不会生效。ESP32的ADC默认是12位但线性度一般特别是接近0和4095时非线性明显。可以在代码里做分段校准或者用外部ADC芯片。4.3 STM32版本定时器配置与DMA传输STM32的方案最复杂但最稳定。以STM32F103为例用TIM2的通道1生成PWMPA0作为ADC输入。关键配置系统时钟72MHzTIM2预分频72-1计数频率1MHz自动重装载值20000-1这样周期正好20ms。捕获比较值设为500到2500对应0.5ms到2.5ms。// TIM2 PWM初始化 TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_TimeBaseStructure.TIM_Period 20000 - 1; TIM_TimeBaseStructure.TIM_Prescaler 72 - 1; TIM_TimeBaseStructure.TIM_ClockDivision 0; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse 1500; TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM2, TIM_OCInitStructure); TIM_Cmd(TIM2, ENABLE);ADC用DMA传输连续采样多个通道避免CPU干预。主循环里读取DMA缓冲区做滤波和映射然后更新TIM2的捕获比较寄存器。STM32的ADC是12位参考电压3.3V如果电位器接3.3V那么0-4095对应0-3.3V。注意STM32的IO口是3.3V电平SG90的信号线也是3.3V兼容的可以直接连接。注意STM32的定时器通道和引脚有固定映射关系比如TIM2_CH1默认在PA0但也可以重映射到PA15。配置前一定要查数据手册的复用功能表否则PWM出不来。5. 调试过程中最常见的五个坑与排查链路5.1 舵机不动或只抖动一下这是最典型的供电问题。排查顺序先用万用表量舵机电源引脚电压如果低于4.5V换独立电源。如果电压正常量PWM信号引脚看是否有50Hz的方波。没有示波器的话可以用LED串联一个1kΩ电阻接在信号线上正常应该看到LED微弱闪烁50Hz人眼看不出来但亮度约10%。如果LED常亮或常灭说明PWM没输出检查定时器配置和引脚映射。还有一个可能舵机的信号线接错了。SG90的线序通常是棕色-地红色-电源橙色-信号。不同厂家可能不同接反了舵机内部电路可能损坏。我习惯先用万用表确认线序再上电。5.2 角度范围不对旋钮转到底舵机只转一半这是映射范围没校准。先单独测试舵机写一个固定脉宽1.5ms看舵机是否在中间位置。然后写0.5ms和2.5ms看是否到两端。如果舵机本身范围正常那就是ADC映射问题。用串口打印ADC读数旋钮从最小到最大看读数范围是多少。如果只有200到3900就按这个范围做映射而不是0到4095。5.3 舵机抖动或发出“滋滋”声抖动通常来自ADC噪声或PWM信号不稳定。先加硬件滤波电容再在软件里做滑动平均。如果还抖降低PWM刷新率比如从50Hz降到30Hz。另外舵机内部的控制电路对电源噪声很敏感在舵机电源引脚就近加一个100μF电解电容和一个0.1μF陶瓷电容效果立竿见影。5.4 开发板频繁复位舵机启动电流导致电源跌落。除了独立供电还可以在代码里做软启动上电后先让舵机保持在中间位置等电源稳定后再响应电位器。或者用一个大电容2200μF并联在开发板电源输入端但这不是根本解决办法独立供电才是正道。5.5 电位器旋转时舵机反应迟钝或有跳变电位器内部磨损导致接触不良或者ADC采样速率太低。先换一个电位器试试。如果问题依旧提高ADC采样率比如用定时器触发ADC转换而不是在循环里轮询。另外映射算法里的浮点运算如果太慢也会导致响应迟钝。可以预先计算一个查找表把ADC读数直接映射到PWM占空比运行时只做查表操作。6. 从单舵机到多舵机扩展思路与注意事项当你跑通了单舵机加电位器的方案很自然会想控制多个舵机。比如用两个电位器分别控制两个舵机做一个简单的机械臂。这时候要注意几个问题。第一PWM资源。Arduino Uno的Servo库最多支持12个舵机但会占用所有定时器导致analogWrite()和millis()可能受影响。ESP32的LEDC有16个通道但只有8个高速通道和8个低速通道低速通道可以生成任意频率高速通道频率范围有限。STM32的每个通用定时器有4个独立通道一个定时器就能控制4个舵机而且周期和脉宽完全同步。第二供电。每个SG90空载100mA堵转500mA4个舵机同时动作峰值电流可能超过2A。你需要一个5V/3A以上的电源并且每个舵机的电源引脚都就近加去耦电容。地线要足够粗最好用星型接地避免舵机电流在地线上产生压降干扰ADC读数。第三ADC资源。如果你用多个电位器需要多个ADC通道。Arduino Uno有6个ADC通道ESP32有18个但ADC2在WiFi开启时不可用STM32F103有10个。多通道采样时要注意采样时间和通道切换的稳定时间。我通常在每个通道采样后加一个短的延时或者用DMA自动扫描多个通道。第四机械联动。多个舵机控制一个机械结构时角度映射要统一。比如两个舵机分别控制机械臂的肩部和肘部电位器的旋转方向要和舵机的转动方向一致否则操作起来很别扭。可以在代码里加一个反向标志或者物理上把电位器反装。提示如果你要做多舵机同步运动比如让两个舵机同时从0度转到180度不要用两个独立的循环分别控制。用一个循环同时更新所有舵机的PWM值这样它们的运动是同步的。否则第一个舵机转完第二个才开始看起来像卡顿。7. 实测数据与性能边界SG90到底能做到多准我用一个12位ADCESP32和一个10kΩ线性电位器独立线性度±2%做了一组测试。电位器旋转范围约280度ADC读数从180到3980。映射到舵机0-180度后用激光笔固定在舵机臂上在1米外的墙上打点测量实际角度偏差。目标角度实测角度偏差0-1.2-1.24544.1-0.99090.50.5135135.80.8180181.51.5偏差主要来自三个因素电位器线性度、舵机内部反馈电位器的线性度、以及舵机齿轮间隙。SG90的齿轮间隙大约1-2度这是机械结构决定的软件无法补偿。如果你需要更高精度得换金属齿轮舵机或者数字舵机。刷新率方面ESP32在50Hz PWM下ADC采样加滤波加映射主循环耗时约2ms完全跟得上。Arduino Uno在16MHz下浮点运算较慢主循环约5ms也能满足50Hz刷新。但如果加复杂的滤波算法比如卡尔曼滤波Uno可能吃力建议用查找表或者定点运算。功耗方面单个SG90在空载往复运动时平均电流约150mA峰值400mA。电位器分压电路静态电流0.5mA。整个系统用5V/1A电源供电绰绰有余。如果电池供电4节AA镍氢电池2000mAh大约能连续工作6-8小时。8. 写在最后几个让我少走弯路的小习惯调试舵机项目时我养成了几个习惯帮我省了很多时间。第一个是永远先用固定脉宽测试舵机确认舵机本身和供电没问题再接电位器。第二个是在代码里加串口打印把ADC原始值、滤波后的值、映射后的角度、最终脉宽都打出来一眼就能看出哪一环出了问题。第三个是给舵机电源单独走线不和MCU共用一根杜邦线线径至少22AWG。第四个是电位器旋钮上贴一个小标记方便观察机械角度和ADC读数的对应关系。还有一个容易被忽略的点SG90的堵转保护。如果舵机被外力卡住内部电机持续堵转电流飙升几分钟内就能烫到无法触摸长期这样会烧毁电机或者齿轮。虽然SG90没有内置过流保护但你可以在代码里加一个简单的保护逻辑如果ADC读数对应的角度和舵机实际角度偏差持续超过一定阈值比如通过电流检测或者时间判断就切断PWM输出。不过对于入门项目只要不强行卡住舵机臂一般不会出问题。这个电位器控制SG90的方案麻雀虽小五脏俱全。它涵盖了模拟采集、数字滤波、PWM生成、执行器驱动、供电设计、机械校准等嵌入式开发的多个核心环节。把这套东西吃透再去看更复杂的运动控制、机器人关节、云台稳定系统会发现底层逻辑都是相通的。我后来做三轴云台、机械臂、自动追踪小车用的都是同一套思维框架只是传感器从电位器换成了陀螺仪、编码器或者视觉模块。所以别小看这个简单的组合它值得你花时间把每个细节都摸清楚。