传感器_03:电机驱动——让 GPIO 当指挥官,别让 GPIO 当电源

适用人群:刚能点亮 LED、能用 PWM 调个灯,但第一次想让"东西动起来"的同学。你会调 ledc,也知道 GPIO 有 3.3 V,可一接电机 MCU 就重启,甚至 IO 直接冒烟。

没看过本专栏 01/02 篇也能读:前两篇讲的是传感器怎么"读数",这篇讲的是执行器怎么"出力"------传感器是耳朵,执行器是手脚。

读完你能得到:

① 彻底搞清 GPIO 为什么不能直接驱动电机;

② H 桥原理 + TB6612 / DRV8833 选型 + PWM 调速的 LEDC/MCPWM 代码;

③ 50 Hz 舵机:脉冲宽度怎么映射到角度,供电与共地怎么做;

④ 28BYJ-48(ULN2003)与 A4988 步进驱动代码,以及"为什么步进电机不能一下提速到最高";

⑤ 续流、栅极下拉、大电流走线、电流采样这些面试常问的工程常识;

⑥ 我踩过的 N 个坑,和若干故意试错实验。


一、为什么电机不能直接接 GPIO

先说我的真实翻车现场。

大三上我想做个自动喂食器,齿轮箱带一个小转盘。我拆了个旧的 5 V 直流减速电机,直接把红线接 ESP32 的 GPIO25,黑线接 GND,写了几句 gpio_set_level(25, 1)。按下复位键,电机"咔"地转了一下------然后开发板就黑了,再按复位没反应。我以为是板子坏了,换了一块,接上去又是一下黑屏。第二块 GPIO25 从那以后只能输出 1.6 V 左右,基本废了。

问题不在代码,在我把 GPIO 当电源用。

1.1 三条越界红线

红线 GPIO 能给的 电机需要的 后果
电压 3.3 V(或 5 V USB 经 LDO) 常见 3 V / 5 V / 12 V 电压不够 → 转不动
电流 乐鑫手册:单个 IO 最大 40 mA 源出 / 28 mA 灌入GPIO_DRIVE_CAP_3,绝对最大值) 普通 3 V 直流电机启动/堵转可达几百 mA 电流一冲 → brownout 复位或 IO 烧坏
电感反冲 GPIO 输出没有续流保护 电机线圈断电时 V = -L·di/dt,反冲几十伏 直接击穿 MCU 内部保护二极管

⚠️ 注意:40 mA / 28 mA 是 datasheet 的绝对最大值,不是推荐工作值。对 ESP32 而言,持续驱动几十 mA 以上就该考虑外部驱动。

这和 低功耗_03 里那句"GPIO 是信号不是电源"是同一件事------那篇从功耗角度说,这篇从功率角度说。

1.2 反冲电压到底多吓人

电机线圈不是电阻,是电感。电感电流不能突变,你突然关断 GPIO,电流必须找地方走,于是线圈两端会感应出一个高压尖峰:

text 复制代码
  V = -L · (Δi / Δt)

  L 可能只有几百 µH,但 Δt 是 ns 级
  结果:尖峰轻松到几十伏,远超 GPIO 的 VCC+0.3 V 上限

除非你在电机两端并联一个续流二极管(flyback diode),给这股电流一条回路。GPIO 引脚里没有这个二极管,所以反冲直接打到芯片内部。

1.3 正确的角色分工

角色 谁干 走什么线
指挥官 MCU GPIO 细号线,只传"开/关/方向/转速"
电源开关 驱动芯片 / MOSFET / 继电器 粗线或大铜皮,走电机电流
能量来源 独立电源或稳压器 电源按电机的功率设计

核心原则:信号和功率分开,但所有地必须共在一起。 不共地,信号就没有参考点,驱动芯片根本不知道你 GPIO 高电平是几伏。


二、直流电机(DC):H 桥、驱动芯片与 PWM 调速

2.1 H 桥:用四个开关控制方向和刹车

直流电机只有两根线,电流从 A→B 转一个方向,从 B→A 就反向。怎么做到双向?答案是把四个开关(MOSFET)摆成 H 形:

text 复制代码
              VM
              │
         ┌────┴────┐
    Q1   │         │   Q2
   ┌─────┤         ├─────┐
   │     │         │     │
   │     │  MOTOR  │     │
   │     │         │     │
   │     │         │     │
   └─────┤         ├─────┘
    Q4   │         │   Q3
         └────┬────┘
              │
             GND

  正转:Q1、Q3 导通,电流 VM → Q1 → MOTOR → Q3 → GND
  反转:Q2、Q4 导通,电流 VM → Q2 → MOTOR → Q4 → GND
  刹车:Q3、Q4 导通,电机两端被短路到 GND,转子的动能快速耗掉
  滑行:四个全关,电机自由转

绝对不能出现的是"同臂导通" :比如 Q1 和 Q4 同时打开,电源直接通过这两个 MOS 短路到地,瞬间电流只受 MOS 内阻限制,芯片会立刻炸。专业术语叫 shoot-through(直通/穿通)

这也是驱动芯片比自己搭 H 桥更省心的地方------TB6612、DRV8833 内部都有死区时间互锁逻辑,从输入端就不可能让同臂 MOS 同时导通。

