U-Boot 的board_init_r是很多嵌入式工程师眼里的"黑盒"——串口打印一刷而过,设备该起来的都起来了,但真让你说清楚dm驱动骨架是在哪一行、按什么顺序搭起来的,多数人答不上来。我最早接触这块的时候也一样,改个dts节点发现驱动没 probe,加打印加到自己都晕,最后才意识到问题出在board_init_r里那几段看似不起眼的initr_*调用顺序上。这篇就把board_init_r里设备模型(driver model,下文简称 dm)的骨架搭建过程完整拆一遍,从gd->flags的置位到dm_init_and_scan的两次扫描,再到initr_dm_devices的收尾,把每一步"为什么放在这个位置"讲透。适合已经能跑通 U-Boot、想搞明白 dm 初始化时序的嵌入式驱动开发者,也适合正在往新板子上移植 dm 驱动、被 probe 顺序坑过的朋友。
1. 先搞清楚 board_init_r 到底在什么时间点被调用
1.1 从 _main 到 board_init_r 的交接
U-Boot 的启动分两个大阶段,board_init_f和board_init_r。前者跑在重定位之前,主要干内存布局规划、gd结构体初始化这些活;后者跑在重定位之后,代码已经搬到 RAM 里,可以放心调用各种复杂函数了。board_init_r的入口在common/board_r.c,它的调用者是arch/*/lib/crt0.S里的_main,_main在完成重定位、清 BSS、设置栈之后,一个bl board_init_r就跳进来了。
这个时间点很关键:此时 DDR 已经可用,gd->relocaddr指向新的 U-Boot 位置,gd->malloc_base也准备好了,堆内存可以正常分配。dm 框架里大量使用malloc来分配udevice、driver绑定结构,所以 dm 的初始化必须放在堆可用之后。这也是为什么dm_init_and_scan不在board_init_f里调用的根本原因——那时候堆还没影呢。
1.2 init_sequence_r 数组:骨架的真正载体
board_init_r函数体本身很短,核心就一句initcall_run_list(init_sequence_r)。真正干活的是init_sequence_r[]这个函数指针数组,它定义在common/board_r.c里,按顺序排列了一堆initr_*函数。这个数组就是 dm 骨架的"施工顺序表",每一项都是一个初始化阶段。
数组里和 dm 直接相关的项,按出现顺序大致是这几个:
initr_dm(早期 dm 初始化,只做最基础的环境搭建)initr_of_live(如果开了 OF_LIVE,把设备树展开成 live tree)initr_dm_devices(扫描并 probe 所有驱动)- 中间还夹着
initr_serial、initr_console_record、initr_env等
很多人以为 dm 是一次性初始化完的,其实不是。U-Boot 故意把 dm 拆成"先建骨架、后填血肉"两段,中间插入了串口、环境变量这些依赖 dm 但又必须先于完整扫描可用的东西。这个设计思路值得单独拎出来讲。
1.3 为什么 dm 要分两阶段初始化
设想一下,如果 dm 一次性把所有驱动都 probe 完,会有什么问题?最直接的就是串口。串口驱动本身也是 dm 驱动,但调试信息要靠串口输出,如果串口 probe 之前 dm 扫描就崩了,你连报错都看不到。所以 U-Boot 的做法是:先用initr_dm建立一个最小的 dm 运行环境,让serial这类基础驱动能提前 probe 出来,等串口能打印了,再走initr_dm_devices做全量扫描。这样即使后面某个驱动 probe 失败,你至少能在串口上看到错误信息。
这个"先让日志通道可用,再做重活"的思路,在嵌入式 bring-up 里非常常见,我自己移植新板子时也一直沿用——先把串口打通,再谈其他外设。
2. initr_dm:dm 骨架的第一根桩
2.1 dm_init_and_scan 的第一次调用
initr_dm的实现很简洁,核心就是调用dm_init_and_scan(false)。注意这个false参数,它对应的是dm_init_and_scan的pre_reloc_only形参。传false意味着"不只处理重定位前的驱动,所有驱动都可以纳入扫描范围"。但这里有个细节:虽然传了false,这次调用并不会 probe 所有设备,因为此时很多依赖还没就绪。
dm_init_and_scan内部做了三件事:dm_init、dm_scan_platdata(如果没开 OF_CONTROL)、dm_scan_fdt。dm_init负责初始化gd->dm_root、gd->uclass_root这两个根节点,把 dm 的"树根"立起来。dm_scan_fdt则遍历设备树,为每个有compatible属性的节点创建对应的udevice,并尝试和U_BOOT_DRIVER注册的驱动做匹配。
2.2 gd->dm_root 和 uclass_root 的建立
dm_init里最关键的两行是创建dm_root和uclass_root。dm_root是整个设备树的根udevice,所有设备节点最终都挂在它下面;uclass_root是所有 uclass 的根,每个 uclass(比如UCLASS_SERIAL、UCLASS_GPIO)都是它的子节点。
这里有个容易踩的坑:dm_root和uclass_root本身也是udevice,它们的driver是root_driver和uclass_driver。如果你在dm_init之前就想访问某个 uclass,比如uclass_get_device,那必然失败,因为uclass_root还没建。我见过有人在board_init_f阶段就想拿 GPIO,结果一路返回-ENODEV,查半天才发现是时序问题。
2.3 第一次扫描的边界:哪些设备会被 probe
initr_dm阶段的扫描,重点是那些标记了DM_FLAG_PRE_RELOC或者被uclass声明为"早期需要"的设备。典型的就是串口。以ns16550为例,它的驱动定义里有.flags = DM_FLAG_PRE_RELOC,所以能在重定位前就被识别。但真正让它 probe 的,是initr_serial里显式调用的serial_init,而不是dm_init_and_scan自动 probe 的。
换句话说,initr_dm主要完成的是"建树 + 绑定",probe 是后面按需触发的。这个区分很重要:绑定(bind)只是把udevice和driver关联起来,probe 才是真正调用驱动的.probe回调去初始化硬件。很多人把这两个概念混为一谈,导致看日志时误以为驱动已经跑过了。
3. initr_serial 与 initr_dm_devices 之间的时序玄机
3.1 串口为什么必须夹在两次 dm 扫描中间
init_sequence_r里initr_serial排在initr_dm_devices前面,这不是随便排的。initr_serial会调用serial_init(),而serial_init内部通过uclass_get_device_by_seq(UCLASS_SERIAL, 0, &dev)拿到串口设备并 probe 它。此时uclass_root已经由initr_dm建好,所以能成功拿到设备。
如果顺序反过来,先initr_dm_devices再initr_serial,会怎样?理论上也能跑,但风险在于:全量扫描时如果某个驱动 probe 失败并尝试打印错误,而串口还没初始化,这条错误信息就丢了。更糟的是,某些平台的printf在串口未就绪时会走puts的 fallback 路径,可能触发未定义行为。所以 U-Boot 把串口初始化提前,本质是保证"日志通道先于一切重活可用"。
3.2 initr_dm_devices 的全量扫描逻辑
initr_dm_devices调用的是dm_init_and_scan(true),注意这次传的是true。这个参数会让扫描过程更彻底,配合dm_scan_fdt的后续处理,把所有还没 bind 的设备都补上,并触发uclass的post_bind、post_probe等回调。
具体流程可以拆成几步:
- 遍历设备树中所有节点,跳过
status = "disabled"的 - 对每个节点,查找匹配的
driver(按compatible字符串匹配of_match表) - 匹配成功则调用
device_bind_with_driver_data创建udevice - 绑定完成后,对需要立即 probe 的设备调用
device_probe - 触发
uclass的post_probe回调,做 uclass 级别的收尾
这里第 4 步的"需要立即 probe"是有条件的。默认情况下,dm 采用惰性 probe——设备 bind 了但不一定马上 probe,等第一次被uclass_get_device之类调用时才 probe。但有些设备标记了DM_FLAG_ACTIVE_DMA或者 uclass 有post_probe需求,会在扫描阶段就 probe。
3.3 一个真实的 probe 顺序踩坑案例
我之前调一块新板子,I2C 上的 PMIC 一直 probe 失败,报-EPROBE_DEFER。查了半天发现是 I2C 控制器本身还没 probe,PMIC 作为 I2C 子设备自然拿不到总线。问题出在设备树里 I2C 控制器节点的status被写成了disabled,而 PMIC 节点是okay。dm 扫描时跳过了 disabled 的 I2C 控制器,但 PMIC 节点还在,于是 PMIC 尝试 probe 时找不到父总线,返回-EPROBE_DEFER。
这个坑的教训是:dm 的 probe 顺序强依赖设备树的层级和status属性。父节点 disabled,子节点就算 okay 也没用。排查这类问题时,我习惯先dm tree看一眼设备树在 dm 里的实际形态,再dm uclass确认 uclass 绑定情况,比盲猜高效得多。
4. dm 骨架里的 uclass 机制:驱动分类的底层逻辑
4.1 uclass 是什么,为什么需要它
uclass 是 dm 框架里对"一类设备"的抽象。比如所有串口都属于UCLASS_SERIAL,所有 GPIO 都属于UCLASS_GPIO。它的价值在于提供统一的操作接口:你不需要知道具体是ns16550还是pl011,只要拿到UCLASS_SERIAL的设备,就能调serial_putc。
从实现上看,每个 uclass 对应一个uclass_driver,里面定义了post_bind、post_probe、pre_remove等回调,以及per_device_auto这样的自动分配大小。当设备 bind 到某个 uclass 时,dm 会按per_device_auto给udevice->uclass_priv分配内存,驱动可以直接用这块空间存私有数据,不用自己 malloc。
4.2 uclass 的注册时机
uclass 的注册靠UCLASS_DRIVER宏,展开后是一个linker_list项,在链接阶段就被收集到.u_boot_list段里。dm_init里会遍历这个段,把每个uclass_driver实例化成uclass节点挂到uclass_root下。所以 uclass 的注册是"静态注册、运行时实例化",不依赖设备树。
这里有个细节:UCLASS_DRIVER宏里的.id字段必须唯一,且要和U_BOOT_DRIVER里的.id对应。如果两个驱动用了同一个 uclass id 但 uclass 本身没注册,bind 时会报-ENODEV。我遇到过有人复制驱动代码时忘了改.id,结果两个驱动抢同一个 uclass,行为诡异。
4.3 uclass 与 driver 的匹配规则
匹配发生在device_bind阶段。dm 拿到一个设备树节点后,先解析compatible,然后在所有U_BOOT_DRIVER里找of_match表能匹配上的。匹配成功后,驱动的.id字段决定它属于哪个 uclass。如果.id是UCLASS_SERIAL,那这个设备就挂到 serial uclass 下。
匹配失败的情况也常见:设备树节点写了compatible = "vendor,foo",但没有任何驱动的of_match里有这个字符串,dm 会跳过这个节点,不报错也不 bind。这种"静默跳过"很容易让人误以为驱动加载了,实际根本没匹配上。排查时用dm tree看节点是否出现在树里,是最直接的办法。
5. 从 board_init_r 反推 dm 驱动的移植要点
5.1 新板子移植 dm 驱动的检查清单
基于上面拆解的骨架,移植 dm 驱动时可以按这个顺序自查:
| 检查项 | 位置 | 常见问题 |
|---|---|---|
| 驱动是否注册 | U_BOOT_DRIVER宏 | 宏拼写错误导致没进 linker list |
| compatible 是否匹配 | of_match表 | 设备树字符串和驱动不一致 |
| uclass 是否注册 | UCLASS_DRIVER宏 | uclass id 冲突或缺失 |
| 设备树 status | 节点属性 | 父节点 disabled 导致子节点失效 |
| probe 依赖顺序 | -EPROBE_DEFER处理 | 依赖的总线未就绪 |
| 私有数据分配 | per_device_auto | 大小不够导致越界 |
这张表是我自己移植时总结的,基本覆盖了 90% 的"驱动不工作"问题。按顺序查一遍,比漫无目的地加打印快得多。
5.2 用 dm 命令做运行时诊断
U-Boot 编译时如果开了CONFIG_CMD_DM,就能在命令行用dm系列命令。最常用的三个:
dm tree:打印完整的 dm 设备树,看层级和绑定情况dm uclass:列出所有 uclass 及其下的设备dm devres:查看设备资源分配,排查内存问题
我调 probe 顺序问题时,习惯先dm tree确认设备在树里的位置,再dm uclass看 uclass 绑定,最后结合-EPROBE_DEFER的返回值定位依赖链。这套组合拳比单纯看代码高效太多。
5.3 关于 initr_dm 和 initr_dm_devices 的取舍
有人会问:能不能把两次 dm 初始化合并成一次?技术上可以,但不建议。合并后你就失去了"串口先于全量扫描可用"这个保障,一旦某个驱动 probe 崩溃,调试信息可能丢失。U-Boot 官方把这个拆分保留至今,是有充分工程理由的。
如果你在做极度精简的 bootloader,确实想省掉一次扫描,那至少要保证串口在 dm 初始化之前能用。但说实话,省这点开销意义不大,dm 扫描本身很快,真正的耗时在驱动 probe 的硬件操作上。
6. 几个容易被忽略的 dm 初始化细节
6.1 gd->flags 里的 DM 相关标志
gd->flags里有几个和 dm 相关的位,比如GD_FLG_DM_DEVRES表示设备资源管理已启用。这些标志在dm_init里被设置,后续驱动可以通过gd->flags判断 dm 是否就绪。如果你在驱动里看到if (!(gd->flags & GD_FLG_DM_DEVRES))这样的判断,就是在做这个检查。
6.2 of_live 对 dm 扫描的影响
开了CONFIG_OF_LIVE后,设备树会被展开成struct device_node的 live tree,而不是每次解析 dtb。initr_of_live就负责这个展开。展开后dm_scan_fdt遍历的是 live tree,速度更快,但内存占用更高。嵌入式项目里要不要开,取决于你的 RAM 预算和启动时间要求。我一般在小 RAM 平台上关掉,大 RAM 平台开着省解析时间。
6.3 dm 扫描失败时的错误处理
dm_init_and_scan返回非零时,initr_dm会直接panic。这意味着 dm 骨架搭建失败是致命错误,不会继续往下走。这个设计是合理的——dm 是后续所有驱动的基础,骨架塌了后面全完。但调试时要注意,panic 信息可能因为串口还没完全就绪而打印不全,必要时用 JTAG 抓。
7. 把 dm 骨架理解透之后能做什么
理解board_init_r里 dm 骨架的搭建过程,最直接的好处是排查 probe 问题时不再靠猜。你知道设备是在哪一步 bind 的、哪一步 probe 的、uclass 是什么时候挂上去的,就能精准定位问题出在哪个环节。
再往深一层,你可以基于这套机制做定制。比如实现一个自定义 uclass,把一类特殊外设统一管理;或者在post_probe回调里插入自己的初始化逻辑,做板级定制而不改驱动源码。这些进阶玩法都建立在对骨架时序的清晰理解上。
我自己在最近一个项目里,就是靠重写某个 uclass 的post_probe,把原本散落在各处的板级初始化代码收拢到一处,维护成本降了不少。这种"顺着框架的设计意图走"的改法,比硬改驱动内部逻辑要稳得多。
最后分享一个我常用的调试技巧:在dm_init_and_scan入口和出口各加一条debug打印,配合CONFIG_DEBUG_UART的早期串口,能把 dm 扫描的完整时间窗口框出来。这样即使全量扫描中途出问题,你也能从日志里看出是扫描前还是扫描后崩的,缩小排查范围。这个技巧在 bring-up 新板子时救过我好几次。