
ESP32 伺服馬達 SG90 控制:PWM 脈衝控制與多軸管理
如果你手上有一顆 SG90,想用 ESP32 讓它老老實實轉到你要的角度,那你來對地方了。這篇我打算用「邊做邊講」的方式,把 ESP32 控制伺服馬達這件事從頭拆開:訊號怎麼送、角度怎麼換算、多軸怎麼同步、動作怎麼變順,最後再談那個最容易被忽略、也最常把專案搞死的地方 —— 電源。
先講一個事實:SG90 大概是市面上最便宜的微型伺服馬達之一,便宜到你會想一次買一整包。它的應用場景也很廣,機器手臂、雲台、智慧門鎖、RC 模型,甚至只是想在桌上放一個會動的小玩意兒,都輪得到它。ESP32 伺服馬達控制這件事,本質上牽涉到 PWM 脈衝寬度調變、LEDC 硬體計時器、多通道同步,以及電源管理這幾個面向。
還有一件事要先說在前面:接線只要三條線,程式三行就能跑起來,但真正會出事的通常是「電源」。這段我們後面會單獨拉出來講。
本文依序會走過這些主題:
- 伺服馬達 PWM 控制原理(50 Hz、1~2 ms 脈衝)
- ESP32 LEDC 硬體 PWM 產生器
- 多軸伺服管理(8+ 通道獨立控制)
- 平滑運動與梯形加減速
- Servo 函式庫 vs 底層 LEDC API
- 電源設計與實務注意事項
SG90 規格
先看規格。這張表建議你存下來,後面算電源、算速度、估動作時間都會用到。
| 參數 | 數值 |
|---|---|
| 工作電壓 | 4.8 ~ 6.0 V |
| 堵轉扭力 (4.8V) | 1.8 kg.cm |
| 轉速 (4.8V) | 0.12 sec/60deg (無負載) |
| 控制訊號 | PWM 50 Hz (20 ms 週期) |
| 脈衝寬度範圍 | 0.5 ~ 2.5 ms |
| 旋轉角度 | 0 ~ 180 deg(部分可 360deg 連續旋轉) |
| 死區 | 約 2~5 us(對應 ~0.2deg) |
| 重量 | 9 g |
| 尺寸 | 23 x 12.2 x 29 mm |
有兩個數字值得你先圈起來:一個是 0.12 sec/60deg,這決定了你的動作至少要花多久才做得完;另一個是 堵轉扭力 1.8 kg.cm,它決定了你的電源要多大顆。
PWM 控制原理
伺服馬達的控制原理其實簡單到有點可愛:每隔 20 ms(也就是 50 Hz)送一個脈衝,脈衝的寬度決定轉到幾度。就這麼一件事,沒有什麼編碼或封包要處理。

上圖是同一顆馬達在三種角度下的訊號長相。注意看:週期永遠是 20 ms 不變,變的只有脈衝有多寬。這也解釋了為什麼 PWM 頻率飄掉會直接讓伺服抖動 —— 週期一歪,馬達解讀到的角度就歪。
角度與脈衝寬度的對應關係是線性的:
| 角度 | 脈衝寬度 | Duty Cycle (50 Hz) |
|---|---|---|
| 0° | 1.0 ms | 5.0% |
| 45° | 1.25 ms | 6.25% |
| 90° | 1.5 ms | 7.5% |
| 135° | 1.75 ms | 8.75% |
| 180° | 2.0 ms | 10.0% |

把它畫成圖就是一條直線。所以你只要記住中間點:1.5 ms 對應 90 度,之後每多 1 度就多約 5.5 us 的脈衝寬度。關鍵公式如下:
脈衝寬度 (ms) = 1.5 + (角度 - 90) / 90
角度範圍: 0 ~ 180 度
脈衝寬度範圍: 0.5 ~ 2.5 ms (SG90 標準)
解析度: 約 11 us/deg (20ms / 180deg / 20bit timer)
硬體接線

