Skip to content

安装 nRF Connect SDK 和 VS Code

按照以下步骤安装 nRF Connect SDK、其工具链及 VS Code 集成开发环境。自 2023 年 9 月起,可直接通过 VS Code 下载 nRF Connect SDK 及其工具链,本指南将详细说明此过程。

在前三个步骤(1-3)中,我们将下载用于向 DevAcademy 支持的各款 Nordic Semiconductor 开发套件烧录固件所需的工具(Segger J-Link、nrfutil 及 nrfutil device 命令)。

从 SEGGER J-Link 软件下载适用于您平台的安装程序。 运行安装程序;在安装过程中出现“选择可选组件”窗口时,务必勾选“为 J-Link 安装传统 USB 驱动程序”,该驱动是某些支持开发套件的必备组件。

2. 安装 nrfutil 及 nrfutil device 命令

2.1. 从 nRF Util 产品页面 下载与您操作系统兼容的二进制文件,并保存到磁盘驱动器(例如 Windows 系统可存为 C:\nordic_tools\nrfutil.exe)。

若使用 macOS 或 Linux 系统,可将其存放在已添加到系统 PATH 的目录中(例如 /usr/bin/),这样可跳过步骤 2.2。

对于 Linux 系统,请记住 nrfutil 有一些先决条件,这些条件列在 nRF Util 先决条件安装中。如果您的机器上尚未安装这些条件,请确保也下载它们。

2.2.(Windows)更新系统的 PATH 以包含 nrfutil 的存储位置。打开“编辑账户的环境变量”并添加存储 nrfutil 二进制文件的路径(C:\nordic_tools\)。

2.3. 您刚刚下载的 nrfutil 二进制文件不包含任何预安装的命令。在这一步中,我们将升级核心 nrfutil 并下载 device 命令。

2.3.1 为确保我们拥有最新版本的 nrfutil,请在终端(命令提示符或 PowerShell)中运行以下命令。使用哪个终端无关紧要,因为在步骤 2.2 中已全局设置了 nrfutil。

text
nrfutil self-upgrade

2.3.2 安装 nrfutil device 命令

我们需要使用 device 命令将二进制文件烧录至开发套件。

在您当前活动的终端中,输入:

text
nrfutil install device

您应看到如下输出:

text
[00:00:02] ###### 100% [Install packages] Install packages

3. 安装 VS Code

前往 https://code.visualstudio.com/download 下载并安装与您操作系统匹配的版本。

4. 安装 nRF Connect 扩展包

在活动栏中,点击扩展图标,然后在搜索框中输入 nRF Connect for VS Code 扩展包 ,并点击安装 ,如下图所示:

nRF Connect for VS Code 扩展包使开发者能够利用广受欢迎的 Visual Studio Code 集成开发环境(VS Code IDE)来开发、构建、调试和部署基于 Nordic nRF Connect SDK(软件开发工具包)的嵌入式应用。该扩展包含编译器接口、链接器、完整构建系统、支持 RTOS 的调试器、与 nRF Connect SDK 的无缝对接、设备树可视化编辑器以及集成的串行终端等多项实用开发工具。

VS Code 的 nRF Connect 扩展包包含以下组件:

  • nRF Connect for VS Code:主扩展包含构建系统和 nRF Connect SDK 的接口,同时提供管理 nRF Connect SDK 版本和工具链的界面。

  • nRF DeviceTree:提供设备树语言支持及设备树可视化编辑器。

  • nRF Kconfig:提供 Kconfig 语言支持。

  • nRF 终端:串行和 RTT 终端。

  • Microsoft C/C++:为 C/C++添加语言支持,包括 IntelliSense 等功能。

  • CMake:CMake 语言支持。

  • GNU 链接器映射文件:支持链接器映射文件。

我们可以通过扩展下载任意偏好的 nRF Connect SDK 版本及其工具链。完整的 nRF Connect for VS Code 文档可在此处查阅。

5. 安装工具链

工具链是一组协同工作以构建 nRF Connect SDK 应用程序的工具集合,包含汇编器、编译器、链接器及 CMake 等组件。

首次打开 nRF Connect for VS Code 时,系统会提示您安装工具链。这通常发生在扩展程序未在您的计算机上检测到任何已安装工具链的情况下。

点击安装工具链 ,系统将列出可下载并安装至您计算机的工具链版本。请选择与您计划使用的 nRF Connect SDK 版本相匹配的工具链版本。我们始终推荐使用最新标记版本的 nRF Connect SDK。

默认情况下,nRF Connect for VS Code 仅显示工具链的已发布标签(即稳定版本)。若您正在评估新功能并希望使用预览标签或其他类型标签(例如客户采样 -cs),请点击"显示所有工具链版本",如下图所示:

请注意,生产代码应仅使用已发布的标签版本。

6. 安装 nRF Connect SDK

在 VS Code 的 nRF Connect 扩展中,点击管理 SDK。通过管理 SDK 菜单,我们可以安装或卸载 nRF Connect SDK 版本。由于这是首次使用该扩展,界面将仅显示两个选项。

点击安装 SDK,系统会列出可下载并安装到本机的所有可用 nRF Connect SDK 版本。请选择项目开发所需的 nRF Connect SDK 版本。

若您已在 VS Code 中打开 SDK 文件夹,将不会显示管理 SDK 菜单选项,而是看到管理 west 工作区 。要解决此问题,请在 VS Code 中打开另一个窗口或文件夹。

如果看不到这两个选项,请确保您安装了最新版本的 nRF Connect for VS Code 扩展包。

值得注意的是,nRF Connect SDK 与 IDE 无关,这意味着您可以选择使用任何 IDE 或完全不使用 IDE。通过 nRF Util (nrfutil)命令行界面(CLI)即可下载并安装 nRF Connect。不过,我们强烈推荐搭配 VS Code 使用我们的 nRF Connect for VS Code 扩展包,因为它不仅集成了便捷的图形用户界面(GUI)和高效命令行界面(CLI),还包含诸多能极大简化固件开发的功能。若要将其他 IDE 配置为与 nRF Connect SDK 协同工作,则需要执行本课程范围之外的额外手动步骤。

构建并烧录首个 nRF Connect SDK 应用

在本练习中,我们将基于 blinky 示例编写一个简单应用,用于控制开发板上的 LED 灯闪烁。同样适用于所有支持的 Nordic Semiconductor 开发板(nRF54、nRF53、nRF52、nRF70 或 nRF91 系列)。目的是确保构建和烧录示例所需的所有工具都正确设置。重点是学习如何通过“复制示例”模板创建应用,构建应用,并将其烧录到 Nordic 芯片开发板上。

1. 创建文件夹

在根目录附近创建一个文件夹,用于存放本课程中我们将完成的所有练习

我们将在 C 盘创建该文件夹,路径为 C:\myfw\ncsfund。请避免将应用程序存储在路径过长的位置,因为在某些操作系统(如 Windows)上,如果应用程序路径过长,构建系统可能会失败。同时,路径中请勿使用空格和特殊字符。

2. 创建应用程序

在 VS Code 中,点击 nRF Connect 扩展图标。在欢迎视图中,点击创建新应用程序。

3. 在创建新应用程序时,选择复制示例

你将看到三个选项:

  • 创建空白应用:会生成一个带有空 main() 函数的空白应用;

  • 复制示例:则会展示来自 nRF Connect SDK 各模块的所有模板"示例",允许你基于模板创建应用。请注意,若机器上安装了多个 SDK 版本,系统会提示选择从哪个 SDK 版本复制示例。

  • 而浏览应用索引 (本课程不涉及):用于从在线应用索引中复制与 nRF Connect SDK 兼容的现有树外应用版本,并围绕其设置 west 工作区仓库。

该 SDK 包含丰富的模板集。在模板语境下:

  • 示例是简单展示单一功能或库的模板(如 SHA256 示例、UART 示例、PWM 示例、LED 闪烁示例等)

  • 应用程序通常较为复杂,包含多种库以实现特定用例(例如资产追踪应用、键盘应用、鼠标应用)。

3.1 在搜索栏输入 blinky,选择第二个 Blinky 示例(路径为 zephyr/samples/basic/blinky),如下图所示。

Blinky 示例会使开发板上的 LED1 灯持续闪烁。

我们的首个应用将以 Blinky 示例为基础。Blinky 示例源自 nRF Connect SDK 中的 Zephyr 模块,因此您会在示例路径中看到 zephyr 名称:zephyr\samples\basic\blinky。

3.2 输入您希望存储应用程序的完整路径。

1. 选择您希望存储应用程序的位置。输入您在步骤 1 中创建的目录(例如:C:\myfw\ncsfund\)。

2. 将你的应用程序命名为 l1_e2。这是我们本课程中练习将采用的命名规范。请注意,这将创建一个名为 l1_e2 的应用程序文件夹。

输入完整路径后,按回车键确认。

VS Code 会询问您是想在同一 VS Code 实例中打开应用程序还是新建一个 VS Code 实例。选择打开以在同一 VS Code 实例中打开应用程序。

这将复制所选模板 “Blinky 示例”,将其存储在你指定的应用程序目录中,并添加一个未构建的应用程序到 VS Code,如下所示:

nRF Connect for VS Code 可能会显示未构建应用程序的头文件未找到的错误波浪线,这些通常出现在“问题”选项卡中。请在此阶段忽略这些错误波浪线 ,一旦您添加并构建配置(下一步),这些错误将自动解决。

4. 添加构建配置

nRF Connect SDK 的众多优势之一在于应用程序源代码与软件配置/硬件描述之间的高度解耦,这使得为新的硬件或软件配置切换构建变得极其简单。

本步骤将指定我们要为其构建应用程序的开发板或自定义板(硬件)。我们还将选择构建中要使用的软件配置(*.conf 及可能的设备树覆盖文件)。

您在“添加构建配置”中设置的内容决定了传递给底层命令行工具 west 的参数。

在应用程序视图中,点击应用名称下方的添加构建配置 。

这将打开如下所示的添加构建配置窗口:

首先需要注意的是,图形界面会显示用于构建应用程序的 SDK/工具链版本。请确认该版本与您计划使用的 SDK/工具链以及复制示例的 SDK 版本一致。若不一致,请点击下拉箭头选择正确版本。

4.1. 使用开发板目标 ,选择您要烧录应用程序的目标开发板。

这是最基础的一步。您得先告诉系统,您的代码最终要跑在哪块具体的硬件板子上(比如 nrf52840dk_nrf52840)。系统知道了主板型号,才能知道它有多少内存、CPU是什么、有哪些默认接口等信息。

4.2 我们将保持其余部分的默认构建配置 ,但理解这些配置各自的作用非常重要。

4.3. 基础配置文件(Base configuration files)。根据您在步骤 3 中选择的模板,您将看到至少一个基础应用配置文件 prj.conf。某些模板包含多个应用配置文件(例如:prj.conf、prj_minimal.conf、prj_cdc.conf)。模板中若存在这些不同配置,其说明可在模板文档中找到。闪烁灯模板仅有一个选项,这相当于 west 中的 FILE_SUFFIX (及 CONF_FILE)。

这个文件 (prj.conf) 就是您的软件功能清单。您通过写 CONFIG_...=y 这样的“开关”来告诉系统:“我要开启蓝牙功能”、“我要开启日志打印功能”、“我要用这个特定的传感器驱动”。这是您最常修改的文件之一。几乎所有软件功能的开启/关闭都在这里配置。

4.4. 额外 Kconfig 片段(Extra Kconfig fragments):字段将列出模板中可用或添加到应用程序文件夹中的 Kconfig 片段。这些是对应用程序配置文件的修改器(第 3 课将详细介绍)。闪烁灯模板中未包含此类片段。该功能等同于 EXTRA_CONF_FILE 在 west 工具中的配置参数。

假设您不想修改标准的 prj.conf 文件,但又想临时增加或改变一些配置(比如,只在调试时开启某个功能)。您可以把这些临时的改动写在一个单独的 .conf 文件里,然后在这里加载它。这个“升级包”的配置会覆盖“标准套餐”里的同名配置。当您想维护多个应用版本时。例如,可以创建一个 debug.conf 文件,里面写上 CONFIG_LOG=y,只在需要调试时挂上这个文件,而不用去改动 prj.conf

4.5. 基础设备树覆盖层(Base Devicetree overlays):字段将列出模板中可用或已添加到应用程序文件夹中的设备树覆盖层。这些是对硬件描述的修改(将在第 3 课中介绍)。闪烁灯模板没有这些内容。这相当于 west 中的 DTC_OVERLAY_FILE。

虽然您选了主板型号,但您可能在上面做了些“魔改”,比如外接了一个非官方的屏幕到主板的某个引脚上。这个 .overlay 文件就是用来告诉系统:“听好了,我把一个I2C屏幕接在了P0.26和P0.27引脚上,你待会儿驱动它的时候别找错地方了。”当您外接了任何自定义硬件(传感器、屏幕、按钮等)时,就必须用它来描述硬件连接。

4.6. 额外设备树覆盖层(Extra Devicetree overlays)。它提供了额外的自定义设备树覆盖文件,将与基础设备树覆盖文件"混合"使用。这相当于 west 中的 EXTRA_DTC_OVERLAY_FILE。

和“额外Kconfig片段”类似,它允许您在不修改基础硬件魔改图纸的情况下,再叠加一层硬件改动。在复杂项目中,比如您的产品有一个主板,还可以选配A、B两种不同的扩展板。您可以为扩展板A和B分别创建一个overlay文件,根据需要挂载。

4.7. 代码片段(Snippets)。代码片段将软件配置(Kconfig)和硬件配置(Devicetree)整合在一个包中。SDK 中有多个代码片段

有些复杂功能(比如DFU无线升级、安全启动),需要同时修改软件配置(Kconfig)和硬件配置(Devicetree)。代码片段把这些需要同步修改的东西打包好了,您只需要勾选这个“大礼包”,系统会自动帮您应用所有相关的软硬件配置。当您要集成SDK里提供的某个复杂子系统时,用这个最方便,能避免手动配置出错。

4.8 构建目录名称(Build directory name):字段允许您手动指定最终二进制文件及临时构建文件的存储目录。我们将保留工具默认指定的名称,即 build;若此前已构建过应用程序,则目录名会显示为 build_1。

4.9 优化级别(大小、速度或调试)。此处我们有四个选项。

  • 使用项目默认值(Use project default):使用应用程序配置文件(prj.conf)中设置的项目默认值,该配置文件将在第 3 课中详细介绍。

  • 调试优化 (-Og)(Optimize for debugging (-Og)):若计划调试应用程序并使用 nRF Connect for VS Code 中的 RTOS 感知调试器,设置此选项至关重要。在 GUI 中选择此选项将向构建系统传递以下 Kconfig 符号:CONFIG_DEBUG_THREAD_INFO="y" 以及 CONFIG_DEBUG_OPTIMIZATIONS="y"

(开发阶段必选!)**告诉师傅:“别把线路缠得太死,所有零件都要方便我拆开检查。” 这会保留所有调试信息,让您可以在VS Code里单步跟踪代码、查看变量。

  • 速度优化(-O2)(Optimize for speed(-O2)):这将向构建系统传递以下 Kconfig 配置 CONFIG_SPEED_OPTIMIZATIONS="y"

告诉师傅:“性能第一!让它跑得越快越好!” 编译器会用各种方法提升代码运行速度,但可能会让程序体积变大。

  • 优化体积(-Os)(Optimize for size (-Os)):这将向构建系统传递以下 Kconfig 配置 CONFIG_SIZE_OPTIMIZATIONS="y"

告诉师傅:“越小越好!我要把它塞进一个很小的机箱里。” 编译器会尽力压缩代码体积,这对于内存(Flash)有限的芯片至关重要。

在本练习中,我们将保留使用项目默认值(Use project default)的选项。但需注意,若计划调试应用程序,务必记得选择优化调试 (-Og)(Optimize for debugging (-Og))。

保留生成配置后构建选项(Build after generating configuration)的勾选状态,这样在点击构建配置(Build Configuration)按钮后即可触发构建流程。

4.11. 自 nRF Connect SDK v2.8.0 版本起,Sysbuild 功能默认启用。当您的应用程序包含多个镜像时,Sysbuild 尤为重要。nRF5340这样的芯片有多个核心(一个应用核,一个网络核)。Sysbuild是一种高级构建系统,可以一次性地、协调地编译好几个镜像(比如应用核的程序和网络核的程序),并确保它们能正确协同工作。当您使用像nRF5340这样的多核芯片,或者您的项目包含一个需要独立编译的Bootloader时,Sysbuild会让整个流程变得简单。

小结:

什么是 West?

在您看到的那些图形界面(GUI)选项背后,真正干活的其实就是它。

west 是什么?一句话解释

west 是 Zephyr 项目的官方命令行工具,一个“项目总管家”。

您可以把它想象成一个万能的瑞士军刀,专门用来管理、构建和调试复杂的 Zephyr(以及 nRF Connect SDK)项目。

为什么需要 west 这么一个“总管家”?

一个现代的物联网项目,比如您在 nRF Connect SDK 中创建的任何一个应用,都不是一个单一的、完整的代码库。它其实是一堆独立的代码仓库(Repositories)的集合体,像这样:

  • Zephyr RTOS 内核:一个仓库。

  • Nordic 的硬件驱动和库:另一个仓库。

  • 安全相关的模块:又一个仓库。

  • 各种第三方库(比如文件系统、网络协议栈):可能还有好几个仓库。

  • 您自己写的应用程序代码:这才是您自己的那个小仓库。

如果让您手动去下载所有这些仓库,并且要确保每个仓库都下载到相互兼容的正确版本,那简直是一场灾难。

west 的核心使命就是解决这个难题。

