4、PWM与模拟输出:LEDC控制器原理,PWM频率与占空比设置,呼吸灯实验,舵机控制基础

说到模拟输出,很多刚接触单片机的朋友第一反应就是「DAC」。但ESP32上只有两个真正的DAC通道,而且精度有限。实际项目中,我们绝大多数「模拟」需求——调光、调速、舵机角度——都是用PWM搞定的。

ESP32的LEDC控制器,说白了就是一套硬件PWM发生器。它不只能控制LED,还能控制电机、舵机、蜂鸣器。我最早用ESP32做四轴飞行器飞控的时候,四个电机的PWM信号就是靠LEDC生成的,稳定得很。

4.1 LEDC控制器架构

LEDC模块内部有两层结构:定时器和通道。定时器决定PWM的频率和分辨率,通道决定哪个引脚输出以及占空比是多少。

LEDC 控制器架构 定时器 0 定时器 1 定时器 2 定时器 3 通道 0 通道 1 通道 2 通道 3 GPIO 引脚 GPIO 引脚 GPIO 引脚 GPIO 引脚 一个定时器可以绑定多个通道,所有绑定通道共享相同的频率和分辨率

每个定时器可以独立设置时钟源、分频系数和计数上限。通道则负责把定时器的计数值和用户设定的占空比阈值做比较,输出高电平或低电平。

关键参数速查表

参数说明典型值
频率PWM波形的周期倒数50 Hz(舵机)、1 kHz(LED)
占空比高电平时间占整个周期的比例0% ~ 100%
分辨率占空比可调节的级数8 bit(256级)、10 bit(1024级)
定时器时钟LEDC内部计数器的时钟源APB_CLK(80 MHz)

4.2 PWM频率与占空比设置

设置PWM其实就三步:配定时器、绑通道、写占空比。我习惯把这三步封装成一个函数,项目里反复调用。

#include <driver/ledc.h>

#define LEDC_TIMER      LEDC_TIMER_0
#define LEDC_MODE       LEDC_LOW_SPEED_MODE
#define LEDC_CHANNEL    LEDC_CHANNEL_0
#define LEDC_DUTY_RES   LEDC_TIMER_13_BIT   // 分辨率 13 bit
#define LEDC_FREQUENCY  5000                 // 频率 5 kHz

void pwm_init(void) {
    // 1. 配置定时器
    ledc_timer_config_t timer_conf = {
        .speed_mode       = LEDC_MODE,
        .timer_num        = LEDC_TIMER,
        .duty_resolution  = LEDC_DUTY_RES,
        .freq_hz          = LEDC_FREQUENCY,
        .clk_cfg          = LEDC_AUTO_CLK
    };
    ledc_timer_config(&timer_conf);

    // 2. 配置通道
    ledc_channel_config_t channel_conf = {
        .gpio_num       = 2,
        .speed_mode     = LEDC_MODE,
        .channel        = LEDC_CHANNEL,
        .timer_sel      = LEDC_TIMER,
        .duty           = 0,
        .hpoint         = 0
    };
    ledc_channel_config(&channel_conf);
}

void set_duty(uint32_t duty) {
    ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, duty);
    ledc_update_duty(LEDC_MODE, LEDC_CHANNEL);
}

这里有个坑——ledc_set_duty只是把值写进寄存器,不会立即生效。必须调用ledc_update_duty才会真正更新输出。我曾经在这个问题上卡了半小时,波形死活不变,后来才发现忘了调用更新函数。

频率与分辨率的关系

ESP32的LEDC定时器是固定位宽的计数器。分辨率越高(bit数越大),能达到的最高频率就越低。公式很简单:

最大频率 = 时钟源频率 / (2^分辨率)

比如80 MHz时钟、13 bit分辨率:80,000,000 / 8192 ≈ 9.7 kHz。想要更高频率?那就得降低分辨率。

4.3 呼吸灯实验

呼吸灯是PWM入门的经典实验。原理很简单:占空比从0逐渐增加到最大值,再从最大值逐渐减小到0,循环往复。

我最早做呼吸灯用的是vTaskDelay加循环,后来发现这样会阻塞任务。更好的做法是用定时器中断或者软件定时器来更新占空比。

void breath_led_task(void *pvParameters) {
    uint32_t duty = 0;
    int8_t direction = 1;

    while (1) {
        ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, duty);
        ledc_update_duty(LEDC_MODE, LEDC_CHANNEL);

        duty += direction * 10;
        if (duty >= 8191) {
            direction = -1;
        } else if (duty <= 0) {
            direction = 1;
        }

        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

步长设为10,延时10 ms,一个完整的呼吸周期大约1.6秒。你可以调整这两个参数来改变呼吸速度。步长越大、延时越短,呼吸越快。

注意占空比上限

13 bit分辨率下,占空比最大值是8191(2^13 - 1)。如果你设成8192,LED会突然熄灭——因为计数器溢出回零了。我曾经在调试一个调光灯具时遇到过这个问题,灯在最高亮度突然闪了一下,排查了半天才发现是占空比越界。

4.4 舵机控制基础

舵机控制是PWM的另一个典型应用。标准舵机使用50 Hz的PWM信号,周期20 ms。占空比对应角度:

角度脉宽占空比(50 Hz)
0°1.0 ms5%
90°1.5 ms7.5%
180°2.0 ms10%

注意,不同品牌的舵机对脉宽范围的定义略有差异。便宜的舵机可能0°对应0.5 ms,180°对应2.5 ms。我建议拿到舵机后先用示波器或者逻辑分析仪实测一下。

#define SERVO_FREQ      50
#define SERVO_DUTY_RES  LEDC_TIMER_14_BIT  // 16384 级

// 角度转占空比
uint32_t angle_to_duty(int angle) {
    // 0° -> 1.0 ms, 180° -> 2.0 ms
    // 50 Hz 周期 = 20 ms
    // 14 bit 满值 = 16384
    float pulse_ms = 1.0 + (angle / 180.0) * 1.0;
    return (uint32_t)((pulse_ms / 20.0) * 16384);
}

void set_servo_angle(int angle) {
    if (angle < 0) angle = 0;
    if (angle > 180) angle = 180;
    uint32_t duty = angle_to_duty(angle);
    ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, duty);
    ledc_update_duty(LEDC_MODE, LEDC_CHANNEL);
}

舵机控制要点

  • 频率必须设为50 Hz,否则舵机会抖动或啸叫
  • 分辨率建议用14 bit,角度控制更精细
  • 改变角度后要给舵机留出响应时间(约200~500 ms)
  • 不要频繁发送角度指令,舵机内部电机有机械惯性

说到舵机,我想起一个项目——用ESP32控制六足机器人。18个舵机同时工作,每个舵机都需要独立的PWM通道。LEDC最多支持16个通道,刚好够用。但要注意,通道多了之后,定时器资源要合理分配。我把所有舵机都挂在同一个定时器上,因为它们频率相同(50 Hz),只是占空比不同。

多通道共享定时器

如果你有多个舵机或者多个LED需要控制,可以把它们绑定到同一个定时器上。这样节省定时器资源,而且所有通道的频率完全一致。唯一的要求是——它们必须工作在相同的频率和分辨率下。

嗯,PWM这部分内容其实不难,但细节很多。频率、分辨率、占空比这三个参数搞清楚了,LEDC控制器就算掌握了。呼吸灯和舵机是两个最典型的应用场景,把这两个实验做一遍,PWM这块基本就通了。