接線的部分,訊號線最單純:ESP32 的 GPIO 直接接到伺服馬達的訊號線就好,3.3V 的邏輯準位對 SG90 完全夠用(它的邏輯閾值大約 1.5V)。
重要:伺服馬達不可以從 ESP32 的 3.3V 取電。
SG90 在堵轉時電流可以衝到 750 mA,這遠遠超過 ESP32 板上 3.3V 穩壓器的輸出能力。比較常見的症狀是:程式看起來沒問題,但馬達一動 ESP32 就重開機 —— 那就是 3.3V 被拉垮了。正確做法是給伺服馬達一顆獨立的 5V 2A+ 電源,並且把 ESP32 的 GND 和電源的 GND 接在一起(共地)。訊號可以參考、電源要分家,這是整個專案最重要的紀律。
ESP32 LEDC PWM 硬體
訊號要從哪裡生出來?ESP32 內建了 LEDC(LED Control)PWM 控制器,顧名思義原本是拿來調 LED 亮度的,但它的精度跟彈性用來驅動伺服馬達剛剛好:
- 8 通道(可擴展到 16 通道)
- 20-bit 解析度(可設定 1~20 bit)
- 硬體自動輸出(不佔用 CPU)
- 可設定頻率(1 Hz ~ 40 MHz)
- 3 組時基(每組共用頻率,各通道獨立佔空比)
最後那一點是重點:同一組時基下面的通道,波形是由同一個計時器產生,所以它們天然同步。多軸要對齊動作時,這就是你不用自己寫排程的原因。
Arduino 程式設計
接下來換寫程式。從最偷懶的寫法開始,再一層一層往下挖。
使用 Servo 函式庫(最簡易)
如果你只是想讓馬達轉起來,用 Servo 函式庫就好,完全不用管脈衝要怎麼算:
// ESP32 伺服馬達 — Servo 函式庫
#include <Servo.h>
Servo servo0;
Servo servo1;
void setup() {
Serial.begin(115200);
servo0.attach(13); // GPIO 13
servo1.attach(12); // GPIO 12
// 歸零
servo0.write(0);
servo1.write(0);
delay(1000);
}
void loop() {
// 掃描 0~180 度
for (int pos = 0; pos <= 180; pos += 1) {
servo0.write(pos);
delay(15); // 約需 15 ms/deg (SG90 0.12s/60deg)
}
for (int pos = 180; pos >= 0; pos -= 1) {
servo0.write(pos);
delay(15);
}
// 直接設定脈衝寬度 (us)
servo1.writeMicroseconds(1000); // 0deg
delay(1000);
servo1.writeMicroseconds(1500); // 90deg
delay(1000);
servo1.writeMicroseconds(2000); // 180deg
delay(1000);
}
這裡有個很容易被忽略的小細節:write() 內部只以 50 Hz 更新,也就是每 20 ms 才能改變一次角度。所以那一行 delay(15) 差不多就是 SG90 的物理極限,再快也追不上。
LEDC 底層 API(精確控制)
需要精確控制的時候,就得自己接底層的 LEDC API,把角度換成脈衝、再換成 duty:
// ESP32 伺服馬達 — LEDC 底層 PWM 控制
#define SERVO_FREQ 50 // 50 Hz
#define SERVO_RESOLUTION 16 // 16-bit 解析度
#define SERVO_CH0 0 // LEDC 通道 0
#define SERVO_PIN0 13
// 角度轉脈衝寬度 (us)
int angleToPulse(int angle) {
// angle: 0~180, 回傳 500~2500 us
return map(angle, 0, 180, 500, 2500);
}
// 脈衝寬度轉 duty cycle
uint32_t pulseToDuty(int pulse_us) {
// 50 Hz = 20,000 us 週期
// 16-bit: 0~65535
return (uint64_t)pulse_us * 65535 / 20000;
}
void setServoAngle(int channel, int angle) {
int pulse = angleToPulse(angle);
uint32_t duty = pulseToDuty(pulse);
ledcWrite(channel, duty);
}
void setup() {
// 設定 LEDC 通道
ledcSetup(SERVO_CH0, SERVO_FREQ, SERVO_RESOLUTION);
ledcAttachPin(SERVO_PIN0, SERVO_CH0);
// 設定到 90deg
setServoAngle(SERVO_CH0, 90);
}
void loop() {
for (int a = 0; a <= 180; a += 5) {
setServoAngle(SERVO_CH0, a);
delay(50);
}
}
關鍵就在 pulseToDuty():16-bit 的 duty 是 0~65535 對應整個 20,000 us 週期,所以 1500 us 的脈衝算出來大約是 4915。這個數字背下來不吃虧,之後看波形或除錯都會用到。
多軸伺服同步控制

