详解Zephyr设备树与设备驱动模型

  1. 前言
  2. Device Tree结构与语法
  3. 用DeviceTree配置硬件信息
  4. 在c代码中访问DeviceTree Zephyr
  5. Driver的实现方式

1.前言

前言-硬件的抽象描述

在做传统的嵌入式c语言开发时,我们常会使用宏定义的方式来实现硬件的抽象,例如:

在实际的驱动代码中就可以屏蔽掉硬件的具体细节,实现便于移植的驱动代码,例如:

这种方法的优点:

简单直观。如果想要修改外设分配,只需修改宏定义,无需修改代码

前言-代码的耦合与解耦

假设我们有一个按钮和一个传感器,都需要用到GPIO以及中断,并且我们想实现代码的解耦

完全解耦并不现实,因为从Soc的硬件实体上讲,GPli0就是一个外设,无法将初始化和中断服务函数分开。

把soc外设的初始化、中断服务函数都集中起来,统一分配硬件资源。应用层代码只需关心实际操作。

前言-DeviceTree和zephyr的意义

问:

用宏定义的方式来抽象硬件,这种方法,不跨平台,且与开发者个人风格相关,有没有更标准的描述硬件的方法?

答:Device Tree

问:

  • 能不能在定义完硬件后,就自动初始化好硬件,不要再写board.c?
  • 如果更换硬件平台,应用代码里对ibrary的调用还得跟着换,有没有不用换的方法?
    答:
    zephyr
  • SoC厂商提前写好标准的DeviceTree,描述好硬件上的全部资源
  • 用户可以修改DeviceTree,添加自己的配置
  • 如果用户使用zephyr驱动,则zephyr驱动会自动初始化硬件
  • 不论Soc怎样变化,只要厂商提供了Zephyr驱动,就不用修改应用代码
  • 用户仍然可以使用传统的寄存器或者库函数的方式操作硬件
  • 用户在应用层也可以访问到DeviceTree里描述的信息

总结:

  • DeviceTree可以记录硬件信息
  • DeviceTree可以提供初始化配置

2.Device Tree 结构与语法

DeviceTree的层次

DeviceTree是一个树状的数据结构。

DeviceTree的层次结构是由什么决定的?也就是说,是什么决定了一个节点成为另一个节点的子节点?

首先是总线的主从关系,其次是硬件的包含关系。

例如:

  1. 所有外设都在ARM地址空间内可被寻址,因此外设节点都是soc的子节点
  2. ds3231RTc是i2c从机,具有i2c地址,故是i2c外设的子节点
  3. Button和LED虽然使用GPIO,但GPIO不是总线。考虑到Button和LED都是板子上的硬件,故按照硬件的包含关系,直接成为根节点的子节点。
  4. 使用ADc通道的硬件同理,也是根节点的子节点。

DeviceTree的适用范围

DeviceTree描述的是板卡级的硬件信息。

由于DeviceTree的层次结构是依赖于总线和地址的,所以devicetree与固件强相关。

  1. 一块板子上有两颗MCU,它们不能共用dts

  2. 一颗MCU有两个具有独立固件的核,它们不能共用dts

  3. 同一个核有两种不同地址空间(安全和非安全),也不能共用dts

DeviceTree语法-DTS基本示例

描述设备树信息的文件是设备树源码(Device Tree Source,

DTS),一个基本示例如图:

  1. /dts-v1/;指明了设备树语法的版本
  2. 设备树具有唯一的根节点,节点之间的包含关系使用大括号来确定
  3. 节点名称写在大括号之前,节点的属性写在打括号内;属性是键值对(Key-Value Pair)的形式。
  4. 可以给节点写一个标签(label),便于在其他地方引用

    要指明一个节点,需要完整的绝对路径,例:/a-node/a-sub-node
    也可以直接使用标签来指明,例如:subnode_nodelabel

DeviceTree语法-DeviceTree中的节点名称

