3. I2C总线协议核心原理

GPIO输入和中断,是嵌入式开发里最基础也最常用的功能。说白了,就是让芯片「感知」外部世界的变化。我刚开始学STM32时,第一个实验就是按键控制LED——按下点亮,松开熄灭。别看简单,这里面把GPIO输入模式、中断配置、优先级管理全串起来了。

今天我们就从实战角度,把这块彻底讲透。

3.1 GPIO输入模式:不只是「读引脚」那么简单

很多人觉得GPIO输入就是调用个HAL_GPIO_ReadPin()完事。嗯,其实没那么简单。输入模式有好几种,选错了,轻则信号抖动,重则直接读不到正确电平。

3.1.1 四种输入模式对比

模式 上拉/下拉 典型场景 注意事项
浮空输入 无 外部已有上拉/下拉 引脚悬空时电平不确定
上拉输入 内部上拉(约40kΩ) 按键接地 默认高电平,按下变低
下拉输入 内部下拉(约40kΩ) 按键接VCC 默认低电平,按下变高
模拟输入 无 ADC采样 数字功能关闭

我个人习惯:按键接地 + 上拉输入。为什么?因为大多数开发板按键设计就是一端接地,另一端接GPIO。你想想看,如果选浮空输入,手指碰一下引脚电平就乱跳,那还怎么玩?

小技巧:在CubeMX的GPIO配置界面,Pin Configuration对话框里可以直接选Pull-up/Pull-down。别漏了这一步,我见过有人配了半天中断不触发,最后发现是上拉没开。

3.1.2 输入配置的CubeMX操作

打开CubeMX,选中你要用的GPIO引脚,在右侧的GPIO Output Level选项里——等等,这里要注意:输入模式没有Output Level,你要选的是GPIO mode为Input mode。然后在下方的GPIO Pull-up/Pull-down里选Pull-up。

举个例子,配置PA0为按键输入:

/* 这是CubeMX自动生成的代码,我们看看就好 */
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

代码很简单对吧?但实际项目中,我建议你在初始化之后加个延时,等电平稳定再读。尤其是按键接长线的时候,寄生电容会让电平建立变慢。

3.2 外部中断(EXTI):让CPU别再傻等

轮询读按键有个大问题:CPU得一直盯着那个引脚,啥也干不了。你想想看,如果主循环里有个1秒的延时,按键按下去得等1秒才有反应,这体验也太差了。

中断就是来解决这个问题的。当引脚电平变化时,硬件自动暂停当前任务,跳转到中断服务函数。处理完再回来,就像接了个电话。

3.2.1 EXTI的触发方式

触发方式 说明 适用场景
上升沿触发 低→高跳变时触发 按键释放检测
下降沿触发 高→低跳变时触发 按键按下检测
双边沿触发 任意跳变都触发 编码器、脉冲计数

我在项目中遇到过一个问题:用下降沿触发按键,结果按一次触发了两次中断。后来发现是按键机械抖动导致的。所以硬件上要加RC滤波,软件上要做消抖处理。

避坑指南:我曾经在一个工控项目里,没做消抖直接用了EXTI中断。结果设备在强电磁干扰环境下疯狂误触发,差点把执行机构烧了。从那以后,我所有中断输入都至少加10ms的软件消抖。

3.2.2 CubeMX中配置EXTI

在CubeMX里配置EXTI很简单:

  1. 选中GPIO引脚,比如PA0
  2. GPIO mode选External Interrupt Mode with Rising/Falling edge trigger detection
  3. 根据需求选上升沿、下降沿或双边沿
  4. GPIO Pull-up/Pull-down选Pull-up(按键接地场景)
  5. 在NVIC Settings选项卡里使能EXTI line中断

生成的初始化代码大致是这样:

/* EXTI初始化 */
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING;  // 下降沿触发
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

/* NVIC配置 */
HAL_NVIC_SetPriority(EXTI0_IRQn, 0, 0);
HAL_NVIC_EnableIRQ(EXTI0_IRQn);

3.3 中断优先级管理:别让重要的事被插队

中断优先级,说白了就是「谁更紧急」。STM32的中断优先级分为抢占优先级和子优先级。抢占优先级高的可以打断低的,同级的按子优先级排队。

3.3.1 优先级分组

STM32用4位二进制数表示优先级,通过NVIC_PriorityGroupConfig()来分配:

