GPIO 外设驱动开发:从点灯到中断

GPIO,说白了就是芯片的「数字手脚」。你想让它输出高电平就输出,想读取外部信号就读取。鸿蒙的 GPIO 驱动模型把这事包装得挺清爽,但底层那些坑,一个都不少。

我最早接触鸿蒙 GPIO 驱动时,第一反应是「这不就是读写寄存器吗?」后来发现,鸿蒙的 HDF(Hardware Driver Foundation)框架加了一层抽象,让驱动可以跨芯片复用。嗯,这个设计思路是对的,但你要是没搞懂底层映射关系,调试起来照样抓瞎。

GPIO 基础概念

GPIO 全称 General Purpose Input Output,通用输入输出口。每个 GPIO 引脚可以独立配置为输入或输出模式。输入模式下,你可以读取外部电平(0 或 1);输出模式下,你可以控制引脚输出高或低电平。

但实际项目中,GPIO 远不止这么简单。你想想看,一个引脚还涉及:

  • 上拉/下拉电阻:引脚悬空时,电平不确定。内部上拉电阻把它拉到高电平,下拉拉到低电平。
  • 驱动能力:有些引脚能输出 2mA,有些能输出 8mA。驱动 LED 或继电器时,这个参数很关键。
  • 复用功能:同一个引脚可能是 GPIO,也可能是 I2C、UART、PWM 的通道。配置错了,功能就乱了。
我的习惯:拿到开发板第一件事,先看芯片手册的 GPIO 章节,把每个引脚的默认状态、复用选项、电气特性过一遍。这一步省不了,否则后面调试全是猜。

鸿蒙 GPIO 驱动模型

鸿蒙的 GPIO 驱动模型分三层:

层级 说明 我遇到过的问题
应用层 通过 HDF IO Service 调用 GPIO 接口 权限配置不对,接口返回 -1
驱动框架层 HDF 统一管理 GPIO 资源,提供标准 API 引脚号映射错误,导致操作了错误的寄存器
芯片适配层 对接具体芯片的 GPIO 寄存器操作 不同芯片的 GPIO 控制器基地址不同,要仔细核对

核心 API 就几个:GpioWrite、GpioRead、GpioSetDir、GpioIrqRequest。但每个 API 背后都对应着一串寄存器操作。鸿蒙帮你封装好了,可你要是不知道底层在干什么,出了问题根本无从下手。

关键点:鸿蒙 GPIO 驱动使用「引脚号」而非「物理引脚」来标识。引脚号 = 控制器编号 × 每组引脚数 + 组内偏移。这个映射关系搞错了,点灯可能点到隔壁引脚上。

GPIO 输出控制 LED

点灯是嵌入式开发的「Hello World」。鸿蒙里控制 LED 的流程很直接:

  1. 获取 GPIO 设备句柄
  2. 设置引脚方向为输出
  3. 写入高电平或低电平

代码示例:

#include "gpio_if.h"

int32_t LedControl(uint16_t gpioNum, uint8_t status)
{
    int32_t ret;

    // 设置引脚为输出方向
    ret = GpioSetDir(gpioNum, GPIO_DIR_OUT);
    if (ret != 0) {
        printf("Set dir failed, ret=%d\n", ret);
        return ret;
    }

    // 写入电平状态
    ret = GpioWrite(gpioNum, status);
    if (ret != 0) {
        printf("Write gpio failed, ret=%d\n", ret);
        return ret;
    }

    return 0;
}

这段代码看起来简单,但我曾经踩过一个坑:GpioSetDir 和 GpioWrite 之间没有延时,有些芯片的 GPIO 方向切换需要几个时钟周期才能稳定。如果你紧接着写电平,可能写了个寂寞。

避坑指南:我曾经在某个项目中,LED 死活不亮。查了半天,发现是 GPIO 引脚复用了 PWM 功能。配置 GPIO 之前,一定要确保引脚没有被其他外设占用。

GPIO 输入读取按键状态

