理解Zephyr编译与配置系统
1. 前言
Zephyr Project是Linux基金会推出的一个Apache2.0开源项目,版权非常友好,适合用于商业项目开发。包含RTOS、编译系统、各类第三方库。NCS中的例程基本都跑在Zephyr RTOS上。
对于之前只接触过IDE+外设驱动库这种开发方式的开发者来说,Zephyr的配置和编译系统可能比较令人费解,但是一旦你能掌握,就会发现它的方便之处。
本文会以最容易理解的方式讲解 Zephyr 的构建系统(Build System)。并列出一些例子。
2. 通过CMake管理源码
本节讲解源码如何管理,不讲CMake的细节。
CMake基本写法
通过zephyr/samples/hello_world
例程的CMakeLists.txt
,我们可以看到:
# SPDX-License-Identifier: Apache-2.0
# 指定CMake版本
cmake_minimum_required(VERSION 3.20.0)
# 从环境变量${ZEPHYR_BASE}找到NCS中的Zephyr安装目录
# 并把整个Zephyr系统当作包来导入
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
# 设定项目名称
project(hello_world)
# 把main.c添加为app目标的源码
target_sources(app PRIVATE src/main.c)
这里的编译目标是app
,最终会编译为libapp.a
,也就是把用户自己的应用层代码编译成库的形式。最后再链接进Zephyr系统。
这里的PRIVATE
控制的是编译的行为:
PRIVATE
:main.c修改后,只会重新编译app目标PUBLIC
:main.c修改后,app目标要重新编译,且所有与APP目标链接的其他目标也要重新编译
条件添加源码
条件添加也很好理解,就是某个CMake变量值为true时,才把源码添加到目标中去。例如:
# Include UART ASYNC API adapter
target_sources_ifdef(CONFIG_BT_NUS_UART_ASYNC_ADAPTER app PRIVATE
src/uart_async_adapter.c
)
这里就是CONFIG_BT_NUS_UART_ASYNC_ADAPTER
为y
时,才添加src/uart_async_adapter.c
到源码中。
目录添加
有时目录层级很多,我们没必要在一个CMakeLists.txt里把所有源码都添加完。
|-CMakeLists.txt
|-aaa
| |-CMakeLists.txt
| `-main.c
`-bbb
|-CMakeLists.txt
`-hello.c
这时,就可以在项目根目录的CMakeLists.txt中写:
add_subdirectory(aaa)
add_subdirectory(bbb)
然后在两个子目录的CMakeLists.txt中添加对应的源码。
当然,目录也是可以条件添加的,最典型的就是在${NCS}/zephyr/driver/CMakeLists.txt
中:
add_subdirectory_ifdef(CONFIG_ADC adc)
也就是说,只有启用了CONFIG_ADC=y
,Zephyr才会去编译${NCS}/zephyr/driver/adc/
目录下的驱动。
此外,如果再去看${NCS}/zephyr/driver/adc/CMakeLists.txt
:
...
zephyr_library_sources_ifdef(CONFIG_ADC_MCUX_LPADC adc_mcux_lpadc.c)
zephyr_library_sources_ifdef(CONFIG_ADC_SAM_AFEC adc_sam_afec.c)
zephyr_library_sources_ifdef(CONFIG_ADC_NRFX_ADC adc_nrfx_adc.c)
zephyr_library_sources_ifdef(CONFIG_ADC_NRFX_SAADC adc_nrfx_saadc.c)
...
就可以看到,这里又根据不同的MCU平台,来添加对应的adc驱动代码。
添加include目录
也就是存放头文件的目录,如:
# 添加CMakeLists.txt所在目录下的inc/目录到app目标
target_include_directories(app PRIVATE inc)
# 也是可以条件添加的
zephyr_include_directories_ifdef(CONFIG_MEMFAULT configuration/memfault)
设置变量
和宏定义类似,把A定义成B。这里,主要是用来定义一些编译系统会用到的东西:
# 指定自己项目的device tree overlay文件(和VS Code中添加build target时,手动选择overlay是一样的)
set(DTC_OVERLAY_FILE app.oerlay)
这和命令行编译时,通过-D
选项传入的参数是一样的:
west build --build-dir /home/jayant/project/ncs-project/peripheral_uart/build \
/home/jayant/project/ncs-project/peripheral_uart \
--pristine \
--board nrf52840dk_nrf52840 \
--no-sysbuild \
-- \
-DNCS_TOOLCHAIN_VERSION:STRING="NONE" \
-DBOARD_ROOT:STRING="/home/jayant/project/ncs-project/peripheral_uart" \
-DCONF_FILE:STRING="/home/jayant/project/ncs-project/peripheral_uart/prj.conf" \
-DDTC_OVERLAY_FILE:STRING="/home/jayant/project/ncs-project/peripheral_uart/app.overlay"
总结
项目通过CMake管理源码和include目录。项目本身会把应用代码编译成build/app/libapp.a
,最后和Zephyr系统一起链接成可执行文件。
Zephyr系统本身的内核、库、驱动等源码也都是用CMake来管理的。
3. 通过Kconfig管理配置
一个编译系统中,肯定有很多配置项的需求,如:
- 前面所述的CMake条件添加源码的功能,实现内核的功能的裁减,按需添加源码
- 代码中的宏,通过宏来实现一些参数值的配置,或者进行条件编译
在Zephyr系统中,每个模块都会有自己的配置项;并且,开发者自己的项目也会有很多配置项;此外,有些配置项之间还有依赖关系。如此复杂的关系,该如何管理?
Kconfig就是把一个模块的所有配置项组成一个菜单。所有模块的菜单,通过层级关系拼接在一起,形成一个大菜单。
了解Kconfig菜单基本写法
可以先从一个简单的例子${NCS}/nrf/samples/bluetooth/peripheral_uart
来参考:
# 引用Zephyr的Kconfig菜单
source "Kconfig.zephyr"
# 自定义本项目的菜单
menu "Nordic UART BLE GATT service sample"
... 此处省略...
endmenu
菜单中的选项,可以配置它的类型、说明,和默认值:
# 此选项用来设置Nordic UART Service线程的栈大小
# 并且具有默认值
config BT_NUS_THREAD_STACK_SIZE
int "Thread stack size"
default 1024
help
Stack size used in each of the two threads
菜单中的选项可以连锁使能:
# 当本选项被设置成y时,通过select,同时把CONFIG_BT_SMP的值设置成y
config BT_NUS_SECURITY_ENABLED
bool "Enable security"
default y
select BT_SMP
help
"Enable BLE security for the UART service"
此外,一个选项也可以指定一个依赖项。如果本选项被启用,但依赖项未被启用,则编译前的配置过程就会报错:
# 配置是否在系统启动时,自动初始化USB ACM设备 (用于输出日志)
# 此配置依赖于CONFIG_USB_CDC_ACM=y,也就是说,起码要把USB_CDC_ACM的代码编译进来
config USB_DEVICE_INITIALIZE_AT_BOOT
bool "Initialize USB device support at boot"
depends on USB_CDC_ACM
help
Use CDC ACM UART as backend for console, shell, or logging.
当然,Kconfig也不是说要写的非常大,把整个项目的配置都写进去。你也可以每个子文件夹下单独写Kconfig,然后在项目的Kconfig中进行包含:
# 通过绝对路径进行包含
source "xxx.Kconfig"
# 通过相对路径进行包含
rsource "src/xxx.Kconfig"
某些简单例程,例如
zephyr/samples/hello_world
,没有什么配置项,所以是可以没有自己的Kconfig的。这种情况下,相当于直接用了Zephyr的Kconfig菜单,也就是相当于:source "Kconfig.zephyr"
Kconfig交互式菜单
我们知道,Kconfig实际上是定义了一个菜单,在哪里能看到这个菜单呢?
我们可以在VS Code中点击nRF Kconfig GUI:
也可以把鼠标悬浮在这个按钮上,点右边的三个点,然后用Guiconfig(弹窗)或Menuconfig(命令行)的方式进行配置。
这里就只介绍nRF Kconfig GUI:
可以看到,菜单的顺序,与我们在Kconfig中编写的顺序是一致的。前面是source "Kconfig.zephyr"
添加的Zephyr菜单;后面是menu "Nordic UART BLE GATT service sample"
定义的本例程的菜单。
保存Kconfig交互式菜单的修改
如果我们只是单纯点击界面右上角的"Apply",那么这些配置是保存在build/zephyr/.config
中的。这是编译过程中生成的一个临时文件,是把各种配置项来源整合到一起,得到的最终配置文件。
如果我们进行pristine build,那么.config
文件就会重新生成,我们之前的修改就消失了。
要想永久保存,应该点击“save to file”。然后保存到prj.conf
中。
Kconfig配置项的来源
前面提到,.config
是把各种来源的配置项整合到一起。那么,配置项总共有哪些来源呢?
- Kconfig菜单中的默认值
- 选择板子后,板子自带的一些config。可以在
zephyr/boards
或者nrf/boards
中查看。 - CMake变量
CONF_FILE
指定的配置文件,这也是最常用的。默认情况下是以下两个文件:- 项目的
prj.conf
,它可以覆盖默认值; - 项目的
boards/<board_name>.conf
,当编译目标中选择的板子和这里的board_name一致时,可以覆盖默认值。此配置和前一项会合并。
- 项目的
- CMake变量
OVERLAY_CONFIG
指定的额外配置文件,也就是在VS Code中创建新的build target时,可以选择的"Kconfig fragments"
显性与隐性配置项
在Kconfig中定义菜单选项时,我们会发现,大多数选项,在变量类型后面会有一个说明字符串(prompt):
config FPU
bool "Support floating point operations"
depends on HAS_FPU
这意味着,这个配置项会出现在Kconfig交互式菜单中,我们可以在交互式菜单中修改它的值:
[ ] Support floating point operations
也可以用prj.conf
之类的配置文件来直接改它的值:
CONFIG_FPU=y
但是,也有一些隐性配置项,它们的变量类型后面不带说明字符串,我们无法直接修改它的值:
config CPU_HAS_FPU
bool
help
This symbol is y if the CPU has a hardware floating point unit.
一个CPU到底带不带FPU,肯定不由开发者的配置决定,因此不能直接修改是很合理的。
这种配置,通常是通过连锁使能select的方式,被其他配置项使能的,例如zephyr/soc/arm/nordic_nrf/nrf52/Kconfig.soc
:
# 隐性配置项
config SOC_NRF52840
bool
select CPU_CORTEX_M_HAS_DWT
select CPU_HAS_FPU
...
# 显性配置项
config SOC_NRF52840_QIAA
bool "NRF52840_QIAA"
select SOC_NRF52840
而这个SOC_NRF52840_QIAA
,是我们选择板子时,52840DK的板子自带的默认配置,来自于zephyr/samples/application_development/out_of_tree_board/boards/arm/nrf52840dk_nrf52840/nrf52840dk_nrf52840_defconfig
:
CONFIG_SOC_NRF52840_QIAA=y
总结
Zephyr的配置系统是Kconfig定义的菜单。可以用prj.conf
之类的文件来修改配置项的值。
Kconfig中的配置项,可以影响CMake中的条件,选择是否添加哪些源码,从而剪裁内核。
Kconfig中的配置项,最终会生成到build/zephyr/include/generated/autoconf.h
中,成为源代码中也可以用到的宏。
不要去尝试修改隐性的Kconfig配置项。
4. DeviceTree和Zephyr驱动模型
device tree比较复杂,具体的语法、使用方法可以参考我的另一篇文章:《详解Zephyr设备树(DeviceTree)与驱动模型》。
本文中尽量简洁地说明device tree的用途。
DTS文件
device tree的文件是Device Tree Source (DTS)。这里用最简洁的语言描述一下dts文件的产生:
- 芯片级的dts文件,定义了芯片上的各种外设资源及其地址;
- 板级的dts文件,可以包含芯片级的dts文件。板级的dts文件中,也会包含板子上的资源,如按键、LED、i2c外设等等;
- 在工程中选板子时,实际上就是选择了板级的dts文件。在工程中,如果想修改默认的dts,是通过
*.overlay
文件进行覆盖; - 编译时,所有这些dts会合并成
build/zephyr.dts
。这就是最终的dts。
overlay文件的逻辑和Kconfig的逻辑类似:
app.overlay
是整个项目的overlayboards/<board_name>.overlay
是板子对应的overlay
Zephyr驱动程序
Zephyr到底是怎么实现,不同的MCU平台共用同一套外设API的?
其实,Zephyr的外设API,如果我们去查看其定义,其实都没做什么有意义的操作:
static inline int z_impl_uart_tx(const struct device *dev, const uint8_t *buf,
size_t len, int32_t timeout)
{
const struct uart_driver_api *api =
(const struct uart_driver_api *)dev->api;
// 其本质上是调用了device结构体中,api这个成员的函数
return api->tx(dev, buf, len, timeout);
}
device结构体的定义:
struct device { const char *name; // 设备的名称 const void *config; // 设备的初始配置 const void *api; // 设备的api函数集合 struct device_state *state; // 设备的工作状态 void *data; // 设备的运行数据 /* ... */ // 其他参数,例如电源管理 };
实际上,在main()
函数运行起来之前,设备驱动的初始化程序就已经先行运行了,有以下5个阶段可以用来初始化外设驱动:
Zephyr外设驱动的整个流程:
-
开发者在Kconfig中,使能了某个外设驱动,如
CONFIG_ADC=y
-
zephyr/driver/
下的CMakeLists.txt,根据CONFIG_ADC=y
,把zephyr/driver/adc/
添加到工程中 -
zephyr/driver/adc/
下有各个半导体厂商向Zephyr提交的ADC驱动代码。此目录下的CMakeLists.txt根据Nordic平台,选择添加Nordic的nrfx adc驱动代码到Zephyr系统中进行编译zephyr_library_sources_ifdef(CONFIG_ADC_NRFX_ADC adc_nrfx_adc.c)
-
编译时,nrfx adc驱动代码中,会通过宏来搜索
zephyr.dts
中的所有ADC节点,看哪些节点的status="okay"
,就初始化那个外设,并且为这个外设定义一个device
结构体实例,并且初始化这个实例。api
就是这个时候赋值的。 -
在开发者的应用层代码中,先获得这个device结构体的指针。后续调用Zephyr标准外设API时,需要把这个指针作为参数传入。
# Zephyr有一套宏,可以从device tree中的节点,获得对应的device结构体指针 static const struct device *uart1_dev = DEVICE_DT_GET(DT_NODELABEL(uart1));
Zephyr DeviceTree和驱动模型的优劣
优点:
- 代码里调用的都是Zephyr标准API,与硬件细节无关。如果后需要更换MCU平台,几乎没有什么移植成本,只需要更换所选的board即可。
- 通用性强,无论是普通的串口,还是USB串口,抑或是LPUART,它们的应用层代码均是Zephyr标准API,只需要更换底层驱动即可。
- 开发者无需花精力在标准、通用的基本功能上,如串口、SPI、网络、按钮等。因为这些驱动都是厂商提供的,在性能、健壮性、功能性上往往都强于开发者自己用寄存器或外设驱动库开发的代码。
缺点:
-
上手难度稍高,需要花精力去学习语法,并且要简单了解驱动代码
-
功能不完全。Zephyr只提供最标准的用法,当用到串口、spi、i2c等协议时,就是最标准的协议。一旦有不符合标准的,或者Zephyr标准库未提供的功能,就无法在Zephyr驱动模型的框架下实现了。
例如,nordic的芯片有PPI的功能,可以让一个外设的event触发另一个外设的task。这个功能Zephyr是没有标准驱动的。
Nordic可以在提交给Zephyr的驱动代码中用PPI。例如,在串口驱动中,通过uart外设和timer外设,加上PPI,实现异步流控串口(Timer的作用是记录发送/接收了多少字节,然后用PPI控制GPIO CTS/RTS),Nordic提供的驱动代码,把他们整体封装成串口,也就是说,Zephyr标准驱动操作的串口,实际并不是单独对应uart这一个外设,而是UART+GPIOTE+TIMER+PPI的复合外设。
如果用户想自己用PPI实现一些自定义功能,只能直接调用nrfx api。
总结
dts怎么写,本质上取决于驱动代码里怎么读取dts。dts的本质就是保存硬件细节相关的信息,使自己的应用代码与硬件细节解耦。
要更详细地了解Device Tree,请参考《详解Zephyr设备树(DeviceTree)与驱动模型》。
5. 配置与编译过程
本节简要介绍整个编译过程,详细的过程可以参考:Zephyr官方文档
- 合并所有来源的device tree,生成
build/zephyr.dts
; - 合并所有来源的Kconfig配置项,其中Kconfig是可以读取device tree的,也就是说device tree中的选项会影响到Kconfig中的某些配置,通常是隐性配置;
- device tree的配置最终会生成:
build/zephyr/include/generated/devicetree_generated.h
Kconfig的配置最终会生成:build/zephyr/include/generated/autoconf.h
- CMake配置会生成ninja配置(这是一个类似于makefile的工具),然后根据ninja文件调用工具链中的gcc,把每个源文件编译并链接到一起。
如果是简单的,单分区的工程,最终会编译成build/zephyr/zephyr.hex
。
如果是多分区的工程,例如带bootloader的工程,最终会编译成build/zephyr/merged.hex
。
6. 其他配置
子镜像的配置
子镜像有两种情况:
- 一个flash上有多个分区。例如应用程序和bootloader,则bootloader是子镜像
- 双核固件。nRF5340是双核MCU,两个核运行的是不同的固件。运行蓝牙例程时,Application core运行用户的应用程序,Network Core运行的是
${NCS}/zephyr/samples/bluetooth/hci_rpmsg
。此时,hci_rpmsg
是子镜像。
一个工程只包含主镜像的源码,编译时会自动在build/<child_image_name>/
目录下同时编译子镜像,最终合并成build/zephyr/merged.hex
。
如何在工程中修改子镜像的Kconfig或者overlay?可以新建一个child_image/
目录,放入配置。
有两种方法:
child_image/<name>.conf
和child_image/<name>.overlay
,分别可以覆盖子镜像的prj.conf
和app.overlay
- 也可以建立
child_image/<name>/
文件夹,在文件夹中就像在子镜像的根目录中一样,写prj.conf
和boards/
文件夹
子镜像是怎么添加到工程中的?如何把默认的子镜像改成自己修改过的子镜像?请参考NCS原始文档 Multi-image builds
Shield配置
很多开发板都是支持Arduino接口的,因此很多器件厂商/分销商会制作Ardiono接口的扩展板:
Zephyr中,也会有这些扩展板的配置(包含device tree和Kconfig)。如果要在工程中启用扩展板,则需要设置CMake变量:
#这个配置必须写在find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})之前
set(SHIELD nrf21540_ek)
或者在编译目标的配置中添加CMake参数:
编译时,会自动合并原始板子和扩展板的Kconfig和Devicetree。