从裸机或 STM32 标准库切到 Zephyr 时,第一个让人不适应的往往不是 API 有多少,而是“工程到底怎么组织”“为什么我改了配置却没生效”“为什么项目拷到别人电脑上就编译不过”。这些问题的背后,几乎都指向同一个东西——Zephyr 的构建系统。本文是 Zephyr 系列课程第四课,围绕项目新建、项目迁移、构建缓存和按键输入四个实际场景展开,从原理讲到代码,目标是让你能把 Zephyr 项目的创建、维护和最基础的外设操作真正跑通。
本文适合已经完成 Zephyr 环境搭建、对 Devicetree 和 Kconfig 有一点概念的读者。如果你还停留在“west 是什么、SDK 怎么装”的阶段,建议先翻阅前几课内容,再回来看构建与缓存的部分,理解会更顺畅。
1. Zephyr 项目为什么要讲迁移、新建与缓存
1.1 Zephyr 项目与传统嵌入式工程的区别
一个 Zephyr 应用本质上是一个与 Zephyr 内核源码一起参与构建的 CMake 工程。它的输入包括应用源码、prj.conf配置文件、Devicetree 源文件或 overlay 文件、CMakeLists.txt,以及 Zephyr 内核与各子系统源码。
这与 STM32CubeIDE 生成的标准库工程有非常大的差异:你不需要手动把每个源文件拖进工程,也不用在 IDE 里逐个勾选中间件。Zephyr 构建系统会根据 Kconfig 和 Devicetree 的配置,自动决定编译哪些内核模块、哪些驱动、哪些子系统。换句话说,工程的组织逻辑从“你告诉 IDE 我要哪些文件”变成了“你告诉构建系统我要哪些功能,它自己去拼接文件”。
这一点听起来很美好,但它也带来了一个学习门槛:许多传统 IDE 自动帮你解决的问题,在 Zephyr 里需要你亲自理解数据结构。比如“当前目标板是什么”“引脚怎么分配”“哪些驱动被编译进来”,这些信息并不全部写在main.c里,而是散落在多个配置文件和 Devicetree 文件中。所以在 Zephyr 里,新建项目不是复制一个模板,而是理解一套构建规则。
1.2 项目新建和迁移到底在操作什么
所谓“新建项目”,在 Zephyr 世界里的本质是创建一组符合构建系统约定目录结构和文件。所谓“迁移项目”,也不是把.uvprojx原样复制,而是把一套应用源码拿到不同的 SDK 版本、不同的板卡、不同的构建环境下重新适配并成功编译。
这个过程最容易出问题的不是 C 代码语法,而是构建层面的信息错位。比如:
- Zephyr SDK 版本不同,Kconfig 选项可能改名。
- 目标板改变,DTS 文件中的节点名、引脚编号全部需要重新核对。
- 旧 build 目录里残留了大量缓存文件,导致新配置没有被真正执行。
- 环境变量
ZEPHYR_BASE指向了一个旧路径,构建系统找到的是另一个版本的 Zephyr。
这些问题的共同点在于:它们都不是“代码写错了”,而是“构建系统状态不正确”。因此理解缓存机制,是 Zephyr 工程实践绕不开的一环。
1.3 缓存为什么会成为题目
很多刚接触 Zephyr 的开发者都遇到过一种诡异情况:明明改了prj.conf,重新编译后行为却没有变化;明明删掉了几个源码文件,构建还是报原来的错误。这种问题的根源几乎都指向 CMake 增量构建与 Zephyr 构建文件的缓存策略。
Zephyr 使用 CMake 和 Ninja 作为底层构建工具。增量编译时,Ninja 会根据文件时间戳判断哪些目标需要重编。但 Kconfig 和 Devicetree 处理的并不是普通源码文件,它们的输入变化不一定能触发所有依赖目标的重新生成。于是就会出现“改了配置但没反应”“改了 DTS 但生成的宏定义没更新”的现象。
理解了这一点,你就能理解为什么 Zephyr 官方反复推荐在关键变更后使用west build -t pristine执行一次干净构建。这很像前端开发里清缓存、清 localStorage;嵌入式里对应的是清掉整个 build 目录。不要觉得这是笨办法,实际上这才是 Zephyr 项目里最可靠的构建方式之一。
2. 环境准备与版本说明
2.1 核心工具链
Zephyr 的构建依赖较多,至少需要以下工具:
- Python 3.8 及以上
- CMake 3.20 及以上
- Ninja 构建工具
- Devicetree Compiler(dtc)
- west,Zephyr 的元工具,用于初始化、构建、烧录等
- 目标平台的交叉编译工具链,一般直接使用 Zephyr SDK
这些工具的安装方式在不同系统上有差异。如果还没有搭好环境,建议先按 Zephyr 官方 Getting Started 文档安装 Zephyr SDK,并确认west --version能正常输出版本号。
2.2 工作区结构
Zephyr 推荐使用一个独立的工作区目录来存放所有相关代码。典型结构如下:
~/zephyrproject/ ├── .west/ ├── zephyr/ ├── modules/ ├── tools/ └── (你自己的应用目录).west/目录记录了 workspace 的配置信息,包括 manifest 路径和 manifest 仓库地址。zephyr/是 Zephyr 内核源码仓库。modules/存放各种可选模块,比如硬件相关的 HAL 库。自己创建的应用目录建议放在工作区根目录下,这样可以和 Zephyr 源码保持平行,而不是直接修改官方仓库。
2.3 构建前需要确认的环境变量
在构建任意项目之前,最好先确认ZEPHYR_BASE环境变量是否设置正确。ZEPHYR_BASE指向 Zephyr 源码根目录,CMake 需要通过它找到 Zephyr 构建包。
在 Zephyr 源码根目录下执行:
source zephyr-env.sh这个脚本会设置ZEPHYR_BASE等环境变量。如果你使用的是新版 Zephyr 和 CMake 包方式安装,某些情况下不一定需要手动 source,但为了统一,建议构建前先检查一次:
echo $ZEPHYR_BASE如果输出为空,说明环境变量未设置,直接构建很可能报找不到 Zephyr 的错误。
需要提醒的是,Zephyr 版本迭代很快,不同大版本的构建系统和 Kconfig 选项存在差异。本文示例以当前主流的 Zephyr 3.x 系列为准,重点讲解通用流程。如果你使用的是其他版本,个别命令和配置项需要对照官方文档调整。
3. 新建 Zephyr 项目的完整流程
3.1 从官方示例复制修改
最快的入门方式是复制官方 sample 并在其基础上修改。比如先构建一个 hello_world,确认环境可用:
cd ~/zephyrproject west build -b nrf52840dk_nrf52840 zephyr/samples/hello_world -d build_hello这条命令用-b指定目标板为 nRF52840 DK,用-d指定输出目录为build_hello。构建成功后可以west flash烧录验证串口输出。
但做实际项目时,不建议长期在zephyr/samples/目录里改代码。更规范的做法是在工作区根目录下创建自己的独立应用目录,这样代码所有权清晰,也不会在升级 SDK 时被覆盖。
3.2 手动创建独立应用目录
假设我们要创建一个名为hello_key的应用,用于按键输入实验。目录结构如下:
hello_key/ ├── CMakeLists.txt ├── prj.conf ├── src/ │ └── main.c └── boards/ └── nrf52840dk_nrf52840.overlayCMakeLists.txt是 CMake 构建入口。prj.conf是应用级 Kconfig 配置,用来覆盖或补充板级默认配置。src/main.c是应用主程序。boards/目录下存放板级 overlay 文件,用于补充或修改 Devicetree,文件命名必须与构建时指定的板名一致。
3.3 编写 CMakeLists.txt
创建hello_key/CMakeLists.txt,内容如下:
cmake_minimum_required(VERSION 3.20.0) find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE}) project(hello_key) target_sources(app PRIVATE src/main.c)逐行解释:
cmake_minimum_required(VERSION 3.20.0)指定 CMake 最低版本。find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})是核心。它会让 CMake 从ZEPHYR_BASE环境变量指定的路径查找 Zephyr 构建包。如果ZEPHYR_BASE没设置,这一步通常会报错。project(hello_key)声明项目名称,同时会触发 Zephyr 构建系统的初始化逻辑。target_sources(app PRIVATE src/main.c)将应用源码加入名为app的目标中。注意这里不需要手动添加内核源码,也不需要手动配置头文件搜索路径,Zephyr 构建系统会自动处理。
3.4 编写 prj.conf
创建hello_key/prj.conf:
CONFIG_GPIO=y CONFIG_PRINTK=yCONFIG_GPIO=y:显式启用 GPIO 驱动框架。很多开发板的默认配置已经开启,但放在自己的应用配置里更明确。CONFIG_PRINTK=y:启用printk输出。如果后续用串口看日志,这个配置必须存在。
3.5 编写一个最小 main.c
为了先验证工程能编译,可以先写一个空的主函数:
#include <zephyr/kernel.h> void main(void) { printk("Hello Zephyr, this is hello_key!\n"); }3.6 构建与烧录
在hello_key目录下执行:
cd ~/zephyrproject/hello_key west build -b nrf52840dk_nrf52840 .第一次构建会生成build/目录,里面包含所有构建中间文件。如果修改了prj.conf或 overlay,再次执行相同命令即可增量构建。烧录使用:
west flash到这里,一个最简 Zephyr 应用就创建成功了。这里的重点是理解:新建 Zephyr 项目不是“在 IDE 里点新建”,而是写清楚CMakeLists.txt、prj.conf、源码和 overlay 这四个部分。
4. 项目迁移与缓存机制
4.1 迁移到底迁移什么
Zephyr 项目迁移通常发生在以下几种场景中:
- 把一个示例项目复制到自己业务仓库。
- 把项目从旧 SDK 版本升级到新 SDK 版本。
- 把原本跑在 A 板卡上的工程迁移到 B 板卡上。
- 把本机开发好的项目放到 CI 服务器或同事电脑上构建。
这些场景有一个共同点:源码文件可能不变,但构建环境、SDK 路径、板级配置发生了变化。迁移过程中最容易被忽略的,就是旧的构建缓存。
4.2 build 目录里到底缓存了什么
在 Zephyr 项目执行过一次west build之后,build/目录会变得非常庞大。这里面有几个关键文件值得注意:
build/ ├── CMakeCache.txt ├── build.ninja ├── zephyr/ │ ├── .config │ ├── include/generated/ │ ├── zephyr.elf │ ├── zephyr.bin │ └── zephyr.hexCMakeCache.txt:记录了 CMake 配置阶段缓存下来的变量,比如BOARD、ZEPHYR_TOOLCHAIN_VARIANT、CMAKE_C_COMPILER等。zephyr/.config:Kconfig 解析后的最终配置结果,所有的CONFIG_*变量都保存在这里。zephyr/include/generated/:由 Devicetree 和 Kconfig 生成的宏定义,C 代码中使用的DT_ALIAS、GPIO_DT_SPEC_GET等宏,最终都会展开成这里的内容。zephyr/zephyr.elf/zephyr.bin/zephyr.hex:最终编译产物,烧录时使用的文件。
问题就出在这:这些文件是缓存的产物,并不总是能根据源码变更自动重新生成。尤其在切换 board、修改prj.conf、修改 Kconfig、修改 overlay 时,部分构建步骤可能因为 Ninja 依赖关系不够精确,继续沿用旧缓存。表层现象就是“我改了配置但没反应”。
4.3 增量构建与 pristine 构建
Zephyr 默认的west build是增量构建。增量构建的好处是快,但代价是可能出现缓存不一致。
Zephyr 提供了west build -t pristine这个构建目标,它相当于:
rm -rf build west build -b nrf52840dk_nrf52840 .也就是清空整个build/目录后再重新构建。Zephyr 官方在遇到“诡异问题”时,第一步建议基本都是执行west build -t pristine。
另一个常用目标是:
west build -t menuconfig它会打开一个字符界面的 Kconfig 配置工具,你可以在这里浏览当前生效的所有配置项。这个工具查看的是解析后的配置树,改动后保存,再退出构建。
在实际开发中,什么时候需要 pristine?
| 变更类型 | 是否建议 pristine |
|---|---|
| 只修改 src/*.c 源码 | 不一定需要,增量编译即可 |
| 修改 prj.conf | 建议 pristine,防止配置残留 |
| 修改 board defconfig | 强烈建议 pristine |
| 修改 overlay 或 DTS | 建议 pristine |
| 切换 board | 必须 pristine 或删除 build |
| 升级 Zephyr SDK 版本 | 必须 pristine 或删除 build |
| 修改 CMakeLists.txt | 视情况,必要时 pristine |
这并非绝对,但按照这个原则执行,可以避开绝大多数“配置不生效”的问题。
4.4 项目迁移到新环境的推荐步骤
假设你拿到一个别人的 Zephyr 项目,需要在自己的电脑上编译运行。如果直接执行west build遇到各种奇怪错误,可以按以下顺序处理:
- 查看项目根目录是否有
west.yml或manifest文件,确认它依赖的 Zephyr 版本和模块列表。 - 在项目目录下初始化或更新 west 工作区,确保 Zephyr 源码版本匹配。
- 确认
ZEPHYR_BASE指向正确的 Zephyr 源码目录。 - 如果项目自带
build/目录,直接删除,不要沿用别人的构建产物。 - 重新执行
west build -b <board> .。
这里要特别强调:不要把人家的build/目录直接拷到自己电脑上。CMakeCache.txt里缓存了对方电脑的编译器路径、SDK 路径、绝对路径等,拷过来大概率会报错。一个干净项目不应该把build/提交到 Git 仓库,这一点要在团队协作中提前约定好。
5. 按键输入实战
5.1 按键硬件基础
按键输入是嵌入式开发最基础的外设之一。从电路上看,按键通常一端接 GPIO,另一端接 GND 或 VCC。常见接法有两种:
- 按键一端接 GPIO,另一端接 GND,GPIO 内部启用上拉电阻。按下时 GPIO 被拉低,平时为高电平。
- 按键一端接 GPIO,另一端接 VCC,GPIO 内部启用下拉电阻。按下时 GPIO 被拉高,平时为低电平。
Zephyr 的 DTS 中通常用GPIO_PULL_UP和GPIO_ACTIVE_LOW来描述第一种接法。GPIO_ACTIVE_LOW表示“低电平是激活状态”,这种描述对驱动层很有意义。因为当 GPIO 配置了GPIO_ACTIVE_LOW后,gpio_pin_get_dt在按键按下时返回 1,而不是返回 0。这可以避免在应用层反复判断电平极性。
5.2 Devicetree overlay 配置
在hello_key/boards/下创建nrf52840dk_nrf52840.overlay。以 nRF52840 DK 为例,其用户按键通常连接在 P0.13,使用内部上拉,按下为低电平:
/* 文件路径:hello_key/boards/nrf52840dk_nrf52840.overlay */ / { aliases { sw0 = &user_button; }; keys { compatible = "gpio-keys"; user_button: user_button { label = "User button"; gpios = <&gpio0 13 (GPIO_PULL_UP | GPIO_ACTIVE_LOW)>; }; }; };这个 overlay 做了什么?
aliases给按键节点起了一个别名sw0。在代码里,可以通过DT_ALIAS(sw0)找到这个节点。这样写的好处是:如果换一块板子,只要 overlay 里依然提供sw0别名,应用代码可以完全不变。keys节点使用compatible = "gpio-keys",这是 Zephyr 中描述一组 GPIO 按键的通用方式。user_button是按键子节点,gpios属性描述它使用的是gpio0控制器的 13 号引脚,标志位是GPIO_PULL_UP | GPIO_ACTIVE_LOW。
如果你的板子不是 nRF52840 DK,需要根据原理图修改gpios属性的控制器名称、引脚号和标志位。
5.3 轮询方式读取按键
轮询是在主循环里周期性地读取 GPIO 电平,实现简单,适合按键频率不高的场景。下面是一个完整示例,实现在按键按下和释放时打印日志。
文件路径:hello_key/src/main.c
#include <zephyr/kernel.h> #include <zephyr/device.h> #include <zephyr/drivers/gpio.h> #define SW0_NODE DT_ALIAS(sw0) static const struct gpio_dt_spec button = GPIO_DT_SPEC_GET(SW0_NODE, gpios); void main(void) { int ret; bool pressed = false; if (!gpio_is_ready_dt(&button)) { printk("Error: button device %s is not ready\n", button.port->name); return; } ret = gpio_pin_configure_dt(&button, GPIO_INPUT); if (ret < 0) { printk("Error: failed to configure button pin\n"); return; } printk("Button polling demo started\n"); while (1) { int val = gpio_pin_get_dt(&button); if (val == 1 && !pressed) { pressed = true; printk("Button pressed\n"); } else if (val == 0 && pressed) { pressed = false; printk("Button released\n"); } k_msleep(10); } }这段代码的流程是:
GPIO_DT_SPEC_GET(SW0_NODE, gpios)从 Devicetree 节点中提取 GPIO 控制器和引脚号,得到一个struct gpio_dt_spec结构体。它是 Zephyr GPIO API 中很常用的数据结构,后续所有操作都基于它。gpio_is_ready_dt检查 GPIO 控制器设备是否就绪。gpio_pin_configure_dt(&button, GPIO_INPUT)将引脚配置为输入模式。gpio_pin_get_dt读取引脚的逻辑电平。由于我们在 overlay 里配置了GPIO_ACTIVE_LOW,按下时返回 1,松开时返回 0。- 使用
pressed变量做边沿检测,避免在按下期间持续打印。
编译运行:
cd ~/zephyrproject/hello_key west build -b nrf52840dk_nrf52840 . west flash打开串口终端,按下按键,应该能看到Button pressed和Button released输出。
5.4 中断方式读取按键
轮询方式有一个缺点:主循环必须频繁读取 GPIO,否则会漏掉短暂按键,而且白白消耗 CPU。更好的方式是使用 GPIO 中断,让按键边沿到来时触发回调函数。
以按下沿触发为例,修改后的中断版本如下:
文件路径:hello_key/src/main.c
#include <zephyr/kernel.h> #include <zephyr/device.h> #include <zephyr/drivers/gpio.h> #define SW0_NODE DT_ALIAS(sw0) static const struct gpio_dt_spec button = GPIO_DT_SPEC_GET(SW0_NODE, gpios); static struct gpio_callback button_cb_data; static void button_isr(const struct device *dev, struct gpio_callback *cb, uint32_t pins) { printk("Button interrupt triggered, pins=0x%x\n", pins); } void main(void) { int ret; if (!gpio_is_ready_dt(&button)) { printk("Error: button device %s is not ready\n", button.port->name); return; } ret = gpio_pin_configure_dt(&button, GPIO_INPUT); if (ret < 0) { printk("Error: failed to configure button pin\n"); return; } /* 触发条件:逻辑状态从 inactive 变为 active。 * 由于 DTS 中配置了 GPIO_ACTIVE_LOW,按下沿满足该条件。 */ ret = gpio_pin_interrupt_configure_dt(&button, GPIO_INT_EDGE_TO_ACTIVE); if (ret < 0) { printk("Error: failed to configure interrupt\n"); return; } gpio_init_callback(&button_cb_data, button_isr, BIT(button.pin)); gpio_add_callback(button.port, &button_cb_data); printk("Button interrupt demo started\n"); while (1) { k_sleep(K_SECONDS(1)); } }这里有几个关键点:
GPIO_INT_EDGE_TO_ACTIVE的含义是“逻辑状态变为 active 时触发”。在GPIO_ACTIVE_LOW配置下,这意味着物理电平由高变低,也就是按键按下的瞬间。gpio_init_callback用来初始化回调结构体。第三个参数BIT(button.pin)表示该回调关心哪个引脚的中断。gpio_add_callback将回调注册到 GPIO 控制器设备上。- 中断触发后,
button_isr在中断上下文执行。不要在回调里做耗时操作、不要调用阻塞函数,这一点很重要。
如果希望检测按键松开,可以把GPIO_INT_EDGE_TO_ACTIVE改成GPIO_INT_EDGE_TO_INACTIVE。
5.5 按键消抖与工程化改进
机械按键在按下和松开的瞬间,由于触点弹跳,GPIO 电平会在短时间内抖动多次。如果直接在中断回调里处理业务逻辑,可能一次按键被误判为多次触发。
最简单的消抖方式是:检测到按下后延时 10 到 30 毫秒,再读一次引脚电平确认。比如:
static bool debounce_button(void) { if (gpio_pin_get_dt(&button) != 1) { return false; } k_msleep(20); return gpio_pin_get_dt(&button) == 1; }对于中断方式,更工程化的做法是:在中断回调里只设置一个标志位,然后通过 Zephyr 的工作队列或消息队列把事件交给线程上下文处理。在上下文切换的间隔中做消抖,既不会阻塞中断,也不会丢失事件。这部分涉及k_work和k_msgq,属于进阶内容,后面可以单独展开。
另一种方式是使用 Zephyr 驱动级支持的消抖功能。较新版本的 Zephyr 中,部分 GPIO 驱动支持GPIO_DEBOUNCE标志:
ret = gpio_pin_configure_dt(&button, GPIO_INPUT | GPIO_DEBOUNCE);但需要注意,这个特性依赖具体 GPIO 控制器驱动,不是所有芯片都支持。使用前先查一下平台手册,否则可能配置失败。
6. 常见问题与排查思路
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 修改 prj.conf 后行为没变化 | Kconfig 解析结果被缓存 | 执行west build -t pristine后重新构建 |
| 切换 board 后编译报错 | build 目录缓存了旧 board 配置 | 删除 build 目录后重新构建 |
| 报错找不到 Zephyr | ZEPHYR_BASE未设置或路径不对 | source zephyr-env.sh,检查echo $ZEPHYR_BASE |
| 项目迁移后各种诡异编译错误 | 旧 build 目录或 CMakeCache 残留 | 删除 build 目录,按第 4.4 节步骤重建 |
| 按键读取始终为高电平 | DTS 引脚号错误或未启用上拉 | 对照原理图检查 overlay 中的 gpios 属性 |