设备树详解

设备树

1.设备树的介绍

设备树就是将设备信息按照树形的形式表示和使用。设备树是在3.x版本以后引入的,在其之前,ARM架构在arch/arm/plat-xxxarch/arm/mach-xxx文件夹下存放描述硬件信息的文件,所有的设备信息都被硬编码在内核源码中,这样就会有一些问题:

  • ARM的板级种类非常多,需要为每一个板级编写一份文件,随着ARM系列的逐渐发展,导致此类代码逐渐增多。然而这类代码对内核来说并没有太大意义,反而导致了内核臃肿;

  • 由于硬件信息硬编码进内核,如果需要修改硬件信息的话只能重新编译内核;

  • 其次,我觉得也是原有的组织架构并不是很好,尤其和设备树对比而言,我也只是感觉,并没法说明白,还有人说的原来经常会发生冲突使用条件编译解决的问题,我觉得也是这方面的问题。

后来,Linus发现了这个问题,ARM社区后来引入PowerPC中的设备树架构来解决这个问题。设备树使用自己的一套语法来描述设备硬件信息,将其和内核分离开,单独编译,只需要在内核启动的时候引导进去即可。这样,就解决了上面的问题,最主要的是它单独编译,避免了硬编码的方式,其次树形的结构对于设备的管理也非常有帮助。

2.设备树的组成

设备树由DTS,DTC,DTB组成,他们的关系如下图所示,程序员按照语法规定编写DTS文件描述硬件信息,通过DTC设备树文件的编译器编译,生成二进制文件DTB作为传递给内核的文件。

下面,我们简单介绍一下各个部分。

2.1DTS文件

DTS文件是用户编写的硬件信息描述文件,其遵循一定的语法。一般放在arch/arm/boot/dts目录下。对其具体的语法不做详细解释,主要描述一下大概特征。详细可参考文章【万字长文】Linux设备树详解,其讲的非常清楚。

  • DTS中是用一个又一个节点表示设备的,一个节点由一对大括号括住,前有标号(可选),节点名,节点起始地址(可选)等信息。每一个节点内可以有子节点,只有一个根节点,形成一个树形的结构。

    /{
        【标号:】[节点名]【@节点起始地址】{
            [属性名:属性值]
            ...
        }
        ...
    }
    • 根节点只有一个,用/表示

    • 可以在多处写一个节点名,其是一个节点,后面写的会合并到前面写的内容里

    • 标号是[节点名]【@节点起始地址】的别名,使用别名应用的格式为&[标号]

    • 设备的硬件信息可以用一对对属性对来描述

  • DTSI文件和DTS文件格式一样,只是一般关于SOC级别起名DTSI文件,用于板级设备树include进来。设备树支持include,可以include .dtsi文件和.h文件。

  • 关于属性,有一些属性是DTS规定的标准属性,有一些是自己定义的,等待自己的解析。有以下几个部分的属性需要关注:

    • 兼容性属性compatible,这个属性是用来做兼容性检查的。对于设备节点,用于在设备统一模型中和设备驱动匹配;在根节点中,用于和linux内核中支持的机器进行匹配,匹配成功才会启动内核。

    • 用于约束其他属性字段大小的属性#address-cells #size-cells

    • 资源表示的属性,比如地址资源reg

    • 表示中断的一组属性,如标识中断控制器,中断地址等的属性

  • 特殊节点,在根节点下有一些特殊节点

    • chosen节点,用于给内核传入参数,其bootargs和console会被linux内核所读取处理

    • aliases节点,用于其别名,内核会处理这个节点,在内核中建立一个查找表

2.2DTB文件

DTB文件通过DTC编译DTS而来,DTC就不多说了,知道了DTB文件的格式之后,就会明白DTC是具体做什么的了。DTB文件来自DTS文件,总体来说格式还是很相似的,主要是它需要是二进制格式的而且要易于程序的处理,我们一起来看一看吧。这部分可参考文章Linux驱动入门-设备树DTS,它的图解还是很棒的。

首先来看一下DTB文件的整体布局:

