干U-Boot底层开发的人,多多少少都被driver model(驱动模型,下文统一叫dm)折磨过。尤其是移植新板子的时候,经常会遇到一个诡异现象:驱动代码明明写进去了,设备树节点看起来也没问题,但系统起来之后串口就是不出声,或者某个外设怎么调都调不出来。这时候你要是能看懂board_init_r里那串又长又有序的初始化序列,顺着dm的骨架一路摸下去,大多数问题都能定位到根因。
这篇文章我就想聊一件事:U-Boot在board_init_r里到底是怎么把dm的驱动骨架搭起来的。我会沿着代码路径,从initr_dm开始,拆到dm_init_and_scan,再拆到lists_bind_fdt,把每一步的关键数据结构和调用关系讲清楚。看完之后,你再遇到“设备没绑定”“驱动没匹配”“probe失败”这类问题,心里就会有一张完整的地图,而不是靠瞎猜和反复加打印碰运气。
这篇文章适合正在做U-Boot移植、底层驱动bringup,或者对dm tree命令打出来的信息一头雾水的人。就算你只是被某个“离奇的uart不工作”问题困住,把dm骨架这部分搞明白,也能帮你节省至少一个下午的调试时间。
1. 从board_init_r这口大锅开始
1.1 为什么“初始化全部”要写成一张表
board_init_r是U-Boot完成代码重定位之后调用的总调度函数,位置在common/board_r.c。它的写法很老派,但非常实用:内部维护了一个init_sequence_r[]函数指针数组,一个个顺序调用。核心循环逻辑大致是:
for (init_fnc_ptr = init_sequence_r; *init_fnc_ptr; ++init_fnc_ptr) { if ((*init_fnc_ptr)() != 0) { hang(); } }每个initr_xxx函数返回0表示成功,返回非0就hang住。这种设计有一个很直接的好处:初始化步骤被拍平成一串串行的依赖链,任何一步挂了,后面就不用跑了。你只需要看最后打印到哪一行,就知道是哪个初始化组件出了问题。
这个数组里塞了几十项,从initr_preserve_ram、initr_reloc、initr_malloc,一路排到initr_serial、initr_net、initr_flash等。跟dm直接相关的关键步骤,大致排布是:
initr_reloc:完成代码重定位;initr_malloc:把malloc切到真正的完整内存区域;initr_dm:启动驱动模型,建立dm骨架;initr_of_live(如果开启CONFIG_OF_LIVE):把扁平设备树展开成live tree,方便运行时操作;initr_dm_devices(部分版本存在):补充扫描;initr_serial:串口子系统开始工作,走dm框架去probe串口设备。
注意这个顺序:dm必须在串口之前就绪。因为串口本身也是设备树里的一个节点,也是挂在dm下面的一个udevice。没有dm骨架,后面所有驱动都没法正常“登记”和“开工”。
1.2 dm在“总调度表”里的特殊地位
init_sequence_r里大部分初始化函数都是相对独立的,比如清BSS、设置环境变量、初始化时钟。但dm不一样,它不是某个具体硬件外设,而是一套基础设施。后面所有外设驱动,无论是串口、网卡、GPIO还是MMC,都要通过dm来管理。
所以你可以把dm理解成“物业的总管理系统”。board_init_r这口大锅里的其他函数是各个商户,而initr_dm是在商户营业之前,先把整栋楼的登记系统、门禁体系、房间号分配规则搭好。没有这套系统,后面的驱动就是无头苍蝇。
而且dm的初始化分两个阶段:dm_init负责建立根骨架,dm_scan负责扫描设备树并绑定设备。这种“先立骨架、再填血肉”的思路贯穿整个dm设计。搞明白这两个阶段,你就能看懂后续所有驱动探测流程。
2. initr_dm:dm的启动扳机
2.1 保存内存池这句代码不能跳过
initr_dm的实现大致如下:
static int initr_dm(void) { /* Save the driver model memory pool */ dm_mem_pool = mem_malloc_start; if (!CONFIG_IS_ENABLED(OF_CONTROL)) return 0; #if CONFIG_IS_ENABLED(OF_LIVE) if (!gd->of_root) gd->of_root = oftree_live_fdt(gd->fdt_blob); #endif return dm_init_and_scan(true); }第一眼看上去,dm_mem_pool = mem_malloc_start;这句赋值很像废话,但背后是有讲究的。U-Boot的malloc分阶段:重定位之前用CONFIG_SYS_MALLOC_F_LEN定义的那一小块区域,重定位之后才切换到完整内存。dm在早期创建的全部uclass、udevice结构体都是从malloc区域里分配的,而重定位过程中,malloc区域的地址和大小会变化。记下mem_malloc_start,相当于给dm结构体所在的“老地盘”留一个锚点,方便将来判断哪些内存是dm占用的,或者在某些配置下做统一释放。旧版U-Boot在dm内存管理上比现在更依赖这个池,现在保留这句算是历史包袱加实际用途兼备。
紧接着,如果启用了设备树控制(OF_CONTROL)和OF_LIVE,这里还可能会提前把gd->of_root建立起来。这一步的意义是:后续dm扫描时如果要用live tree的节点操作接口,直接能用,不用再临时展开。
最后一行dm_init_and_scan(true),正式拉开dm骨架搭建的大幕。
2.2 dm_init_and_scan:先立根,再扫描
dm_init_and_scan是board_init_r里dm初始化的总入口。它做了两件事:先调dm_init,再调dm_scan。看代码:
int dm_init_and_scan(bool pre_reloc_only) { int ret; if (gd->dm_root) { /* Driver model already initialized */ return 0; } ret = dm_init(); if (ret) return ret; ret = dm_scan(pre_reloc_only); if (ret) { log_debug("dm_scan() failed: %d\n", ret); return ret; } return 0; }gd->dm_root是dm是否已经初始化的标志。它是全局数据(global_data)里的一个指针,指向根设备。只要它非空,说明骨架已经存在,不再重复初始化。
dm_init()做的事情更纯粹:
int dm_init(void) { int ret; if (gd->dm_root) { /* Driver model already initialized */ return 0; } INIT_LIST_HEAD(&gd->uclass_root); ret = device_bind_by_name(NULL, false, &root_info, &gd->dm_root); if (ret) return ret; return 0; }这里面有两个关键动作:
第一,初始化gd->uclass_root链表头。往后每注册一个uclass,都会挂到这个链表上。可以说,这就是dm骨架的“主脊梁”。
第二,通过device_bind_by_name创建根设备。root_info只提供了驱动名字:
static const struct driver_info root_info = { .name = "root_driver", };这个root device没有任何硬件含义,纯粹是给整棵设备树一个挂载点。设备树上所有的节点,最终都会以它为祖父或祖先节点,一层层挂下来。
dm_init结束后,dm骨架的“根”已经立起来了。但此刻整个dm体系还是空的,除了一个root device什么都没有。接下来要看dm_scan如何填充血肉。
3. dm的核心三角:uclass、driver、device
3.1 用物业管理系统理解三个角色
在继续追dm_scan的代码之前,必须先把uclass、driver、device这三者的关系讲透。很多人被dm绕晕,就是没分清这三个概念。
打个比方,一家物业公司管理一个小区:
uclass/uclass_driver:相当于“类目中心”,物业把所有房屋分成住宅、商铺、车位、仓库等类型。对应到dm,就是UCLASS_SERIAL、UCLASS_GPIO、UCLASS_MMC这样的类别。每个uclass有一个uclass_driver,提供这类设备的公共行为,比如post_probe回调,当这类设备probe完成后统一做点收尾工作。driver:相当于“管家服务标准”。比如住宅有住宅的管理细则,商铺有商铺的管理细则。在dm里,ns16550_serial_driver就是专门处理一类16550兼容串口硬件的驱动。它里面定义了of_match兼容表、probe回调、ofdata_to_platdata回调等。device(udevice):相当于具体某个“房间号”。设备树里每个带compatible属性的节点,在匹配到driver之后,会被实例化成一个udevice,挂到对应的uclass下面。两个相同串口控制器,共享同一个driver,但会生成两个独立的udevice,各自管自己的寄存器地址和状态。
关键要记住的一点:bind和probe是分离的。bind只是“登记”,给这个设备建立一个udevice记录,分配序号,挂链表,不动硬件。probe才是“开工”,驱动代码里的probe回调就是这时候执行的,访问寄存器、配置时钟、注册中断,都发生在probe阶段。
在board_init_r的dm初始化阶段,只做bind,绝大多数设备不会马上probe。真正触发probe的,是后续某个子系统调用uclass_get_device或device_probe的时候。这就是为什么你经常发现设备树里有个设备,dm tree里也能看到,但Probed列是空的——不是bug,是它还没被“开工”。
3.2 lists_bind_fdt:设备树节点与驱动的“相亲大会”
接着回到dm_scan。dm_scan内部根据配置会走两条路:启用OF_CONTROL时走dm_scan_fdt,否则走dm_scan_platdata(后者多见于SPL阶段使用OF_PLATDATA的配置)。主流情况下,代码路径是:
int dm_scan(bool pre_reloc_only) { if (CONFIG_IS_ENABLED(OF_CONTROL)) { ret = dm_scan_fdt(gd->fdt_blob, pre_reloc_only); } else { ret = dm_scan_platdata(pre_reloc_only); } return ret; }dm_scan_fdt直接调用dm_scan_fdt_node,从gd->dm_root这个根设备开始,递归扫描设备树节点:
int dm_scan_fdt(const void *blob, bool pre_reloc_only) { return dm_scan_fdt_node(gd->dm_root, blob, 0, pre_reloc_only); }dm_scan_fdt_node遍历当前节点下的所有子节点,对每个节点做三件事:
- 如果
pre_reloc_only为true,先判断这个节点是否被标记为“早期可用”,不是的话就跳过; - 调用
lists_bind_fdt,让这个节点去匹配驱动; - 如果匹配成功创建了udevice,就递归处理这个设备下的子节点。
这里最关键的就是lists_bind_fdt。可以把它想成一场相亲大会:设备树节点手上拿着自己的compatible属性,遍历uclass链表,每个uclass再带着自己关联的一批driver,挨个比对compatible。比对上了,就完成绑定。
核心流程简化如下:
static int lists_bind_fdt(...) { for (uclass = uclass_root; uclass; uclass = uclass->next) { struct uclass_driver *uc_drv = uclass->uc_drv; driver = uclass_find_driver_by_ofnode(uc_drv, node); if (!driver) continue; ret = device_bind_common(parent, driver, name, NULL, node, &dev); ... } }device_bind_common是真正的“结婚登记”环节,它会:
- 分配一个
udevice结构体; - 把
dev->driver指向匹配到的driver; - 把设备挂到parent的child链表上,同时挂到对应uclass的device链表上;
- 根据alias和现有设备序号分配
seq编号; - 记录设备树节点偏移量
of_offset; - 但不调用probe。
这个“仅登记、不干活”的设计,让dm在初始化阶段可以快速扫描完整棵设备树,开销很小。如果在这个阶段就去probe所有设备,那板子刚起来就会因为大量设备争抢时钟、GPIO、复位资源而乱套。
3.3 pre_reloc_only:为什么有的设备不能急着绑定
board_init_r里传给dm_init_and_scan的第一个参数是true,也就是pre_reloc_only = true。这意味着第一次扫描只绑定被标记为“早期可用”的设备。设备树里通过u-boot,dm-pre-reloc属性(新版本U-Boot引入了bootph-pre-reloc,两者兼容期并存)来标记。
为什么非要这个机制?两个原因。
第一,内存有限。重定位之前的malloc区域很小,整个dm结构体虽然不大,但设备树里几十上百个节点全绑一遍,内存还是吃紧。SPL阶段尤其明显,很多SPL的malloc区就几KB,根本扛不住全量扫描。
第二,依赖关系。很多设备要能正常工作,依赖的父节点或关联节点(比如clock controller、gpio controller、reset controller)必须先就绪。如果在扫描阶段就把所有设备probe了,很可能子设备的时钟还没配、复位还没解,一通操作全白费。
所以U-Boot的策略是:现在只绑定那些“保命级”设备,比如串口、内存控制器相关的节点,等重定位完成、完整malloc区就绪后,再全量扫描绑定。这也是为什么你在普通U-Boot启动日志里看不到dm_scan第二次调用的痕迹——它往往被封装在后续某个初始化步骤中,按需补全。
实际操作中,如果你发现自己新加的某个外设节点在dm tree里死活不出现,第一反应就应该是:这个节点有没有打上u-boot,dm-pre-reloc标记?有些情况下,U-Boot的fdtgrep或SPL设备树裁剪甚至会把没打标记的节点直接删掉,那就不只是dm的问题,而是设备树在编译阶段就被裁掉了。
4. 实操:验证dm骨架是否搭好
4.1 用dm tree查看绑定关系
骨架搭没搭好,空口无凭,得上命令。U-Boot命令行里最常用的三个命令:
dm tree:按父子层级打印所有设备,同时显示Probed状态;dm uclass:打印所有uclass及其驱动信息;dm devices:打印所有设备实例,不区分层级。
执行dm tree后,输出类似这样:
Class Probed Driver Name ------------------------------------------- root [ + ] root_driver root_driver serial [ + ] ns16550_serial serial@ff020000 gpio [ ] gpio_sunxi gpio@ff024000 mmc [ ] sunxi_mmc mmc@ff0f0000Probed列是[ + ]代表已经probe过,是空格代表只bind了这个设备还没probe。看到serial后面有[ + ],说明串口驱动已经成功跑起来了。如果某个节点压根没出现在dm tree里,说明bind阶段就没成功,那问题大概率出在compatible匹配、pre_reloc标记或设备树裁剪上。
实际排障时,我习惯按这样的顺序操作:
- 先执行
dm tree,看目标设备在不在列表里; - 不在,查设备树节点是否被裁剪、compatible是否写对;
- 在但Probed为空,查依赖设备有没有先probe成功,以及驱动probe回调有没有报错;
- 如果连
dm tree命令本身都跑不了,那说明dm初始化阶段已经崩了,回到代码加早点打印。
4.2 设备没出现?先从三个方向排查
设备没出现在dm tree里,是移植时最常见的问题。我总结下来,90%的原因跑不出这三类:
第一类,设备树裁剪。SPL阶段和正式U-Boot阶段用的设备树可能是不同的。SPL常用CONFIG_OF_SPL_REMOVE_PROPS甚至专门的SPL设备树来减少体积。如果你加节点只加在了主设备树里,SPL用的那个裁剪版本里可能压根没有。正式U-Boot阶段也可能存在fdtgrep过滤,需要确认编译产物里确实包含节点。
第二类,compatible匹配不上。驱动的of_match表里写的是什么字符串,设备树节点里compatible第一个字符串是什么,必须完全一致,包括厂商前缀的大小写。最坑的是有些驱动用的是旧版绑定,比如"ns16550a",但设备树里写的是"snps,dw-apb-uart",那自然匹配不上。
第三类,父节点没绑上。dm_scan_fdt_node是递归的,一个节点要能被绑定,它的父节点必须先绑定成功。如果父节点compatible写错了,或者父节点被pre_reloc过滤掉了,整棵子树都进不来。
4.3 probe失败怎么查
如果设备出现在dm tree里,但Probed列一直是空的,说明bind成功、probe失败或者压根没被probe。这里有一个关键区分:是“没被probe”还是“probe了但失败”。
如果是“没被probe”,往往不是问题。dm设计的哲学是懒加载,只有某个uclass真的需要设备时,才会调用uclass_get_device去probe。比如一个GPIO控制器,如果整个系统运行期间没有任何驱动请求GPIO,它可能永远处于bind状态。
如果是“probe了但失败”,U-Boot会打印错误码。常见原因:
- 依赖设备没就绪。驱动probe里常常会调用
dev_get_clk、reset_get_by_index、gpio_request_by_name,这些调用会隐式probe依赖设备。如果依赖设备自身的probe失败,这边也会跟着返回错误。 - 寄存器地址解析失败。
dev_read_addr拿到的地址如果不对,访问寄存器直接崩或者读出来全是错误值。 - 驱动私有数据没分配。driver定义里如果设了
priv_auto_alloc_size,device_bind_common会自动分配私有数据;如果没有,probe里直接访问dev->priv就会访问空指针。
排查probe问题时,最有效的手段是打开U-Boot的日志系统。在板级配置里开启CONFIG_LOG,然后在驱动文件里加上#define LOG_CATEGORY LOGC_DM,设置loglevel到8以上,你会看到dm层打印的详细调用链,哪一步返回什么错误码清清楚楚。
5. 避坑与心得
5.1 移植新板子的dm排查清单
干脆把这几年踩过的坑整理成一张速查表,你照着排查就行:
| 现象 | 第一步检查 | 常见原因 |
|---|---|---|
dm tree里没有目标设备 | 节点是否有u-boot,dm-pre-reloc或bootph-pre-reloc | 被pre_reloc过滤或设备树裁剪 |
dm tree里节点存在,但compatible匹配不上 | 驱动的of_match表与节点compatible | 字符串不一致,含大小写和厂商前缀 |
| 设备有名字但Probed一直为空 | 谁调用了uclass_get_device | 懒加载设计,没人请求就不probe |
| probe返回错误码 | 依赖设备的probe状态 | clock/gpio/reset等父设备没就绪 |
| 串口完全没有输出 | initr_serial是否在initr_dm之后 | 初始化顺序被改动或dm崩在早期 |
| 系统hang在board_init_r中途 | 最后打印的initr_xxx是哪个 | 该初始化函数返回非0 |
这个表不能解决所有问题,但能帮你快速缩小范围。
5.2 几个容易被忽略的细节
再分享几个纯经验层面的东西,都是常规文档里不太会写到的:
第一,u-boot,dm-pre-reloc的属性迁移。最新U-Boot(2023.04之后)开始用bootph-pre-reloc这些新属性替换老属性。两者在兼容期内都能用,但你如果基于新版本U-Boot做移植,最好直接采用新属性。网上大量老文章还在推老属性,照抄到新版本上可能不生效或者行为有差异。
第二,CONFIG_DM_SEQ_ALIAS和alias编号。设备树里的aliases节点对设备编号影响很大。比如aliases { serial0 = &uart0; serial1 = &uart1; };。如果你的设备树alias写错,或者多个串口节点共用同一序号的alias,U-Boot会分配出奇怪的seq编号,导致console指向错误串口。这就是“明明串口驱动匹配了,但println输出到别处去”的经典原因之一。
第三,dm结构体和malloc区的关系。如果CONFIG_SYS_MALLOC_F_LEN设得太小,dm_init或第一次dm_scan时malloc申请会失败,系统会在很早期就hang住。这类问题最坑,因为日志可能只打印到malloc初始化就断了。我建议SPL阶段至少给CONFIG_SYS_MALLOC_F_LEN留足空间,具体大小取决于设备树节点数量。
第四,多个设备共享一个driver时的静态变量陷阱。一个driver可以被多个udevice实例共享,如果在driver的probe里用了静态局部变量来保存寄存器地址或状态,那第二个设备probe时会把第一个设备的数据冲掉。必须用dev->priv这种per-device数据。
我自己最初搞dm时,被“设备就是不出现”的问题折磨了两天,最后发现只是设备树里少了一个u-boot,dm-pre-reloc属性。从那以后,我养成一个习惯:任何驱动bringup,第一步先跑dm tree看一眼设备在不在,再看Probed状态,最后才翻驱动代码。这套流程帮我省了不知道多少时间。
还有一个救命小技巧:如果你在串口起来前就要看dm状态,可以在board_init_r里initr_serial之前临时加一个printf,直接打印gd->dm_root和几个关键uclass链表的长度。即使串口最终没起来,这些值也能通过JTAG或者LED调试看到。等确认dm骨架没问题了,再回头查串口怎么挂的。因为串口挂不上往往只是结果,dm骨架不稳才是根源。
后续如果要扩展,可以沿着dm_scan的递归逻辑往深里挖,把uclass_find_driver_by_ofnode的匹配细节和device_bind_common的完整初始化流程过一遍。或者把SPL阶段的dm初始化拉出来对比,你会发现SPL里对pre_reloc和内存的限制更严格,理解会更深。不过那是另一个话题了,先把board_init_r里这条主线吃透,你手里的屠龙刀才算真正开刃。