west 这个“总管家”主要干这几件大事:

  1. 代码仓库管理 (最重要的功能)

    1. west init:初始化一个新项目。它会读取一个清单文件(west.yml),然后自动把所有需要的代码仓库从网络上下载到正确的位置。就像总管家按照“采购清单”把所有施工队都请到现场。

    2. west update:更新项目。当SDK版本更新时,运行这个命令,west 会自动检查所有仓库,并将它们更新到清单文件里指定的新版本。确保所有“施工队”都用的是最新的、能互相配合的图纸。

  2. 构建/编译代码 (Build)

    1. west build:这就是您在图形界面里点击“Build Configuration”或“Build”按钮时,背后实际执行的命令。

    2. 您在上一问中提到的所有配置项,比如开发板目标配置文件设备树覆盖层,在命令行里都是通过参数传递给 west build 的。

    3. 例如:west build -b nrf52840dk_nrf52840 -- -DCONF_FILE=prj_minimal.conf 就表示“为 nrf52840dk 这块板子构建,并且使用 prj_minimal.conf 这个软件配置文件”。

  3. 烧录程序 (Flash)

    1. west flash:当代码编译好之后,执行这个命令就可以把生成的程序烧录到您的开发板里。这对应图形界面里的“Flash”按钮。
  4. 调试 (Debug)

    1. west debug:启动调试会话,让您可以在 VS Code 或其他工具里进行单步调试。这对应图形界面里的“Debug”按钮。

图形界面 (GUI) vs. west 命令行

您可以把 nRF Connect for VS Code 插件看作是为 west 配备的一个豪华图形化遥控器

您在图形界面上的操作背后等效的 west 命令 (简化示例)
点击 "Build" 按钮west build
点击 "Flash" 按钮west flash
选择开发板 "nrf52840dk_nrf52840"west build -b nrf52840dk_nrf52840
在 "Extra Devicetree overlays" 添加 my_board.overlaywest build -- -DDTC_OVERLAY_FILE="my_board.overlay"
选择优化级别为 "Optimize for debugging"west build -- -DCONFIG_DEBUG_OPTIMIZATIONS=y

所以,当文档里频繁提到 west 时,它其实是在告诉您这个操作最本质的实现方式。

  • 对于初学者:您完全可以通过 VS Code 的图形界面来完成所有操作,暂时不用理会 west 命令。

  • 对于进阶用户:学习使用 west 命令行会让您更强大。因为您可以:

    • 实现自动化构建:比如在服务器上自动编译和测试您的项目。

    • 进行更精细的控制:使用图形界面不支持的某些高级参数。

    • 更快地执行操作:熟练后,敲命令通常比点鼠标更快。

4.12 点击构建配置(Build Configuration)按钮创建配置并启动构建流程。

构建过程需要一定时间才能完成。打开 nRF Connect 终端(View->Terminal)可查看构建进度。

如上图截图所示,成功构建的标志是显示应用程序的内存使用情况。

然后你就可以在Actions的窗口侧启动Terminal了:

当您切换到 VS Code 的资源管理器或浏览应用程序目录时,会注意到构建过程已生成一个名为 build 的新子目录。该文件夹包含构建输出文件,其中就有我们下一步将要烧录到开发板的二进制文件。

5. 连接开发板

请确保开发套件已连接至电脑并处于通电状态。该设备应显示在 VS Code 扩展程序 nRF Connect 的已连接设备视图中。

如果未看到您的开发板列表,请点击已连接设备视图中的刷新已连接设备图标:

6. 烧录

在操作视图中点击烧录将应用程序烧录至开发板。您可以打开终端面板查看烧录进度,如下图所示。

烧录与擦除并烧录至开发板的区别在于:后者会擦除整个设备,包括应用程序保存的所有数据。

7. 修改代码

为了演示需要,我们调整 LED 的闪烁频率。

通过以下两种方式定位 main.c 文件:在“源文件→应用程序”目录下查找,或通过 Visual Studio Code 的资源管理器视图 。将第 12 行代码中 SLEEP_TIME_MS 的数值从 1000 修改为 100,这将改变 LED 的闪烁间隔时间。

8. 重新烧录代码

重新构建并将应用程序烧录到开发板上。此时应能观察到 LED 灯以更高的频率闪烁。

读取按钮与控制LED

探讨 nRF Connect SDK 如何描述硬件设备,无论是开发套件(DK)、片上系统(SoC)、系统级封装(SiP)还是模块。应用程序与硬件之间的交互通过称为设备驱动程序的软件组件实现,我们将详细解释 nRF Connect SDK 采用的设备驱动模型。以通用输入输出(GPIO)硬件外设及其驱动程序作为案例,我们将逐行分析上节课中烧录到开发板的 blinky 示例程序。

在实践环节,我们将学习如何运用 GPIO 外设来控制 LED 灯,并通过轮询和中断两种方式读取按钮状态。

设备树 Devicetree

在嵌入式系统固件开发中,硬件传统上是通过头文件(.h 或 .hh)进行描述的。nRF Connect SDK 采用了从 Zephyr 实时操作系统中借鉴的更结构化、模块化的硬件描述方法,即通过一种称为设备树(devicetree)的构造来实现。设备树是一种描述硬件的层次化数据结构。被描述的硬件可以是开发套件、系统级芯片(SoC)、系统级封装(SiP)或模块,涵盖从开发套件上 LED 的 GPIO 配置到外设内存映射地址等所有内容。设备树采用由相互连接的_节点_组成的特定格式,每个节点包含一组_属性_ 。

老办法(用头文件 .h):

您在脑子里记下所有东西,或者在墙上用粉笔写:

房子A:“客厅开关在门口左边,卧室灯用的是15瓦的灯泡,厨房水龙头是圆形的…”

房子B:“客厅开关在门口右边,卧室灯用的是20瓦的灯泡,厨房水龙头是方形的…”

当您指挥装修队(您的C代码)干活时,您得给他们一套专门针对房子A的指令。如果要去装修房子B,您得重新给一套完全不同的指令。非常混乱,无法通用。

新办法(用设备树 Devicetree):

您创建了一份标准的《房屋硬件蓝图》。这份蓝图用一种标准格式清清楚楚地描述了所有硬件信息。

设备树基础

顾名思义,设备树是一种树状结构。这种树形结构的人类可读文本格式称为 DTS(即设备树源代码)。

c
/dts-v1/;
/ {
       a-node {
               subnode_label: a-sub-node {
                       foo = <3>;
               };
       };
};

上述树结构包含三个节点:

  1. 一个根节点:/

  2. 名为 a-node 的节点,它是根节点的子节点

  3. 名为 a-sub-node 的节点,它是 a-node 的子节点

节点 a-sub-node 有一个名为 foo 的属性,其值是一个值为 3 的单元格。foo 值的大小和类型由 DTS 中的尖括号(< 和 >)暗示。如果传递真假信息,属性可能具有空值。在这种情况下,属性的存在与否就足够描述信息。

设备树节点具有路径来标识它们在树中的位置。与 Unix 文件系统路径类似,设备树路径是由斜杠(/)分隔的字符串,根节点的路径是单个斜杠: /。否则,每个节点的路径是通过将节点的祖先名称与节点自身名称连接起来形成的,用斜杠分隔。例如,a-sub-node 的完整路径是 /a-node/a-sub-node

节点 (Node):蓝图上的“区域”或“设备”。比如“客厅”、“卧室”、“LED灯区域”、“I2C总线1”等。

属性 (Property):区域或设备的“详细参数”。比如“客厅”这个节点里,有 灯 = <在门口左边>;“卧室”节点里,有 灯泡功率 = <15瓦>。

设备树绑定

设备树绑定规范定义了 compatible 属性。它声明了对设备树节点内容的要求,并为有效节点内容提供语义信息。Zephyr 的设备树绑定规范以 YAML 文件形式定义。每个设备树节点都必须包含 compatible 属性。设备树节点通过该属性与其在绑定规范中的定义进行匹配。

一个叫 binding 的 YAML 文件定义了一套规则。比如,它规定:凡是标记为“卧室”的区域,必须包含“床”和“衣柜”这两个属性,否则就是不合格的蓝图(编译报错)。

以下是一个设备树绑定文件(.yaml)示例,其中定义了名为 nordic,nrf-sample 的 compatible 属性,并包含一个必需的整数类型属性 num-sample

yaml
compatible: "nordic,nrf-sample"
properties:
 num-sample:
 type: int
 required: true

下面是一个示例 DTS 文件(.dts),其中节点 node0 被设置为兼容 nordic,nrf-sample。这意味着 node0 节点必须包含必需的 num-sample 属性,且该属性必须被赋予整数值,否则构建将会失败。

c
node0 {
 compatible = "nordic,nrf-sample";
 num-sample = <3>;
};

设备树绑定文件随 SDK 一同发布在<install_path>\zephyr\dts\bindings目录中(参见 Nordic 半导体设备的设备树绑定文档 )。在某些情况下,您需要自定义 YAML 文件,例如开发自定义驱动程序时。

别名(Aliases)

c
/ {
         aliases {
                 subnode_alias = &subnode_label;
                 };
};

上述代码片段将标签为 subnode_label 的节点 a-sub-node 分配给别名 subnode_alias。这样做的目的是让 C/C++应用程序代码(例如 main.c)能够使用这个别名。在开发板的 dts 文件中定义固定别名(例如用 led0 表示开发板上的第一个 LED)可以提高应用程序代码的可移植性,因为它能避免硬编码变化的设备节点名称,使应用程序代码能更灵活地适应所用开发板的变更。

这样,您的应用程序代码(装修队长的指令)就可以非常简单:“去把 led0 打开”。代码完全不需要关心 led0 在不同板子(房子)上到底是什么引脚。这让您的应用程序代码变得极度便携!

DK 设备树文件

这些硬件细节均在 nRF54l15 DK 的设备树文件中进行了描述。让我们查看该文件,其路径为 <install_path>\zephyr\boards\nordic\nrf54l15dk\rf54l15dk_nrf54l15_cpuapp.dts

在我的Macos上,路径为:/opt/nordic/ncs/v3.0.1/zephyr/boards/nordic/nrf54l15dk/nrf54l15dk_common.dtsi

nRF52833 DK 开发板上的 LED1(请阅读上方信息提示)对应节点名称为 led_0,节点标签为 led0。通常使用节点标签来引用该节点,例如 &led0

led_0 拥有两个属性:gpios 和 label

可以看到属性 gpios 通过 & 符号引用了节点 gpio0。正如我们将在下一段中看到的,gpio0 定义在 SoC 设备树中。开发套件上的 LED1 连接至 nRF52833 SoC 的 GPIO 引脚被定义为 GPIO 0 的 13 号引脚 (P0.13),且为低电平有效。

设备驱动模型

1. 核心思想:API 与驱动实现的分离 (Decoupling)

在传统的嵌入式开发中,您可能会直接调用某个芯片厂商提供的库函数,比如 nrf_gpio_pin_set(13)。这段代码与 nrf 芯片强绑定,如果想把程序移植到STM32上,就必须把所有这些硬件相关的代码都换掉。

Zephyr/nRF Connect SDK 解决了这个问题:

  • API (应用程序接口):提供一套通用的函数,比如 gpio_pin_set_dt()。您的应用程序只调用这些通用API。这些API的函数签名是标准化的,与具体硬件无关。

  • 驱动实现 (Driver Implementation):这是底层的、针对特定硬件的代码。例如,Nordic会提供 gpio_pin_set_dt() 在nRF5x系列芯片上的具体实现,它内部会去操作nRF芯片的GPIO寄存器。ST公司则会提供它在STM32上的实现。

您的应用程序代码(main.c)中只会出现 gpio_pin_set_dt() 这样的通用函数。当您在构建配置中选择的目标板是 nrf52840dk 时,构建系统会自动链接Nordic提供的底层驱动;当您选择其他板子时,它会自动链接对应那块板子的驱动。您的 main.c 一行都不用改,就能在不同硬件上运行。这就是“高可移植性”的根源。

2. 如何与硬件“对话”:设备指针 (const struct device *)

既然API是通用的,那 gpio_pin_set_dt() 函数怎么知道它要操作的是板子上的哪个GPIO端口(比如 GPIOA 还是 GPIOB),或是哪一个UART外设(UART0 还是 UART1)呢?

答案就是通过一个设备指针。您可以把它理解为一个硬件实例的“句柄” (Handle)。它是一个类型为 const struct device * 的指针,指向一个在内存中由驱动程序初始化的、代表特定硬件外设的结构体。

您的任务就是,在代码中为每一个您想使用的硬件实例(比如板子上的LED0、按钮0、UART0)获取到它对应的设备指针。

3. 获取设备指针的现代方法:DEVICE_DT_GET()

获取设备指针的推荐方法是使用 DEVICE_DT_GET() 宏。

  • 它在编译时工作:这是它最大的优点。它直接从设备树(Devicetree)中提取信息。如果在设备树里找不到您指定的节点,或者该节点被禁用了 (status = "disabled"),程序在编译阶段就会直接报错。这能帮您在早期发现配置错误。

  • 它效率高:因为它在编译时就完成了所有工作,所以运行时没有任何开销。相比之下,旧方法 device_get_binding() 是在运行时通过字符串比较来查找设备,既慢又不安全(如果找不到,只会在运行时返回NULL,容易因忘记检查而出错)。

获取指针的典型流程分为两步:

  1. 从设备树获取节点标识符 (Node Identifier),通常使用 DT_ALIAS()DT_NODELABEL()

  2. 将节点标识符作为参数传递给 DEVICE_DT_GET() 来获取设备指针。

4. 使用前的强制安全检查:device_is_ready()

拿到了设备指针还不够,您必须在使用它之前进行检查。因为在系统启动时,内核会调用该设备的初始化函数,但这个初始化过程可能会失败(比如硬件未焊好、配置冲突等)。

device_is_ready() 函数会检查该设备是否已成功初始化并准备就绪。这是一个必须执行的步骤,否则您可能会对一个未初始化的硬件进行操作,导致未定义行为或系统崩溃。

标准代码模板如下:

js
// 1. 从设备树的别名(alias) 'led0' 获取节点标识符,并用它获取设备指针
const struct device *led_dev = DEVICE_DT_GET(DT_ALIAS(led0));

// 2. 检查设备是否就绪
if (!device_is_ready(led_dev)) {
    // 如果设备未就绪,程序不能继续使用它
    return; // 或者进行错误处理
}

// 3. 现在可以安全地使用API了
// (假设 gpio_pin_configure 和 gpio_pin_set 是该驱动的API)
// ...

5. 最佳实践:使用特定外设的专用宏

对于像GPIO、I2C、SPI这样常用的外设,Zephyr提供了比 DEVICE_DT_GET() 更进一步的专用宏,例如 GPIO_DT_SPEC_GET()

为什么这个更好?

一个简单的 DEVICE_DT_GET() 只能获取到设备指针(比如获取到 GPIO_0 这个端口)。但您操作GPIO时,通常还需要引脚号 (pin number)标志位 (flags)(比如上拉、下拉、高电平有效等)。这些信息同样定义在设备树里。

如果用通用方法,您需要:

  1. DEVICE_DT_GET() 获取GPIO端口的指针。

  2. DT_PROP() 去设备树里单独获取引脚号。

  3. DT_PROP() 去设备树里单独获取标志位。

GPIO_DT_SPEC_GET() 这个专用宏一步到位,它会返回一个包含了设备指针、引脚号、标志位等所有信息的结构体。这极大简化了您的代码,并减少了出错的可能。

使用专用宏的代码模板:

js
// 1. 从设备树别名 'led0' 一次性获取所有GPIO相关信息
static const struct gpio_dt_spec led_spec = GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios);

// 2. 检查设备是否就绪(注意,函数也变成了专用的)
if (!gpio_is_ready_dt(&led_spec)) {
    return;
}

// 3. 使用这个 spec 结构体来调用API
int ret = gpio_pin_configure_dt(&led_spec, GPIO_OUTPUT_ACTIVE);
// ...
ret = gpio_pin_set_dt(&led_spec, 1);

总结

概念作用关键宏/函数
API与实现解耦实现代码可移植性-
设备指针作为硬件实例的句柄,传递给API函数const struct device *
获取设备指针从设备树获取硬件句柄,编译时检查DEVICE_DT_GET(DT_ALIAS(...))
就绪检查确保硬件已成功初始化,防止运行时错误device_is_ready()
专用宏 (最佳实践)一次性获取指针和所有相关配置,简化代码GPIO_DT_SPEC_GET(), gpio_is_ready_dt()

GPIO通用API

整个流程可以分解为四个核心步骤:获取句柄 -> 配置引脚 -> 操作引脚 -> 读取引脚

1. 初始化:获取引脚的“全能句柄” (gpio_dt_spec)

在操作任何一个具体的GPIO引脚(比如连接到一个LED或按钮的引脚)之前,您需要一个指向它的“句柄”。在 Zephyr 的 GPIO API 中,这个句柄不仅仅是一个设备指针,而是一个更强大的结构体:struct gpio_dt_spec

这个结构体非常方便,因为它将操作一个引脚所需的所有信息都打包在了一起:

  • const struct device *port: 指向控制该引脚的GPIO控制器(如 GPIO_0)的设备指针。

  • gpio_pin_t pin: 您要操作的具体引脚号(如 13)。

  • gpio_dt_flags_t dt_flags: 从设备树中自动提取的配置标志(如 GPIO_ACTIVE_LOW)。

如何获取它?

使用专用的宏 GPIO_DT_SPEC_GET()。这个宏需要两个参数:

  1. 节点标识符 (Node Identifier):通常通过 DT_ALIAS(led0)DT_NODELABEL(my_led) 从设备树中获取。

  2. 属性名 (Property Name):在设备树中,包含GPIO信息的那个属性的名称,对于 Nordic 的板子,这个名字几乎总是 gpios

代码实例:

假设设备树中有 led0 这个别名,您只需要一行代码:

js
// 从设备树别名'led0'的'gpios'属性中,获取所有信息并填充到led这个结构体中
static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios);

执行完这行代码后,led.portled.pinled.dt_flags 就被自动填充好了。您无需再手动从设备树中一点点解析这些信息。

安全检查 (必须执行):

在获取到spec结构体后,必须检查其底层的设备是否准备就绪。

c
if (!gpio_is_ready_dt(&led)) {
    // 如果设备未就绪,后续操作将失败。必须在此处处理错误。
    return 0; // 或者 panic()
}

2. 配置引脚 (gpio_pin_configure_dt)

获取并验证句柄后,您需要告诉硬件这个引脚是用作输入还是输出。

  • 函数: gpio_pin_configure_dt()

  • 参数:

    • 指向您的 gpio_dt_spec 结构体的指针(例如 &led)。

    • 配置标志,可以使用 | (按位或) 组合。

代码实例:

c
// 将led引脚配置为普通输出
int ret = gpio_pin_configure_dt(&led, GPIO_OUTPUT_ACTIVE);

// 将一个按钮引脚配置为输入,并启用内部上拉电阻
// int ret = gpio_pin_configure_dt(&button, GPIO_INPUT | GPIO_PULL_UP);

