在设备树还未被广泛使用时的那些事

电脑手机等设备开机后,bios / bootloader开始启动,它开始执行一些固定的程序,比如启动操作系统,调用系统内核从而让系统调用硬件中的寄存器。

然后请设想一下:bootloader刚刚将Linux内核复制到内存中,然后跳到内核的入口点开始执行。

此时内核就像运行在处理器上的一个裸机程序。需要配置处理器,设置虚拟内存,向控制台打印一些信息。但是这些事情如何完成?所有的这些操作都要通过写寄存器来实现,但Linux内核如何知道这些寄存器的地址?如何知道当前有多少个CPU核可以使用?有多少内存可以访问?

早期解决方案

最直接的办法就是在内核代码里为指定平台写好这些代码,由内核配置参数决定哪些平台代码将被启用。

当底层架构都固定不变时这种方法还不错,比如在x86处理器上内部的寄存器,或是BIOS的访问。

但对于变化量来说, 比如PCI/PCIe外设,就需要内核明确了解这些变化的细节。

此后的ARM架构及其生态不断扩大,不同的嵌入式设备等开始遍地开花。我们俗称这些设备的主板叫“开发板”,更通俗一点就是“板子”。ARM架构和其生态的扩大不能不说是一件好事,但它同样也给当时的linux社区带来了一个很大的潜在麻烦。因为嵌入式平台中很多公司的芯片采用的都是ARM架构,随着Android的成功,这些代码越来越多。据说常见的平台如s3c2410板级目录下边的代码有数万行,难怪Linux Torvalds会说“this whole ARM thing is a fucking pain in the ass”

对于每块不同的板子,即使处理器使用相同的编译器和函数,但具体到某一种芯片,它就有自己的寄存器地址和不同的配置方式。不仅如此,每种板子都有自己的外设。

内核要存这些板子芯片和外设的参数文件啊。但是芯片越来越多,外设也越来越多。内核要存的硬件信息也就越来越多。结果造成内核中有大量的头文件、补丁和特殊的配置参数,它们的一种组合就对应于一款芯片的一种特殊板型。更糟糕的是,这些代码大多是杂乱且重复的,这使得ARM体系结构的代码维护者和内核维护者在发布一个新的版本的时候有大量的工作要做。。

那有人肯定想说,为什么用哪就只存哪,那不就能省很多空间了嘛。言之有理,但修改内核文件,意味着要重新编译内核,这对于开发者和用户是及其不友好的。另外一点是,一个设备少数的寄存器记录也需要大量的代码,编写设备的配置参数文件也不是特别简单。

设备树的出现

那我们如果将这些板级细节丢到内核外呢?这些板级细节被bootloader调用后传递给内核,这样子是不是让内核更加精简,内核对各种板子的兼容性更强,同时可扩展性更高呢?(毕竟设备描述文件已经被丢到外边了,增删改也不需要重新编译内核,再动态的增加减少设备文件也简单了许多)

这时就需要讲到设备树了。“开放固件设备树(Open Firmware Device Tree)”或简称为设备树(DT)是数据描述硬件的结构和语言。更具体地说,是对操作系统可读的硬件的描述。这样,操作系统就无需对应用程序的详细信息进行硬编码(即设备描述文件丢内核里边)。

DT最初是由Open Firmware创建的,它是用于将数据从开放固件传递到客户端的通信方法程序(类似于操作系统)。一个操作系统使用了设备树,用于在运行时发现硬件的拓扑,以及从而支持大多数可用硬件。由于开放式固件通常在PowerPC和SPARC平台上使用,Linux对这些架构的支持已经使用设备树很长时间了。这距离linus大怒说出(2011年3月17日)“this whole ARM thing is a f*cking pain in the ass”还早着。于是从2011年3月开始,内核在PowerPC、ARM等体系里正式打算使用设备树。以ARM体系为例,加入设备树的版本就是v3.1,可以在arch/arm/boot/目录下看到dts目录的出现。

设备树能存我们需要的设备描述文件了,那内核如何调用设备树文件来获取硬件细节信息呢?大概可以这么理解:设备树基本上就是画一棵电路板上CPU、总线、设备组成的树,Bootloader会将这棵树传递给内核,然后内核可以通过驱动和其它手段识别这棵树,并根据它展开出Linux内核中的platform_device、i2c_client、spi_device等设备,而这些设备用到的内存、IRQ等资源,也被传递给了内核,内核会将这些资源绑定给展开的相应的设备。

浅谈设备树组成及文件类型

如何理解设备树

