开发你的第一个nRF Connect SDK(NCS)/Zephyr应用程序

############ 本文更新于2026年2月26日,对齐nRF54L15 DK和nRF Connect SDK v3.2.3 ############

Nordic有2套并存的SDK:老的nRF5 SDK和新的nRF Connect SDK(简称NCS),两套SDK相互独立,大家选择其中一套进行开发即可。一般而言,如果你选择的芯片是nRF51或者nRF52系列,那么推荐使用nRF5 SDK。如果你选择的是Nordic最新产品系列,比如nRF54,nRF53,nRF70或者nRF91系列,那么请选择nRF Connect SDK。还有一种特殊情况,虽然你选择的是nRF52芯片,但需要使用最新的技术或者特性,比如蓝牙测向,蓝牙Mesh,蓝牙低功耗音频,PAWR,Matter,FindMy,那么也需要使用nRF Connect SDK,换句话说,最新的技术或者特性,Nordic都只会在NCS上进行开发,而nRF5 SDK将进入维护阶段不再增加新的特性(如发现bug,会对其进行修复的)。关于nRF5 SDK的介绍,请参考这篇博文之前的博文,基本上都是基于nRF5 SDK进行阐述的,尤其是这篇:https://www.cnblogs.com/iini/p/9043565.html,详细讲解了nRF5 SDK开发环境的搭建,这里不再赘述。下面将只对nRF Connect SDK进行阐述,以期让大家快速了解这个Nordic最新SDK,并尽快熟悉和上手。 

1. nRF Connect SDK介绍

nRF Connect SDK,简称NCS,是Nordic最新的SDK平台,该平台将支持Nordic所有产品线,包括蓝牙低功耗,蜂窝网,Wi-Fi,GPS,2.4G,蓝牙Mesh,Zigbee,Thread,Matter, Homekit, FindMy,ANT+,DECT NR+,PMIC等,换句话说,由于短距离无线网络和长距离无线网络共用同一个SDK,将使得你同时具备两种网络的开发经验,因为他们的框架是一样的,驱动是一样的,网络协议栈的编写风格也是相仿的。只要你熟悉了其中一种网络的开发,那么相关的经验可以迅速复制到新网络平台上。

nRF Connect SDK内嵌Zephyr RTOS,并沿用了Zephyr project的编译系统。Zephyr Project是Linux基金会推出的一个Apache2.0开源项目,版权非常友好,适合用于商业项目开发。Zephyr Project是一个合作社区,其产品就是Zephyr,具体包括Zephyr RTOS,Zephyr组件以及Zephyr编译系统等。Zephyr很多地方都模拟了Linux,比如使用了DeviceTree和Kconfig,对Linux很熟的同学,阅读Zephyr代码会感到很亲切的。经常有人问:为什么NCS要使用Zephyr RTOS?其实答案就蕴含在Zephyr Project的愿景中:The Zephyr™ Project strives to deliver the best-in-class RTOS for connected resource-constrained devices, built be secure and safe。Zephyr的愿景跟Nordic的产品策略高度吻合,这也是为什么Nordic要选Zephyr的主要原因。NCS SDK和Zephyr Project两者最大的区别有3个:一是owner不同,NCS SDK由Nordic负责,Zephyr SDK由Linux基金会负责。NCS开发中碰到的所有问题,Nordic都将负责解决。二是产品管理不一样,NCS SDK将由Nordic完成所有相关测试和考核,并符合Nordic产品开发,测试,发布和质量控制流程,因此NCS有自己的版本,并跟Zephyr版本控制解耦。三是NCS具有很多增强特性,比如Nordic特有的蓝牙链路层等。所以,从产品角度看,NCS SDK和Zephyr SDK是两套完全不同的SDK。但是,如果从代码角度看,那么NCS SDK和Zephyr SDK又基本上是一样的。在本文章的下面部分,在不引起混淆的情况下,经常会把NCS和Zephyr两个概念换着使用,因为本质上他们是一个东西。

NCS SDK可以同时在Windows,macOS和Linux上运行。NCS使用Kconfig和Device Tree而不是传统的头文件来配置项目代码,使用CMake和Ninja编译系统,并使用了大量Python脚本以辅助生成一些头文件,代码和hex,这些都大大增加开发的可移植性和便利性。

image 

NCS SDK放在GitHub上,为了照顾中国开发者,NCS同时也放在Nordic中国镜像服务器上,里面包含多个仓库,其中主仓库(manifest)是nrf,同时还包含zephyr,mcuboot,nrfxlib,matter,hostap等其他仓库。

image 

由于Zephyr和NCS的build系统是一样的,如果你正在学习Zephyr RTOS,那么也可以参考本篇文章,从NCS SDK开始学习Zephyr。由于NCS SDK新增了很多特性,比如图形化的DEBUG,丰富的例程,你会发现从NCS SDK学习Zephyr是一条便捷通道。

2. nRF Connect SDK开发环境安装和搭建

下面所有安装强烈建议使用默认安装目录,不要去更改安装位置,而且安装目录不要有中文字符和空格

2.1 前置安装

在我们开始正式的开发环境配置之前,我们先需要下载和安装如下工具,如下两项为强制安装项

如下为可选安装项,不强制要求,对于初学者,请直接跳过;对于已经熟悉nRF Connect SDK的开发者来说,下面工具对开发还是有帮助的:

  • nRF Util: https://www.nordicsemi.com/Products/Development-tools/nRF-Util/Download?lang=en#infotabs,这是Nordic最新的命令行工具,支持的功能非常多,包括SDK安装,设备代码烧写擦除,蓝牙OTA等。如果你想通过命令行安装和开发nRF Connect SDK,那么nRF Util这个工具也是必须安装的。大家可以直接下载上面链接的exe文件,然后将其放在环境变量中即可完成安装,如下: 
 nrfutil只是一个命令容器,装好nrfutil后,我们还需要具体安装开发环境必须的nrfutil命令,比如device命令(用来烧写和擦除设备代码)。打开cmd,输入:nrfutil install device,执行成功后的日志是:

 除了device命令,还有很多其他命令,大家可以通过help命令查看。另外,nrfutil也可以通过Python的pip方式安装,这里就不再赘述。 

  • nRF command line tools:https://www.nordicsemi.com/Software-and-Tools/Development-Tools/nRF-Command-Line-Tools/Download#infotabs,这个就是j-link驱动以及nrfjprog等Nordic提供的针对nRF52的命令行工具,目前进入维护阶段,不再更新。
  • west,git和Python,这三个工具大家手动安装一下,某些场合还是蛮有用处的。
  • 请注意,上面的nRF Util,west,git和Python,即使大家没有手动安装他们,下面将要下载的nRF Connect SDK工具链也会自动包含这些工具,但这些自带的工具只能在nRF Connect终端里面执行,而不能在大家熟悉的bash,powershell,cmd等终端里面执行,因为只有nRF Connect终端才配置好了他们的执行环境,换言之,如果你想在bash,powershell,cmd等终端里面执行nRF Util,west,git和Python对应的命令,那么你就必须手动安装他们。

2.2 VS Code开发环境搭建

Microsoft Visual Studio Code(VS Code)是Nordic推荐的开发nRF Connect SDK应用的跨平台IDE工具,所以nRF Connect SDK开发环境搭建都是在VS Code中进行的。

2.2.1 插件安装

首先下载相应的插件。打开VS Code,进入Marketplace,搜索“nrf”,然后选择“nRF Connect for VS Code Extension Pack”进行安装,一旦nRF Connect for VS Code Extension Pack安装成功,所有nRF插件都自动安装成功。目前Nordic开发了如下nRF插件:

 

安装成功后,点击左边窗格的nRF Connect VS code图标,如果你是第一次使用nRF Connect VS code插件,你将看到如下界面:

image

如果你之前已经使用过nRF Connect VS code插件,那么你看到的界面将如下所示:

image

上面两个红圈标出的“Install SDK” 和“Manage SDKs”功能是一样的,他们都会同时安装nRF Connect SDK和对应的工具链,当然大家也可以单独点击旁边的“Install Toolchain”或者“Manage toolchains”来单独安装对应的工具链。 

2.2.2 nRF Connect SDK及工具链安装

如前所述,nRF Connect SDK同时放在全球和中国镜像服务器,对于中国区用户,请选择“Mainland China”服务器,这样可以直接下载;反之,如果选择global服务器,那么必须挂VPN,否则很有可能就会下载不完整或者失败。

上面的nRF Connect VS code插件安装成功后,点击左边的插件图标,进入WELCOME面板,选择Manage SDKs,然后在右边列表框中选择Install SDK,如下: 

image

选择“Mainland China”服务器:

image

选择“nRF Connect SDK”:(Nordic为了方便客户移植nRF5 SDK应用到nRF Connect SDK,又推出了nRF Connect SDK Bare Metal版本,这个版本SDK跟nRF5 SDK基本兼容,没有Zephyr RTOS,但是编译系统还是沿用nRF Connect SDK那套,但是nRF Connect SDK bare metal版本只支持nRF54L芯片,其他Nordic芯片都不支持,而且只支持基本的蓝牙特性,比较复杂的功能也不支持。这里我们只讲解nRF Connect SDK,nRF Connect SDK bare metal后面有机会另开博文讲解)

image

选择相应版本的nRF Connect SDK,对于新用户,请直接选择当前的最高版本(下面为v3.2.3版本):

image

选好版本,然后选择SDK安装根目录,使用默认目录即可,如下。请注意,不要选择太长的目录名,这样会导致在Windows操作系统下编译工程的时候报各种奇奇怪怪的错误。

image

然后VS code开始下载nRF Connect SDK及工具链,先下载工具链再下载SDK,如下:

image

image

安装成功后将出现如下界面:

image

对于Windows系统,安装成功后,nRF Connect SDK和工具链都放在如下目录:

image 

可以看出,所有版本工具链都放在toolchains目录中,每个版本nRF Connect SDK放在以版本为名的目录中,比如v3.2.3。打开一个nRF Connect SDK目录,比如v3.2.3,可以看到nRF Connect SDK包含多个仓库,其中nrf是主仓库:

image

至此,nRF Connect SDK开发环境已经搭好。

2.2.3 百度网盘下载(不再更新!)

之前下载nRF Connect SDK经常出问题,为此,我们把它放在百度网盘上,以方便大家下载。现在Nordic新建了中国服务器,SDK下载已不是问题了。由于历史原因,我们还是把百度网盘链接保留在这,供需要人下载。

进入目录“开发你的第一个NCS(Zephyr)应用程序”,选择相应的版本,比如ncs_v2.9.0,然后把里面的压缩包下载下来,压缩包包含工具链和SDK两部分,工具链解压到C:\ncs\toolchains目录,SDK直接解压到C:\ncs目录,最后,打开C:\ncs\toolchains\toolchains.json文件,在文件中添加刚才解压的工具链名字,如下: 

[
  {
    "default_toolchain": {
      "ncs_version": "v2.9.0"    //改成你的Toolchain版本
    },
    "schema": 1,
    "toolchains": [
      {
        "identifier": {
          "bundle_id": "b620d30767"  //改成你的Toolchain ID
        },
        "ncs_versions": [
          "v2.9.0"     //改成你的Toolchain版本
        ]
      }
    ]
  }
]

上面更新操作主要让nRF Connect VS code插件可以找到这个工具链,从而使用这个工具链,请确保这个文件格式正确,否则nRF Connect VS code插件打开后会是一片空白。   

3. nRF Connect SDK工程概览

3.1 编译hello_world工程

我们先用nRF Connect VS code编译一个nRF Connect SDK工程,让大家对nRF Connect SDK工程有一个大概的印象,这里以v3.2.3\zephyr\samples\hello_world工程为例。

