ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

内核驱动开发最难啃的硬骨头:片上资源管理框架详解

内核驱动开发最难啃的硬骨头:片上资源管理框架详解 1. 为什么说片上资源管理是驱动开发里最难啃的硬骨头我学内核走到这一篇之前一直自认为对驱动开发已经有了基本概念写个 hello world 模块、操作几个寄存器、处理个中断这些都是常规操作。但真正开始在嵌入式 Linux 平台上写商用驱动时我才发现自己对资源管理这四个字的理解远远不够。先讲一个我自己的真实经历。早年在做 Cortex-M3 裸机项目时操作 GPIO 就是往寄存器里写值开启外设时钟就是设置 RCC 控制寄存器对应的位想要用哪个外设直接操作从来不需要向谁申请。后来转到嵌入式 Linux 平台做 BSP 开发第一次看到设备树里密密麻麻的 clocks、pinctrl、interrupts、dmas 属性时整个人是懵的——为什么驱动一个外设要配置这么多东西为什么我不能像单片机一样直接读写寄存器这个问题背后的本质正是片上硬件资源管理。在 Linux 内核里任何外设 IP 都不是某个驱动私有的一个 UART 的时钟可能同时给 SPI 和定时器用一条物理引脚既可以被 GPIO 使用、也可以被复用成 I2C 或 PWM 功能一个 DMA 控制器要服务几十个外设。如果每个驱动都像裸机那样直接操作全局寄存器驱动之间的相互踩踏就是必然的系统稳定性根本无从谈起。所以内核引入了框架framework的概念用一套统一的接口和状态追踪机制把时钟、引脚、中断、DMA 通道等资源的分配、使能、复用、释放全部管起来。驱动开发者不再直接和寄存器打交道而是通过框架提供的 API 去申请资源拿到之后使用用完再归还。这种申请-使用-释放的模型和用户态 malloc/free 的逻辑一脉相承只是内核在背后做得更深、更严谨。这篇文章是我学习系列里对这一问题的一次系统性梳理。我会从资源管理的分工逻辑讲起走一遍从设备树到驱动 probe 的完整链路然后追踪一次 GPIO 申请的源码级调用过程最后分享我在真实项目中踩过的资源管理相关的坑。对正在学习内核驱动开发、尤其是刚从单片机裸机转过来的朋友这篇应该能帮你把碎片化的知识串成一张完整的图。2. 六大框架的分工逻辑管时钟的、管引脚的、管中断的各司其职Linux 内核把片上资源拆成了几个相对独立的框架每个框架只负责一类资源。理解这个分治思路是入门的关键。我在实际工作中最常打交道的主要是下面这六个。2.1 clock 框架给外设供血clock framework 管理的是 SoC 上的所有时钟源、PLL、分频器和门控。驱动在 probe 阶段通过devm_clk_get拿到一个struct clk指针再用clk_prepare_enable开启时钟用clk_get_rate查询频率用clk_set_rate设置频率。框架内部维护引用计数同一个时钟被多个设备使用时直到最后一个使用者释放时钟才会真正关闭。这个框架我一开始很不适应因为裸机开发里时钟就是打开电源这么简单。但在 Linux 里时钟是分层的一个外设的最终工作频率可能经过 PLL 倍频、多级 divider 分频、最后再经过一个 gate 门控才到达外设。任何一个环节的状态不对外设都可能出现主控寄存器配置没问题、但功能就是不正常的诡异现象。2.2 pinctrl 框架负责引脚复用与电气属性pinctrl 管的是引脚的身份。一颗物理引脚可以作为 GPIO、可以作为 I2C 数据线、也可以作为 PWM 输出这取决于 SoC 内部的引脚复用寄存器。pinctrl 子系统包含两大部分pinmux复用负责选择引脚功能pinconf配置负责上下拉、驱动强度、压摆率等电气属性。设备树里常见的pinctrl-0 iomuxc_uart1_txd这种写法就是把某个引脚配置成 UART1 的 TXD 功能。这里有个关键点驱动里通常不需要主动调用 pinctrl API因为内核在设备 probe 前会自动应用设备树里pinctrl-names指定的 default 状态。这也是很多初学者写了 pinctrl 配置却发现没生效时最困惑的地方——实际上它生效了只是发生在你没注意到的阶段。2.3 gpiolib统一 GPIO 访问接口GPIO 子系统的价值在于把不同 SoC 的 GPIO 控制器差异封装在gpio_chip结构体里。驱动开发者只需要用gpiod_get、gpiod_set_value等 API 操作不需要关心底层寄存器差异。一个特别重要的点现代内核推荐使用基于描述符descriptor的gpiod_*接口而不是过时的gpio_request_one那种基于整数编号的旧接口。这个我在第 5 节会专门展开讲。gpiolib 还负责和 pinctrl 协调防止同一个引脚被两个驱动同时占用——这是它非常核心的职责。2.4 中断子系统genirq irqchip中断框架大家接触得最多request_irq、devm_request_irq这些接口应该都不陌生。但要深入理解片上资源管理必须知道 irq domain 机制SoC 上每个中断控制器都注册一个irq_domain负责把硬件中断号映射为 Linux 的虚拟中断号。设备树里interrupts gpio5 1 IRQ_TYPE_LEVEL_HIGH这种写法最终就是通过 irq domain 完成映射的。这里有个容易忽略的细节一个 GPIO 引脚被配置为中断输入时它横跨了 GPIO 和中断两个框架。gpiolib 内部会注册一个 irq_domain让 GPIO 可以转换为中断使用同时 genirq 负责中断的注册、使能、线程化处理。理解这种跨框架联动对排查中断相关的疑难问题特别有帮助。2.5 dmaengine管理 DMA 通道DMA 通道是典型的共享资源一个 SoC 可能只有十几个 DMA channel但需要 DMA 功能的外设可能有好几十个。dmaengine 框架负责通道的分配、使用和释放驱动侧通过dma_request_chan拿通道然后用device_prep_slave_sg等接口准备传输描述符。这个框架的理解难度比前几个高因为你不仅要会申请通道还要理解 DMA 引擎的硬件描述符链表是如何被内核封装的。而且 dmaengine 不走 devm 管理必须手动释放通道这块我后面会讲到踩坑经历。2.6 regulator / power domain管电压和电源域regulator 框架管理电源轨的电压和开关power domain 管理 SoC 上某个电源域比如 GPU、NPU 所在的域的上电时序和状态。这两个框架在普通驱动里可能不常用但涉及低功耗设计、电源管理时基本绕不开。我把这六个框架整理成一张表方便对照记忆框架管理对象核心 API设备树关键属性clock时钟源/分频/门控devm_clk_get, clk_prepare_enableclocks, clock-namespinctrl引脚复用/电气属性devm_pinctrl_get, pinctrl_select_statepinctrl-0, pinctrl-namesgpiolibGPIO 引脚devm_gpiod_get, gpiod_set_valuegpios, gpio-namesgenirq/irqchip中断devm_request_irq, platform_get_irqinterrupts, interrupt-namesdmaengineDMA 通道dma_request_chandmas, dma-namesregulator电压/开关devm_regulator_get, regulator_enablevcc-supply 等这六个框架之间还会互相调用、互相约束。比如 pinctrl 和 gpiolib 之间要协调引脚占用clock 和 regulator 要在休眠时配合关闭。理解它们的独立职责和协作关系是掌握片上资源管理的核心。3. 资源是怎么递到驱动手里的从设备树到 probe 的完整链路前面讲了框架的分工接下来我把一条完整的资源申请链路走一遍这是理解整个体系最直接的方式。假设我们要写一个挂在 I2C 总线上的触摸屏驱动——这是嵌入式开发里非常常见的外设场景。设备树里通常这样描述i2c3 { touch: touch38 { compatible vendor,touch-ic; reg 0x38; interrupt-parent gpio1; interrupts 13 IRQ_TYPE_EDGE_FALLING; reset-gpios gpio1 12 GPIO_ACTIVE_LOW; vcc-supply reg_3v3; clocks clk_ext_osc; pinctrl-0 pinctrl_touch_reset; pinctrl-names default; }; };当内核枚举到这个节点时首先根据 compatible 找到对应的 i2c_driver然后调用其 probe 函数。在 probe 里驱动代码会这样申请资源static int touch_probe(struct i2c_client *client) { struct device *dev client-dev; struct gpio_desc *reset_gpio; struct regulator *vcc; struct clk *ext_clk; int irq; /* 1. 电源轨先拿到 regulator 引用再使能 */ vcc devm_regulator_get(dev, vcc); if (!IS_ERR(vcc)) regulator_enable(vcc); /* 2. 外部时钟拿到 clock 引用prepare 并 enable */ ext_clk devm_clk_get(dev, NULL); if (!IS_ERR(ext_clk)) clk_prepare_enable(ext_clk); /* 3. GPIO 复位脚以输出低电平的方式申请 */ reset_gpio devm_gpiod_get(dev, reset, GPIOD_OUT_LOW); if (!IS_ERR(reset_gpio)) gpiod_set_value(reset_gpio, 1); /* 4. 中断直接从 i2c_client 里拿已经映射好的中断号 */ irq client-irq; devm_request_threaded_irq(dev, irq, NULL, touch_irq_handler, IRQF_TRIGGER_FALLING | IRQF_ONESHOT, touch, client); return 0; }注意一个细节驱动从头到尾没有自己操作过电源管理单元的寄存器、没有配置过时钟控制器、也没有手工配置过引脚复用。它只是在申请真正干活的是框架。这里是很多人第一道坎为什么驱动里没有调用任何 pinctrl API引脚复用却生效了答案是 I2C 控制器框架在注册 client 之前会自动应用设备树里pinctrl-0对应的 default 状态。内核的 device 模型在调用 probe 前会检查设备节点是否有 pinctrl 属性如果有就自动执行 pinctrl_select_state(dev, PINCTRL_STATE_DEFAULT)。只有当驱动需要在不同工作模式间切换引脚功能时比如从 UART 切到 GPIO 做产线测试才需要手动调用devm_pinctrl_get配合pinctrl_select_state。这种申请-使用-释放的模型本质上和用户态malloc/free是完全一致的思路。内核为此提供了 devm_*managed device resource系列接口它的好处非常明显当 probe 执行到中途失败返回错误时已经申请的资源会被内核自动回滚释放不需要驱动开发者自己写goto err清理路径。我最早写驱动时用的全是手动清理代码又长又容易漏。后来全部换成 devm 接口代码量至少减少三分之一而且资源泄漏的隐患少了很多。举一个典型的对比手动清理版本长这样static int xxx_probe(struct platform_device *pdev) { struct clk *clk; struct regulator *reg; int ret; clk clk_get(pdev-dev, NULL); if (IS_ERR(clk)) { ret PTR_ERR(clk); goto err_clk; } reg regulator_get(pdev-dev, vcc); if (IS_ERR(reg)) { ret PTR_ERR(reg); goto err_reg; } /* ... 业务逻辑 ... */ return 0; err_reg: clk_put(clk); err_clk: return ret; }换成 devm 接口后static int xxx_probe(struct platform_device *pdev) { struct clk *clk; struct regulator *reg; clk devm_clk_get(pdev-dev, NULL); if (IS_ERR(clk)) return PTR_ERR(clk); reg devm_regulator_get(pdev-dev, vcc); if (IS_ERR(reg)) return PTR_ERR(reg); /* ... 业务逻辑 ... */ return 0; }函数退出时无论成功还是失败内核都会自动把 clk 和 regulator 释放干净。当然devm 也不是万能的。有几个资源类型不在 devm 管理范围内我列一下第一DMA channeldma_request_chan返回的通道必须手动dma_release_channel第二request_threaded_irq虽然用devm_request_threaded_irq可以管理但有些驱动对中断释放时机有特殊要求时还是需要手动处理第三devm 接口要挂在正确的 device 上如果挂错了父设备资源释放时机就会变得非常诡异。4. 一次 GPIO 申请的内核源码追踪gpiod_get 背后发生了什么第 3 节展示了驱动侧怎么使用资源但内核在背后到底做了什么我用 gpiod_get 这条线做一次源码级追踪。之所以选 GPIO是因为它是最直观的例子而且它牵扯到与 pinctrl 的协作最能体现资源管理的本质。先梳理一下 API 家族。传统做法是基于整数 GPIO 编号的gpio_request现在新驱动基本不用了。现代推荐写法是gpiod_get(dev, con_id, flags)它基于设备树描述符工作。con_id 对应设备树属性名字比如设备树里reset-gpios对应 con_idreset。完整的调用链大致是这样的gpiod_get(dev, con_id, flags) - gpiod_get_index(dev, con_id, index, flags) - of_find_gpio(dev, con_id, index, flags) - gpiod_get_from_of_node(...) - of_get_named_gpiod_flags(...) - gpio_device_find(...) - gpiod_request_commit(...) - gpio_request_commit - gpiod_configure_flags(...) - gpiod_direction_output / gpiod_direction_input第一层of_find_gpio是在设备树节点里查找名为con_id-gpios的属性解析出 GPIO 控制器 phandle、引脚编号硬件 GPIO number、有效电平标志。index 参数用来支持一个设备有多个同类型 GPIO 的情况比如cd-gpios和wp-gpios通过 con_id 区分如果同名属性是一个数组就通过 index 区分。接着gpiod_get_from_of_node把设备树里的 controller phandle 换成gpio_chip结构体同时完成硬件引脚号到gpio_desc描述符的映射。这里需要提一下 gpiolib 的历史包袱早期版本依赖一个全局整数编号空间gpio base多个控制器拼接在一起编号冲突问题很让人头疼。后来引入gpio_device/gpio_desc后推荐做法是不再关心全局编号全程用 desc 指针操作资源。真正关键的一步是gpiod_configure_flags它根据请求时的 flags 决定引脚是输入还是输出、初始电平是多少。但更有意思的是gpiod_direction_output内部触发的一个联动gpio_chip的request回调如果指向gpiochip_generic_request就会调用pinctrl_gpio_request向 pinctrl 子系统宣告这个引脚现在被 GPIO 使用了。如果设备树里已经把这个引脚复用成其他外设功能pinctrl 就会返回 -EBUSYgpiod_get 随之失败。这正是我在实际项目中遇到最多的 GPIO 报错来源之一日志长这样gpio: pin 3 (gpio-3) status -16 gpiochip_irq_map: irq 83 failed-16 就是 -EBUSY。遇到这种错误第一反应应该是去/sys/kernel/debug/pinctrl/下面查引脚复用表看看这个引脚当前被谁占用而不是怀疑 GPIO 控制器初始化出了问题。我见过太多工程师在这种报错面前对着 GPIO 驱动源码连查半天最后发现是设备树里引脚重复配置了。顺着这条链路还能看到一个设计亮点gpiolib 和 irqchip 的联动。gpiod_to_irq函数会调用gpio_chip-to_irq回调把 GPIO 描述符转换成 Linux 虚拟中断号这个转换通过 irq domain 在gpio_chip内部完成。也就是说一个 GPIO 引脚被配置为中断输入时同时横跨了 GPIO 和中断两个框架两边的生命周期管理都需要考虑。5. 我在实际项目中踩过的四个资源管理坑理论讲再多不如一次真实的调试经历让人印象深刻。下面四个问题都是我在嵌入式 Linux 驱动开发中实际遇到的按症状-排查-根因-修复的顺序记录下来希望对大家有参考价值。5.1 休眠唤醒后外设假死时钟被框架按引用计数关掉了现象一个使用外部晶振的音频 codec系统 suspend 再 resume 之后 codec 不工作了。I2C 通信正常但音频数据是静音寄存器读写也正常就是不出声。排查过程先看 dmesg 有没有报错没有。然后用示波器量 MCLK 引脚发现时钟波形没了。再看时钟树/sys/kernel/debug/clk/clk_summary里对应的 clock 的 enable_count 在 suspend 后变成了 0。根因驱动在 probe 时用devm_clk_get拿到了时钟引用但没有调用clk_prepare_enable而是假设系统启动默认就把时钟打开了。休眠时内核的 clock 框架检查该时钟引用计数为 0就把它正常关闭了resume 后没有驱动再去重新打开它于是外设拿到了一个没有时钟信号的尸体。修复在驱动 probe 里显式调用clk_prepare_enableremove 里对应clk_disable_unprepare。一行代码的事但排查花了我一个下午。教训是永远不要对时钟的默认状态做任何假设内核时钟框架的引用计数机制非常严格它不仅管开没开还管谁在用、用了几次。5.2 引脚报 -EBUSYpinctrl 与 gpio 的占用冲突现象新写的一个 LED 驱动probe 时devm_gpiod_get返回 -EBUSY同样的代码在另一块板子上完全正常。排查过程两个板子的设备树不同。新板子的设备树里这颗 LED 对应的引脚同时出现了 pinctrl 配置和一个音频节点的 pinmux 配置。我进入/sys/kernel/debug/pinctrl/查看引脚 owner发现这个引脚归 audio 驱动所有。根因设备树里手滑audio 节点的 pinmux 引用了和 LED 相同的引脚导致 pinctrl 预留权冲突。这属于设备树配置错误不是驱动代码问题。修复修正设备树让两个节点分别使用不同引脚或者确认 audio 节点确实需要这颗引脚后把 LED 改到空闲引脚上。这个案例给我们的启示是-EBUSY 不一定是代码问题务必第一时间去 debugfs 查引脚占用表用数据说话不要干猜。5.3 中断申请反复失败gpio_to_irq 接口已过时现象移植一个老驱动到新内核代码里用gpio_to_irq(gpio_num)获取中断号在 5.x 内核上报错或者返回一个无效的中断号。排查过程查内核文档和提交记录gpio_to_irq这个基于整数编号的接口在新的 gpiolib 框架里已经被移出推荐路径。旧接口依赖全局 GPIO 编号而新框架期望驱动直接使用中断框架提供的虚拟中断号。根因接口过时。设备树方式下client-irq/platform_get_irq已经能直接从 DT 的interrupts属性拿到映射好的中断号不需要再走 GPIO 中转。如果硬件确实把断电引脚当作中断源正确的做法是在设备树里描述interrupts属性让中断框架帮忙映射而不是在代码里手动gpio_to_irq。修复设备树里加interrupt-parent和interrupts描述驱动里直接用devm_request_threaded_irq(dev, client-irq, ...)。这个问题在我们移植旧内核驱动到新平台时非常常见希望大家能避开。5.4 DMA 通道泄漏只申请不释放现象一个 SPI 驱动反复打开、关闭设备节点每次 open 都会申请 DMA 通道但没有释放最终 DMA channel 被耗尽其他外设申请 DMA 失败系统出现各种稀奇古怪的问题。排查过程dmesg 没有明确报错但从 dmaengine 的 debugfs 节点可以看到可用的 channel 数量在持续减少。代码 review 发现驱动在 open 时调用dma_request_chan申请通道但只在 remove 时才释放。根因设计错误。DMA 通道是有限共享资源应该遵循probe 时申请、remove 时释放的长期占用模型而不是跟随设备节点的 open/close 频率去申请释放。如果必须在 open 时申请就必须在 close 时释放并且所有异常分支都要考虑。修复把dma_request_chan移到 probe 里一次申请全局复用remove 里统一dma_release_channel。这个案例暴露出一个通用教训资源申请的时机应该和资源本身的生命周期匹配而不是和业务操作的频率匹配。用枚举思路来讲你要考虑这个资源是绑定设备生命周期还是绑定业务会话生命周期想清楚了再决定申请位置。6. 顺着这条线往下学给后来者的路线建议与个人体会第 5 节的内容可以视为一种反面教材式的学习路径。如果你和我一样是边做项目边学内核的路线会发现光看懂这些还远远不够。最后分享几点我在这块内容上的进阶思路。第一优先读官方文档别一头扎进源码的汪洋。内核源码树里的 Documentation/clk.rst、Documentation/gpio.rst、Documentation/pinctrl.rst 这些文档虽然年代可能久了点但讲清了设计动机和接口使用场景比我见过的大多数博客都靠谱。建议先用几个晚上把这三篇轮着读透再回头看设备树属性会有一种豁然开朗的感觉。第二学会用 debugfs 验证你的推断。/sys/kernel/debug/clk/clk_summary、/sys/kernel/debug/pinctrl/pins、/sys/kernel/debug/gpio这三个节点是排查资源问题的三件套。我的建议是写完一个驱动后刻意去这几个节点里查一下资源状态确认引用计数、引脚 owner、GPIO 方向是否符合预期。这种主动验证的习惯比遇到问题临时翻 debugfs 要高效得多因为你对正常状态已经有了直观记忆。第三找一个具体的 SoC 平台把源码走读一遍。不同厂商的 pinctrl/gpio/clock 驱动在内部实现上差异巨大但都实现了内核框架定义的回调接口。读一个具体平台的实现能帮你把框架硬件两层联系起来。比如 NXP i.MX 平台的 pinctrl 驱动就是通过寄存器位操作来配置 mux 模式而 STM32MP1 的 pinctrl 驱动则要分析硬件 bank/offset 的布局两者结构完全不同但最终都是通过pinmux_ops的set_mux回调接入内核框架。第四动手写一个小而完整的驱动练手。我的建议是写一个同时使用 clock、gpio、irq 的虚拟字符设备驱动设备树里声明一个外部时钟、一个 GPIO 输出、一个 GPIO 中断然后在驱动里实现 file_operations 和中断处理。这个练习能把对框架 API 的认识变成能力也能真正理解为什么内核要把资源抽象成申请-使用-释放的模型。我第一次完成这个练习的时候豁然开朗。在我整个内核学习过程中前面讲进程调度、内存管理的时候还能靠概念图硬啃到了片上资源管理这一块我才第一次真正意识到内核不是一个单纯的操作系统而更像是一套车队调度系统。每个设备驱动都是申请用车的部门引用计数是排班表pinctrl 是车库钥匙管理员DMA 通道就是那几辆最抢手的工作车。理解了这套调度逻辑再看任何设备驱动的 probe 函数一眼就能看出它申请了哪些资源、生命周期管理是否合理、潜在的风险点在哪里。希望这一篇内容能帮你跨过这道坎。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进