2.2 TB6612FNG 与 DRV8833 怎么选

这两块板在国产模块市场最常见,都是双 H 桥,都能同时或分别驱动两个直流电机。

参数 TB6612FNG DRV8833
电机供电 VM 2.5 V ~ 13.5 V 2.7 V ~ 10.8 V
逻辑供电 VCC/VDD 2.7 V ~ 5.5 V 1.8 V ~ 7 V(逻辑)
单桥连续电流 1.2 A 1.5 A RMS(PWP/RTY 封装);0.5 A RMS(PW 小封装)
单桥峰值电流 3.2 A 2 A
驱动 MOS 内阻 约 0.5 Ω(@5 V, 1 A) 360 mΩ(HS+LS)
过流/过温保护 无过流指示,有过热关断 有过流、短路、欠压、过温保护
睡眠模式 STBY 低电平进入待机 nSLEEP 低电平进入睡眠
电流限制 无内置电流环 有(通过 xISEN 采样电阻 + 200 mV 内部基准)

💡 选型建议 :7.2 V / 12 V 小车用 TB6612;5 V 以下锂电池小项目用 DRV8833。但买 DRV8833 模块时一定看清封装/板子参数,有的模块用 PW 小封装,单桥只能 0.5 A,硬带 1 A 电机会发烫。

TB6612FNG 单通道控制真值表(以通道 A 为例;STBY 低电平时全部待机):

IN1 IN2 PWM STBY OUT1 OUT2 模式
H L H H H L 正转(CW)
L H H H L H 反转(CCW)
H H H/L H L L 短刹车(Short brake)
L L H/L H OFF OFF 停止(Stop / 滑行)
X X X L OFF OFF 待机(Standby)

⚠️ 注意:PWM 引脚低电平时,IN1/IN2 不同向也会进刹车(见表中 L/H + PWM=L)。所以实际调速时,IN1/IN2 只决定方向,PWM 决定"给多少能量"。

2.3 续流:为什么芯片没炸还能继续跑

直流电机 PWM 调速时,MOS 管每个周期都在开关。关断瞬间电机线圈里的电流必须有去处,否则就会感应出高压。H 桥里的处理办法通常叫 decay mode(衰减模式)

  • 慢衰减(slow decay):电机两端被短接到地或电源,电流在线圈里自己慢慢耗掉。
  • 快衰减(fast decay):电流通过 MOS 的体二极管流回电源或地,衰减快但纹波大。

TB6612FNG 内部输出级是 MOS,体二极管已经能完成续流,模块上通常不再额外加二极管;DRV8833 数据手册更是明确说电流通过 body diodes 回流。如果你的驱动芯片是继电器或晶体管 H 桥 ,那续流二极管就必须外接,否则第一个 PWM 周期就可能把器件打坏。


2.4 ESP32 LEDC 控制 TB6612:一份能编过的代码

先算分辨率。ESP32 的 LEDC 频率和分辨率互相牵制:

text 复制代码
  计数器最高位宽:ESP32 为 20 bit;ESP32-S3/C3 为 14 bit
  频率 20 kHz、APB 80 MHz 时:
      20 kHz 周期 = 4000 个 tick → 约 12 bit 可用
  这里取 11 bit(2048 级)留点余量,让 ledc_timer_config 更容易成功

接线:

text 复制代码
  ESP32 GPIO18 ──→ TB6612 AIN1
  ESP32 GPIO19 ──→ TB6612 AIN2
  ESP32 GPIO21 ──→ TB6612 PWMA
  ESP32 GPIO22 ──→ TB6612 STBY
  TB6612 VM  ────→ 电机电源(例 7.4 V 2S 锂电)
  TB6612 VCC ────→ 3.3 V
  共 GND(ESP32 GND ↔ TB6612 GND ↔ 电池负极)

代码:

c 复制代码
// dc_motor.c ------ ESP-IDF v5.x,TB6612FNG 单路直流电机
#include "driver/gpio.h"
#include "driver/ledc.h"
#include "esp_log.h"
#include "esp_check.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

#define MOTOR_AIN1      GPIO_NUM_18
#define MOTOR_AIN2      GPIO_NUM_19
#define MOTOR_PWMA      GPIO_NUM_21
#define MOTOR_STBY      GPIO_NUM_22

#define LEDC_TIMER      LEDC_TIMER_0
#define LEDC_MODE       LEDC_LOW_SPEED_MODE
#define LEDC_CHANNEL    LEDC_CHANNEL_0
#define PWM_FREQ_HZ     20000
#define PWM_RES         LEDC_TIMER_11_BIT     // 2048 级
#define PWM_MAX_DUTY    2048

static const char *TAG = "dc_motor";