首先,我们点击“打开现有工程”图标,下面红框标出来的两个图标选其一即可:

image

浏览到目录:zephyr\samples\hello_world:

image

选择“Open”,工程将出现在“APPLICATIONS” 面板中,然后选择“Add Build Configuration”,下面红框圈出来的2个图标选其一即可:

image

选择SDK和工具链,然后选择目标设备,这里选择 nrf54l15dk/nrf54l15/cpuapp(这个列表框支持搜索匹配功能,大家可以输入“54L15”来快速找到这个目标设备): 

image

往下翻滚上面的页面,选择“Generate and Build”,然后开始编译:

image

编译成功后,可以看到如下日志: 

image

大家一定要用好上面的日志窗口,编译过程中产生的所有日志都在这个窗口中,大家可以向上翻滚以查看更多日志信息,或者直接最大化这个窗口以便查看。这里提一个非常重要的事情,怎么查看编译报错信息,比如上面例子,我手动制造一个错误,然后再编译,日志窗口报如下信息:

image

很多人盯着上面红框标出的错误提示,不知所措。实际上,这个错误信息没有任何意义,大家不要管它。大家可以继续往上翻滚日志窗口,直至找到第一个报错的地方,那个才是我们要找的真正报错信息。上例的报错信息如下:

image

这个报错信息我们就很熟了,大家可以根据这个报错信息很快找到问题所在:原来CONFIG_BOARD_TARGET2标识符没有定义。实际上,只要大家细心一点,大部分问题都可以通过日志窗口的报错提示来解决,再不行,可以直接把报错信息发给AI(推荐先使用Nordic自己的AI:https://devzone.nordicsemi.com/),AI将会告诉你答案。

这里大家对nRF Connect SDK工程有一个大概的印象就好了,后面我们再深入研究它。 

3.2 用CMake语法组织NCS工程

在深入介绍NCS工程之前,我们先看看大家熟悉的Keil工程,我们打开一个Keil工程,如下:

打开后,左边窗口会显示这个工程包含的所有文件,这些文件其实是通过如下窗口一个一个添加进去的:

Keil工程还需要添加包含目录,这个通过如下窗口实现的:

可以看出,Keil添加源文件和目录的方式比较直观,但缺乏灵活性,而且效率低下。

和Keil工程一样,nRF Connect SDK工程也需要添加源文件和目录,但它不是使用图形方式去添加源文件和目录,而是通过CMake命令或者函数来添加源文件和目录。具体来说,nRF Connect SDK工程是通过CMakeLists.txt表示的,比如zephyr\samples\hello_world\CMakeLists.txt: 

再比如nrf\applications\nrf_desktop的CMakeLists.txt文件:

请注意,CMakeLists.txt是Zephyr标准命名,也就是说,当你需要新建一个Zephyr工程时,它的工程只能取名为“CMakeLists.txt”,文件名和后缀都要一模一样,否则Zephyr编译系统会忽略这个文件,不认为这是一个Zephyr工程。除了CMakeLists.txt,nRF Connect SDK还有很多其他标准命名,比如Kconfig,prj.conf,app.overlay,sysbuild.conf,pm_static.yml,大家必须使用跟它们一模一样的名字,包含文件名和后缀,Zephyr编译系统才会自动找到并使用这些文件。当然,你也可以使用非标准文件名去命名某些配置文件,比如aaa.conf,这种情况下系统默认会忽略这个配置文件,只有当你手动选择这个配置文件,系统才会使用它

咱们再仔细看看zephyr\samples\hello_world这个工程,它的CMakeLists.txt只添加了一个main.c文件,而且这个main.c文件只有一行打印语句,如下:

没有kernel和串口驱动的支持,上面的printf是跑不起来的,按照我们的经验来理解,一个工程也不可能只有一个main.c,实际上当我们把这个工程编译后,我们发现这个工程包含了很多文件,如下:

这些文件是怎么添加进来的呢?这里面就会用到后面会讲的Kconfig配置,Kconfig其实就是一大堆宏(define),这些宏都有默认值,我们的kernel/串口驱动等文件就是因为Kconfig默认使能了,从而默认装载了。以上面zephyr\drivers\serial\uart_nrfx_uarte.c文件为例,文件目录zephyr\drivers\serial下面也有一个CMakeLists.txt文件,内容如下:

可以看出,当CONFIG_UART_NRFX_UART这个Kconfig使能的时候,系统就会自动装载uart_nrfx_uart.c,当你选择Nordic板子编译这个工程时,CONFIG_UART_NRFX_UART这个Kconfig就会默认使能,从而自动装载uart_nrfx_uart.c文件。Zephyr每个目录下面都有一个CMakeLists.txt文件,这许许多多的CMakeLists.txt文件和众多的Kconfig合在一起,让我们轻松便利地装载自己需要的文件或目录,从而构建出自己需要的工程,这种灵活性和可扩展性是Zephy非常大的一个特色

3.3 选择Board(板子)进行编译

使用Keil编译一个工程时需要选择Device,如下:

同样的,在NCS中你需要选择一个board(板子或者板卡),选择板子,就是选择芯片,除此之外,开发板还规定了芯片的一些外设使能情况,以及一些基本外围电路连接情况,这个跟Keil选择Device的操作是异曲同工的,而且NCS这种做法更灵活,功能也更多,扩展性也更好。Nordic定义的所有板子放在目录:zephyr\boards\nordic,

以及nrf\boards\nordic: 

那某个具体的例子到底支持哪些板子呢?这个可以从例子自带的sample.yaml得知,比如例子:nrf\samples\bluetooth\peripheral_lbs,打开sample.yaml文件,我们可以看到:

image

上面红框框起来的板子都是经过测试的,本例子肯定支持。至于红框中没有列出的板子,不能说就不支持,只能说没有测过。上面板子命名方式如何理解呢?以nRF54L15为例,它对应的其中一款板子编号为:nrf54l15dk/nrf54l15/cpuapp/ns,其中:

  • nrf54l15dk表示开发板的名称或者编号
  • nrf54l15表示开发板包含的芯片的型号或者名称,一个开发板有可能包含若干个芯片
  • cpuapp表示芯片的app核,n一个芯片有可能包括多个核,这里指RF54L15 Cortex-M33的内核,而不是那个RISC-V核
  • ns表示non-secure(非安全域),如果是TF-M应用,需要加上ns后缀;如果是普通的应用,则不需要ns后缀

对于nRF54L15来说,我们经常选择nrf54l15dk/nrf54l15/cpuapp这个编号,如下:

image

编译的时候,每个应用都需指定其image的ROM起始地址和大小,以及运行时所占RAM的起始地址和大小,比如Keil,是在如下界面完成相关配置:

在NCS中,上述ROM和RAM信息也是放在board定义文件中,即下面所说的DeviceTree文件中,大家通过修改DeviceTree文件,就可以修改相应的ROM和RAM信息,比如:

这个是单image情况,在NCS中还有一种多image情况,即芯片需要同时跑多个image,最典型的例子就是同时跑bootloader和app,这种情况我们一般在partition文件里面指定ROM和RAM信息,如下:

我们后面会对pm文件进行详细阐述,这里有一个大概印象就好。

3.4 使用Kconfig和DeviceTree配置工程 

在Keil工程中,我们一般使用头文件来配置工程,比如下面的sdk_config.h:

为了方便查看这个头文件,点击下面的“Configuration Wizard”,如下:

这个工具一下子把一行一行的define(宏)图形化了,方便了我们找到相应的宏。

在NCS中,我们是通过两个头文件来完成上述同样的功能:autoconf.h和devicetree_generated.h,autoconfig.h长下面这样:

devicetree_generated.h长下面这样:

autoconf.h和devicetree_generated.h有点特殊,他们是编译系统(主要是Python脚本)编译生成的,因此这两个文件用户不能直接修改,用户只能通过这两个文件的输入文件去间接修改这两个文件。

autoconf.h是由许许多多的Kconfig文件和多个以.conf(比如prj.conf)为后缀的文件融合而生成的,通过文本编辑器就可以打开这些Kconfig和conf文件,比如nrf\samples\bluetooth\peripheral_lbs\Kconfig:

比如nrf\samples\bluetooth\peripheral_lbs\prj.conf:

Kconfig是宏定义的地方,而prj.conf等其他conf文件是引用宏或者给宏赋值的地方。比如宏CONFIG_BT_LBS_SECURITY_ENABLED,它在Kconfig变成config BT_LBS_SECURITY_ENABLED,表示宏在这里定义,宏只能定义一次,后面要引用它或者给它赋值,就不能再使用“config BT_LBS_SECURITY_ENABLED”这种格式了,而应该直接使用CONFIG_BT_LBS_SECURITY_ENABLED,比如你在prj.conf文件中给它赋值为No:CONFIG_BT_LBS_SECURITY_ENABLED=n。大家可以仔细比对一下,可以得知autoconf.h里面的条目跟Kconfig或者conf文件是一一对应的。Kconfig和prj.conf都是Zephyr标准命名,大家必须使用跟它们一模一样的名字,包括文件名和后缀,编译系统才会自动找到它们并使用它们,否则编译系统将忽略非标准命名文件大家也不要被Kconfig和prj.conf这种奇怪的名字吓着,其实他们都是txt文件,可以直接使用文本编辑器打开和修改。Keil有图形化工具来配置头文件,Kconfig有没有相应的工具呢?答案是肯定的,我们会在后面详细阐述Kconfig原理和使用说明,下面是图形化工具的一个简图:

devicetree_generated.h是有许许多多的dts/dtsi文件和多个以overlay后缀(比如nrf54l15dk_nrf54l15_cpuapp.overlay)结尾的文件融合而生成的,通过文本编辑器就可以打开这些dts和overlay文件,前面提到的选择板子进行编译,板子的定义就是dts文件,也就是说板子的dts文件除了可以用来指定编译目标,也可以用来定义设备有关的宏,比如zephyr\boards\nordic\nrf54l15dk\nrf54l15dk_common.dtsi:

 

比如nrf\samples\bluetooth\peripheral_uart\boards\nrf54l15dk_nrf54l15_cpuapp.overlay:

dts/dtsi是宏定义的地方,而nrf54l15dk_nrf54l15_cpuapp.overlay等其他overlay文件是引用宏或者给宏赋值的地方。比如current-speed = <115200>,不管是在dts/dtsi文件,还是在overlay文件,他们的格式是一样的,都是current-speed = <115200>,但是只有在dts/dtsi定义过current-speed,你在overlay文件中才能引用它并对它重新赋值。至于current-speed = <115200>如何转换成#define DT_N_S_soc_S_peripheral_50000000_S_uart_c6000_P_current_speed 115200,这个意义并不大,因为在我们的代码中,我们不是通过宏DT_N_S_soc_S_peripheral_50000000_S_uart_c6000_P_current_speed来引用这个波特率的,而是通过DeviceTree API来获取波特率,这个DeviceTree API会最终输出DT_N_S_soc_S_peripheral_50000000_S_uart_c6000_P_current_speed这个宏,所以我们不需要关心devicetree_generated.h这个文件,我们只需要关心DeviceTree的语法格式以及DeviceTree一些常用API就可以了。app.overlay, prj_<board>.overlay和<board>.overlay都是Zephyr标准命名,大家必须使用跟它们一模一样的名字,包括文件名和后缀,编译系统才会自动找到它们并使用它们,否则编译系统将忽略非标准命名文件大家也不要被app.overlay<board>.overlay这种奇怪的名字吓着,其实他们都是txt文件,可以直接使用文本编辑器打开和修改。同样Nordic也提供图形化工具来帮助大家配置和修改DeviceTree,这个我们会在后面详细阐述,下面是工具的一个简图:

3.5 nRF Connect SDK几个重要目录

如前所述,nRF Connect SDK包含了多个仓库,每个仓库都是相互独立的,而且每个仓库包含的代码都很多,如果一行一行代码读下去,那将是一个无底洞。所以实际开发中,我们都是参考例子,按照例子去做,碰到不懂的API,再去看API说明,循环往复,最终完成自己的开发。

我们先说说applications目录,NCS中商业级的应用程序都放在:nrf\applications: 

如果你的应用跟上面的应用相似,那么推荐你直接参考上面的应用来开发,因为他们属于turn-key级的方案,非常接近商业级应用,你需要的开发工作量最少。

其次是如下例子目录nrf\samples,这个都是Nordic自己开发的一些例程: 

然后就是Zephyr自带的例子zephyr\samples:

大家有时候会觉得nrf或者zephyr例子还是不够多,比如很多驱动API怎么用,好像没有例子。其实Zephyr所有API的的使用,都可以在zephyr\tests下面找到示例,所以当你找不到例子的时候,不妨在这里找一找:

如果想绕开Zephyr设备驱动模型,直接操作Nordic底层驱动nrfx API,那么可以参考modules\hal\nordic\nrfx\samples\src这里面的例子:

NCS里面包含众多API,到底该使用哪些API?API说明又在哪里?一般而言,我们只使用仓库里面的include目录下的API,API说明也在那里

比如nrf仓库的include目录:nrf\include

Zephyr标准API的include目录zephyr\include\zephyr:

其他仓库也是遵守这个规范的,比如Nordic开发的底层驱动API(与RTOS无关):modules\hal\nordic\nrfx\drivers\include 

注:有些人会问,modules\hal\nordic\nrfx\drivers\include和zephyr\include\zephyr\drivers两个目录里面的驱动API,我到底该使用哪个呢?zephyr\include\zephyr\drivers这个是Zephyr标准的驱动API,按照Zephyr标准来定义的,它调用了底层API:modules\hal\nordic\nrfx\drivers\include,modules\hal\nordic\nrfx\drivers\include这里面的API都是Nordic自己实现的,跟平台无关。所以说,一般推荐使用zephyr\include\zephyr\drivers这里面的API,只有这里面没有或者实现不了的功能(比如将同一个引脚动态分配给UART和SPI,Zephyr标准API就无能为力),这个时候才使用modules\hal\nordic\nrfx\drivers\include这里面的API。

4 运行你的第一个nRF Connect SDK应用

大家对NCS工程有一个大概了解后,就可以动手跑一下例子,实际感受一下NCS工程。大家可以跑一下zephyr\samples\hello_world这个例子,基本上所有板子都会支持这个例子,这个例子会在串口助手里面打印出“Hello World!”,由于例子比较简单,我们就不在这里演示。

前面介绍nRF Connect SDK工程概览的时候,大部分时候我们是以nrf\samples\bluetooth\peripheral_lbs这个为例的,peripheral_lbs是一个经典的蓝牙跑马灯例子,大家也可以跑一下peripheral_lbs这个例子,来感受一下NCS工程。

4.1 运行蓝牙数据透传例子peripheral_uart

下面我会以nrf\samples\bluetooth\peripheral_uart这个为例来详细阐述NCS工程的组织,编译,运行和debug等,peripheral_uart就是闻名遐迩的蓝牙数据透传例子,这个例子支持Nordic所有蓝牙芯片,比如nRF52840/nRF52832/nRF5340/nRF54L15/nRF54H20,如前所述,你选择的板子不一样,编译的代码就不一样,如果你选择板子nrf52840dk/nrf52840,那么编译的程序就自动适配nRF52840芯片;如果你选择nrf54l15dk/nrf54l15/cpuapp,那么编译的程序就自动适配nRF54L15芯片......下面我们以nrf54l15dk/nrf54l15/cpuapp为例来编译和运行peripheral_uart例子(除了在选择Board target的时候不一样,其他板子跟它基本上一模一样)。

为了浏览代码方便,可以先通过VS Code的File -> Open Folder打开nRF Connect SDK根目录,如下:

image

选择Open an Existing Application,如下:

image

选择目录:nrf\samples\bluetooth\peripheral_uart,如下: 

打开上述项目后,点击“Add Build Configuration”,将自动跳出Add Build Configuration页面,在Board target列表框中,选择板子nrf54l15dk/nrf54l15/cpuapp(这个列表框支持搜索匹配功能,大家可以输入“54L15”来快速找到这个目标设备): 

image

注意:大家根据自己情况选择合适的SDK版本和Toolchain版本。

然后选择Configuration文件,如果留空,默认选择prj.conf,大家也可以选择其他配置项。 

其他一些配置项的说明如下所示: 

选择Build Configuration,开始编译,编译成功后,你将看到如下输出: 

整个VS code一览图如下所示: 

我们先把编译成功后的代码下载到板子中,看看板子运行效果。在点击ACTIONS窗口中的“Flash”按钮之前,请确保nRF54L15开发板已连入PC,连接的时候只要将一根USB type C电缆(手机充电线就可以)一头连PC,一头连板子的DEBUGGER USB口,即完成所有连线操作,后续不管是日志打印还是Debug,都可以通过这根USB电缆完成,连接效果图如下所示:

下载成功后,会有如下输出日志:

这个时候我们可以利用一个串口助手查看程序运行日志,比如Putty等,这里我们利用Nordic另一个VS Code插件:nRF Terminal来查看日志。首先点击下面红框里的图标:(这里选择COM4口,因为日志是从COM4口输出)

继续选择COM4,如下: 

选择波特率以及是否有流控:

为了后面演示方便,我们把nRF Terminal的模式切换为行模式,如下:

此时可以按一下USB口旁边的RESET按钮,让板子复位一下,此时我们可以看到:

打开手机版nRF Connect,此时我们可以看到如下广播:

点击“CONNECT”,连上后,我们将看到如下蓝牙服务: 

点击RX Characteristic旁边的箭头,发送“Hello, nRF54”,如下:

在nRF Terminal中,输入“Hello, phone”并回车,如下:

此时手机端界面如下所示: 

可以看出,手机把“Hello, nRF54”通过蓝牙传给了nRF54L15开发板,nRF54L15开发板把“Hello, phone”通过蓝牙传给了手机,实现了蓝牙的双向数据透传功能。

4.2 nRF Connect VS code插件功能介绍

下面我们看看nRF Connect插件的一些重要功能。

首先我们看一下ACTIONS窗口,这个窗口包含多个条目:Build/Debug/Flash/nRF Kconfig GUI等,每个条目其实包含多个功能,即点击条目不同地方,执行不同操作,这个大家一定要注意。

4.2.1 编译

编译的时候,一定要先选好工程,默认是build目录,即编译所有工程;展开build目录,下面会有多个工程,选择其中一个工程,则只编译这个工程。一般来说,我们推荐选择build目录,即编译所有工程。当把鼠标放在ACTIONS窗口中的Build条目,我们可以看到Build条目包含多个功能,当鼠标点击最左端图标,表示小编译,即只编译改动部分;当鼠标点击“循环箭头”,表示大编译,即把原来的编译目录删掉重新编译工程;当鼠标点击“三个点”图标,跳出一个配置菜单。

4.2.2 烧写

和编译一样,Flash之前一定要选好工程,一般来说,建议直接选择Build目录,而不要选择下面的具体工程,这样点击一下就可以把所有工程都烧进去,点击Flash条目不同地方,呈现不同功能,如下:

如前所述,在执行Build和Flash操作之前,选择APPLICATIONS窗口的build目录,即所有工程。但在执行Debug操作,查看Kconfig以及查看设备树时,请先选择build目录下面一个具体的工程,这个在多工程情况下尤为重要。

4.2.3 调试代码

在调试代码之前,请先确保编译的时候选择了debugging优化级别:(选择debugging优化级别,可以看到更多debug信息,当然你不选择debugging优化级别也是可以的)

选好debugging优化级别后,重新编译,编译成功后点击ACTIONS窗口中的“Debug”条目,进入Debug页面,如下:

这里要特别强调一下,nRF Connect支持查看每个线程的call stack,这个功能真是太棒了,如下所示,每个线程的call stack显示得一清二楚:

而且我们还可以查看每个线程的状态,欲查看每个线程的状态,ctrl+shift+p进入Command Palette,选择“nRF Debug: Focus on Thread Viewer View”,如下:

然后,你将看到:

我们也可以查看存储器里面的内容,即Flash和RAM里面的原始内容,查看方法是:ctrl+shift+p进入Command Palette,选择“nRF Debug: Focus on Memory Explorer View”,如下:

大家可以把鼠标放在各个存储器地址上试一下,可以看到这个地址对应的符号,点击“Show Symbol in File”,可以跳到符号定义的地方,如下:

如果要查看芯片某个外设的情况,在Peripherals窗口区找到要查看的外设,比如UARTE20,展开即可看到它的运行情况,如下:

点击代码行左侧,即可添加断点: 

除了可以添加普通断点,还可以添加条件断点。首先选择要加条件断点的代码行,然后ctrl+shift+p进入Command Palette,选择“Debug: Add Conditional Breakpoint”,如下: 

跳出一个“表达式”窗口, 假设我们要实现一个功能,当blink_status变量大于10时,自动停在main.c的dk_set_led这行,为此,我们可以输入表达式:blink_status > 10,即可完成预定的条件断点功能,如下: 

调试的时候,我们经常会碰到一种情况:不覆盖出问题板子的固件,直接使用有问题的image调试,这个时候,我们就不能直接点击Debug条目来进入调试界面,而是要调出上下文菜单,选择“Debug without Flashing”,如下:

除了使用VS code内带的调试功能,我们还可以使用外部的Ozone来调试代码(请确认Ozone软件已经安装并且有DK连到PC),直接点击“Debug with Ozone”条目,系统自动打开此工程的Ozone调试界面,大家就可以使用Ozone来调试这个工程了,如下:

4.2.4 代码浏览

VS code自带的搜索功能默认搜索的范围是整个NCS目录下所有的文件,这种搜索效率比较低,如下:

 nRF Connect插件内置的搜索功能则只会在指定的文件夹内搜索,大幅提升了搜索效率,尤其对于Linux和MacOS这种路径长度不受限的系统来说,我们可以直接选择工程的顶级目录,这样就相当于在当前工程中进行搜索。Windows由于路径名长度的255字节限制,我们只能选择下面一些子目录来搜索,如下:

比如我们搜索“bt_enable”这个函数,结果如下:

VS code有一个Go to Definition(代码跳转)功能,对应的快捷键为:ctrl+鼠标左键单击,如下:

 

nRF Connect插件也集成了这个功能,这样Go to Definition就可以在当前选择的工程中跳来跳去,省去了搜索操作,极大方便了用户代码阅读。比如查看k_sleep的定义,它直接跳到如下界面:

Go to Definition不仅可以用来看代码,也可以用来看conf文件,比如查看prj.conf里面的“CONFIG_LOG=y”,如下:

4.2.5 查看Kconfig文件

如前所述,每一个NCS工程包含多个kconfig和conf文件,这些文件最终会合并成一个文件.config。我们现在来查看一下peripheral_uart这个工程的Kconfig文件,首先在Applications窗口选择“peripheral_uart”工程,然后展开如下的Kconfig目录,我们的确可以看到peripheral_uart工程包含了很多Kconfig和conf文件,大家可以一一点击这些文件以实际看看他们长什么样。

这些Kconfig和conf文件最终会合并成一个.config文件,它位于如下位置:

前面说过,这个.config文件又会转成autoconf.h,最终通过define(宏定义)的方式在代码中发挥作用,如下:

从上可以看出,“CONFIG_LOG=y”转换成了“#define CONFIG_LOG 1”。

前面也提到过,用户不能直接通过编辑autoconf.h或者.config文件来修改宏定义的值,用户只能修改Kconfig或者conf文件来间接达成这个目标。一般而言,是通过修改prj.conf文件来达成目标,但是prj.conf是一张空白纸,用户对NCS工程里面包含的CONFIG定义又不熟,以至于很多用户无从下手。其实,用户可以通过Kconfig图形工具来查看和编辑Kconfig文件,目前NCS支持nRF Kconfig GUI和menuconfig(或者guiconfig)来查看和编辑Kconfig文件。下面我们实际演示一下nRF Kconfig GUI的使用。

首先,在Applications窗口选择“peripheral_uart”工程,然后点击actions窗口的“nRF Kconfig GUI”条目,然后点击上面红圈的“Toggle Panel”按钮,关闭下面窗口,并拖动下面红圈的横线,把下面的信息窗口拖大一些,如下:

我们查看一下当前工程的日志配置情况,输入“log”,跳出如下界面: 

点击“Jump to Item”,我们将得到Logging模块所有配置,如下:

展开Backends菜单,我们可以看到peripheral_uart工程是使用RTT Viewer作为日志输出控制台。

我们可以打开RTT Viewer来验证一下,我们可以直接使用VS code里面的nRF Terminal来做RTT viewer。首先,点击下面的“connect”图标:

然后选择你的J-link设备,成功后,我们将看到如下日志:

关于RTT Viewer,有一点需要大家注意一下,每次复位之后,新日志有可能不会自动显示在屏幕上,此时需要重新连接一次RTT Viewer,新日志才会显示出来。后面我们会详细讲解如何编辑Kconfig文件以生成自己需要的conf文件。

4.2.6查看DeviceTree文件

如前所述,每一个NCS工程包含多个dts/dtsi和overlay文件,这些文件最终会合并成一个文件zephyr.dts。我们现在来查看一下peripheral_uart这个工程的DevieTree文件,首先在Applications窗口选择“peripheral_uart”工程,然后展开如下的Devicetree目录,我们的确可以看到peripheral_uart工程包含了很多dts/dtsi和overlay文件,大家可以一一点击这些文件以实际看看他们长什么样。

这些dts/dtsi和overlay文件最终会合并成一个zephyr.dts文件,它位于如下位置:

 前面说过,这个zephyr.dts文件又会转成devicetree_generated.h,最终通过define(宏定义)的方式在代码中发挥作用,如下:

从上可以看出,uart20的波特率最终定义成了115200。

前面也提到过,用户不能直接通过编辑devicetree_generated.h或者zephyr.dts文件来修改宏定义的值,用户只能修改dts/dtsi和overlay文件来间接达成这个目标。一般而言,是通过修改app.overlay(或者<board name>.overlay)文件来达成目标,但是app.overlay(或者<board name>.overlay)是一张空白纸,用户对Devicetree语法又不熟,以至于很多用户无从下手。其实,用户可以通过Nordic Devicetree Visual Editor来查看和编辑Devicetree文件。下面我们实际演示一下Devicetree Visual Editor的使用。

首先,在Applications窗口选择“peripheral_uart”工程,然后点击actions窗口的“Devicetree”条目,或者直接点击左边工具栏的“Devicetree Visual Editor”图标,如下:

我们查看一下uart20的波特率,如下: 

如果要修改Devicetree文件,请确保本工程根目录下有一个app.overlay文件(适用所有板子),或者boards目录下有板子自己的<board>.overlay文件(上面peripheral_uart工程就是采用这种方式),否则你将修改系统的DeviceTree文件,将影响系统里其他工程。后面我们会详细讲解如何编辑DeviceTree文件以生成自己需要的overlay文件。 

4.3 west命令行方式开发环境介绍

除了可以使用nRF Connect VS code插件来编译和烧写NCS工程,用户也可以使用west命令来编译和烧写NCS工程。west命令需要在专门的命令行环境中执行,目前有两种方式可以打开nRF Connect SDK的命令行环境,一是直接使用VS code里面的nRF Connect终端,二是使用nrfutil命令打开操作系统自带的终端。

大家可以通过如下红框圈出来的方式打开nRF Connect终端:

image

 

通过nrfutil命令可以直接打开操作系统自带的终端并包含nRF Connect SDK工具链环境,在shell中输入如下命令:

nrfutil toolchain-manager launch --ncs-version v3.2.3 --terminal

上述命令执行成功后,我们将看到:

image

一个典型的west命令如下所示:

west build -b nrf54l15dk/nrf54l15/cpuapp
注:-b指定板子,这里使用nRF54L15开发板

下面我们还是以nrf\samples\bluetooth\peripheral_uart工程为例来详细讲解west命令使用方式,并使用Windows的CMD终端来执行west命令。

首先,进入工程所在的目录:

cd C:/ncs/v2.9.0/nrf/samples/bluetooth/peripheral_uart

输入如下命令编译此工程:

west build -b nrf54l15dk/nrf54l15/cpuapp -p

编译成功后,使用如下命令将代码下载到板子中:

west flash 

大家可以使用GDB命令或者Ozone进行Debug调试,Ozone调试界面如下所示。感兴趣的同学可以自己去摸索和实践,这里就不再赘述。

你可以使用menuconfig来查看Kconfig文件,menuconfig对应命令为:

west build -t menuconfig

执行上述命令后,将显示如下界面,大家可以按照界面提示去查看或者临时修改配置值。

4.4 编译输出目录一览

不管采用VS Code还是west命令行,他们底层的工作逻辑是一样的,所以最终编译生成的内容是一样的。VS Code或者west编译成功后,将在NCS工程根目录下生成一个build目录,所有编译有关的内容都将放在这个目录下。下面我们大概介绍一下这个目录的结构和内容。

上面的peripheral_uart例子编译成功后,将在工程根目录下生成一个build目录,build目录结构如下:

如果使能了MCUboot的话,那么build目录会包含一个app工程目录和一个mcuboot工程目录,以及升级用的zip文件,如下: 

可以看出,build目录下面有几个重要的文件或者目录

  • 各个子工程的编译输出目录,比如上面的peripheral_uart目录,mcuboot目录等,每个子工程所有编译生成文件都放在这个目录下
  • build.ninja,ninja项目描述文件
  • CMakeCache.txt,CMake项目描述文件
  • partitions.yml,芯片存储区分区文件,详细描述每个子工程放在NVM哪个区域,使用哪个区域的RAM
  •  merged.hex,所有工程对应的映像文件将合并成一个merged.hex文件,用于直接烧写代码
  • dfu_application.zip,升级用的zip包,将这个压缩包传给手机或者服务器以传输升级文件

我们再看一个具体子工程目录的结构和内容,比如peripheral_uart目录,我们主要查看它下面的zephyr目录的内容,如下:

大家可以看到这个目录下有很多重要文件:

  • .config,Kconfig生成的最终文件,此工程所有的Kconfig和.conf文件最终将合并成这个.config文件,通过查看.config文件就知道此工程最终配置符不符合你的期望
  • zephyr.dts,DeviceTree生成的最终文件,此工程所有的dts/dtsi文件和.overlay文件最终将生成这个zephyr.dts文件,通过查看zephyr.dts文件就知道此工程最终配置符不符合你的期望
  • zephyr.bin,此子工程编译生成的image
  • zephyr.map,此子工程的map文件

5. 修改nRF Connect SDK工程配置文件

每一个NCS或者Zephyr工程都包含了非常多的配置项或选项,总数有可能达几千项之多,然而绝大多数配置项我们都不需要去管他们,只需要使用默认值即可,所以开发一个简单的NCS应用程序,有可能我们不需做任何配置,就可以跑起来。当你开发复杂的应用程序的时候,又可以利用NCS的Kconfig/Devicetree配置系统,非常方便又灵活地配置你的工程。很多人会问,既然Kconfig和DeviceTree都可以配置NCS工程,那我要定义一个宏的时候是放在Kconfig里面还是DeviceTree里面?Kconfig主要负责软件的配置,DeviceTree主要负责板子硬件的配置。Devicetree有严格的语法要求,它能定义的宏是预先规定好的并且非常有限,一般来说,我们直接引用现有Devicetree配置文件里面的宏定义,而不额外添加新的宏定义。换句话说,新的宏定义我们一般都放在Kconfig文件里面

如3.3节所述,Kconfig配置系统最终通过autoconf.h在代码中发挥作用,Devicetree配置系统最终通过devicetree_generated.h在代码中发挥作用,但是autoconf.h和devicetree_generated.h有点特殊,他们是编译系统(主要是Python脚本)编译生成的,因此这两个文件用户不能直接修改,用户只能通过这两个文件的输入文件去间接修改这两个文件。autoconf.h是由许许多多的Kconfig文件和多个以.conf(比如prj.conf)为后缀的文件融合而生成的,通过文本编辑器就可以打开并编辑这些Kconfig和.conf文件。devicetree_generated.h是有许许多多的dts/dtsi文件和多个以.overlay(比如app.overlay)为后缀的文件融合而生成的,通过文本编辑器就可以打开并编辑这些dts/dtsi和overlay文件

5.1 使用prj.conf和nRF Kconfig GUI修改Kconfig配置

如前所述,每个模块里面的Kconfig文件是宏定义的地方,而例子目录里面的prj.conf等其他.conf文件是引用宏或者给宏赋值的地方。还是以前面提及的logging backend配置为例,打开nRF Kconfig GUI,找到这个配置,并跳到它定义的地方,如下:

 然后我们可以看到这个Kconfig定义在如下目录:

 我们打开如下的Kconfig.uart文件:

如前所述,“config LOG_BACKEND_UART”表示定义了一个宏:CONFIG_LOG_BACKEND_UART,这个宏在prj.conf里面有如下赋值:

也就是说,日志打印UART后端关闭了,我们现在将其打开,如下:

我们一般不在nRF Kconfig里面直接修改Kconfig的值,如下:

如上所示,点击“Apply”之后,修改保存在.config文件里面,这是一个临时文件,每次执行大编译,你这里的修改就作废,所以我们一般都在prj.conf里面修改。当然,你可以不点击“Apply”而直接点击“Save to File”,然后选择prj.conf文件(你选择nrf54l15dk_nrf54l15_cpuapp.conf文件也是一样的,两者的区别是prj.conf适用于所有板子,而nrf54l15dk_nrf54l15_cpuapp.conf仅适用于nrf54l15dk_nrf54l15_cpuapp),这样nRF Kconfig会自动把刚才的修改保存到prj.conf里面,如下:

注:目前nRF Kconfig在显示peripheral_uart工程的日志配置时有一点问题,它没有显示出UART后端配置项,如下所示,哪怕LOG_BACKEND_UART没有使能,它也应该显示出来:

而且RTT_CONSOLE明明使能了,它显示为没有使能。如下,.config文件显示RTT_CONSOLE使能了:

碰到这种情况怎么办呢?一切以生成的.config文件为准,它说使能了就使能了,它说没有使能就没有使能,哪怕你的Kconfig或者prj.conf或者nRF Kconfig显示的值跟它不一样。如果nRF Kconfig显示的值跟.config不一样,一般是nRF Kconfig出问题了;如果是Kconfig或者prj.conf设置的值跟最后生成的.config不一样,这个时候很有可能在其他模块或者其他地方设置了其他值,或者你的设置依赖没有打开,导致你的设置无效,大家只要在下面的Config files目录搜索相关配置值,就可以找到冲突的地方:

除了系统模块可以定义Kconfig文件,你也可以在自己的工程目录里面定义自己的Kconfig文件,如何定义?依葫芦画瓢,仿照例子来即可。记住,在NCS或者Zephyr里面,只要可以用文本编辑器打开的文件,他们的语法都是直接可读的,不需要你另外去学习他们,直接仿照例子,你就可以定制自己的内容。

peripheral_uart工程除了prj.conf文件外,还有其他.conf文件,比如下面这些.conf文件:

跟prj.conf文件不一样,这些.conf文件的命名都是用户自己定的,因此Zephyr编译系统不会自动包含它们,如果你需要把这些.conf文件包含到自己的工程中,需要手动包含他们,如下:

除了prj.conf文件外,boards目录下面的以<board>.conf命名的.conf文件也是Zephyr标准conf文件,Zephyr编译系统可以自动找到他们,并和prj.conf里面的配置一起合并,需要注意的是,<board>.conf优先级高于prj.conf当一个宏同时在prj.conf和<board>.conf里面赋值,以<board>.conf里面的赋值为最后结果。请记住,按照Zephyr标准命名方式要求,目录名必须是“boards”,里面的.conf文件必须以板子名字来命名,否则系统将忽略它们,如下:

最后,请记住prj.conf里面的配置适用于所有板子,而<board>.conf仅适用于本board对应的工程

下面我们实际演示一下prj.conf配置修改,我们将日志的UART后端打开,如下:

然后重新编译并下载代码到板子中,打开串口助手,我们发现日志的确输出到串口中了:

5.2 修改Devicetree配置 

如前所述,每个仓库的boards和dts目录下定义了很多板子,这些板子是由dts/dtsi文件来描述的,dts/dtsi是宏定义的地方,如下:

而工程目录中的.overlay文件是引用宏或者给宏赋值的地方,如下:

以上面的current-speed为例,不管是在dts/dtsi文件,还是在overlay文件,他们的格式是一样的,都是current-speed = <数值>,但是只有在dts/dtsi定义过current-speed,你在overlay文件中才能引用它并对它重新赋值。至于current-speed = <115200>如何转换成#define DT_N_S_soc_S_peripheral_50000000_S_uart_c6000_P_current_speed 115200(位于devicetree_generated.h文件中),这个意义并不大,因为在我们的代码中,我们不是直接通过宏DT_N_S_soc_S_peripheral_50000000_S_uart_c6000_P_current_speed来引用这个波特率的,而是通过DeviceTree API来获取波特率,这个DeviceTree API会最终输出DT_N_S_soc_S_peripheral_50000000_S_uart_c6000_P_current_speed这个宏,所以我们不需要关心devicetree_generated.h这个文件,我们只需要关心DeviceTree的语法格式以及DeviceTree一些常用API就可以了。

5.2.1 Devicetree语法概述

我们还是以nrf\samples\bluetooth\peripheral_uart\build\peripheral_uart\zephyr\zephyr.dts为例来看看dts语法:

Devicetree语法的核心就是节点(node),一个dts文件包含多个节点,这些节点再组织成一个树形结构,树形结构的根就是“/”,换句话说,“/”下面包含多个节点,节点内又可以包含子节点,如下为uart@c6000节点定义:

              uart20: uart@c6000 {
                compatible = "nordic,nrf-uarte";
                reg = < 0xc6000 0x1000 >;
                interrupts = < 0xc6 0x1 >;
                status = "okay";
                endtx-stoptx-supported;
                frame-timeout-supported;
                current-speed = < 0x1c200 >;
                pinctrl-0 = < &uart20_default >;
                pinctrl-1 = < &uart20_sleep >;
                pinctrl-names = "default", "sleep";
            };

一般而言,一个节点定义包含如下要素:

  • 节点ID,每个节点都有一个node identifier,Devicetree API一般都是通过节点ID来找到这个节点的。节点ID是由编译系统自动生成的,不体现在dts文件里面,但它是Devicetree操作的核心。
  • 节点名,如uart@c6000,这个是必选的,@是名字的一部分,@后面跟着地址,可以是物理绝对地址,也可以是相对地址。我们可以通过/soc/peripheral_50000000/uart_c6000找到这个节点(dts文件里面符号名可以大写和小写,但代码中一律转成小写;符号名可以包含, @ - 等特殊字符,但代码中这些特殊字符都转成了下划线符号_),对应Devicetree API就是DT_PATH(soc, peripheral_50000000, uart_c6000),这种方式获得node ID还是有点复杂,我们更喜欢用下面这种方式
  • nodelabel,如uart20,非必选。一般而言,带有nodelabel的节点才是一个真正意义上的节点,它一般会对应一个物理设备,比如一个芯片外设或者外挂的一个传感器等,访问这种节点时,你可以不用DT_PATH方式,而直接采用DT_NODELABEL方式,比如DT_NODELABEL(uart20),来获取这个节点ID。带nodelabel的节点是一个“独立的”节点,你可以在overlay文件中直接引用这个节点;而不带nodelabel的节点就不那么独立了,你必须通过路径的方式来引用它,也就是放在根节点“/”里面,如下:

    另外大家一定要分清nodelabel和属性“label”的区别,有些节点会有一个属性“label”,这个只是巧合,属性也叫label,如下:    这个label跟我们的nodelabel没有半点关系。 

  • 属性(property),如compatible,status,current-speed等,都是属性,所有属性都放在{ };中,属性的值就是我们要的宏定义值,得到节点ID后,我们就可以通过DT_PROP来获得属性值,比如DT_PROP(DT_NODELABEL(uart20), current_speed),这个API最终将展开成DT_N_S_soc_S_peripheral_50000000_S_uart_c6000_P_current_speed,即115200。

我们运用上面的Devicetree节点知识来解读一下下面的overlay文件:

可以看出,根节点下面包含了chosen,aliases和zephyr,user三个节点,chosen包含一个属性:nordic,pm-ext-flash, aliases包含三个属性:myi2c,myspi,myuart,zephyr,user包含两个属性:io-channels和extint-gpios。我们可以通过Devicetree API获得这些属性的值,比如DT_ALIAS(myuart)就可以得到uart30的node ID。一些人估计会困惑,我们不是可以直接通过DT_NODELABEL(uart30)来获得uart30的node ID,为什么还要多此一举?其实这个是为了适配不同的硬件,有的硬件是uart30,有的硬件是uart0,我们引入一个别名myuart,从而兼容所有硬件。chosen的作用跟aliases差不多,zephyr,user用来操作GPIO的。

节点里面有一个特别的属性:compatible,编译系统通过compatible属性值找到这个节点对应的binding文件,这个binding文件以yaml为后缀,compatible属性值为名,实际上节点里面可以定义哪些属性,每个属性的取值范围等都是由这个yaml文件决定的,如下:

还有一个非常特殊的属性status,status有“okay”和“disabled”两种取值,“okay”表示使能节点对应的设备,“disabled”表示关闭节点对应的设备,此时即使设备驱动代码已经装载,它还是无法工作。有时,在Zephyr自带驱动中,系统会根据compatible和status这两个属性来决定要不要装载相应的驱动程序。

节点是Devicetree语法的核心,一般而言“{ };”包起来的就是一个节点,节点一般对应一个物理设备,每个节点都有节点ID,得到了节点ID,就得到了操作设备的“句柄”,我们就可以利用Devicetree API去读取节点里面的属性值,从而完成对相应设备的操作。

5.2.2 使用<board>.overlay和Devicetree Visual Editor修改Devicetree配置

我们一般通过修改overlay文件来改变Devicetree默认配置,我们经常使用的有app.overlay和<board>.overlay,如下:

需要注意的是,<board>.overlay优先级高于app.overlay,当一个属性值同时在app.overlay和<board>.overlay里面赋值,以<board>.overlay里面的赋值为最终结果。按照Zephyr标准命名方式要求,<board>.overlay所在目录的名字必须是“boards”,<board>必须是板子名字,app.overlay放在工程根目录下,而且大家必须使用跟它们一模一样的名字,包括文件名和后缀,这样Zephyr编译系统才能自动找到这两个文件,并将其合并,否则你需要手动选择非标准命名方式的overlay文件。另外,请记住app.overlay适用于所有板子的工程,而<board>.overlay仅适用于本board对应的工程

实际中,我们经常结合Nordic Devicetree Visual Editor和参考代码一起来完成设备树相关配置,修改Devicetree配置前,请先确保在应用工程根目录下有app.overlay文件或者在boards目录有<board>.overlay文件,之后我们所有的修改都放在这个overlay文件,而不是直接修改系统里面定义的dts/dtsi文件。

我们现在实际演示一下如何修改uart20波特率,在Applications窗口选择“peripheral_uart”工程,然后点击actions窗口的“Devicetree”条目,或者直接点击左边工具栏的“Devicetree Visual Editor”图标,如下:

找到uart20波特率配置地方,如下: 

 确认编辑器选择了boards\nrf54l15dk_nrf54l15_cpuapp.overlay这个文件,并更改波特率为230400,如下:

此时打开源文件boards\nrf54l15dk_nrf54l15_cpuapp.overlay,我们可以看到Editor自动把修改保存到这个文件,如下:

保存,编译并下载代码到板子中,你就会发现修改生效了。

我们再看Devicetree Visual Editor几个其他示例,如下为更改SPI引脚的操作示例:

除了通过spi00外设去修改pin脚,我们也可以通过pinctrl节点去修改spi00引脚,两者的修改最终都会体现在overlay文件中。

如下为配置ADC channel的操作示例:

上面的channel@0和channel@1需要先在overlay文件中定义,然后才能呈现在Devicetree Visual Editor中。

前面提到过chosen,aliases和zephyr,user三个特殊节点,这三个特殊节点一般直接在overlay文件进行修改,如果你的zephyr.dts文件没有包含这3个节点,那么在Devicetree Visual Editor将看不到这三个节点,反之,你的overlay文件或者dts文件包含了这3个节点,那么Devicetree Visual Editor就可以查看并编辑他们,如下:

zephyr,user是一个非常实用的节点,当你需要随意定义一个property,又不想写相应的binding文件,就可以将其定义在zephyr,user节点中,我们一般在这个节点中定义中断,GPIO和ADC通道。

上述演示中会用到这个例子https://github.com/aiminhua/ncs_samples/tree/master/ble_comprehensive(或者使用gitee链接https://gitee.com/minhua_ai/ncs_samples/tree/master/ble_comprehensive)。 

Devicetree Visual Editor还有一个非常实用的功能,就是图形化查看每个引脚的定义,以及引脚定义有没有冲突,如下:

通过上面的图,我们可以一目了然的知道每个引脚的定义,这在查找引脚定义有没有冲突时非常有用。

最后提一下,Devicetree Visual Editor是用来帮助你熟悉Devicetree语法的,一旦你熟悉了Devicetree语法(实际上你把https://github.com/aiminhua/ncs_samples/tree/master/ble_comprehensive这个例子里面的用法掌握好就差不多了),就可以抛开Devicetree Visual Editor,直接在overlay文件里面修改配置,这样效率最高。

5.2.3 外设驱动和Devicetree API

如前所述,通过Kconfig和Devicetree,Zephyr系统在初始化kernel的时候会同时初始化每个使能的外设驱动,也就是说,在跑main()之前,外设驱动已经装载并初始化成功,用户在main()之后是看不到外设初始化代码的,无法对其进行初始化处理,用户需在Devicetree中配置外设驱动初始化参数。外设驱动程序放在zephyr\drivers目录,大家可以仔细研究一下每个外设驱动是如何装载和初始化的,下面我们以Nordic I2C外设驱动为例,简要介绍Zephyr设备驱动模型。

打开文件zephyr\drivers\i2c\i2c_nrfx_twim.c:

可以看到这个驱动文件以及相关驱动是用Kconfig决定是否装载,然后通过Devicetree API定义了一系列常量,比如i2c设备,各种i2c设备初始化条目,以及常量初始化值,并通过kernel的z_sys_init_run_level()来自动调用i2c_nrfx_twim_init(),从而在kernel装载时自动完成i2c设备初始化工作。

I2C外设驱动初始化参数是直接从Devicetree配置中提取出来的,如下:

上面的I2C_FREQUENCY定义如下:

#define I2C_FREQUENCY(idx)      I2C_NRFX_TWIM_FREQUENCY(DT_PROP_OR(I2C(idx), clock_frequency,      \
                                   I2C_BITRATE_STANDARD))

