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号引脚上,低电平点亮。
步骤其实就三步:
- 设置GPIO0为输出模式(写GPIO_OUTPUT_EN)
- 写GPIO_OUTPUT_VAL,让第0位为0(低电平)
- 观察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;
}
4.4 按键中断:从轮询到事件驱动
轮询读按键,CPU得一直盯着GPIO引脚,啥也干不了。中断就不一样了——按键按下,CPU才响应,平时该干嘛干嘛。
蜂鸟E203的GPIO中断配置,需要操作三个寄存器:
- GPIO_INT_EN: 使能对应引脚的中断
- GPIO_INT_POL: 设置触发极性(上升沿/下降沿)
- GPIO_INT_PEND: 中断发生后,这里会置1。处理完中断后,必须写1清除这个位。
配置流程如下:
- 设置GPIO_INT_POL,选择下降沿触发(按键按下时电平从高变低)
- 设置GPIO_INT_EN,使能对应引脚的中断
- 在中断服务函数中,读取GPIO_INT_PEND判断中断源
- 处理完业务逻辑后,写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,代码耦合度高,还容易漏掉按键。中断就清爽多了。
整体逻辑我用一张图来表示:
流水灯的主循环代码大致如下:
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这些复杂外设,学起来会轻松很多。