GPIO_OUTPUT_ACTIVE 是一个方便的宏,它等价于 GPIO_OUTPUT。如果您的设备树中定义了 GPIO_ACTIVE_LOW,那么这个标志会自动被考虑进去。

3. 操作引脚 (输出)

配置为输出后,您可以改变引脚的电平状态。

  • 设置电平: gpio_pin_set_dt(const struct gpio_dt_spec *spec, int value)

    • value = 1: 设置为逻辑高状态。如果引脚是低电平有效 (GPIO_ACTIVE_LOW),物理电平会变为低。

    • value = 0: 设置为逻辑低状态。

  • 翻转电平: gpio_pin_toggle_dt(const struct gpio_dt_spec *spec)

    • 每次调用,电平状态翻转一次。

代码实例:

c
// 点亮LED (设置为逻辑高)
gpio_pin_set_dt(&led, 1);

// 熄灭LED (设置为逻辑低)
gpio_pin_set_dt(&led, 0);

// 闪烁LED
gpio_pin_toggle_dt(&led);

4. 读取引脚 (输入)

读取输入引脚的状态有两种方式,您需要根据应用场景和功耗要求来选择。

方法一:轮询 (Polling) - 简单但耗电

这种方法就是在一个循环里不断地去读取引脚的当前状态。

  • 函数: gpio_pin_get_dt(const struct gpio_dt_spec *spec)

  • 返回值: 10,代表引脚的逻辑电平。

代码实例:

c
// 假设button是配置为输入的gpio_dt_spec
int val = gpio_pin_get_dt(&button);
if (val > 0) {
    // 按钮被按下
}

缺点: CPU需要一直忙于检查,无法进入低功耗的睡眠模式,非常耗电。只适用于简单的、不关心功耗的测试场景。

方法二:中断 (Interrupt) - 高效省电(推荐)

这是推荐的方法。您给引脚设置一个“触发器”,然后让CPU去休眠或处理其他任务。当引脚状态改变时(例如按钮被按下),硬件会自动“唤醒”CPU来执行一个您预先定义好的函数。

设置中断的完整5步流程:

  1. 配置中断触发方式: 使用 gpio_pin_interrupt_configure_dt()
text
// 配置当中断引脚变为逻辑高时触发 (例如,下降沿触发一个上拉输入的按钮)
gpio_pin_interrupt_configure_dt(&button, GPIO_INT_EDGE_TO_ACTIVE);

2. 定义回调函数 (ISR - 中断服务程序): 这个函数就是中断触发时要执行的代码。注意:ISR中代码要尽可能简短快速

js
// 当按钮中断发生时,这个函数会被调用
void button_pressed_isr(const struct device *dev, struct gpio_callback *cb, uint32_t pins) {
// 比如,在中断里翻转LED的状态
  gpio_pin_toggle_dt(&led);
}
  1. 定义一个全局/静态的 gpio_callback 结构体变量:它用来存储ISR函数指针和引脚信息。
text
static struct gpio_callback button_cb_data;
  1. 初始化回调结构体: 使用 gpio_init_callback() 将您的ISR和引脚关联到这个结构体上。
text
// 初始化回调:将button_cb_data与我们的ISR函数和按钮引脚关联起来
gpio_init_callback(&button_cb_data, button_pressed_isr, BIT(button.pin));
  1. 将回调添加到驱动中: 使用 gpio_add_callback() 正式“注册”这个中断。
text
// 将配置好的回调添加到按钮所在的GPIO控制器驱动中
gpio_add_callback(button.port, &button_cb_data);

完成这五步后,中断系统就工作了。每次触发按钮,button_pressed_isr 函数就会被自动执行。

总结:关键函数速查

目标关键函数/宏作用
获取句柄GPIO_DT_SPEC_GET()从设备树一次性获取引脚所有信息。
安全检查gpio_is_ready_dt()检查硬件是否初始化成功。
配置引脚gpio_pin_configure_dt()设置引脚为输入/输出,配置上/下拉等。
设置电平gpio_pin_set_dt()将输出引脚设为逻辑高或低。
翻转电平gpio_pin_toggle_dt()翻转输出引脚的逻辑电平。
轮询读取gpio_pin_get_dt()主动读取输入引脚的当前状态。
配置中断gpio_pin_interrupt_configure_dt()设置中断的触发条件(边沿/电平)。
注册中断gpio_init_callback() & gpio_add_callback()将您的ISR函数与硬件中断关联起来。

nRF Connect SDK 应用开发要素

在 nRF Connect SDK 中,应用程序包含多个不同元素,这些元素通过构建系统以某种方式组合生成最终可执行文件。理解这些元素的用途、必要性及相互间的协作关系,对创建自定义应用至关重要。本课程将逐一解析这些组件,阐明它们如何协同运作。实践环节中,我们将从零开始构建最小化可运行应用,并通过添加自定义文件与配置来实现应用定制。

text
app/
|-- CMakeLists.txt
|-- Kconfig
|-- prj.conf
|-- <board_name>.overlay
|-- src/
    |-- main.c

配置文件

核心概念:Kconfig 系统

您可以将 Kconfig 理解为项目的**“软件功能总开关”**。它是一个强大的配置系统(源自 Linux 内核),远比简单的在头文件中使用 #define 要高级。

它的主要作用是在编译时,根据您的配置,精确地裁剪出最终应用程序需要的所有代码,并设定好所有参数。不需要的功能模块(及其代码和内存占用)将完全不被包含在最终的二进制文件中。

1. 应用程序的“需求清单”:prj.conf

这是您作为应用开发者最常打交道的文件。

  • 它的作用:明确声明您的这个特定应用程序需要哪些软件功能,以及如何配置这些功能。

  • 它的语法:非常简单,CONFIG_SYMBOL=value

    • CONFIG_GPIO=y: 启用 GPIO 驱动模块。y 代表 "yes"。

    • CONFIG_LOG=y: 启用 日志模块。

    • CONFIG_LOG_BUFFER_SIZE=2048: 配置 日志模块的缓冲区大小为 2048 字节。

  • 它的位置:位于您的应用程序项目文件夹的根目录。这个文件是您项目的一部分,会随着您的项目一起进行版本控制(例如,提交到 Git)。

2. 开发板的“出厂设置”:<board_name>_defconfig

每个官方支持的开发板,在SDK中都有一个默认的配置文件。

  • 它的作用:为这块特定的开发板提供一个合理的基础配置。它通常会启用该板子正常工作所需的核心驱动,比如 UART(用于日志输出)、GPIO、以及针对其特定SoC的硬件优化(如 MPU)。

  • 它的位置:深埋在SDK的目录结构中,例如 zephyr/boards/arm/nrf52833dk_nrf52833/。这个文件是SDK的一部分,而不是您项目的一部分。

3. 配置的合并与优先级 (Precedence) - 这是关键!

当您构建项目时,构建系统会像制作三明治一样,将多个配置层叠加在一起,生成最终的配置。这个过程的顺序和优先级至关重要:

  1. 基础层 (Bottom Layer):首先,系统会加载您所选开发板的 <board_name>_defconfig 文件。这提供了一个默认的基准。

  2. 应用层 (Top Layer):然后,系统会加载您的应用程序的 prj.conf 文件。

最重要的规则:应用层 (prj.conf) 的配置会覆盖基础层(板级配置)的同名配置。

举个例子:

  • nrf52833dk_nrf52833_defconfig 文件中说:CONFIG_SERIAL=y (默认启用串口)。

  • 但您的特定应用可能是一个超低功耗的传感器,完全不需要串口日志,为了节省代码空间和功耗,您可以在您的 prj.conf 文件中写上:CONFIG_SERIAL=n

最终构建时,系统会采用 CONFIG_SERIAL=n 这个配置。prj.conf 的决定拥有最终话语权

4. “黄金法则”:永远不要修改板级配置文件

这是所有开发者必须遵守的准则。原因如下:

  • 破坏SDK更新:当您更新nRF Connect SDK版本时,更新工具(如 west update)会尝试更新这些SDK内部文件。如果您修改了它们,会导致版本控制冲突(Git merge conflict),使更新过程变得非常痛苦。

  • 破坏可移植性:您的项目现在依赖于一个被您“魔改”过的SDK。如果您把项目发给同事,或者换一台电脑,他们的标准SDK里没有您的修改,项目可能就无法正确编译。

  • 影响所有项目:这个修改会影响到所有使用该板型配置的项目,可能会导致其他不相关的项目出现意想不到的问题。

正确的做法是:始终在您自己的 prj.conf 中进行您需要的所有配置和覆盖。

5. 如何知道 CONFIG_ 符号的含义?

这是一个非常实际的问题。Kconfig系统中有成千上万个配置符号。

  1. VS Code 插件 (首选):正如原文所说,将鼠标悬停在 prj.conf 文件中的任何 CONFIG_ 符号上,插件会弹出一个小窗口,清晰地显示该符号的帮助文本、类型(布尔/整型/字符串)以及它的依赖关系。这是最快、最方便的方法。

  2. 官方文档:您可以在 nRF Connect SDK 或 Zephyr 的官方文档网站上搜索特定的符号,以获取更详细的说明。

总结

配置文件作用位置是否应该修改?
prj.conf应用程序的软件功能需求清单。您的项目文件夹中。是,这是您的主战场。
<board>_defconfig开发板的硬件相关基础配置。SDK的 boards 目录中。绝对不要!

掌握了 Kconfig 的工作方式,您就掌握了如何精确控制 nRF Connect SDK 项目的软件构成。您可以自由地添加蓝牙协议栈、文件系统、各种驱动,或者为了极致的优化而剥离掉所有非必需的组件,而这一切都只需要修改 prj.conf 这个简单的文本文件。

内核配置(nRF Kconfig 图形界面)

修改 prj.conf(应用程序配置文件)内容的另一种方法是使用 nRF Kconfig 图形界面。Kconfig 指软件配置项,它将 nRF Connect SDK 和 Zephyr 提供的所有功能组织成菜单和子菜单,并以图形树状结构呈现。

nRF Kconfig 图形界面视图使我们能够轻松浏览并实现各种可用功能。在 nRF Kconfig 图形界面中选择/取消选择功能,相当于在 prj.conf 文件中添加/删除相应配置行。

nRF Kconfig GUI 可在 RF Connect 扩展的 Actions 选项中找到,如下图所示。

Kconfig 还有其他可视化编辑器,您可以通过 nRF Kconfig GUI 下的子菜单访问它们,如下图所示。

Kconfig 采用菜单结构将相关配置项归类管理,例如模块、设备驱动、C 库以及启动选项等。菜单可包含子菜单,子菜单又能继续嵌套下级菜单。当我们展开任意菜单时(以设备驱动为例),该类别下的所有配置符号都会显示,其中部分符号还拥有自己的专属菜单。

勾选框表示该符号已启用,无论是在应用程序配置文件中还是在开发板配置文件中。例如,如果我们在顶部的搜索栏输入"GPIO",就能看到在这个闪烁灯示例配置中 GPIO 驱动程序已启用。

自 nRF Connect SDK v2.8.0 版本起,Sysbuild 成为默认构建系统。nRF Kconfig 图形界面可让您查看项目中 Sysbuild 本身及其所有镜像的 Kconfig 配置。 操作时需先在"应用视图"中选择目标镜像 ,随后点击"操作视图"中的 nRF Kconfig 图形界面按钮,如下图所示即为选中应用镜像时的操作示意。

在 Kconfig 菜单中进行更改后,有三种不同的保存方式,如下所示。

“应用 Apply”选项会将更改保存在临时配置文件中(位于 build->Zephyr 目录下的.config 文件),这些更改会在执行全新构建时被还原。

“保存到文件 Save to File”选项会将更改保存至 prj.conf 文件,从而使这些更改在不同构建中得以保留。

“最小化保存 Save (Minimal)”选项仅将我们刚刚所做的更改保存至一个单独的文件中。

VS Code 的 nRF Connect 扩展支持两种交互式 Kconfig 配置界面(nRF Kconfig 图形界面和 menuconfig)。它们都能帮助您浏览相关配置选项并查看当前取值。menuconfig 的优势在于会显示选项依赖关系及其定义位置,但缺点是所有配置仅临时生效,执行纯净构建(pristine build)时会被清除。而 nRF Kconfig 图形界面是唯一能将配置永久保存至 prj.conf 文件的可视化工具。

设备树覆盖、CMake 与构建系统

1. 设备树覆盖文件 (Devicetree Overlays)

核心思想:在不修改SDK自带的、原始的设备树文件(.dts)的前提下,对硬件描述进行“打补丁”或“魔改”。

为什么需要它?

这与我们之前讨论的“黄金法则”一样:永远不要修改SDK的源文件。原始的设备树文件描述的是开发板的“出厂默认状态”。您的项目可能会外接一个传感器,或者需要将某个外设(如SPI)的引脚从默认位置改到其他位置。Overlay文件就是专门用来做这些定制化修改的。

如何工作?

  1. 它也是DTS文件:Overlay文件的语法与标准的DTS文件完全相同。.overlay 只是一个命名约定,让其用途一目了然。

  2. 引用式修改:您不需要复制整个原始节点。只需要使用 & 符号引用您想修改的节点标签(例如 &spi1),然后在大括号 {} 中写下您要添加或修改的属性。

  3. 自动加载:最简单的方式是,在您的项目根目录下创建一个名为 <board_name>.overlay 的文件(例如 nrf52833dk_nrf52833.overlay)。构建系统会自动找到并应用它。

示例解析:

c
&spi1{
    status = "okay"; // 将spi1外设的状态从默认的 "disabled" 改为 "okay",从而启用它。
};

&pinctrl { // 引用引脚控制器节点
    // 为spi1的默认状态和睡眠状态重新定义MOSI引脚
    spi1_default: spi1_default {
        group1 {
            psels = <NRF_PSEL(SPIM_MOSI, 0, 25)>; // 将MOSI引脚从默认值改为P0.25
        };
    };
    // ... sleep state ...
};

这个Overlay文件做了两件事:启用了SPI1外设,并将其MOSI引脚重定向到了P0.25。

2. CMake

核心思想:CMake是整个nRF Connect SDK项目的总构建蓝图

每个应用程序根目录下的 CMakeLists.txt 文件是构建流程的起点和总指挥。它负责:

  • 告诉构建系统项目的名称。

  • 包含Zephyr项目的核心CMake逻辑。

  • 添加您自己的源文件(.c 文件)到编译列表。

  • 设置各种编译选项。

简单来说,当您点击VS Code中的“Build”按钮时,背后实际是 west 在调用 cmake 来解析您的 CMakeLists.txt 文件,从而启动整个复杂的编译过程。

3. Sysbuild (系统构建)

核心思想:一个**“元”构建系统**,用于管理和协调多个独立程序(镜像)的构建,确保它们能协同工作。

什么时候必须用它?

  1. 多核应用:最典型的例子是 nRF5340。它有一个高性能的应用核和一个低功耗的网络核。您的主程序跑在应用核上,而蓝牙协议栈跑在网络核上。这两个是独立的程序,Sysbuild负责分别编译它们,并确保两者能正确通信。

  2. 带Bootloader的应用:您的项目可能包含一个主应用程序和一个用于固件升级的Bootloader(如MCUBoot)。这两个也是独立的程序。Sysbuild会先编译Bootloader,再编译您的主程序,并对主程序进行签名,最后将两者合并成一个可烧录的完整固件。

Sysbuild如何配置?

Sysbuild引入了它自己的Kconfig配置体系,以 SB_ 为前缀,通常在 sysbuild.conf 文件中进行配置。

示例解析 (Kconfig.sysbuild):

问题是:在 sysbuild.conf 中直接写 SB_CONFIG_NETCORE_IPC_RADIO=y 来为nRF5340的网络核启用蓝牙功能,在编译单核芯片(如nRF52833)时会产生一个烦人的警告。

解决方案:不直接在 sysbuild.conf 中设置值,而是在一个名为 Kconfig.sysbuild 的文件中,为这个选项提供一个有条件的默认值

text
// 如果这块板子支持网络核IPC (比如nRF5340),那么就默认启用IPC Radio
config NRF_DEFAULT_IPC_RADIO
    default y if SUPPORT_NETCORE_IPC_RADIO

这样做的好处是,当为nRF52833这样的单核芯片编译时,SUPPORT_NETCORE_IPC_RADIO 不存在,这个默认值就不会生效,因此也就避免了警告。这是一个更优雅、更具可移植性的配置方法。

4. Trusted Firmware-M (TF-M)

核心思想:利用Arm TrustZone硬件技术,为系统创建一个**“安全保险箱”,将系统划分为安全区 (Secure Processing Environment, SPE)** 和非安全区 (Non-secure Processing Environment, NSPE)

为什么需要它?

为了极致的安全。您可以将敏感数据(如私钥、证书)和关键代码(如加密算法、安全启动逻辑)全部放在安全区(由TF-M管理)。即使您的大型主应用程序(运行在非安全区)被黑客攻击或出现严重bug,它也无法访问到安全区内的数据,从而保证核心资产的安全。

作为开发者,您需要做什么选择?

对于支持TrustZone的芯片(如nRF5340, nRF9160),您在选择构建目标时面临一个抉择:

  1. 不使用安全分离 (<board_target>)

    1. 例如:nrf5340dk_nrf5340

    2. 您的应用程序会作为一个单一的、拥有所有权限的镜像来运行。

    3. 优点:开发简单,心智负担小。

    4. 缺点:没有硬件级别的安全隔离。

  2. 使用安全分离 (<board_target>/ns)

    1. 例如:nrf5340dk_nrf5340/ns (ns = non-secure)

    2. 这是一个Sysbuild构建。构建系统会自动:

      1. 将您的应用程序编译成一个非安全镜像

      2. TF-M编译成一个安全镜像

      3. 将两个镜像合并成一个最终的固件。

    3. 优点:安全性极高,符合现代物联网安全标准。

    4. 缺点:您的应用代码运行在受限的环境中,访问硬件需要通过TF-M提供的安全服务,增加了复杂性。

总结

