Linux驱动---设备驱动模型

一、简介

在上一篇文章《字符设备》中,我们学习了驱动开发的基础框架。掌握了这个框架后,我们只需要添加对应的代码就可以开发驱动。不过,这种方式存在一个问题:如果我们将硬件的信息都写进驱动代码中,那么每次硬件的引脚接口发生变化,驱动代码就需要重新修改,这显然是不合理的。

那么,有没有合适的解决方案呢?答案是肯定的,Linux引入了设备驱动模型分层的概念, 将我们编写的驱动代码分成了两块:设备与驱动。

  • 设备负责提供硬件资源;
  • 驱动代码负责去使用这些设备提供的硬件资;
  • 总线将它们联系起来;

这样子就构成以下图形中的关系。

针对Linux下的这种分层理念,我们在学习驱动模型之前要先了解以下几个概念:

  • 设备(device):挂载在某个总线上的物理设备;
  • 驱动(driver):与特定设备相关的软件,负责初始化该设备以及提供一些操作该设备的操作方式;
  • 总线(bus):负责管理挂载对应总线的设备以及驱动;
  • 类(class):对于具有相同功能的设备,归结到一种类别,进行分类管理;

二、驱动模型

2.1、总线

通过上面的图我们也可以看出来,在Linux的设备驱动模型中,总线是整个模型的核心,负责将设备和驱动联系起来。

驱动模型中的总线可以是真实存在的物理总线(USB总线、I2C总线等),也可以是为了驱动模型架构设计出的虚拟总线(Platform总线)。为此Linux设备驱动模型都将围绕“总线-设备-驱动”来展开,因为符合Linux设备驱动模型的设备与驱动都是必须挂载在一个总线上的。


每条总线管理着两条链表---设备链表和驱动链表。设备链表用于管理该总线上所有的设备实例,驱动链表用于管理该总线上所有已注册的驱动程序。


当我们向系统的某条总线上注册一个驱动时,它会向Linux内核里该总线的驱动链表中插入我们的新驱动;同样,当我们向系统总线上接入一个新设备时,也会向该总线的设备链表中插入我们的新设备。在插入的同时该总线的核心层会执行bus_type结构体中的match()方法对新插入的设备/驱动进行匹配,如USB总线通过PID(产品ID)和VID(厂商ID)来匹配,而Platform总线则通过compatible字符串进行匹配。

这样,在Linux内核中无论是设备先出现还是驱动先出现,总能彼此找到对方。当Linux内核的总线核心层在匹配成功后,将会调用device_driver结构体中probe函数,并且在移出设备或驱动时,会调用device_driver结构体中remove函数,分别完成驱动的注册和移除工作。


最后,我们再来看一下总线在内核中的表现方式---bus_type结构体(linux/device.h)

struct bus_type {
     const char              *name;
     const struct attribute_group **bus_groups; // 为bus目录创建属性
     const struct attribute_group **dev_groups; // 为device目录创建属性
     const struct attribute_group **drv_groups; // 为driver目录创建属性int (*match)(struct device *dev, struct device_driver *drv); // 匹配函数
     int (*uevent)(struct device *dev, struct kobj_uevent_env *env);
     int (*probe)(struct device *dev);
     int (*remove)(struct device *dev);
 ​
     int (*suspend)(struct device *dev, pm_message_t state);
     int (*resume)(struct device *dev);
 ​
     const struct dev_pm_ops *pm;
 ​
     struct subsys_private *p; // 私有数据
};
  • name :指定总线的名称,当新注册一种总线类型时,会在/sys/bus目录创建一个新的目录,目录名就是该参数的值;
  • drv_groups、dev_groups、bus_groups :分别表示驱动、设备以及总线的属性;
  • match :当向总线注册一个新的设备或者是新的驱动时,会调用该回调函数。该回调函数主要负责判断是否有注册了的驱动适合新的设备,或者新的驱动能否驱动总线上已注册但没有驱动匹配的设备;
  • uevent :总线上的设备发生添加、移除或者其它动作时,就会调用该函数,来通知驱动做出相应的对策;
  • probe :当总线将设备以及驱动相匹配之后,执行该回调函数,最终会调用驱动提供的probe函数;
  • remove :当设备从总线移除时,调用该回调函数;
  • p :该结构体用于存放特定的私有数据,其成员klist_devices和klist_drivers记录了挂载在该总线的设备和驱动;