DTB文件被组织成struct ftd_header、memory reservation block、structure block、string block四部分,按照一定的对其方式对齐,每个部分都有其设计的必要性。对其每一部分格式的理解可以从两方面来进行:DTS编译成DTB文件格式后是什么样的(二进制排布),DTB文件格式是如何被linux内核解析出来的(内核结构体)。为了对内容的讲解更直观一些,下面给出的代码是linux内核解析出来的结构体,详细的内容在下面讲解linux解析中会提及。

  • struct ftd_header:文件头部,存储DTB文件的引导信息,包括文件的标识大小版本,其他块的大小偏移等信息。具体的头部信息可以从后来linux解析时的结构体可以看出来:

    "scripts/dtc/libfdt/fdt.h"
    ​
    struct fdt_header {
            uint32_t magic;                  /* magic word FDT_MAGIC    魔术,标识该地址开始是设备树,用于内核检验*/
            uint32_t totalsize;              /* total size of DT block  DTB文件总大小*/
            uint32_t off_dt_struct;          /* offset to structure     dt_struct部分在文件中的偏移*/
            uint32_t off_dt_strings;         /* offset to strings       dt_string部分在文件中的偏移*/
            uint32_t off_mem_rsvmap;         /* offset to memory reserve map    mem_rsvmap部分在文件中的偏移*/
            uint32_t version;                /* format version          版本信息,用于内核检查*/
            uint32_t last_comp_version;      /* last compatible version         上一个兼容版本*/
    ​
            /* version 2 fields below */
            uint32_t boot_cpuid_phys;        /* Which physical CPU id we're
                                                booting on */
            /* version 3 fields below */
            uint32_t size_dt_strings;        /* size of the strings block       dt_string块大小*/
    ​
            /* version 17 fields below */
            uint32_t size_dt_struct;         /* size of the structure block     dt_struct块大小*/
    };

  • memory reservation block:设备树支持用户预留指定的内存空间,根据用户文件DTS的设置,将预留内存信息放到该块中,linux内核启动的时候会解析该段并根据该段信息分配内存。

    "scripts/dtc/libfdt/fdt.h"
    ​
    struct fdt_reserve_entry {
            uint64_t address;   // 起始地址
            uint64_t size;      // 大小
    };

  • structure block:用于存储DTS文件编写的树形节点信息,其实和原样翻译成二进制基本一样,将{翻译成立特定的tag标号用于标识,节点中的属性也存储着,但是特殊的,其为了减少内存占用,将属性名存储在下一个块string block中,在该块中指定属性名string在块string block中的偏移(因为属性名重用很多)。

    "scripts/dtc/libfdt/fdt.h"
    ​
    struct fdt_node_header {    // 节点头
            uint32_t tag;   // {标识
            char name[0];   // 节点名字 (注意,定义为0长数组,即指向当前位置的指针)
    };
    ​
    struct fdt_property {       // 属性
            uint32_t tag;           // 属性标识
            uint32_t len;           // 属性值长度
            uint32_t nameoff;       // 属性名在string block中的偏移
            char data[0];           // 属性值
    };

  • string block:接上一个块,用于存储节点中的属性字符串,为了节省内存。这里没有什么特殊结构,每一个字符串以\0结尾,就像一个字符串池。

3.设备树到linux中去

编译成的DTB文件最终需要传入到内核中,由内核解析生成对应的内核结构,维护系统运行,具体步骤如下:

  • 1>内核接收uboot传入的DTB文件

  • 2>uboot和linux内核开始的时候使用DTB文件结构,均使用FDT(Flattened Device Tree扁平设备树)的相关结构体定义和操作实现,flatten指其不是树形结构,是平面结构;

  • 3> linux内核将FDT扁平结构抽象为以device_node为节点的树形结构,提供一组OF(Open Firmware开放固件)函数用于操作;

  • 4> linux启动时将符合条件的device_node设备节点加入到platform_device平台设备中。

下面我们对每一部分进行说明。(该部分可以参见文章系列Linux设备树详解(一) 基础知识,其分析代码很清楚)

3.1DTB从uboot->linux

在嵌入式开发中,常见使用uboot启动linux,并传入设备树.dtb文件给linux内核,具体是怎么进行参数传递的呢,我们一起来看看。

uboot传递设备树给linux

在uboot中bootz命令用于引导linux启动,传递设备树给linux内核,具体过程请参考文章uboot引导linux启动,这里只说明和设备树相关的问题:

  • 设备树chosen节点的bootargs属性:在使用uboot启动linux的时候,我们常常会设置bootargs环境变量来配置内核的控制台以及根文件系统等信息,它是怎么传递到内核的呢?在调用linux入口之前,uboot会在设备树的/chosen节点下创建一个bootargs属性,将bootargs信息写在里面,最终通过设备树传递进内核。

  • 设备树的地址:要想让linux使用设备树,那么必然是要把设备树地址传递给linux的。linux入口(zImage镜像开始地址处)提供了r0,r1,r2三个ABI,其中r2即为设备树地址。所以我们使用bootz命令传入fdt设备树地址,bootz命令的回调函数最终会将其作为第三个参数(对应r2)调用linux入口,即以参数的形式传递给linux内核。

上述这些都是uboot根据linux的行为即其留出的接口进行的操作,下面我们看看linux是怎么接收linux设备树的。

linux接收设备树

这部分主要是对上部分的一个补充,让我们明白uboot的行为其实依据于linux的接口。首先,通过vmlinux.lds链接脚本我们可以得到linux的起始位置在stext处,在stext中我们可以看到linux对于r0,r1,r2三个传参的支持:

 * This is normally called from the decompressor code.  The requirements
 * are: MMU = off, D-cache = off, I-cache = dont care, r0 = 0,
 * r1 = machine nr, r2 = atags or dtb pointer.

另外,关于chosen中bootargs的解析在后面解析设备树的时候有讲解,在这里不过多赘述。

3.2FDT

FDT对DTB文件的表示方法

DTB是一个二进制文件,内核没有办法直接应用,因此,内核使用FTB(Flattened Device Tree扁平设备树)来描述这个二进制文件,其包括一些结构体以及一系列的fdt操作函数(其实,在uboot中使用dtb文件也是用的ftb)。转换关系如下图所示:

上图是数据结构的表示,ftd用各种数据结构来表示tdb文件的各个部分,使得当将结构体对应到对应的tdb文件地址上的时候,我们从数据结构就可以正确的访问对应的dtb数据,这里我们是直接以dtb文件为数据来源,使用数据结构来更加清楚的访问

有了这些基础的数据结构支持后,再定义一些对这些数据进行访问和修改的接口函数,那么fdt设备树就完整了。这些函数实现的本质,其实都是要找到对应节点、属性的首地址,然后强转成对应的结构体就可以使用结构体去访问数据。那问题就在于怎么找到节点或者属性呢?我们先观察一下dtb二进制文件的格式,发现在节点或者属性开始的的地方,都有一个特定的标识符来标识(Tag),比如FDT_BEGIN_NODE=0X00000001表示节点的开始。这样我们依次遍历文件,就可以得到每一个节点和属性了。但是要访问指定的节点或者属性还是比较困难,因为是扁平的,总是需要挨个往下去寻找匹配,不过幸好linux在启动之后就立马将其转换为树形结构device_node了(在device_node树形结构中我们就可以使用路径来找到特定的节点),只是这个转换过程中必须得使用fdt来进行操作,并且我们进行的处理总是对于大量节点而不是某个特定的节点的。下面是内核常用到的一些fdt函数:

内核启动过程对设备树的处理之FDT阶段

linux内核接收到设备树后,开始的时候是使用FDT来对设备树进行相关操作的,下面是linux启动后对fdt进行操作的详细流程:

可以看到,在FDT阶段主要做了三个事情:

  • 调用setup_machine_fdt进行兼容性检查得到对应的machine_desc,并对所有节点进行一个扫描,对其chosen节点、root节点、memory节点进行早期的处理。

    • 关于兼容性,由于linux内核只有一份,但是它可运行在多个硬件机器之上,而每个硬件机器其硬件资源结构,初始化配置肯定都是不一样的,那linux又是怎么做到兼容的呢?linux内核有一个数据结构是struct machine_desc,该结构即对硬件机器的描述,包括标识一个特定机器,以及该机器上使用的相关函数,这样我们就可以把硬件机器相关的内容抽象到这个结构体中了。linux内核中,兼容性和可扩展性真的做的挺好的,如果我们新添加一个硬件机器,我们只需要使用特定的宏在自己的文件中定义即可,它会把将这个结构定义在特殊的段中,在兼容性匹配的时候直接就可以在段中访问到。一般,对于特定机器的定义都定义在mach-xxx.c文件中。所以,当我们进行linux的移植的时候,要做的其中一部,就在在内核中定义我们这个新的硬件机器,即创建一个mach文件,在里面定义struct 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

    • 对于节点的扫描中,对于chosen节点主要是获取其中的bootargs属性到boot_command_line,后面又会赋值给其他变量供内核操作;对root节点要找到#size-cells,#address-cells;对于memory节点的访问,如果有memory并且符合一定要求的话,内核就会对所有节点的reg属性中的内存空间进行保留。

  • 调用early_init_fdt_scan_reserved_mem对设备树中的各种内存资源进行保留分配处理,主要保存设备树自身地址、dtb文件的保留地址区域中记录的内存空间、节点reg属性中的内存空间。其中,对于reg中的内存还被记录在了struct reserved_mem结构体类型的reserved_mem全局数组中。

  • 调用unflatten_device_tree进行FDT到device_node树型设备树结构的建立。这一部分我们在下一节讲述。

3.3内核启动过程对设备树的处理之FDT->device_node

在上面我们提到了,用FDT来操作设备树的特点是,其数据来源是直接使用的dtb文件,具有扁平的特点,对于节点和属性的访问是按照dtb文件从头往后遍历的,对于访问特定节点来说非常的不利。然而我们在对设备树的应用上,最常使用的就是访问设备树的特定节点。所以在linux内核中,我们看到linux在启动时就已将fdt设备树转化为device_node表示的树形结构了。device_node的特点相对于fdt,第一个就是其表示设备树的数据结构是不一样的,它不再局限的依附于dtb文件的格式,而使用device_node结构体以用户更容易接收的方式表示节点以及属性,其数据源不是使用的dtb文件了,是新开的空间,以device_node结构体创建。第二个就是数据结构的组织形式采用了树形的结构,使得特定节点或属性的搜索更加的快速,更能适用于linux启动后的用户应用。当然,它也定义了一套基于device_node树型结构的OF操作合集,用于我们访问设备树。

device_node结构体

从这个结构体中我们就可以看出其数据的表示以及组织形式,节点使用device_node来表示,节点的属性用其中的struct propert结构链表来表示,并且使用partent、child、sibling指针来维护树形结构。

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
};
FDT->device_node

