4、设备树与内核启动:bootloader传递设备树、内核解析流程、设备树与平台设备
好,咱们今天聊点硬核的。设备树怎么从 bootloader 一路跑到内核手里?内核又是怎么把它解析成平台设备的?
说实话,我早年刚接触嵌入式 Linux 时,总觉得设备树就是个配置文件。后来被坑了几次才明白——它其实是内核启动的「骨架」。没有它,驱动根本不知道自己在哪块板子上跑。
4.1 bootloader 传递设备树:最后一棒
先说说 bootloader 这头。U-Boot 也好,别的 bootloader 也罢,它们的工作就是把设备树从存储介质里读出来,塞到内存里,然后告诉内核:「嘿,你的设备树在地址 0x80000000 上。」
具体怎么传?我习惯用 bootm 或者 bootz 命令。比如:
# 加载内核和设备树到内存
tftp 0x80000000 zImage
tftp 0x83000000 myboard.dtb
# 启动内核,传递设备树地址
bootz 0x80000000 - 0x83000000
这里有个细节:bootz 后面的 - 表示没有独立的 initramfs。设备树地址放在最后,内核启动时会从那个地址读取 DTB。
U-Boot 还会做一件事:它会在传递前修改设备树。比如根据实际硬件调整内存大小、MAC 地址等。这个操作叫 fdt 命令:
# 修改内存节点
fdt set /memory reg <0x80000000 0x10000000>
# 修改以太网 MAC
fdt set /soc/ethernet local-mac-address [02 01 02 03 04 05]
嗯,这里要注意:U-Boot 修改的是内存中的 DTB,不是原始文件。所以每次启动都会重新修改,挺灵活的。
4.2 内核解析流程:从二进制到设备模型
内核收到 DTB 后,怎么把它变成能用的东西?
说白了,就是三步:解压、解析、注册。
第一步:解压 DTB
内核启动早期,汇编代码会调用 __vet_fdt 检查 DTB 的魔数(0xd00dfeed)。然后把它解压到内核能访问的线性映射区。
第二步:解析 DTB
C 代码接手后,unflatten_device_tree() 函数登场。它把扁平的 DTB 变成一棵树——struct device_node 结构体组成的树。
我当年调试一个驱动时,发现设备节点死活找不到。后来打印了 of_allnodes 链表,才发现 DTB 里节点名写错了。你想想看,一个拼写错误能让整个驱动罢工。
第三步:注册平台设备
树建好了,内核开始遍历它。遇到 compatible 属性匹配的节点,就创建 struct platform_device。这个过程在 of_platform_populate() 里完成。
举个例子,假设 DTB 里有这么一段:
uart0: serial@10000000 {
compatible = "ns16550";
reg = <0x10000000 0x1000>;
interrupts = <0 33 4>;
};
内核解析后,会生成一个平台设备:
struct platform_device {
.name = "ns16550",
.id = 0,
.resource = {
[0] = {
.start = 0x10000000,
.end = 0x10000fff,
.flags = IORESOURCE_MEM,
},
[1] = {
.start = 33,
.end = 33,
.flags = IORESOURCE_IRQ,
},
},
};
然后内核把设备挂到平台总线上,等着驱动来匹配。
- bootloader 加载 DTB 到内存
- 内核早期检查 DTB 合法性
- unflatten_device_tree() 构建设备树
- of_platform_populate() 注册平台设备
- 驱动通过 compatible 匹配设备
4.3 设备树与平台设备:一对好搭档
平台设备(Platform Device)是 Linux 设备模型里最基础的一类。它代表那些不依赖总线(比如 PCI、USB)的硬件——说白了就是 SoC 内部的外设。
设备树和平台设备的关系,就像图纸和实物。图纸(DTB)描述了硬件长什么样,实物(platform_device)就是内核里对应的对象。
匹配过程
驱动注册时,会带上一个 of_device_id 表:
static const struct of_device_id my_uart_of_match[] = {
{ .compatible = "ns16550" },
{ .compatible = "snps,dw-apb-uart" },
{},
};
MODULE_DEVICE_TABLE(of, my_uart_of_match);
static struct platform_driver my_uart_driver = {
.probe = my_uart_probe,
.remove = my_uart_remove,
.driver = {
.name = "my_uart",
.of_match_table = my_uart_of_match,
},
};
内核匹配时,会遍历设备树节点,看 compatible 属性能不能对上。对上了就调用 probe 函数。
我曾经踩过一个坑:驱动里 of_match_table 写对了,但忘了加 MODULE_DEVICE_TABLE。结果内核编译时没生成别名,设备树匹配不上。折腾了两天才发现——嗯,从那以后我再也不敢漏这行了。
资源获取
驱动拿到平台设备后,怎么获取硬件信息?用 platform_get_resource() 和 of_property_read_*() 系列函数:
static int my_uart_probe(struct platform_device *pdev)
{
struct resource *res;
void __iomem *base;
int irq;
// 获取内存资源
res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
base = devm_ioremap_resource(&pdev->dev, res);
// 获取中断号
irq = platform_get_irq(pdev, 0);
// 读取设备树自定义属性
u32 clock_freq;
of_property_read_u32(pdev->dev.of_node, "clock-frequency", &clock_freq);
// ... 初始化硬件
}
你看,设备树里的 reg、interrupts 自动变成了 struct resource。自定义属性用 of_property_read_* 读取。这套机制让驱动完全和硬件描述解耦了。
devm_ 系列函数(比如 devm_ioremap_resource)。它们会自动管理资源释放,省得你在 remove 函数里手动清理。
4.4 避坑指南
最后分享几个我踩过的坑:
- DTB 版本不匹配:内核和 DTB 的版本要一致。我试过用 4.19 内核配 5.4 的 DTB,结果
interrupts-extended属性解析失败,中断全乱了。 - 地址空间重叠:多个设备节点用了相同的
reg地址。内核注册时会报resource collision,驱动 probe 直接返回 -EBUSY。 - 忘记
status = "okay":设备树节点默认status = "disabled"。如果你没显式改成okay,内核根本不会注册这个设备。 - 中断号写错:
interrupts属性的格式取决于中断控制器。GIC 用<0 33 4>,老式控制器可能用<33 4>。搞混了驱动就收不到中断。
说实话,设备树调试最头疼的就是问题定位。我建议你在内核启动参数里加 earlycon 和 log_buf_len=1M,这样能尽早看到设备树解析的日志。如果某个设备没注册,先查 /sys/firmware/devicetree/base 下的节点对不对,再查驱动 compatible 表。
好了,这一章就到这。设备树和内核启动的关系,说白了就是「bootloader 递 DTB,内核解析成设备,驱动匹配干活」。下一章咱们聊聊设备树的中断控制器——那才是真正让人头疼的地方。