esp_err_t dc_motor_init(void)
{
    gpio_config_t io = {
        .pin_bit_mask = (1ULL << MOTOR_AIN1) | (1ULL << MOTOR_AIN2) | (1ULL << MOTOR_STBY),
        .mode = GPIO_MODE_OUTPUT,
        .pull_up_en = GPIO_PULLUP_DISABLE,
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .intr_type = GPIO_INTR_DISABLE,
    };
    ESP_RETURN_ON_ERROR(gpio_config(&io), TAG, "gpio config failed");

    /* STBY 拉高,否则芯片一直睡觉 */
    ESP_RETURN_ON_ERROR(gpio_set_level(MOTOR_STBY, 1), TAG, "stby high failed");
    ESP_RETURN_ON_ERROR(gpio_set_level(MOTOR_AIN1, 0), TAG, "ain1 init failed");
    ESP_RETURN_ON_ERROR(gpio_set_level(MOTOR_AIN2, 0), TAG, "ain2 init failed");

    ledc_timer_config_t tcfg = {
        .speed_mode       = LEDC_MODE,
        .timer_num        = LEDC_TIMER,
        .duty_resolution  = PWM_RES,
        .freq_hz          = PWM_FREQ_HZ,
        .clk_cfg          = LEDC_AUTO_CLK,
    };
    ESP_RETURN_ON_ERROR(ledc_timer_config(&tcfg), TAG, "ledc timer config failed");

    ledc_channel_config_t ccfg = {
        .gpio_num       = MOTOR_PWMA,
        .speed_mode     = LEDC_MODE,
        .channel        = LEDC_CHANNEL,
        .timer_sel      = LEDC_TIMER,
        .duty           = 0,
        .hpoint         = 0,
    };
    ESP_RETURN_ON_ERROR(ledc_channel_config(&ccfg), TAG, "ledc channel config failed");

    return ESP_OK;
}

/* speed: -1000 ~ +1000;0 为停止 */
esp_err_t dc_motor_set(int16_t speed)
{
    if (speed < -1000) speed = -1000;
    if (speed >  1000) speed =  1000;

    if (speed == 0) {
        ESP_RETURN_ON_ERROR(gpio_set_level(MOTOR_AIN1, 0), TAG, "stop AIN1");
        ESP_RETURN_ON_ERROR(gpio_set_level(MOTOR_AIN2, 0), TAG, "stop AIN2");
        ESP_RETURN_ON_ERROR(ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, 0), TAG, "duty 0");
        ESP_RETURN_ON_ERROR(ledc_update_duty(LEDC_MODE, LEDC_CHANNEL), TAG, "update 0");
        return ESP_OK;
    }

    /* 先刹车到 0 再换向,避免高速切换方向的大电流冲击 */
    static int16_t s_last_speed = 0;
    if ((s_last_speed > 0 && speed < 0) || (s_last_speed < 0 && speed > 0)) {
        ESP_RETURN_ON_ERROR(gpio_set_level(MOTOR_AIN1, 1), TAG, "brake AIN1");
        ESP_RETURN_ON_ERROR(gpio_set_level(MOTOR_AIN2, 1), TAG, "brake AIN2");
        ESP_RETURN_ON_ERROR(ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, PWM_MAX_DUTY), TAG, "brake duty");
        ESP_RETURN_ON_ERROR(ledc_update_duty(LEDC_MODE, LEDC_CHANNEL), TAG, "brake update");
        vTaskDelay(pdMS_TO_TICKS(50));
    }
    s_last_speed = speed;

    uint32_t duty = (uint32_t)((uint32_t)PWM_MAX_DUTY * (uint32_t)__builtin_abs(speed) / 1000U);

    if (speed > 0) {
        ESP_RETURN_ON_ERROR(gpio_set_level(MOTOR_AIN1, 1), TAG, "fwd AIN1");
        ESP_RETURN_ON_ERROR(gpio_set_level(MOTOR_AIN2, 0), TAG, "fwd AIN2");
    } else {
        ESP_RETURN_ON_ERROR(gpio_set_level(MOTOR_AIN1, 0), TAG, "rev AIN1");
        ESP_RETURN_ON_ERROR(gpio_set_level(MOTOR_AIN2, 1), TAG, "rev AIN2");
    }

    ESP_RETURN_ON_ERROR(ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, duty), TAG, "set duty");
    ESP_RETURN_ON_ERROR(ledc_update_duty(LEDC_MODE, LEDC_CHANNEL), TAG, "update duty");
    return ESP_OK;
}

⚠️ dc_motor_set() 里的"先刹车再换向"不是强迫症。直流电机转起来有反电动势,高速时直接反接相当于电源 + 反电动势串联,启动电流能达到堵转电流的几倍,驱动芯片会过热保护甚至烧。正确流程:减速 → 停稳 → 换向 → 提速。
💡 如果你想用 ESP32 的 MCPWM(带死区)驱动 H 桥,或者做有刷电机闭环,需要看 MCPWM 那套 API。对入门项目,LEDC 够用,代码也更短。
💡 ESP-IDF 的 servo 例程或 Arduino 的 Servo 库底层也是 LEDC/MCPWM,只是帮你封装好了角度转换。如果你想搞懂脉宽怎么算出来的,建议先手写 LEDC;等调通了再换封装也不迟。

2.5 什么时候测电流?怎么测?

只要开始带电机,就必须关心电流。常用两种办法:

  1. 采样电阻 + 运放:在 H 桥下桥串一个 0.1 Ω 电阻,用差分运放放大后送 ADC。TB6612 没有内置采样脚,DRV8833 有 xISEN。
  2. 霍尔电流传感器:如 ACS712,隔离式、不串入功率地,适合学生实验。