分组 抢占优先级位数 子优先级位数 典型应用
Group 0 0 4 所有中断同级
Group 1 1 3 简单系统
Group 2 2 2 常用配置
Group 3 3 1 多优先级场景
Group 4 4 0 强抢占需求

我个人习惯用Group 2,也就是4级抢占优先级、4级子优先级。够用,也不容易搞乱。

核心原则:时间敏感的中断(如定时器、通信)给高优先级;按键、GPIO这类可以等一等的,给低优先级。别把按键中断设得比系统滴答定时器还高,否则系统时钟会乱套。

3.3.2 在CubeMX中设置优先级

在NVIC Settings选项卡里,每个中断都可以单独设置Preemption Priority和Sub Priority。CubeMX会自动检查优先级分组是否一致,如果不一致会报错。

举个例子,我们给EXTI0中断设优先级为2,子优先级为1:

HAL_NVIC_SetPriority(EXTI0_IRQn, 2, 1);
HAL_NVIC_EnableIRQ(EXTI0_IRQn);

注意:HAL_NVIC_SetPriority()的第一个参数是IRQn号,第二个是抢占优先级,第三个是子优先级。数值越小,优先级越高。

3.4 HAL库GPIO回调函数:中断处理的优雅写法

早期标准库时代,我们直接在中断服务函数里写业务逻辑。结果就是中断函数又长又乱,还容易出问题。HAL库引入了回调机制,把中断处理和业务逻辑分开了。

3.4.1 回调函数的工作流程

当EXTI中断触发时,硬件跳转到对应的中断服务函数(如EXTI0_IRQHandler())。这个函数由HAL库实现,它会:

  1. 检查中断标志位
  2. 清除中断标志位
  3. 调用HAL_GPIO_EXTI_Callback()回调函数

我们只需要重写这个回调函数,在里面写自己的业务逻辑就行。

/* 在用户文件中重写回调函数 */
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    if(GPIO_Pin == GPIO_PIN_0)
    {
        /* 按键按下,翻转LED */
        HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_0);
        
        /* 这里可以加消抖延时 */
        HAL_Delay(20);
        
        /* 等待按键释放 */
        while(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET);
    }
}
注意:回调函数是在中断上下文中执行的。不要在回调里做耗时操作,比如打印、延时太长、复杂计算。否则会影响其他中断的响应。我一般只在回调里设标志位,真正的处理放到主循环里。

3.4.2 多引脚共用回调

多个EXTI中断共用一个回调函数,通过GPIO_Pin参数区分。比如PA0和PC13都配置为EXTI中断:

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    switch(GPIO_Pin)
    {
        case GPIO_PIN_0:
            /* PA0按键处理 */
            break;
        case GPIO_PIN_13:
            /* PC13按键处理 */
            break;
        default:
            break;
    }
}

这样写清晰多了。每个引脚的处理逻辑独立,互不干扰。

3.5 知识结构图:GPIO输入与中断全景

下面这张图把本章的核心知识点串起来了。从GPIO输入模式的选择,到EXTI中断的配置,再到优先级管理和回调函数的使用,一目了然。

GPIO输入与中断知识结构图 GPIO输入与中断知识结构 GPIO输入模式 浮空输入 上拉输入 下拉输入 模拟输入 EXTI外部中断配置 上升沿触发 下降沿触发 双边沿触发 中断优先级管理(NVIC) 抢占优先级(4级) 子优先级(4级) 优先级分组(Group 2) HAL_GPIO_EXTI_Callback() 在回调中只设标志位 主循环处理业务逻辑

3.6 实战要点总结

说了这么多,最后给你几个实战中一定会用到的要点:

  • 输入模式选型:按键接地用上拉输入,按键接VCC用下拉输入,外部已有上拉/下拉用浮空输入
  • 中断触发选择:按键按下用下降沿,按键释放用上升沿,编码器用双边沿
  • 消抖不能省:硬件加RC滤波,软件加10-20ms延时或定时器消抖
  • 优先级分配:时间敏感的中断给高优先级,按键等交互中断给低优先级
  • 回调函数:只设标志位,不做耗时操作,真正的处理放到主循环
我的经验:刚开始做中断时,总想把所有逻辑都塞进回调函数里。后来发现这样调试特别痛苦——中断一多,互相干扰,查问题查到崩溃。现在我的原则是:回调函数不超过10行代码,超过的统统用标志位+主循环处理。