本节目标:掌握 GPIO 输出操作 → 理解 PWM 脉宽调制 → 用 LEDC 硬件模块实现呼吸灯
一、本节要解决的问题
嵌入式学习的两步传统:第一步点亮 Helloworld(串口打印),第二步点亮 LED。本节做两件事:
- 点亮并闪烁一个 LED ------ 最基础的 GPIO 高低电平输出;
- 让 LED 亮度渐变(呼吸灯) ------ GPIO 只能输出高/低两种电平,无法输出"一半亮",必须借助 PWM 脉宽调制 和 ESP32 的 LEDC 硬件外设。
二、硬件基础
LED 的驱动电路非常简单:GPIO27 → 限流电阻 → LED → GND。
- GPIO 输出 高电平 3.3V → 回路中有电流 → LED 点亮;
- GPIO 输出 低电平 0V → 无电流 → LED 熄灭。
注意 :教程中开发板的 LED 接在 GPIO27 。而当前仓库
ledc/main/ledc.c里写的是#define LED_GPIO 15,这是本地改动过的值(该工程同时被切到了 esp32s3 目标)。实际接线以自己板子的原理图为准,esp32开发板资料/2、开发板原理图/ESP32开发板原理图1.1.pdf是权威依据。
三、知识点
3.1 GPIO 的四种常见工作模式
| 模式 | 说明 |
|---|---|
| 输出 | 可以设置 GPIO 的高低电平 |
| 输入 | 可以获取外部输入的高低电平,通常需要配置上拉/下拉电阻 |
| 浮空输出 | 可以设置高低电平,但需要在电路外部加上拉电阻 |
| 开漏输入 | 可以获取外部输入的高低电平,但需要在电路外部加上拉电阻 |
本节用的是输出模式,并且把上拉、下拉、中断全部关闭。
3.2 PWM 与占空比
如果直接用 GPIO 输出脉冲,灯会一闪一闪。当脉冲切换频率高到约 25Hz 以上时,人眼就分辨不出闪烁了,只会感觉到"灯变暗了"。
暗多少由 占空比(高电平时间占整个 PWM 周期的百分比)决定:
- 占空比 0% → 全灭;50% → 半亮;100% → 全亮(等同于一直输出高电平);
- 动态改变占空比,就能看到灯从暗到亮、从亮到暗------这就是呼吸灯。
四、代码分析
4.1 工程结构
ledc/
├── CMakeLists.txt
├── main/
│ ├── CMakeLists.txt # idf_component_register(SRCS "main.c" "ledc.c" REQUIRES driver freertos)
│ ├── main.c # app_main(),选择运行哪个 demo
│ ├── ledc.c # 本节全部实现
│ └── ledc.h # 对外声明两个初始化函数
└── sdkconfig
两个演示函数都声明在 ledc.h 中,app_main() 里注释掉一个、留下一个来切换:
c
// 主函数
void app_main(void)
{
//led_flash_init(); //简单led闪烁
led_breath_init(); //呼吸灯
}
4.2 闪烁:GPIO 初始化 + 任务
c
//定义LED的GPIO口
#define LED_GPIO 15
//LED闪烁初始化
void led_flash_init(void)
{
gpio_config_t led_gpio_cfg = {
.pin_bit_mask = (1<<LED_GPIO), //指定GPIO
.mode = GPIO_MODE_OUTPUT, //设置为输出模式
.pull_up_en = GPIO_PULLUP_DISABLE, //禁止上拉
.pull_down_en = GPIO_PULLDOWN_DISABLE, //禁止下拉
.intr_type = GPIO_INTR_DISABLE, //禁止中断
};
gpio_config(&led_gpio_cfg);
xTaskCreatePinnedToCore(led_run_task,"led",2048,NULL,3,NULL,1);
}
流程是:填 gpio_config_t 结构体 → 调用 gpio_config() 把配置写入底层 → 建一个任务循环翻转电平。
c
//LED闪烁运行任务
void led_run_task(void* param)
{
int gpio_level = 0;
while(1)
{
vTaskDelay(pdMS_TO_TICKS(500));
gpio_set_level(LED_GPIO,gpio_level);
gpio_level = gpio_level?0:1;
}
}
gpio_level 在 0/1 之间来回取反,配合 500ms 延时,形成 1Hz 的闪烁。
4.3 呼吸灯:LEDC 初始化
LEDC 需要初始化两个对象,缺一不可:
ledc_timer_config_t:配置定时器,决定 PWM 频率与占空比分辨率;ledc_channel_config_t:配置输出通道,并把通道关联到上面那个定时器。
c
#define LEDC_TIMER LEDC_TIMER_0 //定时器0
#define LEDC_MODE LEDC_LOW_SPEED_MODE //低速模式
#define LEDC_OUTPUT_IO (LED_GPIO) //选择GPIO端口
#define LEDC_CHANNEL LEDC_CHANNEL_0 //PWM通道
#define LEDC_DUTY_RES LEDC_TIMER_13_BIT //分辨率
#define LEDC_DUTY (4095) //最大占空比值,这里是2^13-1
#define LEDC_FREQUENCY (5000) //PWM周期
c
//LED呼吸灯初始化
void led_breath_init(void)
{
//初始化一个定时器
ledc_timer_config_t ledc_timer = {
.speed_mode = LEDC_MODE, //低速模式
.timer_num = LEDC_TIMER, //定时器ID
.duty_resolution = LEDC_DUTY_RES, //占空比分辨率,这里是13位,2^13-1
.freq_hz = LEDC_FREQUENCY, // PWM频率,这里是5KHZ
.clk_cfg = LEDC_AUTO_CLK // 时钟
};
ESP_ERROR_CHECK(ledc_timer_config(&ledc_timer));
//ledc通道初始化
ledc_channel_config_t ledc_channel = {
.speed_mode = LEDC_MODE, //低速模式
.channel = LEDC_CHANNEL, //PWM 通道0-7
.timer_sel = LEDC_TIMER, //关联定时器,也就是上面初始化好的那个定时器
.intr_type = LEDC_INTR_DISABLE,//不使能中断
.gpio_num = LEDC_OUTPUT_IO, //设置输出PWM方波的GPIO管脚
.duty = 0, // 设置默认占空比为0
.hpoint = 0
};
ESP_ERROR_CHECK(ledc_channel_config(&ledc_channel));
...
分辨率与占空比的关系 :13 位分辨率意味着占空比取值是 0 ~ 2¹³−1 = 0 ~ 4095,4095 即 100%。分辨率越高,亮度分级越细腻,但同样频率下占空比可调范围越小。
接下来开启渐变功能并注册回调:
c
//开启硬件PWM
ledc_fade_func_install(0);
//创建一个事件组,用于通知任务渐变完成
s_ledc_ev = xEventGroupCreate();
//配置LEDC渐变
ledc_set_fade_with_time(LEDC_MODE,LEDC_CHANNEL,LEDC_DUTY,2000);
//启动渐变
ledc_fade_start(LEDC_MODE,LEDC_CHANNEL,LEDC_FADE_NO_WAIT);
//设置渐变完成回调函数
ledc_cbs_t cbs = {.fade_cb=ledc_finish_cb,};
ledc_cb_register(LEDC_MODE,LEDC_CHANNEL,&cbs,NULL);
xTaskCreatePinnedToCore(ledc_breath_task,"ledc",2048,NULL,3,NULL,1);
}
两个关键函数的区别:
ledc_set_fade_with_time(模式, 通道, 目标占空比, 时长ms):设定"在 2000ms 内渐变到 4095";ledc_fade_start(..., LEDC_FADE_NO_WAIT):启动 渐变,NO_WAIT表示立刻返回不阻塞,所以必须另想办法知道"渐变什么时候结束"------答案就是回调。
4.4 渐变完成通知:中断回调 → 事件组 → 任务
回调函数在中断上下文中执行,不能做耗时操作,因此它只负责"打个标记":
c
//用于通知渐变完成
static EventGroupHandle_t s_ledc_ev = NULL;
//关灯完成事件标志
#define LEDC_OFF_EV (1<<0)
//开灯完成事件标志
#define LEDC_ON_EV (1<<1)
//渐变完成回调函数
bool IRAM_ATTR ledc_finish_cb(const ledc_cb_param_t *param, void *user_arg)
{
BaseType_t xHigherPriorityTaskWoken;
if(param->duty)
{
xEventGroupSetBitsFromISR(s_ledc_ev,LEDC_ON_EV,&xHigherPriorityTaskWoken);
}
else
{
xEventGroupSetBitsFromISR(s_ledc_ev,LEDC_OFF_EV,&xHigherPriorityTaskWoken);
}
return xHigherPriorityTaskWoken;
}
IRAM_ATTR 把函数放进 IRAM,保证中断期间一定能执行到;在中断里操作事件组必须用 xEventGroupSetBitsFromISR(带 FromISR 后缀的版本),并通过 xHigherPriorityTaskWoken 告知内核是否需要任务切换。
任务侧等待这个事件,并反向启动下一次渐变:
c
//ledc 渐变任务
void ledc_breath_task(void* param)
{
EventBits_t ev;
while(1)
{
ev = xEventGroupWaitBits(s_ledc_ev,LEDC_ON_EV|LEDC_OFF_EV,pdTRUE,pdFALSE,pdMS_TO_TICKS(5000));
if(ev)
{
//设置LEDC开灯渐变
if(ev & LEDC_OFF_EV)
{
ledc_set_fade_with_time(LEDC_MODE,LEDC_CHANNEL,LEDC_DUTY,2000);
ledc_fade_start(LEDC_MODE,LEDC_CHANNEL,LEDC_FADE_NO_WAIT);
}
else if(ev & LEDC_ON_EV) //设置LEDC关灯渐变
{
ledc_set_fade_with_time(LEDC_MODE,LEDC_CHANNEL,0,2000);
ledc_fade_start(LEDC_MODE,LEDC_CHANNEL,LEDC_FADE_NO_WAIT);
}
//再次设置回调函数
ledc_cbs_t cbs = {.fade_cb=ledc_finish_cb,};
ledc_cb_register(LEDC_MODE,LEDC_CHANNEL,&cbs,NULL);
}
}
}
逻辑闭环:关灯渐变结束(占空比=0)→ 触发 OFF 事件 → 任务启动"亮到 4095"的渐变 → 结束后触发 ON 事件 → 任务再启动"暗到 0"的渐变,如此往复,形成呼吸效果。
五、关键 API 速查
| API | 作用 |
|---|---|
gpio_config() |
按 gpio_config_t 配置引脚模式、上下拉、中断 |
gpio_set_level(pin, level) |
设置引脚输出电平 |
ledc_timer_config() |
配置 LEDC 定时器(频率、占空比分辨率) |
ledc_channel_config() |
配置 LEDC 通道并关联定时器、绑定 GPIO |
ledc_fade_func_install(0) |
使能硬件渐变功能(用渐变前必须调用) |
ledc_set_fade_with_time() |
设定目标占空比与渐变时长 |
ledc_fade_start() |
启动渐变,LEDC_FADE_NO_WAIT 为立即返回 |
ledc_cb_register() |
注册渐变完成回调 |
xEventGroupCreate() / xEventGroupWaitBits() |
事件组的创建与等待(任务上下文) |
xEventGroupSetBitsFromISR() |
事件组置位(中断上下文专用) |
六、编译与现象验证
bash
cd esp32-board/ledc
idf.py set-target esp32s3 # 该工程当前目标是 esp32s3
idf.py build
idf.py -p COM<端口> flash monitor
- 调用
led_flash_init()时:LED 以 1Hz 闪烁(亮 500ms、灭 500ms); - 调用
led_breath_init()时:LED 在 2 秒内由亮渐暗,再用 2 秒由暗渐亮,循环不止。