概念核心作用您需要做什么
Devicetree Overlay“打补丁”,定制化修改硬件描述。创建 <board_name>.overlay 文件,在里面引用并修改节点。
CMake项目的总构建脚本。编写 CMakeLists.txt 来组织您的源文件和项目配置。
Sysbuild管理多镜像项目(如多核、Bootloader)的元构建系统。理解其工作原理,并通过 sysbuild.conf 或 Kconfig.sysbuild 来配置要构建的镜像。
TF-M基于TrustZone的硬件安全隔离方案。根据项目安全需求,选择构建目标是 <board>/ns (安全) 还是 <board> (非安全)。

打开串口监视器

要查看应用程序的输出,您需要在计算机上配置终端模拟器。您可以使用任何想要的终端模拟器进行操作。本课程将向您展示如何使用 VS Code 中 nRF Connect 扩展的内置终端模拟器(称为 nRF Terminal),或使用串行终端应用程序 nRF Connect for Desktop 来实现这一目的。您可以通过下方标签页任选一种方式。

要在 VS Code 中使用终端模拟器,首先转到已连接设备选项卡并打开下拉列表。

然后打开该已连接设备的下拉列表。

然后点击在 nRF 终端中连接串行端口图标,如下所示。

然后选择具有以下配置的串行终端:

波特率:115200 波特/秒

8 位/字符,无奇偶校验,1 个停止位(8n1)

无流控制:rts 和 cts = 关闭

或者:

构建可定制的 nRF Connect SDK 应用

当您从运行官方示例迈向开发自己的专属应用时,掌握如何定制化项目是至关重要的一步。一个专业的嵌入式项目不仅包含核心的应用逻辑,还应该具备模块化的代码结构、可配置的功能以及针对特定硬件的适配能力。

接下来,我们将一步步学习如何实现这三个目标,将一个简单的应用改造成一个结构清晰、功能可控的专业项目。

1. 添加并管理自定义代码模块

随着项目功能的增加,将所有代码都堆在 main.c 中会迅速变得难以维护。最佳实践是将相关的功能封装到独立的 .c.h 文件中。

首先,我们在 src 目录下创建自己的功能文件,例如 myfunction.cmyfunction.h

myfunction.h (头文件 - 声明接口)

c
#ifndef MY_FUNCTION_H
#define MY_FUNCTION_H

// 声明一个简单的求和函数,这是暴露给其他模块的接口
int sum(int a, int b);

#endif // MY_FUNCTION_H
  • #ifndef ... #define ... #endif 是一种被称为“头文件保护宏 (Include Guard)”的标准做法,它能防止因重复包含同一个头文件而导致的编译错误。

myfunction.c (源文件 - 实现功能)

c
#include "myfunction.h"

// 对头文件中声明的函数进行具体实现
int sum(int a, int b) {
    return a + b;
}

仅仅创建文件是不够的,我们还必须告诉构建系统(CMake)将这个新的源文件包含到编译流程中。这需要在项目根目录的 CMakeLists.txt 文件中完成。

通过 target_sources() 函数,我们可以将 myfunction.c 添加到名为 app 的编译目标中。

bash
# 将 src/myfunction.c 添加到编译源文件列表
target_sources(app PRIVATE src/myfunction.c)

完成以上步骤后,我们就可以在 main.c 中像使用标准库一样使用我们的自定义函数了。

main.c

c
#include <zephyr/kernel.h>
#include <zephyr/printk.h>

// 包含我们自己的头文件
#include "myfunction.h"

int main(void)
{
    int a = 3, b = 4;
    while(1) {
        // 调用自定义函数
        printk("The sum of %d and %d is %d\n", a, b, sum(a, b));
        k_msleep(1000);
    }
    return 0;
}

这种方法虽然简单直接,但有一个显著的缺点:无论我们的应用是否真的需要 sum() 这个功能,myfunction.c 都会被编译进最终的固件中,这会不必要地占用宝贵的闪存空间。为了解决这个问题,我们需要引入一种更智能、可配置的管理方式。

2. 使用 Kconfig 创建可配置的功能

nRF Connect SDK 的精髓在于其强大的 Kconfig 系统,它允许我们为项目中的任何功能创建一个“开关”。我们可以利用这个系统,让 myfunction 模块的包含与否变为一个可配置的选项。

第一步:在 Kconfig 文件中定义“开关”

在项目根目录创建一个名为 Kconfig 的文件(注意,没有 .txt 或其他扩展名)。这个文件用于定义我们自己的配置选项。

bash
# 必须首先引入 Zephyr 的基础 Kconfig 文件,以继承整个配置系统
source "Kconfig.zephyr"

# 定义一个我们自己的配置选项
config MYFUNCTION
    bool "Enable my custom function" # 这是在图形化配置界面中显示的描述信息
    default n                       # 默认值为 n (no),即默认关闭此功能

这就在 Kconfig 系统中创建了一个新的符号 CONFIG_MYFUNCTION

第二步:在 CMakeLists.txt 中使用“开关”

现在,我们修改 CMakeLists.txt,使用 target_sources_ifdef() 函数来替代之前的 target_sources()。这个函数会检查一个 Kconfig 符号是否存在且被设为 y,只有满足条件时,它才会将指定的源文件加入编译。

bash
# 仅当 CONFIG_MYFUNCTION=y 时,才将 src/myfunction.c 加入编译
target_sources_ifdef(CONFIG_MYFUNCTION app PRIVATE src/myfunction.c)

第三步:在 C 代码中使用“开关”

最后,我们还需要在 C 代码中响应这个开关。使用标准的C预处理器指令 #ifdef,我们可以让代码在编译时就根据 CONFIG_MYFUNCTION 的状态进行调整,从而避免在功能关闭时出现找不到函数或头文件的编译错误。

c
#include <zephyr/kernel.h>
#include <zephyr/printk.h>

// 仅当功能开启时,才包含对应的头文件
#ifdef CONFIG_MYFUNCTION
#include "myfunction.h"
#endif

int main(void)
{
    while (1) {
        // 仅当功能开启时,才编译和执行相关代码
#ifdef CONFIG_MYFUNCTION
        int a = 3, b = 4;
        printk("The sum of %d and %d is %d\n", a, b, sum(a, b));
#else
        // 如果功能关闭,则打印提示信息
        printk("MYFUNCTION feature is not enabled.\n");
#endif
        k_msleep(1000);
    }
    return 0;
}

如何控制开关?

现在,我们拥有了完全的控制权。只需在 prj.conf 文件中添加一行配置即可:

  • CONFIG_MYFUNCTION=y:开启自定义功能。

  • CONFIG_MYFUNCTION=n 或注释掉此行:关闭自定义功能。

通过这套“Kconfig 定义 -> CMake 控制编译 -> C 代码响应”的组合拳,我们实现了一个真正模块化的功能,可以按需启用或禁用,这正是专业嵌入式项目所应具备的特性。

3. 使用设备树 Overlay 定制硬件

除了软件功能,我们经常还需要针对特定应用修改硬件配置,例如更改串口的波特率或重新分配外设的引脚。直接修改SDK中的设备树文件是严令禁止的,正确的做法是使用设备树覆盖文件 (Devicetree Overlay)

Overlay 文件就像一个透明的“补丁”,它被叠加在原始的设备树之上,只修改我们关心的部分。

第一步:创建 Overlay 文件

最规范的做法是在项目根目录创建一个 boards 文件夹,然后在其中创建一个与您的构建目标完全同名的 .overlay 文件。例如,如果您的构建目标是 nrf52833dk_nrf52833,那么文件名就应该是 nrf52833dk_nrf52833.overlay

第二步:在 Overlay 文件中修改硬件属性

假设我们想将日志输出串口(在nRF52833上通常是 uart0)的波特率从默认的 115200 修改为 9600

boards/nrf52833dk_nrf52833.overlay

c
// 使用 & 符号引用我们想要修改的节点标签
&uart0 {
    // 将 current-speed 属性的值修改为 9600
    current-speed = <9600>;
};

构建系统会自动找到并应用这个文件。

第三步:全新构建并验证

当您对项目的构建配置(如 Kconfig、CMake 或 Overlay)进行修改后,为了确保所有更改都能正确生效,执行一次全新构建 (Pristine Build) 是一个非常重要的好习惯。这会删除旧的构建目录,避免因缓存而导致各种奇怪的问题。在 VS Code 的 nRF Connect 插件中,可以很方便地一键触发全新构建。

构建完成后,您需要验证修改是否成功。最可靠的方法是查看最终生成的设备树文件。在 VS Code 中,使用命令面板 (Ctrl+Shift+P) 运行 nRF DeviceTree: Open Compiled Output,在打开的文件中搜索 uart0,您应该能看到 current-speed 属性已经变成了 9600

当您烧录此固件后,您会发现原来的串口终端(设置为115200波特率)不再有任何输出。只有当您将串口终端的波特率也调整为9600时,才能重新看到熟悉的日志信息,这进一步证明了我们的硬件定制已成功生效。

通过掌握以上这些定制化技巧,您就具备了将任何官方示例转变为一个结构良好、功能可控、硬件适配的专属应用的能力,为开发复杂、可靠的物联网产品打下了坚实的基础。

向控制台输出消息与日志记录

在一个新的软件开发环境中,首先要学习的技能之一就是能够在控制台上打印诸如经典的"Hello World!"之类的消息。在前一课程中,我们已经简要见识过用 printk() 在控制台打印简单消息。本课程我们将深入学习日志记录,既包括简单的 printk() 方法,也会介绍使用高级日志模块的复杂方法。

printk()函数

要在控制台打印基础信息,我们可以使用 printk() 方法。其语法 printk() 与 C 语言标准 printf() 类似,既可以传入字符串字面量,也可以使用格式字符串配合一个或多个待打印变量。不过 printk() 功能相对简化,仅支持 printf() 的部分特性,这种设计使其特别适合嵌入式开发场景。

支持的基础格式说明符包括:

  • 有符号十进制:%d%i 及其子类别

  • 无符号十进制:%u 及其子类别

  • 无符号十六进制:%x%X被视为 %x

  • 指针:%p

  • 字符串:%s

  • 字符:%c

  • 百分号:%%

  • 换行符:\n

  • 回车符:\r

使用 printk() 非常简单,您只需:

  1. 通过在应用程序配置文件中启用配置选项 CONFIG_CONSOLE 实现此功能。若该选项已在开发板配置文件中设置,则无需此步骤。

  2. 提供多种选项可供选择,例如 UART 控制台(CONFIG_UART_CONSOLE)和 RTT 控制台(CONFIG_RTT_CONSOLE)。

UART 控制台: UART 控制台利用通用异步收发传输器(UART)硬件实现设备与计算机之间的串行通信。

RTT 控制台 :RTT(实时传输)是 SEGGER 微控制器公司开发的专有技术,支持 J-Link 设备和基于 ARM 的微控制器进行双向通信。通过 RTT 控制台,您可以查看设备发出的调试消息和日志信息。

printk() 函数的输出不会被延迟,这意味着输出会立即发送到控制台,无需任何互斥或缓冲处理。这也被称为同步日志记录、就地日志记录或阻塞式日志记录。日志信息在生成时就会立即发送,并且 printk() 函数会一直等待,直到消息的所有字节都发送完毕才会返回。这一特性限制了该函数在时间敏感型应用中的使用。

日志模块

日志模块是推荐用于向控制台发送消息的方法,不同于 printk() 函数(该函数会阻塞直到消息所有字节发送完毕)。日志模块支持即时日志和延迟日志等众多高级功能。

日志记录模块在编译时和运行时都具有高度可配置性。通过使用适当的配置选项,可以逐步从编译中移除日志,从而在不需要日志时减小镜像大小并缩短执行时间。在编译期间,可以根据模块和严重级别过滤日志。

例如以下代码行:

text
LOG_INF("nRF Connect SDK Fundamentals");

将输出:

text
[00:00:00.382,965] <inf> Less4_Exer2: nRF Connect SDK Fundamentals

[00:00:00.382,965] 是与消息生成相关的时间戳,采用“时:分:秒.毫秒,微秒”格式。日志模块通过内部调用内核函数 k_cycle_get_32() 获取该时间戳。这个例程返回系统硬件时钟测量的自启动以来的当前时间(运行时间)。如果系统中存在外部实时时钟 ,可以修改为返回实际的日期时间。

<inf> 表示日志级别,此处"inf"代表信息级。

Less4_Exer2 是生成该日志消息的模块名称。

nRF Connect SDK Fundamentals 是实际的日志消息内容。

再举一个例子,以下代码行:

c
 LOG_INF("Exercise %d",2);
 LOG_DBG("A log message in debug level");
 LOG_WRN("A log message in warning level!");
 LOG_ERR("A log message in Error level!");

将在控制台打印如下内容:

其余三条日志语句将以三种不同的严重级别输出信息。请注意,警告级别的消息会显示为黄色,错误级别的消息会显示为红色。这是因为_错误和警告日志的着色功能_ (LOG_BACKEND_SHOW_COLOR)默认处于启用状态。

日志模块位于子系统与操作系统服务菜单下,如下图所示:

串行通信(UART)

通用异步收发传输器(UART)是一种流行的串行通信协议。它被用于与各类传感器、电子元件进行通信,也常通过 USB 转 UART 转换器作为控制台的后端接口。本课程中,我们将学习以中断驱动方式使用 UART 驱动程序,当新数据到达时触发应用中断并调用回调函数(ISR)。

UART驱动

在Zephyr RTOS中,操作UART外设有多种方式,但异步API (Asynchronous API) 无疑是功能最强大、效率最高的一种。它利用了Nordic芯片内置的EasyDMA(直接内存访问)引擎,可以在后台自动完成数据的接收和发送,而无需CPU的持续干预。这极大地释放了CPU资源,让您的应用程序可以专注于处理更核心的业务逻辑,是绝大多数应用场景下的首选方案。

接下来,我们将系统地学习如何配置和使用这个强大的API。

第一步:驱动使能与初始化

与使用任何Zephyr驱动一样,我们首先需要确保UART驱动及其异步功能已被启用,并获取到硬件的设备指针。

prj.conf 中启用配置 确保您的项目配置文件 prj.conf 中包含了以下两行:

bash
# 启用串口驱动
CONFIG_SERIAL=y
# 启用串口驱动的异步API功能
CONFIG_UART_ASYNC_API=y
  1. 第一行通常已在开发板的默认配置中启用,但第二行 CONFIG_UART_ASYNC_API 是使用我们接下来要学习的所有功能的关键。

在代码中获取设备指针在您的源文件中,首先包含UART驱动的头文件,然后使用我们熟悉的设备树宏来获取UART外设的设备指针。

js
#include <zephyr/drivers/uart.h>

// 从设备树中获取uart0节点的设备指针
const struct device *uart = DEVICE_DT_GET(DT_NODELABEL(uart0));

// 在使用前,务必检查设备是否已成功初始化并准备就绪
if (!device_is_ready(uart)) {
    printk("UART device not ready\n");
    return;
}
  1. 这里的 uart0 是设备树中代表硬件UART控制器的节点标签,而 uart 是一个指向该设备实例的指针,我们后续将通过这个指针来调用所有API函数。

而对于nRF54L15,则:

js
const struct device *uart= DEVICE_DT_GET(DT_NODELABEL(uart20));

在 nRF54 系列设备上,外设采用两位数编号。本练习中我们将使用 &uart20 实例。

第二步:配置UART参数

虽然UART的基本参数(如默认波特率)已在设备树中定义,但异步API允许我们在运行时动态地修改它们。

  1. 定义配置结构体 创建一个 uart_config 类型的结构体变量,并填入您期望的参数。
js
const struct uart_config uart_cfg = {
    .baudrate = 115200,
    .parity = UART_CFG_PARITY_NONE,
    .stop_bits = UART_CFG_STOP_BITS_1,
    .data_bits = UART_CFG_DATA_BITS_8,
    .flow_ctrl = UART_CFG_FLOW_CTRL_NONE // 默认不使用硬件流控
};
  1. 应用配置 调用 uart_configure() 函数将配置应用到设备上。
c
int err = uart_configure(uart, &uart_cfg);
if (err) {
     printk("Failed to configure UART\n");
     return;
}

第三步:核心机制 - 事件回调

异步API的精髓在于其事件驱动的模式。您需要定义一个回调函数 (Callback Function),当特定的UART事件发生时(如数据到达、发送完成),驱动会自动在中断上下文中调用这个函数。

  1. 定义回调函数 这个函数有一个固定的原型。通常,我们在函数内部使用 switch 语句来处理我们感兴趣的不同事件。
js
static void uart_cb(const struct device *dev, struct uart_event *evt, void *user_data)
{
    switch (evt->type) {
case UART_RX_RDY:
    // 数据已准备好,可以读取
    // ... 在这里处理接收到的数据 ...
    break;

case UART_RX_DISABLED:
    // 接收已被禁用(例如缓冲区已满)
    // ... 在这里决定是否重新开启接收 ...
    break;

case UART_TX_DONE:
    // 发送操作已全部完成
    // ... 可以在这里执行发送完成后的清理工作 ...
    break;

default:
    break;
}
}

重要提示:回调函数在中断服务程序(ISR)中执行,其优先级非常高。为了保证系统的响应性,函数内的代码应尽可能简短、高效,避免执行耗时操作。

  1. 注册回调函数 使用 uart_callback_set() 函数,将我们定义的回调函数“注册”到UART驱动中。
c
err = uart_callback_set(uart, uart_cb, NULL);
if (err) {
     printk("Failed to set UART callback\n");
     return;
}

第四步:接收数据

配置好回调后,我们就可以启动数据接收了。

  1. 准备接收缓冲区

首先,定义一个静态的缓冲区,用于存放从UART接收到的数据。

c
static uint8_t rx_buf[20] = {0};
  1. 启动接收

调用 uart_rx_enable() 函数来启动接收。这个函数会立即返回,而DMA会在后台默默地将收到的数据填入您提供的缓冲区。

c
err = uart_rx_enable(uart, rx_buf, sizeof(rx_buf), 100); // 100us 的接收超时
if (err) {
    printk("Failed to enable UART RX\n");
}

此函数的最后一个参数 timeout 非常有用。它定义了一个“非活动超时”。如果在收到至少一个字节后,在指定的超时时间内没有再收到新的字节,驱动就会触发一个 UART_RX_RDY 事件,即使缓冲区没有被填满。这对于接收不定长的数据包非常关键。

  1. 在回调中处理数据

UART_RX_RDY 事件发生时,我们可以在回调函数中访问接收到的数据。