没有电流数据,你就不知道电机是不是在堵转,也不知道驱动芯片有没有在热保护边缘。


三、舵机(Servo):不是角度传感器,是自带闭环的电机

舵机和直流电机的最大区别:舵机自己知道现在转到哪了。它内部有电机、减速齿轮、电位器和比较电路。你给它的不是"电压",而是一个 PWM 脉冲------它自己把脉冲宽度转成目标角度,然后驱动电机去追那个位置。

3.1 50 Hz PWM:周期 20 ms,脉宽才是命令

标准舵机信号:

text 复制代码
  频率:50 Hz(周期 20 ms)
  高电平脉宽:0.5 ms ~ 2.5 ms

  0.5 ms ────── 0°
  1.0 ms ────── 45°
  1.5 ms ────── 90°
  2.0 ms ────── 135°
  2.5 ms ────── 180°

⚠️ SG90 数据手册给的是 500 µs ~ 2400 µs 对应 0° ~ 180°。网上很多教程写 0.5 ms ~ 2.5 ms 也能用,但把舵机推到极限位置容易让它一直堵转、发热。建议程序里把范围限到 600 µs ~ 2300 µs,给机械结构留余量。

3.2 用 LEDC 产生舵机信号

LEDC 的分辨率怎么选?以 ESP32、50 Hz 为例:

text 复制代码
  目标:20 ms 周期里能分辨约 10 µs(0.1° 量级)
  20 ms / 10 µs = 2000 级 → 11 bit(2048 级)就够
  50 Hz 下 ESP32 可达 16 bit,ESP32-S3/C3 可达 13~14 bit,
  这里选 13 bit(8192 级)兼容两者

代码:

c 复制代码
// servo.c ------ ESP-IDF v5.x,LEDC 驱动 SG90 舵机
#include "driver/gpio.h"
#include "driver/ledc.h"
#include "esp_check.h"

#define SERVO_GPIO      GPIO_NUM_27
#define SERVO_FREQ_HZ   50
#define SERVO_RES       LEDC_TIMER_13_BIT    // 8192 级
#define SERVO_CHANNEL   LEDC_CHANNEL_1
#define SERVO_TIMER     LEDC_TIMER_1
#define SERVO_MODE      LEDC_LOW_SPEED_MODE

#define DUTY_MIN_US     600     // 安全下限
#define DUTY_MAX_US     2300    // 安全上限
#define PERIOD_US       20000

static uint32_t s_max_duty;

esp_err_t servo_init(void)
{
    s_max_duty = (1U << SERVO_RES);

    ledc_timer_config_t t = {
        .speed_mode      = SERVO_MODE,
        .timer_num       = SERVO_TIMER,
        .duty_resolution = SERVO_RES,
        .freq_hz         = SERVO_FREQ_HZ,
        .clk_cfg         = LEDC_AUTO_CLK,
    };
    ESP_RETURN_ON_ERROR(ledc_timer_config(&t), "servo", "timer cfg");

    ledc_channel_config_t c = {
        .gpio_num   = SERVO_GPIO,
        .speed_mode = SERVO_MODE,
        .channel    = SERVO_CHANNEL,
        .timer_sel  = SERVO_TIMER,
        .duty       = 0,
        .hpoint     = 0,
    };
    ESP_RETURN_ON_ERROR(ledc_channel_config(&c), "servo", "channel cfg");

    /* 默认回到 90° */
    return servo_write_us(1500);
}

/* 输入:脉宽 µs */
esp_err_t servo_write_us(uint32_t pulse_us)
{
    if (pulse_us < DUTY_MIN_US) pulse_us = DUTY_MIN_US;
    if (pulse_us > DUTY_MAX_US) pulse_us = DUTY_MAX_US;

    uint32_t duty = (uint32_t)(((uint64_t)pulse_us * s_max_duty) / PERIOD_US);

    ESP_RETURN_ON_ERROR(ledc_set_duty(SERVO_MODE, SERVO_CHANNEL, duty),
                        "servo", "set duty");
    ESP_RETURN_ON_ERROR(ledc_update_duty(SERVO_MODE, SERVO_CHANNEL),
                        "servo", "update duty");
    return ESP_OK;
}

/* 输入:0 ~ 180 */
esp_err_t servo_write_angle(uint8_t angle)
{
    if (angle > 180) angle = 180;
    uint32_t pulse = DUTY_MIN_US
                   + ((DUTY_MAX_US - DUTY_MIN_US) * (uint32_t)angle) / 180U;
    return servo_write_us(pulse);
}

3.3 供电与共地:最容易被忽略的两大杀手

供电:SG90 空载才几十 mA,但启动/堵转瞬间能到 200 mA 以上。ESP32 开发板上的 3.3 V LDO 或 USB 5 V 输出根本扛不住,舵机一转,板子电压被拉低,MCU 直接 brownout 重启。

正确接法

text 复制代码
  独立 5 V/1 A 电源 ──→ 舵机 VCC(红线)
  独立电源 GND  ──────→ ESP32 GND(黑/棕线)← 必须共地
  ESP32 GPIO27 ───────→ 舵机信号线(黄/橙线)

