1. 从 board_init_r 切入:为什么设备模型是绕不开的坎
玩过 u-boot 移植的人都有一个共同体会:板子能不能起来,串口能不能打印,存储能不能识别,网卡能不能工作,最后都绕不开一个东西——设备模型(Driver Model,简称 DM)。而board_init_r这个函数,就是整个 u-boot 从“原始初始化”切换到“驱动接管”的分水岭。你如果在这个阶段没把 DM 骨架搭对,后面所有外设驱动都是空中楼阁。
我最早接触 u-boot 的时候,习惯性地把驱动当成一堆独立的 C 文件,每个文件自己初始化自己的硬件,自己管理自己的全局变量。这种写法在早期 u-boot 里确实行得通,板级文件里塞一堆#ifdef,编译时把不需要的驱动裁掉,运行时直接调函数指针。但问题是,当 SoC 越来越复杂,同一颗芯片要支持几十种板子,外设复用关系错综复杂,这种“各管各的”模式就彻底崩了。设备模型的出现,本质上就是给 u-boot 引入了一套类似 Linux 内核的设备-驱动-总线三层抽象,让驱动代码和板级配置解耦。
board_init_r是 u-boot 第二阶段初始化的核心入口,它运行在重定位之后,此时代码已经搬到了 RAM 的高端地址,堆栈和全局数据都已经就绪。这个函数里做的事情非常多,但最关键的一步就是dm_init_and_scan()的调用。这个调用把设备模型从“静态描述”变成“动态实例”,让所有在设备树里声明过的节点,按照U_BOOT_DRIVER注册的驱动,逐个绑定、探测、激活。你可以把它理解成一场大型的“相亲大会”:设备树是相亲名单,驱动是候选人,总线是介绍人,board_init_r就是主持人宣布活动开始的那一刻。
为什么说“屠龙刀在手”?因为一旦你理解了board_init_r里 DM 骨架的搭建逻辑,你就拥有了快速定位驱动问题、快速移植新板、快速裁剪系统体积的能力。很多人在移植 u-boot 时遇到“串口没输出”“MMC 识别不到”“网卡 ping 不通”这类问题,第一反应是去翻驱动代码,其实更高效的路径是回到board_init_r,看看 DM 扫描有没有走到那一步,设备有没有被正确绑定。这把“屠龙刀”砍下去,问题往往迎刃而解。
这篇文章适合谁看?如果你正在做 u-boot 移植、BSP 开发、嵌入式 Linux 系统集成,或者你单纯想搞明白 u-boot 的驱动是怎么从设备树变成可调用对象的,那接下来的内容就是为你准备的。我会从整体设计思路讲到具体代码路径,从board_init_r的调用链讲到 DM 的绑定探测机制,再结合我实际踩过的坑,把“驱动骨架怎么搭起来”这件事说透。
2. 设备模型整体设计与 board_init_r 的衔接逻辑
2.1 为什么 u-boot 要引入 DM:从“板级硬编码”到“设备树驱动”
早期 u-boot 的驱动模型可以用一句话概括:板级文件里直接写死。比如board/samsung/smdk2410/smdk2410.c里会直接调用serial_init(),而这个函数内部又直接操作寄存器。这种模式的问题在于,同一颗 SoC 的不同板子,如果串口引脚不同、时钟源不同,就得复制一份板级文件改一改。代码重复率高,维护成本大,而且编译时裁剪只能靠#ifdef,非常不优雅。
设备模型引入之后,驱动被拆成三个核心概念:设备(struct udevice)、驱动(struct driver)、总线(struct uclass)。设备是硬件实例的抽象,驱动是操作方法的集合,总线是同类设备的统一管理接口。设备树里的一个节点,在 DM 里对应一个udevice;U_BOOT_DRIVER宏注册一个driver;UCLASS_DRIVER宏注册一个uclass。三者通过board_init_r里的扫描流程串联起来。
这种设计的好处非常明显。第一,驱动代码不再关心“我是第几个串口”,只关心“我操作的是哪个寄存器基址”,基址从设备树里读。第二,板级文件不再需要包含具体驱动头文件,只需要在设备树里描述硬件。第三,裁剪驱动只需要改设备树和配置宏,不需要动板级 C 代码。第四,同一套驱动可以跨 SoC 复用,只要设备树节点兼容字符串匹配得上。
2.2 board_init_r 在启动流程中的位置:重定位之后的第一件大事
u-boot 的启动流程大致分为两个阶段。第一阶段是board_init_f,运行在只读的 Flash 或 ROM 里,主要做内存控制器初始化、串口初始化(早期调试串口)、重定位准备。第二阶段是board_init_r,运行在 RAM 里,此时全局数据、堆、栈都已经就绪,可以跑复杂的驱动模型了。
board_init_r的调用链大致是这样的:_main->board_init_r->init_sequence_r[]里的各个函数 ->dm_init_and_scan()。注意,dm_init_and_scan()并不是board_init_r里第一个被调用的函数,它前面还有initr_trace、initr_reloc_global_data、initr_barrier等。但它是驱动模型真正“活过来”的关键节点。
为什么要把 DM 扫描放在这个位置?因为 DM 扫描需要堆内存来分配udevice结构体,需要设备树已经解析完毕(gd->fdt_blob已经指向正确的地址),需要gd->dm_root已经初始化。这些条件在board_init_f阶段都不具备,只有到了board_init_r才满足。所以board_init_r是 DM 骨架搭建的“天时地利人和”之地。
2.3 dm_init_and_scan 的调用时机与前置条件
dm_init_and_scan()在board_init_r里被调用时,有几个前置条件必须满足。第一,gd->fdt_blob必须已经指向设备树二进制文件在内存中的地址。这个地址通常由board_init_f阶段的fdtdec_setup()或者board_init_r早期的initr_reloc_global_data设置好。第二,gd->dm_root必须为 NULL 或者已经通过dm_init()初始化。第三,堆内存gd->malloc_base必须已经可用,因为 DM 扫描过程中会大量使用malloc分配udevice和uclass结构体。
我见过不少移植失败的案例,问题就出在gd->fdt_blob没有正确设置。比如设备树被编译到了错误的位置,或者重定位时没有把设备树一起搬过去,导致dm_init_and_scan()扫描时读到的是一堆乱码。表现就是串口没有任何输出,或者输出到一半就卡死。排查这种问题,最直接的方法是在dm_init_and_scan()入口加一句printf("fdt_blob = %p\n", gd->fdt_blob),看看地址是否合理。
3. DM 骨架的核心组件与绑定探测机制
3.1 udevice、driver、uclass 三者的关系与数据结构
要理解 DM 骨架,必须先搞清楚三个核心结构体的关系。struct udevice代表一个设备实例,它里面有一个struct driver *driver指针,指向操作这个设备的驱动;有一个struct uclass *uclass指针,指向这个设备所属的总线类型;还有一个ofnode node,指向设备树里对应的节点。struct driver里最重要的是bind、probe、remove三个回调函数,以及of_match匹配表。struct uclass里有一个uclass_driver,定义了这类设备的统一操作接口,比如post_bind、pre_probe、post_probe等。
三者关系可以用一个生活化类比:uclass是“行业分类”,比如“餐饮业”;driver是“具体菜谱”,比如“川菜做法”;udevice是“具体餐馆”,比如“某家川菜馆”。设备树里的节点就是“开店申请”,board_init_r里的扫描就是“审批流程”,审批通过后,餐馆才能营业(probe),顾客才能点菜(调用驱动接口)。
在代码层面,U_BOOT_DRIVER宏会把驱动结构体放到.u_boot_list_2_driver_1段里,UCLASS_DRIVER宏会把总线驱动放到.u_boot_list_2_uclass_driver_1段里。链接脚本会把这些段收集起来,形成一个数组。dm_init_and_scan()遍历这些数组,把驱动和总线注册到 DM 核心的全局链表里。
3.2 设备树节点如何变成 udevice:bind 流程拆解
设备树节点变成udevice的过程叫bind。dm_init_and_scan()会调用dm_scan_fdt(),后者遍历设备树里所有带有compatible属性的节点(或者带有u-boot,dm-pre-reloc等特殊属性的节点)。对于每个节点,DM 核心会查找匹配的驱动。匹配规则是:驱动的of_match表里有一个compatible字符串,设备树节点的compatible属性里也有一个或多个字符串,只要有一个匹配上,就算匹配成功。
匹配成功后,DM 核心调用device_bind_common(),分配一个udevice结构体,设置driver、uclass、node等字段,然后调用驱动的bind回调。bind回调里通常做两件事:一是从设备树读取硬件参数(比如寄存器基址、时钟频率、引脚配置),保存到udevice的私有数据区(platdata或priv);二是初始化一些软件状态,比如链表头、缓冲区指针。
这里有一个关键点:bind阶段不能访问硬件寄存器。因为此时设备可能还没有上电,时钟可能还没有使能,引脚可能还没有复用。bind只做“信息收集”和“软件初始化”,真正的硬件操作要留到probe阶段。我见过有人在bind里直接写寄存器,结果在某些板子上因为时钟没开导致总线挂死,排查了半天才发现是阶段搞错了。
3.3 probe 的触发时机:什么时候真正操作硬件
probe是驱动真正操作硬件的阶段。board_init_r里的dm_init_and_scan()只负责bind,不负责probe。probe是懒加载的,只有当某个设备被第一次使用时,才会触发probe。比如串口驱动,当printf第一次调用serial_putc时,才会去probe串口设备。MMC 驱动,当mmc_init被调用时,才会去probeMMC 控制器。
这种懒加载设计的好处是启动速度快。不需要在board_init_r里把所有设备都初始化一遍,只初始化真正用到的。但坏处是调试起来稍微麻烦一点,因为probe的调用栈可能很深,不容易一眼看出是哪个设备触发的。
probe的触发路径通常是:uclass_get_device()->device_probe()->drv->probe()。device_probe()里会先检查设备是否已经probe过(dev->flags & DM_FLAG_ACTIVATED),如果没有,就调用驱动的probe回调。probe回调里可以访问硬件寄存器,可以申请中断,可以初始化 DMA。probe成功后,设备被标记为ACTIVATED,后续调用直接走驱动接口,不再重复probe。
4. 实操:从 board_init_r 到驱动可用的完整路径
4.1 配置与编译:确保 DM 相关宏正确开启
在动手之前,先检查你的 u-boot 配置。DM 相关的宏主要有几个:CONFIG_DM是总开关,必须为 y;CONFIG_DM_SERIAL、CONFIG_DM_MMC、CONFIG_DM_ETH等是各子系统的 DM 开关;CONFIG_OF_CONTROL是设备树控制开关,必须为 y;CONFIG_OF_SEPARATE或CONFIG_OF_EMBED决定设备树是单独编译还是嵌入到 u-boot.bin 里。
我一般用make menuconfig来检查这些配置。路径是Device Drivers->Driver Model,里面可以看到Enable Driver Model是否勾选。然后在Device Drivers->Serial里确认Support serial driver model是否勾选。MMC、ETH 类似。配置完成后,编译时会自动把U_BOOT_DRIVER注册的驱动链接进去。
有一个容易忽略的点:CONFIG_DM开启后,很多传统的非 DM 驱动会被自动禁用。比如CONFIG_SERIAL_LEGACY可能会被关掉。如果你发现某个驱动突然不工作了,先检查是不是因为 DM 开关导致旧驱动被裁掉了。
4.2 设备树编写:节点、compatible、寄存器与时钟
设备树是 DM 的“硬件描述文件”。一个典型的串口节点长这样:
uart0: serial@10000000 { compatible = "vendor,uart-v1"; reg = <0x10000000 0x1000>; clocks = <&clk_uart0>; clock-frequency = <24000000>; status = "okay"; };compatible是匹配驱动的关键,必须和驱动里of_match表的字符串完全一致。reg是寄存器基址和长度,驱动在bind阶段会通过dev_read_addr()读取。clocks是时钟句柄,驱动在probe阶段会通过clk_get_by_index()获取时钟。status为okay表示启用,disabled表示禁用。
我踩过的一个坑是:设备树里写了节点,但status忘了改成okay,结果 DM 扫描时直接跳过,串口死活没输出。后来在dm_scan_fdt()里加了一句打印,才发现节点被跳过了。所以每次改设备树,第一件事就是检查status。
4.3 驱动注册:U_BOOT_DRIVER 宏展开与 of_match 匹配
驱动注册的核心是U_BOOT_DRIVER宏。展开后大致是这样的:
ll_entry_declare(struct driver, uart_v1, driver) = { .name = "uart_v1", .id = UCLASS_SERIAL, .of_match = uart_v1_ids, .bind = uart_v1_bind, .probe = uart_v1_probe, .remove = uart_v1_remove, .ops = &uart_v1_ops, };ll_entry_declare会把结构体放到.u_boot_list_2_driver_1段里。链接脚本u-boot.lds里会把这个段收集起来,形成__u_boot_list_2_driver_1到__u_boot_list_2_driver_3之间的数组。dm_init_and_scan()遍历这个数组,把每个驱动注册到 DM 核心。
of_match表是一个struct udevice_id数组,每个元素包含一个compatible字符串和一个data值。data可以用来区分同一驱动的不同变体。比如同一个 UART 驱动可以支持vendor,uart-v1和vendor,uart-v2,通过data区分寄存器偏移。
4.4 绑定与探测的现场记录:用 log 和 debug 追踪流程
调试 DM 最有效的手段是打开CONFIG_DM_DEBUG和CONFIG_LOG。打开后,dm_init_and_scan()会打印每个被绑定的设备名称、驱动名称、uclass 名称。device_probe()会打印 probe 的开始和结束。这些日志对于定位“设备有没有被绑定”“驱动有没有被 probe”非常有用。
我通常会在board_init_r里dm_init_and_scan()之后加一句:
dm_dump_all();这个函数会打印当前所有已绑定的设备树状结构,包括设备名称、驱动、uclass、是否 probe。一眼就能看出哪个设备没绑定上,哪个设备 probe 失败了。
如果日志太多,可以用log filter命令动态调整日志级别。比如只看 serial 相关的日志:
log filter set serial debug这个命令在 u-boot 命令行里执行,可以实时打开 serial 子系统的调试日志。
5. 常见问题与排查技巧实录
5.1 设备没绑定:compatible 不匹配与 status 禁用
设备没绑定的最常见原因是compatible不匹配。设备树里写的是vendor,uart-v1,驱动里写的是vendor,uart,差一个字符都不行。排查方法是打开CONFIG_DM_DEBUG,看dm_scan_fdt()有没有打印“找不到匹配驱动”的日志。或者用fdtgrep工具检查设备树里节点的compatible属性。
第二个常见原因是status为disabled。有些 SoC 的默认设备树里,外设节点默认是disabled,需要板级设备树覆盖成okay。如果你用的是CONFIG_OF_SEPARATE,板级设备树会覆盖 SoC 设备树,检查覆盖后的结果。
第三个原因是节点没有u-boot,dm-pre-reloc属性,但你在board_init_f阶段就试图访问它。board_init_f阶段只能访问带有u-boot,dm-pre-reloc的节点,其他节点要等到board_init_r才能访问。
5.2 probe 失败:时钟未使能、引脚未复用、寄存器访问异常
probe失败的表现通常是:设备绑定了,但第一次使用时卡死或者返回错误。常见原因有三个。第一,时钟未使能。驱动在probe里调用clk_get_by_index()获取时钟,但忘了调用clk_enable()。第二,引脚未复用。驱动在probe里操作寄存器,但引脚还处于默认功能,没有切换到外设功能。第三,寄存器访问异常。比如基址读错了,或者寄存器宽度搞错了(32 位寄存器用 16 位访问)。
排查probe失败,我一般会在probe回调入口和出口加打印,看看到底走到哪一步卡住了。如果卡在clk_enable(),就去检查时钟树配置。如果卡在寄存器读写,就用md命令手动读一下寄存器,看看值是否合理。
5.3 启动卡死:dm_init_and_scan 死循环或内存越界
dm_init_and_scan()死循环的情况比较少见,但一旦发生就很致命。常见原因是设备树里有循环引用,比如节点 A 的phandle指向节点 B,节点 B 又指向节点 A,DM 扫描时递归解析导致栈溢出。另一个原因是堆内存不足,malloc返回 NULL,但代码没有检查,继续往 NULL 指针写数据,导致硬件异常。
排查这种问题,可以在dm_init_and_scan()前后加内存打印,看看堆使用量。也可以用CONFIG_SANDBOX在 PC 上模拟运行,用 gdb 单步调试。Sandbox 是 u-boot 的一个特殊配置,可以在 Linux 用户态运行 u-boot,非常适合调试 DM 逻辑。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 串口无输出 | 设备未绑定 | 打开 DM_DEBUG 看扫描日志 | 检查 compatible 和 status |
| 设备绑定但 probe 失败 | 时钟未使能 | 在 probe 里加打印 | 调用 clk_enable |
| 启动卡死 | 设备树循环引用 | 用 fdtgrep 检查 phandle | 删除循环引用 |
| 内存越界 | 堆不足 | 打印 malloc 返回值 | 增大 CONFIG_SYS_MALLOC_F_LEN |
| 驱动不匹配 | of_match 表错误 | 对比设备树和驱动字符串 | 修正字符串 |
6. 个人实操心得与后续扩展方向
6.1 我踩过的三个坑与对应解法
第一个坑是gd->fdt_blob地址错误。有一次我移植一块新板子,串口死活没输出,后来发现是重定位时设备树没有被正确搬运,gd->fdt_blob指向了 Flash 里的旧地址,而 Flash 已经被映射走了。解法是在board_init_f里确保fdtdec_setup()被正确调用,并且在重定位时把设备树一起搬到 RAM 里。
第二个坑是uclass驱动没注册。我写了一个自定义的 uclass,但忘了用UCLASS_DRIVER宏注册,结果uclass_get()一直返回-ENODEV。后来在链接脚本里检查.u_boot_list_2_uclass_driver_1段,发现是空的。加上宏之后问题解决。
第三个坑是probe顺序问题。有些驱动依赖另一个驱动先probe,比如 MMC 驱动依赖 GPIO 驱动先probe。如果依赖关系没处理好,MMCprobe时 GPIO 还没准备好,就会失败。解法是用uclass_get_device_by_seq()显式获取依赖设备,或者用device_probe()手动触发依赖设备的probe。
6.2 如何用 dm tree 和 dm dump 快速定位问题
dm tree命令会打印当前 DM 的设备树,包括每个设备的名称、驱动、uclass、是否 probe。dm dump命令会打印更详细的信息,包括设备的私有数据、平台数据、寄存器基址。这两个命令是调试 DM 的“瑞士军刀”。
我一般先用dm tree看整体结构,找到可疑设备,然后用dm dump <device>看具体信息。比如串口没输出,先dm tree看 serial 设备是否存在,如果存在但没 probe,就dm dump serial@10000000看它的probe状态和错误码。
6.3 从 DM 骨架延伸到驱动裁剪与启动优化
理解了 DM 骨架之后,你可以做两件很有价值的事情。第一是驱动裁剪:通过设备树status和配置宏,把不需要的驱动裁掉,减小 u-boot 体积。第二是启动优化:把关键设备的probe提前到board_init_r早期,减少懒加载带来的首次访问延迟。
我做过一个项目,把 MMC 和网卡的probe提前到board_init_r里dm_init_and_scan()之后立即执行,启动时间缩短了 200 毫秒。方法是在板级文件里调用uclass_get_device()强制触发probe。但要注意,提前probe会增加启动阶段的功耗,如果产品对功耗敏感,需要权衡。
6.4 后续可以深入的方向:uclass 自定义与驱动分层
如果你已经掌握了基本的 DM 骨架,下一步可以研究自定义 uclass。比如你有一类特殊的传感器,可以定义一个UCLASS_SENSOR,统一管理传感器的初始化、校准、数据读取。这样上层代码只需要调用sensor_read(),不需要关心具体是哪个传感器驱动。
另一个方向是驱动分层。把通用逻辑放在 uclass 驱动里,把硬件相关逻辑放在具体驱动里。比如 I2C 总线驱动负责时序,具体传感器驱动负责寄存器操作。这种分层设计可以让代码复用率大幅提升。
我个人在实际操作中的体会是:u-boot 的 DM 骨架看起来复杂,但核心逻辑其实很清晰——设备树描述硬件,驱动注册方法,board_init_r触发扫描,probe按需加载。把这四个环节串起来,大部分驱动问题都能快速定位。最后再分享一个小技巧:每次修改设备树或驱动后,先跑dm tree确认设备绑定状态,再跑dm dump确认 probe 状态,最后才去调试具体硬件操作。这个顺序能帮你省下大量盲目排查的时间。