可以看出,它直接提取下面clock-frequency的属性值:

I2C设备初始化成功后,就可以调用I2C传输函数了,如前所述,所有可供用户调用的I2C API都位于文件:zephyr\include\zephyr\drivers\i2c.h,比如i2c_write_dt/i2c_read_dt/i2c_write/i2c_read等,这些API最终都是调用i2c_transfer,i2c_transfer定义如下: 

api->transfer将回调如下函数:

仔细观察上述I2C操作API,你会发现他们都要求一个输入参数:const struct device *dev,即设备句柄,不仅是I2C,Zephyr所有设备都是通过设备句柄来引用和操作的,得到设备句柄是操作设备的第一步,一般而言,我们可以通过Devicetree API来获取设备句柄,比如DEVICE_DT_GET,它的定义如下:

可以看到这个API的输入参数就是节点ID,如前所述,节点ID可以通过DT_NODELABEL,DT_PATH,DT_CHOSEN,DT_ALIAS等获得,比如DT_NODELABEL定义如下:

这里要特别强调一下,这些Devicetree API都是大写的,因为他们本质上都是宏,而不是真正意义上的API,这些宏会在预编译阶段展开,如下面例子所示:

在开发nRF Connect SDK应用程序时,用到比较多的外设有:uart,spi,i2c,ADC,中断和IO口。下面我们将以nRF54L15DK为例,一步步详细讲解这几个外设的操作方式。

