Skip to content

2.5 ADC 与定时器 ​

这节要搞懂:怎么测电压(读光敏、电池),怎么调亮度和发声音。 需要基础:2.2

一、什么是 ADC ​

ADC(Analog-to-Digital Converter,模数转换器)是一种把连续变化的模拟电压转换成离散数字量的外设。转换结果的精细程度由分辨率决定。

为什么需要它:GPIO 只能读"高"或"低"两种状态,但现实中很多东西是连续变化的:

  • 光敏电阻:环境光照越亮,电阻越小,分压电压越低
  • 电池电压:3.7V、3.5V、3.3V……
  • 电位器:转到不同角度对应不同电压

ESP32-S3 的 ADC 是 12 位的,也就是把 0 ~ 3.3V 分成 4096 份:

0V     → 0
1.65V  → 约 2048
3.3V   → 4095

本项目只用一路 ADC

天气时钟里 ADC 只干一件事:读光敏电阻,判断环境明暗,自动调背光。

接在 GPIO1(ADC1_CH0)。下面的代码就是照着这个场景写的。

二、ADC 代码 ​

c
#include "esp_adc/adc_oneshot.h"

#define ADC_CHANNEL     ADC_CHANNEL_0    // ADC1 的 0 通道 = GPIO1,接光敏电阻
#define ADC_MAX_VALUE   4095             // 12 位的最大值

static adc_oneshot_unit_handle_t adc_handle;

void adc_init(void)
{
    // 1. 创建 ADC1
    adc_oneshot_unit_init_cfg_t adc_config = {
        .unit_id = ADC_UNIT_1,           // ⭐ 必须是 ADC1,原因见下
    };
    adc_oneshot_new_unit(&adc_config, &adc_handle);

    // 2. 配置这一路通道
    adc_oneshot_chan_cfg_t channel_config = {
        .atten    = ADC_ATTEN_DB_12,     // 量程:调到最大,约 0~3.1V
        .bitwidth = ADC_BITWIDTH_12,     // 分辨率:12 位,读数 0~4095
    };
    adc_oneshot_config_channel(adc_handle, ADC_CHANNEL, &channel_config);
}

// 读一次
int adc_value = 0;
adc_oneshot_read(adc_handle, ADC_CHANNEL, &adc_value);   // 0 ~ 4095

通道配置里那两个参数各管一件事:

参数管什么填什么
.bitwidth分辨率:把电压分成多少份ADC_BITWIDTH_12 → 4096 份
.atten量程:最大能测到多少伏ADC_ATTEN_DB_12 → 约 0~3.1V

衰减(atten)是什么

ADC 内部能直接测的电压其实很小(不到 1V)。衰减就是先把输入电压按比例缩小,再送进去测 —— 这样就能测更大的电压了。

衰减档位大致量程
ADC_ATTEN_DB_00 ~ 0.95V
ADC_ATTEN_DB_2_50 ~ 1.25V
ADC_ATTEN_DB_60 ~ 1.75V
ADC_ATTEN_DB_120 ~ 3.1V ⭐

我们的分压电路最高能到 3.3V,所以要选最大的那一档。选小了,电压一高就顶满不动了。

读数是通过指针带回来的

c
int adc_value = 0;
adc_oneshot_read(adc_handle, ADC_CHANNEL, &adc_value);

adc_oneshot_read() 的返回值是错误码(成功/失败),不是读数。读数是通过最后那个参数 &adc_value 写回来的。

所以千万别写成 int v = adc_oneshot_read(...) —— 那拿到的是 0(ESP_OK),不是电压。

原始值怎么变成百分比

c
int light = adc_value * 100 / ADC_MAX_VALUE;

把 0~4095 映射成 0~100,读日志的时候直观得多。

注意这是整数除法,必须先乘 100 再除 —— 顺序反了(adc_value / 4095 * 100)结果永远是 0。

必须用 ADC1!

ESP32-S3 有 ADC1 和 ADC2 两组,ADC2 在开了 WiFi 之后就用不了了(射频和 ADC2 抢同一套硬件)。

我们的天气时钟要联网,所以所有模拟量都必须接在 ADC1 上。

ESP32-S3 的 ADC 引脚对应关系:

对应引脚能用吗
ADC1(CH0 – CH9)GPIO 1 – 10✅ 联网时也能用
ADC2(CH0 – CH9)GPIO 11 – 20❌ 开 WiFi 后失效

通道号 = 引脚号 - 1:ADC_CHANNEL_0 就是 GPIO1,ADC_CHANNEL_3 就是 GPIO4。

本课程的光敏电阻接在 GPIO1,所以用 ADC_CHANNEL_0。

这个坑非常隐蔽 —— 单独测试好好的,一联网就读不出来了。

别照抄老 ESP32 的教程

老款 ESP32 的 ADC1 是 GPIO 32-39,S3 是 GPIO 1-10,完全不一样。

看网上教程时,先确认它写的是哪款芯片。

三、什么是 PWM ​

PWM(Pulse Width Modulation,脉冲宽度调制)是一种通过改变方波中高电平所占一个完整周期的时间比例来等效控制输出功率的技术。这个比例称为占空比(Duty Cycle)。

打个比方:想让灯半亮,就让它飞快地开开关关(每秒几千次),亮一半时间灭一半时间(比如一整个周期是1秒,那高电平持续500ms,低电平持续500ms)。人眼跟不上这个速度,看到的就是"半亮"。

同样的道理:电机转慢一点、加热器温度低一点、喇叭发出某个音调,全都是这么控制的。

三个关键参数 ​

25%50%75%灯很暗灯半亮灯很亮一个周期 T(三行的周期完全一样)高电平
参数含义例子
周期 T一次"开+关"花多长时间5000Hz → T = 0.2 毫秒
频率 f一秒重复多少次,f = 1/T背光常用 5000Hz
占空比高电平时间 ÷ 周期50% = 半亮
分辨率占空比能分成多少档10 位 = 1024 档(0~1023)

注意看上面的图

三行的周期完全一样(竖虚线对齐),变的只是高电平那一段有多宽。

PWM 调的是"宽度",不是"频率" —— 这就是它名字的由来。

PWM 是怎么被"造"出来的 ⭐ ​

理解这一步,后面很多约束就都说得通了。

芯片里有一个不停数数的计数器,还有一个你设定的比较值:

计数器输出比较值 = duty最大值0计数 < 比较值 → 输出高计数 ≥ 比较值 → 输出低把虚线(比较值)往上挪 = 占空比变大 = 灯更亮

规则只有一条:

计数器的值 < 比较值  →  引脚输出高电平
计数器的值 ≥ 比较值  →  引脚输出低电平

计数器从 0 一直数到最大值,然后归零重来 —— 周而复始就形成了方波。

  • 你改比较值(duty) → 高电平那段变宽或变窄 → 占空比变了
  • 你改计数器数得多快 → 一圈用时变了 → 频率变了

全程不用 CPU 参与

这一切都是硬件自动完成的。你设好参数之后,CPU 该干嘛干嘛,引脚上的方波一直在那儿输出,一条指令都不用执行。

四、频率和分辨率要一起选 ​

新手常见的困惑:为什么我填的频率和分辨率,ledc_timer_config() 会返回错误?

因为这两个参数不是独立的 —— 它们在抢同一份资源。

先认清三个角色 ​

名词是什么例子
分辨率把一个周期分成多少份,决定调光能多细腻13 位 = 分成 2^13=8192 份
频率一秒重复多少次,决定周期有多长5000Hz → 周期=1/5000 = 0.2 毫秒
时钟源硬件能提供的最大"数数速度"ESP32-S3 常用 80MHz

矛盾在哪 ⭐ ​

关键约束只有一句:

在一个 PWM 周期内,计数器必须完整地数完 (2^分辨率) 个数。

于是两个要求就打架了:

  • 你要求频率高 → 周期变短 → 留给数数的时间就少
  • 你同时要求分辨率高 → 要数的数变多

时间少、要数的多 —— 数不完。

时钟脉冲(每秒 80000000 个,这是硬件上限,不可能更快)
││││││││││││││││││││││││││││││││││││││││││││││││││││││

低频 + 高分辨率:
├──────────── 周期很长,来得及数完 8192 个 ────────────┤   ✅