c
case UART_RX_RDY:
    // evt->data.rx.len 包含了这次接收到的数据长度
    // evt->data.rx.buf 指向了我们的接收缓冲区
    // evt->data.rx.offset 是数据在缓冲区中的起始偏移
    printk("Received %d bytes: %s\n", evt->data.rx.len, &evt->data.rx.buf[evt->data.rx.offset]);
    break;
  1. 实现连续接收

默认情况下,当接收缓冲区被填满后,UART接收会自动停止,并触发 UART_RX_DISABLED 事件。为了实现不间断的连续接收,我们必须在这个事件的处理中,再次调用 uart_rx_enable()

c
case UART_RX_DISABLED:
     // 重新启用接收,实现无缝连续接收
     uart_rx_enable(dev, rx_buf, sizeof(rx_buf), 100);
     break;

第五步:发送数据

与接收相比,发送数据要简单得多。

  1. 准备发送缓冲区

定义一个包含了您想发送的数据的缓冲区。

js
static const uint8_t tx_buf[] = {"Hello from nRF Connect SDK!\n\r"};
  1. 启动发送

调用 uart_tx() 函数。这个函数同样会立即返回,DMA会在后台将缓冲区中的所有数据依次发送出去。

c
err = uart_tx(uart, tx_buf, sizeof(tx_buf), SYS_FOREVER_US);
if (err) {
    printk("Failed to send data\n");
}
  1. (可选)处理发送完成事件

如果您的应用需要在数据全部发送完毕后执行某个操作(例如释放动态分配的缓冲区),您可以在回调函数的 UART_TX_DONE 事件中进行处理。

c
case UART_TX_DONE:
     printk("Transmission finished.\n");
     // 可以在这里触发下一个操作
     break;

举例:通过UART控制板上灯光

js
#include <stdio.h>
#include <zephyr/kernel.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/drivers/uart.h>

/* 1000 msec = 1 sec */
#define SLEEP_TIME_MS   1000

static const struct gpio_dt_spec led0 = GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios);
static const struct gpio_dt_spec led1 = GPIO_DT_SPEC_GET(DT_ALIAS(led1), gpios);
static const struct gpio_dt_spec led2 = GPIO_DT_SPEC_GET(DT_ALIAS(led2), gpios);
static const struct gpio_dt_spec led3 = GPIO_DT_SPEC_GET(DT_ALIAS(led3), gpios);

static const struct device *uart = DEVICE_DT_GET(DT_NODELABEL(uart20));

#define RECEIVE_BUFF_SIZE 10
static uint8_t rx_buf[RECEIVE_BUFF_SIZE] = {0};
#define RECEIVE_TIMEOUT 100
static uint8_t tx_buf[] =   {"nRF Connect SDK Fundamentals Course\r\n"
 "Press 0-2 on your keyboard to toggle LEDS 0-2 on your development kit\r\n"};

static void uart_cb(const struct device *dev, struct uart_event *evt, void *user_data)
{
    switch (evt->type) {
        case UART_RX_RDY:
            if(evt->data.rx.len == 1) {
                if(evt->data.rx.buf[evt->data.rx.offset] == '0') {
                    gpio_pin_toggle_dt(&led0);
                } else if(evt->data.rx.buf[evt->data.rx.offset] == '1') {
                    gpio_pin_toggle_dt(&led1);
                } else if(evt->data.rx.buf[evt->data.rx.offset] == '2') {
                    gpio_pin_toggle_dt(&led2);
                } else if(evt->data.rx.buf[evt->data.rx.offset] == '3') {
                    gpio_pin_toggle_dt(&led3);
                }
            }
            break;
        case UART_RX_DISABLED:
            uart_rx_enable(dev, rx_buf, sizeof(rx_buf), RECEIVE_TIMEOUT);
            break;
        default:
            break;
    }
}

int main(void)
{
    int ret;

    if(!device_is_ready(led0.port)) {
        printk("Error: led0 device not ready\r\n");
        return 1;
    }

    if (!device_is_ready(uart)) {
        return 0;
    }

    ret = gpio_pin_configure_dt(&led0, GPIO_OUTPUT_INACTIVE);
    if (ret < 0) {
        return 0;
    }

    ret = gpio_pin_configure_dt(&led1, GPIO_OUTPUT_INACTIVE);
    if (ret < 0) {
        return 0;
    }

    ret = gpio_pin_configure_dt(&led2, GPIO_OUTPUT_INACTIVE);
    if (ret < 0) {
        return 0;
    }

    ret = gpio_pin_configure_dt(&led3, GPIO_OUTPUT_INACTIVE);
    if (ret < 0) {
        return 0;
    }

    ret = uart_callback_set(uart, uart_cb, NULL);
    if (ret < 0) {
        return 1;
    }

    ret = uart_tx(uart, tx_buf, sizeof(tx_buf), SYS_FOREVER_US);
    if (ret) {
        return 1;
    }

    ret = uart_rx_enable(uart ,rx_buf,sizeof rx_buf,RECEIVE_TIMEOUT);
    if (ret) {
        return 1;
    }

    while (1) {
        k_msleep(SLEEP_TIME_MS);
    }
}

查看配置冲突

构建了应用程序,您可以返回 prj.conf 文件,注意到我们添加的第一行 CONFIG_SERIAL 下方出现了蓝色波浪线。这表示存在配置冲突,通常是因为该 Kconfig 符号在当前的构建上下文中被多次设置。将鼠标悬停在 Kconfig 符号上可查看配置冲突。此处可见构建中多次设置了 CONFIG_SERIAL

为APPLICATIONS选择build,然后在Details视图中展开 Kconfig。这里我们可以看到构成最终构建的所有文件。这里我们看到开发板的设备树中启用了 CONFIG_SERIAL 配置项。

查看设备树可视化编辑器

让我们通过设备树可视化编辑器来确认 UART 外设是否已在设备树中启用。打开设备树可视化编辑器。

Context Files:列出当前构建上下文对应的设备树文件(显示于Build Contexts View中)。

Context Overview:列出当前构建上下文的设备树信息。

Nodes:列出所选设备树文件中所有可用节点。此处对节点和引脚的所有修改都会自动同步到当前设备树文件的代码中。这些节点也会以图形化形式呈现在Editor View中,您可以通过该视图检查和修改节点属性及引脚分配。

Nodes视图中,找到 soc 菜单并通过点击右侧箭头展开。向下滚动直至找到 uart20,注意其方框内带有勾选标记,表示该外设在构建上下文中已启用。

串行通信(I2C)

与传感器和外部组件通信是微控制器最常见的任务之一。I2C(内部集成电路)总线是一种广泛用于与外部组件通信的协议。本课程将介绍 I2C 的基础知识,并学习如何使用 nRF Connect SDK 中的 I2C 外设驱动与外部传感器交互。通常在 nRF Connect SDK/Zephyr 中,传感器驱动会作为独立的"设备驱动"模块开发。但本课程重点在于掌握 I2C 接口 API,因此我们将直接通过应用程序调用这些 API 与传感器交互。

I2C驱动程序

I2C (Inter-Integrated Circuit) 是一种非常流行的两线式串行总线协议,广泛用于微控制器(MCU)与各种外设(如传感器、EEPROM、OLED显示屏等)之间的近距离通信。在nRF Connect SDK中,Zephyr提供了一套标准化的I2C控制器API,让我们可以方便地与连接在I2C总线上的任何设备进行交互。

本篇教程将指导您完成从零开始配置I2C驱动,到执行基本读写操作的全过程。

第一步:软件使能与硬件描述

在与I2C设备通信之前,我们必须先完成两项基础配置:在软件中启用I2C驱动,并在硬件描述(设备树)中声明我们的I2C设备。

  1. 启用I2C驱动

首先,在您的项目配置文件 prj.conf 中,添加以下配置来启用Zephyr的I2C驱动模块:

text
CONFIG_I2C=y
  1. 包含API头文件

在需要使用I2C功能的源文件中,包含相应的API头文件:

c
#include <zephyr/drivers/i2c.h>
  1. 在设备树中描述您的I2C设备

这是最关键的一步。I2C是一个总线,上面可以挂载多个设备。我们必须通过设备树覆盖文件 (Devicetree Overlay) 来告诉系统:

  1. 我们要使用哪个I2C总线控制器(如 i2c0i2c1)。

  2. 我们的设备(例如一个传感器)挂载在这个总线上。

  3. 这个设备的I2C地址是多少。

假设我们要连接一个I2C传感器,其设备手册上标明地址为 0x4A,并且我们将其连接到了开发板的 i2c0 总线上。我们需要在项目的 .overlay 文件中添加如下内容:

c
/* boards/nrf52840dk_nrf52840.overlay */

/* 引用 i2c0 总线控制器节点 */
&i2c0 {
    /* 在 i2c0 节点下,定义一个子节点来代表我们的传感器 */
    /* 节点命名规范:<node-name>@<i2c-address> */
    mysensor: mysensor@4a {
        /* 兼容性字符串,对于通用I2C设备,可设为 "i2c-device" */
        compatible = "i2c-device";
        /* 设备的I2C地址,这是必须的 */
        reg = <0x4a>;
        /* 为设备创建一个标签,方便在代码中引用 */
        label = "MYSENSOR";
    };
};

这段代码清晰地描述了“一个名为mysensor、地址为0x4A的I2C设备,被连接在i2c0总线上”。

第二步:初始化设备句柄

完成了硬件描述后,我们需要在C代码中获取一个能够代表这个I2C设备的“句柄”,以便后续调用API。与GPIO类似,I2C驱动也提供了一个方便的专用结构体 i2c_dt_spec

  1. 获取设备句柄

i2c_dt_spec 这个结构体打包了操作I2C设备所需的所有信息:指向I2C总线控制器的指针 (bus) 和目标设备的地址 (addr)。我们使用 I2C_DT_SPEC_GET() 宏,并传入在设备树中定义的传感器节点的标识符来获取它。

js
/* 使用设备树宏,通过我们定义的节点标签 'mysensor' 来获取节点标识符 */
#define I2C_DEVICE_NODE DT_NODELABEL(mysensor)

/* 使用 I2C_DT_SPEC_GET 宏,传入节点标识符,获取完整的设备规约结构体 */
static const struct i2c_dt_spec dev_i2c = I2C_DT_SPEC_GET(I2C_DEVICE_NODE);

执行完这行代码后,dev_i2c 这个结构体变量就完整地代表了我们的传感器。

  1. 检查总线就绪状态

在使用之前,必须检查其所依赖的I2C总线控制器是否已准备就绪。

c
if (!device_is_ready(dev_i2c.bus)) {
     printk("I2C bus %s is not ready!\n\r", dev_i2c.bus->name);     return; }

请注意,我们检查的是 dev_i2c.bus,即总线控制器本身。

第三步:执行I2C读写操作

现在,我们万事俱备,可以通过 dev_i2c 句柄来与传感器进行通信了。

I2C 写操作 (i2c_write_dt)

写操作通常用于配置传感器内部的寄存器。一个典型的写操作需要发送至少两个字节:第一个字节是目标寄存器的地址,第二个(及后续)字节是要写入的值。

c
int ret;
// 准备一个缓冲区:将0x8C这个值写入到地址为0x03的寄存器中
uint8_t config_data[2] = {0x03, 0x8C};

ret = i2c_write_dt(&dev_i2c, config_data, sizeof(config_data));
if (ret != 0) {
    printk("Failed to write to I2C device\n");
}

I2C 读操作 (i2c_read_dt)

这个函数用于从设备读取数据。但需要注意,在调用它之前,您通常需要先执行一次写操作,告诉设备您接下来想读取哪个寄存器。

c
uint8_t register_to_read = 0x03;
uint8_t read_data;

// 1. 先写入要读取的寄存器地址
ret = i2c_write_dt(&dev_i2c, &register_to_read, sizeof(register_to_read));

// 2. 然后再执行读操作
if (ret == 0) {
    ret = i2c_read_dt(&dev_i2c, &read_data, sizeof(read_data));
    if (ret != 0) {
        printk("Failed to read from I2C device\n");
    }
}

I2C 写后读 (i2c_write_read_dt) - 推荐的读取方式

上述“先写后读”的操作非常普遍,因此I2C API提供了一个更高效、更可靠的复合函数 i2c_write_read_dt。它在一个原子事务中完成了“写入寄存器地址”和“读取寄存器数据”两个步骤,避免了总线被其他任务中断的风险。

c
uint8_t register_to_read = 0x02;
uint8_t read_data;

// 同时执行写和读:写入1个字节(寄存器地址),然后读取1个字节(寄存器数据)
ret = i2c_write_read_dt(&dev_i2c, &register_to_read, sizeof(register_to_read), &read_data, sizeof(read_data));
if (ret != 0) {
    printk("Failed to write/read I2C device\n");
}

I2C 突发读 (i2c_burst_read_dt)

当需要从一个起始地址开始,连续读取多个寄存器的数据时(例如读取加速度计的X, Y, Z三轴数据),i2c_burst_read_dt 是最佳选择。

c
// 假设颜色传感器的红色数据低字节寄存器地址为 BH1749_RED_DATA_LSB
// 我们要连续读取6个字节,覆盖R, G, B三个通道的数据
uint8_t rgb_values[6] = {0};

ret = i2c_burst_read_dt(&dev_i2c, BH1749_RED_DATA_LSB, rgb_values, sizeof(rgb_values));
if (ret != 0) {
    printk("Failed to perform burst read\n");
}

通过以上步骤,您已经掌握了在nRF Connect SDK中与I2C设备通信的完整流程。核心在于正确地使用设备树Overlay来描述硬件,并通过 i2c_dt_spec 句柄来调用相应的API函数,从而实现稳定可靠的数据交换。

连接一个BME280温湿度传感器

本节将阐述如何使用一块搭载了 BME280 传感器的 Waveshare 15231 模块来获取温度读数。该模块同时支持 I2C 和 SPI 通信协议。

1. 硬件连接

要进行通信,首先需要将传感器模块连接到开发板(此处以 nRF54L15 DK 为例)。连接应使用开发板上的 P1 排针接口。

  • 核心连接

    • P1.11 引脚配置为 SCL (串行时钟)。

    • P1.12 引脚配置为 SDA (串行数据)。

    • 开发板的 VDDIOGND 分别连接到传感器模块的 VCC (电源) 和 GND (接地)。

2. BME280 工作原理

2.1 传感器模式

BME280 传感器具有三种工作模式:睡眠模式、强制模式和正常模式。传感器上电后,默认进入睡眠模式。为了读取数据,必须先将其配置为正常模式强制模式

2.2 基于寄存器的通信

与 BME280 的所有通信都是通过读写其内部的8位寄存器来完成的。这些寄存器构成了传感器的内存映射表。

  • 寄存器分类

    • 校准寄存器 (Calibration data): 只读。存储着出厂时固化的校准参数,用于修正原始读数。

    • 控制寄存器 (Control registers): 可读可写。用于配置传感器的工作模式、采样率等。

    • 数据寄存器 (Data registers): 只读。存储着传感器完成测量后得到的原始数据。

    • 状态寄存器 (Status) 和 芯片ID (chip-id): 只读。

    • 复位寄存器 (reset): 只写。

2.3 数据读取与校准

传感器的数据寄存器提供20位的压力值、20位的温度值和16位的湿度值。由于所有寄存器都是8位宽,读取一个完整的测量值需要执行多次字节读取操作。

  • 突发读取 (Burst Read):为了高效地读取连续地址上的数据(如温度、压力和湿度值),推荐使用“突发读取”模式。此模式从一个起始地址(例如 0xF7)开始连续读取多个字节,而无需为每个字节单独指定地址。

  • 数据补偿 (Compensation):从数据寄存器直接读出的值是未经补偿的原始数据 (uncompensated measurements)。必须结合从校准寄存器中读出的校准参数,通过特定的数学公式进行计算,才能得到真实的环境温度、压力和湿度值。校准参数的寄存器地址和数据类型是固定的。

3. 软件实现步骤

3.1 项目配置 (prj.conf)

prj.conf 文件用于为项目启用或禁用特定的软件功能模块。

  1. 启用 I2C 驱动: 在 prj.conf 文件中添加以下配置,以在项目中包含 I2C 驱动程序。
text
CONFIG_I2C=y
  1. 启用浮点数打印支持printk 函数默认不支持打印浮点数以节省代码空间。为了能以浮点格式显示温度,需要添加以下配置。这会使最终的程序增加约1KB的大小。
text
CONFIG_CBPRINTF_FP_SUPPORT=y
3.2 设备树 (Devicetree) 配置

设备树是一种用来描述硬件连接和配置的文本格式,它将硬件信息与C代码分离。对于外部传感器,需要通过一个覆盖文件 (overlay file) 来向主板的默认设备树中添加额外信息。

  1. 创建覆盖文件: 在项目目录下创建一个名为 boards 的文件夹,并在其中创建一个 .overlay 文件。文件名应遵循 [board]_[soc].overlay 的格式,例如 nrf54l15dk_nrf54l15_cpuapp_ns.overlay

  2. 添加设备树节点: 在覆盖文件中添加以下内容。这段代码的作用是:

    1. 启用 i2c22 控制器。

    2. 为该控制器配置 SCL 和 SDA 所使用的具体引脚 (P1.11P1.12)。

    3. 在 I2C 总线上声明一个名为 mysensor 的新设备,其I2C地址为 0x77

c
&i2c22 {
    status = "okay";
    pinctrl-0 = <&i2c22_default>;
    pinctrl-1 = <&i2c22_sleep>;
    pinctrl-names = "default", "sleep";
    mysensor: mysensor@77{
        compatible = "i2c-device";
        status = "okay";
        reg = < 0x77 >;
    };
};

&pinctrl {
    /omit-if-no-ref/ i2c22_default: i2c22_default {
        group1  {
            psels = <NRF_PSEL(TWIM_SCL, 1, 11)>,
                    <NRF_PSEL(TWIM_SDA, 1, 12)>;
        };
    };
/omit-if-no-ref/ i2c22_sleep: i2c22_sleep {
    group1  {
        psels = &lt;NRF_PSEL(TWIM_SCL, 1, 11)&gt;,
                &lt;NRF_PSEL(TWIM_SDA, 1, 12)&gt;;
        low-power-enable;
    };
};
};

关于I2C地址 0x77:BME280的7位设备地址是 111011x。最后一位 x 由 SDO 引脚的电平决定。在 Waveshare 15231 模块上,该引脚被上拉至高电平,因此地址为 1110111,即十六进制的 0x77