⚠️ 舵机信号线只走信号电流 ,μA 级;VCC 和 GND 才是功率回路。红线接 GPIO、黑线接 GND 是经典错误,会直接烧 IO。

共地:如果你用独立电源但不把电源地和 ESP32 地连起来,舵机内部比较器看到的"高电平"没有参考点,它会乱抖、尖叫,甚至根本不转。很多学生把电源分开后忘了这根线,舵机表现就像"程序有 bug",其实是硬件问题。


四、步进电机(Stepper):开环就能定位的秘密

步进电机的特点:一个脉冲走一步。你不用编码器也知道它转了多少------只要脉冲没丢、没超过转子能跟上的最大速度。

4.1 28BYJ-48 + ULN2003:新手最便宜的步进实验

28BYJ-48 是个 5 V 的四相五线单极性步进电机,参数:

参数 说明
额定电压 5 VDC 实际 4~6 V 都能转
相数 4 相 但共用一个中心抽头
减速比 1/64 内部转子转 64 圈,输出轴转 1 圈
步距角 5.625° / 64 转子每步 5.625°,经减速后输出轴约 0.0879°
单相电阻 50 Ω ± 7% 5 V 下约 100 mA/相

ULN2003 是 7 路达林顿阵列,每路最大 500 mA,输出端内部带共阴极钳位二极管。用 4 路驱动 28BYJ-48 时,COM 脚必须接到电机电源正极(5 V),这样感性电流才有回路。

💡 3.3 V GPIO 能不能直接驱动 ULN2003A?28BYJ-48 单相约 100 mA,ULN2003A 在 250 mA 时的导通输入电压最大 2.7 V,所以 3.3 V 完全够用。如果你要推更大的电流(接近 300 mA 以上),建议测一下输入电流或加电平转换。

接线:

text 复制代码
  ESP32 GPIO13 ──→ ULN2003 IN1 ──→ 电机 Orange(A 相)
  ESP32 GPIO12 ──→ ULN2003 IN2 ──→ 电机 Yellow(B 相)
  ESP32 GPIO14 ──→ ULN2003 IN3 ──→ 电机 Pink   (C 相)
  ESP32 GPIO27 ──→ ULN2003 IN4 ──→ 电机 Blue  (D 相)
  5 V  ──────────→ 电机 Red(公共端)
  5 V  ──────────→ ULN2003 COM(续流二极管公共端)
  GND ───────────→ ULN2003 GND ↔ ESP32 GND

4.2 相序:整步 vs 半步

单极性 28BYJ-48 最常见的 8 步半步序列(顺时针):

text 复制代码
        A(Orange)  B(Yellow)  C(Pink)   D(Blue)
Step1      1          0          0         1
Step2      1          0          0         0
Step3      1          1          0         0
Step4      0          1          0         0
Step5      0          1          1         0
Step6      0          0          1         0
Step7      0          0          1         1
Step8      0          0          0         1

"1" = GPIO 输出高 → ULN2003 导通 → 该相被拉低到地 → 线圈通电。

> 💡 有些数据手册里的相序表用"0"表示通电、"1"表示断电(从线圈电压角度看),和我这里"GPIO 输出电平"的写法正好相反。看表时先看清楚它定义的是哪个信号。

每走 8 步,内部转子转 5.625°;输出轴经过 64:1 减速,只转 5.625°/64 ≈ 0.0879°。所以一圈 360° 需要 4096 个半步。如果取整步序列(只走 Step1/3/5/7),一圈就是 2048 步,扭矩略小、抖动更大。

⚠️ 注意:网上有些相序表写的是"逆时针",或者线圈颜色对应关系不同。如果你的电机只抖不转,先把脉冲周期放慢到 100 ms/步,然后对调相邻两相把相序表反着跑------90% 的"相序错"问题这样解决。

半步驱动代码:

c 复制代码
// stepper_28byj48.c ------ ESP-IDF v5.x
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

#define COIL_A  GPIO_NUM_13
#define COIL_B  GPIO_NUM_12
#define COIL_C  GPIO_NUM_14
#define COIL_D  GPIO_NUM_27

static const gpio_num_t s_coils[4] = { COIL_A, COIL_B, COIL_C, COIL_D };

/* 半步序列,一行是一步,顺序 1→8 顺时针 */
static const uint8_t s_half_step[8][4] = {
    {1, 0, 0, 1},
    {1, 0, 0, 0},
    {1, 1, 0, 0},
    {0, 1, 0, 0},
    {0, 1, 1, 0},
    {0, 0, 1, 0},
    {0, 0, 1, 1},
    {0, 0, 0, 1},
};

void stepper28_init(void)
{
    for (int i = 0; i < 4; i++) {
        gpio_reset_pin(s_coils[i]);
        gpio_set_direction(s_coils[i], GPIO_MODE_OUTPUT);
        gpio_set_level(s_coils[i], 0);
    }
}

void stepper28_step_once(uint32_t delay_ms)
{
    static int s_step = 0;
    for (int i = 0; i < 4; i++) {
        gpio_set_level(s_coils[i], s_half_step[s_step][i]);
    }
    s_step = (s_step + 1) % 8;
    vTaskDelay(pdMS_TO_TICKS(delay_ms));
}

