← 返回教程库

舵机控制:让硬件转到指定角度

最后更新 2026-06-22
L2 · 传感与交互 ⏱ 约 16 分钟 🟡 涉接线/强电
你将学到
  • 用 ESP-IDF 的 MCPWM 外设驱动一个 SG90 舵机从 0° 扫到 180°
  • 讲透舵机和普通电机到底差在哪
  • 理解 50Hz 周期里"高电平脉宽"是怎么编码成角度的,并自己写出角度→脉宽换算
  • 避开"舵机抖动/ESP32-S3 重启(brownout)"的供电坑,并能按表排查
  • 知道多舵机场景该上 PCA9685
🛒 器材清单
器材数量参考
SG90 舵机1
杜邦线3

价格随渠道波动,以购买页实时为准。

想象一个机械臂要把零件从 A 点抓到 B 点,它的每个关节都得停在一个确切的角度上——多转 5° 就抓空了。这种"转到指定位置并稳稳停住"的活,普通电机干不了,舵机才是答案。智能门锁的拨片、舵机狗的腿、自动喂食器的出料口、相机云台,背后都是同一个东西在工作。

这一节我们接一个最常见的 SG90 小舵机,用 ESP-IDF 的 MCPWM 外设让它从 0° 扫到 180° 再扫回来,然后回头把"它凭什么能精准转到角度""为什么供电不当会让整块板子重启"这两件事讲清楚。

读这篇前,你应该已经啃过 PWM 与 LEDC 调光——那一节用 LEDC 控亮度,这一节我们换一个更适合舵机的外设 MCPWM,但底层都是"PWM 脉宽"那套逻辑。


舵机和普通电机,到底差在哪

这是很多人第一次接舵机时最糊涂的地方,先说透。

普通直流电机你给它通电,它就一直转,你只能控制它转多快、往哪个方向转,控制不了它停在哪。断电的瞬间它停在哪算哪,全凭惯性。

舵机不一样:它内部是"直流电机 + 减速齿轮 + 电位器 + 一块控制电路"打包在一起的闭环系统。你给它一个目标角度,它内部的电位器实时测量当前转到了哪,控制电路一边比对一边纠偏,直到精准转到你要的角度并主动顶住不动——你用手去掰它,它还会使劲转回去。

维度 普通直流电机 舵机
你能控制的 转速、方向 角度(位置)
能不能停在指定位置 不能 能,且主动保持
控制信号 通电/调速 特定脉宽的 PWM
典型角度范围 连续旋转 多为 0°~180°
用在哪 风扇、轮子、水泵 关节、舵面、云台、门锁拨片

一句话记住:电机管"转不转、转多快",舵机管"转到哪个角度"。 这一节学后者,下一节 直流电机驱动 学前者。

接线

SG90 这种小舵机三根线,颜色是行业惯例:

舵机 接到
棕色 GND 独立 5V 电源的 GND,并和 ESP32-S3 共地(见下方供电警告)
红色 VCC 独立 5V 电源的正极(见下方供电警告)
橙色 信号 任意输出 GPIO(本篇用 GPIO13)

棕红橙这三色几乎是 SG90 的标配。有些牌子用黑/红/黄或黑/红/白,但中间那根永远是正极,两边分别是地线和信号线,对不上颜色时按这个规律判断。

⚠️ 安全

接线顺序很重要:先确认舵机的红线接的是独立 5V、不是 ESP32-S3 的引脚,再上电。为什么不能从板子取电、"共地"是什么意思,下面《供电》一节专门讲透——这是控舵机第一大坑,请务必看完再通电。

让它转:用 MCPWM 发舵机信号

控舵机本质就是给信号线持续发一串 50Hz、脉宽随角度变化的 PWM。ESP-IDF 里发这种"按周期 + 比较点切高低电平"的信号,最趁手的外设是 MCPWM(Motor Control PWM,乐鑫专门为电机/舵机这类场景做的外设)。

