Arduino 摇杆模块控制舵机云台:读准 X/Y、死区与速度控制完整代码

Cartoon of a black analog joystick module with a mushroom-dome cap and mint-green potentiometers, rainbow jumper wires arcing across to a pan-tilt assembly of two dark translucent smoky-brown micro servos with pink labels in cream printed brackets, teal and navy arrows showing horizontal and vertical sweep

模拟摇杆就是两个互成直角的电位器加一个按键,所以用 analogRead() 读两支模拟引脚就能拿到它 —— 但人人都会先写的那一行 servo.write(map(analogRead(A0),0,1023,0,180)),做出来的云台会自己慢慢爬,一松手又弹回正中。解法是量出你这支摇杆真正停在哪里,再让摇杆的位移决定速度,而不是角度。

做一个摇杆控制的云台,需要哪些零件?

HW-504 摇杆模块是一块黑色小板,顶上一个磨砂黑的拇指帽,十字支架底下藏着两个薄荷绿的电位器,板边是一排直的五针公排针。两个 TowerPro MG90S 微型舵机负责水平和俯仰;它们到手时就是电机加一根引线,所以支架也是这个作品的一部分。

选配 —— 舵机独立供电与 USB 数据线

ItemPriceQty
AA Battery Holder with Cover On/Off Switch 2/3/4 Slots Battery AA Holder Red/Black Wire - AA BATTERY CASE (4S)AA Battery Holder with Cover On/Off Switch 2/3/4 Slots Battery AA Holder Red/Black Wire - AA BATTERY CASE (4S)AABATS4RM3.55
Data Cable Type-A Type-C MicroUSB Type-B 0.5m 1m 30cm 0.3m 100cm Data Transfer Upload Code - TYPE-A TO TYPE-B (1.0M)Data Cable Type-A Type-C MicroUSB Type-B 0.5m 1m 30cm 0.3m 100cm Data Transfer Upload Code - TYPE-A TO TYPE-B (1.0M)ATBC100RM4.90

4 节 AA 电池盒,就是「两个舵机把 USB 供电的 Uno 拖到重启」这件事的解法:四节串联标称 6 V,正好落在 MG90S 的 4.8–6 V 额定范围内,而且它只喂舵机。Uno 那个插座认的是全尺寸 USB-B(打印机那种)方头 —— 抽屉里没有这个形状的线就加一条。

两包杜邦线不是买重复了:两头的公母方向正好相反。摇杆模块伸出来的是针,所以要用公对母杜邦线的母头去套它,另一端才落到 Uno 和面包板上。每根舵机引线的末端是一个三位的插座,所以要用公对公杜邦线 —— 从 Uno 引到面包板电源轨的那两根也一样。用摇杆键盘扩展板双摇杆扩展板可以把接线整个省掉;电脑跟 CH340 驱动过不去的话,就选FT232 版 Uno

模拟摇杆模块到底是怎么工作的?

HW-504 板上那两个电位器,都接成分压器跨在你喂给模块的同一组 +5 V 和 GND 上,十字支架的两个轴各带动一个。摇杆走完整个行程,每个滑动端的电压就从接近 0 V 摆到接近 5 V,而 ATmega328P 的 10 位 ADC 把它换算成 0–1023 —— 大约每个计数 4.9 mV。

那个按键就是一个普通的轻触开关,接在 SW 脚和 GND 之间,模块上没有任何上拉电阻,所以不管它的话,这支脚就是悬空的,读到的是噪声。pinMode(PIN_SW, INPUT_PULLUP) 会把 ATmega328P 自带的 20–50 kΩ 上拉接进来:这支脚从此静态为 HIGH,一按下去就被拉到 LOW。

回中弹簧把摇杆送回的是电气中点附近,不是电气中点本身:十字摆架和电位器碳膜轨道的成型公差,会让一支静止的摇杆停在 490 多到 530 出头之间的任何位置,两个轴不一样,每一块模块也不一样。静止值是 507 的摇杆没有毛病,默认它是 512 才有。

