4. GPIO外设驱动:从寄存器到中断的实战

GPIO,说白了就是芯片的“手脚”。你要让LED亮起来,得靠它;你要检测按键有没有按下,也得靠它。这一章,我们就拿蜂鸟E203开发板上的GPIO模块开刀,从寄存器描述一路干到按键中断和LED流水灯。

我个人习惯,学一个新外设,先看数据手册里的寄存器表。别急着写代码,先把“敌人”的底牌摸清楚。

4.1 GPIO模块寄存器全景

蜂鸟E203的GPIO模块,寄存器映射非常规整。我把它整理成了一张表,你写驱动的时候可以随时回来查。

偏移地址 寄存器名称 描述 宽度
0x00 GPIO_INPUT_VAL 输入值寄存器(只读) 16位
0x04 GPIO_OUTPUT_VAL 输出值寄存器 16位
0x08 GPIO_OUTPUT_EN 输出使能寄存器 16位
0x0C GPIO_INT_EN 中断使能寄存器 16位
0x10 GPIO_INT_POL 中断极性寄存器 16位
0x14 GPIO_INT_PEND 中断挂起寄存器(写1清除) 16位

嗯,这里要注意:GPIO_INPUT_VAL 是只读的,你读到的就是引脚当前的电平状态。GPIO_OUTPUT_EN 这个寄存器特别关键——你写输出值之前,必须先把对应的位使能成输出模式。我在项目中遇到过有人忘了设这个,结果LED死活不亮,排查了半天才发现是输出使能没开。

4.2 点亮一颗LED:输出控制实战

先来个最简单的:点亮开发板上的LED0。开发板原理图上,LED0连接在GPIO的第0号引脚上,低电平点亮。

步骤其实就三步:

  1. 设置GPIO0为输出模式(写GPIO_OUTPUT_EN)
  2. 写GPIO_OUTPUT_VAL,让第0位为0(低电平)
  3. 观察LED是否亮起

代码写起来很直接:

// 定义GPIO基地址
#define GPIO_BASE       0x10012000
#define GPIO_OUTPUT_EN  (*(volatile uint32_t *)(GPIO_BASE + 0x08))
#define GPIO_OUTPUT_VAL (*(volatile uint32_t *)(GPIO_BASE + 0x04))

void led_on(void) {
    // 使能GPIO0为输出
    GPIO_OUTPUT_EN |= (1 << 0);
    // 输出低电平,点亮LED
    GPIO_OUTPUT_VAL &= ~(1 << 0);
}
小技巧: 我习惯用 volatile 关键字修饰寄存器指针。编译器有时候会“自作聪明”优化掉你的读写操作,加上 volatile 就是告诉它:“别动我的寄存器,老老实实读写。”

4.3 按键输入与消抖处理

按键输入比输出稍微麻烦一点。麻烦在哪?机械抖动。你按下去的那一瞬间,电平会跳变好几次,如果不做处理,一次按键可能会被误判成十几次。

我曾经在一个项目里吃过这个亏。按键控制电机启停,结果按一下,电机抖得像抽风一样。后来加了消抖,世界清净了。

消抖的经典做法有两种:

  • 硬件消抖: 加RC滤波电路,简单粗暴。
  • 软件消抖: 检测到电平变化后,延时10~20ms再读一次。如果电平稳定,才算有效。

在裸机环境下,我推荐用软件消抖。因为灵活,而且不占额外元件。下面是一个简单的按键读取函数:

#define GPIO_INPUT_VAL (*(volatile uint32_t *)(GPIO_BASE + 0x00))

// 假设按键接在GPIO1,按下为低电平
int key_pressed(void) {
    if ((GPIO_INPUT_VAL & (1 << 1)) == 0) {
        // 检测到低电平,延时消抖
        delay_ms(20);
        // 再次确认
        if ((GPIO_INPUT_VAL & (1 << 1)) == 0) {
            return 1;  // 按键确实按下了
        }
    }
    return 0;
}
注意: delay_ms(20) 在裸机里是忙等待,会占用CPU。如果你的系统对实时性要求高,可以考虑用定时器中断来做消抖计时。不过对于本章的流水灯实验,忙等待完全够用。

4.4 按键中断:从轮询到事件驱动

轮询读按键,CPU得一直盯着GPIO引脚,啥也干不了。中断就不一样了——按键按下,CPU才响应,平时该干嘛干嘛。

