Skip to content

从寄存器到子系统:驱动演进之路

前言:我们为什么要折腾这个

说实话,在上一章我们通过 ioremap() + writel() 的方式点亮 LED 的时候,我真的感觉很有成就感。你看,一行 writel(val, GPIO1_DR) 就能让 LED 亮起来,这种直接操控硬件的感觉真的很爽。

但后来我发现一个问题:这种方式在简单的 demo 里确实没问题,可一旦项目复杂起来,代码会变得非常难以维护。你想想,如果你的驱动里到处都是 writel(0x0209C000, ...) 这样的硬编码地址,过两个月再回头看,你自己都得翻手册才能搞清楚这行代码在干什么。

更糟糕的是,这种直接操作寄存器的方式有个致命问题:它把硬件细节和驱动逻辑混在一起了。如果你明天换了块板子,GPIO 引脚重新分配了,整个驱动代码得大改。这就像是你家里装修,电工把所有电线都裸露在外面,每次想改动个开关位置都得重新布线。

这时候我就在想:有没有一种办法,能让驱动开发者不需要知道底层寄存器地址,也不需要手动配置引脚复用,只要说"我要控制 GPIO1_IO03"就能用?

答案就是:pinctrl + gpio 子系统

从直接操作寄存器说起

让我们先回顾一下上一章我们是怎么点灯的。如果你还记得,我们做了这么几件事:

c
// 1. 映射寄存器地址
IMX6U_CCM_CCGR1 = ioremap(0x020C406C, 4);
MUX_CTL_GPIO1_IO03 = ioremap(0x020E0068, 4);
PAD_CTL_GPIO1_IO03 = ioremap(0x020E02F4, 4);
GPIO1_GDIR = ioremap(0x0209C004, 4);
GPIO1_DR = ioremap(0x0209C000, 4);

// 2. 使能时钟
writel(readl(IMX6U_CCM_CCGR1) | (3 << 26), IMX6U_CCM_CCGR1);

// 3. 配置引脚复用
writel(0x5, MUX_CTL_GPIO1_IO03);

// 4. 配置电气特性
writel(0x10B0, PAD_CTL_GPIO1_IO03);

// 5. 设置方向为输出
writel(readl(GPIO1_GDIR) | (1 << 3), GPIO1_GDIR);

// 6. 点灯
writel(readl(GPIO1_DR) & ~(1 << 3), GPIO1_DR);

这里有什么问题呢?让我数一数:

  1. 硬编码的物理地址到处都是。每个寄存器地址都是硬编码的,换个芯片就得全部改掉。

  2. 配置步骤冗长且容易出错。你得记住:时钟→复用→电气特性→方向→数据,少一步都不行。而且这些步骤的顺序还有讲究,搞错了就可能起不来。

  3. 代码没法复用。每个驱动都得重复这套流程,没法共享。

  4. 没有冲突检测。如果两个驱动都想用同一个引脚,没人提醒你,最后就是两个驱动打起来。

  5. 和设备树脱节。我们前面花了那么多功夫学设备树,结果驱动里还是用硬编码地址,设备树的引脚配置信息根本没用到。

说实话,这种写法在嵌入式开发的早期确实很常见。那时候芯片简单,引脚少,驱动也不多,这么写还能接受。但现在都 2026 年了,我们的系统越来越复杂,如果还用这种方式写驱动,真的会把自己逼疯。

驱动分离与分层的哲学

现在让我们来聊聊 Linux 内核是怎么解决这个问题的。核心思想就四个字:分离与分层

什么叫"分离"?就是把硬件相关的操作和设备驱动的逻辑分开。硬件相关的操作——配置引脚复用、设置电气特性、使能时钟——这些事情应该由专门的子系统来处理,而不是每个驱动都自己写一套。

什么叫"分层"?就是在驱动和硬件之间插入一层抽象,这层抽象负责屏蔽硬件差异,给上层提供统一的接口。

