GPIO 子系统基础:从硬件原理到 Linux 操作接口

GPIO 是嵌入式开发里最基础、也最绕不开的外设。说白了,它就是芯片上那些可以编程控制的引脚——你可以让它们输出高电平点个灯,也可以读它们的状态判断按键有没有按下。我刚开始做驱动时,总觉得 GPIO 太简单,不就是读写寄存器吗?后来踩了几个坑才明白,Linux 的 GPIO 子系统把这件事包装得挺讲究,理解透了,后面做 PWM、I2C 模拟、中断触发都会顺手很多。

GPIO 硬件原理:引脚内部长什么样?

先看硬件侧。一个 GPIO 引脚内部,通常包含这几个关键模块:

  • 输出驱动器:推挽或开漏结构,决定引脚能输出什么电平。
  • 输入缓冲器:施密特触发器或简单比较器,把引脚电压转成数字 0/1。
  • 上拉/下拉电阻:内部可配置的弱上拉或弱下拉,防止引脚浮空。
  • 复用选择器:决定这个引脚是 GPIO 还是其他功能(比如 UART TX)。
  • 中断控制器:检测上升沿、下降沿或电平变化,产生中断信号。

全志 T113 的 GPIO 控制器叫 PIO(Port I/O),它管理着 9 组 Bank(PA~PI),每组最多 32 个引脚。每个引脚有 4 个配置寄存器:

寄存器 功能 位宽
GPIO_CFGx 引脚功能选择(输入/输出/外设复用) 每引脚 4 位
GPIO_DAT 数据寄存器,读/写引脚电平 32 位
GPIO_DRVx 驱动能力配置(电流强度) 每引脚 4 位
GPIO_PULx 上拉/下拉电阻配置 每引脚 2 位
我的习惯:调试新板子时,我会先读 GPIO_DAT 寄存器确认引脚默认电平。有一次发现某个引脚输出总是不对,查了半天才发现是 BootROM 把它配成了特殊功能模式,CFG 寄存器没改回来。

Linux GPIO 子系统框架:三层架构

Linux 的 GPIO 子系统分三层,每层各司其职。我画了张图帮你理清关系:

Linux GPIO 子系统三层架构 用户空间 sysfs 接口 / chardev 接口 (libgpiod) 内核空间 GPIO 核心层 (gpiolib) — 提供统一 API 硬件抽象层 GPIO 控制器驱动 (pinctrl + gpio_chip) 全志 T113 PIO 硬件寄存器 调用方向

这张图从左到右看,调用方向是从用户空间往下走到底层硬件。反过来,中断信号从硬件往上传递。我重点说下中间两层:

  • gpiolib(核心层):提供 gpiod_get()、gpiod_set_value() 等统一 API。它不关心底层是哪个芯片,只维护一个全局 GPIO 描述符表。
  • pinctrl + gpio_chip(硬件抽象层):每个 GPIO 控制器注册一个 gpio_chip 结构体,里面包含 .request、.free、.direction_input、.direction_output、.get、.set 等回调函数。pinctrl 负责引脚复用和电气属性配置。
关键点:在 T113 上,PIO 驱动同时实现了 pinctrl 和 gpio_chip。这意味着你在设备树里配引脚功能时,pinctrl 会自动帮你设好 CFG 寄存器,而 gpio_chip 负责数据读写。两者配合,缺一不可。

设备树 GPIO 配置:怎么写才规范?

设备树是描述硬件拓扑的语言。GPIO 相关的节点,通常包含这几部分:

/* 全志 T113 设备树 GPIO 配置示例 */
&pio {
    /* 引脚别名定义 */
    led_pin: led_pin@0 {
        pins = "PB2";
        function = "gpio_out";
        bias-pull-up;
        drive-strength = <10>;
    };

    key_pin: key_pin@0 {
        pins = "PH5";
        function = "gpio_in";
        bias-pull-up;
    };
};

/* 使用 GPIO 的外设节点 */
led {
    compatible = "gpio-leds";
    status = "okay";
    pinctrl-0 = <&led_pin>;
    pinctrl-names = "default";

    power_led {
        gpios = <&pio 2 0 GPIO_ACTIVE_HIGH>;  /* PB2, 高电平点亮 */
        label = "power:blue";
        default-state = "on";
    };
};

button {
    compatible = "gpio-keys";
    status = "okay";
    pinctrl-0 = <&key_pin>;
    pinctrl-names = "default";

    power_key {
        gpios = <&pio 7 5 GPIO_ACTIVE_LOW>;   /* PH5, 低电平按下 */
        label = "power";
        linux,code = <KEY_POWER>;
    };
};

这里有几个容易搞混的地方,我单独列出来:

写法 含义 常见错误
&pio 2 0 Bank 2 (PB), 引脚偏移 0 把 Bank 编号和引脚号搞反
GPIO_ACTIVE_HIGH 高电平表示"有效" 忘记定义宏,直接写 0/1
bias-pull-up 内部上拉 50kΩ 输入引脚没配上拉,导致电平不稳
drive-strength 输出驱动电流 (mA) 设太大可能引起信号过冲
我曾经踩过的坑:有一次设备树里配了 gpios = &pio 2 0 GPIO_ACTIVE_HIGH,但内核死活不认。查了半天发现是 pinctrl 节点里 function = "gpio_out" 写成了 "gpio"——少了个 _out,pinctrl 根本没把引脚切到 GPIO 模式。所以,pinctrl 和 gpios 两个属性必须同时配对,缺一不可。

GPIO 操作接口:sysfs vs chardev

Linux 提供了两种用户空间操作 GPIO 的方式。早期用 sysfs,现在推荐 chardev。我分别讲下。

sysfs 接口(传统方式)

sysfs 把每个 GPIO 暴露为一个文件。操作步骤很简单:

# 导出 GPIO
echo 66 > /sys/class/gpio/export

# 配置方向
echo out > /sys/class/gpio/gpio66/direction

# 写值
echo 1 > /sys/class/gpio/gpio66/value

# 读值
cat /sys/class/gpio/gpio66/value

# 用完释放
echo 66 > /sys/class/gpio/unexport

但 sysfs 有个问题:它用 GPIO 编号(全局整数)来标识引脚。不同内核版本、不同板子,编号可能不一样。而且它不支持中断、不支持批量操作。所以内核社区已经标记它为 deprecated。

chardev 接口(推荐方式)

chardev 接口通过 /dev/gpiochipN 设备文件操作 GPIO。它使用 libgpiod 库,API 更清晰:

# 查看 GPIO 芯片信息
gpioinfo

# 设置输出并写值
gpioset gpiochip0 2=1

# 读取输入
gpioget gpiochip0 5

# 监控中断事件
gpiomon --rising-edge gpiochip0 5

在 C 代码里,使用 libgpiod 的流程是这样的:

#include <gpiod.h>

struct gpiod_chip *chip;
struct gpiod_line *line;
int ret;

/* 打开 GPIO 芯片 */
chip = gpiod_chip_open_by_name("gpiochip0");
if (!chip) {
    perror("gpiod_chip_open");
    return -1;
}

/* 获取引脚(PB2 = chip0, offset 2) */
line = gpiod_chip_get_line(chip, 2);
if (!line) {
    perror("gpiod_chip_get_line");
    gpiod_chip_close(chip);
    return -1;
}

/* 配置为输出,初始高电平 */
ret = gpiod_line_request_output(line, "my-led", 1);
if (ret < 0) {
    perror("gpiod_line_request_output");
    gpiod_chip_close(chip);
    return -1;
}

/* 写值 */
gpiod_line_set_value(line, 0);  /* 灭 */
sleep(1);
gpiod_line_set_value(line, 1);  /* 亮 */

/* 清理 */
gpiod_line_release(line);
gpiod_chip_close(chip);
我建议:新项目直接用 chardev + libgpiod。sysfs 虽然简单,但迟早要淘汰。而且 libgpiod 支持事件监听、批量读写、行配置,调试起来方便很多。我在 T113 上做过测试,gpiod_line_set_value() 的延迟大约 2~5μs,比 sysfs 的 write 系统调用快一个数量级。

调试与故障定位:三板斧

GPIO 出问题,通常就三种情况:电平不对、方向不对、复用冲突。我的调试流程是这样的:

  1. 查设备树:dtc -I dtb -O dts -o dump.dts /sys/firmware/fdt,确认 pinctrl 和 gpios 属性正确。
  2. 查内核日志:dmesg | grep gpio,看有没有 probe 失败或资源冲突。
  3. 查寄存器:用 devmem 直接读 PIO 寄存器,确认 CFG 和 DAT 值。

举个例子,T113 上 PB2 的 CFG 寄存器地址是 0x02000000 + 2*0x24 + 0x00(Bank 偏移 + 引脚组偏移)。你可以这样读:

# 读 PB2 的 CFG 寄存器(假设 PB 是 Bank 2)
devmem 0x02000048 32

# 如果返回值是 0x00000001,说明 PB2 被配成了输出模式
# 如果返回值是 0x00000000,说明是输入模式
注意:用 devmem 读寄存器前,确保内核没有占用该引脚。否则可能读到缓存值,不是真实硬件状态。我习惯先 echo 66 > /sys/class/gpio/unexport 释放掉,再读寄存器。

嗯,GPIO 子系统的基础就这些。从硬件寄存器到设备树配置,再到用户空间接口,这条链路你走通了,后面做任何外设驱动都会顺手很多。记住:先确认硬件电平,再查软件配置——这是调试的黄金法则。