前两天帮人调一块新板子,MIPI屏点不亮,串口日志倒是正常,设备树节点目测也对,驱动文件也在,但就是probe不进去。查了半天,最后问题出在compatible没匹配上,驱动压根没被bind。这让我又一次体会到,在U-Boot里如果你不理解DM(Driver Model)这套驱动骨架,遇到问题基本只能瞎猜。很多从Linux转过来的朋友,对设备模型那一套理论很熟,一进U-Boot就发懵,因为U-Boot把整个设备模型的骨架搭建压缩到了board_init_r里短短几步初始化中。这篇就把board_init_r里面DM驱动骨架是怎么一步步搭起来的拆开讲清楚,看完你也能拿着这把屠龙刀,快速定位和解决U-Boot驱动相关的问题。
1. 先看大局:board_init_r里那串init序列到底在跑什么
1.1 定位init_sequence_r
U-Boot的启动分两大阶段,board_init_f和board_init_r。前者英文叫“before relocation”,后者叫“after relocation”。代码重定位到RAM之后,board_init_r就开始执行一套长长的初始化序列,这个序列的核心就是一个函数指针数组,定义在common/board_r.c:
init_fnc_t init_sequence_r[] = { initr_malloc, initr_dm, initr_sys_info, initr_of_live, initr_bdinfo, initr_reloc, ... initr_dm_devices, ... };说白了这个数组就是一个“待办清单”,U-Boot按顺序挨个调用里面的初始化函数。只要哪个函数返回非0,整个启动就认为失败,直接halt。你不需要背这个数组的全部内容,只需要抓到几个关键点:initr_malloc先把内存堆准备好,initr_dm紧接着把设备模型初始化掉,后面initr_of_live如果开了CONFIG_OF_LIVE,会把扁平设备树转成live tree(运行时树),再往下才是各种外设的正式初始化,比如initr_serial、initr_net之类的。
initr_dm在不同版本里的实现稍有差别,新版本里它的实现非常直白:
static int initr_dm(void) { return dm_init_and_scan(false); }就这么一句话。甚至老版本里你看到的是:
static int initr_dm(void) { dm_scan_platdata(false); dm_scan_fdt(gd->fdt_blob, false); return 0; }看到这里你可能会说,这不就是扫一下平台数据和设备树吗?对,本质上就是这件事,但“扫一下”这三个字背后的工作量远超表面。整个U-Boot的所有驱动能不能用,几乎都取决于这一步之前,你的U_BOOT_DRIVER、UCLASS_DRIVER有没有被正确编译进固件,设备树节点有没有和驱动匹配上。后面所有驱动probe都是建立在这棵扫描出来的设备树之上的。
1.2 dm_init_and_scan的前置依赖
dm_init_and_scan不是想调就能调的,它有几个前置依赖,这也是我建议你理解board_init_r时要连起来看的原因。
第一个依赖是内存堆(heap)已就绪。board_init_f阶段时代码还在Flash或SRAM里跑,那时候的malloc空间往往非常小,只够早期串口这类必需设备用。到了initr_malloc,U-Boot会在RAM里划出一块完整的malloc区域,代码里能看到类似malloc_simple或mem_malloc_init之类的调用。DM初始化过程中会创建uclass实例、udevice实例,这些struct都要动态分配内存,堆没起来之前,你就算想初始化DM也分不出内存。
第二个依赖是gd(global data)结构体已经准备到位且重定位后的地址正确。gd->dm_root这个指针专门用来保存DM的根设备,从dm_init开始,后续所有DM操作都能通过DM_ROOT()这个宏找到根节点。如果gd本身乱了,后面所有东西都会崩,而且崩得毫无规律。
第三个依赖是早期DM初始化已经做过一轮。在board_init_f阶段,你会看到一个initf_dm,它调用的是dm_init_and_scan(true),注意参数是true。这个参数的含义是pre_reloc_only,也就是只扫描绑定了DM_FLAG_PRE_RELOC标志的驱动和设备树节点。为什么这么做?因为在Flash里跑的早期代码,资源和时间都极其有限,不可能把所有驱动都扫一遍,只需要把串口、时钟这些早期必须要用的设备先拉起来就行。等进入board_init_r,代码已经在RAM里舒舒服服跑着,这时候再调用dm_init_and_scan(false),把整个设备树里所有设备完整扫描、绑定一遍。如果你只盯着board_init_r看,不看board_init_f里的那次early scan,很容易误解为什么有些设备在日志里出现两次。
搞懂了前置条件,我们再钻进DM骨架本身。
2. dm骨架的三个底座:driver、uclass、udevice与那段链接表魔法
2.1 三个核心结构体
U-Boot的设备模型,核心抽象有三个结构体:struct driver、struct uclass_driver和struct udevice。光看名字你可能觉得它们差不多,其实分工完全不同,我习惯用一个公司的结构去类比。
struct driver是一份“岗位说明书”,描述一个具体的驱动实现。它里面有name、id(归属哪个uclass)、of_match(设备树compatible匹配表)、probe、remove、bind这些回调函数。比如你写一个GPIO驱动,probe函数就是初始化寄存器、设置引脚方向这些东西。struct uclass_driver是一个“部门章程”,描述某一类设备的通用行为,管着一堆driver。同样是GPIO,不同厂商的GPIO控制器驱动千差万别,但上层用GPIO时只关心gpio_request、gpio_direction_output这些通用接口,这些通用接口的定义和行为就在uclass_driver里。struct udevice则是一个“具体员工”,代表设备树里或平台数据中某个实际存在的设备实例,它既有指向driver的指针,也有指向所属uclass的指针,还有挂到父设备下的链表节点。
举个例子,设备树里有个gpio@ff010000节点,它对应的udevice就是一个具体实例;这个实例的driver是某个厂商的GPIO驱动;它所属的uclass是UCLASS_GPIO。上层的GPIO操作函数拿到这个udevice后,先通过uclass找到通用层接口,再通过driver调用具体的硬件操作。三层各司其职,缺一层都跑不起来。
还有一个隐藏的结构struct uclass,它是uclass_driver的运行实例。你可以把uclass_driver看成静态的“章程”,而struct uclass是运行时创建的“部门实体”,它里面有一个链表头dev_head,把属于这个uclass的所有udevice串起来。这样你要遍历某个uclass下所有设备时,直接顺着这个链表走就行。
2.2 U_BOOT_DRIVER和UCLASS_DRIVER宏与链接表
通常我们写U-Boot驱动,不会直接定义一个struct driver变量然后到处传指针,而是用U_BOOT_DRIVER这个宏注册:
U_BOOT_DRIVER(gpio_sunxi) = { .name = "sunxi_gpio", .id = UCLASS_GPIO, .of_match = sunxi_gpio_ids, .probe = sunxi_gpio_probe, };这个宏展开后是什么?它本质上定义了一个名为_u_boot_list_2_driver_2_gpio_sunxi的struct driver变量,并且用__attribute__((section(".u_boot_list_2_driver_2_gpio_sunxi")))把它放到了指定的段里:
#define U_BOOT_DRIVER(__name) \ ll_entry_declare(struct driver, __name, driver)这就要提到U-Boot设备模型最精妙、也最让新手看不懂的设计:链接表(linker list)。U-Boot的驱动分散在几百个.c文件里,你怎么在编译期收集出一个完整的驱动数组?用全局数组当然不行,因为你没法事先知道要写多少个驱动。链接表的思路是:把每个驱动声明成独立符号,放到同名的section里,然后在链接时让链接脚本把这些同section的符号连续排列,自动形成一张表。
链接脚本里你通常会发现类似这样的描述:
.u_boot_list : { KEEP(*(SORT(.u_boot_list_2_driver_1))); KEEP(*(SORT(.u_boot_list_2_driver_2))); KEEP(*(SORT(.u_boot_list_2_driver_3))); }配合include/u-boot/symbols.h里生成的起始结束符号,你就能用ll_entry_start拿到表的首地址,用ll_entry_count算出有多少项。uclass_driver也是同一个套路:UCLASS_DRIVER(root)定义的其实也是这种链接表项,只是放在了uclass这个名字对应的section里。
这里有个最容易踩的坑:链接表里的符号如果没人引用,很可能会被链接器的--gc-sections优化掉。所以链接脚本里必须用KEEP()把它保住。有时候你明明写了U_BOOT_DRIVER,但dm tree里就是看不到,第一反应就该去检查有没有被gc掉,或者对应的.o文件有没有真的编进u-boot这个目标里。
理解了链接表,你就知道dm到底从哪里拿驱动列表了。它不是靠什么配置文件去“发现”驱动,而是靠编译器把分散的驱动打包成一个二进制里的静态数组,运行时直接遍历这个数组。这套机制的另外一个好处是,不需要任何动态注册步骤,也不依赖初始化顺序,天然适合嵌入式这种资源紧张的环境。
3. dm_init_and_scan到底推进了什么
3.1 dm_init:造出root设备
dm_init_and_scan的第一步是dm_init()。这个函数做了一件很小但至关重要的事:创建根设备(root device)。代码上看非常简洁:
int dm_init(void) { return device_bind_common(NULL, &root_driver, "root", NULL, 0, NULL, 0, &DM_ROOT_NON_CONST); }root_driver是一个特殊的驱动,定义在drivers/core/root.c里,它对应的uclass是UCLASS_ROOT。device_bind_common会创建根设备的udevice实例,并把它的指针存到gd->dm_root。为什么要造一个根设备?因为DM里的所有设备都要求有父设备,整个设备树要从一个根节点开始组织。后续所有通过设备树扫描出来的设备,都是这个root的child,或者child的child。你可以把root理解成一个虚拟的“总装车间”,所有实际硬件设备都挂在这个车间下面。
dm_init还会初始化一些全局链表,比如uclass实例链表。这里有一点要记住:dm_init只创建root设备,不创建任何真实硬件设备。真实设备全是后面scan阶段绑出来的。
3.2 dm_scan_platdata:board文件里的固化设备
接下来是dm_scan_platdata。这个函数遍历的是另一张链接表,这张表里的元素是struct driver_info,使用U_BOOT_DEVICE宏注册出来的:
U_BOOT_DEVICE(sunxi_gpio_bank0) = { .name = "sunxi_gpio", .platdata = &gpio_bank0_platdata, };这种注册方式在纯设备树平台上用得越来越少,但在一些不开设备树、或者固定外设的场景下很常见。它的工作方式是通过driver_info里的name字段,在driver链接表里查找同名驱动,找到之后调用device_bind_by_name把设备绑定到该驱动上。
这个阶段需要注意:设备树平台下,driver_info表往往是空的,因为设备信息全在设备树里。但U-Boot不关心你有没有,它每次都会先遍历一下这张表。如果某项驱动没有设置DM_FLAG_PRE_RELOC,而当前是early scan(参数为true),这段信息就会被跳过。
3.3 dm_scan_fdt:设备树节点与驱动匹配
dm_scan_fdt才是大部分现代平台真正的主角。这个函数会从设备树根节点开始,深度优先遍历所有子节点,对每个节点尝试找到匹配的driver并bind。匹配方式不是看driver的name,而是看driver里的of_match数组:
static const struct udevice_id sunxi_gpio_ids[] = { { .compatible = "allwinner,sunxi-gpio" }, { } };对于设备树里的一个节点,U-Boot会取出它的compatible属性,然后去driver的of_match数组里逐一比较字符串。匹配上了,就认为这个节点应该由这个驱动负责,调用device_bind_common创建一个udevice实例,把节点信息(offset、compatible等)塞进设备结构体。匹配不上,就跳过这个节点,继续遍历下一个节点。
这一步的遍历顺序也很有意思,是先父后子,递归进行。比如“i2c控制器”节点先被绑定,然后它下面的“i2c设备”子节点再被绑定。这样天然保证了父子关系在udevice的链表中是被正确建立的。dm_scan_fdt还会配合pre_reloc_only参数做判断:early阶段只绑定带有u-boot,dm-pre-reloc属性或驱动带DM_FLAG_PRE_RELOC标志的节点,其他节点留到重定位后的完整扫描。
3.4 bind不是probe:从bind到probe的完整路径
很多人刚接触DM时分不清bind和probe的差别,这里我多说两句。bind只是把设备树节点/平台数据和driver关联起来,创建udevice,建立uclass下的链表关系,这个阶段不会碰硬件寄存器。probe才是真正初始化硬件,调用驱动的probe回调,做一些寄存器配置、中断初始化之类的活。
scan阶段只做bind,不做probe。所以你在dm tree里看到一个设备处于act(active)状态,那是它已经被probe过了;如果处于---之类的状态,说明只bind了,还没probe。真正的probe动作是“按需”的,当某个代码调用uclass_get_device、dm_get_device或者直接device_probe(dev)时,才会触发:
int device_probe(struct udevice *dev) { ... if (dev->flags & DM_FLAG_ACTIVATED) return 0; if (dev->parent) device_probe(dev->parent); ... dev->flags |= DM_FLAG_ACTIVATED; ret = uclass_pre_probe(dev); if (ret) goto fail; ret = drv->probe(dev); if (ret) goto fail; ret = uclass_post_probe(dev); ... }device_probe的执行路径里有个非常关键的行为:它会先probe父设备。父设备不成功,子设备直接失败。比如你调一个挂在I2C总线上的触摸芯片驱动,结果I2C控制器本身probe失败,那么触摸芯片的probe就会因为这个父设备失败而被阻断,而且你光看触摸芯片的日志,往往只能看到一串暧昧的错误。
probe顺序还有uclass层的介入:uclass_pre_probe和uclass_post_probe分别在驱动probe前后调用。它们通常用来做uclass级别的通用初始化。比如某个uclass要求所有设备在probe之前先分配一块公共数据区,或者probe之后统一设置某个标志,就是在这两个回调里实现的。理解了bind和probe的区别,再看启动日志里“DM: dev ...”那些打印,就知道哪一步是哪一步了。
4. 实战:从启动日志反推骨架与排查问题
4.1 验证骨架的几个命令和手段
讲完原理,我们回到文章开头那个屏点不亮的场景。在调试DM时,我最常用的手段就是U-Boot命令行里的dm命令。开机进入U-Boot命令行,敲:
=> dm tree你会看到系统把整个设备树扫描后的设备列出来,包括device的地址、所属uclass、父设备、名字和状态。如果某个节点没出现在列表里,说明bind就没发生,问题大概率出在compatible字符串匹配或驱动没被编译进来。如果出现在列表里但状态不是active或probe报错,那就是probe阶段的问题,可以顺着probe回调去找。
还有一个命令是dm uclass,它列出所有已注册的uclass实例,可以帮你确认某个uclass_driver有没有被链接进来。如果缺失,这里就不会显示。dm dump或dm drv在部分版本里也能用,分别看设备和driver列表。
如果想看更细的匹配日志,可以打开CONFIG_DEBUG_DM和CONFIG_DM_WARN。打开之后,device_bind_common和device_probe会打印较多调试信息,比如某个节点“no match”之类。配合串口输出,排起查来效率高很多。
4.2 常见问题速查表与排障思路
下面这几种情况,是我在实际项目里遇到最多、也最有代表性的DM骨架问题,我整理成一张速查表:
| 现象 | 大概率原因 | 排查方向 |
|---|---|---|
dm tree里看不到设备节点 | compatible不匹配,或驱动未被链接 | 检查of_match数组和设备树节点的compatible;确认U_BOOT_DRIVER的of_match已定义;检查对应.o是否编进固件 |
设备在dm tree里能看到,但一直不是active | 没有代码主动probe它,或probe被跳过 | 找到上层使用者,确认用户代码是否调用了uclass_get_device/device_probe |
| probe时报“missing uclass”或找不到uclass id | UCLASS_DRIVER没注册,或driver.id写错 | 检查enum uclass_id枚举定义,确认uclass链接表里有对应条目 |
| 多个设备共用一个驱动,只有第一个device正常 | platform data或uclass platdata分配问题 | 检查per_device_auto、per_device_platdata_auto等宏,确认每个设备的数据区独立 |
| 父设备probe失败,子设备莫名其妙起不来 | 父设备依赖的前置资源没就绪 | 先单测父设备,单独调device_probe父设备,看它具体死在哪个返回值 |
开了CONFIG_DEBUG_DM还是没日志 | 某些log被优化,或者串口本身是早期设备 | 在驱动probe函数里手动加printf缩进,配合DM_DEBUG宏在必要位置打点 |
这里面最隐蔽的是compatible匹配问题。很多Linux下的驱动已经写好了,复制到U-Boot里,of_match数组却在驱动文件里没有正确导出,或者设备树里的compatible写成了"allwinner,sunxi-gpio"而驱动里的of_match只写了"allwinner,sun8i-gpio"。字符串匹配没有任何缓存,错一个字符就是整个节点消失,且没有任何主动报错。所以我建议你现在就养成习惯:写完U_BOOT_DRIVER,第一时间用dm tree验证节点在不在;不在,先比对compatible字符串,用二分法缩小范围。
还有一个值得说的坑是uclass id。U-Boot的uclass id定义在一个大的枚举里,类似UCLASS_ROOT、UCLASS_SERIAL、UCLASS_GPIO这些。如果你在自定义驱动里把.id不小心写成一个不存在的枚举值,或者改成别人的uclass id,那么bind阶段找uclass实例时就会失败。这种问题看起来像“系统崩溃”,其实是骨架没搭对。
5. 写在最后:换个角度看这块骨架
老话说功夫在诗外,对嵌入式工程师来说,源码就是最好的老师。每次我调U-Boot驱动调不动,都会重新回到drivers/core/root.c、device.c、uclass.c这几个核心文件里过一遍。这三个文件加起来也没几千行,但把U-Boot设备模型的骨架讲得明明白白。
我自己最深的体会是,不要去背那些回调函数的调用顺序,而是要记住“链接表收集驱动、scan阶段绑定设备、probe按需初始化硬件”这个总纲。只要这个总纲在脑子里,遇到dm tree里看不到节点、probe失败、uclass缺失这类问题,你第一时间就知道该往哪个方向查。屠龙刀不是指某个具体函数,而是这套“由静到动、先结构后行为”的骨架思维。
另外一个小技巧,调试U-Boot设备模型时别只看代码加打印,多利用命令行命令。U-Boot本身就是个交互环境,dm tree一下能省掉很多无意义的插桩日志。如果你调的板子串口还没起来,那就要从early scan阶段开始排查,看看board_init_f里的initf_dm之后,早期设备到底有没有被正确bind和probe,这一步起不来,后面board_init_r里的一切都是空中楼阁。
最后再分享一个我个人的土办法:我习惯在驱动probe函数入口加一行printf("probe %s\n", dev->name),先确认它到底有没有被调用,然后才去分析外部问题。代码里加了这行不减,出问题时永远快人一步。