第 5 章 扁平的世界——DTB 格式解构
本章的任务是揭开那个从 Bootloader 传给内核的二进制黑盒。如果你一直在折腾设备树源文件(DTS),你可能觉得设备树就是一种描述硬件的文本语法——写起来像 C,跑起来像配置文件。但这里有一个微妙的转折:内核并不读文本。
文本是给人看的,机器只认二进制。当 Bootloader 完成它的使命,把控制权交给内核的那一瞬间,它必须把这一整棵复杂的硬件树——从根节点到最底层的 GPIO 引脚——压缩进一个连续的、线性的、没有指针的二进制块里。这就是 DTB(Devicetree Blob)。这不仅仅是个格式转换问题,想象一下你把一张错综复杂的地图(DTS)折叠成一个小方块(DTB)扔给另一个旅行者(内核)。如果折叠方式没有严格的约定,内核展开的时候就会把「山脉」读成「河流」,或者直接迷路。
我们要解决的就是这个「折叠约定」。IEEE 1275 的 Open Firmware 并没有定义这个约定——在那些老系统上,设备树是通过调用固件方法动态遍历的。但现代嵌入式系统需要更高效、更静态的方式,于是我们需要 DTB 格式。它是一个单一线性数据结构,没有指针,全是偏移量。
在看具体的结构之前,我们先碰一下那个让人有点不爽的东西:版本号。DTB 格式不是一成不变的,从诞生到现在它经历了多次迭代。为了让内核能识别手里拿到的这张「地图」到底是哪一年的版本,头部必须包含版本信息。本书描述的是 Version 17。这是一个硬性规定:一个合规的 Bootloader 给内核的 DTB 应该是 Version 17 或者更新的,且必须向后兼容 Version 16。这里的版本号指的是二进制结构的版本,不是你写的内容的版本。哪怕你描述的是最新的 ARMv9 芯片,只要 DTB 结构是老式的,版本号就得如实反映。
5.2 破解头部
万事开头难但也最重要。DTB 的头部就是它的「身份证」,里面藏着通往所有数据块的索引。头部本身是一个 C 结构体 struct fdt_header,所有字段都是 32 位整数并且全部采用大端模式存储——这是为了兼容那些古老的大端架构,比如 PowerPC。让我们把这个结构拆开来看一个字段一个字段地过,如果你正在写解析代码,这里任何一个字节读错后面全盘皆输。
magic 必须是 0xd00dfeed(大端)。这一行是第一道防线,如果你读到的不是这个数字那你手里的东西要么不是 DTB 要么已经损坏。千万别试图跳过这个检查,否则你就是在拿随机的内存地址当指针用,结果通常是一个漂亮的 Kernel Panic。
totalsize 是整个 DTB 结构的字节大小。注意这个大小必须是完全的,它不仅包括头、内存保留块、结构块和字符串块本身,还包括夹在这些块中间的「空白填充区」。如果你要拷贝整个 DTB 到新地址,这个字段就是你的标尺。
off_dt_struct 和 size_dt_struct 是结构块的偏移和大小,这是 DTB 的核心数据区后面我们会花大篇幅讲它。off_dt_strings 和 size_dt_strings 是字符串块的偏移和大小,属性名都存在这里。off_mem_rsvmap 是内存保留块的偏移。注意:这个结构块里没有 size_mem_rsvmap 字段,因为内存保留表的结尾是用一个全零条目标记的,不需要额外的大小字段。
version 是当前版本号,如果我们正在讨论的格式这里应该是 17。last_comp_version 是最低兼容版本,对于 Version 17 来说这个值是 16,这意味着这个 DTB 可以被能读懂 Version 16 的内核正确解析。boot_cpuid_phys 是启动 CPU 的物理 ID,这是为了告诉内核:「我是谁?我是那个负责启动的 CPU」。这个值必须和设备树中对应 CPU 节点的 reg 属性里的物理 ID 一致。
5.3 内存保留块——在此处止步
在正式开始解析设备树之前,有些内存区域是绝对的禁区。这就是内存保留块存在的意义,它告诉客户端程序(通常是内核):「这几块内存我有用,你别动」。
想象一下,Bootloader 在运行时把自己放在了内存的某个角落,或者它初始化了一些用于 IOMMU 地址翻译的表(TCE tables)。如果内核不知道这些区域的存在,它可能会开心地把这些内存分配给进程做堆栈——然后系统在某个随机时刻崩溃。或者在某些使用 RTAS(Run-Time Abstraction Services)的 Open Firmware 平台上,运行时服务代码必须一直驻留在内存里,内核不能覆盖它。
除非 Bootloader 明确告诉你你可以访问这块保留内存,否则严禁触碰。这不仅仅是建议,这是系统稳定性的基石。内存保留块是一个结构体数组,每个条目描述一段保留区域。它的定义非常简单粗暴:address 是物理内存起始地址,size 是区域大小(字节)。这个列表不是通过计数结束的,而是通过一个特殊的「终结者条目」——一个 address 和 size 都为 0 的条目。读到它就说明列表结束了。
这里的 address 和 size 永远是 64 位的,即使在 32 位 CPU 上结构体本身依然是 64 位字段,只不过高 32 位会被忽略。内存保留块中的每个条目以及整个块本身,都必须相对于 DTB 起始地址** 8 字节对齐**。没对齐的 DTB 在某些架构上会导致未对齐内存访问异常。
如果你是通过 UEFI 启动的,这里有一个坑。UEFI 有自己的内存映射,但 DTB 里的内存保留块是独立的一套逻辑。为了防止 UEFI 自己的应用程序把 DTB 里标记为保留的内存给分配了,这里的每一项都必须同步出现在 UEFI 的 GetMemoryMap() 返回的内存地图里,类型设为 EfiReservedMemoryType。如果你只改了 DTS 而没更新 UEFI 的配置,或者反过来,内存管理的步调就会不一致。
5.4 结构块——树的骨架
现在我们进入了 DTB 的心脏地带。结构块的任务是把那个有层次、有节点、有属性的树压扁成一串线性的数据流。它没有指针,只有「标记」和「数据」。
结构块由一系列「片段」组成,每个片段都以一个 Token(标记) 开头。Token 是一个 32 位大端整数,它的值决定了后面跟的是什么数据。所有的 Token 必须 4 字节对齐,如果前面的数据长度不对齐就需要插入填充字节(值为 0x0)。
这里有五种核心 Token 你最好把它们背下来。FDT_BEGIN_NODE (0x00000001) 意味着进入了一个新节点,紧接着是这个 Token 的是节点名称字符串(以 \0 结尾),名称后面可能需要填充字节以保持 4 字节对齐。FDT_END_NODE (0x00000002) 意味着当前节点结束了,这个 Token 没有额外数据,它后面直接跟下一个 Token(通常要么是兄弟节点的 BEGIN,要么是父节点的 END)。
FDT_PROP (0x00000003) 是最复杂的一个。看到 0x3 意味着后面跟着一个属性,它的数据格式是一个小头部 + 属性值:len (uint32_t) 是属性值的长度(可以是 0),nameoff (uint32_t) 是关键点——这不是属性名字符串本身而是一个偏移量,指向字符串块里该属性名的位置,Value 是实际的属性值数据长度为 len,Padding 填充到 4 字节边界。
FDT_NOP (0x00000004) 是个特殊的占位符。解析器看到它就直接跳过,这有什么用?用于「打补丁」或「删除」。如果你想在运行时删除树里的某个节点或属性,但你不想移动后面所有的数据(因为那是巨大的内存拷贝),你只需要把它覆盖成 FDT_NOP。这是一个非常巧妙的设计,允许原地修改 DTB。FDT_END (0x00000009) 标志着整个结构块的结束,整个 DTB 里只能有一个 FDT_END,它必须是结构块的最后一个 Token。
规则很简单但必须严格遵守。树是递归的,FDT_BEGIN_NODE 和 FDT_END_NODE 必须成对出现,子节点的一对必须嵌套在父节点的一对里面。顺序很重要:对于任何一个节点,它的所有属性必须定义在它的所有子节点之前。虽然从逻辑上讲穿插写也能理解,但为了简化解析代码,规范强制要求先写完属性再写子节点。
5.5 字符串块——去重的仓库
你可能在刚才的 FDT_PROP 结构里注意到了,属性名并不在结构块里而是通过偏移量引用。这就是字符串块的作用,它是 DTB 的「公共字典」。所有的属性名(比如 compatible, reg, status)都被集中存放在这里,每个都是 \0 结尾的字符串直接首尾相接堆在一起。当 FDT_PROP 需要记录 compatible 这个属性时,它只需要记录 compatible 在字符串块里的偏移量即可。这种设计极大地节省了空间,因为成百上千个节点都会复用同样的几个属性名。字符串块没有对齐要求,它可以在任何偏移位置出现。
5.6 对齐——别让 CPU 抓狂
最后我们要谈谈对齐。我们前面提到了几个地方需要填充字节,这不是为了好玩而是为了 CPU 的健康。DTB 整体必须加载到 8 字节对齐的内存地址上。虽然为了兼容老 32 位机有些软件支持 4 字节对齐,但那是不符合规范的。内存保留块内部条目 8 字节对齐。结构块内部 Token 4 字节对齐。
为了满足这些苛刻的条件,DTB 生成器(DTC)会在各个块之间自动插入 (free space) 区域。你在解析时一定要依赖头部的偏移量字段去寻址,千万不要假设结构块紧跟着头部。如果你打算在运行时搬运 DTB,请务必确保目标地址也是 8 字节对齐的。否则,虽然代码在 x86 上可能跑得欢,但在 ARM 或 PowerPC 上,你可能会因为触发未对齐异常而死得很难看。
本章回响
现在我们构建的是一张精密的地图。从 struct fdt_header 的第一行开始,我们就在通过偏移量这种「相对坐标」来重建一棵绝对复杂的树。没有指针意味着它可以直接从磁盘加载或通过网络传输,不需要重定位;字符串块的独立意味着我们可以在不破坏结构的情况下修补属性名。这一切的设计哲学都是为了交换——让 Bootloader 这个「甲」能毫无歧义地把硬件信息交给内核这个「乙」。
下一章,我们将利用这些知识开始真正地编写代码来遍历这棵树。你会发现虽然它是二进制的,但只要你看透了那些 Token 的规律,它甚至比文本更容易操作。