数据来源说明:本文基于 Zephyr 构建系统开源部分撰写。west/CMake/Kconfig/Devicetree 均为开源基础设施,
samples/bluetooth/peripheral、subsys/bluetooth/下的 CMakeLists.txt 与 Kconfig 可对照源码阅读。文中部分文件已用 gitee 镜像 main 分支核对(https://gitee.com/zephyrproject-rtos/zephyr/raw/main/),核对结果与 NCS v3.2.1 研究文档有出入处已按实际源码更正并标注;gitee 镜像只有 main 分支,ncs-v3.2.1 tag 不在镜像上,行号以 main 为参考并标注版本差异。SoftDevice Controller 闭源,本文只讲它在构建里怎么被链入,不冒充看过其源码。
做 Zephyr BLE 开发,敲一行west build -b nrf54l15dk_nrf54l15_cpuapp samples/bluetooth/peripheral,等一会儿build/zephyr/zephyr.elf就出来了。但这个 elf 里到底塞了什么、为什么塞这些、靠什么决定塞哪些文件,多数人没深究过。遇到"我明明开了 CONFIG_BT,为什么没编进 controller""换个板子 controller 选不中""prj.conf 改了不生效"这类问题,就只能反复 clean 重试。
这篇把一个 BLE demo 从源码到固件的完整编译框架拆开讲清楚。核心是 west、CMake、Kconfig、Devicetree 这四件套怎么协同,代码基于 NCS v3.2.1 / nRF54L15,关键文件已对照 gitee 镜像 main 分支核对。
一、全景:四件套各管一摊,谁也别越界
先建立全景认知,四件套职责分明:
west 管工作区,拉哪些仓库、各仓库哪个版本,它说了算。CMake 管构建编排,编译哪些文件、怎么链接,它说了算。Kconfig 管功能裁剪,CONFIG_* 开关决定编不编译某个模块。Devicetree 管硬件描述,板子有什么、HCI 节点使能哪个 controller,它说了算。
四者不是平行关系,是一条流水线:west 调起 cmake → cmake 先处理 devicetree,从 dts 节点生成 Kconfig 依赖符号(DT_HAS_ENABLED)→ cmake 跑 Kconfig 解析器,合并 board defconfig 和 app prj.conf,生成.config和autoconf.h→ cmake 再根据 CONFIG和 devicetree 决定编译哪些源文件 → 最后链接成 elf。
记住这条流水线的顺序很重要,后面每一环都是顺着它走的。devicetree 在 Kconfig 之前处理,所以 Kconfig 能依赖 devicetree 生成的符号;而 CMake 的源文件裁剪又依赖 Kconfig 的结果。三个工具串成一条链,谁在前谁在后是固定的。
二、west 工作区与 manifest:代码从哪来
工作区根目录有.west/config,指明 manifest 仓库是nrf/,manifest 文件是nrf/west.yml,Zephyr 树在zephyr/。
nrf/west.yml是 NCS 的 manifest,定义所有要拉取的仓库。zephyr的 revision 是ncs-v3.2.1,它是 Zephyr 核心,还会import自己的子模块(cmsis、hal_nordic、littlefs 等)。nrfxlib的 revision 是v3.2.1,Nordic 库,里面含 SoftDevice Controller 的预编译库。还有 mcuboot、mbedtls、trusted-firmware-m 这些。
磁盘上大致是这个布局:v3.2.1/下面zephyr/是 RTOS 核心(OS、构建系统、samples、subsys、dts),nrf/是 Nordic 专属(boards、subsys/bluetooth/controller、应用),nrfxlib/是 Nordic 库(softdevice_controller、mpsl、crypto),bootloader/是 mcuboot,modules/是第三方模块。
west 的活儿到这就结束了——它把代码摆到正确位置,剩下的交给 CMake。理解这点能避免一个常见误区:别去 west 里找"编译逻辑",west 不管编译,它只管拉代码。
三、west build 做了什么:本质是调 cmake
west build -b nrf54l15dk_nrf54l15_cpuapp zephyr/samples/bluetooth/peripheral,本质上是调 cmake。实现见zephyr/scripts/west_commands/build.py,它拼出来的命令大致是:
cmake -DBOARD=nrf54l15dk_nrf54l15_cpuapp \ -S zephyr/samples/bluetooth/peripheral \ -B build -G Ninja-DBOARD指定目标板,-S指定应用源码目录,-B指定构建目录,-G Ninja指定用 Ninja 做底层生成器。然后应用的CMakeLists.txt用find_package(Zephyr)把整个 Zephyr 构建系统拉进来。
所以 west build 不是什么神秘魔法,它就是个 cmake 的包装器。真要看构建细节,直接读 CMakeLists.txt,别在 west_commands 里转。
四、CMake 层级:从应用到协议栈的递进
CMake 是分层的,从应用层一路递进到协议栈。
应用层最简单,zephyr/samples/bluetooth/peripheral/CMakeLists.txt(已对照 gitee 镜像 main 分支核对)实际内容是:
cmake_minimum_required(VERSION 3.13.1) find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE}) project(peripheral) target_sources(app PRIVATE src/main.c src/cts.c )这里有两处和某些文档说法不一样,按实际源码更正:第一,cmake_minimum_required是3.13.1,不是 3.20.0。第二,应用贡献的不止main.c,还有cts.c(Current Time Service,这个样例带了个时间服务)。find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})是关键一行,它触发整个 Zephyr 构建系统的加载。
Zephyr 顶层zephyr/CMakeLists.txt由find_package触发,关键是子目录遍历顺序:先add_subdirectory(soc),再add_subdirectory(boards),再add_subdirectory(subsys)(Bluetooth 从这里进入),再add_subdirectory(drivers),然后遍历ZEPHYR_MODULE_NAMES,把 nrf/、nrfxlib/ 等模块加进来。这个顺序不是随便排的,soc 和 boards 先处理,才能为后面的 devicetree 和 Kconfig 提供板级信息。
五、Bluetooth 子系统的 CMake:add_subdirectory_ifdef 是裁剪的核心
到了zephyr/subsys/bluetooth/CMakeLists.txt(已对照 gitee 镜像核对),核心机制全在这。实际内容:
add_library(subsys__bluetooth INTERFACE) target_include_directories(subsys__bluetooth INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}) add_subdirectory(common) add_subdirectory_ifdef(CONFIG_BT_HCI host) add_subdirectory_ifdef(CONFIG_BT_SHELL shell) add_subdirectory_ifdef(CONFIG_BT_CONN services) add_subdirectory_ifdef(CONFIG_BT_MESH mesh) add_subdirectory_ifdef(CONFIG_BT_AUDIO audio) if(CONFIG_BT_CTLR AND CONFIG_BT_LL_SW_SPLIT) add_subdirectory(controller) endif()add_subdirectory_ifdef(CONFIG_X dir)的含义是:只有CONFIG_X=y时才进入dir编译。这是 Kconfig 控制编译范围的核心机制。host 在CONFIG_BT_HCI时编,services 在CONFIG_BT_CONN时编,mesh 在CONFIG_BT_MESH时编。
这里有个容易看错的点,按实际源码更正:Zephyr 软链路层 controller 目录的进入条件是if(CONFIG_BT_CTLR AND CONFIG_BT_LL_SW_SPLIT),两个条件都要满足,不是只看CONFIG_BT_LL_SW_SPLIT。CONFIG_BT_CTLR是"是否使用控制器"的总开关,CONFIG_BT_LL_SW_SPLIT是"用 Zephyr 软链路层"的具体选择,两者是包含关系。少了CONFIG_BT_CTLR这个前提,单独看BT_LL_SW_SPLIT会误判。
Host 源文件裁剪在zephyr/subsys/bluetooth/host/CMakeLists.txt(已对照 gitee 核对),按 Kconfig 选源文件:
if(CONFIG_BT_HCI_HOST) zephyr_library_sources(uuid.c addr.c buf.c hci_core.c hci_common.c id.c) zephyr_library_sources_ifdef(CONFIG_BT_BROADCASTER adv.c) zephyr_library_sources_ifdef(CONFIG_BT_OBSERVER scan.c) if(CONFIG_BT_CONN) zephyr_library_sources(conn.c l2cap.c att.c gatt.c) if(CONFIG_BT_SMP) zephyr_library_sources(smp.c keys.c) else() zephyr_library_sources(smp_null.c) endif() endif() endif()zephyr_library_sources_ifdef(CONFIG_X file.c)是另一个裁剪原语:CONFIG_X=y才把file.c加进编译列表。peripheral 样例 prj.conf 设了CONFIG_BT_PERIPHERAL=y、CONFIG_BT_SMP=y,而BT_PERIPHERAL会 selectBT_BROADCASTER和BT_CONN,所以最终编译hci_core.c, adv.c, conn.c, l2cap.c, att.c, gatt.c, smp.c, keys.c这些。注意 SMP 没开时走的是smp_null.c(空实现),这是实际源码里的 else 分支,别以为不开 SMP 就什么都不编。
六、Kconfig 机制:prj.conf 怎么变成 CONFIG_*
zephyr/samples/bluetooth/peripheral/prj.conf关键项:CONFIG_BT=y是总开关,CONFIG_BT_PERIPHERAL=y选 peripheral 角色,CONFIG_BT_SMP=y开配对加密,CONFIG_BT_BAS=y/HRS=y这些是内置 GATT 服务,CONFIG_BT_SETTINGS=y做密钥持久化。
构建时 Zephyr 跑 Kconfig 解析器,合并 board defconfig 和 app prj.conf,生成build/zephyr/.config和autoconf.h。.config给人看,autoconf.h给编译器用——所有CONFIG_X=y在autoconf.h里变成#define CONFIG_X 1,C 代码里就能用#ifdef CONFIG_X来条件编译。
Bluetooth Kconfig 树在zephyr/subsys/bluetooth/Kconfig(已对照 gitee 核对):menuconfig BT是总开关,choice BT_STACK_SELECTION选栈类型,config BT_HCI是默认项(HCI 架构,host+controller+HCI driver),config BT_CUSTOM是自定义非 HCI 栈(极少用)。if BT_HCI下面,config BT_PERIPHERAL会 selectBT_BROADCASTER和BT_CONN,config BT_CENTRAL会 selectBT_OBSERVER和BT_CONN。最后source "subsys/bluetooth/controller/Kconfig"把 controller 选择拉进来。
select 是 Kconfig 的关键机制:选了BT_PERIPHERAL,它依赖的BT_BROADCASTER、BT_CONN会被自动选中,不用你手动开。这就是为什么 prj.conf 里只写角色,不写一堆底层开关——select 链帮你补全了。
七、Controller 选择:两个独立 config 各依赖一个 devicetree 符号
这是决定"用哪个链路层"的核心,也是最绕的一环。关键认知:controller 选择不是用 Kconfig 的 choice,而是两个独立的 config,各自依赖一个 devicetree 自动生成的符号。
Zephyr 软链路层在zephyr/subsys/bluetooth/controller/Kconfig(gitee 镜像对该文件触发内容过滤,未能独立联网核对,此处依据研究文档并标注):
config BT_LL_SW_SPLIT bool "Software-based Bluetooth LE Link Layer [EXPERIMENTAL]" default y depends on DT_HAS_ZEPHYR_BT_HCI_LL_SW_SPLIT_ENABLEDSoftDevice Controller 在nrf/subsys/bluetooth/controller/Kconfig:
config BT_LL_SOFTDEVICE bool "SoftDevice Link Layer" default y depends on SOC_SERIES_NRF54LX depends on DT_HAS_NORDIC_BT_HCI_SDC_ENABLEDDT_HAS_<COMPAT>_ENABLED是从 devicetree 自动生成的 Kconfig 符号:只要有一个compatible = "xxx"且status = "okay"的节点,对应符号就是 y。两个 config 都default y,但只有 DT 节点使能的那个能真正被选中——因为depends on不满足时,config 不会出现在可选列表里。
这个设计把"用哪个链路层"的决定权交给了 devicetree,而不是让用户在 Kconfig 里手动选。板子的 dts 描述了硬件上有什么 controller 节点,Kconfig 自动跟着走。换板子时只要 dts 配对,controller 选择自动切换,不用改 prj.conf。
八、Devicetree:nrf54l15dk 怎么选中 SoftDevice
devicetree 这块分两步,理解这两步的连锁反应是关键。
第一步,radio 节点下声明两个 HCI 子节点(zephyr/dts/vendor/nordic/nrf54l_05_10_15.dtsi):
radio: radio@8a000 { compatible = "nordic,nrf-radio"; bt_hci_sdc: bt_hci_sdc { compatible = "nordic,bt-hci-sdc"; status = "disabled"; }; bt_hci_controller: bt_hci_controller { compatible = "zephyr,bt-hci-ll-sw-split"; status = "disabled"; }; };两个子节点默认都是 disabled。bt_hci_sdc对应 SoftDevice Controller,bt_hci_controller对应 Zephyr 软链路层。
第二步,SoC 级 dtsi 启用 SDC(zephyr/dts/arm/nordic/nrf54l_05_10_15_cpuapp.dtsi):
/ { chosen { zephyr,bt-hci = &bt_hci_sdc; zephyr,entropy = &psa_rng; }; }; &bt_hci_sdc { status = "okay"; };这两步的连锁效应是:&bt_hci_sdc { status = "okay" }让DT_HAS_NORDIC_BT_HCI_SDC_ENABLED=y,于是BT_LL_SOFTDEVICE可选且默认 y;zephyr,bt-hci = &bt_hci_sdc这个 chosen 让 host 知道用哪个 HCI 设备;bt_hci_controller保持 disabled,BT_LL_SW_SPLIT不可选。
chosen 是 devicetree 里"系统级指定"的机制,类似"这个角色由谁扮演"。zephyr,bt-hci这个 chosen 节点告诉 host:你的 HCI 设备是&bt_hci_sdc。后面 host 就是靠这个 chosen 找到 HCI 驱动的。
九、host 怎么拿到 HCI 设备:chosen 到驱动的两条路径
这里要讲清楚一个容易混淆的点。研究文档里提到 host 在hci_core.c用DT_CHOSEN(zephyr_bt_hci)找到 HCI 设备。我对照 gitee 镜像 main 分支的subsys/bluetooth/host/hci_core.c核对,发现上游 main 分支当前仍保留较老的bt_hci_driver_register()回调注册模型(bt_init→hci_init,driver 通过bt_hci_driver_register(drv)注册,host 调bt_dev.drv->open()、bt_dev.drv->send()收发),并没有在固定某一行用DT_CHOSEN(zephyr_bt_hci)查找。
这里需要分清两件事:devicetree 层面的 chosen 指定(zephyr,bt-hci = &bt_hci_sdc,这个在 dtsi 里确实存在)和 host 代码层面怎么消费这个 chosen。在 NCS v3.2.1 这条用 SoftDevice Controller 的路径上,HCI 驱动是 nrf 仓库里的hci_driver.c,它用DT_DRV_COMPAT nordic_bt_hci_sdc+DEVICE_DT_INST_DEFINE为每个status="okay"的nordic,bt-hci-sdc节点实例化一个驱动设备,host 再通过 chosen 找到这个设备、拿到它的hci_driver_api(open/send/close)。而上游 zephyr main 的 hci_core.c 用的是回调式bt_hci_driver_register,两者是不同版本/不同路径的注册方式。
所以准确的表述是:chosen 在 devicetree 层把"用哪个 HCI 节点"定下来,具体到代码里 host 怎么拿到这个设备,NCS v3.2.1 走的是基于 devicetree 实例化的设备模型(DT_DRV_COMPAT+DEVICE_DT_INST_DEFINE),而上游 main 还保留着回调注册的老模型。行号会随版本变动,别死记某一行,抓住"chosen 指定节点 → 驱动按 compatible 实例化 → host 通过 chosen 或注册拿到 api"这条主线即可。这部分我已对照 gitee main 核对,ncs-v3.2.1 tag 不在镜像上,以实际 NCS 源码为准。
十、compatible binding:节点的"类型说明书"
两个 compatible 各有一个 binding 文件说明属性。zephyr,bt-hci-ll-sw-split的 binding 在zephyr/dts/bindings/bluetooth/zephyr,bt-hci-ll-sw-split.yaml,nordic,bt-hci-sdc的 binding 在nrf/dts/bindings/bluetooth/nordic,bt-hci-sdc.yaml,两者都include: bt-hci.yaml,继承公共属性bt-hci-name、bt-hci-bus、bt-hci-quirks。
binding 是 devicetree 的"类型说明书",它定义某个 compatible 的节点能有哪些属性、属性什么类型、默认值多少。构建系统读 binding 来校验 dts 写得对不对,也靠 binding 生成给 C 代码用的宏(比如DT_HAS_NORDIC_BT_HCI_SDC_ENABLED这种符号,就是从 binding + 节点状态自动生成的)。
十一、模块怎么进入构建:nrf 和 nrfxlib 的注册
nrf/通过nrf/zephyr/module.yml注册为 Zephyr 模块:
build: cmake: . kconfig: Kconfig.nrf settings: soc_root: . board_root: . dts_root: .soc_root、board_root、dts_root都指向 nrf 自己,意思是让 nrf/dts 的 binding、nrf/boards 也被构建系统搜索到。这就是为什么 nrf 仓库里定义的nordic,bt-hci-sdcbinding 能被识别——模块注册时把自己的 dts_root 加进了搜索路径。
nrfxlib/通过nrfxlib/zephyr/module.yml(带cmake-ext: True)作为外部库接入。它不贡献源码进 Zephyr 树,而是以预编译库的形式被链接。
模块机制是 Zephyr 构建系统的扩展点。第三方芯片厂、协议栈厂商要接入 Zephyr,不用改 Zephyr 核心,写个 module.yml 注册进来就行。Nordic 的 nrf 和 nrfxlib 就是这么接进来的。
十二、最终链接了什么:elf 里的成分清单
对 nrf54l15dk app core,最终固件包含这些成分。
应用层是main.c(和 cts.c,前面更正过)。Zephyr Host 是hci_core.c, conn.c, l2cap.c, att.c, gatt.c, smp.c, keys.c, adv.c这些。Nordic HCI driver 是nrf/subsys/bluetooth/controller/hci_driver.c,仅BT_LL_SOFTDEVICE=y时编译(见nrf/subsys/bluetooth/CMakeLists.txt的add_subdirectory_ifdef(CONFIG_BT_LL_SOFTDEVICE controller))。SoftDevice Controller 是预编译静态库nrfxlib/softdevice_controller/lib/nrf54l/.../libsoftdevice_controller_<variant>.a,由nrfxlib/softdevice_controller/CMakeLists.txt链入。还有 MPSL(多协议调度库,SDC 依赖)、内核、驱动。
hci_driver.c的设备注册(nrf/subsys/bluetooth/controller/hci_driver.c):
#define DT_DRV_COMPAT nordic_bt_hci_sdc #define BT_HCI_CONTROLLER_INIT(inst) \ DEVICE_DT_INST_DEFINE(inst, hci_driver_init, ..., &hci_driver_api) BT_HCI_CONTROLLER_INIT(0)DT_DRV_COMPAT声明这个驱动管的是nordic,bt-hci-sdc这个 compatible,DEVICE_DT_INST_DEFINE为每个status="okay"的这种节点实例化一个驱动设备。host 通过 chosen 找到它,拿到hci_driver_api里的 open/send/close。
注意这里hci_driver.c在 nrf 仓库,闭源的 SoftDevice Controller 库在 nrfxlib,两者分开。hci_driver.c 是开源的 HCI 适配层(它本身不含链路层逻辑,只是把 host 的 HCI 调用转给 SoftDevice 库),SoftDevice 库才是闭源的链路层实现。本文只讲 hci_driver.c 在构建里怎么被编进去、怎么注册设备,不涉及 SoftDevice 库内部。
十三、完整构建链路图
把整条链路串起来看一次:
west build -b nrf54l15dk_nrf54l15_cpuapp samples/bluetooth/peripheral │ ▼ cmake -DBOARD=... -S <sample> -B build │ ▼ 应用 CMakeLists.txt: find_package(Zephyr) → zephyr/CMakeLists.txt │ ▼ 处理 devicetree: bt_hci_sdc 节点 status=okay → DT_HAS_NORDIC_BT_HCI_SDC_ENABLED=y │ ▼ 处理 Kconfig: prj.conf(CONFIG_BT=y, BT_PERIPHERAL=y) + board + BT_LL_SOFTDEVICE=y → .config / autoconf.h │ ▼ zephyr/subsys/bluetooth/CMakeLists.txt: CONFIG_BT_HCI=y → 编 host/; CONFIG_BT_LL_SW_SPLIT 未选 → 跳过 zephyr controller/ │ ▼ nrf/subsys/CMakeLists.txt: CONFIG_BT=y → 进 nrf/subsys/bluetooth/; CONFIG_BT_LL_SOFTDEVICE=y → 编 nrf controller/hci_driver.c │ ▼ nrfxlib/softdevice_controller/CMakeLists.txt: CONFIG_BT_LL_SOFTDEVICE=y → 链接 libsoftdevice_controller_multirole.a │ ▼ host 通过 chosen 找到 &bt_hci_sdc 设备 → hci_driver_api │ ▼ 链接: app + Zephyr host + nrf hci_driver + SoftDevice 库 + MPSL + kernel → zephyr.elf这条链路里,devicetree 是起点(决定有哪些节点),Kconfig 是中转(把节点状态变成 CONFIG 开关),CMake 是执行(按开关决定编什么),最后链接成 elf。四件套各管一摊,但顺序固定、环环相扣。
十四、动手跟读建议
想真正吃透这套构建系统,建议照着源码跟读一遍。
第一步,读zephyr/samples/bluetooth/peripheral/CMakeLists.txt和prj.conf,看应用层最简的 CMake 和配置长什么样。第二步,读zephyr/subsys/bluetooth/CMakeLists.txt,理解add_subdirectory_ifdef怎么按 CONFIG 裁剪目录。第三步,读zephyr/subsys/bluetooth/host/CMakeLists.txt,看zephyr_library_sources_ifdef怎么按 CONFIG 选源文件,注意 SMP 的 else 分支。第四步,读zephyr/subsys/bluetooth/Kconfig的menuconfig BT和choice BT_STACK_SELECTION,看 select 怎么自动补全依赖。第五步,对照zephyr/subsys/bluetooth/controller/Kconfig和nrf/subsys/bluetooth/controller/Kconfig,理解 controller 选择的 DT 依赖。第六步,读nrf54l_05_10_15.dtsi的 radio 子节点和nrf54l_05_10_15_cpuapp.dtsi的 chosen + enable,看 devicetree 怎么定 controller。第七步,读nrf/subsys/bluetooth/controller/hci_driver.c的DT_DRV_COMPAT和DEVICE_DT_INST_DEFINE,看驱动怎么按节点实例化。
跟读的时候重点抓三个东西:devicetree 节点状态怎么变成 Kconfig 符号、Kconfig 符号怎么控制 CMake 编译范围、chosen 怎么把 host 和具体 HCI 驱动连起来。这三点搞清楚,构建系统的骨架就立起来了。
写在最后
Zephyr 的构建系统看起来四件套很复杂,拆开看就一条主线:devicetree 描述硬件 → Kconfig 把硬件状态变成编译开关 → CMake 按开关决定编哪些文件 → 链接成固件。west 只负责把代码摆到位。把add_subdirectory_ifdef的目录裁剪、zephyr_library_sources_ifdef的文件裁剪、DT_HAS_*_ENABLED的 DT 到 Kconfig 桥接、chosen 的节点指定这四点想通,后面遇到任何"为什么没编进去""为什么选不中"的构建问题,都能顺着这条链路定位到是哪一环出了问题。
下一篇会展开讲空中报文接收的完整链路,看一个无线包从 radio 收下来,经 Controller、HCI,到 Host 怎么一层层往上送。
如果你正在啃 Zephyr 构建系统,建议照着源码把这条链路跟读一遍,从west build一直跟到zephyr.elf,走一遍比看十遍文档都管用。
标签:#Zephyr #BLE #构建系统 #CMake #Kconfig #Devicetree #嵌入式开发 #NCS #nRF54L #west