高频 + 高分辨率:
├─ 周期很短 ─┤
  时间只够数 2000 个,可你要求数 16384 个                ❌

结论:鱼和熊掌不可兼得。要么降频率,要么降分辨率。

把这个矛盾写成公式 ​

上面那句话翻译成算式就是:

① 分辨率 N 位  →  一个周期要数 2^N 个数

② 计数器由时钟驱动,来一个脉冲 +1
   →  数一个数的时间 = 1 / 时钟源频率

③ 一个周期的时间 = 2^N × (1 / 时钟源频率) = 2^N / 时钟源频率

④ PWM 频率 = 1 / 周期 = 时钟源频率 / 2^N

两边乘以 2^N,就得到那个约束:

PWM 频率 × 2^分辨率 ≤ 时钟源频率

左边那一坨其实有直观含义

频率 × 2^分辨率 = 计数器每秒要数多少个数。

5000 次/秒 × 每次 8192 个 = 每秒要数 40,960,000 个

而时钟源每秒只能给 80,000,000 个脉冲 —— 要数的不能超过能给的,就这么简单。

为什么是"≤"而不是"="

时钟源和计数器之间还有一个分频器,可以让计数变慢:

                时钟源频率
PWM 频率 = ──────────────────
            分频系数 × 2^分辨率

分频系数最小是 1(不可能让计数器跑得比时钟源还快)。

  • 刚好相等 → 分频系数 = 1,已经到极限
  • 小于时钟源 → 分频系数 > 1,还有余量

算两个例子 ​

ESP32-S3 的 LEDC 默认时钟源是 80MHz:

✅ 5kHz + 13 位
   每秒要数 5,000 × 8,192 = 40,960,000  ≤ 80,000,000
   分频系数 = 80,000,000 / 40,960,000 ≈ 1.95   →  配得出来

❌ 40kHz + 14 位
   每秒要数 40,000 × 16,384 = 655,360,000  远超 80,000,000
   分频系数 = 0.12 < 1                      →  配不出来,返回错误

直接查表 ​

从公式反推:最大分辨率 = log₂(时钟源 ÷ PWM 频率)

PWM 频率理论最大分辨率ESP32-S3 实际可用
1 kHz16 位14 位(芯片上限)
5 kHz13 位13 位 ⭐ 背光常用
10 kHz12 位12 位
20 kHz11 位11 位
40 kHz10 位10 位

ESP32-S3 的分辨率上限是 14 位

即使频率很低、算出来能到 16 位,芯片本身最高只支持 14 位(LEDC_TIMER_14_BIT)。

14 位 = 16384 档,调光已经细腻到肉眼完全分辨不出台阶了,够用。

配不出来怎么办

按这个顺序试:

  1. 降分辨率 —— 大多数场合 10 位(1024 档)完全够用,先降这个
  2. 降频率 —— 只要还在"人眼看不见闪烁"的范围内(>1kHz)就行
  3. 换时钟源 —— clk_cfg 除了 LEDC_AUTO_CLK,还能选 XTAL(40MHz)等

新手直接用 5000Hz + 10 位,绝对配得出来,也绝对够用。

频率该选多少 ​

用途推荐频率为什么
LED / 背光调光1 ~ 5 kHz低于 100Hz 人眼能看到闪烁;太高没必要还费电
电机调速20 kHz 以上低于 20kHz 会发出人耳能听见的啸叫
无源蜂鸣器2 ~ 4 kHz这里频率就是音调(本项目没用,了解即可)
舵机固定 50 Hz靠 0.5~2.5ms 的脉宽表示角度

两种相反的用法

调光:改占空比,频率不动 —— 我们的背光就是这样。

发声:改频率来改音调,占空比固定 50%。

同一个 PWM 外设,用法完全相反,看你要控制的是"功率"还是"频率"。

五、用 PWM 调背光 ​

背光电流大,要用三极管驱动

2.8 寸屏的背光有好几颗 LED,电流可能超过 GPIO 能承受的 20mA(2.2 讲过)。

所以背光不是直接接 GPIO,而是:GPIO → 限流电阻 → 三极管基极,由三极管去开关背光的电流。

代码上没区别,还是输出 PWM;差别只在硬件接法上。