先把 MCPWM 的几个零件认一下,它们是层层搭起来的:

  • timer(定时器):定义"一个周期多长"。我们让它分辨率 1µs(resolution_hz = 1000000,即 1 tick = 1µs)、周期 20000 ticks = 20ms = 50Hz。
  • operator(运算器):挂到 timer 上,是 generator 的载体。
  • comparator(比较器):设一个"比较值",timer 数到这个值时触发一个事件——我们用它来控制脉宽。
  • generator(发生器):真正把电平发到 GPIO 的角色,由"周期开始""比较点命中"这两类事件来决定何时拉高、何时拉低。

把它们组合起来:周期一开始就拉高,数到比较值就拉低——于是"高电平持续的时长"就等于比较值(µs),这正是舵机要的脉宽。改比较值,就改了角度。

下面是完整能跑的版本,放进工程的 main/main.c,信号线接 GPIO13:

// ESP-IDF 5.x:用 MCPWM 驱动 SG90 舵机从 0° 扫到 180° 再扫回来
// 参考实现,需结合你的板子/舵机自行验证脉宽边界
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "driver/mcpwm_prelude.h"

static const char *TAG = "servo";

#define SERVO_GPIO          13          // 舵机信号线接的 GPIO
#define SERVO_MIN_PULSE_US  500         // 0°   对应的脉宽(µs)
#define SERVO_MAX_PULSE_US  2500        // 180° 对应的脉宽(µs)
#define SERVO_MAX_ANGLE     180

#define TIMEBASE_RES_HZ     1000000     // 1µs 一个 tick
#define TIMEBASE_PERIOD     20000       // 20000 ticks = 20ms = 50Hz

// 角度(0~180)线性映射成脉宽(µs)
static inline uint32_t angle_to_pulse_us(int angle)
{
    return SERVO_MIN_PULSE_US +
           (uint32_t)(angle * (SERVO_MAX_PULSE_US - SERVO_MIN_PULSE_US) / SERVO_MAX_ANGLE);
}

void app_main(void)
{
    // 1. 建 timer:定义 20ms 周期(50Hz),1µs 分辨率
    mcpwm_timer_handle_t timer = NULL;
    mcpwm_timer_config_t timer_cfg = {
        .group_id      = 0,
        .clk_src       = MCPWM_TIMER_CLK_SRC_DEFAULT,
        .resolution_hz = TIMEBASE_RES_HZ,
        .count_mode    = MCPWM_TIMER_COUNT_MODE_UP,
        .period_ticks  = TIMEBASE_PERIOD,
    };
    ESP_ERROR_CHECK(mcpwm_new_timer(&timer_cfg, &timer));

    // 2. 建 operator 并 connect 到 timer
    mcpwm_oper_handle_t oper = NULL;
    mcpwm_operator_config_t oper_cfg = { .group_id = 0 };
    ESP_ERROR_CHECK(mcpwm_new_operator(&oper_cfg, &oper));
    ESP_ERROR_CHECK(mcpwm_operator_connect_timer(oper, timer));

    // 3. 建 comparator:比较值就是脉宽(µs)
    mcpwm_cmpr_handle_t comparator = NULL;
    mcpwm_comparator_config_t cmp_cfg = { .flags.update_cmp_on_tez = true };
    ESP_ERROR_CHECK(mcpwm_new_comparator(oper, &cmp_cfg, &comparator));

    // 4. 建 generator:把信号发到 GPIO13
    mcpwm_gen_handle_t generator = NULL;
    mcpwm_generator_config_t gen_cfg = { .gen_gpio_num = SERVO_GPIO };
    ESP_ERROR_CHECK(mcpwm_new_generator(oper, &gen_cfg, &generator));

    // 5. 设动作:周期开头(timer 归零)拉高,数到比较值时拉低
    //    → 高电平时长 == 比较值(µs) == 脉宽
    ESP_ERROR_CHECK(mcpwm_generator_set_actions_on_timer_event(generator,
        MCPWM_GEN_TIMER_EVENT_ACTION(MCPWM_TIMER_DIRECTION_UP,
            MCPWM_TIMER_EVENT_EMPTY, MCPWM_GEN_ACTION_HIGH),
        MCPWM_GEN_TIMER_EVENT_ACTION_END()));
    ESP_ERROR_CHECK(mcpwm_generator_set_actions_on_compare_event(generator,
        MCPWM_GEN_COMPARE_EVENT_ACTION(MCPWM_TIMER_DIRECTION_UP,
            comparator, MCPWM_GEN_ACTION_LOW),
        MCPWM_GEN_COMPARE_EVENT_ACTION_END()));

    // 6. 启动 timer
    ESP_ERROR_CHECK(mcpwm_timer_enable(timer));
    ESP_ERROR_CHECK(mcpwm_timer_start_stop(timer, MCPWM_TIMER_START_NO_STOP));

    ESP_LOGI(TAG, "舵机已就绪,开始来回扫角度");

    while (1) {
        for (int a = 0; a <= 180; a++) {        // 0° 扫到 180°
            uint32_t us = angle_to_pulse_us(a);
            ESP_ERROR_CHECK(mcpwm_comparator_set_compare_value(comparator, us));
            vTaskDelay(pdMS_TO_TICKS(15));      // 每度停 15ms,扫完约 2.7 秒
        }
        for (int a = 180; a >= 0; a--) {        // 再扫回来
            uint32_t us = angle_to_pulse_us(a);
            ESP_ERROR_CHECK(mcpwm_comparator_set_compare_value(comparator, us));
            vTaskDelay(pdMS_TO_TICKS(15));
        }
    }
}