卡通俯视图:摇杆的圆形行程范围里,橙色十字准星标出几何正中心,一个偏开的淡青色死区圆盘、圆盘中心的深蓝圆点标出摇杆真正的静止位置,画面一角是一块黑色蘑菇头摇杆模块
橙色准星是你以为摇杆停的地方,深蓝圆点是它真正停的地方。青色圆盘就是必须把这段偏差吞掉的死区。

摇杆和两个舵机怎么接到 Uno 上?

Uno 上只有一个 5 V 插孔,而这个作品要分好几路出去,所以让面包板的电源轨把它扇开。先从 Uno 把电源轨喂起来。数引脚之前先看一眼摇杆板上的丝印:它写的是 GND+5VVRxVRySW —— 地在最前面,不是大多数模块那种 VCC 打头的顺序。

接到 为什么
Uno 5VGND 近侧+ 电源轨 两根公对公杜邦线。一个插孔就变成一整条轨
摇杆 +5VGND 近侧+ 电源轨 两个分压器都由 Uno 的 5 V 供电。这里喂 6 V 会把滑动端电压顶到超过 ADC 自己的参考
摇杆 VRxVRy A0A1 ADC 要读的就是这两路滑动端电压
摇杆 SW D2 开关对地;代码里打开内部上拉
水平舵机橙线 D9 只走信号 —— 每 20 ms 一个 1–2 ms 的脉冲
俯仰舵机橙线 D10 一样,第二个通道
两个舵机的棕线 远侧 电源轨 让舵机的回流电流不走摇杆那条地
两个舵机的红线 远侧+ 电源轨 由电池喂,绝不由 Uno 喂 —— 见供电那一节
近侧 电源轨 远侧 电源轨 共地。一根线,也是两组电源之间唯一的一根线

把两组电源放在面包板的两个长边上。400 孔面包板的每条长边都有自己的一对 + 电源条,所以 Uno 喂一边、电池喂另一边,而把两条 接起来的那根线,是两边之间唯一跨过去的线。要是改成把两条 + 桥起来,6 V 就落到 Uno 的 5 V 脚上 —— 这是这个作品绝对不能接的一根线。没有电池的时候,正是靠这根桥线让舵机吃 Uno 的电,而那只能算一次短暂的空载测试。

9 脚和 10 脚是个合理的默认,不是硬性要求。不管你接哪几支脚,Servo 库都会在第一次 attach() 时把 Timer1 占为己有,而 Timer1 正是产生 9 脚和 10 脚 PWM 的那个硬件 —— 所以反正这两支脚上的 analogWrite() 都会失效。3、5、6、11 脚的 PWM 不受影响。

那行显而易见的 map() 代码,为什么会漂移又抖动?

那行 map() 代码瞄错了数字。静止值 507 的摇杆映射出来是 89°,不是 90°,云台就歪了一度。更麻烦的是,位置模式让云台绝对地跟随摇杆:一松手,弹簧把摇杆送回中位,你刚对准的相机就自己转回房间正中。这是给云台用错了控制律,不是调参数能修掉的 bug。

它的死区是零。ADC 读数本来就会来回跳一两个计数,而 map() 把 1023 个计数摊到 180° 上,5.7 个计数就是一度。一度的指令大约是 10 µs 脉宽,而 MG90S 的死区带是 5 µs —— 于是没人碰的摇杆,也能让齿轮组来回找位。

它的地在动。最容易被怀疑的那一个其实是无辜的:ADC 的参考就是给电位器供电的同一条 5 V 轨,所以这条轨一塌,滑动端电压按同样比例一起塌,读数并不会变。真正抵消不掉的,是回流电流。两个舵机会把几百毫安从共用的地线和面包板触点上拉回去,这条路径上 0.2 Ω 的电阻就把 300 mA 变成 60 mV 的地电位偏移 —— 大约 12 个 ADC 计数,随着舵机动作忽有忽无。

怎么让摇杆控制速度,而不是控制角度?

