4、GPIO裸机驱动开发:从寄存器到实战

好,咱们进入第四讲。GPIO,说白了就是芯片的“手脚”。你想让芯片控制LED亮灭,或者检测按键有没有按下,都得靠它。我刚开始学SoC时,觉得GPIO太简单了,不就是写个1或0嘛。结果真到项目里,中断配置和防抖处理,可是坑了我不少时间。

今天咱们就把GPIO这块彻底吃透。从寄存器原理,到输出控制LED,再到输入检测按键,最后搞定中断和防抖。一条龙讲完。

4.1 GPIO模块寄存器详解

每个GPIO引脚,背后都有一堆寄存器在管着。你想想看,一个引脚既要能输出,又要能输入,还得能触发中断,光靠一个寄存器肯定不行。

以我常用的某款Cortex-M4 SoC为例,GPIO模块通常包含以下几类核心寄存器:

寄存器类型 功能描述 位宽
GPIOx_MODER 模式选择:输入、输出、复用功能、模拟 32位,每2位控制1个引脚
GPIOx_OTYPER 输出类型:推挽或开漏 32位,每1位控制1个引脚
GPIOx_OSPEEDR 输出速度:低速、中速、高速、超高速 32位,每2位控制1个引脚
GPIOx_PUPDR 上下拉配置:无、上拉、下拉、保留 32位,每2位控制1个引脚
GPIOx_IDR 输入数据寄存器,只读 32位,每1位对应1个引脚电平
GPIOx_ODR 输出数据寄存器 32位,每1位控制1个引脚输出
GPIOx_BSRR 位设置/清除寄存器,原子操作 32位,低16位置位,高16位复位
GPIOx_AFRL/AFRH 复用功能选择,每个引脚4位 32位,AFRL控制0-7,AFRH控制8-15

重点提醒:配置GPIO的第一步,永远是使能对应GPIO端口的时钟。我见过太多新手,寄存器配了半天,结果引脚没反应,最后发现是时钟没开。这个坑,我当年也踩过。

4.2 输出控制:点亮LED

LED驱动是GPIO最基础的应用。说白了,就是让引脚输出高电平或低电平。

我个人习惯用BSRR寄存器来控制输出,而不是直接写ODR。为什么?因为BSRR支持原子操作,你写ODR时如果被中断打断,可能会出问题。

来看一段实际代码:

/* 假设LED接在PA0引脚,低电平点亮 */
#define GPIOA_BASE      0x40020000
#define RCC_AHB1ENR     (*(volatile uint32_t *)0x40023830)
#define GPIOA_MODER     (*(volatile uint32_t *)(GPIOA_BASE + 0x00))
#define GPIOA_BSRR      (*(volatile uint32_t *)(GPIOA_BASE + 0x18))

/* 初始化PA0为输出 */
void LED_Init(void)
{
    // 1. 使能GPIOA时钟
    RCC_AHB1ENR |= (1 << 0);
    
    // 2. 设置PA0为输出模式(MODER[1:0] = 01)
    GPIOA_MODER &= ~(0x3 << 0);   // 先清零
    GPIOA_MODER |= (0x1 << 0);    // 设置为输出
    
    // 3. 默认关闭LED(输出高电平)
    GPIOA_BSRR = (1 << 16);       // BR0置位,复位ODR0
}

/* 点亮LED */
void LED_On(void)
{
    GPIOA_BSRR = (1 << 0);        // BS0置位,设置ODR0为1
    // 注意:如果LED是低电平点亮,这里应该用BR
}

/* 熄灭LED */
void LED_Off(void)
{
    GPIOA_BSRR = (1 << 16);       // BR0置位,复位ODR0
}

/* 翻转LED */
void LED_Toggle(void)
{
    GPIOA_ODR ^= (1 << 0);        // 异或操作翻转
}

小技巧:翻转LED时,用ODR异或的方式最简洁。但要注意,如果多个任务同时操作同一个引脚,还是用BSRR更安全。我在做多线程项目时,就因为这个吃过亏。

4.3 输入检测:按键读取

按键检测比LED输出要复杂一些。因为按键按下时,电平会变化,但机械开关会有抖动。

先看最基本的轮询方式读取按键:

/* 按键接在PC13,按下为低电平 */
#define GPIOC_IDR      (*(volatile uint32_t *)(GPIOC_BASE + 0x10))

/* 初始化PC13为上拉输入 */
void Key_Init(void)
{
    // 使能GPIOC时钟
    RCC_AHB1ENR |= (1 << 2);
    
    // 设置PC13为输入模式(MODER[27:26] = 00)
    GPIOC_MODER &= ~(0x3 << 26);
    
    // 使能内部上拉(PUPDR[27:26] = 01)
    GPIOC_PUPDR &= ~(0x3 << 26);
    GPIOC_PUPDR |= (0x1 << 26);
}

/* 读取按键状态,返回1表示按下,0表示未按下 */
uint8_t Key_Read(void)
{
    if((GPIOC_IDR & (1 << 13)) == 0)
    {
        return 1;  // 按下
    }
    else
    {
        return 0;  // 未按下
    }
}