了解了device_node数据结构的形式后,我们来看一下内核是怎么从FDT转化成这个结构的,详细的转换过程如下图所示:

如图所示,内核中调用unflatten_device_tree函数来进行去扁平化。主要由两部分组成:

  • __unflatten_device_tree(initial_boot_params, &of_root,early_init_dt_alloc_memory_arch);函数,是去扁平化的实现,这个函数算是整个代码中理解比较困难的了,但是理清楚了就还可以。

    • 该函数是使用递归实现的,传入根节点地址,处理完当前节点之后会向下递归,并且会等孩子节点都遍历完之后才从本节点向上退出。

    • 对于每个节点的处理,首先会对节点本身进行处理,完成kobject的创建、节点名以及树型结构的创建;然后再对节点中的属性进行遍历,将他们添加到device_node中的属性链表中;最后,可以利用of函数访问特定属性,对节点中的一些成员比如name,type进行赋值。

    • 另外,需要非常注意的一点是,device_node的内存分配。虽然device_node创建的是树型结构,但是它并不像我们平时写小程序一样,每次malloc一个结构体,因为这样的话设备树的内存就是分散的。linux内核中,device_node结构的设备树内存是集中的一大块。它是怎么做的呢?首先,它需要知道这些一共需要多大的空间,然后一次性分配,之后在从分配的空间中建立树型结构。我们可以看到,linux对__unflatten_device_tree函数调用了两次,第一次传入起始内存mem为0,并设置dryrun为true。dryrun为true的话该函数就不会进行设备树创建的相关操作,但是对于每个结构的所需内存偏移都是累加到mem的,这样在递归退出的时候,mem就是所用内存的大小。这里注意,其一个节点中有一些指针项,也是需要在该块中连续的分配内存的,紧挨着device_node结构体分配,形式就是:device_node,节点名大小,属性占用空间..(下一个节点)。有了占用内存大小之后,一次性分配。然后第二次调用__unflatten_device_tree函数的时候内存已经分配好了,传入首地址,直接按照偏移依次取用就好了。

  • 调用of_alias_scan函数对建立好的设备树的alias和chosen节点进行了一个处理。

    • 对于chosen,按照其stdout-path值作为路径查找stdout对应设备的device_node节点,赋值给of_stdout全局变量。

    • alias中的属性记录了节点的别名,形式为别名=节点名,这里对于alias中的每一个属性,建立struct alias_prop结构,将别名绑定到其对应的设备节点上,最后添加到全局链表aliases_lookup