i2c22的含义

  • i2c: 代表这是一个I2C通信接口(Nordic有时也称之为TWIM,即Two-Wire Interface Master)。

  • 22: 是这个I2C接口在nRF54L15芯片内部所有外设中的唯一实例编号或地址标识

pinctrl-0 = <&i2c22_default>;pinctrl是“Pin Control”(引脚控制)的缩写。这行代码是说,当i2c22处于第0种状态时,它的引脚配置由一个名为i2c22_default的配置块来决定。

pinctrl-1 = <&i2c22_sleep>;:同理,当i2c22处于第1种状态时,引脚配置由i2c22_sleep配置块决定。

c
&pinctrl {
    /* ... 配置块1: default ... */
    i2c22_default: i2c22_default {
        group1  {
            psels = <NRF_PSEL(TWIM_SCL, 1, 11)>,
                    <NRF_PSEL(TWIM_SDA, 1, 12)>;
        };
    };
    /* ... 配置块2: sleep ... */
    i2c22_sleep: i2c22_sleep {
        group1  {
            psels = <NRF_PSEL(TWIM_SCL, 1, 11)>,
                    <NRF_PSEL(TWIM_SDA, 1, 12)>;
            low-power-enable;
        };
    };
};

这部分定义了上面pinctrl-0pinctrl-1所引用的具体配置是什么。

  • &pinctrl { ... }:表示我们要引用并修改芯片的总引脚控制器。

i2c22_default: i2c22_default { ... }:这里定义了名为i2c22_default的配置块。

  • psels = <...>psels是“Pin Selects”的缩写,这是最核心的映射关系

  • NRF_PSEL(TWIM_SCL, 1, 11):这是一个宏,意思是将TWIM_SCL(I2C时钟)功能,分配给端口111号引脚(即P1.11)。

  • NRF_PSEL(TWIM_SDA, 1, 12):将TWIM_SDA(I2C数据)功能,分配给端口112号引脚(即P1.12)。

  • i2c22_sleep: i2c22_sleep { ... }:这里定义了睡眠状态下的引脚配置。

    • psels = <...>:引脚映射关系通常保持不变。

    • low-power-enable;:这是一个关键的标志,它告诉引脚控制器,将这些引脚配置为低功耗模式,以在系统睡眠时最大限度地节省电量。

3.3 C 代码实现
  1. 包含头文件: 在 main.c 中包含 I2C 驱动和打印功能的头文件。
c
#include <zephyr/drivers/i2c.h>
#include <zephyr/sys/printk.h>
  1. 获取设备节点并初始化: 使用宏 DT_NODELABEL 从设备树中获取 mysensor 节点的标识符。然后,I2C_DT_SPEC_GET 宏利用这个标识符创建一个 i2c_dt_spec 结构体实例,该实例包含了与传感器通信所需的所有信息(如I2C总线指针和设备地址)。
js
#define I2C_NODE DT_NODELABEL(mysensor)

static const struct i2c_dt_spec dev_i2c = I2C_DT_SPEC_GET(I2C_NODE);

// 检查I2C总线是否准备就绪
if (!device_is_ready(dev_i2c.bus)) {
    printk("I2C bus %s is not ready!\n\r",dev_i2c.bus->name);
    return -1;
}
  1. 定义寄存器地址: 为了代码的可读性,使用 #define 定义关键寄存器的地址。
c
#define CTRLMEAS 0xF4  // 控制寄存器
#define CALIB00  0x88  // 校准参数起始地址
#define ID       0xD0  // 芯片ID寄存器地址
#define TEMPMSB  0xFA  // 温度数据最高位寄存器地址
  1. 验证通信:读取芯片ID: 通过读取芯片ID寄存器来确认传感器是否连接正确且能够通信。这是一个组合操作:先写入要读取的寄存器地址 (ID),然后读取一个字节的数据。
c
uint8_t id = 0;
uint8_t regs[] = {ID};
int ret = i2c_write_read_dt(&dev_i2c, regs, 1, &id, 1);

if (ret != 0) {
    printk("Failed to read register %x \n", regs[0]);
    return -1;
}

if (id != 0x60) { // BME280的芯片ID应为0x60
    printk("Invalid chip id! %x \n", id);
    return -1;
}

读取校准参数: 使用突发读取 i2c_burst_read_dt 从校准参数起始地址 CALIB00 连续读取6个字节。

c
uint8_t values[6];
ret = i2c_burst_read_dt(&dev_i2c, CALIB00, values, 6);
if (ret != 0) {
    printk("Failed to read register %x \n", CALIB00);
    return;
}

读取后,将字节数组中的数据按小端模式(Little-Endian)组合成16位的校准参数值。

c
// 示例:组合温度校准参数
dig_t1 = ((uint16_t)values[1]) << 8 | values[0];
dig_t2 = ((uint16_t)values[3]) << 8 | values[2];
dig_t3 = ((uint16_t)values[5]) << 8 | values[4];

配置传感器工作模式: 向控制寄存器 CTRLMEAS (0xF4) 写入一个值来配置传感器。例如,写入 0x93 可以将传感器设置为正常模式,并设置温度和压力的过采样率。

c
uint8_t sensor_config[] = {CTRLMEAS, 0x93};
ret = i2c_write_dt(&dev_i2c, sensor_config, 2);
if (ret != 0) {
    printk("Failed to write register %x \n", sensor_config[0]);
    return -1;
}

循环读取并计算温度: a. 读取原始数据:从温度数据起始地址 TEMPMSB (0xFA) 突发读取3个字节。

c
uint8_t temp_val[3] = {0};
ret = i2c_burst_read_dt(&dev_i2c, TEMPMSB, temp_val, 3);
if (ret != 0) {
    printk("Failed to read register %x \n", TEMPMSB);
    continue;
}
b. **组合原始值**:将读取到的3个字节组合成一个20位的原始温度值 `adc_temp`。
int32_t adc_temp = (temp_val[0] << 12) | (temp_val[1] << 4) | ((temp_val[2] >> 4) & 0x0F);

c. 补偿计算:调用数据手册中提供的补偿函数(例如 bme280_compensate_temp),传入原始值 adc_temp 和之前读取的校准参数,得到精确的温度值。

text
int32_t comp_temp = bme280_compensate_temp(adc_temp, &calibration_data);

d. 格式化并显示:将计算结果转换为浮点数(单位:摄氏度),并打印到控制台。

c
float temperature = (float)comp_temp / 100.0f;
double fTemp = (double)temperature * 1.8 + 32;

printk("Temperature in Celsius : %8.2f C\n", (double)temperature);
printk("Temperature in Fahrenheit : %.2f F\n", fTemp);
3.4 烧录与观察

完成硬件连接和代码编写后,编译应用程序并烧录到开发板。通过串口终端,可以看到类似以下的输出:

text
*** Booting nRF Connect SDK ***
Temperature in Celsius :    26.37 C
Temperature in Fahrenheit : 79.47 F
...

多线程应用

nRF Connect SDK 采用 Zephyr RTOS,这是一款专为嵌入式开发设计的实时操作系统。Zephyr 包含众多内核服务及其他功能特性(如线程),支持多线程应用开发。本课程将详解线程概念及 nRF Connect SDK/Zephyr 提供的线程相关服务。

裸机编程 vs RTOS 编程

裸机应用程序的核心,就是在设备上电/复位例程中初始化硬件/软件后,主函数中的一个大型循环。所有执行都是顺序逻辑,换句话说,除非被中断服务例程(ISR)打断,否则所有指令都是按顺序执行的。因此在裸机编程中,唯一非顺序逻辑的实现方式就是利用异常处理。

虽然裸机编程通常被认为具有更高的能效、更低的内存占用和潜在的性能优势,但这并非绝对。对于简单应用,采用单一顺序逻辑循环就足够了,可以受益于裸机程序固有的能效和内存节省特性。然而随着应用复杂度增加,仅通过顺序逻辑来维护固件架构会变得困难、难以扩展且不便移植。这时采用实时操作系统(RTOS)的优势就显现出来了。

在操作系统之上设计应用程序,可以让多个并发逻辑运行在不同执行单元(称为线程)中,使架构变得简单,而不是像独立模式下那样只在主函数中运行单一顺序逻辑。

实时操作系统的核心称为内核 ,它控制系统中的所有内容。另一个重大优势是像 Zephyr 这样的 RTOS 原生提供了大量现成的库、驱动程序和协议栈资源。

中断服务程序(ISRs)在基于 RTOS 的应用程序和裸机应用程序中都可用。它们由配置的不同设备驱动程序(包括回调函数)和协议栈异步生成。

Zephyr RTOS 基础

在 nRF Connect SDK 中,代码的执行主要由几种不同的单元来完成,包括线程和中断服务程序(ISR)。调度器负责决定在任何给定时刻哪个单元可以使用CPU。

1. 线程 (Threads)

线程是实时操作系统(RTOS)调度器可以进行CPU时间调度的最小逻辑执行单元。可以将其理解为一段可以独立运行的代码,它会与其他线程竞争CPU使用权。

1.1 线程状态

在任何时刻,一个线程都必然处于以下三种状态之一:

  • 运行 (Running) 状态:线程当前正在CPU上执行。这意味着调度器已经选择此线程来占用CPU。

  • 就绪 (Runnable / Ready) 状态:线程已经准备好执行,没有任何依赖项(如等待某个资源),它唯一等待的就是CPU时间。调度器会在这些就绪的线程中选择下一个要运行的线程。

  • 非就绪 (Non-runnable / Unready) 状态:线程因某些因素而无法执行。例如,它可能正在等待一个尚未就绪的资源(如等待数据接收),或者它已经被挂起或终止。调度器不会考虑将处于此状态的线程投入运行。

1.2 线程类型
  • 系统线程 (System threads) 系统线程是由 Zephyr RTOS 在初始化期间自动创建的线程。默认情况下总会有两个:

    • 主线程 (main thread):负责执行RTOS的初始化,并调用应用程序的 main() 函数。

    • 空闲线程 (idle thread):当系统中没有其他任务需要执行时,此线程会运行。在Nordic的设备中,它会激活电源管理功能以节省功耗。

  • 用户创建的线程 (User-created threads) 开发者可以定义自己的线程来执行特定任务。例如,可以创建一个线程专门用于读取传感器数据,另一个线程用于处理这些数据。

  • 工作队列线程 (Workqueue threads) 工作队列(Workqueue)是一种常见的执行机制,它允许将一个函数(称为“工作项”)提交给一个专用的线程(即“工作队列线程”)来执行。

其核心思想是将非紧急或耗时的工作从中断服务程序(ISR)或高优先级线程中“卸载”到一个优先级较低的线程中去处理。

  • 工作流程

    • 一个ISR或高优先级线程将一个“工作项”提交到一个内核对象——工作队列中。

    • 一个专用的工作队列线程以“先进先出”(FIFO)的顺序从队列中取出工作项。

    • 工作队列线程调用预先定义好的处理函数来执行该工作项。

  • 优势:与为每个小任务都创建一个独立线程相比,工作队列更轻量。因为所有工作项共享同一个工作队列线程的栈空间,所以无需为每个工作项单独分配栈内存,从而节省了系统资源。系统提供了一个默认的系统工作队列 (system workqueue),可供任何代码使用。

2. 线程优先级 (Thread Priority)

线程的优先级由一个整数表示,数值越小,优先级越高。例如,优先级为4的线程会比优先级为7的线程先获得执行机会。

根据优先级的正负,线程被分为两种类型:

  • 协作式线程 (Cooperative threads):优先级为负数。一旦一个协作式线程开始运行,它将一直占用CPU,直到它自己主动放弃(例如,任务完成或等待资源),才会让出CPU。这种线程用途有限。

  • 可抢占线程 (Preemptible threads):优先级为非负数(大于等于0)。当一个可抢占线程正在运行时,如果有一个优先级更高(或相等)的线程变为就绪状态,调度器会立即中断当前线程,转而运行那个更高优先级的线程。这个过程称为**“抢占” (Preemption)**。

默认情况下,可抢占线程的优先级范围是 014。主线程的默认优先级是 0,空闲线程的默认优先级是 15(在Zephyr v3.5及以后版本,空闲线程优先级为14)。

3. 调度器 (Scheduler)

CPU时间是有限资源。当一个应用包含多个并发逻辑时,调度器的职责就是决定在任何时刻哪个任务可以使用CPU。

  • Tickless RTOS:Zephyr 是一个**“Tickless”(无滴答)的实时操作系统。这意味着它不是依赖一个周期性的定时器中断来触发调度,而是完全由事件驱动**的。

  • 重调度点 (Rescheduling point): 重调度点是调度器被调用以选择下一个要运行线程的时刻。任何导致就绪线程状态发生变化的操作都会触发一个重调度点。例如:

    • 一个线程调用 k_yield() 主动放弃CPU。

    • 通过信号量、互斥锁等同步机制,一个被阻塞的线程被唤醒,状态从“非就绪”变为“就绪”。

    • 一个正在等待数据的线程接收到了新数据。

    • 当启用了时间分片(Time Slicing)功能时,一个线程连续运行的时间超过了其允许的最大时间片。

4. 中断服务程序 (ISRs)

中断服务程序(Interrupt Service Routines, ISRs)是由硬件事件(如外设数据准备好)异步触发的函数,它们不经过调度器调度

  • 关键特性:ISRs 会抢占当前正在运行的任何线程的执行。线程的执行只有在所有ISR工作完成后才能恢复。

  • 核心原则:正因为ISR具有最高优先级的抢占能力,所以它必须非常快速地执行完毕。ISR中严禁包含任何耗时的操作(如复杂的计算、循环)或可能导致阻塞的函数调用(如等待信号量)。

  • 最佳实践:如果中断事件需要进行耗时处理,应当在ISR中将该任务移交给一个线程来完成,例如,通过向工作队列提交一个工作项。

线程创建与优先级

本节将阐述如何在 nRF Connect SDK 中创建并初始化两个线程,并探讨它们的优先级如何相互影响。

1. 线程的创建方式

在 Zephyr RTOS 中,创建线程主要有两种方式:

  1. 动态创建:在程序运行时通过 k_thread_create() 函数创建。

  2. 静态定义:在编译时通过 K_THREAD_DEFINE() 宏来定义。这是更常用的方法,它负责定义并初始化一个线程,并将其相关的数据结构注册到 RTOS 内核中。

K_THREAD_DEFINE() 宏的接口定义如下,它接受多个参数来配置线程的行为:

K_THREAD_DEFINE(name, stack_size, entry, p1, p2, p3, prio, options, delay)

  • name: 线程的标识符 (ID)。

  • stack_size: 分配给线程的栈空间大小。

  • entry: 线程的入口函数,线程启动后将执行此函数。

  • p1, p2, p3: 传递给入口函数的三个可选参数。

  • prio: 线程的调度优先级。

  • options: 可选的线程选项。

  • delay: 可选的调度延迟,即线程创建后延迟多久才进入就绪状态。

2. 实践:创建与调度

2.1 定义线程参数

首先,为将要创建的两个线程定义栈大小和调度优先级。

c
#define STACKSIZE 1024
#define THREAD0_PRIORITY 7
#define THREAD1_PRIORITY 7
  • 栈大小 (Stack Size): 栈的大小应为2的幂次方(如512, 1024, 2048)。在实际应用开发中,需要仔细估算以避免不必要的内存浪费。

  • 优先级 (Priority): 此处将两个线程设置为相同的优先级,这是观察它们之间调度行为的关键。

2.2 定义线程入口函数

每个线程都需要一个入口函数,它本质上就是该线程要执行的主体代码。此处让两个线程在一个无限循环中打印一条信息。

c
// 线程0的入口函数
void thread0(void)
{
    while (1) {
        printk("Hello, I am thread0\n");
    }
}

// 线程1的入口函数
void thread1(void)
{
    while (1) {
        printk("Hello, I am thread1\n");
    }
}

由于这两个线程没有相互依赖,也没有调用任何让出CPU或休眠的函数,它们将始终处于“就绪 (Runnable)”状态,持续竞争CPU资源。

2.3 使用宏定义线程

使用 K_THREAD_DEFINE() 宏来静态定义这两个线程。

c
K_THREAD_DEFINE(thread0_id, STACKSIZE, thread0, NULL, NULL, NULL,
        THREAD0_PRIORITY, 0, 0);

K_THREAD_DEFINE(thread1_id, STACKSIZE, thread1, NULL, NULL, NULL,
        THREAD1_PRIORITY, 0, 0);

这里为线程指定了ID、栈大小、入口函数和优先级,而可选的参数、选项和延迟均设置为默认值(NULL0)。

2.4 初始运行结果:线程饥饿

编译并烧录程序后,通过串口终端会观察到如下输出:

text
*** Booting nRF Connect SDK ***
Hello, I am thread0
Hello, I am thread0
Hello, I am thread0
...

可以发现,只有 thread0 的信息被打印出来。thread1 即使被创建且优先级相同,也永远无法获得执行机会。这种现象称为 线程饥饿 (thread starvation)。原因是 thread0 一旦获得CPU,就再也没有执行任何能够触发“重调度点”的操作(如主动放弃CPU或等待事件),因此调度器没有机会去运行其他线程。

3. 解决方案一:线程让步 (Yielding)

为了避免 thread1 饥饿,可以让 thread0 在完成一次打印后,使用 k_yield() 主动让出 (yield) CPU。

k_yield() 的作用是使当前线程放弃CPU执行权,让调度器选择另一个同等或更高优先级的就绪线程来运行。调用 k_yield() 后,原线程的状态从“运行”变为“就绪”,并被排到就绪队列的末尾。

3.1 修改 thread0 并观察

thread0printk() 后添加 k_yield()

c
void thread0(void)
{
    while (1) {
        printk("Hello, I am thread0\n");
        k_yield();
    }
}

烧录程序后,输出变为:

text
*** Booting nRF Connect SDK ***
Hello, I am thread0
Hello, I am thread1
Hello, I am thread1
Hello, I am thread1
...

现在,thread0 打印一条消息后主动让出CPU。由于 thread1 是一个同等优先级的就绪线程,调度器会立即将其投入运行。但因为 thread1 自身从不让出CPU,所以一旦它开始运行,就会永远霸占CPU,反过来导致 thread0 饥饿。

3.2 修改 thread1 并观察

thread1 中也加入 k_yield()