速度控制只是在摇杆代码上改一小处。把 角度 = f(摇杆) 写成 角度 = 角度 + f(摇杆):摇杆决定的是每一帧加上去的度数,松手就是加零,云台停在你转到的地方。它还顺手白送了大家事后才补上的那层平滑:指令角度每一帧最多只能动 MAX_STEP,所以任何读数 —— 有噪声的、跳变的、别的什么 —— 都没办法让云台动得比这更快。均值滤波是去对付噪声;速率限制则让噪声不管多大都变得无害。

这些数字是舵机定下来的。它每 20 ms 刷新一次,帧再短,也只是在下达它执行不了的动作。摇杆推到底、每帧 1.5° 的话,云台的角速度是 75°/s,走完代码里 10°–170° 的水平行程要两秒出头 —— 从容,也拍得出来。MG90S 的额定速度是 0.08 秒转 60°,也就是 750°/s,所以 1.5° 一步它约两毫秒就走完 —— 走完之后这一帧剩下的时间它都闲着。舵机从来没有在追指令,这就是扫掠看起来平顺而不是一顿一顿的原因。60 个计数的死区是上面那笔地偏移预算的五倍,而每个方向仍然留下 88% 的行程可用。

/*
  模拟摇杆(HW-504)-> 两个 MG90S 舵机,用速度控制做云台。
  库:Servo(Arduino IDE 自带)。

  摇杆 GND -> 电源轨 GND    +5V -> 电源轨 5V
  摇杆 VRx -> A0    VRy -> A1    SW -> D2(内部上拉)
  舵机橙线 -> D9(水平)和 D10(俯仰)
  舵机红线 -> 6V 电池正极    舵机棕线 -> GND,与 Uno 共地

  摇杆的位移决定角度变化的速度,而不是角度本身。
  一松手云台就停在原地。按住按键,它会自己走回中位。
*/
#include <Servo.h>

const uint8_t PIN_VRX  = A0;
const uint8_t PIN_VRY  = A1;
const uint8_t PIN_SW   = 2;
const uint8_t PIN_PAN  = 9;    // Timer1 的引脚 - 见正文
const uint8_t PIN_TILT = 10;

const int      DEADZONE = 60;    // 实测静止值两侧各留的 ADC 计数
const float    MAX_STEP = 1.5;   // 摇杆推到底时每 20 ms 一帧走的度数
const uint16_t FRAME_MS = 20;    // 一个舵机刷新周期

const int PAN_MIN  = 10, PAN_MAX  = 170;   // 离机械限位留出余量
const int TILT_MIN = 30, TILT_MAX = 150;

Servo pan, tilt;

int   restX, restY;                        // 上电时实测,而不是假定 512
float panAngle = 90.0, tiltAngle = 90.0;
uint32_t lastFrame = 0, lastReport = 0;

// 多路开关切换之后的第一次转换丢掉,再取八次平均。
int readAxis(uint8_t pin) {
  analogRead(pin);
  long sum = 0;
  for (uint8_t i = 0; i < 8; i++) sum += analogRead(pin);
  return (int)(sum / 8);
}

// 摇杆偏移量 -> 每帧度数。死区之内为零,推到底为 MAX_STEP。
float axisRate(int raw, int rest) {
  int offset = raw - rest;
  if (offset > -DEADZONE && offset < DEADZONE) return 0.0;

  int span, past;
  if (offset > 0) { past = offset - DEADZONE; span = 1023 - rest - DEADZONE; }
  else            { past = offset + DEADZONE; span = rest - DEADZONE;        }
  if (span < 1) span = 1;

  float k = (float)past / (float)span;
  if (k >  1.0) k =  1.0;
  if (k < -1.0) k = -1.0;
  return k * MAX_STEP;
}

void setup() {
  Serial.begin(115200);
  pinMode(PIN_SW, INPUT_PULLUP);   // 模块自己没有上拉电阻

  // 校准:读出你这支摇杆真正的静止值。setup 期间手不要碰。
  restX = readAxis(PIN_VRX);
  restY = readAxis(PIN_VRY);
  Serial.print(F("rest X = "));    Serial.print(restX);
  Serial.print(F("   rest Y = ")); Serial.print(restY);
  Serial.println(F("   (near 512, but neither will be exactly 512)"));

  pan.attach(PIN_PAN);
  tilt.attach(PIN_TILT);
  pan.write((int)panAngle);
  tilt.write((int)tiltAngle);
}