圖中四個通道由同一組時基驅動,所以四個脈衝是同時起跑的,差別只在各自的高準位持續多久。這代表你可以放心地說「四顆馬達同時出發」,而不會出現第一顆已經轉完、第四顆才剛起步的窘境。
// ESP32 四軸伺服同步控制 (LEDC 多通道)
#define SERVO_FREQ 50
#define SERVO_RES 16
// 伺服設定結構
struct ServoConfig {
uint8_t pin;
uint8_t channel;
int currentAngle;
int targetAngle;
};
ServoConfig servos[4] = {
{13, 0, 90, 0}, // Servo 0: GPIO 13, CH0
{12, 1, 90, 0}, // Servo 1: GPIO 12, CH1
{14, 2, 90, 0}, // Servo 2: GPIO 14, CH2
{27, 3, 90, 0}, // Servo 3: GPIO 27, CH3
};
void setupAllServos() {
for (int i = 0; i < 4; i++) {
ledcSetup(servos[i].channel, SERVO_FREQ, SERVO_RES);
ledcAttachPin(servos[i].pin, servos[i].channel);
setAngle(i, servos[i].currentAngle);
}
}
void setAngle(int idx, int angle) {
servos[idx].targetAngle = constrain(angle, 0, 180);
// 轉換脈衝寬度: 0.5~2.5ms
int pulse = map(angle, 0, 180, 500, 2500);
uint32_t duty = (uint64_t)pulse * 65535 / 20000;
ledcWrite(servos[idx].channel, duty);
}
void moveAllTo(int angles[4]) {
for (int i = 0; i < 4; i++) {
setAngle(i, angles[i]);
}
}
void setup() {
setupAllServos();
}
void loop() {
int pose1[4] = {0, 45, 90, 135};
int pose2[4] = {180, 135, 90, 45};
moveAllTo(pose1);
delay(2000);
moveAllTo(pose2);
delay(2000);
}
要注意 setAngle() 是「立刻跳到目標角度」。馬達沒有情緒,但你會看到它甩頭。想要優雅一點,就得往下看平滑運動。
平滑運動(梯形加減速)