5.2.3.1 UART设备操作示例

假设我们要操作uart30,首先在overlay里面对它进行如下赋值:

&uart30 {
    status = "okay";
    current-speed = < 1000000 >;
    /delete-property/ rts-pin;
    /delete-property/ cts-pin;
    /delete-property/ hw-flow-control;
};

nRF54L15使用uart30,nRF52840有可能使用uart1,两个uart编号是不一样的,但是我们又想用同一个程序去兼容不同uart编号,为此我们定义如下uart别名:

aliases {
    myuart = &uart30;  
};    

这样在代码中我们可以引用myuart,而不同的板子可以对应不同的overlay文件,不同的overlay文件可以选择不同的uart编号,从而实现一个程序代码兼容不同uart外设。

uart设备句柄可以通过如下API获得:

const struct device *uart = DEVICE_DT_GET(DT_ALIAS(myuart));

uart发送函数示例如下:

struct uart_data_t *tx = k_malloc(sizeof(*tx));
uart_tx(uart, tx->data, tx->len, SYS_FOREVER_MS);

uart接收函数示例如下:

uart_rx_enable(uart, uart_rx_buf[0], sizeof(uart_rx_buf[0]), UART_WAIT_FOR_RX);

5.2.3.2 I2C设备操作示例

假设我们要操作i2c22,首先在overlay里面对它进行如下赋值:

&i2c22 {
    status = "okay";
    clock-frequency = <I2C_BITRATE_FAST>;
    pinctrl-0 = < &i2c22_default >;
    pinctrl-1 = < &i2c22_sleep >;
    pinctrl-names = "default", "sleep";
    i2c_dev_0: i2c_dev_0@50 {
        compatible = "i2c-device";
        reg = <0x50>;
    };
    i2c_dev_1: i2c_dev_1@b {
        compatible = "i2c-device";
        reg = <0x0B>;
    };      
};

由于I2C是总线,它下面需要外挂设备,这里我们假设外挂了i2c_dev_0(I2C从机地址为0x50)和i2c_dev_1(I2C从机地址为0x0B)。跟前面UART一样,为了兼容不同I2C外设,我们定义如下I2C别名:

aliases {
    myi2c = &i2c22; 
};    

I2C设备句柄可以通过如下API获得:

const struct i2c_dt_spec i2c_dev0 = I2C_DT_SPEC_GET(DT_NODELABEL(i2c_dev_0));
const struct i2c_dt_spec i2c_dev1= I2C_DT_SPEC_GET(DT_NODELABEL(i2c_dev_1));

I2C写函数示例如下:

i2c_write_dt(&i2c_dev0, (uint8_t *)&addr16, EEPROM_SIM_ADDRESS_LEN_BYTES);