void loop() {
  if (millis() - lastFrame < FRAME_MS) return;
  lastFrame = millis();

  int x = readAxis(PIN_VRX);
  int y = readAxis(PIN_VRY);

  if (digitalRead(PIN_SW) == LOW) {
    // 按住按键:按同样的速率限制走回中位。绝不瞬移。
    panAngle  += constrain(90.0 - panAngle,  -MAX_STEP, MAX_STEP);
    tiltAngle += constrain(90.0 - tiltAngle, -MAX_STEP, MAX_STEP);
  } else {
    panAngle  = constrain(panAngle  - axisRate(x, restX), PAN_MIN,  PAN_MAX);
    tiltAngle = constrain(tiltAngle + axisRate(y, restY), TILT_MIN, TILT_MAX);
  }

  pan.write((int)(panAngle + 0.5));
  tilt.write((int)(tiltAngle + 0.5));

  // 实时数值,好让你亲眼看着死区在干活。
  if (millis() - lastReport >= 500) {
    lastReport = millis();
    Serial.print(F("X "));       Serial.print(x);
    Serial.print(F("  Y "));     Serial.print(y);
    Serial.print(F("  pan "));   Serial.print((int)panAngle);
    Serial.print(F("  tilt "));  Serial.println((int)tiltAngle);
  }
}

在 Uno 上这份代码编译出来占 6,030 字节 Flash —— 32 KB 的 18% —— 加 252 字节 RAM。串口监视器设 115200,第一行就会打印出你这块模块的静止值。手离开摇杆云台还会慢慢爬,就把 DEADZONE 调大;某个轴方向反了,就把那一次 axisRate 调用前的符号取反;如果模块转了 90° 安装、把两个轴对调了,就把 PIN_VRXPIN_VRY 换过来。

Arduino 的 5 V 脚能带两个 MG90S 舵机吗?

MG90S 的额定电压是 4.8–6 V,空载约 10 mA,转动时 120–250 mA,堵转约 700 mA —— 堵转这个数字是实测值,不是 TowerPro 给的规格。两个一起动就是 240–500 mA,Uno 自己还要 40–50 mA,而 USB 这条路上守着一颗自恢复保险丝,超过 500 mA 就断开。这个作品是骑在那条线上,而不是落在线内,所以它会一直「正常」到俯仰舵机带上负载、板子重启为止。

给舵机单独供电。四节 AA 装在带开关的电池盒里串联,标称 6.0 V,落在 MG90S 的额定范围内,而一组全新碱性电池约 6.4 V,一带上负载就会落下来。电池盒的红线接远侧 + 电源轨,黑线接远侧 电源轨 —— 绝不接 Uno 的 5 V 脚。那支脚是稳压器的输出,不是输入;板子插着 USB 的时候,它和 USB 口喂进来的是同一条轨,所以从那里灌进去的 6 V 会倒着穿过稳压器,顺着线缆往你笔电的 USB 口上走。电池盒那两根裸露的线头,在插进面包板孔之前先绞紧。

卡通供电拓扑图:一条半透明蓝色 USB 线沿一条通路喂着一块蓝色小 Arduino 板,一个黑色四节 AA 电池盒沿另一条通路喂着两个深色半透明烟褐色微型舵机,两条红色供电线之间画着橙色叉号,一条黑色地线带青色勾号把所有东西连在一起
两条供电通路,一条地。电池喂舵机,USB 喂板子,两边之间唯一跨过去的线是地线。

舵机要装在什么支架上?

MG90S 到手就是电机加一根引线,所以支架是一个要你自己拿主意的设计。在云台里,下面那个舵机扛着上面那个、再加上顶上放的东西,所以它的负载是一段力臂末端的重量。一个俯仰舵机加一个轻支架 —— 3 cm 力臂上 40 g —— 大约是 0.12 kg·cm,而 MG90S 在 6 V 下是 2.2 kg·cm。换成 5 cm 力臂上放一台 200 g 的手机,你要的就是 1.0 kg·cm:账面上还在额定范围里。不过真正把尼龙齿轮打滑牙的,很少是这种稳态负载 —— 而是磕一下、或者一头顶进限位时的冲击,一瞬间全落在一颗齿上。这就是承重那一轴该上金属齿轮的理由。这个选择剩下的部分,看我们的舵机对比指南