读取按键状态,核心是处理「抖动」。机械按键按下和释放时,电平会快速跳变几次,这就是抖动。如果不做消抖处理,一次按键可能被误判为多次。

读取流程:

  1. 设置引脚方向为输入
  2. 读取引脚电平
  3. 软件消抖(延时 + 再次读取确认)

代码示例:

#include "gpio_if.h"
#include "osal_time.h"

int32_t ReadKey(uint16_t gpioNum)
{
    uint8_t value;
    int32_t ret;

    // 设置输入方向
    ret = GpioSetDir(gpioNum, GPIO_DIR_IN);
    if (ret != 0) {
        return ret;
    }

    // 第一次读取
    ret = GpioRead(gpioNum, &value);
    if (ret != 0) {
        return ret;
    }

    // 延时 10ms 消抖
    OsalMSleep(10);

    // 第二次读取,确认电平稳定
    ret = GpioRead(gpioNum, &value);
    if (ret != 0) {
        return ret;
    }

    return value;
}

消抖延时多少合适?我一般用 10ms~20ms。太短了消不干净,太长了影响响应速度。你想想看,如果用户按一下键要等 50ms 才有反应,体验就很差了。

中断模式驱动开发

轮询读取按键,CPU 一直在忙等,浪费资源。中断模式才是正经做法:引脚电平变化时,硬件自动触发中断,CPU 暂停当前任务去处理。

鸿蒙 GPIO 中断配置步骤:

  1. 注册中断回调函数
  2. 设置中断触发条件(上升沿、下降沿、双边沿、高电平、低电平)
  3. 使能中断

代码示例:

#include "gpio_if.h"

// 中断回调函数
int32_t GpioIrqHandler(uint16_t gpioNum, void *data)
{
    printf("GPIO %d interrupt triggered!\n", gpioNum);
    // 在这里处理按键逻辑
    return 0;
}

int32_t InitGpioIrq(uint16_t gpioNum)
{
    int32_t ret;

    // 设置输入方向
    ret = GpioSetDir(gpioNum, GPIO_DIR_IN);
    if (ret != 0) return ret;

    // 注册中断,触发条件为上升沿
    ret = GpioIrqRequest(gpioNum, GPIO_IRQ_TRIGGER_RISING, GpioIrqHandler, NULL);
    if (ret != 0) {
        printf("Irq request failed, ret=%d\n", ret);
        return ret;
    }

    // 使能中断
    ret = GpioIrqEnable(gpioNum);
    if (ret != 0) {
        printf("Irq enable failed, ret=%d\n", ret);
        return ret;
    }

    return 0;
}
我的经验:中断回调函数里不要做耗时操作,比如打印日志、延时。中断上下文里时间很宝贵,你占太久会影响系统实时性。正确的做法是:在回调里只设置一个标志位,然后在主循环或任务中处理具体逻辑。

中断触发条件怎么选?按键按下时电平从高变低,用下降沿触发;释放时从低变高,用上升沿触发。如果你既要检测按下又要检测释放,就用双边沿触发。

避坑指南:我曾经在中断回调里直接调用了 OsalMSleep,结果系统直接死机。中断里不能调用会引起调度或阻塞的函数,这是铁律。另外,多个 GPIO 共用同一个中断号时,要在回调里判断是哪个引脚触发的。

本章知识结构

GPIO 外设驱动开发知识体系 GPIO 驱动开发 基础概念 驱动模型 输出控制 输入读取 上拉/下拉 驱动能力 复用功能 应用层接口 HDF 框架层 芯片适配层 设置方向 写入电平 轮询读取 软件消抖 中断模式 核心:引脚号映射 → 方向配置 → 电平读写 → 中断注册,每一步都要确认芯片手册 避坑:复用功能冲突、中断上下文阻塞、消抖延时选择

这张图把 GPIO 驱动开发的核心脉络理清楚了。从基础概念到驱动模型,再到输出、输入和中断,每个分支都有对应的知识点和注意事项。我个人建议你把这图打印出来贴在工位上,写代码时扫一眼,能少走很多弯路。