1. 引言
Zephyr是由Linux基金会管理的开源实时操作系统(RTOS) [1],其前身为用于数字信号处理的Virtuoso操作系统,后被风河(Wind River)收购,更名为Rocket RTOS。2016年它成为了Linux基金会的项目,更名为Zephyr。
Zephyr得到了多家半导体企业的支持,包括恩智浦、意法半导体、瑞萨、北欧半导体(Nordic)、英特尔和德州仪器等,并已经被应用到了众多设备中,覆盖了消费电子、能源、医疗、工业、农业等领域[2]。Zephyr的Apache 2.0开源协议授权让它在非商用和商用解决方案中都可免费使用[3]。
近年来Zephyr的热度逐渐上升,在嵌入式开发中的采用度逐步增加。Eclipse基金会的《2024年物联网和嵌入式开发者调查报告》表明,在资源受限设备上使用Zephyr的开发者从2022年的8%增长到了2024年的21%,这已经和裸机直接编程的比例相当,也非常接近第二位的FreeRTOS (29%) [4]。
相比FreeRTOS等小型RTOS而言,教育生态不够成熟的Zephyr系统规模更大,结构更复杂,这提高了开发者入门和精通的门槛。本文对Zephyr硬件抽象层和设备驱动的架构与实现进行系统性分析,重点阐述了设备驱动模型和设备树的作用。为了展示基于Zephyr的嵌入式软件开发,本文在BBC micro:bit V2开源硬件上构建样例Zephyr设备驱动和应用程序,并做解释和验证。
2. Zephyr的硬件抽象层和配置概述
Zephyr有着完善的设备驱动支持,而且高度可配置。作为Linux基金会的项目,它用到了和Linux内核类似的工具,特别是设备树(Device Tree)和Kconfig配置语言。本章将对与开发息息相关的硬件抽象化和配置进行概述。
2.1. 设备驱动模型
Zephyr的设备驱动模型负责初始化系统中所有的驱动程序,为系统中的所有设备驱动提供了统一的配置方法[5]。如图1所示的是设备驱动模型的概览。
Zephyr中每一种子系统驱动(UART、I2C等)都有着泛用类型(Generic Type,非设备特定)的接口,具体的驱动实现会提供实现这些驱动接口函数的指针。在图1中可以看到,在子系统2中有两种设备驱动的实例,但是两种驱动都会提供泛用API 1到3的实现。应用程序代码可以在兼容的设备上直接使用泛用API,具体驱动的实现代码会被调用。如子系统1中所示,同一种驱动可以在系统中多次实例化,比如多个UART接口。
设备驱动代码在初始化时也会为每个设备提供驱动特定的配置,即图1中的struct config。在实际代码中这可能是通过Kconfig配置的参数,比如显示器的刷新频率。驱动代码还可以为每个驱动指定一个结构用于存储相关的数据。
Figure 1. An overview of the device driver model (source: zephyrproject.org)
图1. 设备驱动模型概览(来源:zephyrproject.org)
一个驱动的泛用接口定义会出现在驱动的头文件中,图2中定义了subsystem子系统的泛用接口subsystem_do_this和subsystem_do_that函数。图3中的my_driver驱动实现了自己的do_this和do_that函数,并将它们的指针填入了驱动API结构(do_this和do_that成员)。注意应用程序代码应该直接使用subsystem_do_this/that函数,这两个函数会通过DEVICE_API_GET宏进入正确的驱动接口实现,即my_driver_do_this/that函数。在实际的驱动中,subsystem会被替代为能够代表设备的名称,例如在通用的显示驱动接口(include/zephyr/drivers/display.h)中,subsystem被替代为了display。
Figure 2. A sample driver interface definition (source: zephyrproject.org)
图2. 样例驱动接口定义(来源:zephyrproject.org)
Figure 3. A sample driver implementation (source: zephyrproject.org)
图3. 样例驱动实现(来源:zephyrproject.org)
在进行具体子系统驱动的实例化时,驱动代码还会提供初始化代码和初始化的优先级。
2.2. 设备树
设备树(Device Tree)是用于描述硬件的层级化数据结构。设备树规范[6]描述了设备树的概念、用途、结构、设备树绑定(binding)和设备树语言。
2.2.1. 设备树的作用
Zephyr和Linux同样使用设备树,Zephyr为了减少运行时的数据和代码,会使用设备树的数据产生C语言头文件[7]。Zephyr中定义了一整套宏,用于访问设备树节点和取得设备树节点的属性。
Zephyr中设备树有两项主要作用:
设备树和Kconfig在Zephyr中都起到了配置语言的作用,设备树用于描述硬件和启动时的配置,Kconfig则主要用于配置软件。
设备树有两种输入文件:设备树源文件和设备树绑定[8]。源文件描述了设备树本身,绑定则用于描述设备树的内容,特别是数据类型和结构。Zephyr在构建时使用这两种文件生成C头文件,devicetree.h头文件提供通用的宏访问设备树(以“DT_”打头)。
2.2.2. 设备树的语法
图4所示的是一个最小的样例设备树源文件[9]:
Figure 4. A minimum device tree file (source: zephyrproject.org)
图4. 设备树最小样例(来源:zephyrproject.org)
图中“/”代表根节点,a-node是根节点的子节点,a-sub-node是a-node的子节点,a-sub-node还有一个label (标签) subnode_nodelabel。标签是可选的,在设备树中每个标签只能出现一次,代码可以通过标签直接访问节点。每个节点都有自己的路径,和Linux文件路径相似,例如a-sub-node的全路径为:/a-node/a-sub-node。
图5所示的是一个较为贴近实际硬件的设备树样例:
Figure 5. A complete device tree example (source: zephyrproject.org)
图5. 一个完整的设备树样例(来源:zephyrproject.org)
在图5中可以看到节点名的命名方法为“总线类型或设备名@地址”,这样的惯例不仅有助于区分类似的节点,还能够帮助快速确定节点指向的设备和总线类型。地址的惯例根据设备类型有所不同:
在内存中映射的外设:使用寄存器映射的基地址,例如i2c@40003000表示I2C映射的寄存器基地址为0x40003000。
I2C外设:使用外设在I2C总线上的地址,例如apds9960的I2C地址为0x39。
SPI外设:使用外设的片选线序号,如果没有则使用0。
内存:使用物理内存的起始地址,例如memory@2000000表示从0x2000000物理地址开始的RAM。
在内存中映射的闪存:和RAM类似使用物理起始地址,例如flash@8000000。
固定的闪存分区:使用分区的偏移量,例如在flash@8000000设备中可以有一个partitions节点代表分区表,其中有partition@0和partition@20000两个节点,分别意味着起始地址0x8000000和0x8020000的两个分区。
设备树节点中每个属性有一个名称和一个值,属性的值可以是字符串、整型数、布尔值、8位整型数组、字符串数组、混合类型数组、指向节点的phandle (类似C语言中的指针)、复数的phandle或是phandle数组。
设备树节点中几个重要的属性如下:
compatible:表示节点所代表的硬件设备,本文翻译为兼容名。兼容名属性在构建过程中十分重要,驱动程序通过兼容名的值查找可以适配的硬件。兼容名的值可以是字符串数组,将数个驱动程序从最特定到最泛用进行排列,首个匹配的驱动程序会被加载。
reg:用于设备寻址,其格式为16进制的<地址,长度>。
status:用于表示节点是否启用。Zephyr支持“okay”和“disabled”,分别表示启用和禁用。节点必须启用,Zephyr的驱动模型才会应用到节点上。
除了标签,设备树源文件中还可以定义chosen (选择)和alias (别名)来帮助应用代码或驱动寻找特定的节点,如图6所示。
Figure 6. Use chosen and aliases nodes in a device tree file (source: zephyrproject.org)
图6. 在设备树中使用chosen和aliases节点(来源:zephyrproject.org)
图中/alias和/chosen节点都不指向实际的硬件设备,它们被用来指定设备树中的其他节点:my-uart是/soc/serial@12340000路径的别名(uart0标签名),uart0标签还被选为“zephyr, console”。选择和别名可以帮助抽象化不同的开发板,例如闪灯样例(samples/basic/blinky/src/main.c)中使用led0别称节点达到支持多种开发板的目的,只要开发板的设备树文件中有别称为led0的节点,样例即可运行。
Zephyr中每个支持的开发板都有自己的主设备树文件,micro:bit V2的文件位于路径boards/bbc/microbit_v2/bbc_microbit_v2.dts,其中可以看到GPIO按钮、LED显示矩阵、I2C总线和I2C总线上的传感器等硬件。应用也可以提供专门针对开发板的设备树覆盖文件,路径为“<应用或模块路径>/boards/<开发板名>.overlay”。覆盖文件中可以增加新的选择/别名节点,也可以配合新的设备树绑定文件(见下节)增加节点。
2.2.3. 设备树绑定
设备树自身的结构相对自由,需要有设备树绑定才能够正确、完整地描述硬件[10]。设备树绑定中包含对设备树节点格式和内容的要求。Zephyr使用YAML文件存储设备树绑定。
图7所示的是一个样例绑定文件[11]:
Figure 7. A sample device tree binding file (source: Martin Lampacher’s code on GitHub)
图7. 一个样例设备树绑定文件(来源:Martin Lampacher在GitHub上的代码)
从图7中可以看到3个重要的键值[12]:
description (描述):描述绑定文件适配的硬件的字符串。
compatible (兼容名):和设备树中的兼容名对应,一个绑定文件的兼容名如果和一个设备树节点一致,则该设备树节点的格式应当符合绑定文件的内容。
properties (属性):描述了符合绑定的节点中的属性与格式。
图8所示的是设备树节点符合图7中的定义:
Figure 8. A device tree node that is compatible with the binding (source: Martin Lampacher’s code on GitHub)
图8. 符合绑定文件的设备树节点(来源:Martin Lampacher在GitHub上的代码)
从图8中可以看到:
节点的兼容名和绑定的一致。
每一个属性都有按照绑定中type的类型赋值。
Zephyr中默认包括的绑定文件位于dts/bindings子目录下,按照类型进行分类,以兼容名的名称进行命名。
除非向Zephyr中添加新的硬件支持,一般开发中不添加新的绑定文件。需要时应用可以增加新的绑定文件(<应用或模块路径>/dts/bindings/<兼容名>.yaml),并在设备树覆盖文件中添加符合绑定定义的节点。
2.2.4. 在程序中访问设备树节点和属性
从C/C++应用代码中可以用多种方式访问设备树节点。
Figure 9. Methods to access a device tree node (source: zephyrproject.org)
图9. 访问设备树节点的方法(来源:zephyrproject.org)
以图9为例,多种宏都可以得到i2c@40002000节点(注意:将所有不是字母数字的字符替换为下划线):
DT_PATH(soc, i2c_40002000):将全路径以逗号隔开,省略所有“/”。
DT_NODELABEL(i2c1):使用标签名。
DT_ALIAS(sensor_controller):使用别名。
DT_INST(x, vnd_soc_i2c):寻找第x个兼容名为“vnd,soc-i2c”的节点。在本例中因为只有一个节点,x应为0。在多“vnd, soc-i2c”节点的情况下,x和设备树中节点的对应关系不能保证。
对于chosen节点(图9中不包括),使用DT_CHOSEN指定节点,例如针对图6中的设备树可以使用“DT_CHOSEN(zephyr_console)”。
注意:上述宏不能用于变量,只能用于宏定义。
DT_NODE_HAS_PROP宏可以用于检测节点是否有特定属性,例如 “DT_NODE_HAS_PROP(DT_NODELABEL(i2c1), clock_frequency)”的值为1。访问节点的属性时使用DT_PROP宏,例如“DT_PROP(DT_PATH(soc, i2c_40002000), clock_frequency)”的值为100000。DT_PROP的值可以用于变量初始化或是静态定义。
Zephyr定义了众多与设备树相关的宏,在官方文档中有分类总结。在开发中请根据需要查阅文档,并参考Zephyr丰富的开发板/传感器样例库。
2.3. Kconfig配置工具
Kconfig是在构建时配置Zephyr内核和子系统的主要方式,Kconfig也是Linux内核的配置系统。Zephyr中的Kconfig配置选项按照文件夹的层级结构分布,从Zephyr代码库根目录的Kconfig.zephyr文件开始。根Kconfig文件用包含(include)语句包括了子系统(例如内核、驱动和代码库)的Kconfig文件,子系统还可以进一步深入定义更深层的Kconfig结构和选项。
开发板和应用可以指定需要启用的配置。BBC micro:bit V2板的默认选项位于文件boards/bbc/microbit_v2/bbc_microbit_v2_defconfig中,包括系统时钟、串口和控制台等选项。每个应用中的prj.conf则包含了应用所需的选项。
与Linux类似,在Zephyr中可以通过命令行界面进行Kconfig选项配置[13]。针对应用构建后产生的build文件夹运行命令“west build --build-dir ./build -t menuconfig”即可进入命令行界面(见图10)。
Figure 10. Kconfig menuconfig interface
图10. Kconfig配置命令行界面
在界面中可以通过方向键和ESC/空格键进行导航,在选项上通过空格键进行选择。修改选项后D键保存最小配置到文件,也就是当前界面中定义的Kconfig选项和Zephyr定义的开发板默认选项的区别。图11所示的是micro:bit V2 LED矩阵显示样例的输出结果(第3.1节会使用这一样例):
Figure 11. Kconfig minimum config output
图11. Kconfig最小配置输出
对比前面提到的开发板默认Kconfig选项和应用添加的选项(samples/boards/bbc/microbit/display/prj.conf),可以看到只有“CONFIG_NRFX_GPIOTE_NUM_OF_EVT_HANDLERS”选项是上述两个文件中没有包括的,这是因为北欧半导体的HAL层自动定义了这一选项(modules/hal_nordic/nrfx/nrfx_kconfig.h)。除了这样的例外情况,一般在命令行界面中选中了新的选项,用最小选项输出就可以帮助确定新的选项名,之后就可以将其加入到prj.conf文件中,从而在编译过程中包括这一选项。
Kconfig选项除了用于开启子系统功能之外,也用于配置驱动、应用代码,以及下一章将要讲解的日志系统。在代码中可以用“CONFIG_<Kconfig配置名>”宏取得配置值,构建用的CMakeLists.txt文件也可以用“CONFIG_<Kconfig配置名>”读取配置值。
3. 基于Zephyr的嵌入式应用开发
本章中,我们将结合样例在Zephyr上实践嵌入式应用开发,帮助理解上一章中的理论。
3.1. 环境配置和运行第一个程序
首先,跟随Zephyr项目入门指南(https://docs.zephyrproject.org/latest/develop/getting_started/index.html)完成环境配置、Zephyr和Zephyr SDK的安装。总体来说,Zephyr在Linux中的安装和配置步骤最为简洁,推荐在Ubuntu Linux上进行Zephyr的实验和开发。本章提及的命令和环境细节均以在Ubuntu 24.04版本上使用Zephyr 4.0.99开发版本为准,运行时使用BBC micro:bit V2开发板(见图12)。
Figure 12. micro:bit V2 board (source: microbit.org [14])
图12. micro:bit V2板(来源:microbit.org [14])
Zephyr的样例库中包括众多开发板和传感器的样例,不过指南中提到的闪灯样例(Blinky,路径samples/basic/blinky)并不能直接套用在micro:bit V2上。此处我们采用micro:bit V2的LED矩阵显示样例(路径samples/boards/bbc/microbit/display)。连接开发板到Ubuntu系统上,运行图13中的命令进行编译和烧录。命令中的“-p”选项意味着进行全新编译,当对工程进行重复编译时使用“-p auto”选项允许west工具只对更改的部分进行重新编译,这适合在开发迭代时节约时间。
Figure 13. Commands to compile and flash the sample onto the micro:bit V2 board
图13. 针对micro:bit V2板编译和烧录的命令
成功后开发板会自动启动Zephyr,开发板背后(见图12,有BBC micro:bit v2字样的面为正面) 5乘5的LED矩阵会显示数字倒计时9到0,然后是LED的逐行逐列“行军”,最后开始持续滚动显示“Hello Zephyr!”的字样。
该实例展示了较为复杂的单组件运作,从主函数(samples/boards/bbc/microbit/display/src/main.c)可以看到样例通过一个针对micro:bit板专用的中间层(drivers/display/mb_display.c)对泛用的显示驱动(头文件zephyr/drivers/display.h)进行扩充,实现了大多数的功能,例如初始化、打印数字或字母,以及按照0/1矩阵点亮LED等。
3.2. 闪灯样例和设备树问题
上一节提到,Zephyr的闪灯样例在micro:bit V2上不能运行,本节让我们了解其背后的理由和如何修复与设备树相关的问题。
运行图14所示的命令尝试编译闪灯样例:
Figure 14. Commands to compile the blinky sample
图14. 编译闪灯样例的命令
运行的结果是图15所示的编译错误:
Figure 15. Compile error of the blinky sample
图15. 闪灯样例的编译错误
第2.2.4节中提到,Zephyr提供一整套设备树宏,本例中GPIO代码使用的DT_ALIAS宏不能完全展开。Zephyr中设备树宏错误的原因一般都与编译错误中提到的头文件无关,而是设备树有格式/内容的错误,或者访问设备树的方式有误。几种常见的错误如下:
混淆了选择、别名、标签名和节点名,或者输入了错误的字符串(例如没有将非字母数字的字符转换为下划线)。
在硬件特定的宏中(例如图15的GPIO_DT_SPEC_GET需要指向一个GPIO phandle节点)使用了不同硬件的节点。
设备树的节点和绑定的格式要求不一致,导致节点未能生成正确的头文件,因此应用或者驱动中的宏无法展开。注意:这和简单的设备树语法错误不同,语法问题在编译设备树时就会导致编译失败,内容的问题则可能导致在应用代码中无法使用特定属性或宏。
使用了错误的宏组合或者宏的参数错误,特别是For-Each循环宏和硬件特定的宏。
打开micro:bit V2的设备树文件(boards/bbc/microbit_v2/bbc_microbit_v2.dts),可以看到aliases节点下没有led0,缺少led0别名导致了编译的失败[15]。
micro:bit V2的LED矩阵由十个GPIO输出控制,个别改变一个控制引脚(pin)并不能点亮LED。红色的电源指示灯和黄色的USB指示灯也并没有连接到GPIO上,因此只是依靠开发板本身,我们并不能通过扩展设备树简单地修改好闪灯样例。不过,micro:bit V2可以外接LED,将外接LED的GPIO添加到设备树中就可以修复闪灯样例。
添加设备树覆盖文件samples/basic/blinky/boards/bbc_microbit_v2.overlay修复编译错误[16],见图16:
Figure 16. The device tree overlay file to fix the compilation error
图16. 修复编译错误的设备树覆盖文件
可以看到文件增加了一个兼容名为gpio-leds的节点leds,然后为含有GPIO信息的led_0子节点增加别名led0。gpio-leds的驱动(drivers/led/led_gpio.c)提供了开关和设定亮度的接口,不过在闪灯样例中,代码(samples/basic/blinky/src/main.c)只是通过GPIO_DT_SPEC_GET宏从设备树取得了GPIO引脚的信息,然后直接使用gpio_pin_toggle_dt切换GPIO输出状态。
对比主设备树文件的edge_connector (边缘连接器)节点和开发板的引脚图[17]可以看到,图16中gpio0接入点引脚4对应P2引脚(开发板下侧标记2的金手指),运行时如果有连接外接LED,闪灯样例就能够运行。
类似的设备树覆盖文件方法,只要正确地修改GPIO接入点和引脚号,也可以让没有led0别名的开发板支持闪灯样例。
3.3. 样例应用和详解
本节将使用基于官方样例[18]改编的样例应用(https://github.com/lingyuan-he/zephyr-example)。除了主程序代码还包括:
一个简单的自定义代码库(accel):从3-轴加速度传感器取得加速度数值,该库可以通过Kconfig启用或禁用。
一个简单的自定义LED矩阵驱动层(ledmatrix):不使用Zephyr的显示驱动,手动通过GPIO点亮单个行或列的LED,该驱动层可以通过Kconfig启用/禁用和配置。
设备树覆盖文件:用于辅助自定义代码库和LED矩阵驱动层,并展示简单的设备树功能。
驱动、代码库和主函数各自配置了日志模块,可以通过Kconfig配置日志级别。
3.3.1. 3-轴加速度传感器的代码调用
从主设备树文件上可以看到,micro:bit V2上内建了ST的lsm303agr 3-轴加速度传感器(见图17)。在样例应用中,custom-module/lib/accel/accel.c源代码和custom-module/include/app/lib/accel.h头文件将寻找传感器设备和从传感器设备取得3-轴加速度值的功能包装到了一个简单的自定义库accel中。
Figure 17. micro:bit V2 device tree file snippet (source: Zephyr on GitHub)
图17. micro:bit V2设备树文件片段(来源:Zephyr GitHub代码库)
accel库代码中,寻找传感器设备的get_accel_device函数通过别名accel寻找设备树中的加速度传感器设备,这一别名在micro:bit V2的主设备树文件中并不存在(其中只有accel0),而是由样例应用设备树覆盖文件(app/boards/bbc_microbit_v2.overlay)提供的。其中增加了accel别名,指向标签为lsm303agr_accel的节点。
设备树覆盖文件能在开发板的主设备树文件上进行增添和修改,它的几项用途如下[19]:
增加别名(本例的accel)或者选择。
覆写已有节点的属性值,例如更改串口的数据速率。
删除节点的一个属性。
增加子节点,例如总线上新的子设备。
回到accel.c代码中,get_accel_values函数用于获取3-轴加速度值,其中sensor_sample_fetch和sensor_channel_get函数调用完成了样本刷新和取样本值的功能。了解它们是如何针对特定的传感器完成代码调用的,能够帮助我们更加深入地理解Zephyr的设备驱动模型(第2.1节)。
sensor_sample_fetch和sensor_channel_get函数均为泛用传感器驱动API,从Zephyr代码库头文件include/zephyr/drivers/sensor.h可以看到两个函数会分别调用设备驱动API sample_fetch和channel_get函数。设备树中设备的兼容名决定了适配的驱动程序。在设备树文件中,传感器的兼容名有两个:“st,lis2dh”和“st,lsm303agr-accel”。驱动的适配顺序是先查找第一个兼容名,在Zephyr代码中搜索st_lis2dh (非字母数字的字符替代为下划线),可以找到drivers/sensor/st/lis2dh/lis2dh.c文件包含定义驱动的语句“#define DT_DRV_COMPAT st_lis2dh”。图18所示的是该驱动的驱动API结构定义:
Figure 18. lis2dh device driver API definition (source: Zephyr on GitHub)
图18. lis2dh设备驱动的API定义(来源:Zephyr GitHub代码库)
可以看到该驱动将lis2dh_sample_fetch和list2dh_channel_get函数的指针指定为设备sample_fetch和channel_get API的实现。lis2dh驱动支持I2C和SPI总线,在主设备树文件中可以看到,micro:bit V2中的传感器是在i2c总线上的。图19所示的是lis2dh驱动的部分初始化代码:
Figure 19. lis2dh device driver initialization code (source: Zephyr on GitHub)
图19. lis2dh设备驱动初始化代码(来源:Zephyr GitHub代码库)
代码通过DT_INST_FOREACH_STATUS_OKAY宏,对每一个状态为okay的兼容设备扩展LIS2DH_DEFINE宏,后者会通过DT_INST_ON_BUS判断设备是否在spi总线上,如果是,就进一步扩展LIS2DH_DEFINE_SPI初始化驱动,否则会扩展LIS2DH_DEFINE_I2C宏(micro:bit V2的情况)。那么,设备树是如何让DT_INST_ON_BUS能够进行判定的呢?
micro:bit V2设备树中传感器所在的i2c节点兼容名为“nordic,nrf-twim”,从其绑定文件dts/bindings/i2c/nordic,nrf-twim.yaml中可以看到,文件包含(include)了nordic,nrf-twi-common.yaml (同文件夹下),然后该文件又进一步包含了i2c-controller.yaml,在这一文件中终于看到了“bus: i2c”的信息。也就是说,从设备树绑定可以得知传感器从属于使用i2c总线的控制器。
由于lis2dh驱动能够被正确地配置,系统不会查找兼容“st,lsm303agr-accel”的驱动。在运行时,accel代码库中的sensor_sample_fetch和sensor_channel_get函数会调用st_lis2dh驱动的函数。
在Zephyr的在线文档中,通过兼容名可以找到设备树绑定的参考页面,例如本例中的驱动文档标题为“st,lis2dh (on i2c bus)”。
3.3.2. 设备树绑定和自定义驱动
在样例应用中,自定义的ledmatrix驱动(custom-module/drivers/ledmatrix/ledmatrix.c)使用GPIO在LED矩阵上实现了简单点亮矩阵边缘一排或一行5枚LED的功能。在前一节中提到,驱动需要匹配到设备树的设备节点上。本例中我们创建了自定义的“custom-ledmatrix”兼容名和其绑定,以及ledmatrix驱动实现。
图20和图21所示的分别是custom-ledmatrix设备树绑定文件(custom-module/dts/bindings/custom-ledmatrix.yaml)和micro:bit V2设备树覆盖文件中的对应节点:
Figure 20. The device tree binding file for custom-ledmatrix
图20. custom-ledmatrix设备树绑定文件
Figure 21. The custom-ledmatrix device tree node
图21. cutstom-ledmatrix设备树节点
从图20中可以看到,custom-ledmatrix绑定中有两个GPIO引脚phandle数组,分别代表LED矩阵的行GPIO引脚(推挽)和列GPIO引脚(开漏) [20]。在图21中,注意到GPIO接入点、引脚号和逻辑电平模式与开发板主设备树文件中“led_matrix”节点(兼容名“nordic,nrf-led-matrix”)是一致的[21]。样例中我们使用GPIO在不使用动态刷新的情况下进行亮、灭灯,所以不需要其他的属性。
需要特别注意的是,为了表示phandle每个说明符(specifier)成员的长度(例如GPIO除了接入点之外需要提供两个数据成员),在绑定中一般应提供名称为“#*-cells”的属性。不过由于GPIO类phandle十分常见,只要属性的命名以“-gpios”结尾,如本例中的led-row-gpios和led-col-gpios,就不需要提供这一属性。关于“#*-cells”属性的细节详见官方文档。
从图21中还可以看到设定设备状态就绪的语句(status为“okay”),节点能够使用该属性是因为绑定文件包含了base.yaml。上一节中提到,标记设备就绪对于驱动的初始化是必须的,例如图19中用到的DT_INST_FOREACH_STATUS_OKAY宏。
自定义驱动的头文件定义见custom-module/include/app/drivers/ledmatrix.h,可以看到驱动API由5个函数组成(见ledmatrix_driver_api结构定义),分别负责点亮LED矩阵最边缘的行或是列(共4个API)和关闭LED显示(第5个API)。在驱动的实现(custom-module/drivers/ledmatrix/ledmatrix.c)中,这5个函数会被实现(见driver_api结构) [22]。现在读者应该能够理解ledmatrix驱动的基本结构。最后,图22所示的是驱动的初始化宏:
Figure 22. The initialization of the ledmatrix driver
图22. ledmatrix驱动的初始化
LEDMATRIX_DEFINE中使用GPIO_DT_SPEC_GET_BY_IDX配合DT_INST_FOREACH_PROP_ELEM_SEP,从设备树循环提取GPIO引脚phandle数组中的成员,从而静态组成gpio_dt_spec数组[23],用于在设备驱动配置结构(见图23)中存储行和列GPIO引脚属性[24]。
Figure 23. The configuration structure of the ledmatrix driver
图23. ledmatrix驱动的配置结构
和上一节提到的传感器驱动类似,DT_INST_FOREACH_STATUS_OKAY针对每个状态为就绪的、兼容名为“custom-ledmatrix”的设备进行驱动初始化。
通过ledmatrix驱动层,样例应用的主函数就可以很容易地直接进行LED行或是列的点亮操作。配合加速度传感器的数据,样例应用实现了根据重力方向点亮LED矩阵对应边缘行或列的效果。
3.3.3. 日志系统
Zephyr提供了日志系统的支持,应用代码、驱动、代码库可以注册各自的日志模块,并通过Kconfig配置模块的日志级别。日志的可能级别从低到高分别为:DBG (调试)、INF (信息)、WRN (警告)和ERR (错误)。代码中通过调用LOG_X (X为级别)宏就可以使用与printk类似的语法写日志。以样例应用中的accel代码库为例,custom-module/lib/accel/accel.c中包含了zephyr/logging/log.h头文件,然后使用LOG_MODULE_REGISTER宏定义了日志模块accel,其日志级别为CONFIG_ACCELLIB_LOG_LEVEL。
在Kconfig中,CONFIG_LOG配置用于在全局启用日志,然后通过添加CONFIG_<模块>_LOG_LEVEL_X (X为级别)配置设定个别模块的级别。本例中应用的配置文件app/prj.conf通过CONFIG_LOG=y在全局开启了日志功能,然后通过CONFIG_ACCELLIB_LOG_LEVEL_INF=y选项将accel模块的日志级别定义为INF(信息)级别。ledmatrix驱动和应用主代码各自也有日志模块的配置。
使用日志系统相比使用printk更加可配置,例如只有调试时才需要的日志可以通过默认日志级别进行过滤,发布应用时也可以很容易地禁用日志输出。
4. 运行和调试Zephyr应用
4.1. 运行样例应用
编译和部署样例应用的命令如图24所示:
Figure 24. Commands to download and deploy the example application
图24. 下载和部署样例应用的命令
样例应用开始运行时,将开发板平放于台面上,此时LED矩阵不会点亮,如果将开发板拿起,一侧垂直朝向地面时,检测到重力一侧的一排或一列5个LED会点亮。例如,当开发板垂直于台面正面并面向读者时,LED矩阵最下一行会点亮(见图25)。
Figure 25. Illumination of the bottom row LEDs when the board is upright
图25. 开发板垂直摆放时,最下一排的LED点亮
4.2. Zephyr应用的调试
在Zephyr应用开发中,最简单的调试方法就是输出日志。micro:bit V2运行样例应用时会将日志输出到串口,可以通过任何串口工具连接串口,例如使用minicom的命令:“minicom -D /dev/ttyACM0 -b 115200”。
样例应用的默认日志级别为INF,编译时可以通过包括debug.conf的选项将日志级别降低为DBG,程序就会输出传感器数据和GPIO操作细节,命令为:“west build -b bbc_microbit_v2 app -p --extra-conf debug.conf”。
Zephyr支持在micro:bit V2上使用GDB进行远程调试,应用编译和烧录(“west build”和“west flash”)后,运行“west debug”就会启动GDB。GDB简单的用法例如:设置断点(“b main.c:<行数>”或“b <函数名>”)、逐行执行(n)、继续执行(c)和打印变量(“p <变量名>”)。“west debug”命令还可以指定GDB以外的调试接口[25],例如jlink和openocd。
5. 结语
Zephyr在系统设计上借鉴了Linux等大型开源软件的设计理念,引入了Linux和桌面系统开发者熟悉的概念和开发过程,但相对常见的RTOS,复杂度增加了数个级别。通过将硬件进行抽象化,以及提供帮助简化开发过程的工具和框架(例如west工具和twister测试框架),Zephyr希望能吸引不同领域的开发者和企业用户。但是,开发和调试难度的上升也让不少开发者望而却步,特别是熟悉面向硬件直接编程或是使用小型RTOS的嵌入式开发者。
希望在阅读本文后,读者对在Zephyr上进行嵌入式软件开发有了初步的了解。本文中的实例并不涉及过于具体的硬件细节或是复杂的应用需求,Zephyr的官方文档、实例,以及北欧半导体等硬件厂商的样例项目都十分有参考价值。虽然官方文档的中文化有所欠缺,但国内开发者在各类平台上发布的学习笔记一直在增加,线上讨论也十分热烈。
Zephyr近年来劲头强势,硬件厂商、开发者和开源社区的热情正盛,项目的开发活跃程度远超其他RTOS。期待Zephyr项目在未来能够简化复杂的系统架构,改善学习难度高和代码调试困难等问题,并覆盖更多的硬件和应用,成为一个全方位的主流物联网操作系统。