ESP32-S3 上的 PWM 外设叫 LEDC(LED Control)。

c
#include "driver/ledc.h"

#define BACKLIGHT_GPIO      17

void backlight_init(void)
{
    // 1. 配置定时器:频率 5000Hz,10 位精度(亮度范围 0~1023)
    ledc_timer_config_t timer_config = {
        .speed_mode      = LEDC_LOW_SPEED_MODE,
        .timer_num       = LEDC_TIMER_0,
        .duty_resolution = LEDC_TIMER_10_BIT,     // 2^10 = 1024 级
        .freq_hz         = 5000,                  // 5000Hz,人眼看不到闪
        .clk_cfg         = LEDC_AUTO_CLK,
    };
    ledc_timer_config(&timer_config);

    // 2. 配置通道:绑到背光引脚
    ledc_channel_config_t channel_config = {
        .gpio_num   = BACKLIGHT_GPIO,
        .speed_mode = LEDC_LOW_SPEED_MODE,
        .channel    = LEDC_CHANNEL_0,
        .timer_sel  = LEDC_TIMER_0,
        .duty       = 512,                        // 初始 50% 亮度
        .hpoint     = 0,
    };
    ledc_channel_config(&channel_config);
}

// 设置亮度:0 = 全灭,1023 = 全亮
void backlight_set(int brightness)
{
    ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, brightness);
    ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0);   // ⚠️ 别忘了这行
}

忘了 update_duty

ledc_set_duty 只是"写下来",必须再调 ledc_update_duty 才真正生效。

这是个高频坑:代码看着没问题,亮度就是不变。

六、什么是定时器 ​

定时器(Timer)本质上是一个会自动数数的计数器:它按固定的时钟节拍递增,数到设定值时触发一个动作(产生中断、翻转引脚、清零重来)。

PWM 其实就是定时器做出来的 ⭐ ​

你可能已经发现了 —— 第三节讲的"计数器 + 比较值",那就是一个定时器。

PWM 外设 = 定时器 + 比较器 + 一个能被硬件直接控制的引脚。

那"频率和分辨率"要不要自己配 ​

频率和分辨率没有默认值,要自己配

c
ledc_timer_config_t timer_config = {
    .freq_hz         = 5000,                  // ⭐ 必填,没有默认值
    .duty_resolution = LEDC_TIMER_10_BIT,     // ⭐ 必填,没有默认值
    // ...
};

两个都不填就报错,而且填的组合必须满足第四节那个约束,否则 ledc_timer_config() 返回 ESP_ERR_INVALID_ARG。

ESP32-S3 上有好几种定时器,分工不同 ​

外设干什么用我们用它吗
LEDC专门做 PWM:调光、发声✅ 第五节用的就是它
MCPWM电机专用 PWM,带死区、刹车保护❌ 做电机驱动才用
GPTimer通用定时器,定时触发中断❌ 需要硬件级精确同步时才用
esp_timer软件定时器(底层是硬件定时器),微秒级✅ 最常用,见下
FreeRTOS Tick内核用来调度任务由系统管理(1.6 讲过)

为什么不用"定时器中断翻转引脚"来做 PWM

理论上可以:开一个定时器,每次中断就把引脚翻转一下。

但代价大到不可接受:5kHz 的 PWM 意味着每秒 10000 次中断,CPU 光是进出中断就忙不过来了,别的任务全得让路。

LEDC 是纯硬件翻转的 —— 你设好参数就撒手不管,CPU 占用是 0。

结论:需要 PWM 就用 LEDC,别自己用定时器中断硬造。

本节要点 ​

  • ADC = 把模拟电压转成数字,ESP32-S3 是 12 位(0~4095)
  • ⭐ 必须用 ADC1(S3 上是 GPIO 1-10),ADC2 开了 WiFi 就失效
  • PWM = 用高低电平的时间比例控制输出,核心参数是周期、占空比、分辨率
  • PWM 由"计数器 + 比较值"产生,全程硬件完成,不占 CPU
  • 频率 × 2^分辨率 ≤ 时钟源 —— 本质是"计数器每秒要数的数不能超过时钟脉冲数"
  • 调光改占空比、发声改频率,两种相反的用法
  • ⭐ set_duty 之后必须 update_duty
  • ⭐ PWM 本来就是定时器做的;需要 PWM 用 LEDC,别用定时器中断硬造