现在,内核里的设备树已经变成device_node的树型结构了,里面包含了设备树所描述的各种设备信息。在我们需要的时候,我们可以通过OF函数来对特定设备节点进行访问,了解其设备信息。另外,内核在启动的时候就需要加载一些platform设备,这个就是下一节所介绍的了。

3.4内核启动过程对设备树的处理之device_node->platform_device

到这里,我们的设备树已经以树型结构维护在内核里面了,并且我们可以使用OF函数来查看各种硬件设备的信息,为我们所用。内核在启动时,也需要装载一些设备投入使用,基本都是platform_device。所以,这一步就是内核将设备树上的某些设备节点添加为platform_device。在这一部分,我不准备详细的追踪代码了,因为这部分和设备统一模型密切相关,深究的话就偏离了我们的主体。主要从入口、设备统一模型、实现三个方面来简单讲述。

入口

内核在of_platform_populate函数中完成对应设备节点的实例化,添加到platform_device设备。但是,这个函数的调用很特别,它是基于initcall机制被调用的。关于initcall机制,请参考linux的initcall机制 - 牧野星辰 - 博客园 (cnblogs.com)文章。

initcall大来说,就是系统启动后会启动init进程来做一些事情,即调用一些函数。这些函数来自于特定的段,我们可以通过内核提供的函数接口来定义我们需要被调用的函数到特定的段中,这样就会被调用。

  • 如下所示是do_initcalls在内核启动后的调用过程,do_initcalls中就会创建init进程去调用各个函数。

    start_kernel
    -> rest_init(); // 剩余初始化
            -> kernel_thread(kernel_init, NULL, CLONE_FS);
                -> kernel_init()
                    -> kernel_init_freeable();
                        -> do_basic_setup();
                            -> do_initcalls();  // 会在段中依次调用各个函数

  • 内核中使用下面的命令将customize_machine函数添加到其某个特定段中,即会在do_initcalls里面被调用。

    arch_initcall(customize_machine);

  • 在customize_machine函数中调用了machine_desc->init_machine()函数,用于执行特定板子的初始化函数。在前面我们有讲过,machine_desc是内核匹配到的硬件机器类型,其一般由特定板子的mach文件中定义。

    static int __init customize_machine(void)
    {
            of_iommu_init();
            if (machine_desc->init_machine)
                    machine_desc->init_machine();       // 这里
    #ifdef CONFIG_OF
            else
                    of_platform_populate(NULL, of_default_bus_match_table,
                                            NULL, NULL);
    #endif
            return 0;
    }

  • 在对应的mach文件(例如mach-imx6ul.c)中,可见 .init_machine = imx6ul_init_machine,所以调用的是imx6ul_init_machine函数,表示要执行我这个板子上对应的初始化。

    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

  • 在imx6ul_init_machine函数中,执行了 of_platform_populate(NULL, of_default_bus_match_table, NULL, NULL);函数,该函数填充platform,即实现device_node到platform_device的转化。

