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。

注意:设备树地址不能和内核加载地址重叠。我见过有人把 DTB 放在内核地址附近,结果内核解压时直接把 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);

    // ... 初始化硬件
}

你看,设备树里的 reginterrupts 自动变成了 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>。搞混了驱动就收不到中断。

说实话,设备树调试最头疼的就是问题定位。我建议你在内核启动参数里加 earlyconlog_buf_len=1M,这样能尽早看到设备树解析的日志。如果某个设备没注册,先查 /sys/firmware/devicetree/base 下的节点对不对,再查驱动 compatible 表。

好了,这一章就到这。设备树和内核启动的关系,说白了就是「bootloader 递 DTB,内核解析成设备,驱动匹配干活」。下一章咱们聊聊设备树的中断控制器——那才是真正让人头疼的地方。