你可以把它理解成搬家。传统的方式是,你每个房间的东西都自己搬,这很累。新的方式是,你请了一支专业的搬家队(子系统),你只需要告诉他们"把这台电视搬到新家",剩下的打包、运输、拆包、摆放都由他们搞定。你不需要知道搬家车怎么开,也不需要知道电视怎么打包,你只需要知道"我要搬电视"这个意图。

在我们的 LED 驱动里,"搬家队"就是 pinctrl 子系统和 gpio 子系统。我们只需要告诉它们"我要用 GPIO1_IO03,设置为输出",剩下的引脚配置、时钟使能都由它们搞定。

这里有个很重要的设计理念:驱动不应该知道硬件的细节。驱动只需要知道"我要控制哪个 GPIO",至于这个 GPIO 的寄存器地址是多少、需要配置哪些位、时钟要不要使能,这些都不是驱动该关心的事情。

pinctrl + gpio 子系统全景图

现在让我们来看看这两个子系统是如何协同工作的。我先给你画个全景图,有个整体印象:

┌─────────────────────────────────────────────────────────────────┐
│                         用户空间程序                              │
│                     open("/dev/AES_LED")                        │
└─────────────────────────────┬───────────────────────────────────┘
                              │ 系统调用

┌─────────────────────────────────────────────────────────────────┐
│                     LED 驱动 (我们的代码)                         │
│                  of_get_named_gpio("led-gpio")                  │
│                  gpio_set_value(gpio, 1)                        │
└─────────────────────────────┬───────────────────────────────────┘
                              │ GPIO API

┌─────────────────────────────────────────────────────────────────┐
│                     GPIO 子系统核心层                            │
│                  (gpiolib.c - 提供统一接口)                      │
└───────────────────┬─────────────────────────────┬───────────────┘
                    │                             │
                    ▼                             ▼
┌───────────────────────────────┐   ┌───────────────────────────────┐
│   GPIO 控制器驱动 (mxc)       │   │   GPIO 控制器驱动 (其他)      │
│   (gpio-mxc.c)                │   │                               │
└───────────────┬───────────────┘   └───────────────────────────────┘
                │ 寄存器操作

┌─────────────────────────────────────────────────────────────────┐
│                    pinctrl 子系统                                │
│              (配置引脚复用和电气特性)                             │
└─────────────────────────────────────────────────────────────────┘
                │ 设备树解析

┌─────────────────────────────────────────────────────────────────┐
│                        硬件寄存器                                │
│   IOMUXC, GPIO_DR, GPIO_GDIR, CCM_CCGR, ...                     │
└─────────────────────────────────────────────────────────────────┘

从这个图里你可以看到几个关键点:

  1. 我们的驱动只需要调用 GPIO API。像 gpio_set_value() 这样的函数,我们不需要知道底层寄存器在哪,也不需要知道怎么操作。

  2. GPIO 子系统负责管理所有 GPIO 控制器。无论是 i.MX 的 GPIO 控制器,还是其他厂商的,都通过统一的接口向上提供能力。

  3. pinctrl 子系统负责引脚配置。在我们使用某个 GPIO 之前,pinctrl 子系统已经根据设备树配置好了引脚的复用功能和电气特性。

  4. 设备树是配置的中心。所有的硬件配置信息——引脚复用、电气特性、GPIO 编号——都写在设备树里,驱动通过设备树获取这些信息。

为什么要用两个子系统

你可能会问:为什么不能一个子系统搞定,非要分成 pinctrl 和 gpio 两个?

这里有个很重要的设计理念:职责分离

pinctrl 子系统负责的事情是:这个引脚要配置成什么功能(GPIO?UART?SPI?),它的电气特性是什么(驱动强度?上下拉?)。

gpio 子系统负责的事情是:这个 GPIO 引脚的值是 0 还是 1,它是输入还是输出。

你可以这么理解:pinctrl 是"装修队",进场之前把房间的基础设施搞好;gpio 是"开关",装修好了之后你来控制灯的开关。如果装修没搞好(比如引脚没配置成 GPIO 功能),你按开关也没用。

