4. GPIO子系统概述:Linux GPIO子系统架构、GPIO控制器与GPIO号

好,我们进入GPIO子系统的正题了。

说实话,GPIO是嵌入式开发里最基础、也最绕不开的模块。你点个灯、读个按键、控制个外设使能,几乎都离不开它。但很多工程师用了好几年GPIO,对背后的子系统架构还是一知半解。今天我就带你把它彻底理清楚。

4.1 Linux GPIO子系统架构

Linux的GPIO子系统,说白了就是一套标准化的接口框架。它把千奇百怪的硬件GPIO控制器,封装成统一的API给驱动开发者用。你想想看,没有这个框架的话,每换一款SoC,GPIO的操作方式都得重学,那得多痛苦。

整个架构分三层:

  • 硬件层:就是SoC内部的GPIO控制器,或者外接的GPIO扩展芯片(比如PCA9535)。
  • 内核层:GPIO子系统核心代码,提供gpio_request()、gpio_direction_output()、gpio_set_value()这些标准API。
  • 驱动层:具体GPIO控制器的驱动,负责实现底层寄存器操作。

我在项目中遇到过一种情况:某款芯片的GPIO控制器比较特殊,不支持中断触发沿的动态配置。当时我不得不写了一个小补丁,在gpiolib的irq_chip结构体里做了一些hack。嗯,这种时候你就知道,理解架构层次有多重要了。

核心要点:GPIO子系统是典型的"分层抽象"设计。上层驱动不用关心底层寄存器怎么配,只要调用标准API就行。底层驱动只需要实现gpio_chip结构体里的回调函数。

4.2 GPIO控制器与gpio_chip结构体

每个GPIO控制器在内核里都对应一个 struct gpio_chip 结构体。这个结构体就是硬件和内核之间的桥梁。

我挑几个关键字段说说:

字段 说明
label 控制器名字,调试的时候很有用
ngpio 这个控制器管理多少个GPIO
base 全局GPIO号的起始编号
request 申请GPIO时的回调
free 释放GPIO时的回调
direction_input 设置输入方向
direction_output 设置输出方向并设置初始值
get 读取GPIO电平
set 设置GPIO电平

举个例子,假设你有一个GPIO扩展芯片挂在I2C总线上,驱动里会这样注册:

static struct gpio_chip my_expander_chip = {
    .label          = "my-i2c-expander",
    .ngpio          = 8,
    .base           = -1,          // 动态分配
    .request        = my_expander_request,
    .free           = my_expander_free,
    .direction_input  = my_expander_dir_in,
    .direction_output = my_expander_dir_out,
    .get            = my_expander_get,
    .set            = my_expander_set,
    .can_sleep      = true,        // I2C操作可能睡眠
};

个人习惯:我一般把base设为-1,让内核动态分配GPIO号。这样可以避免不同板子间的编号冲突。除非你有非常明确的硬件设计约束,否则别手写固定base值。

4.3 GPIO号的迷思

GPIO号,是很多新手最容易搞混的地方。我刚开始学的时候也踩过坑。

Linux内核里,每个GPIO都有一个全局唯一的编号,范围从0到最大支持的GPIO数。这个编号是怎么来的?

假设你有两个GPIO控制器:

  • 控制器A:管理GPIO0~GPIO31,base=0
  • 控制器B:管理GPIO32~GPIO63,base=32

那么,控制器B的第0号引脚,全局编号就是32。你在设备树里看到的 gpios = <&gpio2 5 GPIO_ACTIVE_LOW>,这里的5是控制器内部的偏移量,不是全局号。

我曾经踩过的坑:在用户空间通过/sys/class/gpio操作时,直接用了设备树里的偏移量5,结果操作的完全是另一个引脚。后来才意识到,sysfs里用的是全局GPIO号。那次排查花了我整整一个下午。

为什么会这样设计?说白了,就是为了解耦。驱动开发者只需要关心自己控制器内部的偏移量,不用管全局编号怎么分配。内核的gpiolib会自动帮你做映射。

4.4 设备树中的GPIO描述

设备树里描述GPIO的方式,其实很直观。看一个典型例子:

&i2c1 {
    status = "okay";

    temp_sensor: temperature-sensor@48 {
        compatible = "ti,tmp102";
        reg = <0x48>;
        interrupt-parent = <&gpio1>;
        interrupts = <15 IRQ_TYPE_EDGE_RISING>;
    };
};

leds {
    compatible = "gpio-leds";
    led0 {
        gpios = <&gpio2 5 GPIO_ACTIVE_HIGH>;
        linux,default-trigger = "heartbeat";
    };
};

这里 &gpio2 5 表示:使用gpio2这个控制器,偏移量是5。内核解析时,会找到gpio2对应的gpio_chip,用它的base加上5,得到全局GPIO号。

我个人习惯在设备树里把GPIO相关的节点注释写清楚,比如:

// GPIO2_5 对应原理图上的 J9-3 引脚,用于控制电源使能

别小看这个习惯。项目做到后期,硬件改版、引脚重分配的时候,这些注释能救你的命。

4.5 用户空间的GPIO访问

虽然我们做内核驱动,但有时候调试阶段需要在用户空间快速验证GPIO功能。Linux提供了两种方式:

  • sysfs接口(老方式,但简单):/sys/class/gpio/
  • chardev接口(新方式,推荐):/dev/gpiochipN

我建议你尽快切换到chardev方式。sysfs接口虽然用起来方便,但已经被标记为deprecated了。而且chardev支持更丰富的操作,比如批量读写、事件监听等。

举个例子,用libgpiod库操作GPIO:

# 读取GPIO状态
gpioinfo gpiochip0

# 设置GPIO13为输出并输出高电平
gpioset gpiochip0 13=1

# 监听GPIO4的上升沿中断
gpiomon --rising-edge gpiochip0 4

一句话总结:GPIO子系统把复杂的硬件操作抽象成了简单的API调用。你只要理解gpio_chip、GPIO号映射、设备树描述这三块,就能应对绝大多数场景。

下一章,我们会深入PinCtrl子系统。到时候你会发现,GPIO和PinCtrl其实是密不可分的两兄弟。嗯,先消化今天的内容吧。