上圖把三種做法攤在一起看。灰色那條是直接跳躍 —— 位置瞬間從 0 跳到 180,機構會承受全部的加速度。橘色那條是階梯式插值,每度停一下,位置是漂亮直線,但速度在頭尾是硬切上去的,所以接近目標時常常會抖一下。綠色那條是梯形加減速,起步慢、中段快、收尾慢,位置曲線是 S 形,動作最順。
// 階梯式插值 — 避免伺服馬達抖動
void smoothMove(int idx, int target, int stepDelay = 20) {
int current = servos[idx].currentAngle;
int step = (target > current) ? 1 : -1;
// 逐步移動,每次移動 1 度
for (int pos = current; pos != target; pos += step) {
setAngle(idx, pos);
servos[idx].currentAngle = pos;
delay(stepDelay); // 每度延遲 (ms)
}
// 確保到達目標
setAngle(idx, target);
servos[idx].currentAngle = target;
}
// 自動計算最佳速度
void smoothMoveWithSpeed(int idx, int target, float speedDegPerSec) {
int current = servos[idx].currentAngle;
int dist = abs(target - current);
if (dist == 0) return;
int delayMs = max(5, (int)(1000.0 / speedDegPerSec));
int step = (target > current) ? 1 : -1;
// 梯形加減速
int accelDist = dist / 3; // 加速距離
int decelDist = dist / 3; // 減速距離
for (int pos = current; pos != target; pos += step) {
setAngle(idx, pos);
servos[idx].currentAngle = pos;
int remaining = abs(target - pos);
int traveled = abs(pos - current);
if (traveled < accelDist) {
// 加速: 逐漸減少延遲
delay(delayMs * (1 + (float)(accelDist - traveled) / accelDist));
} else if (remaining < decelDist) {
// 減速: 逐漸增加延遲
delay(delayMs * (1 + (float)(decelDist - remaining) / decelDist));
} else {
// 等速
delay(delayMs);
}
}
}
實務上的經驗值:把加速與減速各留行程的三分之一、每度延遲 15~20 ms 起跳,大多數 SG90 就不會再甩頭了。
PCA9685 I2C 擴展(32+ 通道)
當軸數多起來,ESP32 的 GPIO 跟通道就開始不夠用。這時候可以外掛 PCA9685 這顆 16 通道的 PWM 驅動 IC,只要兩條 I2C 線就能再開 16 路,而且可以串接多顆(最多 62 顆,等於 992 顆伺服)。
// ESP32 + PCA9685 16 通道伺服控制
// PCA9685 使用 I2C 通訊,可控制 16 個伺服馬達
// 多個 PCA9685 可串接 (最多 62 個 = 992 伺服)
#include <Wire.h>
#include <Adafruit_PWMServoDriver.h>
Adafruit_PWMServoDriver pwm = Adafruit_PWMServoDriver();
// PCA9685 PWM 頻率設定
#define SERVO_FREQ 50 // 50 Hz
// 脈衝寬度極限 (us)
#define SERVO_MIN 500 // 0deg
#define SERVO_MAX 2500 // 180deg
void setup() {
pwm.begin();
pwm.setOscillatorFrequency(27000000); // PCA9685 內部震盪器
pwm.setPWMFreq(SERVO_FREQ); // 50 Hz
delay(10);
}
void setPCA9685Servo(int ch, int angle) {
int pulse = map(angle, 0, 180, SERVO_MIN, SERVO_MAX);
// PCA9685 的 pulse 單位: 1/4096 週期
// 50 Hz 週期 = 20 ms = 20000 us
// 4096 / 20000 * pulse_us
pwm.setPWM(ch, 0, pulse * 4096 / 20000);
}
void loop() {
// 控制通道 0~15
for (int ch = 0; ch < 16; ch++) {
setPCA9685Servo(ch, 90); // 全部置中
}
delay(2000);
for (int ch = 0; ch < 16; ch++) {
setPCA9685Servo(ch, 0);
}
delay(2000);
}
PCA9685 的計算邏輯跟 LEDC 一樣,只是單位換成 1/4096 週期,而且它有自己的震盪器,記得先校正頻率,不然 16 顆馬達的角度會集體偏移。
ESP-IDF LEDC API
如果你是用 ESP-IDF 而不是 Arduino 框架,邏輯完全一樣,只是把 API 換成 C 版本的設定結構:
// ESP-IDF 伺服馬達控制
#include "driver/ledc.h"
#define SERVO_PIN 13
#define SERVO_CHANNEL LEDC_CHANNEL_0
#define SERVO_TIMER LEDC_TIMER_0
#define SERVO_FREQ 50
#define SERVO_RES LEDC_TIMER_16_BIT
void setup() {
ledc_timer_config_t timer_conf = {
.speed_mode = LEDC_LOW_SPEED_MODE,
.duty_resolution = SERVO_RES,
.timer_num = SERVO_TIMER,
.freq_hz = SERVO_FREQ,
.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 = SERVO_CHANNEL,
.timer_sel = SERVO_TIMER,
.duty = 0,
.hpoint = 0
};
ledc_channel_config(&ch_conf);
}
void setServo(int angle) {
uint32_t pulse = map(angle, 0, 180, 500, 2500); // us
uint32_t duty = pulse * 65535 / 20000;
ledc_set_duty(LEDC_LOW_SPEED_MODE, SERVO_CHANNEL, duty);
ledc_update_duty(LEDC_LOW_SPEED_MODE, SERVO_CHANNEL);
}
這裡很容易忘的一步是 ledc_update_duty():duty 設完要推一把才會生效,只寫 ledc_set_duty() 的話馬達完全不動。
電源設計注意事項