在实际编写linux驱动模块时,Linux内核已经为我们写好了大部分总线驱动,正常情况下我们一般不会去注册一个新的总线, 内核中提供了bus_register函数来注册总线,以及bus_unregister函数来注销总线,其函数原型如下:

int bus_register(struct bus_type *bus);  //成功返回0,失败返回负数
void bus_unregister(struct bus_type *bus);


当我们成功注册总线时,会在/sys/bus/目录下创建一个新目录,目录名为我们新注册的总线名。bus目录中包含了当前系统中已经注册了的所有总线,例如i2c,spi,platform等。我们看到每个总线目录都拥有两个子目录devices和drivers, 分别记录着挂载在该总线的所有设备以及驱动。

2.2、设备

在Linux中,一切都是以文件的形式存在, 设备也不例外。/sys/devices目录记录了系统中所有设备,实际上在sys目录下所有设备文件最终都会指向该目录对应的设备文件。

然后我们再来看一下设备在内核中的表现方式---device结构体(linux/device.h)

struct device {
     const char *init_name;
     struct device           *parent;
     struct kobject kobj;
     struct bus_type *bus;
     struct device_driver *driver;
     void            *platform_data;
     void            *driver_data;
     struct device_node      *of_node;
     dev_t                   devt;
     struct class            *class;
     void (*release)(struct device *dev);
     const struct attribute_group **groups;  /* optional groups */
     struct device_private   *p;
};
  • init_name:指定该设备的名称,总线匹配时,一般会根据比较名字来进行配对;
  • parent:表示该设备的父对象;
  • bus:表示该设备依赖于哪个总线,当我们注册设备时,内核便会将该设备注册到对应的总线;
  • of_node :存放设备树中匹配的设备节点。当内核使能设备树,总线负责将驱动的of_match_table以及设备树的compatible属性进行比较之后,将匹配的节点保存到该变量;
  • driver_data:驱动层可通过dev_set/get_drvdata函数来获取该成员;
  • class :指向了该设备对应类,我们可以在/sys/class目录下对应的类找到该设备,如input、leds、pwm等目录;
  • release:回调函数,当设备被注销时,会调用该函数。如果我们没定义该函数时,移除设备时,会提示“Device ‘xxxx’ does not have a release() function, it is broken and must be fixed”的错误;
  • group:指向struct attribute_group类型的指针,指定该设备的属性;

内核也提供相关的API来注册和注销设备,如下所示:

int device_register(struct device *dev);  //成功返回0,失败返回负数
void device_unregister(struct device *dev);

2.3、驱动

设备能否正常工作,取决于驱动。驱动需要告诉内核, 自己可以驱动哪些设备,如何初始化设备。接下来,我们再来看一下驱动在内核中的表示方式---device_driver结构体(linux/device.h)

struct device_driver {
        const char              *name;
        struct bus_type         *bus;

        struct module           *owner;
        const char              *mod_name;      /* used for built-in modules */

        bool suppress_bind_attrs;       /* disables bind/unbind via sysfs */

        const struct of_device_id       *of_match_table;
        const struct acpi_device_id     *acpi_match_table;

        int (*probe) (struct device *dev);
        int (*remove) (struct device *dev);

        const struct attribute_group **groups;
        struct driver_private *p;

};
  • name :指定驱动名称,总线进行匹配时,利用该成员与设备名进行比较;
  • bus :表示该驱动依赖于哪个总线,内核需要保证在驱动执行之前,对应的总线能够正常工作;
  • owner :表示该驱动的拥有者,一般设置为THIS_MODULE;
  • suppress_bind_attrs:布尔量,用于指定是否通过sysfs导出bind与unbind文件,bind与unbind文件是驱动用于绑定/解绑关联的设备;
  • of_match_table :指定该驱动支持的设备类型。当内核使能设备树时,会利用该成员与设备树中的compatible属性进行比较;
  • remove :当设备从操作系统中拔出或者是系统重启时,会调用该回调函数;
  • probe :当驱动以及设备匹配后,会执行该回调函数,对设备进行初始化。通常的代码,都是以main函数开始执行的,但是在内核的驱动代码,都是从probe函数开始的;
  • group :指向struct attribute_group类型的指针,指定该驱动的属性;