⚠️ 这个演示用了 vTaskDelay,最慢只能到 10 ms/tick(默认 tick 1 ms 时实际是 1 ms 整数倍)。真正跑高速步进需要用硬件定时器或 esp_timer 做 µs 级延时,否则电机到 10 ms/步就上不去了。


4.3 A4988:两相四线步进的 step/dir 接口

A4988 驱动双极性两相四线步进电机(比如 42 步进电机、3D 打印机用的 NEMA17)。它把复杂的相序表封装在内部,你只需要给两个信号:

  • DIR:方向(高/低)
  • STEP:一个上升沿走一步

细分通过 MS1/MS2/MS3 设置:

MS1 MS2 MS3 细分
L L L 整步
H L L 半步
L H L 1/4 步
H H L 1/8 步
H H H 1/16 步

A4988 关键规格:

  • 电机供电 VBB:8 V ~ 35 V
  • 逻辑供电 VDD:3 V ~ 5.5 V
  • 输出电流:±2 A 绝对最大;不加散热片实际建议 ≤ 1 A,加散热片可达 2 A
  • 每步电流:ITripMAX = VREF / (8 × RS),RS 是采样电阻
  • STEP 脉宽:高、低都至少 1 µs
  • DIR 相对 STEP 的建立/保持时间:至少 200 ns
  • RESET 和 SLEEP:必须拉高,否则不工作

⚠️ A4988 的电位器(VREF)决定电流上限。很多模块默认 VREF 约 0.8 V,RS 典型 0.1 Ω,电流约 1 A。如果你把电机换成小电流型号,必须重调 VREF,否则电机发烫、噪音大、寿命缩水。

接线:

text 复制代码
  ESP32 GPIO18 ──→ A4988 STEP
  ESP32 GPIO19 ──→ A4988 DIR
  ESP32 GPIO21 ──→ A4988 MS1(可选,接地=整步)
  ESP32 GPIO22 ──→ A4988 MS2(可选)
  ESP32 GPIO23 ──→ A4988 MS3(可选)
  3.3 V ─────────→ A4988 VDD
  12 V ──────────→ A4988 VMOT
  GND  ──────────→ A4988 GND ↔ ESP32 GND(共地)
  A4988 1A/1B/2A/2B ──→ 步进电机四线

  ★ A4988 RESET 和 SLEEP 必须接高电平(可短接在一起)

基本驱动代码:

c 复制代码
// a4988.c ------ ESP-IDF v5.x,整步驱动 NEMA17
#include "driver/gpio.h"
#include "esp_check.h"
#include "esp_rom_sys.h"   // esp_rom_delay_us

#define A4988_STEP  GPIO_NUM_18
#define A4988_DIR   GPIO_NUM_19

void a4988_init(void)
{
    gpio_config_t io = {
        .pin_bit_mask = (1ULL << A4988_STEP) | (1ULL << A4988_DIR),
        .mode = GPIO_MODE_OUTPUT,
        .pull_up_en = GPIO_PULLUP_DISABLE,
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .intr_type = GPIO_INTR_DISABLE,
    };
    ESP_ERROR_CHECK(gpio_config(&io));
    gpio_set_level(A4988_STEP, 0);
    gpio_set_level(A4988_DIR, 0);
}

/* dir=1 正转,dir=0 反转;steps 为 STEP 脉冲个数 */
void a4988_move(uint32_t steps, bool dir, uint32_t delay_us)
{
    gpio_set_level(A4988_DIR, dir ? 1 : 0);

    for (uint32_t i = 0; i < steps; i++) {
        gpio_set_level(A4988_STEP, 1);
        esp_rom_delay_us(delay_us > 0 ? delay_us : 1);
        gpio_set_level(A4988_STEP, 0);
        esp_rom_delay_us(delay_us > 0 ? delay_us : 1);
    }
}

⚠️ 这里用 esp_rom_delay_us 做 busy-wait,只是演示。实际跑高速、跑 RTOS 时,应该用硬件定时器中断或 MCPWM 输出方波,避免任务长时间占用 CPU。

4.4 为什么不能一步提到最高速?梯形加减速思路

步进电机转子有惯性,它不可能瞬间从 0 跳到 1000 step/s。你硬给高速脉冲,转子跟不上磁场,就会丢步,表现为电机"咔咔"叫、转不动甚至反转。

所以实际运动通常做 梯形速度曲线

text 复制代码
  速度
   ▲
   │      ┌──────────┐
   │     /            \
   │    /              \
   │   /                \
   │  /                  \
   └─/────────────────────\──────▶ 步数
     加速段   匀速段   减速段

最经典的实时算法来自 Microchip 的 AVR446 应用笔记

text 复制代码
  设定时器频率为 f_t,步距角 α,目标角加速度 ω̇
  第一个间隔:
      c0 = f_t · sqrt(2α / ω̇)

  后续间隔用近似递推(避免开方):
      cn = cn-1 - (2 · cn-1) / (4n + 1)

  n 是第 n 步。加速时步间隔越来越小,电机越来越快;
  减速时把公式反过来用,步间隔越来越大。

