第一章 GPIO基础与按键输入

各位同学,欢迎来到《从零搭建血压计嵌入式开发环境》的第一课。今天咱们聊聊嵌入式开发里最基础、也最容易被忽视的部分——GPIO和按键输入。

说实话,我刚开始做嵌入式那会儿,觉得GPIO不就是个高低电平嘛,有啥好学的?直到有一次,一个简单的按键检测程序,让我在产线上折腾了整整两天……嗯,从那以后,我再也不敢小看这个“基础”了。

1.1 GPIO模式详解

GPIO,全称是General Purpose Input Output,通用输入输出口。说白了,就是芯片上那些可以编程控制的引脚。

但你知道吗?同一个引脚,在不同模式下,电气特性是完全不同的。我见过不少新手,上来就配成推挽输出,结果接了个开漏设备,信号死活不对。

GPIO模式 典型应用 注意事项
推挽输出 驱动LED、蜂鸣器 能输出高低电平,驱动能力强
开漏输出 I2C总线、电平转换 只能输出低电平,高电平靠上拉
浮空输入 按键检测(需外接上拉) 电平不确定,容易误触发
上拉输入 按键检测(常用) 内部上拉电阻,省外部元件
下拉输入 检测高电平有效信号 内部下拉电阻
模拟模式 ADC采样 关闭数字功能,降低功耗

个人经验:在血压计项目中,按键输入我习惯用上拉输入模式。为什么?因为按键按下时接地,松开时被拉到高电平,逻辑清晰,而且省掉了外部上拉电阻,PCB面积能省一点是一点。

1.2 按键消抖原理

按键消抖,这是个老生常谈的问题。但你真的理解为什么要消抖吗?

机械按键按下和松开的瞬间,触点会弹跳。你想想看,一个金属片和另一个金属片碰撞,怎么可能一下子就稳定接触?这个弹跳过程大概持续5-20ms,期间会产生多个高低电平脉冲。

如果不做消抖,会发生什么?

  • 按一次键,程序检测到多次触发
  • 血压计可能一次测量启动好几次
  • 用户界面乱跳,体验极差

消抖的方法主要有两种:

硬件消抖:在按键两端并联一个电容(通常0.1μF),利用电容的充放电特性滤除毛刺。这个方法简单粗暴,但会增加BOM成本。

软件消抖:检测到电平变化后,延时10-20ms再读一次。如果电平一致,才认为是有效按键。我个人更推荐软件消抖,因为零成本,而且灵活。

避坑指南:我曾经在一个项目中,用了10ms延时消抖,结果用户按键太快,程序反应不过来。后来改成“连续采样3次,两次一致即有效”的算法,效果好了很多。

1.3 外部中断配置

轮询检测按键,说白了就是CPU一直在问“你按了吗?你按了吗?”——这太浪费了。更好的方式是外部中断:按键按下时,主动通知CPU。

配置外部中断,一般需要以下几个步骤:

  1. 使能GPIO时钟和中断控制器时钟
  2. 配置GPIO为输入模式(上拉或下拉)
  3. 将GPIO映射到外部中断线
  4. 设置触发方式(上升沿、下降沿、双边沿)
  5. 配置中断优先级
  6. 使能中断

以STM32为例,代码大概是这样的:

// 配置PA0为外部中断,下降沿触发
void EXTI_Config(void)
{
    GPIO_InitTypeDef GPIO_InitStruct;
    EXTI_InitTypeDef EXTI_InitStruct;
    NVIC_InitTypeDef NVIC_InitStruct;
    
    // 1. 使能时钟
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE);
    
    // 2. 配置GPIO
    GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0;
    GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU;  // 上拉输入
    GPIO_Init(GPIOA, &GPIO_InitStruct);
    
    // 3. 映射中断线
    GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0);
    
    // 4. 配置EXTI
    EXTI_InitStruct.EXTI_Line = EXTI_Line0;
    EXTI_InitStruct.EXTI_Mode = EXTI_Mode_Interrupt;
    EXTI_InitStruct.EXTI_Trigger = EXTI_Trigger_Falling;  // 下降沿触发
    EXTI_InitStruct.EXTI_LineCmd = ENABLE;
    EXTI_Init(&EXTI_InitStruct);
    
    // 5. 配置NVIC
    NVIC_InitStruct.NVIC_IRQChannel = EXTI0_IRQn;
    NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 0x01;
    NVIC_InitStruct.NVIC_IRQChannelSubPriority = 0x01;
    NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStruct);
}

注意:同一个中断线不能同时映射多个GPIO。比如PA0和PB0不能同时使用EXTI0线。这是硬件限制,没得商量。

1.4 中断服务函数编写

中断服务函数,就是CPU响应中断后执行的代码。写这个函数有几个铁律:

  • 快进快出——不要在中断里做复杂运算
  • 清除标志位——否则会一直进中断
  • 避免延时——中断里用delay,等于自杀

我见过最离谱的代码,是在中断里调了个printf,结果整个系统卡死。为什么?因为printf本身可能触发中断,形成嵌套,最后栈溢出。

正确的做法是:中断里只做标记,具体处理放到主循环里。

// 全局标志位
volatile uint8_t g_key_pressed = 0;

// 中断服务函数
void EXTI0_IRQHandler(void)
{
    if(EXTI_GetITStatus(EXTI_Line0) != RESET)
    {
        // 简单的软件消抖:延时10ms再检测
        // 注意:这里不能用HAL_Delay,要用简单的循环延时
        for(volatile uint32_t i = 0; i < 10000; i++);
        
        if(GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == 0)
        {
            g_key_pressed = 1;  // 只做标记
        }
        
        EXTI_ClearITPendingBit(EXTI_Line0);  // 清除标志位
    }
}

// 主循环中处理
int main(void)
{
    // 初始化...
    EXTI_Config();
    
    while(1)
    {
        if(g_key_pressed)
        {
            g_key_pressed = 0;  // 清除标记
            // 在这里处理按键逻辑
            // 比如启动血压测量、切换显示模式等
            Key_Process();
        }
        
        // 其他任务...
    }
}

小技巧:中断里用的变量,记得加volatile关键字。否则编译器优化时,可能认为这个变量永远不会被修改,导致逻辑错误。我踩过这个坑,debug了一下午才发现。

好了,这一章的内容就到这里。GPIO看似简单,但它是嵌入式系统的基石。按键输入更是人机交互的第一步。把这些基础打牢,后面的血压计项目才能走得稳。

下一章,咱们聊聊定时器和PWM——怎么用它们来控制血压计的充气泵和泄气阀。到时候见。