这里有个细节:为什么用内部上拉?因为按键通常一端接地,另一端接GPIO。用内部上拉,按键没按时引脚被拉到高电平,按下时被拉到低电平。省一个外部电阻。

4.4 中断配置:上升沿与下降沿

轮询方式太浪费CPU了。你想想看,CPU一直在那死循环读按键,啥也干不了。所以实际项目中,我们都会用中断。

GPIO中断的配置流程,我总结为四步:

  1. 配置GPIO为输入模式(和上面一样)
  2. 使能SYSCFG时钟,配置外部中断线映射
  3. 配置中断触发方式:上升沿、下降沿或双边沿
  4. 使能NVIC中断,编写中断服务函数

来看完整代码:

/* 配置PC13为下降沿触发中断 */
#define SYSCFG_EXTICR4  (*(volatile uint32_t *)0x40013814)
#define EXTI_IMR        (*(volatile uint32_t *)0x40013C00)
#define EXTI_FTSR       (*(volatile uint32_t *)0x40013C0C)
#define EXTI_PR         (*(volatile uint32_t *)0x40013C14)

void Key_Interrupt_Init(void)
{
    // 1. GPIO配置为上拉输入(同上)
    Key_Init();
    
    // 2. 使能SYSCFG时钟
    RCC_APB2ENR |= (1 << 14);
    
    // 3. 将PC13映射到EXTI13
    SYSCFG_EXTICR4 &= ~(0xF << 4);   // 清零
    SYSCFG_EXTICR4 |= (0x2 << 4);    // 0x2对应GPIOC
    
    // 4. 使能EXTI13中断
    EXTI_IMR |= (1 << 13);
    
    // 5. 配置下降沿触发(按键按下时产生下降沿)
    EXTI_FTSR |= (1 << 13);
    
    // 6. 配置并使能NVIC
    NVIC_EnableIRQ(EXTI15_10_IRQn);
    NVIC_SetPriority(EXTI15_10_IRQn, 1);
}

/* 中断服务函数 */
void EXTI15_10_IRQHandler(void)
{
    if(EXTI_PR & (1 << 13))
    {
        // 清除中断标志位(写1清除)
        EXTI_PR |= (1 << 13);
        
        // 处理按键事件
        // 注意:这里不能做耗时操作
        LED_Toggle();
    }
}

重要提醒:中断服务函数里一定要先清除中断标志位,再处理业务逻辑。我曾经在一个项目中,因为清除标志位的顺序搞反了,导致中断反复触发,系统直接卡死。排查了整整一天才找到问题。

4.5 防抖处理:软件与硬件方案

按键抖动是机械开关的物理特性。按下和松开时,电平会快速跳变几次,持续5-20ms。如果不做防抖,一次按键可能会触发多次中断。

我常用的防抖方案有三种:

4.5.1 软件延时防抖(最简单)

/* 软件延时防抖,适用于轮询方式 */
uint8_t Key_Read_Debounce(void)
{
    if(Key_Read() == 1)  // 第一次检测到按下
    {
        Delay_ms(10);    // 延时10ms,避开抖动期
        if(Key_Read() == 1)  // 再次确认
        {
            // 等待按键松开
            while(Key_Read() == 1);
            return 1;    // 返回有效按键
        }
    }
    return 0;
}

4.5.2 硬件RC滤波(最可靠)

在按键和GPIO之间加一个RC低通滤波器。R取10kΩ,C取0.1μF,时间常数τ=RC=1ms。这样抖动的高频分量就被滤掉了。

嗯,这里要注意,RC滤波会增加响应延迟。如果按键需要快速响应,这个方案就不太合适。

4.5.3 定时器扫描防抖(最推荐)

我个人最推荐这种方式。用定时器每隔10ms扫描一次按键,连续两次读到相同状态才认为有效。

/* 定时器中断中调用,每10ms执行一次 */
static uint8_t key_state = 0;    // 当前按键状态
static uint8_t key_confirm = 0;  // 确认后的状态

void Key_Scan(void)
{
    uint8_t current = Key_Read();
    
    if(current == key_state)
    {
        // 两次采样一致,确认状态
        if(key_confirm != current)
        {
            key_confirm = current;
            if(current == 1)  // 检测到按下
            {
                Key_Event_Handler();  // 触发按键事件
            }
        }
    }
    else
    {
        // 状态不一致,更新采样值
        key_state = current;
    }
}

避坑指南:我曾经在一个工业控制项目中,用了最简单的软件延时防抖。结果因为系统中有多个中断,延时函数被频繁打断,导致防抖时间不准,按键时而灵敏时而迟钝。后来改成定时器扫描方案,问题才彻底解决。所以,在有RTOS或多中断的系统里,千万别用阻塞式延时做防抖。

小结

GPIO看似简单,但要做好并不容易。从寄存器配置到中断处理,再到防抖设计,每一步都有讲究。我建议你在学习时,先拿一块开发板,把LED点亮、按键读取、中断触发这三个实验逐个做通。然后再把防抖加上去,看看效果对比。

下一讲,我们会讲定时器的裸机驱动开发。定时器和GPIO配合起来,能玩出很多花样。到时候见。