1. 前言

Zephyr Project是Linux基金会推出的一个Apache2.0开源项目,版权非常友好,适合用于商业项目开发。包含RTOS、编译系统、各类第三方库。NCS中的例程基本都跑在[Zephyr RTOS](Kernel — Zephyr Project Documentation)上。

对于之前只接触过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_ADAPTERy时,才添加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:

image-20231028192036882

也可以把鼠标悬浮在这个按钮上,点右边的三个点,然后用Guiconfig(弹窗)或Menuconfig(命令行)的方式进行配置。

image-20231028192059493

这里就只介绍nRF Kconfig GUI:

image-20231028192424706

可以看到,菜单的顺序,与我们在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是把各种来源的配置项整合到一起。那么,配置项总共有哪些来源呢?

  1. Kconfig菜单中的默认值
  2. 选择板子后,板子自带的一些config。可以在zephyr/boards或者nrf/boards中查看。
  3. CMake变量CONF_FILE指定的配置文件,这也是最常用的。默认情况下是以下两个文件:
    • 项目的prj.conf,它可以覆盖默认值;
    • 项目的boards/<board_name>.conf,当编译目标中选择的板子和这里的board_name一致时,可以覆盖默认值。此配置和前一项会合并。
  4. 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文件的产生:

  1. 芯片级的dts文件,定义了芯片上的各种外设资源及其地址;
  2. 板级的dts文件,可以包含芯片级的dts文件。板级的dts文件中,也会包含板子上的资源,如按键、LED、i2c外设等等;
  3. 在工程中选板子时,实际上就是选择了板级的dts文件。在工程中,如果想修改默认的dts,是通过*.overlay文件进行覆盖;
  4. 编译时,所有这些dts会合并成build/zephyr.dts。这就是最终的dts。

overlay文件的逻辑和Kconfig的逻辑类似:

  • app.overlay是整个项目的overlay
  • boards/<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个阶段可以用来初始化外设驱动:

image-20230312215847338

Zephyr外设驱动的整个流程:

  1. 开发者在Kconfig中,使能了某个外设驱动,如CONFIG_ADC=y

  2. zephyr/driver/下的CMakeLists.txt,根据CONFIG_ADC=y,把zephyr/driver/adc/添加到工程中

  3. zephyr/driver/adc/下有各个半导体厂商向Zephyr提交的ADC驱动代码。此目录下的CMakeLists.txt根据Nordic平台,选择添加Nordic的nrfx adc驱动代码到Zephyr系统中进行编译

    zephyr_library_sources_ifdef(CONFIG_ADC_NRFX_ADC    adc_nrfx_adc.c)
  4. 编译时,nrfx adc驱动代码中,会通过宏来搜索zephyr.dts中的所有ADC节点,看哪些节点的status="okay",就初始化那个外设,并且为这个外设定义一个device结构体实例,并且初始化这个实例。api就是这个时候赋值的。

  5. 在开发者的应用层代码中,先获得这个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官方文档

  1. 合并所有来源的device tree,生成build/zephyr.dts
  2. 合并所有来源的Kconfig配置项,其中Kconfig是可以读取device tree的,也就是说device tree中的选项会影响到Kconfig中的某些配置,通常是隐性配置;
  3. device tree的配置最终会生成:build/zephyr/include/generated/devicetree_generated.h
    Kconfig的配置最终会生成:build/zephyr/include/generated/autoconf.h
  4. CMake配置会生成ninja配置(这是一个类似于makefile的工具),然后根据ninja文件调用工具链中的gcc,把每个源文件编译并链接到一起。

如果是简单的,单分区的工程,最终会编译成build/zephyr/zephyr.hex

如果是多分区的工程,例如带bootloader的工程,最终会编译成build/zephyr/merged.hex

6. 其他配置

子镜像的配置

子镜像有两种情况:

  1. 一个flash上有多个分区。例如应用程序和bootloader,则bootloader是子镜像
  2. 双核固件。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/目录,放入配置。

image-20231028220754152

有两种方法:

  • child_image/<name>.confchild_image/<name>.overlay,分别可以覆盖子镜像的prj.confapp.overlay
  • 也可以建立child_image/<name>/文件夹,在文件夹中就像在子镜像的根目录中一样,写prj.confboards/文件夹

子镜像是怎么添加到工程中的?如何把默认的子镜像改成自己修改过的子镜像?请参考NCS原始文档 Multi-image builds

Shield配置

很多开发板都是支持Arduino接口的,因此很多器件厂商/分销商会制作Ardiono接口的扩展板:

image-20231028214708544

Zephyr中,也会有这些扩展板的配置(包含device tree和Kconfig)。如果要在工程中启用扩展板,则需要设置CMake变量:

set(SHIELD nrf21540_ek)

或者在编译目标的配置中添加CMake参数:

image-20231028215131762

编译时,会自动合并原始板子和扩展板的Kconfig和Devicetree。