操作过程 ​

这一节做两个实验:先用光敏电阻把 ADC 跑通,再用 PWM 做个呼吸灯。

建工程的流程每节都一样,见 2.2 第 2 步。工程名填 ADC,Target 选 esp32s3。

动手做一遍①:用光敏电阻读出"有多亮" ​

目标:串口每半秒打印一次光线强度,拿手电筒照一下,数字应该跟着变。

光敏电阻走的是 GPIO1(ADC1_CH0)。

板子上为什么还配了一个固定电阻

光敏电阻本身只是"阻值会跟着光变",阻值没法直接被 ADC 读到。

它要和一个固定电阻组成分压电路,才能把阻值变化转成电压变化:

3.3V ──[ 光敏电阻 ]──┬── GPIO1(ADC 在这里量电压)
                     │
                    [ 10kΩ ]
                     │
                    GND

光越强 → 光敏电阻阻值越小 → 它分走的电压越少 → GPIO1 上的电压越高 → 读数越大。

第 2 步:写代码 ​

c
#include <stdio.h>
#include "esp_adc/adc_oneshot.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

#define ADC_CHANNEL     ADC_CHANNEL_0  // GPIO1
#define ADC_MAX_VALUE   4095

static const char *TAG = "ADC";
static adc_oneshot_unit_handle_t adc_handle;

static void adc_task(void *arg)
{
    (void)arg;

    while (1) {
        int adc_value = 0;
        adc_oneshot_read(adc_handle, ADC_CHANNEL, &adc_value);   // 读原始值 0~4095

        int light = adc_value * 100 / ADC_MAX_VALUE;             // 换算成百分比

        ESP_LOGI(TAG, "ADC value: %d, light: %d%%", adc_value, light);

        vTaskDelay(pdMS_TO_TICKS(500));                          // 每半秒读一次
    }
}

void app_main(void)
{
    adc_oneshot_unit_init_cfg_t adc_config = {
        .unit_id = ADC_UNIT_1,
    };
    adc_oneshot_new_unit(&adc_config, &adc_handle);

    adc_oneshot_chan_cfg_t channel_config = {
        .atten = ADC_ATTEN_DB_12,
        .bitwidth = ADC_BITWIDTH_12,
    };
    adc_oneshot_config_channel(adc_handle, ADC_CHANNEL, &channel_config);

    xTaskCreate(adc_task, "adc_task", 2048, NULL, 5, NULL);
}

ESP_LOGI 比 printf 好在哪

c
ESP_LOGI(TAG, "ADC value: %d, light: %d%%", adc_value, light);

打出来长这样:

I (6283) ADC: ADC value: 2173, light: 53%
部分是什么
I日志级别(I = Info,还有 W 警告、E 错误)
(6283)开机到现在的毫秒数
ADC你定义的 TAG,一眼看出是哪个模块打的

模块一多,靠 TAG 就能一眼分辨谁在说话;时间戳还能帮你看出两条日志隔了多久。

另外 %% 是转义,打印出来是一个 %(因为单个 % 会被当成格式符的开头)。

第 3 步:编译烧录 ​

操作流程见 2.2 第 4 步

在 VS Code 底部状态栏依次点:编译 → 烧录(或者直接点"编译、烧录并监视")。

烧录方式、串口、芯片型号第一次设好就不用再动了。

第 4 步:拿手电筒照一下 ​

盯着串口,先什么都不做,再用手机手电筒对着光敏电阻照过去:

红框里:前四行是室内环境光,之后用手电筒照上去,读数一路冲到满量程,移开又落回来
红框里:前四行是室内环境光,之后用手电筒照上去,读数一路冲到满量程,移开又落回来

把日志拆开看:

I (10276) ADC: ADC value: 2252, light: 54%     ← 室内环境光,稳定在 2200~2300
I (10776) ADC: ADC value: 2247, light: 54%
I (11276) ADC: ADC value: 2281, light: 55%
I (11776) ADC: ADC value: 2215, light: 54%
I (12276) ADC: ADC value: 3839, light: 93%     ← 手电筒照过来了
I (12776) ADC: ADC value: 4095, light: 100%    ← ⭐ 顶到满量程了
I (13276) ADC: ADC value: 4095, light: 100%
I (13776) ADC: ADC value: 4095, light: 100%
I (14276) ADC: ADC value: 2469, light: 60%     ← 手电筒移开