内核提供了函数来注册/注销驱动,成功注册的驱动会记录在/sys/bus//drivers目录, 函数原型如下所示:

int driver_register(struct device_driver *drv);  //成功返回0,失败返回负数
void driver_unregister(struct device_driver *drv);

三、设备树

3.1、设备树简介

上面我们一直在强调的就是设备驱动分层,所以为了将设备和驱动剥离开来,社区引入了设备树(Device Tree)这种数据结构。它用来描述硬件设备的信息,特别是硬件设备与操作系统如何交互的方式。设备树的作用有:

  • 描述硬件布局:设备树可以描述CPU、内存、外设、GPIO等硬件资源的位置和属性;
  • 硬件与驱动分离:设备树将硬件信息从内核代码中分离出去,使得内核能够更加通用,不依赖特定硬件配置;
  • 动态配置:设备树支持热插拔设备和动态硬件配置,内核可以在启动时解析设备树来获取硬件信息;

设备树通常由设备树源文件(.dts文件)和编译后的设备树二进制文件(.dtb文件)组成,它在描述硬件资源时有两个特点:

  • 树的主干就是系统总线,在设备树里面称为“根节点”。IIC控制器、GPIO控制器、SPI控制器等都是接到系统主线上的分支,在设备树里称为“根节点的子节点”;
  • 设备树可以像头文件(.h文件)那样,一个设备树文件引用另一个设备树文件,这样可以实现“代码”的重用。例如多个硬件平台都使用I.MX6ULL作为主控芯片,那么我们可以将I.MX6ULL芯片的硬件资源写到一个单独的设备树文件里面(一般使用.dtsi后缀),其他设备树文件直接使用#include xxx引用即可;

3.2、设备树格式

我们已经了解了设备树是什么,接下来,我们再来一起看一看设备树长什么样。在Linux内核源码中,设备树文件存放在arch/arm/boot/dts路径中,以我使用的NXP IMX6ULL开发板为例,查看对应的igbboard-imx6ull.dts文件,内容如下:

点击查看代码
#include "imx6ull.dtsi"     /*头文件*/

/*设备树根节点*/
/ {
        model = "LingYun IoT System Studio IoT Gateway Kits Board Based on i.MX6ULL"; /*model属性,用于指定设备的制造商和型号*/
        compatible = "lingyun,igkboard-imx6ull", "fsl,imx6ull"; /*compatible属性,系统用来决定绑定到设备驱动的关键,用来查找节点的方法之一*/

        /*根节点的子节点*/
        chosen {
                stdout-path = &uart1;
        };

        /*根节点的子节点*/
        memory@80000000 {
                device_type = "memory";
                reg = <0x80000000 0x20000000>;
        };

        /*根节点的子节点*/
        reserved-memory {
                #address-cells = <1>;
                #size-cells = <1>;
                ranges;
                 linux,cma {
                        compatible = "shared-dma-pool";
                        reusable;
                        size = <0xa000000>;
                        linux,cma-default;
                };
        };

        /*根节点的子节点*/
        leds {
                compatible = "gpio-leds";
                pinctrl-names = "default";
                pinctrl-0 = <&pinctrl_gpio_leds>;
                status = "okay";

                sysled {
                        lable = "sysled";
                        gpios = <&gpio4 16 GPIO_ACTIVE_HIGH>;
                        linux,default-trigger = "heartbeat";
                        default-state = "off";
                };
           };
      /*-------------以下内容省略-------------*/

};

/*设备树节点追加内容*/
/*+--------------+
  | Misc Modules |
  +--------------+*/
/*而是向原有节点追加内容*/
&uart1 {
        pinctrl-names = "default";
        pinctrl-0 = <&pinctrl_uart1>;
        status = "okay";
};

&pwm1 { /* backlight */
        #pwm-cells = <2>;
        pinctrl-names = "default";
        pinctrl-0 = <&pinctrl_pwm1>;
        status = "okay";
};
... ...

总体上,设备树的源码分为三部分:

  • 头文件。设备树是可以像C语言那样使用“#include”引用“.h”后缀的头文件,也可以引用设备树“.dtsi”后缀的头文件。