这种分离的设计还有一个好处:可移植性。你的驱动代码只需要调用 gpio 子系统的接口,至于底层是什么芯片、引脚怎么配置,完全不影响你的代码。换芯片的时候,只需要修改设备树和 pinctrl/gpio 控制器驱动,你的驱动代码可以完全不动。

现在的 LED 驱动是什么样的

让我们先看看最终效果,有个直观的感受。这是使用子系统之后的 LED 驱动代码(硬件抽象层部分):

c
// 从设备树获取 GPIO 编号
led.gpio_sub_sys_nr = of_get_named_gpio(led.device_tree_node, "led-gpio", 0);

// 设置为输出模式,初始值为 1
gpio_direction_output(led.gpio_sub_sys_nr, 1);

// 设置 GPIO 值
gpio_set_value(led.gpio_sub_sys_nr, 0);  // 点亮

就这么简单!没有物理地址,没有 ioremap,没有寄存器操作。我们只需要告诉子系统"我要用这个 GPIO",剩下的都由子系统搞定。

那么子系统是怎么知道这个 GPIO 需要配置什么引脚复用、什么电气特性的呢?答案在设备树里:

c
&iomuxc {
    pinctrl_aes_led: led_grp {
        fsl,pins = <
            MX6UL_PAD_GPIO1_IO03__GPIO1_IO03    0x10B0
        >;
    };
};

/ {
    imx_aes_led {
        compatible = "imxaes_led";
        pinctrl-names = "default";
        pinctrl-0 = <&pinctrl_aes_led>;
        led-gpio = <&gpio1 3 GPIO_ACTIVE_LOW>;
        status = "okay";
    };
};

这里有两个关键信息:

  1. pinctrl-0 = <&pinctrl_aes_led> 告诉 pinctrl 子系统:这个设备需要用哪个引脚配置。

  2. led-gpio = <&gpio1 3 GPIO_ACTIVE_LOW> 告诉 GPIO 子系统:这个设备用的是 gpio1 控制器的第 3 号引脚,而且它是低电平有效的。

子系统会在设备加载的时候自动读取这些配置,把引脚配置好。等我们的驱动代码执行的时候,引脚已经配置完毕,我们可以直接使用了。

接下来我们要做什么

现在我们对子系统有了整体印象,接下来的章节会深入分析每个部分。我们的学习路径是这样的:

  1. 硬件基础:先搞清楚 i.MX 6ULL 的引脚复用和 GPIO 模块是怎么工作的,这是理解子系统的前提。

  2. pinctrl 子系统:深入源码,分析 pinctrl 子系统是如何工作的,它和设备树是如何交互的。

  3. gpio 子系统:同样深入源码,看看 gpio 子系统是如何管理 GPIO 的,它的 API 是怎么实现的。

  4. 设备树配置:学习如何在设备树里正确配置 pinctrl 和 gpio。

  5. 驱动实现:编写一个完整的 LED 驱动,使用新 API,从设备树读取配置,控制 LED。

  6. 编译测试:上板验证,看看我们的驱动是否能正常工作。

  7. 内核对比:对比主线内核和 imx 内核的差异,看看这两个内核在子系统实现上有什么不同。

在正式开始之前,我需要提醒你一点:子系统的源码量很大,pinctrl-imx.c 就有两万多行,gpio-mxc.c 也有七百多行。我们不可能逐行分析每一段代码,那样会迷失在细节里。我们的策略是:抓住主线,理解核心流程,遇到细节再看。

另外,我会用主线内核(third_party/linux_mainline)和 imx 内核(third_party/linux-imx)进行对比,让你看看这两个内核在实现上的差异。这对于你以后做内核移植或者驱动兼容会很有帮助。

准备好了吗?让我们先从硬件基础开始,搞清楚我们在操作什么。

下一步: 阅读 02_hardware_foundation.md 了解 i.MX 6ULL 的引脚复用和 GPIO 硬件原理。

Built with VitePress