内核中关于设备树的文档位于kernel/Documentation/devicetree/目录。设备树是Power.org组织定义的一套规范,规范文档可以在官网上找到,目前最新的版本是 https://www.power.org/documentation/epapr-version-1-1/设备树是从软件使用的角度描述硬件的,不是从硬件设计的角度描述的。我们在写设备树时没有必要按照硬件逻辑生搬硬套,也不要指望通过阅读设备树弄清楚硬件是如何设计的。对于软件可以自动识别的硬件,如USB设备,PCI设备,也是没有必要通过设备树描述的。

规范内容是可以分为两个层次的。第一层是关于设备树组织形式的,如设备树结构,节点名字的构成等,第一个层次是基础,是理解第二个层次的前提。第二层是关于设备树内容的,如多核CPU怎样描述,一个具体的设备如何描述。第二层可以看成是第一层的具体应用。相对来说第二层内容更多,更具体,根据描述的内容不同,定义规范的方式也有差别,比如关于CPU,内存,中断这些基础的内容,是在epapr中说明的,而关于外设的规范是在专门的地方说明的。

设备树首先是一个树形结构,并且是一棵树。除了根节点外其他子节点都有唯一的父节点,节点下可以有子节点和属性(子节点可以看成是树枝,属性可以看成是叶子)。属性由名字和值组成(名字是必须的,但是值不是必须的,如果只要根据是否存在这个属性就可以表示我们想要的功能,那么可以不需要有值)。如:

从内核代码中截取的一个DTS片段。

“/”表示根节点。
“model = "Newflow AM335x NanoBone"”是根节点下边的属性。
“cpus”是根节点的一个子节点。
“cpu0-supply = <&dcdc2_reg>”是“cpu@0”子节点下的属性。

节点下的属性用来表示节点的特性,子节点和父节点具有一定的从属关系。

真实的硬件不可能是这样规则的树形结构,所以设备树仅是软件开发人员为了描述硬件而做的一个近似表示而已,连抽象都算不上。

设备树文件类型

设备树包含DTC(device tree compiler),DTS(device tree source)和DTB(device tree blob)。

DTS(device tree source)

.dts文件是一种对Device Tree的ASCII文本描述,一个dts文件对应一个ARM架构的machine。但是一个SOC板可能对应多个产品。这些产品的dts文件会存在大量冗余。为了简化,Device Tree将这些冗余提炼为.dtsi文件,dtsi文件相当于C语言的头文件,dts文件需要include引入dtsi文件。

通常,soc厂商会将soc的硬件信息或多个开发板公用的硬件部分写成dtsi文件,将特定于某块开发板的信息写成dts文件,这样,dts文件+dtsi文件就构成了完整的设备树。

DTC(编译工具)

DTC为编译工具,它可以将.dts文件编译成.dtb文件。DTC的源码位于内核的scripts/dtc目录下,内核选中CONFIG_OF,编译内核的时候,主机可执行程序DTC就会被编译出来。

DTB(二进制文件)

DTC编译.dts生成的二进制文件(.dtb),bootloader在引到内核时,会预先读取.dtb到内存,进而由内核解析。

设备树被各种东西的调用解析

设备树从二进制文件状态被bootloader调用,再到内核读取设备树的信息并解析,大体可以区分两步。一步是硬件层面上的,也就是bootloader加载设备树到内存中;第二步是内核解析设备树,使其像早期的platform文件一样能被识别并匹配对应的硬件,然后与调用它们的软件相绑定。

dtb文件如何被加载到内存

相关资料链接:https://www.cnblogs.com/zongzi10010/p/10793084.html

首先要明确的是U-boot的启动:

bootm <uImage_addr>                            // 无设备树, 如bootm 0x30007FC0
bootm <uImage_addr> <initrd_addr> <dtb_addr> // 有设备树

可以看到在有设备树的情况下,第三个参数 <dtb_addr>指向了设备树文件 .dtb的具体地址,我们对其做解析即可。

if (argc == 4) {
//第三个参数argv[3]就是设备树地址
of_flat_tree = (char *) simple_strtoul(argv[3], NULL, 16);
/* unsigned long simple_strtoul(const char *cp, char **endp, unsigned int base)
功能:将一个字符串转换成unsigend long型数据。
返回:返回转换后数据。*/

if (be32_to_cpu(*(ulong *)of_flat_tree) == OF_DT_HEADER) {
// 大小端转换函数,le32_to_cpu is used for convesions from 32bit little endian data into CPUs endianness,大多数情况下无事发生

printf ("\nStarting kernel with device tree at 0x%x...\n\n", of_flat_tree);

cleanup_before_linux (); // 加载前的一些清理工作

theKernel (0, bd->bi_arch_number, of_flat_tree); // //把dtb的地址传到r2寄存器里

} else {
printf("Bad magic of device tree at 0x%x!\n\n", of_flat_tree);
}

}

最终的启动流程如下:

nand read.jffs2 0x30007FC0 kernel;     // 读内核uImage到内存0x30007FC0
nand read.jffs2 0x32000000 device_tree; // 读dtb到内存32000000
bootm 0x30007FC0 - 0x32000000 // 启动, 没有initrd时对应参数写为"-"