#include "imx6ull.dtsi"     /*头文件*/
  • 设备树节点。设备树给我们最直观的感受是它由一些嵌套的大括号“{}”组成, 每一个“{}”都是一个“节点”。“/ {…};”表示“根节点”,每一个设备树只有一个根节点。在根节点内部的chosen{...}、memory{...}等字符,都是根节点的子节点。如果我们想要添加一个自定义的节点(如leds),需要添加到根节点里。
/*设备树根节点*/
/ {
        model = "LingYun IoT System Studio IoT Gateway Kits Board Based on i.MX6ULL"; /*model属性,用于指定设备的制造商和型号*/
        compatible = "lingyun,igkboard-imx6ull", "fsl,imx6ull"; /*compatible属性,系统用来决定绑定到设备驱动的关键,用来查找节点的方法之一*/

        /*根节点的子节点*/
        chosen {
                stdout-path = &uart1;
        };

        /*根节点的子节点*/
        memory@80000000 {
                device_type = "memory";
                reg = <0x80000000 0x20000000>;
        };

        /*根节点的子节点*/
        reserved-memory {
                #address-cells = <1>;
                #size-cells = <1>;
                ranges;
                 linux,cma {
                        compatible = "shared-dma-pool";
                        reusable;
                        size = <0xa000000>;
                        linux,cma-default;
                };
        };

        /*根节点的子节点*/
        leds {
                compatible = "gpio-leds";
                pinctrl-names = "default";
                pinctrl-0 = <&pinctrl_gpio_leds>;
                status = "okay";

                sysled {
                        lable = "sysled";
                        gpios = <&gpio4 16 GPIO_ACTIVE_HIGH>;
                        linux,default-trigger = "heartbeat";
                        default-state = "off";
                };
           };
      /*-------------以下内容省略-------------*/

};
  • 设备树节点追加内容。在官方的设备树头文件中,已经定义了绝大部分的设备节点,如uart、i2c、spi、pwm控制器等。很多情况,我们需要在这些节点里添加、删除或者修改一些内容,此时我们可以使用&来引用前面已经定义好的节点。
&pwm1 { /* backlight */
        #pwm-cells = <2>;
        pinctrl-names = "default";
        pinctrl-0 = <&pinctrl_pwm1>;
        status = "okay";
};

其中,pwm1的定义如下:

pwm1: pwm@2080000 { /*节点标签:节点名称@单元地址*/
                compatible = "fsl,imx6ul-pwm", "fsl,imx27-pwm"; /*model属性用于指定设备的制造商和型号*/
                reg = <0x02080000 0x4000>;  /*reg属性描述设备资源在其父总线定义的地址空间内的地址*/
                interrupts = <GIC_SPI 83 IRQ_TYPE_LEVEL_HIGH>;  /*描述中断相关的信息*/
                clocks = <&clks IMX6UL_CLK_PWM1>,   /*初始化GPIO外设时钟信息*/
                     <&clks IMX6UL_CLK_PWM1>;
                clock-names = "ipg", "per";
                #pwm-cells = <3>;       /*表示有多少个cells来描述pwm引脚*/
                status = "disabled";    /*状态属性用于指示设备的“操作状态”*/
            };

3.3、节点格式

我们知道设备树由一个根节点和众多子节点组成,子节点也可以继续包含其他节点,也就是子节点的子节点。设备树的组成很简单,下面我们来看一看节点的基本格式吧。

node-name@unit-address{
    属性1 = …
    属性2 = …
    属性3 = …
    子节点…
}
  • node-name:节点名称,用于指定节点的名称,如上面的pwm。它的长度为1至31个字符,只能由“数字、大小字母、英文逗号句号、下划线和加减号”组成,节点名应当使用大写或小写字母开头并且能够描述设备类别。
  • @unit-address:其中的符号@可以理解为是一个分割符,“unit-address”用于指定“单元地址”,它的值要和节点“reg”属性的第一个地址一致,如上面的@2080000。如果节点没有“reg”属性值,可以直接忽略“@unit-address”。 同级别的子节点的节点名可以相同,但是要求“单元地址”不同,node-name@unit-address 的整体要求同级唯一。
  • 节点标签:节点名的简写,当其他位置需要引用时可以使用节点标签来向该结点中追加内容,如上面节点名pwm前面多了个pwm1,这个pwm1就是我们所说的节点标签。
  • 节点路径:通过指定从根节点到所需节点的完整路径,可以唯一标识设备树中的节点。不同层次的设备树节点名字可以相同,同层次的设备树节点要唯一。
  • 节点属性:节点的“{}”中包含的内容是节点属性,通常情况下一个节点包含多个属性信息, 这些属性信息就是要传递到内核的“板级硬件描述信息”,驱动中会通过一些API函数获取这些信息。

