1. 从 board_init_r 看 U-Boot 设备模型的骨架搭建逻辑
搞嵌入式的人对 U-Boot 都不陌生,但真正把它的设备模型(driver model,简称 dm)吃透的人其实不多。大多数人改板子的时候就是照着已有的板级文件抄一遍,能跑就行,至于board_init_r里那一串dm_开头的函数到底在干什么,往往是模糊的。我当初也是这么过来的,直到有一次调试一个 I2C 设备死活 probe 不上,才被迫把这条链路从头到尾捋了一遍,捋完之后发现整个 dm 骨架的设计其实非常清晰,只是官方文档写得比较散。
这篇内容就是把我自己踩坑之后的理解整理出来,围绕board_init_r这个函数,讲清楚 U-Boot 的 dm 驱动骨架到底是怎么一步步搭起来的。适合已经能编译 U-Boot、能烧录、但对 dm 内部机制还比较模糊的嵌入式工程师,也适合想从传统board_init_f/board_init_r那套老式初始化流程迁移到 dm 框架的朋友。读完之后你至少能做到:知道每个dm_调用发生在什么阶段、设备树里的节点是怎么变成udevice的、驱动是怎么和udevice绑定的、以及 probe 到底在什么时候被触发。
先把结论摆出来:U-Boot 的 dm 骨架本质上是一个两阶段扫描 + 延迟绑定 + 按需 probe的机制。board_init_r里做的事情,核心就是把设备树里的节点信息先"登记"进来,把驱动"挂"上去,但真正让硬件动起来的 probe,很多是推迟到具体驱动第一次被使用的时候才发生的。理解了这个"登记"和"激活"分离的设计,后面所有的疑惑基本都能自己推出来。
2. board_init_r 在启动流程里的位置与职责
2.1 从 board_init_f 到 board_init_r 的交接
要讲board_init_r,得先知道它前面发生了什么。U-Boot 的启动分两个大阶段:board_init_f和board_init_r。前者运行在重定位之前,内存还没完全就绪,主要干的是初始化 DRAM、串口、计算重定位地址这些"生存必需"的事情;后者运行在重定位之后,代码已经搬到 RAM 里了,这时候才有条件去做那些依赖完整内存环境的事情,比如设备模型的初始化。
这个分界点很关键。因为 dm 框架需要动态分配内存来创建udevice、uclass、driver这些结构体,如果放在board_init_f阶段,内存管理器还没起来,根本没法 malloc。所以 dm 的初始化天然只能放在board_init_r里。你在board_init_f里是看不到dm_init这类调用的,这不是设计者偷懒,而是被内存环境逼出来的必然选择。
board_init_r的典型签名是void board_init_r(gd_t *new_gd, ulong dest_addr),它拿到重定位后的全局数据指针和目的地址,然后开始一系列初始化。在较新的 U-Boot 版本里,这个函数内部会依次调用dm_init_and_scan、initr_*系列函数、dm_scan_other等,最后进入run_main_loop或者main_loop。dm 的骨架就是在这个函数的前半段搭起来的。
2.2 board_init_r 里 dm 相关调用的顺序
不同版本的 U-Boot 在board_init_r里的具体调用顺序会有差异,但大致的骨架是稳定的。以常见的 ARM 平台为例,dm 相关的调用大致按这个顺序出现:
dm_init_and_scan(false)—— 初始化 dm 核心并扫描设备树- 各种
initr_*函数 —— 比如initr_env、initr_serial、initr_net等 dm_scan_other(false)—— 扫描非设备树来源的设备- 进入主循环
这里有个容易搞混的点:dm_init_and_scan的参数是pre_reloc_only,传false表示"不只是重定位前的设备,全部都要扫描"。在board_init_r阶段我们传false,因为这时候重定位已经完成,所有设备都应该被纳入管理。
注意:如果你在调试时发现某个设备在
board_init_r之后依然没有对应的udevice,第一件事就是确认它的设备树节点有没有被正确编译进 dtb,以及它的u-boot,dm-pre-reloc或u-boot,dm-spl属性是否影响了扫描范围。
2.3 为什么 dm 初始化要放在这么靠前的位置
有人可能会问,为什么 dm 初始化要放在board_init_r的这么靠前的位置,而不是等所有东西都准备好了再搞?原因在于,后面大量的initr_*函数本身就依赖 dm。比如initr_serial需要通过 dm 找到串口设备,initr_net需要通过 dm 找到网卡设备。如果 dm 骨架没搭好,这些函数就没法通过uclass_get_device之类的接口拿到设备句柄。
所以顺序上必须是"先搭骨架,再填血肉"。骨架就是dm_init建立起来的uclass链表和udevice链表,血肉就是后续各个initr_*里对具体设备的 probe 和配置。这个先后关系搞反了,系统直接起不来。
3. dm 骨架的三大核心数据结构
3.1 udevice:设备的运行时代表
udevice是 dm 框架里最核心的结构体,一个udevice就代表一个"设备实例"。注意是实例,不是类型。比如你有两个 I2C 控制器,那就有两个udevice,但它们可能共用同一个driver。
udevice里几个关键字段值得记住:
driver—— 指向这个设备使用的驱动uclass—— 指向这个设备所属的 uclassparent—— 父设备指针,体现设备树的层级plat—— 平台数据(platform data),来自设备树或板级代码priv—— 驱动的私有数据,由驱动自己定义flags—— 状态标志,比如DM_FLAG_ACTIVATED表示已经 probe 过
plat和priv的区别是新手最容易搞混的。简单说,plat是"描述这个设备是什么"的数据,通常从设备树解析而来,在绑定阶段就填好了;priv是"驱动运行过程中需要记住什么"的数据,在 probe 阶段才分配和初始化。打个比方,plat像是设备的身份证,priv像是驱动的工作笔记。
3.2 uclass:设备的分类管理者
uclass是"设备类"的概念,比如所有 I2C 控制器属于UCLASS_I2C,所有 GPIO 属于UCLASS_GPIO。每个 uclass 有自己的操作函数集uclass_driver,定义了这类设备的通用行为,比如post_probe、pre_remove、child_post_bind等。
uclass 存在的意义是提供一层抽象。上层代码想用一个 I2C 设备,不需要知道具体是哪个厂商的控制器,只需要通过uclass_get_device(UCLASS_I2C, ...)拿到一个udevice,然后调用 uclass 定义的标准接口就行。这就是驱动模型解耦的核心价值。
在board_init_r的 dm 初始化过程中,uclass 是按需创建的。当扫描到一个设备,发现它所属的 uclass 还不存在,就会先创建这个 uclass,把对应的uclass_driver挂上去,然后再把设备挂到这个 uclass 下面。
3.3 driver:设备与代码的桥梁
driver结构体把"设备"和"操作代码"连接起来。它里面最关键的是of_match表,用来做设备树节点和驱动的匹配。当 dm 扫描到一个设备树节点时,会遍历所有已注册的 driver,用of_match里的compatible字符串去比对,匹配上了就把这个 driver 绑定到设备上。
driver里还有bind、probe、remove、unbind这几个回调。bind在绑定阶段调用,负责解析设备树、填充plat;probe在激活阶段调用,负责真正初始化硬件、填充priv。这个 bind 和 probe 的分离,就是前面说的"登记"和"激活"分离的具体体现。
| 结构体 | 职责 | 创建时机 | 关键字段 |
|---|---|---|---|
| udevice | 代表一个设备实例 | 扫描设备树时 | driver, uclass, parent, plat, priv |
| uclass | 管理一类设备 | 首次遇到该类设备时 | uclass_driver, dev_head |
| driver | 连接设备与代码 | 编译时静态注册 | of_match, bind, probe |
4. dm_init_and_scan 的内部执行链路
4.1 dm_init:搭起最基础的框架
dm_init_and_scan的第一步是dm_init。这个函数做的事情相对简单但很关键:初始化gd->dm_root,创建根设备root,初始化 uclass 链表头。根设备是一个特殊的udevice,它不对应任何真实硬件,但作为整棵设备树的根存在,所有顶级设备都是它的子设备。
dm_init还会根据配置决定是否初始化dm_root的plat。在设备树里,根节点/对应的就是dm_root,它的compatible通常是"u-boot,dm-root"之类。这一步完成后,dm 框架就有了一个可以挂载子设备的"根"。
我个人的经验是,如果你在调试时打印gd->dm_root,发现它是 NULL,那基本可以确定dm_init没有被调用,或者调用失败了。这种情况常见于你自己裁剪了board_init_r但忘了保留 dm 初始化调用。
4.2 dm_scan_fdt:从设备树批量登记设备
dm_init之后是dm_scan_fdt,这是骨架搭建的主力。它会遍历设备树,对每个节点尝试创建udevice。具体流程是:
- 从根节点的子节点开始递归遍历
- 对每个节点,检查它是否有
compatible属性(没有的话可能是纯容器节点,跳过或特殊处理) - 用
compatible去匹配已注册的 driver - 匹配成功则创建
udevice,调用 driver 的bind回调 - 把
udevice挂到对应的 uclass 和父设备下
这里有个细节:dm_scan_fdt默认只扫描标记了u-boot,dm-pre-reloc的节点,还是扫描全部节点,取决于传入的参数。在board_init_r里我们传false,所以是扫描全部。但在 SPL 阶段,为了节省空间,通常只扫描u-boot,dm-pre-reloc的节点。
实操心得:设备树节点的
status属性如果是"disabled",dm 扫描时会跳过它。我遇到过好几次设备 probe 不上,最后发现是设备树里status忘了改成"okay"。这个坑很隐蔽,因为编译不会报错,运行时也不会有明显提示。
4.3 dm_scan_platdata 与 dm_scan_other:补充非设备树设备
不是所有设备都来自设备树。有些老平台或者特殊设备是通过板级代码里的platdata数组静态定义的。dm_scan_platdata就是负责扫描这些静态定义的设备,把它们也纳入 dm 管理。
dm_scan_other则是一个弱函数,允许具体平台自己实现额外的扫描逻辑。比如某些平台有动态生成的设备,就可以在这里挂进去。这两个函数保证了 dm 框架的兼容性——既支持现代的纯设备树方式,也支持传统的静态定义方式。
4.4 扫描完成后的状态
dm_init_and_scan返回时,dm 骨架已经搭好了:所有设备树里的设备都有了对应的udevice,driver 的bind回调都调用过了,plat数据都填充好了,uclass 也都创建好了。但注意,这时候大部分设备的probe还没有被调用,硬件还没有真正初始化。
这个状态可以理解为"花名册已经建好了,每个人都知道自己是谁、归哪个部门管,但还没开始干活"。真正让设备干活,要等到后续initr_*函数或者驱动使用者主动调用device_probe或uclass_get_device的时候。
5. 绑定与探测:bind 和 probe 的分离设计
5.1 bind 阶段到底做了什么
bind是设备与驱动"联姻"的时刻。当dm_scan_fdt找到一个匹配的设备树节点和驱动后,会调用驱动的bind回调。这个回调的典型工作是:
- 解析设备树节点里的属性,填充
udevice->plat - 做一些不需要硬件访问的准备工作
- 如果有子设备,可能需要在这里处理
以 I2C 控制器驱动为例,bind里通常会读取reg属性拿到寄存器基地址,读取clock-frequency拿到总线频率,把这些存到plat里。但这时候不会去读写 I2C 寄存器,因为硬件可能还没上电,时钟可能还没使能。
bind阶段的一个重要约束是:不能做任何可能失败的硬件操作。因为bind的返回值虽然可以表示失败,但失败处理比较麻烦,而且bind是在扫描阶段批量调用的,一个失败可能影响后续设备。所以约定俗成,bind只做"纯软件"的准备工作。
5.2 probe 阶段才是真正的硬件初始化
probe是设备"上岗"的时刻。当某个设备第一次被使用时,dm 框架会调用它的probe回调。probe里做的事情包括:
- 使能时钟、复位控制器
- 配置寄存器
- 分配和初始化
priv数据 - 注册子设备(如果有)
- 设置
DM_FLAG_ACTIVATED标志
probe的触发是惰性的。也就是说,如果一个设备从来没被使用过,它的probe就永远不会被调用。这个设计的好处是启动快——不需要在启动时初始化所有硬件,只初始化真正用到的。对于 U-Boot 这种追求快速启动的场景,这个优化很有价值。
但惰性 probe 也带来一个问题:probe 的时机不确定,调试时不好定位。我的做法是在probe函数入口加一句debug打印,这样从启动日志里就能看到每个设备的 probe 顺序,排查依赖问题特别有用。
5.3 probe 的顺序与依赖处理
probe 的顺序由依赖关系决定。如果设备 A 依赖设备 B,那么使用 A 之前必须先 probe B。dm 框架通过uclass_get_device_by_phandle这类接口来解析依赖,确保被依赖的设备先 probe。
举个实际例子:一个 I2C 温度传感器,它的probe需要先通过uclass_get_device_by_phandle(UCLASS_I2C, ...)拿到它挂载的 I2C 总线设备。这个调用会触发 I2C 总线的 probe(如果还没 probe 的话),保证总线先就绪,然后传感器才能通信。
这个依赖链是自动解析的,不需要手动排序。但前提是设备树里的phandle引用要写对。我踩过的坑是:设备树里i2c-bus = <&i2c1>写成了i2c-bus = <&i2c0>,结果传感器跑到另一条总线上去了,怎么都读不到数据。
5.4 手动触发 probe 的场景
虽然 probe 大多是惰性的,但有些设备需要在启动阶段就强制 probe。比如串口,因为要用来打印日志,必须在initr_serial里主动 probe。这时候会用到uclass_get_device或device_probe显式触发。
uclass_get_device(UCLASS_SERIAL, 0, &dev)这个调用会做两件事:找到 UCLASS_SERIAL 下的第 0 个设备,如果它还没 probe 就 probe 它。这个"找到并激活"的语义是 dm 里最常用的接口模式。
注意:
uclass_get_device的第二个参数是设备序号,不是任意 ID。序号是按设备在 uclass 里的注册顺序分配的,从 0 开始。如果你有多个同类设备,序号顺序可能和你想的不一样,最好用uclass_get_device_by_name或uclass_get_device_by_ofnode来精确定位。
6. 实操:跟踪一次完整的 dm 初始化过程
6.1 准备一个可调试的环境
要真正理解 dm 骨架,光看代码不够,得动手跟踪一次。我建议用一个 QEMU 能跑的 ARM 平台,比如qemu_arm或vexpress_ca9x4,这样不需要真实硬件,编译和运行都快。
配置上打开 dm 的调试输出:
CONFIG_DM_DEBUG=y CONFIG_DEBUG_UART=yCONFIG_DM_DEBUG会打开 dm 核心的调试打印,能看到每个设备的 bind 和 probe 过程。编译后运行,日志里会出现类似这样的输出:
dm_scan_fdt: scanning node /soc/serial@10000000 dm_bind: bound driver serial_pl01x to device serial@10000000 dm_probe: probing device serial@10000000这些日志就是骨架搭建过程的直接证据。
6.2 在关键函数下断点
如果条件允许,用 GDB 连上 QEMU,在dm_init_and_scan、dm_scan_fdt、device_probe这几个函数下断点,单步跟踪。你会看到:
dm_init_and_scan进入后先调dm_init,创建 root 设备- 然后调
dm_scan_fdt,开始递归遍历设备树 - 每遇到一个匹配的节点,就创建
udevice,调bind - 扫描完成后返回,此时
gd->dm_root下已经挂了一串设备
这个过程用 GDB 看一遍,比读十遍代码都管用。我第一次跟的时候,看到udevice一个个被创建出来挂到链表上,那种"原来如此"的感觉很强烈。
6.3 观察 probe 的触发时机
继续跟踪,你会发现在dm_init_and_scan返回后,大部分设备还是未 probe 状态。然后进入initr_serial,这时候uclass_get_device(UCLASS_SERIAL, ...)被调用,串口设备的probe才被触发。
再往后,initr_net触发网卡 probe,initr_mmc触发 MMC 控制器 probe。每个initr_*函数负责激活一类设备。这个"按需激活"的模式,就是 U-Boot 快速启动的关键。
6.4 用 dm 命令在运行时查看状态
U-Boot 命令行里有个dm命令,可以查看 dm 的运行时状态。常用子命令:
dm tree—— 打印设备树,显示所有 udevice 的层级关系dm uclass—— 按 uclass 列出设备dm devres—— 查看设备资源dm drivers—— 列出所有注册的驱动
dm tree特别有用,它能直观地展示骨架结构。输出类似:
root |-- serial@10000000 |-- i2c@10010000 | |-- eeprom@50 |-- mmc@10020000从缩进就能看出父子关系,从名字能看出设备树节点路径。调试设备找不到的问题时,先dm tree看一眼设备在不在,在的话再看它有没有 probe,基本就能定位问题。
7. 常见问题与排查技巧实录
7.1 设备没有出现在 dm tree 里
这是最常见的问题。设备树里明明写了节点,但dm tree里看不到。排查顺序:
- 确认 dtb 是否真的包含了这个节点。用
fdtdump或dtc -I dtb -O dts反编译 dtb,搜节点名。 - 确认节点的
status是不是"okay"。默认缺省是 okay,但显式写了"disabled"就会被跳过。 - 确认节点的
compatible有没有对应的 driver。用dm drivers看已注册驱动的of_match列表。 - 确认节点没有被
u-boot,dm-pre-reloc之类的属性限制在特定阶段。
我遇到过一次,节点在 dtb 里,status也是 okay,但就是不出现在 dm tree。最后发现是compatible字符串拼错了一个字母,driver 匹配不上,dm 就默默跳过了。这种错误没有任何报错,只能靠仔细核对。
7.2 设备在 dm tree 里但 probe 失败
设备登记了但 probe 报错,通常是硬件初始化的问题。常见原因:
- 时钟没使能。很多 SoC 的外设需要先使能时钟才能访问寄存器,如果驱动里忘了
clk_get_by_index+clk_enable,probe 就会挂。 - 复位没释放。类似时钟,复位信号没释放的话寄存器访问会失败。
- 电源域没打开。一些低功耗 SoC 有电源域控制,需要先
power_domain_on。 - 寄存器地址错误。设备树里的
reg和实际不符,或者地址映射没建立。
排查方法是在 probe 函数里加打印,看走到哪一步失败。dm 框架在 probe 失败时会打印错误码,结合错误码查include/linux/errno.h基本能定位方向。
7.3 probe 顺序导致的依赖问题
有时候单个设备 probe 没问题,但组合起来就出问题,这往往是依赖顺序不对。比如一个 GPIO 驱动的 probe 里需要访问 pinctrl,但 pinctrl 还没 probe,就会失败。
dm 框架理论上会自动处理依赖,但前提是依赖关系在设备树里正确表达。如果驱动里是硬编码去拿某个设备,而不是通过phandle,就可能绕过依赖解析,导致顺序错误。
解决办法是尽量用uclass_get_device_by_phandle这类接口,让 dm 框架感知依赖。如果确实需要手动控制顺序,可以在initr_*函数里显式按顺序 probe。
7.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 设备不在 dm tree | dtb 未包含节点 | 反编译 dtb 确认 |
| 设备不在 dm tree | status 为 disabled | 检查设备树 status 属性 |
| 设备不在 dm tree | compatible 无匹配驱动 | dm drivers 查看 of_match |
| probe 失败 | 时钟/复位/电源未就绪 | 检查驱动初始化顺序 |
| probe 失败 | 寄存器地址错误 | 核对 reg 属性与手册 |
| 依赖设备未就绪 | phandle 引用错误 | 检查设备树引用关系 |
| probe 时机不对 | 惰性 probe 未触发 | 显式调用 uclass_get_device |
7.5 几个独家避坑技巧
第一个技巧:在board_init_r里 dm 初始化之后,加一句dm_dump_all()(如果版本支持),能把当前所有设备状态打印出来。这比dm tree命令更早,能在进入命令行之前就看到骨架状态。
第二个技巧:如果怀疑某个驱动的bind没被调用,可以在bind函数入口加printk,因为bind阶段printf可能还没完全就绪,printk更可靠。
第三个技巧:设备树的u-boot,dm-pre-reloc属性会影响扫描范围。如果你在 SPL 阶段需要某个设备,必须给它加这个属性;如果只在 U-Boot 主体需要,就不要加,否则会浪费 SPL 空间。
第四个技巧:dm tree的输出里,已经 probe 的设备和未 probe 的设备显示可能不同(取决于版本)。如果看不出来,用dm uclass配合看,或者直接在代码里检查dev->flags & DM_FLAG_ACTIVATED。
8. 从骨架到血肉:dm 设计的取舍与启示
8.1 为什么 U-Boot 要引入 dm
在 dm 之前,U-Boot 的设备初始化是一堆散落在板级文件里的函数调用,每个板子都要写一遍类似的代码,重复且容易出错。dm 引入之后,设备信息集中到设备树,驱动代码可以跨平台复用,板级文件大幅简化。
这个演进和 Linux 内核走的路是一样的。本质上是用"数据驱动"替代"代码驱动"——设备是什么写在设备树里,怎么操作写在驱动里,两者通过匹配机制关联。这样换一个板子,只要改设备树,驱动不用动。
8.2 两阶段设计的代价与收益
bind 和 probe 分离,收益是启动快、内存省。代价是调试复杂——你得理解两个阶段各自做什么,出问题时要判断是 bind 阶段还是 probe 阶段的问题。
我的体会是,这个代价是值得的。一旦你习惯了这种思维模式,排查问题反而更有条理:先确认 bind 成功(设备在 dm tree 里),再确认 probe 成功(设备被激活),两个阶段分开看,问题范围立刻缩小一半。
8.3 对驱动开发者的实际要求
写 dm 驱动,最重要的是把 bind 和 probe 的职责分清楚。bind 里只做纯软件的事,probe 里才碰硬件。这个边界如果模糊了,比如在 bind 里访问寄存器,可能在扫描阶段就崩溃,而且崩溃点很难定位。
另外,plat和priv的分配要用 dm 提供的接口(dev_get_plat、dev_get_priv),不要自己 malloc。dm 框架会统一管理这些内存,自己 malloc 容易泄漏,而且和框架的生命周期管理冲突。
8.4 后续可以深入的方向
把board_init_r这条链路捋清楚之后,可以继续深入几个方向:一是 uclass 的post_probe、pre_remove这些钩子的使用场景;二是device_remove和unbind的流程,理解设备销毁;三是 SPL 阶段的 dm 裁剪,看看怎么在有限空间里只保留必要的设备。
还有一个很实用的方向是ofnode接口。dm 里大量用ofnode来操作设备树,比直接操作fdt更安全方便。把ofnode的常用接口过一遍,写驱动会顺手很多。
我个人在实际操作中的体会是,dm 这套东西初看复杂,但它的复杂度是"必要的复杂度"——为了跨平台复用和快速启动,这些抽象是躲不掉的。与其绕过去用老办法,不如花两天时间把它吃透,后面改板子、调驱动会轻松很多。踩过几次 probe 顺序的坑之后,我现在拿到一块新板子,第一件事就是dm tree看骨架,然后顺着 probe 日志找问题,基本不会再像以前那样盲目试错了。