I2C读函数示例如下:

i2c_read_dt(&i2c_dev0, pdata, size);

5.2.3.3 SPI设备操作示例

假设我们要操作spi21,首先在overlay里面对它进行如下赋值:

&spi21 {
    status = "okay";
    compatible = "nordic,nrf-spim";
    pinctrl-0 = <&spi21_default>;
    pinctrl-1 = <&spi21_sleep>;
    pinctrl-names = "default", "sleep";
    cs-gpios = <&gpio2 10 GPIO_ACTIVE_LOW>, <&gpio2 8 GPIO_ACTIVE_LOW>;
    spi_dev_0: spi_dev_0@0 {
        compatible = "spi-user-define";
        reg = <0>;
        spi-max-frequency = <DT_FREQ_M(4)>;
    };
    spi_dev_1: spi_dev_1@1 {
        compatible = "spi-user-define";
        reg = <1>;
        spi-max-frequency = <DT_FREQ_M(8)>;
    };                  
};

由于SPI是总线,它下面需要外挂设备,这里我们假设外挂了spi_dev_0(CS脚为P2.10)和spi_dev_1(CS脚为P2.08)。由于Zephyr系统自带的dts binding文件没有定义SPI子设备,我们还需要在工程根目录下面创建dts\bindings\spi-user-define.yaml,内容如下:

#
# Copyright (c) 2022 Kumar Gala <galak@kernel.org>
#
# SPDX-License-Identifier: Apache-2.0
#

description: |
    This binding provides resources required to build and run in Zephyr.

compatible: "spi-user-define"

include: [spi-device.yaml]

跟前面UART一样,为了兼容不同SPI外设,我们定义如下SPI别名:

aliases {
    myspi = &spi21; 
};    

SPI设备句柄可以通过如下API获得:

static struct spi_dt_spec spi_dev0 = SPI_DT_SPEC_GET(DT_NODELABEL(spi_dev_0), SPI_OP, 0);
static struct spi_dt_spec spi_dev1 = SPI_DT_SPEC_GET(DT_NODELABEL(spi_dev_1), SPI_OP, 0);

SPI传输函数示例如下:

spi_transceive_dt(&spi_dev0, &tx_set, &rx_set);

5.2.3.4 ADC设备操作示例

 假设打开ADC通道0和通道1,并将通道0映射到物理引脚AIN7,通道1直接接VDD,相关overlay配置如下:

&adc {
    status = "okay";
    #address-cells = <1>;
    #size-cells = <0>;

    channel@0 {
        reg = <0>;
        zephyr,gain = "ADC_GAIN_2_5";
        zephyr,reference = "ADC_REF_INTERNAL";
        zephyr,acquisition-time = <ADC_ACQ_TIME(ADC_ACQ_TIME_MICROSECONDS, 20)>;
        zephyr,input-positive = <NRF_SAADC_AIN7>;
        zephyr,vref-mv = <900>;
        zephyr,resolution = <10>;                    
    };
    
    channel@1 {
        reg = <1>;
        zephyr,gain = "ADC_GAIN_2_5";
        zephyr,reference = "ADC_REF_INTERNAL";
        zephyr,acquisition-time = <ADC_ACQ_TIME(ADC_ACQ_TIME_MICROSECONDS, 20)>;
        zephyr,input-positive = <NRF_SAADC_VDD>;
        zephyr,vref-mv = <900>;
        zephyr,resolution = <10>;                
    };

};

为了得到上面channel@0和channel@1两个节点里面的属性值,我们会在zephyr,user节点中定义io-channels属性,如下:

zephyr,user {
    io-channels = <&adc 0>, <&adc 1>;    
};

然后我们可以使用如下代码得到每个ADC通道的句柄:

#define DT_SPEC_AND_COMMA(node_id, prop, idx) \
    ADC_DT_SPEC_GET_BY_IDX(node_id, idx),

static const struct adc_dt_spec adc_channels[] = {
    DT_FOREACH_PROP_ELEM(DT_PATH(zephyr_user), io_channels,
                 DT_SPEC_AND_COMMA)
};

配置ADC通道的API见下:

adc_channel_setup_dt(&adc_channels[i]);

通过如下代码初始化ADC采样buffer并读取ADC采样值:

adc_sequence_init_dt(&adc_channels[i], &sequence);
adc_read_dt(&adc_channels[i], &sequence);

5.2.3.5 GPIO设备操作示例

我们先在zephyr,user中定义要操作的GPIO,如下:

zephyr,user {
    extint-gpios = <&gpio0 4 GPIO_ACTIVE_HIGH>;
};

通过下面代码获取GPIO设备句柄:

const struct gpio_dt_spec ext_int =
        GPIO_DT_SPEC_GET(ZEPHYR_USER_NODE, extint_gpios);

下面代码用来配置GPIO口:

gpio_pin_configure_dt(&ext_int, GPIO_INPUT | GPIO_PULL_UP);
gpio_pin_configure_dt(&ext_int, GPIO_OUTPUT);

操作GPIO口代码如下:

gpio_pin_set_dt(&ext_int, 0)
gpio_pin_get_dt(&ext_int)

5.2.3.6 外部中断设备操作示例 

跟GPIO设备一样,我们先在zephyr,user中定义要操作的外部中断IO口,如下:

zephyr,user {
    extint-gpios = <&gpio0 4 GPIO_ACTIVE_LOW>;
};

通过下面代码获取外部中断IO口设备句柄:

const struct gpio_dt_spec ext_int =
        GPIO_DT_SPEC_GET(ZEPHYR_USER_NODE, extint_gpios);

下面代码用来配置GPIO口:

gpio_pin_configure_dt(&ext_int, GPIO_INPUT | GPIO_PULL_UP);
gpio_pin_interrupt_configure_dt(&ext_int, GPIO_INT_EDGE_TO_INACTIVE);
gpio_init_callback(&ext_int_cb_data, ext_int_isr, BIT(ext_int.pin));
gpio_add_callback(ext_int.port, &ext_int_cb_data);

一旦外部中断发生,将执行如下ISR:

void ext_int_isr(const struct device *dev, struct gpio_callback *cb,
            uint32_t pins)
{
    LOG_INF("External interrupt occurs on pin 0x%x at %lldms", pins, k_uptime_get());  
    k_sem_give(&sem_i2c_op);    

 

我做了一个例子,除了包含上面的UART,SPI,I2C,ADC,中断,GPIO操作示例,它还包含QSPI,Flash访问,蓝牙OTA,蓝牙数据透传等操作示例,大家可以自己去研究一下这个例子,例子同时放在Github和Gitee上:

5.3 sysbuild和多image管理

有时一个应用会包含多个image,最典型的情况有三种:一是bootloader + app,bootloader一个image,app一个image,二是tfm + app,tfm一个image,app一个image(注:tfm是trustzone体系里面的安全image,用于装载非安全应用app),三是双核,应用核一个image,网络核一个image(比如nRF5340的应用核和网络核)。在NCS中,编译一个工程,会同时生成多个不同image,这种工程就称为multi-image工程。

传统的SDK,如果一个芯片包含多个image,那么每个image都会对应一个工程,用户需要同时维护多个工程,而且需要同时维护这几个工程的版本关联关系。为了简化多image管理,Zephyr引入了sysbuild概念。sysbuild用来将多个build系统(或者说多个工程)放在同一个build系统(或者说同一个工程)下管理,当你编译当前工程(为了叙述方便,暂且称其为主工程)时,sysbuild会自动把关联的其他工程(为了叙述方便,暂且称其为子工程)一起编译,并在当前工程的build目录下生成其他工程的build输出文件。配合Zephyr的Kconfig配置系统,可以在“神不知鬼不觉”的情况下,偷偷把其他外部工程包含进来,对开发者来说,你只面对一个工程,但编译的时候编译了多个工程,生成了多个image,而且每个子工程的配置可以在主工程里面指定,实现了一个工程管理多个image的目的。

如前所述,每个工程编译后都会生成一个zephyr.hex(zephyr.bin)文件,选用sysbuild后,除了在子工程目录下生成zephyr.hex,还会在build顶级目录下生成一个merged.hex,对于单image工程,这个merged.hex内容跟zephyr.hex内容一样;对于多image工程,这个merged.hex是各个工程的zephyr.hex合并而成。不管单image还是多image工程,用户直接下载merged.hex到板子就可以一次下完工程下面所有image。

我们现在直接看一个sysbuild的例子,使用nrf5340dk/nrf5340/cpuapp编译工程:nrf\samples\bluetooth\peripheral_uart,编译成功后,展开build目录,我们可以看到它下面包含两个工程:一个是跑在应用核的peripheral_uart工程,一个是跑在网络核的ipc_radio工程,如下:

点击“peripheral_uart”,nRF Connect VS code插件切换到此工程的操作界面,如下:

点击“ipc_radio”,nRF Connect VS code插件切换到此工程的操作界面,如下:

需要提醒大家的是,ipc_radio工程位于目录:nrf\applications\ipc_radio,这里只是把它的image编译进来了,管理这个工程还是要回到nrf\applications\ipc_radio,也就是说,在VS code里面对这个工程的源代码修改会直接影响原工程。一般来说,对于这种标准的外部工程 ,我们是不修改源代码的,我们只修改它的配置,而配置是可以在peripheral_uart工程目录下进行,下面对其进行阐述。

5.3.1 命名空间 

如前所述,在主工程目录下,sysbuild可以同时修改主工程和子工程的配置,每个工程都有自己的Kconfig和Devicetree,而且sysbuild系统也有自己的Kconfig,sysbuild怎么区分各个工程的Kconfig和Devicetree呢?或者说,在主工程目录下,如何给其他工程的Kconfig和Devicetree去赋值?这里面就要利用到sysbuild的命名空间概念,sysbuild把每个工程包括sysbuild系统本身都看作是一个独立的命名空间,所以同一个Kconfig可以同时存在各个命名空间中。在不引起混淆情况下,编译系统可以按照传统方式访问每个工程里面的Kconfig配置和Devicetree配置;当会引起混淆情况下,我们就用命名空间来区分配置的具体归属。如果是sysbuild系统级的配置(脱离于所有工程外),它的配置以SB_打头;如果是mcuboot的配置,它的配置以mcuboot打头;如果是网络核的配置,它的配置以ipc_radio打头......应用工程的命名空间就是应用工程的名字,这个只会在west命令行输入参数的时候会用到,平时应用工程作为默认工程直接访问它就可以了。

sysbuild系统级的Kconfig文件放在主工程根目录下,如下所示,Kconfig.sysbuild文件和sysbuild.conf文件就是sysbuild系统级配置文件,用来配置sysbuild系统本身。请注意,Kconfig.sysbuild和sysbuild.conf也是Zephyr标准命名,大家不能改,否则系统会直接忽略

sysbuild.conf大概长下面这样:(注:这是我临时添加进去的一个文件)

sysbuild系统级的Kconfig选项并不多,一般用来自动加载某些外部image,比如MCUboot,网络核image等,常见的Kconfig有SB_CONFIG_BOOTLOADER_MCUBOOT,SB_CONFIG_PM_EXTERNAL_FLASH_MCUBOOT_SECONDARY,SB_CONFIG_NETCORE_IPC_RADIO等,更多Kconfig见:https://docs.nordicsemi.com/bundle/ncs-latest/page/nrf/app_dev/config_and_build/sysbuild/sysbuild_forced_options.html

主工程以外的其他子工程的配置项都放在sysbuild目录下,并以每个子工程的名字作为配置文件的名字,如下:

 

命名空间内的配置项还是跟正常的配置项一样命名和赋值,比如mcuboot目录下prj.conf:

这里解释一下,为什么sysbuild.conf里面的Kconfig需要加命名空间SB,而上面的prj.conf不需要加命名空间mcuboot?其实两者都需要命名空间,因为默认的命名空间是主工程本身,主工程不需要加命名空间,其他工程都需要加命名空间,所以sysbuild.conf里面的Kconfig需要加SB_ 。上面的prj.conf是放在sysbuild/mcuboot目录下的,这个其实就是约定了它的命名空间为子工程mcuboot,命名空间约定好了,里面的Kconfig就不会跟主工程的搞混,也就不用画蛇添足加上mcuboot_之类的前缀。

5.3.2 用File Suffix(文件后缀)组织不同的配置文件集

我们经常会碰到一种情况:将工程区分为release版和debug版本,release简洁高效,debug版日志丰富方便调试,传统的做法是通过一个DEBUG宏来区分release版和debug版,但这种做法功能非常有限,因为很多时候release版和debug版不是一个DEBUG宏能区分的,需要多个宏,有时有可能连硬件连接都不一样,为此,Zephyr引入了File Suffix(文件后缀)机制,我们给.conf文件/overlay文件/pm文件加上同一个文件后缀,表示他们属于同一个配置文件集,当选择这个文件后缀时,所有相关配置文件将被自动选择并编译。

比如nrf\applications\nrf_desktop,定义了release,keyboard,fast_pair等多个文件后缀,如下:(我们只用红圈圈出了“release”文件后缀)

下面我们以用nRF5340DK编译nrf\samples\bluetooth\peripheral_uart为例,演示一下文件后缀的使用。在工程根目录下,我们看到如下文件后缀:bt_rpc:(注:这个文件后缀把整个蓝牙协议栈都跑在网络核)

添加编译配置时,我们按照如下方式指定bt_rpc文件后缀:

编译成功后,我们会发现sysbuild下面所有工程都会自动选择带bt_rpc后缀的配置,如下:

5.3.4 修改sysbuild Kconfig以自动加载MCUboot

如前所示,使用nRF54L15DK编译nrf\samples\bluetooth\peripheral_uart工程时,默认只编译一个app image,我们现在让这个工程同时编译app和MCUboot两个image,为此,我们在工程根目录下,新建一个sysbuild.conf文件,并在其中加入配置:SB_CONFIG_BOOTLOADER_MCUBOOT=y,如下:

 

然后我们什么都不改,按照之前的方式再编译一次这个工程,成功后,我们有:

可以看出,build目录下包含peripheral_uart和mcuboot两个工程,也就是说,编译peripheral_uart这个主工程的时候,不仅把peripheral_uart编译了,也把mcuboot工程编译进来了。点击“peripheral_uart”将切换到peripheral_uart工程,点击“mcuboot”将切换到mcuboot工程,每切换一次,下面的窗口操作对象也会跟着切换,比如当切换到mcuboot工程时,Details窗口将显示mcuboot工程的源代码,Actions窗口将操作mcuboot工程。

查看build目录,我们可以发现它包含三个工程的编译输出文件:sysbuild编译输出文件,peripheral_uart工程编译输出文件,以及mcuboot工程编译输出文件,如下:

每个工程的所有编译输出文件都放在自己目录下面,比如mcuboot工程,其所有的编译输出文件都放在mcuboot目录下,而mcuboot目录下的文件跟一个普通zephyr工程的编译输出文件基本上差不多,如下:

从上可以看出,虽然mcuboot工程放在别的地方,但通过sysbuild,我们在peripheral_uart工程中把mcuboot工程也编译进来了,实现了一个工程管理多个image的目的。虽然我们在VS Code里面可以查看mcuboot工程的源代码,但我们不建议在这里对mcuboot工程进行修改,一般我们只在这里修改mcuboot工程的默认配置,修改方式就是在根目录下建一个sysbuild目录(注:sysbuild是Zephyr标准命名),然后在sysbuild目录下创建mcuboot有关的配置文件,一般而言有两种方式(两种方式选其一即可):

  • 直接在sysbuild目录下创建mcuboot.conf(mcuboot.conf是Zephyr标准命名),这种修改适用于所有板子,也就是所有板子的mcuboot配置都将一样。

  • 在sysbuild目录下创建mcuboot目录(mcuboot是Zephyr标准命名),然后在mcuboot目录下创建需要的配置文件,这种方式适用于每个板子都有自己独立的配置。

     

5.4 存储空间分区和pm_static.yml

如前所述,使能mcuboot后,peripheral_uart工程将生成两个image:app(peripheral_uart本身)和mcuboot,这两个image都放在存储器什么位置。在nRF Connect SDK中,有两种方式可以指定image的装载地址,一个是Devicetree方式,一个是Partition Manager(PM),这两种方式异曲同工,都能实现多image位置管理功能。

还是以上面的peripheral_uart工程为例,当使能mcuboot后,编译目录会多出如下文件:

partitions.yml,pm.config,regions.yml以及build\peripheral_uart\zephyr\include\generated\pm_config.h都是由Partition Manager(PM)生成的,跟Kconfig一样,这些名字都是Zephyr标准命名,PM工作的时候会把partitions.yml先转成pm.config再转成pm_config.h,最终通过pm_config.h在代码中发挥作用。PM本质上就是一个Python脚本,根据系统默认输入或者用户自定义输入,将每个image位置,用户数据区,以及文件系统区的位置和大小指定好,并将最终结果表现在上述生成文件上,不要被这些文件奇怪的后缀名迷惑,你可以直接把他们当成txt文件来查看和编辑。PM的工作就是把芯片的内部存储区以及外部Flash存储区,划分成一块一块(即分区),并给每块取个名字,但这些名字不能随便取,要跟nRF Connect SDK里面的驱动程序相匹配,实际上,大家只要编译一个标准工程,就会生成标准的partitions.yml文件,大家通过它就可以知道每个分区的标准名字。

PM是如何工作的呢?PM首先假定有一个app image,也就是我们的主工程对应的image,然后根据sysbuild配置来决定是否有其他子image要装载,如果有子image要装载,那么到该子image所在的工程目录下,找到pm.yml文件(pm.yml是Zephyr标准命名),根据这个文件来决定子image和app image的相对位置,比如mcuboot子image对应的pm.yml如下所示:

pm.yml是按照相对位置来决定子image位置的,而且里面会用到Kconfig或者DeviceTree的宏定义。这个pm.yml文件直接按照英文字义来排列各个image的位置,比如mcuboot放在mcuboot_primary之前,app属于mcuboot_primary_app,等等。大家困惑的是:怎么忽然多出了这么多分区以及奇奇怪怪的名字?这些分区或者奇奇怪怪的名字跟PM无关,它们是mcuboot进行OTA升级的产物,具体可以参考:https://www.cnblogs.com/iini/p/16085811.html,里面会详述OTA的原理。这里简要介绍一下各个分区的含义。

MCUboot把运行的app所在的区域称为primary区域,即mcuboot_primary;把存放升级文件的区域称为secondary区域,即mcuboot_secondary。mcuboot_primary除了存放app,即mcuboot_primary_app,还需要存放这个app对应的header信息,即mcuboot_pad。mcuboot_primary/mcuboot_secondary/mcuboot_primary_app/mcuboot_pad等都是Zephyr标准命名,大家必须使用跟他们一模一样的名字,否则OTA升级会有问题。这些分区都是针对NVM(Flash)区的,MCUboot不对RAM区进行特殊指定。关于secondary区域,它可以放在内部NVM(Flash)区,也可以放在外部Flash区,如下:

一般来说,使用PM自动生成的分区文件就可以了,如果需要人为指定某个image的位置,也就是说需要对自动生成的partitions.yml进行修改,怎么办呢?其实很简单,把partitions.yml这个文件拷出来,放在工程的根目录下,然后将其重新命名为:pm_static.yml或者pm_static_<board>.yml(请注意,pm_static.yml是Zephyr标准命名,大家只能使用这个名字,否则会被忽略,然后大家就可以按照自己的需求将里面的值进行修改。一旦PM检测到工程根目录下面有pm_static.yml或者pm_static_<board>.yml文件,它就会优先使用pm_static里面的定义,然后补全pm_static没有定义的分区。

所谓分区(Partition),就是对NVM(Flash)(包括内部Flash和外部flash)或者RAM物理区域进行一个逻辑划分,人为划定哪块区域干什么工作,比如把mcuboot这个image放在0x0000到0xC000这块区域。这种分区是人为的,所以你可以随意调整,比如你把mcuboot放在0x0000到0x10000区域,当然也是可以的。我们对Flash或者RAM进行分区,目的就是为了把空间利用好,给各个分区一个ID以便代码可以引用它使用它。当然代码也可以不操作这个分区,这种情况分区只是一个占位符而已,比如app和mcuboot这两个分区。每个分区规定了它的起始地址,结束地址,大小,相对位置以及放在什么物理存储器上,比如mcuboot_primary这个分区:

很多人会疑问,既然指定了分区的起始地址和结束地址,那还有必要去指定各个分区的相对位置吗?这种情况下的确没必要再指定相对位置了,其实这里弄反了一件事情:partitions.yml里面的起始地址和结束地址是相对位置定下来之后的结果。使用相对位置,为编译系统动态确定各个分区的位置提供了便利。如果是我们自己来划分存储器的分区,我们就可以直接使用绝对地址的方式静态指定各个分区的位置。

下面是一个pm_static_nrf54l15dk_nrf54l15_cpuapp.yml文件示例:

mcuboot:
  address: 0x0
  region: flash_primary
  size: 0xc000
mcuboot_pad:
  address: 0xc000
  region: flash_primary
  size: 0x800
app:
  address: 0xc800
  region: flash_primary
  size: 0x165800
mcuboot_primary:
  orig_span: &id001
  - mcuboot_pad
  - app
  span: *id001
  address: 0xc000
  region: flash_primary
  size: 0x166000
mcuboot_primary_app:
  orig_span: &id002
  - app
  span: *id002
  address: 0xc800
  region: flash_primary
  size: 0x165800
settings_storage:
  address: 0x172000
  region: flash_primary
  size: 0xb000
mcuboot_secondary:
  address: 0x0
  orig_span: &id003
  - mcuboot_secondary_pad
  - mcuboot_secondary_app
  region: external_flash
  size: 0x166000
  span: *id003
mcuboot_secondary_pad:
  region: external_flash
  address: 0x0
  size: 0x800
mcuboot_secondary_app:
  region: external_flash
  address: 0x800
  size: 0x165800
external_flash:
  address: 0x166000
  size: 0x69a000
  device: MX25R64
  region: external_flash

这个分区文件把mcuboot_secondary放在外部Flash区域,同时外部Flash剩余区域都放在分区external_flash中(请大家注意分区external_flash和region external_flash两者是完全不搭架的,这里名字相同是一个巧合)。由于采用了<board>后缀标准命名方式,这个分区文件只适用于板子:nrf54l15dk_nrf54l15_cpuapp,其他板子将不受它影响。

5.5 nRF Connect SDK重要编译系统变量小结

前面提到了nRF Connect SDK众多配置选项:Kconfig,Devicetree,sysbuild,文件后缀和PM,当编译一个工程的时候,我们可以通过命令行方式将他们的配置文件传给编译系统,确切说,传给编译系统变量,这些变量拿到这些配置文件后,会按照配置文件的要求,配置和编译当前工程。大家经常碰到的编译系统变量如下所示:

nRF Connect SDK常用编译系统变量

变量名

目的

如何使用

Name of the Kconfig option

为单个构建将给定的 Kconfig 选项设置为特定值。
Kconfig 选项名称可能会受到变量命名空间和 sysbuild Kconfig 命名空间的影响

-D<name_of_Kconfig_option>=<value>

CONF_FILE

选择要用于应用程序的基础 Kconfig 配置文件,并覆盖自动选择过程。
如果应用程序或示例支持多种构建类型,此变量也可用于选择其中一种可用的构建类型。
-DCONF_FILE=<file_name>.conf
-DCONF_FILE=prj_<build_type_name>.conf

SB_CONF_FILE

选择要用于应用程序的基础 sysbuild Kconfig 配置文件,并覆盖自动选择过程

-DSB_CONF_FILE=<file_name>.conf

EXTRA_CONF_FILE

提供要与基础配置文件 “混合” 的额外 Kconfig 片段文件

-DEXTRA_CONF_FILE=<file_name>.conf

DTC_OVERLAY_FILE

选择要用于应用程序的基础设备树覆盖文件,并覆盖自动选择过程

-DDTC_OVERLAY_FILE=<file_name>.overlay

EXTRA_DTC_OVERLAY_FILE

提供额外的自定义设备树覆盖文件,以与基础设备树覆盖文件 “混合”

-DEXTRA_DTC_OVERLAY_FILE=<file_name>.overlay

SHIELD

选择一种受支持的开发板扩展板(shield)来构建固件

-DSHIELD=<shield> (-D<image_name>_SHIELD for images)

FILE_SUFFIX

如果应用程序或示例支持,选择一种可用的带后缀的配置

-DFILE_SUFFIX=<configuration_suffix> (-D<image_name>_FILE_SUFFIX for images)

-S (west) or SNIPPET (CMake)

选择一个代码片段(Snippet),以便在构建期间添加到应用程序固件中

-S <name_of_snippet> (applies the snippet to all images)
-DSNIPPET=<name_of_snippet> (-D<image_name>_SNIPPET=<name_of_snippet> for images)

PM_STATIC_YML_FILE

为分区管理器(Partition Manager)脚本选择一个静态配置文件

-DPM_STATIC_YML_FILE=pm_static_<suffix>.yml

 
上面这些变量名字都是Zephyr标准命名,你只能使用跟它们一模一样的名字,否则编译系统不予识别。你可以在nRF Connect VS Code插件或者west命令行使用这些编译系统变量,编译系统会根据变量的赋值顺序依次装载相关配置。
nRF Connect VS code插件在Add Build Configuration页面中指定这些变量对应的配置文件,如下:
  • CONF_FILE - 在Base configuration files中选择

  • EXTRA_CONF_FILE - 在Extra Kconfig fragments中添加

  • DTC_OVERLAY_FILE - 在Base devicetree overlays中选择

  • EXTRA_DTC_OVERLAY_FILE - 在Extra devicetree overlays中添加

  • SNIPPET - 在Snippets中选择

  • FILE_SUFFIX及其他变量 - 在Extra CMake arguments中提供,并以--开头

West命令行直接通过如下方式指定: 

west build -b nrf54l15dk/nrf54l15/cpuapp -- -DOVERLAY_CONFIG="overlay1.conf;overlay2.conf"

 

6. 让peripheral_uart支持蓝牙空中升级 

由于nRF Connect SDK组件丰富,配置灵活,我们可以非常容易得就实现蓝牙OTA升级功能。我们以nrf\samples\bluetooth\peripheral_uart为例,使其加入蓝牙OTA功能。peripheral_uart是一个蓝牙数据透传应用,默认是不带bootloader的,为了实现OTA功能,第一步就要使能bootloader,为此我们在工程根目录下新建一个sysbuild.conf文件,并在这个文件里面使能mcuboot,如下: 

SB_CONFIG_BOOTLOADER_MCUBOOT=y

然后修改prj.conf文件,加入如下配置:

CONFIG_NCS_SAMPLE_MCUMGR_BT_OTA_DFU=y
CONFIG_NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP=y
CONFIG_NCS_SAMPLE_MCUMGR_BT_OTA_DFU_VALIDATION=y

如果为了禁止手机app操控用户数据存储区,可以加入如下配置:

CONFIG_MCUMGR_GRP_ZBASIC_STORAGE_ERASE=n

上面这个选项不是必须的,如果没有这个选项,手机app就可以擦除用户数据存储区,这样当系统出问题了,可以帮忙恢复系统。

至此,peripheral_uart就具备了蓝牙OTA升级功能。(是不是有点意外,感觉太不可思议了,这就是nRF Connect SDK的魅力!)下面我们来测试一下这个蓝牙OTA升级功能。

  1.  编译并下载修改后的peripheral_uart工程
  2. 将nRF54L15 DK插入电脑,并使用串口助手打开其中一个串口,可以看到如下日志:

  3. 打开手机app:nRF Connect,此时可以看到如下广播:

     

  4. 连接这个蓝牙设备,成功后,我们可以看到SMP服务,说明这个设备已经具备蓝牙OTA功能。

     

  5. 生成新image。我们将peripheral_uart工程的广播名改为:new_dfu,为了加快编译速度,我们直接在main.c里面修改,如下:

  6. 编译修改后的工程(不要点击下载哦),此时我们得到升级文件:dfu_application.zip,如下:

     有的人不喜欢zip升级包,想直接用bin升级包,这个也是可以的。如果需要bin格式的升级文件,请使用zephyr.signed.bin,它在如下目录:

  7. 将dfu_application.zip或者zephyr.signed.bin通过微信或者其他方式拷贝到手机
  8. 再次打开手机app:nRF Connect,点击右上角的DFU

     

  9. 选择刚才拷贝过来的升级文件:dfu_application.zip或者zephyr.signed.bin
  10. 选择“Test and Confirm”

     

  11. 点击OK后,升级正式开始,可以看到如下升级界面

     同时在串口助手,我们可以看到如下日志:

  12. 断开蓝牙连接,我们可以看到设备的广播名已经改成:new_dfu,如下:

     

  13. 至此,整个蓝牙OTA升级成功完成

上面的例子是把升级image放在内部Flash中,也就是说,原始image和升级image共享内部Flash区域,对nRF54L15来说,意味着你的原始image大小大概不能超过1.5MB/2 = 750kB。如果你的代码过大,原始image和升级image无法共享内部Flash区(这个在nRF52芯片家族非常常见),那么可以把升级image放在外部Flash,大家可以参考前面的ble_comprehensive例子,它已经实现了蓝牙OTA功能,并把升级image放在外部Flash,链接如下: 

除了把升级image放在外部Flash这种方案,如果原始image不是特别巨大,那么也可以考虑采用压缩OTA,也就是说把升级image压缩,压缩到原来的70%到50%大小,然后把原始image和压缩后的升级image都放在内部Flash,这样就可以节省一个外部Flash,具体实现细节请参考文档:https://docs.nordicsemi.com/bundle/ncs-latest/page/nrf/samples/nrf_compress/mcuboot_update/README.html

7. 将一个nRF5 SDK例子移植到nRF Connect SDK中

前面多次提到的ble_comprehensive例子,已经把nRF5 SDK里面的SPI例子:nRF5_SDK_17.1.0_ddde560\examples\peripheral\spi,以及i2c例子:nRF5_SDK_17.1.0_ddde560\examples\peripheral\twi_master_with_twis_slave移植到nRF Connect SDK中,大家可以对比一下,看看移植是如何完成的。ble_comprehensive例子链接如下:

posted on 2020-12-22 18:02  iini  阅读(91395)  评论(34)    收藏  举报

导航