在工程目录下编译、烧录、看日志:

idf.py build flash monitor

转一次角度只要一行 mcpwm_comparator_set_compare_value(comparator, us)——改比较值就改脉宽,脉宽变了角度就变。前面那五步配置是一次性的搭台,搭好后整段控制逻辑就这么简洁。

你应该看到什么

  • 编译烧录完进入串口监视,先打印一行 舵机已就绪,开始来回扫角度
  • 舵机的摇臂会匀速地从一头慢慢扫到另一头,再扫回来,循环不停。
  • vTaskDelay(pdMS_TO_TICKS(15)) 改大(比如 30),它扫得更慢;改小(比如 5),扫得更快。
  • 如果它只在那"嗡嗡"地抖、不平滑转动,或者串口在刷开机信息——大概率是供电问题,往下看那张排查表。
💡 提示

比较值是"目标脉宽",但舵机转过去需要时间(SG90 转 60° 大约要 0.1 秒)。所以每改一次角度都得 vTaskDelay 等一下——如果你不等、连着猛改比较值,舵机会在还没转到位时就收到下一个目标,看起来就像它没反应或者乱跳。

💡 提示

这套 MCPWM 不是控舵机的唯一选择。你也可以用上一节的 LEDC 设成 50Hz、再把占空比算成 脉宽 / 20ms(比如 1.5ms 脉宽对应 7.5% 占空比)来发同样的信号。能跑,但 LEDC 的占空比按位分辨率给,换算到几百微秒的脉宽时精度/分辨率不如 MCPWM 直接拿微秒当比较值来得干净。所以舵机优先用 MCPWM,LEDC 是临时凑合或 MCPWM 资源被占满时的次选。

角度是怎么"算"出来的:50Hz 的脉宽游戏

代码帮你发好了信号,但理解原理你才能在它不灵时知道往哪查,也才知道上面那个 angle_to_pulse_us 为什么这么写。

舵机的控制信号是一种特定频率的 PWM:每 20 毫秒发一个脉冲(也就是 50Hz,1 ÷ 0.02s = 50)。关键不在频率,而在每个脉冲"高电平持续多久"——这个时长(脉宽)才是用来编码角度的:

