详解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或者直接写寄存器。
相关推荐
wotaifuzao11 天前
(十)BLE GATT与ATT深度解剖-从属性表到CCCD-订阅收不到数据的终极答案
低功耗蓝牙·ble·nrf52·zephyr·gatt·cccd
星源~11 天前
zephyr-Linux环境下搭建步骤
linux·mcu·嵌入式开发·zephyr
dozenyaoyida20 天前
Zephyr BLE 上行链路源码拆解:一个空中报文怎么从 radio 跑到应用回调(NCS v3.2.1)
controller·嵌入式开发·ble·host·zephyr·协议栈·软链路层
dozenyaoyida22 天前
Zephyr BLE GATT Server 注册到收发调用链源码解析(NCS v3.2.1)
ble·zephyr·gatt·ble协议栈
iini1 个月前
nRF Connect SDK 开发新体验:VS Code + Claude Code + DeepSeek + Nordic MCP 全流程(蓝牙开发实例)
agent·ai编程·nrf connect sdk·zephyr·蓝牙开发·deepseek·mcp·claude code·nordic mcp
乐鑫科技 Espressif2 个月前
用 AI 开发 Zephyr-IoT 应用
人工智能·物联网·esp32·乐鑫科技·zephyr
固执的你2 个月前
串口中断接收协议数据并用状态机解析_printk()打印内容
zephyr
fitpolo2 个月前
Zephyr的SPI
zephyr
toradexsh3 个月前
基于 NXP i.MX8M Plus 测试 Zephyr RTOS
arm·rtos·nxp·imx8mp·zephyr