三件事值得看:

① 时间戳正好差 500

10276 → 10776 → 11276,每条隔 500ms —— 这就是代码里 vTaskDelay(pdMS_TO_TICKS(500)) 的效果,代码写的和实际跑的对上了。

② 不动的时候读数也在小幅跳动

2252 → 2247 → 2281 → 2215,来回差几十。这是 ADC 的正常现象,不是传感器坏了 —— 模拟电压本身就带着一点点噪声,每次采样抓到的瞬间值不会完全一样。

判断有没有问题,看的是变化的幅度:差几十很正常,差几百就说明这一路有毛病了。

③ 连续三次 4095 —— 这是"饱和"了 ⭐

手电筒太近,GPIO1 上的电压已经超过 ADC 的量程上限,再亮也只能报 4095。

饱和意味着这一段完全失去了分辨能力 —— 手电筒离 5 厘米和离 1 厘米,读数都是 4095,你根本分不出来。

读数一直贴着 0 或者 4095 就别往下走

说明分压电路的取值不合适,整个工作区间偏到了量程之外。

改那个 10kΩ 固定电阻的阻值,让正常环境光下的读数落在中间区域(比如 1000~3000),两头才都有余量。

动手做一遍②:LED 呼吸灯 ​

目标:LED 由暗到亮、再由亮到暗,平滑循环,没有一跳一跳的感觉。

用的是 GPIO18 上那颗 LED —— 就是 2.2 点亮过的那三颗之一。硬件一点没变,变的只是驱动它的外设:这次不再用 gpio_set_level() 去开关,改成让 LEDC 去输出方波。

第 2 步:写代码 ​

c
#include <stdio.h>
#include "driver/gpio.h"
#include "driver/ledc.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <stdint.h>

static const char *TAG = "pwm_demo";

#define LED_GPIO GPIO_NUM_18

#define PWM_MODE       LEDC_LOW_SPEED_MODE
#define PWM_TIMER      LEDC_TIMER_0
#define PWM_CHANNEL    LEDC_CHANNEL_0
#define PWM_DUTY_RES   LEDC_TIMER_10_BIT      // 10 位分辨率
#define PWM_FREQUENCY  5000                   // 5kHz
#define PWM_MAX_DUTY   ((1 << 10) - 1)        // 2^10 - 1 = 1023
#define PWM_STEP_DUTY  32                     // 每次加/减多少
#define PWM_STEP_DELAY 20                     // 每一步停多久(毫秒)
#define PWM_TASK_STACK 2048
#define PWM_TASK_PRIO  10

static void pwm_init(void)
{
    // 1. 配置定时器:决定频率和分辨率
    ledc_timer_config_t timer_config = {
        .speed_mode = PWM_MODE,
        .timer_num = PWM_TIMER,
        .duty_resolution = PWM_DUTY_RES,
        .freq_hz = PWM_FREQUENCY,
        .clk_cfg = LEDC_AUTO_CLK,             // 时钟源自动选
    };
    ESP_ERROR_CHECK(ledc_timer_config(&timer_config));

    // 2. 配置通道:决定从哪个引脚输出、用哪个定时器
    ledc_channel_config_t channel_config = {
        .gpio_num = LED_GPIO,
        .speed_mode = PWM_MODE,
        .channel = PWM_CHANNEL,
        .intr_type = LEDC_INTR_DISABLE,       // 不用中断
        .timer_sel = PWM_TIMER,               // 挂到上面那个定时器
        .duty = 0,                            // 开机先灭着
        .hpoint = 0,
    };
    ESP_ERROR_CHECK(ledc_channel_config(&channel_config));
}

// 改占空比:两句必须成对出现,包一层就不会漏
static void set_pwm_duty(uint32_t duty)
{
    ESP_ERROR_CHECK(ledc_set_duty(PWM_MODE, PWM_CHANNEL, duty));
    ESP_ERROR_CHECK(ledc_update_duty(PWM_MODE, PWM_CHANNEL));
}