c
void thread1(void)
{
    while (1) {
        printk("Hello, I am thread1\n");
        k_yield();
    }
}

烧录程序后,输出变为交替打印:

text
*** Booting nRF Connect SDK ***
Hello, I am thread0
Hello, I am thread1
Hello, I am thread0
Hello, I am thread1
...

由于两个线程现在都会在打印后让出CPU,调度器总能在就绪队列中找到另一个同优先级的线程来运行,从而实现了两个线程的交替执行。

k_yield() 的缺点:频繁地调用 k_yield() 会频繁地触发调度器工作。调度器本身的运行也需要消耗CPU时间和功耗。一个设计良好的系统应尽量减少不必要的调度开销。

4. 解决方案二:线程休眠 (Sleeping)

对于打印信息这类非关键任务,让线程不那么频繁地执行是可以接受的。一个比 k_yield() 更好的选择是让线程休眠 (sleep)

休眠是通过 k_sleep() 或其衍生函数(如 k_msleep())实现的。它会将线程置于“非就绪 (Non-runnable)”状态一段指定的时间。在这段时间内,线程不会参与CPU竞争。

4.1 修改代码并观察

将两个线程中的 k_yield() 替换为 k_msleep(5),使其休眠5毫秒。

c
void thread0(void)
{
    while (1) {
           printk("Hello, I am thread0\n");
           k_msleep(5);
    }
}

void thread1(void)
{
    while (1) {
           printk("Hello, I am thread1\n");
             k_msleep(5);
    }
}

烧录后,输出看起来与使用 k_yield() 时相同,都是交替打印。

4.2 休眠的本质区别

尽管输出相似,但底层的运行机制有天壤之别。当两个线程都调用 k_msleep() 进入休眠状态后,它们都处于“非就绪”状态。如果此时系统中没有其他就绪的用户线程,调度器就会让空闲线程 (idle thread) 运行。空闲线程的一个重要作用就是让系统进入低功耗状态

因此,使用休眠不仅能实现任务的交替执行,还能在任务空闲时显著降低系统功耗,这远比频繁让步要高效。

5. 结论:k_yield() vs k_sleep()

  • k_yield()

    • 状态变化:运行 -> 就绪 (Runnable)

    • 效果:线程立即放弃CPU,但仍是调度器的候选者。适用于希望同等或更高优先级的任务能立即得到执行的场景。

  • k_sleep()

    • 状态变化:运行 -> 非就绪 (Non-runnable),直到休眠时间结束才变回 就绪

    • 效果:线程在指定时间内完全脱离调度,不参与CPU竞争。这是实现任务延时、降低频率和节省功耗的首选方式。

时间分片 (Time Slicing)

如果不想在同等优先级的线程之间费心设计完美的让步逻辑,可以启用时间分片功能。时间分片是一种由调度器强制执行的机制,用于确保同等优先级的多个线程能公平地分享CPU时间。

1. 初始场景:没有时间分片

首先,考察两个线程在没有主动让出CPU的情况下的行为。

c
void thread0(void)
{
	while (1) {
            printk("Hello, I am thread0\n");
            k_busy_wait(1000000); // 忙等待1秒
	}
}

void thread1(void)
{
	while (1) {
            printk("Hello, I am thread1\n");
            k_busy_wait(1000000); // 忙等待1秒
	}
}
  • k_busy_wait() 函数是一个“忙等待”函数。它会使当前线程执行一个指定时长的空循环,在此期间持续占用CPU而不让出执行权。此函数主要用于调试目的,不推荐在生产代码中使用。

  • 在这种代码结构下,一旦某个线程开始运行,它将无限期地阻塞另一个同优先级的线程,导致线程饥饿。

2. 启用时间分片

为了避免线程饥饿,可以在项目配置文件 prj.conf 中启用时间分片功能。

text
CONFIG_TIMESLICING=y
CONFIG_TIMESLICE_SIZE=10
CONFIG_TIMESLICE_PRIORITY=0
  • CONFIG_TIMESLICING=y: 启用时间分片特性。

  • CONFIG_TIMESLICE_SIZE=10: 设置时间片的长度(单位:毫秒)。一个线程在被调度器强制抢占前,最多可以连续运行的时长。

  • CONFIG_TIMESLICE_PRIORITY=0: 设置时间分片的优先级阈值。时间分片仅对优先级数值等于或高于此阈值的同级线程生效。设置为 0 意味着所有可抢占线程(优先级从0到15)在同级竞争时,都会受到时间分片的影响。

核心规则:时间分片只在优先级相同的线程之间起作用。

3. 启用时间分片后的行为

3.1 同等优先级线程

thread0thread1 优先级相同时,烧录程序后,终端输出会交替出现:

text
*** Booting nRF Connect SDK ***
Hello, I am thread0
Hello, I am thread1
Hello, I am thread0
Hello, I am thread1
...
Hello, I amHello, I am  thread0
thread1
  • 现象分析:调度器会在一个线程运行了所配置的时间片(此处为10毫秒)后,强制地抢占 (preempt) 它,无论它正在执行什么操作。然后,调度器会把CPU交给就绪队列中下一个同等优先级的线程。

  • 注意:从混乱的输出可以看出,调度器甚至会在 printk() 函数尚未完整输出一条信息时就进行抢占。被抢占的线程下次恢复执行时,会从上次被中断的地方继续。

3.2 不同优先级线程

现在,修改线程的优先级,使 thread0 的优先级高于 thread1

c
#define THREAD0_PRIORITY 6
#define THREAD1_PRIORITY 7

烧录程序后,会发现只有 thread0 的信息被持续输出,thread1 再次陷入饥饿。

现象分析

  • 尽管时间分片功能是开启的,调度器依然会在每个时间片(10毫slug)结束时被唤醒。

  • 唤醒后,调度器会检查是否存在其他同等或更高优先级的就绪线程。

  • 由于 thread1 的优先级较低,调度器在检查后发现 thread0 依然是当前最高优先级的就绪线程。

  • 因此,调度器会决定让 thread0 继续运行下一个时间片。

  • 这个过程会无限重复,导致优先级较低的 thread1 永远无法获得执行机会。

结论:时间分片不能覆盖基本的优先级调度规则。它只是一种在同等优先级的线程之间实现公平调度的机制,而不会让低优先级线程抢占高优先级线程。

工作队列创建与工作项提交

由于高优先级线程有能力“饿死”低优先级线程,一个良好的实践是将所有非紧急的执行任务从高优先级线程中**“卸载” (offload)** 到一个优先级较低的线程中去处理。工作队列(Workqueue)是实现这一目标的理想机制。

1. 问题场景:高优先级线程阻塞低优先级线程

首先,构建一个场景来演示该问题。此场景包含两个线程,thread0 的优先级高于 thread1

1.1 线程定义与优先级

定义三个优先级,其中 thread0 优先级最高,thread1 次之,而将要创建的工作队列线程优先级最低。

c
#define THREAD0_PRIORITY 2
#define THREAD1_PRIORITY 3
#define WORKQ_PRIORITY   4
1.2 模拟耗时工作

定义一个内联函数 emulate_work(),通过一个空循环来模拟耗时的计算任务。此函数在执行期间会持续占用CPU。

c
static inline void emulate_work()
{
    for(volatile int count_out = 0; count_out < 300000; count_out ++);
}
1.3 线程入口函数

两个线程都执行相同的逻辑:记录一个起始时间戳,调用 emulate_work(),然后计算并打印完成该任务所花费的时间,最后休眠20毫秒。

c
void thread0(void)
{
    uint64_t time_stamp;
    int64_t delta_time;

    while (1) {
        time_stamp = k_uptime_get();
        emulate_work();
        delta_time = k_uptime_delta(&time_stamp);

        printk("thread0 yielding this round in %lld ms\n", delta_time);
        k_msleep(20);
    }
}

void thread1(void)
{
    uint64_t time_stamp;
    int64_t delta_time;

    while (1) {
        time_stamp = k_uptime_get();
        emulate_work();
        delta_time = k_uptime_delta(&time_stamp);

        printk("thread1 yielding this round in %lld ms\n", delta_time);
        k_msleep(20);
    }
}
1.4 运行结果分析

烧录程序后,终端输出如下:

text
*** Booting nRF Connect SDK ***
thread0 yielding this round in 23 ms
thread0 yielding this round in 24 ms
thread1 yielding this round in 50 ms
thread0 yielding this round in 23 ms
thread0 yielding this round in 23 ms
thread1 yielding this round in 50 ms
...
  • 现象:高优先级的 thread0 完成 emulate_work() 大约需要23毫秒。而低优先级的 thread1 完成完全相同的任务却需要超过两倍的时间(约50毫秒)。

  • 原因thread0 的优先级更高。当 thread0 从休眠中唤醒时,它会立即抢占正在执行 emulate_work()thread1thread1 只有在 thread0 完成自己的任务并再次进入休眠后,才能获得CPU时间。这种频繁的中断导致 thread1 的执行时间被大大拉长。

2. 解决方案:使用工作队列卸载任务

thread0 中执行的 emulate_work() 是一个非紧急任务。为了避免它阻塞其他线程,应将其卸载到一个优先级更低的工作队列线程中执行。

2.1 定义工作项与处理函数

首先,需要将 emulate_work() 任务与一个“工作项”关联起来。

  1. 定义一个包含 struct k_work 的自定义结构体,用于承载工作项信息。

  2. 创建一个处理函数 offload_function(),该函数是工作项被执行时实际调用的函数。其内容就是调用 emulate_work()

c
struct work_info {
    struct k_work work;
    char name[25];
} my_work;

void offload_function(struct k_work *work_item)
{
    emulate_work();
}
2.2 初始化并提交工作项

修改高优先级的 thread0 的逻辑。

  1. thread0 启动时,使用 k_work_queue_start() 来创建一个新的工作队列 offload_work_q,并为其分配栈空间和之前定义的低优先级 WORKQ_PRIORITY

  2. 使用 k_work_init() 来初始化工作项 my_work,将其与处理函数 offload_function 关联起来。

  3. while 循环中,不再直接调用 emulate_work(),而是调用 k_work_submit_to_queue() 将工作项提交到 offload_work_q 队列中。

c
// 在 thread0 入口函数开始处执行一次
k_work_queue_start(&offload_work_q, my_stack_area,
                   K_THREAD_STACK_SIZEOF(my_stack_area), WORKQ_PRIORITY,
                   NULL);

strcpy(my_work.name, "Thread0 emulate_work()");
k_work_init(&my_work.work, offload_function);

// 在 thread0 的 while 循环中执行
while (1) {
    time_stamp = k_uptime_get();
    // 提交工作项,而不是直接执行
    k_work_submit_to_queue(&offload_work_q, &my_work.work);
    delta_time = k_uptime_delta(&time_stamp);

    printk("thread0 yielding this round in %lld ms\n", delta_time);
    k_msleep(20);
}

thread1 的代码保持不变。

2.3 运行结果分析

再次烧录程序,终端输出变为:

text
*** Booting nRF Connect SDK ***
thread0 yielding this round in 0 ms
thread0 yielding this round in 0 ms
thread1 yielding this round in 26 ms
thread0 yielding this round in 0 ms
thread0 yielding this round in 0 ms
thread1 yielding this round in 26 ms
...
  • 现象

    • thread0 现在完成其循环所需的时间几乎为0毫秒。这是因为它在循环内的工作被急剧缩减为仅仅是“提交一个工作项”,这是一个非常快速的操作。

    • thread1 完成其任务的时间显著缩短(从50毫秒降至26毫秒),接近了它不受干扰时应有的执行时间。

  • 原因thread0 将耗时的 emulate_work() 任务卸载后,自身很快就完成了任务并进入休眠。这使得 thread1 能够获得大段连续的CPU时间,不再被频繁抢占。而被卸载的 emulate_work() 任务,则由低优先级的工作队列线程thread0thread1 都处于休眠状态时,在后台默默执行。

3. 结论

这是一个良好系统架构的范例。通过将非紧急的、耗时的工作从高优先级上下文中卸载到专用的低优先级线程,可以避免不必要的阻塞和延迟,确保高优先级任务能够快速响应,同时也提高了低优先级任务的执行效率和整个系统的吞吐量。作为开发者,应当熟悉并善用RTOS提供的这些内核服务,以构建出高效、响应及时的应用程序。

线程同步

在多线程应用程序中,当多个线程并发运行时,就会出现线程同步的需求。本课程将解释线程同步的必要性,以及如何使用信号量和互斥锁作为线程同步机制。

在练习部分,我们将重点介绍两个常见的线程同步问题,并展示如何使用信号量和互斥锁来解决它们。

互斥锁与信号量

在多线程应用程序中,多个线程并发运行。如果多于一个线程试图同时访问同一段代码——这段代码通常被称为临界区 (critical section)——就可能导致非预期的或错误的行为。为了解决这个问题,就需要引入线程同步 (thread synchronization) 机制,它能确保在任何给定时刻,只有一个线程能够执行临界区中的代码。

实现线程同步的两种常用机制是信号量 (Semaphores)互斥锁 (Mutexes)。它们在本质上都是一种变量,线程在进入临界区之前和离开之后对其进行修改,以确保没有其他线程能进入该区域,直到当前线程完成操作。

1. 临界区与竞态条件

  • 临界区 (Critical Section): 指的是一段访问共享资源(例如,一个全局变量、一个硬件外设如UART、一块内存缓冲区)的代码。

  • 竞态条件 (Race Condition): 当多个线程并发访问并试图修改同一个共享资源,而最终的结果取决于线程执行的精确时序时,就发生了竞态条件。

一个简单的C语言例子可以说明这个问题。假设有一个全局变量 g_counter,两个线程都执行 g_counter++; 操作。

text
// 伪代码
g_counter++;

这行代码在底层通常不是原子操作,它可能被分解为三个步骤:

  1. 从内存读取 g_counter 的值到CPU寄存器。

  2. 在CPU寄存器中将值加一。

  3. 将寄存器中的新值写回内存中的 g_counter

如果两个线程并发执行,可能会发生以下情况:

  1. 线程A读取 g_counter (值为0)。

  2. 线程B在线程A写回之前,也读取 g_counter (值仍然是0)。

  3. 线程A将值加一 (寄存器中为1),并写回内存。g_counter 变为1。

  4. 线程B将值加一 (寄存器中也为1),并写回内存。g_counter 再次变为1。

尽管两个线程都执行了加一操作,但最终结果是1,而不是预期的2。这就是典型的竞态条件。

2. 互斥锁 (Mutex)

互斥锁,其名称是“相互排斥 (Mutual Exclusion)”的缩写,是解决临界区问题的最直接工具。

  • 工作原理: 可以将互斥锁想象成一个只能由一人持有的钥匙。一个线程在进入临界区之前,必须先“锁定”或“获取”这个互斥锁(拿到钥匙)。如果此时互斥锁已经被另一个线程持有,那么该线程将被阻塞(等待),直到持有者“解锁”或“释放”它(归还钥匙)。

  • 核心特性:所有权 (Ownership) 这是互斥锁与信号量最主要的区别。一个互斥锁具有所有权属性,即只有成功锁定该互斥锁的那个线程,才有资格将其解锁。其他任何线程尝试解锁都会失败。这种机制增强了代码的健壮性,防止因误操作导致的状态混乱。

3. 信号量 (Semaphore)

信号量是一种更通用的同步工具。它本质上是一个内部带有一个计数器的内核对象。

  • 工作原理:

    • 获取 (Take/Wait/P-operation): 线程尝试获取信号量,此操作会使信号量的内部计数器减一。如果计数器在减一前大于零,线程继续执行。如果计数器为零,则线程被阻塞,直到有其他线程释放信号量。

    • 释放 (Give/Signal/V-operation): 线程释放信号量,此操作会使计数器加一。如果有正在等待此信号量的线程,其中一个将被唤醒。

  • 类型与用途:

    • 二进制信号量 (Binary Semaphore): 计数器的初始值和最大值都为1。它常被用来实现与互斥锁类似的功能——保护单个共享资源,实现互斥访问。

    • 计数信号量 (Counting Semaphore): 计数器的初始值可以设置为大于1的任意值。它用于管理一个包含多个相同资源的资源池。例如,一个系统有3个可用的打印机,可以将信号量计数器初始化为3。最多可以有3个线程同时获取信号量并使用打印机。第4个尝试获取的线程将被阻塞,直到有线程释放打印机(即释放信号量)。

  • 与互斥锁的关键区别:信号量没有所有权的概念。任何线程都可以释放一个信号量(使其计数器加一),而无需关心是哪个线程获取了它。

4. 总结:互斥锁 vs. 信号量

特性互斥锁 (Mutex)信号量 (Semaphore)
核心用途保护单个共享资源,实现互斥访问。控制对一个或多个资源的并发访问数量,既可用于互斥,也可用于资源计数。
所有权有。只有锁定它的线程才能解锁。无。任何线程都可以执行“释放”操作。
工作机制简单的“锁定/解锁”模型。基于内部计数器的“获取/释放”模型。
典型场景保护全局变量、防止函数重入、保护硬件访问。资源池管理(如内存块、连接池)、任务间同步。

信号量 (Semaphore)

从本质上讲,可以把信号量 (Semaphore) 理解成一个特殊的整数变量。这个变量的作用是作为一个标志,用来表示某个共享资源当前的状态。所谓共享资源,可以想象成一段代码、一个设备或者一块内存,有多个执行流程(通常称为“线程”)都可能需要访问它。

信号量是一种管理资源共享的机制。当存在数量有限的资源,并且需要管理多个线程对这些资源的访问时,信号量就派上了用场。它更像一个“信号装置”,通过发出信号来控制在同一时间点,有多少个线程可以访问特定数量的资源。

信号量的核心特性