实现上不一定非要在中断里跑递推。简单项目可以这样做:

  1. 把加速段拆成 10~20 个速度台阶;
  2. 每个台阶跑固定步数,delay_us 按等差或等比递减;
  3. 匀速段按最快 delay_us 跑;
  4. 减速段把加速表反过来。

💡 如果你只是让小车走一段距离,没有实时位置要求,可以先把速度调到"能稳定转"的最大值,然后保持。真正做 3D 打印机、CNC 才需要精确的 S 曲线或梯形规划。


五、安全与功耗:别让执行器把 MCU 拉下水

5.1 续流与栅极下拉

这两件事在 项目实战_02 里已经强调过,这里系统总结一下:

位置 作用 不加的后果
续流二极管 给电感电流一条回路 反冲电压击穿 MOS/MCU
栅极下拉电阻 MCU 复位/未初始化时把 MOS 栅极固定到低电平 上电瞬间 MOS 半导通,继电器"咔"一下误吸合
栅极串联电阻 抑制振铃、限制充放电电流 开关过快产生 EMI,或 GPIO 灌电流过大

MOS 栅极下拉电阻典型值 10 kΩ ~ 100 kΩ。自己搭 H 桥或继电器低边驱动时,这个电阻必须接;用 TB6612/DRV8833 这类集成驱动芯片,输入端内部一般有下拉,外部可省略,但上电默认状态仍要确认。

5.2 大电流走线与电源功率预留

电机电流走的是功率回路,和 MCU 的 3.3 V 不是一回事。设计要点:

  1. 电源功率要留余量。电机启动电流是运行电流的 3~5 倍,电源额定电流至少按电机额定电流的 1.5 倍选。
  2. 地和电源线要粗。杜邦线带 1 A 以上电流会明显发热,长时间工作建议用更粗的线或 PCB 铜皮。
  3. 退耦电容靠近驱动芯片。在 VM 和 GND 之间放一个 100 µF 电解 + 100 nF 陶瓷,吸收电机换向时的电流纹波。
  4. MCU 的 3.3 V 和电机电源分开。除非电机非常小(<50 mA),否则不要从 MCU 的 LDO 取电。

5.3 电流采样:知道电机在干什么

没有电流反馈,你只能靠摸温度判断是否过载。采样方式前面提过:

text 复制代码
  1) 采样电阻 + 仪表/差分运放 → ADC
     优点:成本低;缺点:引入地线干扰、需要放大电路

  2) 霍尔电流传感器(如 ACS712-05B)→ ADC
     优点:隔离、无需改地线;缺点:体积大、本身也耗电

DRV8833 内部已经做了电流限制(200 mV 基准),但那是"保护驱动芯片",不是"保护你的机械结构"。真正做力控或堵转检测,还是要外部采样。


六、新手必踩的 N 个坑

# 后果(现象长什么样) 正确做法
1 GPIO 直接驱动电机 一接电机 MCU 重启,甚至 GPIO 烧坏 GPIO 只发信号,电流走驱动芯片/MOSFET;电机独立供电
2 TB6612 同侧 MOS 同臂导通(自建 H 桥时) 电源直接短路,MOS 瞬间冒烟 用集成驱动芯片或加硬件死区;若用互补 PWM,必须插 MCPWM 死时间
3 忘了续流二极管/未接 COM 继电器或晶体管 H 桥第一次 PWM 就坏;ULN2003 发热 继电器必加 1N4007/1N4148;ULN2003 的 COM 必须接电机电源正
4 舵机从开发板 3.3 V/5 V 取电 舵机一动 ESP32 重启,或板载 LDO 过热 舵机独立 5 V 电源,信号线接 GPIO,必须共地
5 舵机不共地 舵机抽搐、尖叫、不响应 把独立电源 GND 和 MCU GND 连起来
6 步进相序接错/表反了 电机只抖不转、异响、发热 放慢到 100 ms/步,对调相邻两相或反着跑相序表
7 A4988 的 RESET/SLEEP 悬空 模块通电但不转,STEP 信号没反应 RESET 和 SLEEP 短接到 3.3 V
8 A4988 VREF 不调 小电机烫手、噪音大、力矩不稳 ITripMAX = VREF/(8×RS) 计算并测量 VREF
9 步进电机起步直接给高速 电机"咔咔"叫、丢步、甚至不转 加梯形加减速,先加速到目标速度
10 电机电源和 MCU 电源不隔离、地线乱走 MCU 复位、ADC 跳变、通信丢包 单点接地,大功率回路不经过 MCU 地平面

坑 2 展开:集成驱动芯片真的不会同臂导通吗?

TB6612FNG 数据手册明确写了:"To prevent penetrating current, dead time t2 and t4 is provided in switching to each mode in the IC." 它内部已经插了死区,所以你从 IN1/IN2/PWM 端不可能让它同臂导通。把 IN1=H、IN2=H 写进去,它执行的是"短刹车",不是短路。

但如果你是自己用 4 个 MOS 搭 H 桥,或用两个半桥驱动芯片,同臂导通是真实存在的。做这类实验时一定要在示波器上先看上下桥 PWM 有没有重叠,再通电。


