【嵌入式Linux】Linux设备树详解
设备树是是Linux中一种用于描述硬件配置的数据结构,它在系统启动时提供给内核,以便内核能够识别和配置硬件资源。设备树在嵌入式Linux系统中尤其重要,因为这些系统通常不具备标准的硬件配置,需要根据实际的硬件配置来动态配置内核。在Linux中,设备树源文件的扩展名为.dts
,其二进制编码文件为.dtb
,将.dts
编译成.dtb
需要使用DTC工具,位于Linux内核的scripts/dtc
文件夹下
基于 ARM 架构的 SOC 有很多种,一种 SOC 又可以制作出很多款板子,每个板子都有一个对应的 DTS 文件,那么如何确定编译哪一个 DTS 文件呢?我们就以 I.MX6ULL 这款芯片对应的板子为例来看一下,打开 arch/arm/boot/dts/Makefile
// 从381行开始
dtb-$(CONFIG_SOC_IMX6UL) += \
imx6ul-14x14-ddr3-arm2.dtb \
imx6ul-14x14-ddr3-arm2-emmc.dtb \
....
dtb-$(CONFIG_SOC_IMX6ULL) += \
imx6ull-14x14-ddr3-arm2-emmc.dtb \
imx6ull-14x14-ddr3-arm2-flexcan2.dtb \
imx6ull-14x14-ddr3-arm2-gpmi-weim.dtb \
imx6ull-14x14-ddr3-arm2-wm8958.dtb \
imx6ull-14x14-evk.dtb \
imx6ull-14x14-evk-btwifi.dtb \
imx6ull-14x14-evk-emmc.dtb \
imx6ull-14x14-evk-gpmi-weim.dtb \
当选中 I.MX6ULL 这个 SOC 以后(CONFIG_SOC_IMX6ULL=y)
,所有使用到IMX6ULL 这个 SOC 的板子对应的.dts
文件都会被编译为.dtb
。如果使用了这一款SOC搓了一块板子,那么我们只需要新建一个该板子对应的.dts
,然后将对应的.dtb
文件名添加到dtb-$(CONFIG_SOC_IMX6ULL)
下,这样在使用make编译设备树的时候就会将对应的.dts
编译为二进制的.dtb
一般dtb文件会和根文件目录以及uboot一同烧录进启动存储设备中。(待补充)
如何编写DTS
虽然我们基本上不会从头到尾重写一个.dts 文件,大多时候是直接在 SOC 厂商提供的.dts文件上进行修改。但是我们肯定需要修改.dts文件,因此DTS 文件语法我们还是要学习一遍
.dtsi头文件
和 C 语言一样,设备树也支持头文件,设备树的头文件扩展名为.dtsi
。一般.dtsi 文件用于描述 SOC 的内部外设信息,比如 CPU 架构、主频、外设寄存器地址范围,比如 UART、IIC 等等。设备树是采用树形结构来描述板子上的设备信息的文件,每个设备都是一个节点,叫做设备节点,每个节点都通过一些属性信息来描述节点信息,属性就是键—值对。比如我们使用的正点原子imx6ull使用的设备树是imx6ull-alientek-emmc.dts
,而imx6ull-alientek-emmc.dts
里又include了imx6ull.dtsi
,以下是从imx6ull.dtsi
文件中缩减出来的设备树文件内容:
/ {
aliases {
can0 = &flexcan1;
};
cpus {
#address-cells = <1>;
#size-cells = <0>;
cpu0: cpu@0 {
compatible = "arm,cortex-a7";
device_type = "cpu";
reg = <0>;
};
};
intc: interrupt-controller@00a01000 {
compatible = "arm,cortex-a7-gic";
#interrupt-cells = <3>;
interrupt-controller;
reg = <0x00a01000 0x1000>,
<0x00a02000 0x100>;
};
}
第 1 行,“/”是根节点,每个设备树文件只有一个根节点。如果有多个dts或dtsi,那么这几个设备树文件的根节点会合并为一个根节点。aliases、cpus 和 intc 是三个子节点。
在设备树中节点命名格式为:label:node-name@unit-address
,其中“label”是节点标签,主要是方便访问,“node-name”是节点名字,为 ASCII 字符串,“unit-address”一般表示设备的地址或寄存器首地址,如果没有则可以省去,nodename和unit-addr共同构成了节点名字。intc: interrupt-controller@00a01000
就是这种命名格式,而label的存在使得使用&cpu0
就可以访问cpu@0这个节点
每个节点都有不同属性,不同的属性又有不同的内容,属性都是键值对,值可以为空或任意的字节流。设备树源码中有如下几种数据形式:
1.字符串
compatible = “arm,cortex-a7”;
2.32位无符号整数,可以是一组值也可以是单个值
reg = <0 0x123456 100>;
3.字符串列表,使用‘,’分隔字符串
compatible = “fsl,imx6ull-gpmi-nand”, “fsl, imx6ul-gpmi-nand”;
标准属性
节点是由一堆的属性组成,节点都是具体的设备,不同的设备需要的属性不同,用户可以自定义属性。除了用户自定义属性,有很多属性是标准属性,Linux 下的很多外设驱动都会使用这些标准属性
1. compatible 属性
compatible 属性也叫做“兼容性”属性,compatible 属性的值是一个字符串列表,这个属性的作用是告诉内核,当前的设备节点应该由哪个驱动来处理。内核会根据compatible属性中列出的字符串,去查找与之匹配的驱动程序。如果找到了匹配的驱动,内核就会尝试加载并初始化该驱动,以便操作相应的硬件设备,其格式如下:
compatible = “manufacturer,model”
其中 manufacturer 表示厂商,一般是芯片制造厂商,model 一般是模块对应的驱动名字,比如 imx6ull-alientek-emmc.dts 中 sound 节点是 I.MX6U-ALPHA 开发板的音频设备节点,I.MX6U-ALPHA 开发板上的音频芯片采用的欧胜出品的 WM8960,sound 节点的 compatible 属性值如下:
compatible = “fsl,imx6ul-evk-wm8960”,“fsl,imx-audio-wm8960”;
其中fsl表示厂商是飞思卡尔,“imx6ul-evk-wm8960”是驱动模块的名字,这个属性会遍历字符串列表中符合的驱动程序名称,然后再Linux内核中查找该驱动程序,如果第一个没有则第二个,第二个没有则第三个,直到找到位置。
在内核中,驱动程序通常会提供一个匹配表,这个表中列出了该驱动支持的所有compatible字符串。当内核解析设备树时,它会检查每个设备节点的compatible属性,并与驱动程序的匹配表进行对比,以确定是否应该由该驱动程序来处理该设备节点,比如在文件 imx-wm8960.c 中有如下内容
// 从632行开始
static const struct of_device_id imx_wm8960_dt_ids[] = {
{ .compatible = "fsl,imx-audio-wm8960", },
{ /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, imx_wm8960_dt_ids);
static struct platform_driver imx_wm8960_driver = {
.driver = {
.name = "imx-wm8960",
.pm = &snd_soc_pm_ops,
.of_match_table = imx_wm8960_dt_ids,
},
.probe = imx_wm8960_probe,
.remove = imx_wm8960_remove,
};
imx_wm8960_dt_idss 就是 imx-wm8960.c 这个驱动文件的匹配表,此匹配表只有一个匹配值“fsl,imx-audio-wm8960”,可以和imx6ull-alientek的dts中sound节点中的compatible 属性的字符串相匹配,那么这个节点就会使用此驱动文件。
2. model属性
model 属性值也是一个字符串,一般 model 属性描述设备模块信息,比如名字什么的,比
如:
model = “wm8960-audio”;
3.status 属性
status 属性看名字就知道是和设备状态有关的,status 属性值也是字符串,字符串是设备的状态信息,可选的状态如表:
值 | 描述 |
---|---|
“okay” | 表明设备是可操作的。 |
“disabled” | 表明设备当前是不可操作的,但是在未来可以变为可操作的,比如热插拔设备插入以后。至于 disabled 的具体含义还要看设备的绑定文档。 |
“fail” | 表明设备不可操作,设备检测到了一系列的错误,而且设备也不大可能变得可操作。 |
“fail-sss” | 含义和“fail”相同,后面的 sss 部分是检测到的错误内容 |
4.#address-cells 和#size-cells 属性
这两个属性的值都是无符号 32 位整形,#address-cells
和#size-cells
这两个属性可以用在任何拥有子节点的设备中,用于描述子节点的地址信息。#address-cells
属性值决定了子节点 reg 属性中地址信息所占用的字长(32 位),#size-cells
属性值决定了子节点 reg 属性中长度信息所占的字长(32 位)。#address-cells
和#size-cells
表明了子节点应该如何编写 reg 属性值,一般 reg 属性都是和地址有关的内容,格式如下:
reg = <address1 length1 address2 length2 address3 length3……>
每个“address length”组合表示一个子节点设备或其寄存器的地址范围,其中 address 是起始地址,length 是地址长度,addressx表示第x个设备的起始地址,lengthx表示第x个设备的长度,当#address-cells=<1>的时候表示reg中的每一个address的长度是1字长,同样,#size-cells=<1>
表示每个length的长度是1字长。
这部分看不懂没关系,后面会有详解
5.reg 属性
reg 属性的值一般是(address,length)对。reg 属性一般用于描述设备地址空间资源信息,一般都是某个外设的寄存器地址范围信息
6.ranges属性
ranges 是一个地址映射/转换表,ranges 属性每个项目由子地址、父地址和地址空间长度
这三部分组成,目的是将:
- child-bus-address:子总线地址空间的物理地址,由父节点的#address-cells 确定此物理地址所占用的字长。
- parent-bus-address:父总线地址空间的物理地址,同样由父节点的#address-cells 确定此物理地址所占用的字长。
- length:子地址空间的长度,由父节点的#size-cells 确定此地址长度所占用的字长
如果 ranges 属性值为空值,说明子地址空间和父地址空间完全相同,不需要进行地址转换。以ranges = <0x0 0xe0000000 0x00100000>;
为例,此属性值指定了一个 1024KB(0x00100000)的地址范围子,地址空间的物理起始地址为 0x0,父地址空间的物理起始地址为 0xe0000000,range会将子空间映射到父空间中,这样,我们在写程序的时候就直接以0x0为起点进行编程就好,地址映射会自动映射到父空间的对应位置
特殊属性
chosen属性
chosen 并不是一个真实的设备,chosen 节点主要是为了 uboot 向 Linux 内核传递数据,重点是 bootargs 参数。在chosen中会存储放着uboot中的bootargs数据,由uboot引导内核启动时传递进来。
aliases属性
单词 aliases 的意思是“别名”,因此 aliases 节点的主要功能就是定义别名,定义别名的目的就是为了方便访问节点。别名也可以用过在声明节点时候在@
之前声明
设备树的工作方式
在DTS文件中,每个节点都有 compatible 属性,但根节点 compatible 属性值得拿出来单独说一下,通过根节点的 compatible 属性可以知道我们所使用的设备,一般第一个值描述了所使用的硬件设备名字,第二个值描述了设备所使用的 SOC。我们一般会将设备树和uboot一起烧录到启动设备中,uboot在引导Linux内核启动时,会将设备树dtb文件的首地址传给Linux内核,Linux内核检查设备树的根节点compatible 就可以获知当前设备的信息
以IMX6ULL设备为例子,其设备树arch/arm/mach-imx/mach-imx6ul.c
的根节点信息如下:
/ {
model = "Freescale i.MX6 ULL 14x14 EVK Board";
compatible = "fsl,imx6ull-14x14-evk", "fsl,imx6ull";
Linux内核中存储着其支持的板子和芯片的信息,Linux内核都用MACHINE_START
和MACHINE_END
来定义一个machine_desc
结构体来描述这个设备,Linux内核会获取设备树文件dtb的根节点compatible 属性,然后遍历它支持的machine_desc
结构体,用于确认自己是否支持当前设备。该结构体位于arch/arm/include/asm/mach/arch.h
,源码如下
#define DT_MACHINE_START(_name, _namestr) \
static const struct machine_desc __mach_desc_##_name \
__used \
__attribute__((__section__(".arch.info.init"))) = { \
.nr = ~0, \
.name = _namestr,
#endif
可以看到,要实现该架构体需要两个参数:以IMX6ULL设备为例子,这个设备的machine_desc
结构体实现位于文件 arch/arm/mach-imx/mach-imx6ul.c
中
static const char *imx6ul_dt_compat[] __initconst = {
"fsl,imx6ul",
"fsl,imx6ull",
NULL,
};
DT_MACHINE_START(IMX6UL, "Freescale i.MX6 Ultralite (Device Tree)")
.map_io = imx6ul_map_io,
.init_irq = imx6ul_init_irq,
.init_machine = imx6ul_init_machine,
.init_late = imx6ul_init_late,
.dt_compat = imx6ul_dt_compat,
MACHINE_END
machine_desc
结构体中有个.dt_compat 成员变量,此成员变量保存着本设备兼容属性。只要某个设备(板子)根节点“/”的 compatible 属性值与imx6ul_dt_compat 表中的任何一个值相等,那么就表示 Linux 内核支持此设备。而mach-imx6ul.c
的根节点的compatible属性中的"fsl,imx6ull"
显然与之匹配,那么Linux就可以确认该设备树dtb是自己支持的,从而将设备树加载进来。
Linux内核设备树与machine_desc匹配详解
上一小节说了Linux Kernel通过将uboot提供的设备树的compatible属性和自己的machine_desc
一 一对比,从而确定内核是否支持当前设备树。那内核具体是怎么匹配的呢?
Linux 内核调用 start_kernel 函数来启动内核,start_kernel 函数会调用setup_arch
函数来匹配 machine_desc
,setup_arch
函数定义在文件 arch/arm/kernel/setup.c
中,源码如下:
// 913行开始
void __init setup_arch(char **cmdline_p)
{
const struct machine_desc *mdesc;
setup_processor();
mdesc = setup_machine_fdt(__atags_pointer);
if (!mdesc)
mdesc = setup_machine_tags(__atags_pointer, __machine_arch_type);
machine_desc = mdesc;
machine_name = mdesc->name;
dump_stack_set_arch_desc("%s", mdesc->name);
其中setup_machine_fdt
函数就是用于获取匹配的 machine_desc
的, 参数就是 atags 的首地址,也就是 uboot 传递给 Linux 内核的 dtb 文件首地址,setup_machine_fdt
函数的返回值就是找到的最匹配的 machine_desc
。
函数 setup_machine_fdt
定义在文件 arch/arm/kernel/devtree.c
中
// 204行开始
const struct machine_desc * __init setup_machine_fdt(unsigned int dt_phys)
{
const struct machine_desc *mdesc, *mdesc_best = NULL;
...
if (!dt_phys || !early_init_dt_verify(phys_to_virt(dt_phys)))
return NULL;
mdesc = of_flat_dt_match_machine(mdesc_best, arch_get_next_mach);
找到匹配的 machine_desc
的过程就是用设备树根节点的compatible 属性值和 Linux 内核中 machine_desc
下.dt_compat
的值比较,看看那个相等,如果相等的话就表示找到匹配的 machine_desc
,反之则获取下一个machine_desc
结构体。
从源码可知, 函数of_flat_dt_match_machine
就是用来对比设备树和machine_desc
结构体的函数,其包含两个参数:,参数 mdesc_best
是默认的 machine_desc, arch_get_next_mach
是一个函数,该函数的工作就是获取 Linux 内核中下一个 machine_desc 结构体。我们进入of_flat_dt_match_machine
这个函数再看看,源码如下:
dt_root = of_get_flat_dt_root();
while ((data = get_next_compat(&compat))) {
score = of_flat_dt_match(dt_root, compat);
if (score > 0 && score < best_score) {
best_data = data;
best_score = score;
}
}
首先是用of_get_flat_dt_root()
来获取设备树根节点,然后使用wile循环查找每一个machine_desc
,使用of_flat_dt_match
函数将根节点 compatible 属性的值和每个 machine_desc
结构体中.dt_compat
的值进行比较,直至找到匹配的那个machine_desc
。
如何向设备树中添加或修改内容
添加或者修改内容应该就是向.dtsi
文件中新增节点等等,比如如第一版硬件上有一个 IIC 接口的六轴芯片 MPU6050,第二版硬件又要把这个 MPU6050 更换为 MPU9250 等。我们就要同步的修改设备树文件。或者假设现在有个六轴芯片
fxls8471,fxls8471 要接到 I.MX6U-ALPHA 开发板的 I2C1 接口上,那么相当于需要在 i2c1 这个节点上添加一个 fxls8471 子节点。最简单的处理方法就是在imx6ull.dtsi
文件下的 i2c1 下直接添加一个名为 fxls8471 的子节点
i2c1: i2c@021a0000 {
#address-cells = <1>;
#size-cells = <0>;
compatible = "fsl,imx6ul-i2c", "fsl,imx21-i2c";
reg = <0x021a0000 0x4000>;
interrupts = <GIC_SPI 36 IRQ_TYPE_LEVEL_HIGH>;
clocks = <&clks IMX6UL_CLK_I2C1>;
status = "disabled";
//fxls8471 子节点
fxls8471@1e {
compatible = "fsl,fxls8471";
reg = <0x1e>;
};
};
但是这样会有个问题,其他所有使用到 I.MX6ULL这颗 SOC 的板子都会引用 imx6ull.dtsi 这个文件。直接在 i2c1 节点中添加 fxls8471 就相当于在其他的所有板子上都添加了 fxls8471 这个设备。所以我们应该在对应板子的dts文件中去改,而不是直接改这个设备树头文件。I.MX6U-ALPHA 开发板使用的设备树文件为imx6ull-alientek-emmc.dts
,因此我们需要在
imx6ull-alientek-emmc.dts
文件中完成数据追加的内容,方式如下:
&i2c1 {
clock-frequency = <100000>;
pinctrl-names = "default";
pinctrl-0 = <&pinctrl_i2c1>;
status = "okay";
mag3110@0e {
compatible = "fsl,mag3110";
reg = <0x0e>;
position = <2>;
};
fxls8471@1e {
compatible = "fsl,fxls8471";
reg = <0x1e>;
position = <0>;
interrupt-parent = <&gpio5>;
interrupts = <0 8>;
};
};
相较于源文件,我们改了:
- 添加新属性
clock_frequency
,用于指定i2c1时钟频率 - 将
status
属性从disable改为okay - 添加新的i2c1 子节点
fxls8471
,用于支持我们新加的设备
这个就是向节点追加或修改内容,重点就是通过&label 来访问节点,然后直接在里面编写要追加或者修改的内容。
Linux内核的设备树解析过程
Linux 内核在启动的时候会解析 DTB 文件,然后在/proc/device-tree
目录下生成相应的设备树节点文件。
Linux Kernel在 start_kernel 函数中完成了设备树节点解析的工作,最终实际工作的函数为 unflatten_dt_node。
设备树是用来描述板子上的设备信息的,不同的设备其信息不同,反映到设备树中就是属性不同。具体添加一个硬件的节点的时候需要什么属性会有对应的文档说明,具体路径为Linux 源码目录/Documentation/devicetree/bindings
设备树常用 OF 操作函数
设备树描述了设备的详细信息,这些信息包括数字类型的、字符串类型的、数组类型的,我们在编写驱动的时候需要获取到这些信息。比如设备树使用 reg 属性描述了某个外设的寄存器地址为 0X02005482,长度为 0X400,我们在编写驱动的时候需要获取到 reg 属性的X02005482 和 0X400 这两个值,然后初始化外设。Linux 内核给我们提供了一系列的函数来获
取设备树中的节点或者属性信息,这一系列的函数都有一个统一的前缀“of_”,所以在很多资料里面也被叫做 OF 函数。这些 OF 函数原型都定义在 include/linux/of.h
文件中
1.查找节点的 OF 函数
Linux 内核使用 device_node 结构体来描述一个节点,此结构体定义在文件 include/linux/of.h
中
struct device_node {
const char *name;
const char *type;
phandle phandle;
const char *full_name;
struct fwnode_handle fwnode;
struct property *properties;
struct property *deadprops; /* removed properties */
struct device_node *parent;
struct device_node *child;
struct device_node *sibling;
struct kobject kobj;
unsigned long _flags;
void *data;
#if defined(CONFIG_SPARC)
const char *path_component_name;
unsigned int unique_id;
struct of_irq_controller *irq_trans;
#endif
};
与查找节点有关的 OF 函数有 5 个:
// 通过函数名字查找节点,from用于表示从哪个设备节点开始查找
extern struct device_node *of_find_node_by_name(struct device_node *from,const char *name);
// 通过设备类型查找结点
extern struct device_node *of_find_node_by_type(struct device_node *from,const char *type);
// 根据设备类型以及兼容性comaptible查找节点
extern struct device_node *of_find_compatible_node(struct device_node *from,
const char *type,
const char *compat);
// 通过of_device_id匹配表查找指定节点
extern struct device_node *of_find_matching_node_and_match(
struct device_node *from,
const struct of_device_id *matches,
const struct of_device_id **match);
// 通过路径查找结点
static inline struct device_node *of_find_node_by_path(const char *path)
2.查找父/子节点的OF函数
// 用于获取指定节点的父节点
extern struct device_node *of_get_parent(const struct device_node *node);
extern struct device_node *of_get_next_parent(struct device_node *node);
// 使用迭代的方法查找子节点,从某个节点出发找遍历子节点
extern struct device_node *of_get_next_child(const struct device_node *node,
struct device_node *prev);
// 通过名字获取子节点
extern struct device_node *of_get_child_by_name(const struct device_node *node,
const char *name);
of_get_next_child
函数是Linux内核中用于遍历设备树(Device Tree)的一个API。它允许你迭代地访问一个特定设备节点下的所有子节点。这个函数在驱动开发中非常有用,特别是当你需要处理一个设备节点下的所有子设备时。例如,在一个总线驱动中,你可能会使用这个函数来枚举所有连接到该总线的设备。通过不断地调用of_get_next_child并传入前一个返回的子节点,你可以遍历一个设备节点下的所有子节点。
3.提取属性值的OF函数
节点的属性信息里面保存了驱动所需要的内容,因此对于属性值的提取非常重要,Linux 内核中使用结构体 property 表示属性,此结构体同样定义在文件 include/linux/of.h 中,源码如下:
struct property {
char *name; // 属性名称
int length; // 属性长度
void *value; // 属性值
struct property *next; // 下一个属性,内核中属性以链表的方式联系
unsigned long _flags;
unsigned int unique_id;
struct bin_attribute attr;
};
同样,获取属性值的of函数有:
// 查找指定属性
extern struct property *of_find_property(const struct device_node *np,
const char *name,
int *lenp);
// 获取属性中元素的数量,比如像reg等属性可能是一个数组
extern int of_property_count_elems_of_size(const struct device_node *np,
const char *propname, int elem_size);
// 从属性中获取指定标号的u32类型数据,比如某个属性中还有多个u32类型值,那么可以通过该函数获取指定下标的u32属性值
extern int of_property_read_u32_index(const struct device_node *np,
const char *propname,
u32 index, u32 *out_value);