- 前言
- Device Tree结构与语法
- 用DeviceTree配置硬件信息
- 在c代码中访问DeviceTree Zephyr
- 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的层次结构是由什么决定的?也就是说,是什么决定了一个节点成为另一个节点的子节点?
首先是总线的主从关系,其次是硬件的包含关系。
例如:
- 所有外设都在ARM地址空间内可被寻址,因此外设节点都是soc的子节点
- ds3231RTc是i2c从机,具有i2c地址,故是i2c外设的子节点
- Button和LED虽然使用GPIO,但GPIO不是总线。考虑到Button和LED都是板子上的硬件,故按照硬件的包含关系,直接成为根节点的子节点。
- 使用ADc通道的硬件同理,也是根节点的子节点。

DeviceTree的适用范围

DeviceTree描述的是板卡级的硬件信息。
由于DeviceTree的层次结构是依赖于总线和地址的,所以devicetree与固件强相关。
-
一块板子上有两颗MCU,它们不能共用dts

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

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

DeviceTree语法-DTS基本示例
描述设备树信息的文件是设备树源码(Device Tree Source,
DTS),一个基本示例如图:
- /dts-v1/;指明了设备树语法的版本
- 设备树具有唯一的根节点,节点之间的包含关系使用大括号来确定
- 节点名称写在大括号之前,节点的属性写在打括号内;属性是键值对(Key-Value Pair)的形式。
- 可以给节点写一个标签(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
了解即可,实际开发不需要看这个头文件。
总结
- DeviceTree描述的是板卡级的硬件信息。DeviceTree是树型逻辑结构,层次关系是由总 线的主从关系,以及硬件的包含关系决定的
- DeviceTree的基本单元是节点(Node),节点具有一个名称和多条属性。可以给节点增加标签(label),来便于引用这个节点。
- 板卡级的dts文件可以引用芯片级的dtsi文件,也可以引用.h头文件,从而使用其定义的枚举值和宏。
- 用户可以在自己的工程里通过写.overlay的方式,来覆写原始board dts里的配置
- 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)
- 若有多个block,则需要通过下标来索引
注意,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的实现方式
什么是驱动程序?
-
驱动程序是面向对象的。首先要有一个被操作的对象,然后才有驱动程序。
这个被操作的对象就是device结构体。device结构体本身是抽象的,没有具体的含义。

-
驱动程序在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或者直接写寄存器。