3. GPIO驱动开发:寄存器映射、输入输出与中断
GPIO,说白了就是芯片的“手脚”。你要让LED亮,得靠它;你要读按键,也得靠它。我刚开始接触RISC-V时,觉得GPIO不就是写个1、读个0嘛,有什么难的?结果第一次调板子,LED死活不亮——后来发现是寄存器地址映射错了。嗯,这一章咱们就把GPIO的底裤扒干净。
3.1 GPIO寄存器映射——找到硬件的“门牌号”
在RISC-V系统里,外设寄存器都挂在系统总线上。每个寄存器都有一个固定的基地址+偏移量。我习惯把寄存器映射看作“门牌号”:基地址是小区,偏移量是单元号。
核心概念:GPIO寄存器映射 = 基地址 + 偏移量。基地址由芯片手册给出,偏移量由GPIO模块内部定义。
以常见的RISC-V SoC(比如GD32VF103或K210)为例,GPIO的基地址通常是0x40010000。内部寄存器偏移如下:
| 寄存器名称 | 偏移量 | 功能描述 |
|---|---|---|
| GPIO_CTRL | 0x00 | 控制寄存器(使能/复位) |
| GPIO_DIR | 0x04 | 方向寄存器(0=输入,1=输出) |
| GPIO_DATA | 0x08 | 数据寄存器(读/写引脚电平) |
| GPIO_IE | 0x0C | 中断使能寄存器 |
| GPIO_IP | 0x10 | 中断挂起寄存器 |
在C代码里,我习惯用结构体指针来映射:
// GPIO寄存器映射结构体
typedef struct {
volatile uint32_t CTRL; // 0x00
volatile uint32_t DIR; // 0x04
volatile uint32_t DATA; // 0x08
volatile uint32_t IE; // 0x0C
volatile uint32_t IP; // 0x10
} gpio_reg_t;
// 基地址宏定义
#define GPIO_BASE_ADDR 0x40010000
#define GPIO ((gpio_reg_t *)GPIO_BASE_ADDR)
我的习惯:所有外设寄存器都用 volatile 修饰。编译器优化会“吃掉”看似无用的读写,不加 volatile 的话,中断标志位可能永远读不到新值。
3.2 输出模式——让LED亮起来
输出模式最简单:设置方向为输出,然后往数据寄存器写1或0。但这里有个坑——很多RISC-V芯片的GPIO输出不是直接写DATA寄存器,而是通过“置位/清除”寄存器来避免读-改-写冲突。
举个例子,我手头这块板子用的是“set/clear”方式:
// 设置GPIO0为输出
GPIO->DIR |= (1 << 0); // 位0设为输出
// 输出高电平(置位)
GPIO->SET = (1 << 0); // 只置位位0,其他位不变
// 输出低电平(清除)
GPIO->CLR = (1 << 0); // 只清除位0,其他位不变
为什么不用直接写DATA?你想想看,如果两个中断服务程序同时操作同一个GPIO端口,直接写DATA会导致“读-改-写”竞争。SET/CLR寄存器是原子操作,一次写入只影响指定引脚。
我曾经踩过的坑:某次项目里,我直接用 GPIO->DATA |= (1 << 5); 来点亮LED。结果在中断里也操作了同一个端口,LED偶尔会闪一下。后来换成SET寄存器,问题消失。记住:多任务环境下,原子操作是王道。
3.3 输入模式——读取按键状态
输入模式需要设置方向为输入,然后读取DATA寄存器。但输入信号有抖动问题,尤其是机械按键。我一般会做“去抖”处理。
基本输入代码:
// 设置GPIO1为输入
GPIO->DIR &= ~(1 << 1); // 位1设为输入
// 读取引脚电平
uint32_t pin_state = (GPIO->DATA >> 1) & 0x01;
if (pin_state) {
// 高电平
} else {
// 低电平
}
去抖处理——我常用的软件去抖方法:
- 第一次读到低电平(按键按下),不立即响应
- 延时10~20ms(用定时器或空循环)
- 再次读取,如果还是低电平,确认按键按下
- 释放时同理
// 简单的去抖函数
int debounce_read(uint8_t pin) {
if ((GPIO->DATA & (1 << pin)) == 0) {
// 第一次检测到低电平
delay_ms(10); // 延时10ms
if ((GPIO->DATA & (1 << pin)) == 0) {
return 1; // 确认按下
}
}
return 0;
}
经验之谈:延时10ms是经验值。如果按键质量差,可以加到20ms。但别超过50ms,否则用户会觉得“卡”。
3.4 中断配置——让CPU“主动”响应
轮询模式太浪费CPU了。你想想看,CPU一直在那里“你按了吗?你按了吗?”,啥正事都干不了。中断模式才是正经做法——按键按下时,硬件自动通知CPU。
RISC-V的中断控制器(PLIC或CLINT)负责把外设中断路由到CPU核心。GPIO中断的配置步骤:
- 设置GPIO引脚为输入模式
- 配置中断触发方式(上升沿、下降沿、高电平、低电平)
- 使能GPIO中断(写IE寄存器)
- 在PLIC中使能对应的中断源
- 设置中断优先级
- 全局使能中断(开mstatus的MIE位)
代码示例:
// 配置GPIO1为下降沿中断
GPIO->DIR &= ~(1 << 1); // 输入模式
GPIO->IE |= (1 << 1); // 使能中断
GPIO->IP |= (1 << 1); // 清除挂起位(防止误触发)
// PLIC配置(假设GPIO中断号为13)
PLIC->ENABLE[0] |= (1 << 13); // 使能中断源13
PLIC->PRIORITY[13] = 1; // 设置优先级(1最低,7最高)
PLIC->THRESHOLD = 0; // 设置阈值(0表示所有中断都通过)
// 全局使能中断
__asm__ volatile("csrsi mstatus, 8"); // 设置MIE位
注意:不同RISC-V芯片的PLIC寄存器布局可能不同。有的用 ENABLE 数组,有的用 ENABLE_BASE + 偏移。一定要看芯片手册。
3.5 中断服务程序——处理“按键事件”
中断来了,CPU跳转到中断向量表。RISC-V的机器模式中断入口通常是 0x1C(如果使用mtvec直接跳转)。在中断服务程序里,要做三件事:
- 读取IP寄存器,判断是哪个引脚触发的中断
- 处理事件(比如点亮LED、记录按键值)
- 清除中断挂起位(写IP寄存器)
- 通知PLIC中断处理完成(写CLAIM寄存器)
// GPIO中断服务程序(简化版)
void gpio_irq_handler(void) {
uint32_t pending = GPIO->IP; // 读取挂起位
if (pending & (1 << 1)) {
// GPIO1触发了中断
// 处理按键事件...
GPIO->IP |= (1 << 1); // 清除中断标志
}
// 通知PLIC处理完成
PLIC->CLAIM = 13; // 写中断号到CLAIM寄存器
}
我曾经犯过的错:在中断服务程序里调用 delay_ms()。中断里做延时,整个系统都卡死了。记住:中断服务程序要“短平快”,复杂逻辑放到主循环或任务里处理。
3.6 轮询模式——简单场景下的选择
轮询模式虽然“笨”,但在某些场景下更合适:
- 系统资源紧张:没有多余的中断控制器资源
- 实时性要求不高:比如读取温度传感器,每秒读一次就够了
- 调试阶段:先轮询确认硬件正常,再切中断
轮询代码很简单:
void poll_gpio(void) {
while (1) {
if (debounce_read(1)) {
// 按键按下,执行动作
GPIO->SET = (1 << 0); // 点亮LED
} else {
GPIO->CLR = (1 << 0); // 熄灭LED
}
// 做其他事情...
}
}
轮询的缺点很明显:CPU占用率100%。你想想看,如果系统还要跑网络协议栈、显示驱动,轮询模式根本扛不住。
3.7 本章知识结构图
下面这张图总结了GPIO驱动开发的整体流程:
3.8 中断与轮询的对比
| 特性 | 轮询模式 | 中断模式 |
|---|---|---|
| CPU占用率 | 高(持续检查) | 低(事件触发) |
| 响应延迟 | 取决于轮询周期 | 微秒级 |
| 代码复杂度 | 低 | 中(需配置PLIC) |
| 适用场景 | 简单系统、调试阶段 | 多任务、实时系统 |
| 功耗 | 高(CPU不停跑) | 低(CPU可休眠) |
我的建议:新手先从轮询开始,确认硬件和寄存器映射正确后,再切换到中断。这样出问题时,你能快速定位是硬件问题还是中断配置问题。