電源這件事,用一張表講最快:
| 伺服數量 | 建議電源 | 備註 |
|---|---|---|
| 1~2 顆 | 5V 1A 手機充電器 | 可直接從 USB 取電 |
| 3~6 顆 | 5V 3A 變壓器 | 使用獨立穩壓模組 |
| 7~12 顆 | 5V 5A 以上 | 需使用 5V 10A 電源 + 大電容 |
| 12+ 顆或 PCA9685 | 5V 10A+ / 6V 電池 | 需使用 LM2596 降壓穩壓 |
你可能會覺得一顆 9 公克的馬達能吃多少電?答案是:靜止時幾乎不吃,但一起動就是幾百毫安在跳。所以電源要用「瞬間峰值」去選,不是用平均值。
實務注意事項
- 獨立電源:伺服馬達不可從 ESP32 3.3V 取電,需獨立 5V 2A+ 電源並共地
- 大電容:伺服馬達啟動瞬間電流很大,電源端並聯 470~1000 uF 電解電容
- 訊號電平:ESP32 3.3V PWM 訊號對 SG90 完全相容(SG90 邏輯閾值約 1.5V)
- 死區:SG90 有約 2~5 us 的死區,角度控制精度約 0.2~0.5deg
- 抖動:PWM 頻率必須精確 50 Hz,頻率飄移會造成伺服抖動
- 保護:堵轉時持續供電會燒毀馬達或驅動晶片,建議加入電流檢測
- 金屬齒輪:SG90 為塑膠齒輪,頻繁使用建議更換 MG90S(金屬齒輪)
- 波特率限制:Servo.write() 內部使用 50 Hz 更新,每 20 ms 才能改變一次角度
常見問題
最後把幾個大家都會踩到的坑整理成一張對照表,出問題時先查這裡:
| 問題 | 原因 | 解法 |
|---|---|---|
| 伺服不動作 | 電源不足或 PWM 頻率錯誤 | 確認 5V 電源,確認 50 Hz |
| 伺服抖動 | PWM 不穩定或電源雜訊 | 加大電容,使用 LEDC 硬體 PWM |
| 角度不準 | 脈衝寬度偏差 | 使用 writeMicroseconds() 微調 |
| ESP32 重啟 | 伺服啟動電流拉低 3.3V | 獨立電源 + 共地 + 大電容 |
| Servo.h 編譯錯誤 | ESP32 Servo 函式庫衝突 | 使用 ESP32Saver/ServoESP32 或 LEDC API |
| 多軸不同步 | 多個 Servo 物件延遲 | 使用 LEDC 硬體通道同步 |
總結
繞了一圈回來看,ESP32 控制伺服馬達的路其實有三條,從最簡的 Servo 函式庫到底層 LEDC API,再到外掛 PCA9685。大多數應用用 Servo 函式庫就夠了;但當你需要精確同步或多軸控制時,LEDC 硬體 API 的效能與彈性會讓事情簡單很多。

把上圖的決策流程攤成文字,選型參考就是這四句話:
- 1~2 顆簡易控制:Servo 函式庫(寫入角度,自動管理 PWM)
- 4~8 軸專案:LEDC 底層 API(精確同步,硬體輸出)
- 8~16 軸以上:PCA9685 I2C 擴展(只需 2 條 I2C 線)
- 工業級 / 大扭力:改用 MG996R 或 Dynamixel 智慧伺服(數位通訊)
至於我自己的習慣:先確認電源規格,再決定控制方式。因為馬達會不會動是電源決定的,動得好不好看才是程式決定的 —— 順序顛倒過來,你會多花很多時間在除錯上面。
文章評論