设备统一模型

实例化设备节点,其实就是讲设备节点维护在设备统一模型之中,所以我们需要讲解一下设备统一模型的大概机制。

设备统一模型,即总线,设备,驱动三个部分。总线上可以挂接很多设备,挂接很多驱动。每当添加一个设备或者驱动的到总线上的时候,总线就会调用mach函数查看它有没有和它上面的驱动或者总线所匹配的,有匹配的就会执行驱动中的probe函数,正式为这个设备分配系统资源,投入运行。

我们现在所持有的是设备,需要将一些设备添加到platform平台总线上,这样如果系统中出现其对应得到驱动就会probe。

实现

首先,内核并不是对所有设备节点都实例化,只实例化以下节点:

  • 根节点的子节点

  • 如果节点被实例化,并且其compatible属性能和of_default_bus_match_table表中的某个字段匹配,则递归实例化其子节点。

    const struct of_device_id of_default_bus_match_table[] = {
            { .compatible = "simple-bus", },
            { .compatible = "simple-mfd", },
    #ifdef CONFIG_ARM_AMBA
            { .compatible = "arm,amba-bus", },
    #endif /* CONFIG_ARM_AMBA */
            {} /* Empty terminated list */
    };  

然后,对于每个要被实例化的节点,都会做以下事进行实例化:

  • of_device_alloc:分配并初始化platform_device结构,重点是对其中的reg和irq资源进行了分配,连接到plarform_device结构体中

  • of_device_add:将设备添加到设备统一模型中,即platform平台总线中,会触发match。

至此,linux启动关于设备的所有处理就结束了。

  • 26
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值