信号量的运作遵循以下几个明确的规则:

  1. 初始化: 在创建一个信号量时,需要设定两个值:一个初始计数值和一个最大计数值。初始计数值不能小于 0。这个计数值代表了当前可用资源的数量。

  2. 释放 (Give) 操作: 这个操作会尝试将信号量的计数值加一。如果计数值已经达到了设定的最大值,那么它就不会再增加。这个操作可以由任何线程来执行,甚至可以在一种叫做“中断服务程序 (ISR)”的特殊函数中执行。中断服务程序通常用来响应硬件事件。

  3. 获取 (Take) 操作: 这个操作会尝试将信号量的计数值减一。如果计数值此时已经是 0,意味着没有可用资源,那么尝试获取资源的线程就必须暂停执行并进入等待状态,直到有其他线程执行了“释放 (Give)”操作使计数值不再为 0。需要注意的是,获取操作只能在线程中进行。虽然操作系统内核可能允许中断服务程序 (ISR) 获取信号量,但它绝不能在资源不可用时进入等待状态。

  4. 没有所有权: 这是信号量一个非常关键的特性。一个信号量可以由线程 A 获取,然后由一个完全不同的线程 B 来释放。它不像某些锁机制那样,必须由获取者亲自来释放。任何线程都可以释放它。

  5. 不支持优先级继承: 由于信号量没有“所有权”这个概念,所以它也不支持“优先级继承”。所谓优先级继承,是一种用来解决“优先级反转”问题的技术,可以防止一个高优先级的线程因为等待一个被低优先级线程占用的资源而长时间阻塞。因为任何线程都可以释放信号量,所以获取信号量的线程并不“拥有”它,也就无法进行优先级继承。

信号量的实际应用场景

通过在初始化时设置不同的计数值,信号量可以灵活地应用于不同的场景:

  • 限制并发访问数量 可以初始化一个“满的”信号量,也就是让初始计数值等于最大计数值。这通常用来限制能同时执行某段关键代码的线程数量。

    • 示例:假设有一个程序,其中一段处理文件的代码因为性能或安全原因,最多只能由 5 个线程同时执行。此时,可以创建一个最大计数值和初始计数值都为 5 的信号量。每当一个线程想执行这段代码前,它必须先“获取 (Take)”信号量(计数值减一)。当执行完毕后,再“释放 (Give)”信号量(计数值加一)。这样一来,一旦有 5 个线程进入了该代码区,计数值就变为 0,第 6 个线程在尝试获取时就必须等待,直到前面有线程完成并释放了信号量。
  • 作为同步工具或“门闸” 可以初始化一个“空的”信号量,也就是让初始计数值为 0,最大计数值为 1。这可以用来创建一个“门闸”,在某个条件未满足前,阻止一个或多个线程继续执行。

    • 示例:假设一个线程 A 的任务是准备数据,而线程 B 的任务是处理这些数据。显然,线程 B 必须等到线程 A 完成数据准备后才能开始工作。这时,就可以使用一个初始计数值为 0 的信号量。线程 B 在开始处理数据前,先尝试“获取 (Take)”信号量,由于计数值为 0,它会立刻进入等待状态。线程 A 在数据准备完成后,执行一次“释放 (Give)”操作,将计数值变为 1。此时,正在等待的线程 B 就被唤醒,成功获取信号量(计数值变回 0),然后开始执行它的任务。这就确保了两个线程的执行顺序。

互斥锁 (Mutex)

与信号量 (Semaphore) 不同,互斥锁 (Mutex) 可以看作是一种更专门化的工具。它的状态非常简单,通常只有两种:“锁定 (locked)” 或 “解锁 (unlocked)”。

此外,互斥锁具有一个至关重要的特性,叫做 所有权 (ownership)。这个特性的意思是,只有那个给互斥锁上了锁的线程,才有资格将它解锁。可以把它想象成一种只有一个钥匙的锁机制。当一个线程想要访问某个单一的受保护对象时(例如一段代码或一个硬件资源),它必须先获得这个“钥匙”,也就是获取一个尚未锁定的互斥锁,并将其锁定。完成操作后,再由它自己来解锁。

如果一个线程尝试获取一个已经被其他线程锁定的互斥锁,那么这个线程就会被阻塞,进入等待状态,直到那个持有锁的线程将互斥锁解锁。

互斥锁的一个典型用途是保护可以被多个线程同时访问的临界区 (critical section)。所谓临界区,就是指一段代码,它在执行期间绝不能被其他线程打断,否则可能会导致该代码段中使用的全局或静态数据出现混乱或被破坏。

互斥锁的核心特性

  1. 锁定 (Locking) 与递归锁定

    1. 锁定一个互斥锁会增加其内部的“锁定计数”。

    2. 互斥锁支持 递归锁定 (Recursive Locking),也叫可重入锁定。这意味着,如果一个线程已经持有了某个互斥锁,它再次尝试锁定同一个互斥锁时,并不会像其他线程一样被阻塞。

    3. 为了最终能让其他线程有机会获取该锁,线程必须确保解锁的次数与锁定的次数完全相同。只有当锁定计数减到零时,这个互斥锁才会被真正释放。

  2. 解锁 (Unlocking)

    1. 解锁一个互斥锁会使其内部的锁定计数减一。

    2. 当锁定计数变为零时,互斥锁就进入了完全“解锁”的状态。只有在这个状态下,其他线程才能成功尝试获取并拥有这个互斥锁。

  3. 所有权 (Ownership)

    1. 这是互斥锁的核心规则:只有最初锁定了互斥锁的那个线程,才能对它进行解锁。这一点与信号量截然不同。
  4. 使用限制

    1. 互斥锁的锁定和解锁操作只能在线程中进行,不能在 中断服务程序 (ISR) 中使用。这是因为中断服务程序无法参与操作系统调度器的所有权管理和优先级继承机制。
  5. 支持优先级继承

    1. 由于所有权的存在,持有互斥锁的线程是 有资格 获得 优先级继承 (Priority Inheritance) 的。因为操作系统明确知道哪个线程“拥有”这个锁,所以当一个更高优先级的线程因为等待这个锁而被阻塞时,操作系统可以临时提升当前持有锁的线程的优先级,让它尽快完成任务并释放锁。这有效地避免了“优先级反转”问题。

信号量实战:解决资源竞争问题

接下来,通过一个具体的编程练习来观察信号量 (Semaphore) 如何在实际中发挥作用。这个例子模拟了一个系统中存在特定数量资源的情况。

在这个场景中,有两个核心角色:

  • 生产者线程 (Producer Thread):它的任务是“生产”或“释放”资源。

  • 消费者线程 (Consumer Thread):它的任务是“消费”或“获取”资源。

目标是使用信号量来精确地追踪和管理可用资源的数量。每当消费者获取资源时,代表资源数量的计数值就减一;每当生产者释放资源时,计数值就加一。

第一步:一个有问题的程序

首先,来看一个不使用任何保护措施的初始版本,它将直接暴露问题所在。

  1. 设定线程优先级为了更好地观察问题,特意将消费者线程的优先级设置得比生产者更高。这意味着在系统调度时,消费者线程会比生产者线程有更多的机会被执行。
c
#define PRODUCER_PRIORITY        5
#define CONSUMER_PRIORITY        4

注:在很多实时操作系统中,数值越小代表优先级越高。

  1. 初始化资源数量 假设系统中总共有 10 个可用资源实例。这个资源具体是什么并不重要,它可以是打印机、文件句柄或任何其他有限的共享对象。
c
volatile uint32_t available_instance_count = 10;
  1. 创建生产者和消费者线程

生产者线程 (producer) 在一个无限循环中不断地调用 release_access() 函数来释放资源,然后随机休眠一小段时间。

c
void producer(void)
{
    printk("Producer thread started\n");
    while (1) {
        release_access();
        // 假设此时资源实例的访问权已被释放
        k_msleep(500 + sys_rand32_get() % 10);
    }
}

消费者线程 (consumer) 也在一个无限循环中,它不断调用 get_access() 来获取资源,然后进行一个极短的随机休眠。由于它的优先级更高且休眠时间更短,它会以比生产者快得多的频率尝试获取资源。

c
void consumer(void)
{
    printk("Consumer thread started\n");
    while (1) {
        get_access();
        // 假设此时已获得资源实例的访问权
        k_msleep(sys_rand32_get() % 10);
    }
}
  1. 定义资源操作函数 这两个函数非常简单,只是直接对全局变量 available_instance_count 进行加减操作。
  • 获取资源 (get_access):
c
void get_access(void)
{
    available_instance_count--;
    printk("Resource taken and available_instance_count = %d\n",  available_instance_count);
}
  • 释放资源 (release_access):
c
void release_access(void) {
     available_instance_count++;
     printk("Resource given and available_instance_count = %d\n", available_instance_count);
}
  1. 观察运行结果 编译并运行这个程序后,通过串口终端会看到类似下面这样混乱的输出:
text
Resource taken and available_instance_count = -68
Resource givResource taken and available_instance_count = -68
en and available_instance_count = -67
Resource taken and available_instance_count = -69
Resource taken and available_instance_count = -70
...

问题分析: 输出的资源计数值出现了负数,这在现实世界中是不可能发生的。这个现象的根本原因是一种被称为 “竞态条件” (Race Condition) 的经典并发问题。因为生产者和消费者线程都在没有任何协调机制的情况下,同时读写同一个共享变量 available_instance_count

由于消费者优先级高、运行快,它会连续地对计数值进行减法操作,而生产者来不及做加法。更糟糕的是,一个线程在读取、修改、写回这个变量的过程中,随时可能被另一个线程打断,导致数据彻底错乱。

第二步:使用信号量修复问题

现在,引入信号量来解决这个同步问题。应用程序本身不再需要关心计数值的校验,这个任务将交给信号量来完成。

  1. 定义并初始化一个信号量

使用 K_SEM_DEFINE 宏来创建一个信号量。将其初始计数值和最大计数值都设置为 10,这与系统中可用资源的数量完全对应。

text
K_SEM_DEFINE(instance_monitor_sem, 10, 10);
  1. 修改资源操作函数

get_access 函数中,获取资源之前,先调用 k_sem_take() 来“获取”信号量。

c
void get_access(void)
{
    k_sem_take(&instance_monitor_sem, K_FOREVER); // 尝试获取信号量,如果计数值为0则无限期等待
    available_instance_count--;
    printk("Resource taken and available_instance_count = %d\n",  available_instance_count);
}

release_access 函数中,释放资源之后,调用 k_sem_give() 来“释放”信号量。

c
void release_access(void)
{
    available_instance_count++;
    printk("Resource given and available_instance_count = %d\n", available_instance_count);
    k_sem_give(&instance_monitor_sem); // 释放信号量,使其计数值加一
}
  1. 观察修复后的结果

再次编译并运行程序,输出会变得完全有序和正确:

text
*** Booting nRF Connect SDK ***
Consumer thread started
Resource taken and available_instance_count = 9
Resource taken and available_instance_count = 8
...
Resource taken and available_instance_count = 1
Resource taken and available_instance_count = 0
Producer thread started
Resource given and available_instance_count = 1
Resource taken and available_instance_count = 0
Resource given and available_instance_count = 1
Resource taken and available_instance_count = 0
...

结果分析

可以看到,available_instance_count 的值现在被严格地控制在 0 到 10 之间。

  1. 程序启动后,高优先级的消费者线程迅速运行,连续获取了 10 次资源,将信号量计数值从 10 降到了 0。

  2. 当消费者第 11 次尝试调用 get_access 时,它在 k_sem_take() 处被 阻塞 (block) 了,因为信号量计数值已经是 0,表示没有可用资源。

  3. 此时,低优先级的生产者线程才有机会运行。它调用 release_access,并通过 k_sem_give() 将信号量计数值增加到 1。

  4. 这个 k_sem_give() 操作会立即唤醒正在等待的消费者线程。消费者线程成功获取信号量(计数值再次变为 0),然后继续执行。

通过这个简单的修改,信号量就优雅地解决了线程间的同步问题,确保了对共享资源的访问是安全、有序的。

互斥锁 (Mutex) 实战:保护临界区

本节将通过一个实例来展示互斥锁 (Mutex) 的作用。在这个练习中,会创建两个线程,它们会同时访问同一段代码。当只有一个线程访问这段代码时,程序的逻辑看起来是完美的。但是,当两个线程同时尝试访问时,就会发生意想不到的情况,也就是所谓的“竞态条件 (Race condition)”。我们将看到如何利用互斥锁来同步这两个线程,以解决这个问题。

这个练习中创建的两个线程将拥有相同的优先级。同时,系统会启用一种叫做时间分片 (Time Slicing) 的调度策略,时间片大小设置为 10 毫秒。这意味着,每个线程最多只会被允许连续运行 10 毫秒,时间一到,操作系统调度器就会强制中断它,让另一个具有相同优先级的线程开始运行。这种机制确保了同优先级的任务能交替执行。

第一步:暴露问题所在的程序

设置线程优先级 将两个线程的优先级设置为相同的值。

c
#define THREAD0_PRIORITY        4
#define THREAD1_PRIORITY        4

创建线程函数 创建两个线程的执行函数 thread0thread1。它们都会在一个无限循环中调用一个名为 shared_code_section() 的函数。为了分步观察,暂时先将 thread1 中的调用注释掉。

c
void thread0(void)
{
    printk("Thread 0 started\n");
    while (1) {
        shared_code_section();
    }
}

void thread1(void)
{
    printk("Thread 1 started\n");
    while (1) {
        //shared_code_section();
    }
}

定义共享数据和不变性规则 定义两个全局计数器变量 increment_countdecrement_count。同时定义一个常量 COMBINED_TOTAL,其值为 40。这两个计数器的初始值分别为 0 和 40。

c
#define COMBINED_TOTAL   40
int32_t increment_count = 0;
int32_t decrement_count = COMBINED_TOTAL;
  1. 这个程序设计的核心逻辑是:在任何时刻,increment_countdecrement_count 的总和都应该 恒等于COMBINED_TOTAL (40)。如果这个规则在任何时候被打破,就说明代码存在缺陷。

实现共享代码区域shared_code_section() 函数中,实现以下逻辑:

  1. increment_count 增加 1,如果达到 COMBINED_TOTAL 则归零。

  2. decrement_count 减少 1,如果减到 0 则重置为 COMBINED_TOTAL

c
void shared_code_section(void)
{
    increment_count += 1;
    increment_count = increment_count % COMBINED_TOTAL;
    decrement_count -= 1;
    if (decrement_count == 0)
    {
        decrement_count = COMBINED_TOTAL;
    }
}

添加检查逻辑shared_code_section() 函数的末尾,加入一个 if 语句。这个语句的作用是检查两个计数器的和是否仍然等于 COMBINED_TOTAL。如果不等,就打印出错误信息,表明竞态条件已经发生。

c
// 紧跟在上面的逻辑之后
if(increment_count + decrement_count != COMBINED_TOTAL )
{
    printk("Race condition happend!\n");
    printk("Increment_count (%d) + Decrement_count (%d) = %d \n",
                increment_count, decrement_count, (increment_count + decrement_count));
    k_msleep(400 + sys_rand32_get() % 10);
}

第一次运行:单线程测试 编译并运行当前的代码(此时 thread1 中的调用仍被注释)。终端输出如下:

text
*** Booting nRF Connect SDK ***
Thread 0 started
Thread 1 started
  1. 终端非常安静,没有任何错误信息打印出来。这证实了当只有一个线程 (thread0) 访问 shared_code_section() 时,逻辑是完全正确的,increment_count + decrement_count 的值始终保持为 40。

第二次运行:双线程并发测试 现在,取消 thread1 函数中对 shared_code_section() 调用的注释,让两个线程同时访问共享代码区。

c
void thread1(void)
{
    printk("Thread 1 started\n");
    while (1) {
        shared_code_section();
    }
}

再次编译并运行程序。这一次,终端会立刻开始打印大量的错误信息:

text
*** Booting nRF Connect SDK ***
Thread 0 started
Thread 1 started
Race condition happend!
Increment_count (6) + Decrement_count (35) = 41
Race condition happend!
Increment_count (7) + Decrement_count (34) = 41
Race condition happend!
Increment_count (0) + Decrement_count (1) = 1
...

问题分析: 一旦 thread1 开始访问共享代码,原本正常的逻辑就立刻失效了。这是一个典型的竞态条件案例。根本原因在于,对 increment_countdecrement_count 的修改操作不是 原子性的 (atomic)

例如,当 thread0 执行 increment_count += 1; 时,它可能刚把 increment_count 的值读入 CPU 寄存器,还没来得及写回去,它的 10 毫秒时间片就用完了。此时,调度器会强制切换到 thread1thread1 会完整地执行一次 shared_code_section(),修改了 increment_countdecrement_count。然后,当 thread0 再次被调度运行时,它会用自己之前保存在寄存器里的过时数据完成计算,并将结果写回内存,从而覆盖了 thread1 的修改,导致两个变量的总和不再是 40。

第二步:使用互斥锁修复问题

为了解决这个问题,需要确保在任何时刻只有一个线程能够进入 shared_code_section() 这个临界区。互斥锁正是为此而生。

定义一个互斥锁 使用 K_MUTEX_DEFINE 宏来定义一个全局的互斥锁。

text
K_MUTEX_DEFINE(test_mutex);

使用互斥锁保护临界区 修改 shared_code_section() 函数,在执行计数器逻辑之前锁定互斥锁,在检查总和的 if 语句之前解锁互斥锁。

c
void shared_code_section(void)
{
    // 在进入临界区前,锁定互斥锁
    k_mutex_lock(&test_mutex, K_FOREVER);
    // ---- 临界区开始 ----
    increment_count += 1;
    increment_count = increment_count % COMBINED_TOTAL;

    decrement_count -= 1;
    if (decrement_count == 0)
    {
        decrement_count = COMBINED_TOTAL;
    }
    // ---- 临界区结束 ----

    // 离开临界区后,解锁互斥锁
    k_mutex_unlock(&amp;test_mutex);

    if(increment_count + decrement_count != COMBINED_TOTAL )
    {
        // ... (检查逻辑不变)
    }
}

最终运行结果 再次编译并烧录程序。这一次,终端输出会和第一次单线程测试时一样干净:

text
*** Booting nRF Connect SDK ***
Thread 0 started
Thread 1 started

即使两个线程都在高强度地并发运行,错误信息也再没有出现。

修复原理: 通过 k_mutex_lockk_mutex_unlock,我们将修改共享变量的代码块变成了一个真正的临界区。当一个线程通过 k_mutex_lock 获得了锁之后,其他任何线程如果也尝试获取这个锁,就会被操作系统阻塞,进入等待状态。直到第一个线程完成了临界区内的所有操作并通过 k_mutex_unlock 释放了锁,等待的线程才会被唤醒,并有机会获取锁进入临界区。

这样,就强制了多个线程对共享代码的访问从“并行”变成了“串行”,从而彻底消除了竞态条件,保证了数据的完整性和一致性。

Powered by VitePress