脉宽(高电平时长) 舵机转到
约 0.5ms(500µs)
约 1.5ms(1500µs) 90°(正中)
约 2.5ms(2500µs) 180°

所以"控制角度"的本质,就是在固定的 50Hz 节拍里,调整每个脉冲的宽度。这也正是上面 MCPWM 配置干的事:timer 把周期固定成 20ms,comparator 的值就是高电平那段的微秒数。你要 90°,就把比较值设成 1500,generator 在周期开头拉高、数到 1500µs 拉低——一个 1.5ms 宽的脉冲就成型了,源源不断喂给信号线,舵机内部电路解读这个脉宽、转到对应角度并顶住。

角度到脉宽是线性映射,代码里那行换算就是它:

us = 500 + angle * (2500 - 500) / 180;   // 0°→500µs, 90°→1500µs, 180°→2500µs

这和上一节 PWM 原理 里"调亮度"是同一个 PWM,只是用法不同:调亮度看的是占空比的平均效果(亮度正比于高电平占整周期的比例),控舵机看的是每个脉冲的绝对宽度(脉宽几微秒直接对应几度)。同一个工具,两种玩法。

💡 提示

不同品牌舵机的脉宽边界略有出入,有的 0° 对应 0.6ms(600µs)、180° 对应 2.4ms(2400µs)。本篇按通用的 500~2500µs 来,绝大多数舵机够用。万一你的舵机转不到头或越界顶住异响,把代码里的 SERVO_MIN_PULSE_US/SERVO_MAX_PULSE_US 两个宏改成你舵机的实际边界即可校准。

供电:新手控舵机第一大坑

⚠️ 安全

别用 ESP32-S3 的 3.3V 或开发板引脚直接驱动舵机。 舵机启动/堵转的瞬间电流很大,会把开发板的供电拉垮,触发 ESP32-S3 的欠压保护(brownout)复位或让舵机疯狂抖动。正确做法:舵机用独立 5V 电源(或足够电流的 5V 输出),并且舵机电源的 GND 必须和 ESP32-S3 共地,否则信号不通。这是新手控舵机第一大坑。

把这条坑再说细一点,免得你照做了还踩。

为什么会拉垮? SG90 空转时只吃几十毫安,但它一启动、尤其堵转(被卡住转不动)的瞬间,电流能冲到几百毫安甚至更高。ESP32-S3 开发板那个 5V/3.3V 引脚是给芯片自己供电的,挤不出这么大电流。舵机一抢电,板子电压瞬间塌陷,ESP32-S3 的欠压检测电路(brownout detector)一看电压低于阈值就强制复位——表现就是你的串口一闪一闪不停打印 Brownout detector was triggered 和开机信息。

"共地"是什么意思、为什么不能省? 你用了独立电源给舵机,舵机这一路有自己的"地";ESP32-S3 发信号也是相对它自己的"地"来定高低电平的。如果两个"地"不连在一起,舵机就读不懂 ESP32-S3 发的脉宽信号——因为它俩对"0V 在哪"没有共识。所以独立 5V 的负极必须和 ESP32-S3 的 GND 用一根线连起来,信号才有共同参考。这就是"共地"。

记住这条接线原则:电源正极各走各的(舵机走独立 5V),地必须汇到一起。

故障排查:照这张表查

控舵机出问题,九成是供电和接线,照顺序查:

