3. 外设驱动开发基础

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 {
    // 低电平
}

去抖处理——我常用的软件去抖方法:

  1. 第一次读到低电平(按键按下),不立即响应
  2. 延时10~20ms(用定时器或空循环)
  3. 再次读取,如果还是低电平,确认按键按下
  4. 释放时同理
// 简单的去抖函数
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中断的配置步骤:

  1. 设置GPIO引脚为输入模式
  2. 配置中断触发方式(上升沿、下降沿、高电平、低电平)
  3. 使能GPIO中断(写IE寄存器)
  4. 在PLIC中使能对应的中断源
  5. 设置中断优先级
  6. 全局使能中断(开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直接跳转)。在中断服务程序里,要做三件事:

  1. 读取IP寄存器,判断是哪个引脚触发的中断
  2. 处理事件(比如点亮LED、记录按键值)
  3. 清除中断挂起位(写IP寄存器)
  4. 通知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驱动开发的整体流程:

GPIO驱动开发知识结构图 GPIO驱动开发核心流程 寄存器映射 配置方向(DIR) 输出模式 输入模式 写SET/CLR寄存器 读DATA + 去抖 轮询模式 中断模式 PLIC + IE + IP 💡 选择依据: 实时性要求 / 资源占用 / 开发阶段

3.8 中断与轮询的对比

特性 轮询模式 中断模式
CPU占用率 高(持续检查) 低(事件触发)
响应延迟 取决于轮询周期 微秒级
代码复杂度 低 中(需配置PLIC)
适用场景 简单系统、调试阶段 多任务、实时系统
功耗 高(CPU不停跑) 低(CPU可休眠)

我的建议:新手先从轮询开始,确认硬件和寄存器映射正确后,再切换到中断。这样出问题时,你能快速定位是硬件问题还是中断配置问题。