static void pwm_breath_task(void *arg)
{
    (void)arg;

    while (1) {
        // 由暗到亮
        for (uint32_t duty = 0; duty < PWM_MAX_DUTY; duty += PWM_STEP_DUTY) {
            set_pwm_duty(duty);
            vTaskDelay(pdMS_TO_TICKS(PWM_STEP_DELAY));
        }

        // 补一次最亮:上面是 < 不是 <=,1023 差一点够不着
        set_pwm_duty(PWM_MAX_DUTY);
        vTaskDelay(pdMS_TO_TICKS(PWM_STEP_DELAY));

        // 由亮到暗
        for (int duty = PWM_MAX_DUTY; duty >= 0; duty -= PWM_STEP_DUTY) {
            set_pwm_duty((uint32_t)duty);
            vTaskDelay(pdMS_TO_TICKS(PWM_STEP_DELAY));
        }
    }
}

void app_main(void)
{
    pwm_init();
    xTaskCreate(pwm_breath_task, "pwm_breath", PWM_TASK_STACK, NULL, PWM_TASK_PRIO, NULL);
}

ledc_update_duty() 千万别漏

ledc_set_duty() 只是把新的占空比写进寄存器,不会立刻生效。必须再调一次 ledc_update_duty(),改动才会真正输出出去。

漏了这句,现象是亮度一动不动 —— 代码看着没毛病,灯就是不变。

所以上面把这两句包成了一个 set_pwm_duty():

c
static void set_pwm_duty(uint32_t duty)
{
    ESP_ERROR_CHECK(ledc_set_duty(PWM_MODE, PWM_CHANNEL, duty));
    ESP_ERROR_CHECK(ledc_update_duty(PWM_MODE, PWM_CHANNEL));
}

以后调亮度只调这一个函数,从结构上就漏不掉。这比"记得别忘了"可靠得多。

下降的循环为什么用 int,不用 uint32_t

c
for (int duty = PWM_MAX_DUTY; duty >= 0; duty -= PWM_STEP_DUTY)

如果这里写成 uint32_t,这个循环永远不会结束。

因为无符号数减到负数会绕回一个超大的正数(0 - 32 变成 4294967264),duty >= 0 就永远成立,灯会卡在某个亮度上不动。

倒着数的循环,计数变量要用有符号类型。 这是个很常见的坑,而且编译器一般不报错。

第 3 步:编译烧录 ​

操作流程见 2.2 第 4 步

在 VS Code 底部状态栏依次点:编译 → 烧录(或者直接点"编译、烧录并监视")。

第 4 步:应该看到什么 ​

LED 由暗到亮、再由亮到暗,平滑地呼吸。

算一下一次呼吸多久:

一个方向的步数:1023 ÷ 32 ≈ 32 步
一个方向的耗时:32 × 20ms ≈ 0.64 秒
一次完整呼吸:  0.64 × 2 ≈ 1.3 秒

想让它呼吸得更慢,改 PWM_STEP_DELAY(每步停久一点);想让它更细腻,减小 PWM_STEP_DUTY(步子迈小一点)。两个宏就在代码开头,改完重新烧录就能看出区别。

为什么用 PWM 就能调亮度,用 gpio_set_level 就不行

gpio_set_level() 只有两个选择:全亮或全灭。

PWM 靠的是在一个周期里,亮的时间占多大比例。5kHz 意味着一秒钟亮灭 5000 次 —— 快到眼睛完全跟不上,看到的就是"平均亮度"。

占空比 20% 就是暗,80% 就是亮,灯其实一直在全速闪,只是你看不见。

动手试试 ​

  1. 接一个光敏电阻(配 10kΩ 分压)到 GPIO1,打印读数,用手遮住看数字变化。
  2. 用 PWM 做一个"呼吸灯":亮度从 0 慢慢升到 1023 再降回来。
  3. 把 PWM 频率从 1kHz 降到 30Hz,观察 LED 开始出现肉眼可见的闪烁。
  4. 故意配一个 40kHz + 14 位 的 PWM,看 ledc_timer_config() 返回什么错误;再自己算一下它最大能到几位。