蜂鸟E203的GPIO中断配置,需要操作三个寄存器:

  • GPIO_INT_EN: 使能对应引脚的中断
  • GPIO_INT_POL: 设置触发极性(上升沿/下降沿)
  • GPIO_INT_PEND: 中断发生后,这里会置1。处理完中断后,必须写1清除这个位。

配置流程如下:

  1. 设置GPIO_INT_POL,选择下降沿触发(按键按下时电平从高变低)
  2. 设置GPIO_INT_EN,使能对应引脚的中断
  3. 在中断服务函数中,读取GPIO_INT_PEND判断中断源
  4. 处理完业务逻辑后,写1清除GPIO_INT_PEND

代码示例:

#define GPIO_INT_EN   (*(volatile uint32_t *)(GPIO_BASE + 0x0C))
#define GPIO_INT_POL  (*(volatile uint32_t *)(GPIO_BASE + 0x10))
#define GPIO_INT_PEND (*(volatile uint32_t *)(GPIO_BASE + 0x14))

void gpio_interrupt_init(void) {
    // 下降沿触发
    GPIO_INT_POL &= ~(1 << 1);
    // 使能GPIO1中断
    GPIO_INT_EN |= (1 << 1);
}

// 中断服务函数(简化版)
void gpio_irq_handler(void) {
    if (GPIO_INT_PEND & (1 << 1)) {
        // 按键被按下,执行你的逻辑
        // 比如切换LED状态
        // 清除中断挂起位(写1清除)
        GPIO_INT_PEND |= (1 << 1);
    }
}
核心要点: 中断服务函数要短小精悍。我见过有人把延时消抖写在中断里,结果整个系统卡死。正确的做法是:中断里只设一个标志位,主循环里检测标志位再做消抖和处理。

4.5 综合实战:按键中断控制LED流水灯

好了,把上面的知识点串起来。我们做一个完整的实验:

  • 8个LED组成流水灯,循环点亮
  • 按下按键,流水灯方向反转
  • 按键采用中断方式触发

为什么用中断?你想想看,如果主循环一直在跑流水灯,轮询按键的话,你得在流水灯的每一步都去读GPIO,代码耦合度高,还容易漏掉按键。中断就清爽多了。

整体逻辑我用一张图来表示:

按键中断控制流水灯流程图 主循环:流水灯运行 按键按下 → 触发中断 中断服务:设置方向标志 写1清除 GPIO_INT_PEND 主循环检测方向标志 改变流水灯方向 说明:中断服务函数只设置标志位,不处理具体逻辑。 主循环在每次流水灯步进时检查标志位,决定方向。

流水灯的主循环代码大致如下:

volatile int g_dir = 1;  // 1: 正向, -1: 反向

void led_flow(void) {
    static uint8_t pos = 0;
    
    // 全部熄灭
    GPIO_OUTPUT_VAL |= 0xFF;
    // 点亮当前LED(低电平点亮)
    GPIO_OUTPUT_VAL &= ~(1 << pos);
    
    // 根据方向更新位置
    pos = (pos + g_dir) & 0x07;
    
    delay_ms(200);
}

// 中断服务函数(简化)
void gpio_irq_handler(void) {
    if (GPIO_INT_PEND & (1 << 1)) {
        // 反转方向
        g_dir = -g_dir;
        // 清除中断
        GPIO_INT_PEND |= (1 << 1);
    }
}
个人经验: 全局标志位 g_dir 一定要加 volatile。中断和主循环共享变量,不加 volatile 的话,编译器可能把变量值优化到寄存器里,导致主循环永远看不到中断修改后的值。这个坑我踩过,印象很深。

4.6 性能调优小记

GPIO驱动看起来简单,但调优空间其实不小。我提两个点:

  • 寄存器访问尽量用32位操作: 蜂鸟E203的GPIO寄存器是32位对齐的,用 uint32_t 指针访问效率最高。别用 uint8_t 或者位域,编译器会帮你拆成多条指令,白白浪费周期。
  • 中断延迟控制: 中断服务函数里只做最必要的事。像流水灯方向反转这种操作,设个标志位就够了。你要是把延时消抖放进去,中断延迟会飙升,系统响应变慢。

嗯,这一章的内容就到这里。GPIO驱动是嵌入式开发的“基本功”,把寄存器摸透,把中断用熟,后面的I2C、SPI、UART这些复杂外设,学起来会轻松很多。