3.4、节点属性

设备树最主要的内容是编写节点的节点属性,通常情况下一个节点代表一个设备,下面我整理了一些节点的常用属性:

(1)compatible属性
compatible属性值由一个或多个字符串组成,有多个字符串时使用“,”分隔开。设备树中的每一个设备的节点都要有一个compatible属性,它用来指定该设备所使用的驱动。如 leds 节点中的 compatible = "gpio-leds"; 就指定了该设备使用 Linux 内核源码中自带的通用 Led 驱动。在该驱动文件中也会声明其 compatible = "gpio-leds"; ,通过这个 compatbile 标识符,Linux 内核的 platform 总线就可以帮设备找到驱动,驱动找到设备了;

(2)model属性
用于指定设备的制造商和型号,推荐使用“制造商, 型号”的格式,当然也可以自定义;

(3)status属性
状态属性用于指示设备的“操作状态”,通过status可以去禁止设备或者启用设备,比如okay是使能设备、disabled是禁用设备;

(4)#address-cells 和 #size-cells
#address-cells,用于指定子节点reg属性“地址字段”所占的长度(单元格cells的个数)。 #size-cells,用于指定子节点reg属性“大小字段”所占的长度(单元格cells的个数)。例如#address-cells=2,#size-cells=1,则reg内的数据含义为reg = <address address size address address size>,因为每个cells是一个32位宽的数字,例如需要表示一个64位宽的地址时,就要使用两个address单元来表示;

(5)reg属性
reg属性描述设备资源在其父总线定义的地址空间内的地址。通常情况下用于表示一块寄存器的起始地址(偏移地址)和长度, 在特定情况下也有不同的含义。例如#address-cells = <1>,#size-cells = <1>,reg = <0x9000000 x4000>, 其中0x9000000表示的是地址,0x4000表示的是地址长度,这里的reg属性指定了起始地址为0x9000000,长度为0x4000的一块地址空间;

(6)ranges属性
该属性提供了子节点地址空间和父地址空间的映射(转换)方法,常见格式是 <子地址、父地址、地址长度>。如果父地址空间和子地址空间相同则无需转换。比如对于#address-cells和#size-cells都为1的话,以ranges=<0x0 0x10 0x20>为例,表示将子地址的从0x0~(0x0 + 0x20)的地址空间映射到父地址的0x10~(0x10 + 0x20);

四、设备树API函数

在Linux内核采用设备树之后,驱动程序需要获取设备树的属性。Linux内核为驱动提供了一系列API函数,用于获取设备树的属性值。这些函数都是以“_of_”开头,我们称为OF操作函数,接下来整理一些常见的OF操作函数。

4.1获取设备节点

在内核中,设备以节点的形式附加到设备树上,因此要获得设备信息,必须先获取设备节点。Linux内核使用device_node结构体来描述一个设备节点,此结构体定义在文件 include/linux/of.h 中,代码如下:

点击查看代码
struct device_node {
    const char *name;  /*节点的名字*/
    phandle phandle;
    const char *full_name;  /*节点的全名,node-name[@unit-address]*/
    struct fwnode_handle fwnode;

    struct    property *properties;  /*节点的属性*/
    struct    property *deadprops;    /* removed properties */
    struct    device_node *parent;   /*父节点*/
    struct    device_node *child;    /*子节点*/
    struct    device_node *sibling;  /*节点的兄弟,即同级节点*/
#if defined(CONFIG_OF_KOBJ)
    struct    kobject kobj;
#endif
    unsigned long _flags;
    void    *data;
#if defined(CONFIG_SPARC)
    unsigned int unique_id;
    struct of_irq_controller *irq_trans;
#endif
};


上述数据结构是设备节点结构。让我们来看一下获取设备节点的几个常见函数。
(1)通过设备节点的名字获取设备节点

struct device_node *of_find_node_by_name(struct device_node *from, const char *name);
//from:指定要搜索设备节点的起始位置。若为NULL,则从根节点开始搜索;
//name:要查找的设备节点的名称;
//成功返回设备节点结构,失败时返回NULL;


