3. GPIO中断编程:从轮询到事件驱动
做嵌入式开发,尤其是工业控制,我最常听到的一句话就是:「别让 CPU 傻等」。轮询方式虽然简单,但 CPU 大部分时间都在做无用功。中断,才是真正让系统高效运转的核心机制。这一章,我们就来啃下 CH32V307 的 GPIO 中断。
3.1 外部中断线配置:把引脚和中断控制器连起来
CH32V307 的 GPIO 中断,说白了就是通过 EXTI(外部中断/事件控制器)来管理的。每个 GPIO 引脚都可以作为中断源,但要注意,同一组引脚(比如 PA0、PB0、PC0)共享一个中断线。也就是说,你不能同时让 PA0 和 PB0 都触发 EXTI0 中断。
配置步骤其实很清晰,我一般按这个流程走:
- 使能时钟:GPIO 时钟和 AFIO 时钟都要打开。
- 配置 GPIO 模式:设置为输入模式,带上拉或下拉。
- 连接 EXTI 线:通过 AFIO 将 GPIO 引脚映射到对应的 EXTI 线。
- 配置 EXTI 触发方式:上升沿、下降沿,或者双边沿。
- 使能中断线:在 EXTI 寄存器中使能对应的中断线。
来看一段实际的初始化代码:
void EXTI_Key_Init(void)
{
GPIO_InitTypeDef GPIO_InitStructure = {0};
EXTI_InitTypeDef EXTI_InitStructure = {0};
NVIC_InitTypeDef NVIC_InitStructure = {0};
// 1. 使能时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE);
// 2. 配置 PA0 为上拉输入
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; // 上拉输入
GPIO_Init(GPIOA, &GPIO_InitStructure);
// 3. 连接 EXTI 线 0 到 PA0
GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0);
// 4. 配置 EXTI 线 0
EXTI_InitStructure.EXTI_Line = EXTI_Line0;
EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt;
EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Falling; // 下降沿触发
EXTI_InitStructure.EXTI_LineCmd = ENABLE;
EXTI_Init(&EXTI_InitStructure);
// 5. 配置 NVIC
NVIC_InitStructure.NVIC_IRQChannel = EXTI0_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
}
#define KEY_EXTI_LINE EXTI_Line0,这样换引脚时只需要改一处。
3.2 中断优先级:谁先谁后,得有个规矩
工业控制现场,中断源多得很。定时器、串口、外部信号……如果优先级没配好,高优先级的任务可能被低优先级的打断,系统就乱套了。
CH32V307 用的是 RISC-V 标准的 PLIC(平台级中断控制器)。它支持最多 255 个中断源,每个中断源可以配置独立的优先级。数值越小,优先级越高?不对,这里要注意:数值越大,优先级越高。0 表示中断禁用。
| 优先级值 | 含义 | 典型应用场景 |
|---|---|---|
| 0 | 中断禁用 | 不使用的中断源 |
| 1 | 最低优先级 | 后台数据采集 |
| 2-4 | 中等优先级 | 按键、显示刷新 |
| 5-7 | 高优先级 | 通信、故障保护 |
配置优先级时,我习惯用 NVIC 的 NVIC_SetPriority() 函数。举个例子:
NVIC_SetPriority(EXTI0_IRQn, 2); // 设置抢占优先级为 2
NVIC_SetPriority(USART1_IRQn, 5); // 串口优先级更高
3.3 中断服务函数编写:别在里面干重活
中断服务函数(ISR)是中断的「心脏」。但很多人容易犯一个错误——在 ISR 里写太多代码。你想想看,ISR 执行时间越长,其他中断被阻塞的时间就越长。系统响应就慢了。
我总结了一个「三不原则」:
- 不延时:别用
delay_ms(),那是轮询时代的产物。 - 不等待:别在 ISR 里等某个标志位,用状态机代替。
- 不做复杂计算:浮点运算、大数据处理,统统扔到主循环里。
正确的做法是:ISR 里只做最必要的事——清除中断标志位、设置一个全局标志位,然后立即退出。主循环里再根据标志位去处理具体业务。
volatile uint8_t key_pressed = 0; // 全局标志位
void EXTI0_IRQHandler(void)
{
if(EXTI_GetITStatus(EXTI_Line0) != RESET)
{
// 消抖处理:简单延时后再判断
Delay_Ms(10);
if(GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == 0)
{
key_pressed = 1; // 设置标志位
}
EXTI_ClearITPendingBit(EXTI_Line0); // 清除中断标志
}
}
volatile 修饰。我之前就因为忘了加,编译器优化后主循环永远读不到更新后的值,排查了整整一个下午。
3.4 按键中断实战:从按下到响应
理论说完了,咱们来实战。用按键中断控制一个 LED 的亮灭。按下按键,LED 状态翻转。
硬件连接很简单:
- 按键接 PA0,外部下拉电阻到 GND。
- LED 接 PC0,高电平点亮。
主程序逻辑:
int main(void)
{
// 初始化 LED 引脚
GPIO_InitTypeDef GPIO_InitStructure = {0};
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE);
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOC, &GPIO_InitStructure);
// 初始化按键中断
EXTI_Key_Init();
while(1)
{
if(key_pressed)
{
key_pressed = 0; // 清除标志
GPIO_WriteBit(GPIOC, GPIO_Pin_0,
(BitAction)(1 - GPIO_ReadOutputDataBit(GPIOC, GPIO_Pin_0)));
}
// 其他任务...
}
}
这段代码看起来简单,但有几个细节要注意:
- 消抖:机械按键按下时会有抖动,我用了 10ms 延时做软件消抖。工业现场环境恶劣,有时候需要 20ms 甚至更长。
- 标志位清零时机:一定要在读取完标志位后立即清零,防止重复触发。
- LED 状态翻转:用
1 - GPIO_ReadOutputDataBit()实现,简洁高效。
GPIO_WriteBit() 翻转 LED。结果按键按得快了,LED 闪烁不均匀。后来改成标志位方式,主循环里处理,问题就解决了。记住:ISR 里只做「标记」,不做「动作」。
3.5 知识体系:一张图看懂 GPIO 中断
为了让你更直观地理解整个流程,我画了一张结构图。它展示了从按键按下到 CPU 响应中断的完整路径。
从图上你能看到,一个按键中断要经过 GPIO、EXTI、NVIC 三层才能到达 CPU。每一层都有对应的配置项。我刚开始学的时候,总觉得配置项太多记不住。后来发现,只要理解了数据流,配置就是顺理成章的事。
3.6 小结
这一章我们聊了 GPIO 中断的完整链路。从 EXTI 线的配置,到中断优先级的设定,再到 ISR 的编写规范,最后用按键中断实战串了一遍。核心就一句话:中断是手段,不是目的。别为了用中断而用中断,要清楚你的系统到底需要什么样的实时响应。