支架要么打印一个,要么自己做一个。MG90S 的输出是一根 21 齿的金属花键轴,直径约 4.7 mm,中心还有一个小螺孔,所以打印出来的花键座压上去就套牢了,再往那个孔里拧一颗 M2 自攻螺丝,就能防止它受力时往上爬出来 —— Smith3D 有耗材也有打印机。

装支架之前先让舵机回到中位。舵机轴上次停在哪就还在哪,所以先把代码跑起来,让 setup() 把两个舵机都驱到 90°,然后把每个支架竖直压到它的花键轴上 —— 是竖直压下去,不是转上去,因为这时舵机正锁在那个角度。装到一根没回中的轴上,云台的机械中位就落在随便什么地方:摇杆往一边推不到头就没角度了,往另一边推就撞限位。

做桌面演示的话,硬卡纸加热熔胶就够用,代码里没有任何一处在意这个 —— 而云台一旦能稳稳停住,顶上最该放的当然是一块 ESP32-CAM。这段组装视频把这个连接拿在手上演示了一遍:

卡通图:装好的云台组件,两个深色半透明烟褐色微型舵机带着粉色标签装在米白色 3D 打印支架里,橙红棕三色引线拖出去接到黑色插头上,旁边一个特写小图是银色花键轴,一颗小螺丝正对着轴心的孔落下去
一个打印的底座、一个打印的 U 形支架,加一颗拧进花键轴中心孔的螺丝。特写小图就是防止支架受力时爬出来的那个连接。

真实客户身上常见的错误

两个舵机都吃 Uno 的电,然后怪摇杆。读数乱跳、云台一顿一顿、板子重启 —— 这三样全是上面那笔电流账,不是摇杆坏了。

手按着摇杆做校准。代码只在 setup() 里量一次静止值,之后每一次读数都是相对它的偏移量。板子上电时手还按着摇杆 —— 或者转到一半按了复位 —— 云台就继承了一个假的中位,于是松手它照样慢慢爬:正是校准本来要消掉的那种漂移。松手,复位,看着那两个静止值稳定在同一对数字上。

在机械上跟舵机较劲。支架其实提前挡住了舵机,你还往里写 0° 或 180°,它就会顶着限位待在那里一直吃堵转电流,直到有东西先坏 —— PAN_MINTILT_MAX 就是干这个用的。同样的道理,绝不要用手去掰一个通着电的舵机的输出轴。

常见问题

为什么我的 Arduino 摇杆读出来是 512,不是 0?

因为它是好的。摇杆停在行程中点,每个滑动端就落在碳膜轨道的一半上,输出 5 V 的一半 —— 大约是 ADC 那 1023 个计数里的 512。

为什么我的摇杆静止时读不到正好 512?

十字支架和电位器轨道的机械公差。490 多到 530 出头都算正常,而且两个轴不一样。开机时把两个轴都量一遍。

能用 Arduino 的 5 V 脚带两个舵机吗?

只能做一次短暂的空载测试。两个 MG90S 一起动要 240–500 mA,而 Uno 的 USB 供电超过 500 mA 就跳。四节 AA、两边共地,才是靠得住的接法。

为什么一用 Servo 库,9 脚和 10 脚的 analogWrite 就失效了?

它在第一次 attach() 时接管了 Timer1,也就是产生这两支脚 PWM 的那个硬件 —— 不管舵机实际接在哪几支脚上。3、5、6、11 脚的 PWM 照常。

我是不是该买摇杆扩展板,而不是模块?

不必 —— 两者都是接在模拟脚上的电位器,所以代码完全一样。扩展板省掉接线;裸模块可以装进手持外壳里。

最后更新:2026 年 8 月。有问题?WhatsApp 联系我们

Leave a Reply

Your email address will not be published. Required fields are marked *