七、动手练一练

练习 1|先测电流再让它转(20 分钟)

不要先写方向切换。把 TB6612 接好,电机固定不让它转(或轻轻捏住轴),用万用表电流档串在 VM 回路里:

  • dc_motor_set(100) 时电流多大?
  • dc_motor_set(1000) 时电流多大?
  • 从 1000 直接切到 -1000,电流峰值比匀速时大多少?

这个练习会让你对"反接制动电流"有直观感受,也会让你理解为什么代码里要"先刹车再换向"。

练习 2|【故意试错】舵机不共地(5 分钟)

舵机红线、棕线先接独立 5 V 电源,但电源负极暂时不接 ESP32 GND ,只把信号线接 GPIO。上电后调用 servo_write_angle(90),观察现象:舵机会抖、会叫、或者完全不理你。

然后关掉电源,把独立电源 GND 和 ESP32 GND 连上,再上电------大概率立刻正常。

练习 3|【故意试错】步进相序反了(10 分钟)

把 28BYJ-48 的 A/B 两相线对调,然后跑 stepper28_step_once()。你会看到电机只抖不转。把线换回来,或把相序表反着遍历,电机就正常转了。这个错很常见,见过一次以后 5 秒钟就能定位。

练习 4|用 A4988 让 42 步进转一圈,比较整步和 1/16 细分(20 分钟)

  • 整步:MS1=MS2=MS3=0,发 200 个脉冲转一圈;
  • 1/16 细分:MS1=MS2=MS3=1,发 3200 个脉冲转一圈。

用手摸或听声音:整步抖动明显,1/16 细分平稳很多。这会让你理解"细分不是为了精度,是为了减振"这句话。

练习 5|【故意试错】起步直接给高速 vs 梯形加速(20 分钟)

用 A4988 控制 42 步进:

  • 方法 A:delay_us 直接设 500 µs(约 1000 step/s),看电机能不能启动;
  • 方法 B:从 2000 µs 起步,每 100 步把 delay_us 减 50 µs,直到 500 µs。

方法 B 能启动的速度,方法 A 可能直接丢步。这就是梯形加速的价值。

练习 6|观察 PWM 死区(需要示波器,选做)

如果你有离散 MOS H 桥或两个半桥驱动,用 MCPWM 输出互补 PWM。先用 0 ns 死区,在示波器上观察上下桥栅极信号------很可能有几十到几百纳秒的重叠。然后把 posedge_delay_ticks / negedge_delay_ticks 加到 1 µs 以上,重叠消失。记住:集成驱动芯片内部已经做了这一步,这个实验只是让你理解"为什么不能直接反相两个 PWM"。


小结

三类执行器,三种控制方式:

执行器 控制量 核心电路 最容易翻车的地方
直流电机 电压方向 + PWM 占空比 H 桥(TB6612/DRV8833) GPIO 直接驱动、反接制动电流、不换向直接倒车
舵机 50 Hz PWM 脉宽 内部自带闭环 供电不足、不共地、脉宽推到底
步进电机 脉冲个数 + 方向 驱动器(ULN2003/A4988) 相序错、RESET/SLEEP 悬空、起步太猛

四句话带走:

  1. GPIO 是信号不是电源,电机电流必须走驱动芯片或 MOS。
  2. 感性负载必须有续流回路,不然反冲电压会打坏芯片。
  3. 舵机/电机独立供电,但所有地必须共在一起
  4. 步进电机想快,得先让它慢慢加;梯形加减速是开环定位不丢步的关键。

各驱动参数都来自对应芯片的 Datasheet(TB6612 / DRV8833 / A4988 / 28BYJ-48 / ULN2003A / SG90 / ESP-IDF LEDC·MCPWM 等),选型时以你手上的型号版本为准。

  • 本站相关:项目实战_02(继电器/舵机/MOSFET 联动执行)、低功耗_03(GPIO 不是电源)

下一篇:嵌入式Linux基础_01_交叉编译与开发环境实战

相关推荐
xixiaoyunya3 小时前
内网穿透工具选型分析:从 ngrok 的局限性看国内开发场景下的替代方案
网络
FreeTinker4 小时前
远程查看的技术路径与工程选型分析
网络
YXXY3136 小时前
网络基础2(三)
网络
国科安芯7 小时前
ASL706S:让 MCU 不再“失忆跑飞“的守护芯片
分布式·单片机·嵌入式硬件·安全·fpga开发·架构
m24tyy8 小时前
常见空间测绘搜索引擎介绍和使用以及子域名的收集
网络·测试工具·搜索引擎
数据安全技术观察8 小时前
数据动态脱敏:让脱敏策略跟着数据目录走
大数据·网络·数据库
我爱cope8 小时前
【计算机网络 | 网络层1:网络层的职责:IP 协议究竟解决了什么问题?】
网络·tcp/ip·计算机网络
dogstarhuang10 小时前
OpenAI GPT-5.6 降价后如何重算 API 账单?多模型路由与成本治理实战
服务器·网络·人工智能·大模型·api·ai应用开发·接口管理
树的枝10 小时前
【STM32】03.中断和EXTI外设
笔记·stm32·单片机·嵌入式硬件