(2)通过设备节点类型获取设备节点

struct device_node *of_find_node_by_type(struct device_node *from, const char *type);
//from:指定要搜索设备节点的起始位置。若为NULL,则从根节点开始搜索;
//type:要查找的设备节点的类型,device_type属性值;
//成功返回设备节点结构,失败返回NULL;


(3)通过节点的compatible属性和type获取设备节点

struct device_node *of_find_compatible_node(struct device_node *from, const char *type, const char *compat);
//from参数:指定要搜索设备节点的起始位置。若为NULL,则从根节点开始搜索;
//type参数:要查找的设备节点类型。如果为NULL,则忽略类型限制;
//compatible参数:要查找的设备节点的compatible属性名称;
//成功返回设备节点结构,失败时返回NULL;


(4)通过设备节点路径名获取设备节点

static inline struct device_node *of_find_node_by_path(const char *path)
{
        return of_find_node_opts_by_path(path, NULL);
}
//path参数:设备节点的路径名;
//成功返回设备节点结构,失败时返回NULL;


(5)通过 of_device_id 匹配表来查找指定的节点

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)
//from: 指定要搜索设备节点的起始位置。若为NULL,则从根节点开始搜索;
//matches: of_device_id 匹配表,也就是在此匹配表里面查找节点;
//match: 找到的匹配的 of_device_id;
//成功返回找到的节点,失败时返回NULL;

4.2、获取父子设备节点API

(1)用于获取某一节点的父节点

struct device_node *of_get_parent(const struct device_node *node);
//node参数:要查找父节点的节点;
//功返回父节点的设备节点结构,失败时返回NULL;


(2)遍历某一节点的子节点

device_node *of_get_next_child(const struct device_node *node,struct device_node *prev);
//node参数:父节点;
//prev参数:上一个找到的子节点,即从哪个子节点开始搜索。如果为NULL,表示从第一个子节点开始搜索;
//返回值是找到的下一个子节点的设备节点结构;

4.3、platform_device相关

(1)根据device_node分配platform_device

struct platform_device *of_device_alloc(struct device_node *np,const char *bus_id,struct device *parent);
//np: 设备节点在设备树中对应的节点;
//bus_id: 设备所属的总线类型(如 "spi"、"i2c" 等),通常此参数被设为 NULL,表示依赖设备属性(device properties)标识符自动匹配设备总线类型;
//parent: 设备的父设备,通常设置为 NULL;
//该函数返回值是新分配的platform_device结构体指针,如果分配失败则返回 NULL;


(2)查找与给定设备节点(np)相对应的平台设备

struct platform_device *of_find_device_by_node(struct device_node *np);
//np: 设备节点在设备树中对应的节点;
//如果找到了匹配的平台设备,则返回这个平台设备的指针;否则,返回 NULL;


该函数常用于在设备树驱动程序中查找一个已知的平台设备,以便对其进行进一步的操作或数据访问。在实际编写设备树驱动程序中,通常需要通过 of_find_device_by_node 函数查找与设备节点关联的设备,并使用dev_set_drvdata函数将自定义的设备数据结构(如设备驱动结构体)与平台设备绑定。

(3)基于设备树节点节点创建一个平台设备

struct platform_device *of_platform_device_create(struct device_node *np,const char *bus_id,struct device *parent);
//np: 设备节点在设备树中对应的节点;
//bus_id: 设备所属的总线类型(如 "spi"、"i2c" 等),通常此参数被设为NULL,表示依赖设备属性(device properties)标识符自动匹配设备总线类型;
//parent: 设备的父设备,通常设置为NULL;
//该函数返回值是新创建的平台设备(platform_device)结构体指针。如果创建失败,则返回 NULL


在函数内部,of_platform_device_create 会使用 devm_kzalloc函数为设备结构体分配内存,并调用of_device_add函数将该设备注册到系统总线中。然后,将设备节点、总线类型和父设备作为参数传递给这个平台设备结构体,并返回新创建的平台设备结构体指针。

在这里,我仅仅整理了我目前接触到的一些主要OF操作函数,如果还需要更详细的,可以看这篇文章,我觉得还挺详细的。

本文作者:小信嵌梦

本文链接:https://www.cnblogs.com/Xin-Code9/p/18706412

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @   小信嵌梦  阅读(45)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
收起