然而这个 0x32000000地址是如何被确定的?它有什么样的要求?

设备树文件的内存选址

显然以下几个需求:

  • 不要破坏u-boot本身
  • 内核本身的空间不能占用, 内核要用到的内存区域也不能占用
  • 内核启动时一般会在它所处位置的下边放置页表, 这块空间(一般是0x4000即16K字节)不能被占用

前两条都容易理解,第三条属于内核的知识,在此我们不谈

这里引用资料中的一张图。

                   ------------------------------
0x33f80000 ->| u-boot | 分析lds链接文件
------------------------------
| u-boot所使用的内存(栈等)|
------------------------------
| |
| |
| 空闲区域 |
| |
| |
| |
| |
------------------------------
0x30008000 ->| zImage |
------------------------------ uImage(压缩后的内核映像) = 64字节的头部+zImage
0x30007FC0 ->| uImage头部 |
------------------------------
0x30004000 ->| 内核创建的页表 | head.S
------------------------------
| |
| |
-----> ------------------------------
|
|
--- (内存基址 0x30000000)

显然 0x300000000x30004000之间的空间不能被拿来作为设备树的内存存放地址。而中间的空闲部分虽然可以使用,但又不能放到开头。zImage我们只知道它的开始地址是 0x30008000,大小未知即结束位置未知。一般而言,内核所占的内存在几百M到几G之间,把dtb丢在空闲区域的前部分显然是不理智的。

然后看到U-boot的起始地址 0x33f80000,其所用内存都丢在其地址下边的空间。所以从dtb文件地址开始,到U-boot的起始地址,之间的空间要包含下dtb的文件大小以及U-boot的内存使用。这俩玩意还都是动态的内存大小,这对dtb文件的内存地址确定造成了麻烦。

这里我推测设备树文件起始地址 0x32000000是对应资料实验环境所得到的一个较不错的内存地址选择。最后这部分的内存图示如下:

                   ------------------------------
0x33f80000 ->| u-boot | 分析lds链接文件
------------------------------
| u-boot所使用的内存(栈等)|
------------------------------
| 动态空闲 |
------------------------------
0x32000000 ->| dtb | 设备树文件
------------------------------
| 空闲地址 |
------------------------------
0x30008000 ->| zImage |
------------------------------ uImage(压缩后的内核映像) = 64字节的头部+zImage
0x30007FC0 ->| uImage头部 |
------------------------------
0x30004000 ->| 内核创建的页表 | head.S
------------------------------
| |
| |
-----> ------------------------------
|
|
--- (内存基址 0x30000000)

当然如果以一个破坏页表的方式来启动是启动不起来的,例如:

nand read.jffs2 30004000 device_tree
nand read.jffs2 0x30007FC0 kernel
bootm 0x30007FC0 - 30004000

内核解析设备树的具体流程(自己还没整理)

相关资料链接:https://blog.csdn.net/qq_40537232/article/details/115507062https://www.cnblogs.com/zongzi10010/p/10793082.html

过程大致如下:

  • dtb展开为device_node
  • device_node展开为platform_device
  • 设备匹配

设备树编写应用(略)

此部分先略过,还没摸到。

原则上这部分内容是要详细了解上部分内容的,这里只做个简略介绍。具体工程里,首先看的还是文档。文档是对各种node 的描述,位于内核documentation/devicetree/bingdings/arm/下。很多上层应用开发者没有做过内核开发的经验,对内核一直觉得很神秘,其实可以换一种思路来看内核,相信上层应用开发者最熟悉的就是各种API,工作中可以说就是和API 打交道,对于内核也可以想象是各种API,只不过是内核态的API。

这里设备文件就是根据各种内核态的API 来调用设备树里的板级信息:

  • struct device_node *of_find_node_by_phandle(phandle handle)
  • struct device_node *of_get_parent(const struct device_node_ *node)
  • of_get_child_count()
  • of_property_read_u32_array()
  • of_property_read_u64()
  • of_property_read_string()
  • of_property_read_string_array()
  • of_property_read_bool()

具体的用法这里不做进一步的解释,可以查看官网文档或者上一节提供的资料链接。

回过头来看设备树的作用。

  1. 平台标识,所谓平台标识就是板级识别,让内核知道当前使用的是哪个开发板,这里识别的方式是根据 root 节点下的 compatible 字段来匹配。
  2. 是运行时配置,就是在内核启动的时候 ramdisk 的配置,比如 bootargs 的配置,ramdisk 的起始和结束地址。
  3. 是设备信息集合,这也是最重要的信息,集合了各种设备控制器。

理解设备树还是需要具体去看源码解析和编写规范的,只有实操过才能有真知。