现象 最可能的原因 怎么办
舵机疯狂抖动、不平滑 供电不足,电压被拉垮 换独立 5V 电源驱动舵机,别用板子的引脚供电
串口刷 Brownout detector was triggered、反复重启 舵机抢电导致欠压复位 同上,舵机独立供电;确认 5V 电源电流够(≥1A 起步)
舵机完全不动 信号线没接对 / 没共地 确认橙线接的是代码里的 SERVO_GPIO(13);独立供电时检查 GND 是否和 ESP32-S3 连通
转,但转不到 0° 或 180° 不同舵机脉宽边界不同 SERVO_MIN_PULSE_US/SERVO_MAX_PULSE_US 两个宏校准
转到一半就卡住、有异响 机械堵转 / 摇臂被卡 关电,手动确认摇臂能自由活动,别让它顶到结构件
idf.py buildmcpwm_prelude.h 找不到 ESP-IDF 版本太旧 MCPWM 新驱动需 ESP-IDF 5.x;确认 idf.py --version 是 5.x 并 idf.py set-target esp32s3
ESP_ERROR_CHECK 在某句 abort 该 MCPWM 资源已被占用/参数越界 看串口打印的报错行定位是哪一步;group/timer 别和工程里其他外设冲突

多舵机:该上 PCA9685 了

一个舵机直连一个 GPIO、占一组 MCPWM 资源没问题。但当你做机械臂(4~6 个舵机)甚至人形(十几个)时,问题就来了:GPIO 不够用、MCPWM 资源有限、供电更撑不住、代码里一堆配置也乱。

这时候上 PCA9685 这块 16 路 PWM 驱动板

  • 它只占用 ESP32-S3 两根线(I2C 的 SDA/SCL),却能同时控制 16 个舵机,省 GPIO 也省片内 MCPWM 资源。
  • PWM 由板上专用芯片生成,比软件挨个发更稳、更整齐。
  • 配上独立的大电流 5V 电源接到驱动板的电源端子,统一给所有舵机供电——彻底解决"板子供电带不动"的问题。

入门阶段两三个舵机直连 GPIO 完全够玩,知道"多了要上 PCA9685"即可,到真做机械臂时再深入。

选型一句话

  • SG90(塑料齿)便宜、力小,适合轻负载入门;MG90S(金属齿)更结实,堵转也不易崩齿。
  • 力气不够、带不动负载,先想是不是该换金属齿/更大扭矩的型号,而不是硬怼电压。

动手挑战

别只看着它扫,动手改两个:

  1. 旋钮控舵机:接一个电位器到某个 ADC 输入脚(用 adc_oneshot 读它的值),把读数映射成 0~180°,再换算成脉宽喂给 comparator——拧旋钮,舵机就跟着转。这是"输入驱动输出"的经典组合(ADC 读取在读模拟量那节细讲)。
  2. 扫描雷达雏形:让舵机像上面的扫角度代码那样来回扫,每停一个角度就读一次距离(如果你手上有超声波模块),用 ESP_LOGI(TAG, "angle=%d dist=%dcm", a, d) 把"角度 + 距离"打到串口。这就是扫地机、避障小车的探测原型。

卡住了?把你的接线和想要的效果一起描述给 AI,让它帮你把代码改出来,再回到上面那张排查表逐项核对。

小结 · 你现在掌握了什么

  • 你能用 ESP-IDF 的 MCPWM 外设让 SG90 精准转到指定角度,会"跳转"也会"平滑扫角度"。
  • 你讲得清舵机和电机的根本区别:一个管位置、一个管转速。
  • 你理解了 50Hz 的 PWM 是靠脉宽(500~2500µs)来编码角度的,能自己写出角度→脉宽的线性换算,也明白它和 LEDC 调亮度是同一个 PWM 的两种用法。
  • 你知道了控舵机第一大坑是供电——独立 5V、必须共地——也能照表排查抖动和 brownout 重启。
  • 你知道多舵机场景要上 PCA9685。

下一步:舵机是"精准定位",下一节学"持续转动"的 直流电机驱动,把执行器这块补全。

📄 来源 / 自校链接

本文为公开资料整理,非亲测。关键参数与代码请结合实物与下列官方来源验证。

内容有错、看不懂、或想看下一期?告诉我们 →

本文为公开资料的学习整理,非亲测。涉接线/花钱/合规的步骤请结合实物与官方最新资料验证,风险自负。见免责声明