c 复制代码
 name@address
  • name:必须以字母开头。长度必须1~31字节,允许数字、大小写字母、英文逗号、小数点、加号、减号、下划线
  • @address:如果节点有reg属性,则address必须和reg属性的第一个寄存器地址值相等。可以理解为总线上的地址。如果没有reg属性,则@address必须省略。address是十六进制。

DeviceTree语法-DeviceTree中的属性类型

属性类型

dts支持多种属性类型,见右表。其中int 为32bit

DeviceTree语法-文件引|用

dts可以引用其他的dts或dtsi,这样板卡级dts就可以引用厂商写好的芯片级dtsi,从而减少编写dts的工作量

dts可以引用C语言头文件,从而可以使用里面的枚举值和宏定义值

DeviceTree文件位置

新建project,选择build configuration时,可以选择板卡。这些板卡的dts文件位于:KaTeX parse error: Expected '}', got 'EOF' at end of input: ...y/zephyr/board/{archy/${board-namey/

板卡引用的SoC的dtsi文件,位于:${NCSy/zephyr/dts/arm/nordic/

在我们自己的工程目录中,有.overlay文件,我们可以在里面增、删、改节点以和节点的属性。可以用绝对路径,也可以用label

用label指定节点,覆写其属性

删除属性

删除节点

DeviceTree文件位置

构建项目时,zephyr build system会使用一系列脚本,把board、soc以及用户的overlaydts合并起来。如果项目构建目录的名称是build,最终合并后的完整文件

位于:${project_folder}/build/zephyr/zephyr.dts

DeviceTree最终输出

zephyr build system最终会把zephyr.dts处理成c语言头文件,包含所有的节点信息与属性信息。从而使得实际的代码能够访问到这些信息。这个头文件于:${project_folder}/build/zephyr/include/generated/devicetree_generated.h

了解即可,实际开发不需要看这个头文件。

总结

  1. DeviceTree描述的是板卡级的硬件信息。DeviceTree是树型逻辑结构,层次关系是由总 线的主从关系,以及硬件的包含关系决定的
  2. DeviceTree的基本单元是节点(Node),节点具有一个名称和多条属性。可以给节点增加标签(label),来便于引用这个节点。
  3. 板卡级的dts文件可以引用芯片级的dtsi文件,也可以引用.h头文件,从而使用其定义的枚举值和宏。
  4. 用户可以在自己的工程里通过写.overlay的方式,来覆写原始board dts里的配置
  5. Zephyr Build System在构建时会合并所有的dts以及overlay,生成最终的zephyr.dts,
    并导出为devicetree_generated.h头文件

3.用DeviceTree配置硬件信息

用DeviceTree配置硬件信息一一 常见属性介绍

从上一节我们可以知道,DeviceTree本身的结构和语法其实非常简单,只是规定了一个形式而已,

跟硬件的配置没有任何关系。

要想了解DeviceTree是如何对硬件配置产生影响的,需要了解一些常见的属性。

reg、 #address-cells 与#size-cells

reg属性代表此节点在总线上占用的地址和范围。是由多对(address, length)组合而成的。

而#address-cells和#sieze-cells则表示了这个总线上的节点的reg属性里,每个address和size要占用多少个uint32单元。如右图,address和size各占一个单元。则serial有两个寄存器,首地址分别是0x0和0x200,长度分别是0x100和0x300。

ranges

当一个节点定义了ranges属性,那么它的子节点就可以使用相对地址,而非绝对地址。

总如右图。peripheral基地址为0x40000000。而ADC的地址从oxe000开始,这是一个相对地址。

则ADC在ARM地址空间的绝对地址为0x4e000000

ranges属性的格式为:<子空间首地址 父空间首地址长度>

子空间首地址为0时,子节点的地址就是相对地址。这三个元素要占用几个uint32单元,由图中同色的*-cells决定

status

status用来指定是否启用一个设备(节点),根据DeviceTree Spec有以下几个选项:

  • "okay": 设备是可操作的
  • disabled": 设备目前是不可操作的(但未来可能可以操作,比如设备插入、安装后)
  • "fail": 设备不可操作。设备中检测到错误。
  • "fa11-sss": 设备不可操作。其中sss的部分会根据不同的设备而变换,用于指定特定的错误码
  • "reserved": 设备可操作,但不应该使用。通常用于设备被其他软件控制的情况。

但是实际上Zephyr中基本只会用到"okay"和"disabled",原因后面解释。

compatible

compatible用来说明此节点设备的兼容性。它的值是一个字符串或一个字符串数组。

Zephyr构建系统就是用它来为每个节点找到合适的驱动程序。其具体的应用后面会讲解。

compatible的每个值的通常命名方式是"vendor,device",即某个供应商的某个产品。这不是强制的要求,也可以没有vendor。

如果compatible有多个值,zephyr会按顺序寻找驱动。会使用找到的第一个驱动。

用DeviceTree 配置硬件信息一一域(Domain)

我们知道,DeviceTree是基于总线地址的层次结构。然而,实际的硬件是网状结构,如何才能简洁地描述好真实的硬件之间的关系呢?

其实,除了DeviceTree本身基于地址的树之外,逻辑上还有一些其他的树,例如GPIO树、中断树、ADC树等等。

我们将这种附加在DeviceTree上的,逻辑上的树称为域(Domain)。

很容易发现,每个域都有一个自己的"根节点",称为控制器(Controller),控制器控制了整个域的相关硬件。

控制器节点通常会有一个布尔类型属性*-controller,来表示自己是某个域的控制器。

而域中的子节点,就可以。使用phandle-array类型的属性来说明自己属于哪个域。此属性的第一个值是指向控制器的节点。后续的值是此节点在这个域中的配置。这一条配置被称为specifier。控制器节点中会有一个#*-cells属性来指明specifier的大小必须是多长。

中断域有点类似,但有区别。首先,我们发现adc的interrupts属性只写了specifier,并没有写controller指向哪里。这是因为,构建系统默认把devicetree父节点当作中断域的controller。如果父节点不是controller,则继续向上寻找。直到遇到controller,或者遇到interrupt-parent属性时,才会指定父节点。

类似的还有adc域、pwm域、pin-ctrl域等等。这些域的子节点都采用了specifier的方式,来记录配置信息。控制器节点都有#-cells属性,来指定specifier占用的长度。但是却没有 -controller属性来指明controller节点

用 DeviceTree配置硬件信息-- Device Binding

前面讲到域的概念,我们会发现不同的域的配置方法有一些共性,但也有一些差异,这让我们感觉devicetree的规则很混乱:"除了dts本身的语法之外,竟然还有其他的规则,一个不小心就会写错!"

规则是双刃剑。既可以说规则带来了麻烦(提高了门槛),又可以说规则创造了便利(防止后续编译出奇怪问题).这里的便利性其实体现在编辑器的代码提示与自动补全。

这里,所谓的规则,被称为Device Binding文件。binding文件是yaml格式文件,yaml是标记语言,由多组键值对组成。每个值可以是:

  • 纯量(单个不可分割的值,如整数、字符串)
  • 对象(把键值对当成值)
  • 数组(一组同类型的值)

简易语法:

  • 键、值之间用冒号+空格分隔
  • yaml的层级关系只看缩进(类似python),相同层级的缩进必须相同
  • 数组元素可以是纯量、对象。对象的成员也可以有数组(和json非常相似)

binding和device tree中的节点,通过compatible属性实现联动。

device tree中节点的属性,必须严格按照binding文件中的要求。

厂商都会针对每个模块写好binding文件。

例如:

下图中我自定义了一个电压传感器设备,需要用到ADC。那么我在binding文件中,要求符合compatible ="jayant,voltage-sensor"的所有节点,都必须具有io-channels属性,且类型必须是phandle-array。从而使得这个节点可以通过写specifier的方式,把自己加入到ADc域中。

在编写dts时,不必太过担心。在Vs Code中直接ctrl+鼠标左键点击compatible,就可以跳转到对应的binding文件中。

device binding的约束能力很强大,不仅可以约束节点的属性(指定数据的类型、枚举、甚至强行赋值),还可以约束此compatible节点的子节点的属性。此外,还能给specifier中记录的数值赋予含义。

zephyr build system会从以下位置寻找binding文件:

  • zephyr/dts/bindings/
  • ${board_dir}/dts/bindings/
  • ${project_dir}/dts/bindings/

也可以在CMakeLists.txt中,用list(APPEND DTS_RO0T/path/to/your/dts)命令增加binding文件的目录也可以在编译时,增加选项 west build -b<board_name>---DTS_RO0T=<path/to/your/dts>

如果想要自定义设备类型,可以把yaml文件添加到以上位置。文件名推荐和compatible一致,但不是必须的。

用DeviceTree配置硬件信息一一特殊节点

除了标准的硬件节点之外,还有一些特殊的节点:

  • /chosen:为zephyr kernel选择特定设备(如日志串口)
  • /aliases:给节点起一个别名,类似label。但是label仍是节点,aliases中的别名是属性名。
  • /pinctr:直属于根节点,不属于soc的一个虚拟节点,用于管理数字lio的复用
    /zephyr,user:方便用户开发的节点,可以直接在里面写各种specifier、配置项等。免去了如果自定义一个device,还要写bindiing的麻烦



总结

  • DeviceTree本身的语法只提供了一个基于总线主从关系的树形层次结构,此外每个节点可以用属性来存储信息。语法本身并没有规定硬件要如何描述。
  • DeviceTree中的一些常见属性,补充了这方面的空缺。
    • reg、ranges、#address-cells、#size-cells这四个属性描述了总线上的地址分配
    • status属性描述了设备是否使能
    • compatible属性描述了设备的兼容性
  • 在DeviceTree中,除了本身的树形结构以外,还具有一些逻辑上的树形结构,称为域。域具有控制器和设备节点,控制器是真正实现域的功能的硬件外设,而设备节点只是为了开发方便解耦而进行的一种抽象。
  • 真正限制device tree中属性该如何写的,是device binding文件。binding文件是芯片厂商提供的。有了binding文件,就可以在Vs Code中实现自动的检查与补全。Zephyr实际构建项目时,也是参考binding文件来检查dts的正确性。只有dts按照正确的规则写了,zephyr的驱动代码才能识别到硬件配置,进行自动初始化。
  • zephyr中会有一些特殊的虚拟节点来为开发提供便利。

4.在c代码中访问Device Tree

要想在代码中访问到DeviceTree中的信息,需要通过DeviceTree API来实现:

为了获得某个节点的属性,首先需要这个节点的id (node identifier)来作为句柄。节点id本质上就是devicetree_generated.h中的宏定义。

获得节点id的方式有很多

但是有一种方式需要注意,那就是通过实例ID的方式获取节点ID.

比如:DT_INST(0,nordic_nrf_timer),对应的就是"nordic,nrf-timer"的第0个实例节点。

dts中,如果同一个compatible对应了多个节点,那么就说有多个实例。比如timer0 ~ timer2,led0 ~ led3。

zephyr会根据其在dts中的顺序,给其赋一个编号,从0开始。那么通过DT INST(icompatible)的方式就能用序号遍历所有拿到所有相同compatible的节点的id了。

注意:

  • 所有Device Tree API都是宏,是预编译的结果,都是常量。因此宏的参数必须是常量。不能在for(inti=0;i<n;i++)的循环中用变量i去调用INST的API。
  • DeviceTree中的特殊符号到了c语言中都变成下划线,否则不符合c语言规范

利用DeviceTree API,输入节点id和属性名称,就可以获得属性,详细不多赘述。

普通属性(整数、字符串、数组)

整数与字符串示例:

数组示例:

假设dts为

则c代码中可以写作:

reg属性

  • 获取reg blocks数量:DT_NUM_REGS (node_id)
  • 若只有1个block,则直接读取其地址和长度:
    DT_REG_ADDR(node_id)
    DT_REG_SIZE(node_id)
    • 若有多个block,则需要通过下标来索引
      DT_REG_ADDR_BY_IDX(node_id, idx)
      DT_REG_SIZE_BY_IDX(node_id, idx)

注意,node_id和idx都必须是常量。因为宏的值在编译时就已经展开,因此不能放在循环里运行。

通过phandle属性获取其他节点id

如下图示例,利用phandle-array和数组下标来获取节点id

前面提到,DeviceTree API都是宏,不能在代码运行时用循环语句(for和while)来调用。但是DeviceTree AP提供了遍历展开宏。如:

对设备树中的每一个节点都调用宏函数fn

对设备树中的每一个status为okay的节点调用宏函数fn

对一个节点的所有子节点遍历调用宏函数fn

这些API看似是循环,实际上是在预编译时,把所有遍历的可能性全部展开。

实际上Nordic提供的很多Zephyr驱动,都是用遍历宏来创建外设相关的变量(例如confg结构体),从而能调用nrfx api来完成实际的初始化。

举一个实际的例子,在${Ncs)/zephyr/drivers/led/led_gpio.c中:

定义了:

于是,就可以使用inst API来访问compatible="gpio-leds"的所有led,如下:

  • DT_DRV_INST(0)表示led0 node id
  • DT_DRV_INST(1)表示led1的node id

inst API提供了基于下标的访问device tree的方式

此处用宏函数的方式定义了一个代码模板,内部定义了led驱动程序所需的变量、device结构体等。所有内部调用的宏函数都是基于实例ID的INST API。

此处用遍历宏调用了前面的代码模板。这个遍历宏的效果是:对所有status为okay,且compatible为gpio-leds的节点,执行一次上面的宏函数。

在c代码中访问DeviceTree-- 硬件支持

例如:ADC_DT_SPEC_GET_BY_IDx(node_id,idx)可以提取ADc通道的specifier

就会展开为:

刚好和zephyr的adc驱动中定义的adcchannel结构体的成员一致

zephyr就是用这种方式,在驱动代码中自动遍历所有status="okay"的节点,提取其信息,然后用遍历宏来定义驱动结构体,在kernel启动之前就把硬件的初始化给完成。

总结:

  • 要从c语言中访问DeviceTree中的信息,需要先获得node id。用绝对路径、label、chosen、alias等许多方法都可以获取一个节点的nodeid。其中要注意的是通过实例id的方法(INsT)
  • 有了node id,就可以获取node的属性。普通的属性与reg、phandle、interrupt属性的获取APi不相同。
  • zephyr还提供了遍历宏,从而可以针对特定条件的节点/属性遍历执行宏函数。
  • Zephyr用前面提到的通用API,封装出了各种硬件支持API,方便直接读取各种硬件指定的specifier。

5.Zephyr Driver的实现方式

Zephyr Driver的实现方式

什么是驱动程序?

  1. 驱动程序是面向对象的。首先要有一个被操作的对象,然后才有驱动程序。

    这个被操作的对象就是device结构体。device结构体本身是抽象的,没有具体的含义。

  2. 驱动程序在appilication程序运行之前,就把硬件初始化做好,然后定义好device结构体。

    下图中的五个主要级别都可以定义驱动程序初始化的时间,每个级别内还可以再细分优先级。

    Application是如何获得device结构体句柄的?

    有两种方式:通过name或设备树nodeid。

  • 通过Name的方式

    这种方式,可以与DeviceTree完全无关。

    例程: $(NCS)/zephyr/samples/application_development/out_of _tree_driver 中, 介绍了out of tree driver法.

    驱动程序中,通过DEVICE_DEFINE()定义了device结构体

    在Application中,通过 device_get_binding()函数,就可获得device结构体。

  • 通过device tree的方式

    驱动程序中,通过DEVICE_DT_DEFINE(),定义了结构体,并与deviceTree中的节点绑定。

    Application中,通过 DEVIcE_DT_GET(node_id)宏来获得这个device结构体

我们修改prj.config中的cONFIG_*选项、修改dts中的status属性,其本质是在做什么?

位于zephyr/drivers/adc/目录下的cMakeLists.txt内容

综合前面介绍的devicetree、遍历宏的内容,我们可以知道:

  • 修改driver相关的config选项,其本质是让cMake把驱动程序包含进来。只要启用了相关config,驱动程序就会载入,固件就会变大。
  • 修改status为"okay",其本质是,让驱动程序在使用遍历宏创建device结构体时,能够为这个okay的节点创建device对象。

只有两者都启用,硬件节点才真正的被驱动了,application中才能真正的操作这个节点

Zephyr标准驱动

Zephyr是一个跨平台的操作系统,自然少不了对各类标准硬件的跨平台支持。

详见: https://docs.zephyrproject.org/latest/hardware/peripherals/index.html

这里以counter为例:(在Zephyr中,Timer指的是内核软定时器,而counter指硬件定时器)

在zephyr/include/zephyr/drivers/counter.h中,规定了zephyr标准的counter应该具有哪些api。

在zephyr/drivers/counter/目录下,有各个厂商对自家Mcu产品写好的timer驱动,全部都符合zephyr标准的API。

在Kconfig中启用counter驱动时,zephyr build system就会自动把板子对应厂商的counter驱动编译进来

Zephyr标准驱动支持硬件的全功能吗?

很遗憾,答案是不能。zephyr只支持最基础、最标准的硬件驱动,不支持各个厂商的硬件特性。

例如nrf系列的PPl,非常方便,但是没有zephy标准驱动,所以是不可能有"devicetree里写一下配置,PPl就自动连好了"这种操作的。

外设的SHORT寄存器也是同理。右侧是一段混合代码:

  • 前半部分,zephyr标准已经自动初始化好timer0,所以可以用counter api进行配置。
  • 后半部分,只能利用nrfx api,来连接short寄存器,让timer0自动循环计数。

这里还有个注意事项。

nrf timer本身没有overflow事件,所以把channel 0拿去设置top value了;此外,还把channel 1拿去做输入捕获了。

因此,nrf timer暴露给 zephyr标准驱动的通道就少了两个,实际上zephyr counter 的通道0,是硬件定时器的通道2.

总结

  • Zephyr驱动程序,在Application运行之前就会执行初始化,并且定义device结构体。
  • Application可以通过Name或者Node id的方式,获得device结构体
  • 我们在Kconfig中使能driver,本质上是载入了驱动程序,固件会变大。在dts中把节点的status设为okay,本质上是让驱动程序在初始化时,能够自动搜到这个节点,并为这个节点创建device实例。
  • Zephyr的标准驱动,让各个厂商都实现了相同功能的驱动AP代码,从而实现了跨平台的统一驱动。但是如果想要使用硬件特性的功能,就还是必须使用厂商自己的driveribrary或者直接写寄存器。
相关推荐
乐鑫科技 Espressif4 天前
用 AI 开发 Zephyr-IoT 应用
人工智能·物联网·esp32·乐鑫科技·zephyr
固执的你9 天前
串口中断接收协议数据并用状态机解析_printk()打印内容
zephyr
fitpolo14 天前
Zephyr的SPI
zephyr
toradexsh18 天前
基于 NXP i.MX8M Plus 测试 Zephyr RTOS
arm·rtos·nxp·imx8mp·zephyr
ScilogyHunter1 个月前
Zephyr串口驱动开发及构建完全指南
驱动开发·uart·zephyr
ScilogyHunter1 个月前
Zephyr Hello World应用开发构建完全指南
zephyr·hello world
ScilogyHunter1 个月前
Zephyr Twister测试框架完全指南
zephyr·twister
ScilogyHunter1 个月前
west init 命令详解
init·zephyr·west
ScilogyHunter1 个月前
使用Kconfig配置Zephyr工程完全指南
kconfig·zephyr