3. GPIO与基本外设控制:GPIO配置与中断、LEDC(PWM)控制、ADC采样、定时器(Timer)使用
各位好,欢迎来到第三章。这一章我们聊聊嵌入式开发里最基础、也最绕不开的东西——GPIO和外设控制。
你可能会想,GPIO谁不会啊?不就是拉高拉低嘛。嗯,没错,但ESP-IDF里有些细节,搞不好会让你调一整天。我刚开始用ESP32时,就因为在GPIO中断里做了个延时,结果系统直接死机。后来才明白,中断服务程序(ISR)里不能干重活,这是铁律。
好,我们一个一个来看。
3.1 GPIO配置与中断
ESP32的GPIO功能很丰富,但配置起来其实就几步:选引脚、设方向、配上下拉、使能中断。我个人习惯用结构体一次性配完,清晰又不容易漏。
核心要点:GPIO配置推荐使用gpio_config_t结构体,而不是逐个调用API。这样代码更紧凑,也方便后期维护。
先看一个典型配置:
// 配置一个按键输入,带下降沿中断
gpio_config_t io_conf = {
.pin_bit_mask = (1ULL << GPIO_NUM_0), // 选择GPIO0
.mode = GPIO_MODE_INPUT, // 输入模式
.pull_up_en = GPIO_PULLUP_ENABLE, // 内部上拉
.pull_down_en = GPIO_PULLDOWN_DISABLE, // 下拉关闭
.intr_type = GPIO_INTR_NEGEDGE // 下降沿触发
};
gpio_config(&io_conf);
配置完别忘了安装中断服务:
// 安装GPIO中断服务
gpio_install_isr_service(ESP_INTR_FLAG_DEFAULT);
// 注册中断回调
gpio_isr_handler_add(GPIO_NUM_0, button_isr_handler, NULL);
我曾经踩过的坑:中断回调函数里千万不要做printf、延时、或者任何阻塞操作。ISR里只做两件事:清标志位、用队列或信号量通知任务。否则轻则看门狗复位,重则系统崩溃。
中断回调的推荐写法:
static void IRAM_ATTR button_isr_handler(void* arg) {
// 只发信号,不做处理
xQueueSendFromISR(gpio_evt_queue, &gpio_num, NULL);
}
然后在任务里处理实际逻辑。这样既快又安全。
3.2 LEDC(PWM)控制
LEDC是ESP32的硬件PWM控制器,说白了就是专门用来产生PWM波形的模块。它支持多通道、多定时器,还能自动调频率。我常用它来控制LED亮度或电机速度。
配置LEDC分三步:
- 配置定时器——决定PWM的频率和分辨率
- 配置通道——绑定引脚和定时器
- 设置占空比——输出想要的PWM波形
看代码:
// 1. 配置定时器
ledc_timer_config_t timer_conf = {
.speed_mode = LEDC_LOW_SPEED_MODE,
.duty_resolution = LEDC_TIMER_13_BIT, // 13位分辨率,0~8191
.timer_num = LEDC_TIMER_0,
.freq_hz = 5000, // 5kHz
.clk_cfg = LEDC_AUTO_CLK
};
ledc_timer_config(&timer_conf);
// 2. 配置通道
ledc_channel_config_t channel_conf = {
.gpio_num = GPIO_NUM_2,
.speed_mode = LEDC_LOW_SPEED_MODE,
.channel = LEDC_CHANNEL_0,
.timer_sel = LEDC_TIMER_0,
.duty = 0,
.hpoint = 0
};
ledc_channel_config(&channel_conf);
// 3. 设置占空比(渐变效果)
ledc_set_fade_with_time(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, 4096, 1000);
ledc_fade_start(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, LEDC_FADE_NO_WAIT);
小技巧:用ledc_set_fade_with_time实现呼吸灯效果,比手动循环平滑得多。硬件自动完成渐变,CPU几乎零负担。
LEDC还有一个好处:它支持多通道独立输出,互不干扰。你可以在一个定时器上挂多个通道,频率一致但占空比各自独立。这在做RGB灯或舵机控制时特别方便。
3.3 ADC采样
ADC就是把模拟电压转成数字值。ESP32有两个SAR ADC模块,总共支持最多18个通道。但要注意,Wi-Fi开启时ADC2会被占用,所以建议优先用ADC1。
| 特性 | ADC1 | ADC2 |
|---|---|---|
| 可用通道数 | 8 (GPIO32~39) | 10 (GPIO0,2,4,12~15,25~27) |
| Wi-Fi影响 | 无影响 | Wi-Fi开启时不可用 |
| 分辨率 | 12位 (0~4095) | 12位 (0~4095) |
| 推荐场景 | 电池电压、传感器 | 仅在Wi-Fi关闭时使用 |
ADC配置也很直接:
// 配置ADC1通道0(GPIO36)
adc1_config_width(ADC_WIDTH_BIT_12); // 12位分辨率
adc1_config_channel_atten(ADC1_CHANNEL_0, ADC_ATTEN_DB_11); // 0~3.3V量程
// 读取采样值
int raw = adc1_get_raw(ADC1_CHANNEL_0);
float voltage = raw * 3.3 / 4095.0; // 换算成电压
注意:ADC的输入电压不能超过3.3V。如果需要测量更高电压,必须用分压电阻。我曾经因为没加分压直接烧了一个引脚,教训深刻。
如果你需要更稳定的采样值,建议做多次采样取平均,或者用硬件连续模式。ESP-IDF也提供了adc_read和adc_read_continuous接口,适合高精度场景。
3.4 定时器(Timer)使用
ESP32有4个64位通用定时器,每个都可以独立配置。定时器说白了就是个硬件计数器,到了设定值就触发中断。我常用它来做周期性任务,比如每10ms采集一次传感器数据。
配置定时器分四步:
- 选择定时器——TIMER_0到TIMER_3
- 设置分频和初始值——决定计时精度
- 设置报警值——触发中断的时间点
- 使能中断并启动
看一个10ms定时器的例子:
// 定时器配置结构体
timer_config_t config = {
.divider = 80, // 80分频,APB时钟80MHz → 1MHz
.counter_dir = TIMER_COUNT_UP, // 向上计数
.counter_en = TIMER_PAUSE, // 初始暂停
.alarm_en = TIMER_ALARM_EN, // 使能报警
.auto_reload = TIMER_AUTORELOAD_EN, // 自动重载
};
timer_init(TIMER_GROUP_0, TIMER_0, &config);
// 设置报警值:10ms = 10000个计数(1MHz下)
timer_set_alarm_value(TIMER_GROUP_0, TIMER_0, 10000);
// 注册中断回调
timer_isr_callback_add(TIMER_GROUP_0, TIMER_0, timer_isr_handler, NULL, 0);
// 启动定时器
timer_start(TIMER_GROUP_0, TIMER_0);
中断回调同样要轻量:
static bool IRAM_ATTR timer_isr_handler(void* arg) {
// 通知任务处理
BaseType_t high_task_awoken = pdFALSE;
xSemaphoreGiveFromISR(timer_sem, &high_task_awoken);
return high_task_awoken == pdTRUE; // 返回是否唤醒高优先级任务
}
我的经验:定时器中断里尽量用信号量或任务通知,而不是直接处理业务逻辑。这样既保证了实时性,又不会阻塞其他中断。
定时器的精度取决于分频系数。80分频下每个计数是1微秒,适合毫秒级任务。如果需要更高精度,可以降低分频,但要注意中断频率不能太高,否则CPU会被中断淹没。
知识结构总览
下面这张图把本章四个外设的关系和配置流程串起来了,方便你对照理解:
这张图把四个外设的配置流程和关键点都列出来了。你可以把它当成速查表,做项目时对照着来,不容易漏步骤。
好了,第三章就到这里。GPIO、PWM、ADC、定时器,这四个东西是嵌入式开发的基石。你把它玩熟了,后面做低功耗、做配网,心里就有底了。