从寄存器到子系统:驱动演进之路
前言:我们为什么要折腾这个
说实话,在上一章我们通过 ioremap() + writel() 的方式点亮 LED 的时候,我真的感觉很有成就感。你看,一行 writel(val, GPIO1_DR) 就能让 LED 亮起来,这种直接操控硬件的感觉真的很爽。
但后来我发现一个问题:这种方式在简单的 demo 里确实没问题,可一旦项目复杂起来,代码会变得非常难以维护。你想想,如果你的驱动里到处都是 writel(0x0209C000, ...) 这样的硬编码地址,过两个月再回头看,你自己都得翻手册才能搞清楚这行代码在干什么。
更糟糕的是,这种直接操作寄存器的方式有个致命问题:它把硬件细节和驱动逻辑混在一起了。如果你明天换了块板子,GPIO 引脚重新分配了,整个驱动代码得大改。这就像是你家里装修,电工把所有电线都裸露在外面,每次想改动个开关位置都得重新布线。
这时候我就在想:有没有一种办法,能让驱动开发者不需要知道底层寄存器地址,也不需要手动配置引脚复用,只要说"我要控制 GPIO1_IO03"就能用?
答案就是:pinctrl + gpio 子系统。
从直接操作寄存器说起
让我们先回顾一下上一章我们是怎么点灯的。如果你还记得,我们做了这么几件事:
// 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);这里有什么问题呢?让我数一数:
硬编码的物理地址到处都是。每个寄存器地址都是硬编码的,换个芯片就得全部改掉。
配置步骤冗长且容易出错。你得记住:时钟→复用→电气特性→方向→数据,少一步都不行。而且这些步骤的顺序还有讲究,搞错了就可能起不来。
代码没法复用。每个驱动都得重复这套流程,没法共享。
没有冲突检测。如果两个驱动都想用同一个引脚,没人提醒你,最后就是两个驱动打起来。
和设备树脱节。我们前面花了那么多功夫学设备树,结果驱动里还是用硬编码地址,设备树的引脚配置信息根本没用到。
说实话,这种写法在嵌入式开发的早期确实很常见。那时候芯片简单,引脚少,驱动也不多,这么写还能接受。但现在都 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, ... │
└─────────────────────────────────────────────────────────────────┘从这个图里你可以看到几个关键点:
我们的驱动只需要调用 GPIO API。像
gpio_set_value()这样的函数,我们不需要知道底层寄存器在哪,也不需要知道怎么操作。GPIO 子系统负责管理所有 GPIO 控制器。无论是 i.MX 的 GPIO 控制器,还是其他厂商的,都通过统一的接口向上提供能力。
pinctrl 子系统负责引脚配置。在我们使用某个 GPIO 之前,pinctrl 子系统已经根据设备树配置好了引脚的复用功能和电气特性。
设备树是配置的中心。所有的硬件配置信息——引脚复用、电气特性、GPIO 编号——都写在设备树里,驱动通过设备树获取这些信息。
为什么要用两个子系统
你可能会问:为什么不能一个子系统搞定,非要分成 pinctrl 和 gpio 两个?
这里有个很重要的设计理念:职责分离。
pinctrl 子系统负责的事情是:这个引脚要配置成什么功能(GPIO?UART?SPI?),它的电气特性是什么(驱动强度?上下拉?)。
gpio 子系统负责的事情是:这个 GPIO 引脚的值是 0 还是 1,它是输入还是输出。
你可以这么理解:pinctrl 是"装修队",进场之前把房间的基础设施搞好;gpio 是"开关",装修好了之后你来控制灯的开关。如果装修没搞好(比如引脚没配置成 GPIO 功能),你按开关也没用。
这种分离的设计还有一个好处:可移植性。你的驱动代码只需要调用 gpio 子系统的接口,至于底层是什么芯片、引脚怎么配置,完全不影响你的代码。换芯片的时候,只需要修改设备树和 pinctrl/gpio 控制器驱动,你的驱动代码可以完全不动。
现在的 LED 驱动是什么样的
让我们先看看最终效果,有个直观的感受。这是使用子系统之后的 LED 驱动代码(硬件抽象层部分):
// 从设备树获取 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 需要配置什么引脚复用、什么电气特性的呢?答案在设备树里:
&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";
};
};这里有两个关键信息:
pinctrl-0 = <&pinctrl_aes_led>告诉 pinctrl 子系统:这个设备需要用哪个引脚配置。led-gpio = <&gpio1 3 GPIO_ACTIVE_LOW>告诉 GPIO 子系统:这个设备用的是 gpio1 控制器的第 3 号引脚,而且它是低电平有效的。
子系统会在设备加载的时候自动读取这些配置,把引脚配置好。等我们的驱动代码执行的时候,引脚已经配置完毕,我们可以直接使用了。
接下来我们要做什么
现在我们对子系统有了整体印象,接下来的章节会深入分析每个部分。我们的学习路径是这样的:
硬件基础:先搞清楚 i.MX 6ULL 的引脚复用和 GPIO 模块是怎么工作的,这是理解子系统的前提。
pinctrl 子系统:深入源码,分析 pinctrl 子系统是如何工作的,它和设备树是如何交互的。
gpio 子系统:同样深入源码,看看 gpio 子系统是如何管理 GPIO 的,它的 API 是怎么实现的。
设备树配置:学习如何在设备树里正确配置 pinctrl 和 gpio。
驱动实现:编写一个完整的 LED 驱动,使用新 API,从设备树读取配置,控制 LED。
编译测试:上板验证,看看我们的驱动是否能正常工作。
内核对比:对比主线内核和 imx 内核的差异,看看这两个内核在子系统实现上有什么不同。
在正式开始之前,我需要提醒你一点:子系统的源码量很大,pinctrl-imx.c 就有两万多行,gpio-mxc.c 也有七百多行。我们不可能逐行分析每一段代码,那样会迷失在细节里。我们的策略是:抓住主线,理解核心流程,遇到细节再看。
另外,我会用主线内核(third_party/linux_mainline)和 imx 内核(third_party/linux-imx)进行对比,让你看看这两个内核在实现上的差异。这对于你以后做内核移植或者驱动兼容会很有帮助。
准备好了吗?让我们先从硬件基础开始,搞清楚我们在操作什么。
下一步: 阅读 02_hardware_foundation.md 了解 i.MX 6ULL 的引脚复用和 GPIO 硬件原理。