本文记录基于 Nordic nRF54L15 的 BLE 信道探测(Channel Sounding, CS)测距实验: 自制 Tag 靠近开发板时,开发板上的 4 颗 LED 像信号强度条一样逐颗点亮, 走远时逐颗熄灭。包含完整的环境搭建、编译、烧录、核心代码解析与实测数据, 全部步骤在 Windows 11 上验证通过。
0. 先看成果


上图中红色曲线是相位斜率(phase_slope)距离估计值:拿着 Tag 从开发板旁边匀速 走远到 4~5 米再走回,读数画出一个平滑对称的"山峰"。绿色虚线是 4 档 LED 的切换 阈值——红线每跨过一条绿线,开发板上就多亮/灭一颗 LED。
演示效果:
| < 0.4 m | LED1~4 全亮 |
| ~0.9 m | LED1~3 亮 |
| ~1.4 m | LED1~2 亮 |
| ~2.8 m | LED1 亮 |
| 更远 / 找不到 Tag | 全灭(搜索时 LED1 慢闪心跳) |
1. 什么是 Channel Sounding
Channel Sounding(信道探测)是蓝牙 6.0 引入的测距特性,通过 PBR(基于相位的 测距,Phase-Based Ranging) 和 RTT(往返时间) 两种机制,在 2.4GHz 频段 的多个信道上交换带有精确时间戳/相位信息的报文,从而估算两个设备之间的距离。
相比传统的 RSSI 测距(误差动辄 3~5 米),CS 的典型精度可达亚米级,且具备 中继攻击防护能力,是数字钥匙、防丢器、"Find My" 类应用的理想技术。
CS 测距涉及两个角色:
Initiator(发起方):主动发起测距流程,距离数据在 Initiator 手里
Reflector(反射方):应答测距报文,本身不算距离
2. 硬件清单与角色分配
| nRF54L15-DK (PCA10156) | Initiator + LED 显示 | 测距并驱动 4 颗板载 LED |
| 自研 nRF54L15 TAG 板 | Reflector | CR2032 纽扣电池供电,越小越像"防丢标签" |
| nRF54LM20-DK (PCA10184) | 备用锚点 | 本实验不用(注意:见踩坑记录第 1 条) |
为什么用 DK 做 Initiator 而不是 Tag?因为 CS 的距离估计结果天然在 Initiator 一侧产生。要在哪块板上"显示距离",那块板就必须当 Initiator, 这样 LED 显示完全本地完成,不需要任何板间通信。
3. 环境搭建(NCS v3.3.0)
关键结论:原生 Zephyr 做不了 nRF 的 Channel Sounding(开源链路层没有 CS 实现),必须使用 Nordic 官方的 NCS(nRF Connect SDK),CS 由 SoftDevice Controller(闭源协议栈)提供。
本文使用:
NCS v3.3.0,安装在 C:\ncs\v3.3.0(完整 west workspace)
NCS 配套工具链 C:\ncs\toolchains\936afb6332(内含 west / cmake / ninja / zephyr-sdk / nrfutil,官方 Toolchain Manager 安装)
安装完成后,写一个环境脚本 C:\ncs\ncs-env.sh(Git Bash 下使用),把工具链 加入 PATH:
#!/bin/bash
# NCS v3.3.0 工具链环境(Git Bash 下 source 使用)
export TC=/c/ncs/toolchains/936afb6332
export PATH="$TC:$TC/mingw64/bin:$TC/bin:$TC/opt/bin:$TC/opt/bin/Scripts:$TC/opt/nanopb/generator-bin:$TC/nrfutil/bin:$TC/opt/zephyr-sdk/arm-zephyr-eabi/bin:$TC/opt/zephyr-sdk/riscv64-zephyr-elf/bin:$PATH"
export PYTHONPATH="$TC/opt/bin;$TC/opt/bin/Lib;$TC/opt/bin/Lib/site-packages"
export NRFUTIL_HOME="$TC/nrfutil/home"
export ZEPHYR_TOOLCHAIN_VARIANT=zephyr
export ZEPHYR_SDK_INSTALL_DIR="$TC/opt/zephyr-sdk"
之后每次编译/烧录前,先:
source /c/ncs/ncs-env.sh
4. 固件组成
本实验需要两个固件,都基于 NCS 自带的 Channel Sounding 样例 (C:\ncs\v3.3.0\nrf\samples\bluetooth\channel_sounding\):
| ras_reflector | NCS 样例原样使用 | Tag(板型 nrf54l15tag/nrf54l15/cpuapp) |
| cs_anchor | 复制 ras_initiator 样例后二次开发(见第 7 节) | L15-DK(板型 nrf54l15dk/nrf54l15/cpuapp) |
cs_anchor 的工程放在 app/nrf54l15_tag/cs_anchor/(D 盘),结构就是标准 Zephyr 应用:CMakeLists.txt + prj.conf + src/main.c。
5. west 编译
5.1 一个跨盘符的坑(Windows 特有)
工程源码在 D: 盘,NCS workspace 在 C: 盘。直接在 D 盘执行 west build 会踩两个坑:
west 向上找到 D 盘的另一个 Zephyr workspace(原生 Zephyr 4.4), CMake 通过包注册表把 ZEPHYR_BASE 解析到错误的树,Kconfig 直接报错;
就算指定 NCS 侧的构建目录,west 内部对源码路径求相对路径时会报 ValueError: path is on mount 'D:', start on mount 'C:'。
解法:建一个目录联接(junction),把 D 盘的工程"挂"进 NCS workspace:
:: cmd 下执行(无需管理员)
mklink /J "C:\ncs\v3.3.0\cs_anchor" "D:\luglZephyrproject\app\nrf54l15_tag\cs_anchor"
文件实体仍在 D 盘,但 west 看到的是 C 盘路径,两个坑同时消失。
5.2 编译命令
source /c/ncs/ncs-env.sh
cd /c/ncs/v3.3.0
# Initiator + LED 显示固件 → nRF54L15-DK
west build -b nrf54l15dk/nrf54l15/cpuapp cs_anchor -d build/cs_anchor_l15dk -p always
# Reflector 固件 → 自研 Tag
west build -b nrf54l15tag/nrf54l15/cpuapp \
nrf/samples/bluetooth/channel_sounding/ras_reflector \
-d build/cs_reflector_tag -p always
说明:
-b 指定板型,nrf54l15tag 是自研板的板子定义(NCS 树里已加入)
-d 指定构建目录,两个固件各用一个
-p always pristine 构建,改了 Kconfig/devicetree 后建议加上
编译产物(sysbuild 结构):
build/cs_anchor_l15dk/cs_anchor/zephyr/zephyr.hex ← 约 302 KB
build/cs_reflector_tag/ras_reflector/zephyr/zephyr.hex ← 约 251 KB
6. 烧录
两块板都通过板载 J-Link 调试器烧录。电脑同时插多块板时,必须用序列号 区分目标。先用 nrfutil 查看:
source /c/ncs/ncs-env.sh
nrfutil device list
输出示例(Board version 可以区分板型):
1057728457 → PCA10156 (nRF54L15-DK), 串口 COM34/COM35
1051829895 → PCA10184 (nRF54LM20-DK), 串口 COM32/COM33
6.1 烧录 L15-DK(cs_anchor)
west flash -d build/cs_anchor_l15dk --dev-id 1057728457
注意:nrfutil runner 的序列号参数是 --dev-id,不是 --serial-number。
6.2 烧录 Tag(cs_reflector_tag)——利用 DK 的 DEBUG OUT
自研 Tag 没有板载调试器,借助 L15-DK 的 DEBUG OUT 排针烧录:把 Tag 插到 DK 的 DEBUG OUT 上后,DK 的板载调试器会把 SWD 路由到 Tag 而不是 DK 自己 (所以烧 DK 之前务必先把 Tag 拔下来,反之亦然)。
Tag 插上 DEBUG OUT 后(注意方向),用 jlink runner 烧录,同样要显式指定序列号 (否则两块 J-Link 在场时会选错):
west flash -d build/cs_reflector_tag -r jlink --dev-id 1057728457
烧完把 Tag 拔下来,装 CR2032 电池独立供电即可。
6.3 验证
DK 的日志串口在 COM34(vcom0,115200)。连接成功后会周期性打印:

7. 核心代码解析(cs_anchor/src/main.c)
cs_anchor 在 ras_initiator 样例基础上只做了约 80 行改动。样例本身已完成 扫描 → 连接 → CS 安全配置 → 周期测距的全部流程,并在每次子事件后得到三种 距离估计(ifft / phase_slope / rtt 的中位值)。我们要做的只有三件事: 选哪个估计值、怎么映射成 LED、没连上时显示什么。
7.1 估计值选择:只用 phase_slope
三种估计器的实测表现(详见第 8 节校准数据):phase_slope 全程单调、平滑; ifft 近距离会跳变;rtt 噪声最大。所以融合策略非常简单——优先 phase_slope,失效才退到 rtt:
static float fuse_distance(const cs_de_dist_estimates_t *est)
{
/*
* Measured on this Tag/DK pair (2026-09-12): phase_slope is monotonic
* over 0.2~4 m while ifft is not at close range, so use phase_slope
* only; rtt is the last resort.
*/
if (isfinite(est->phase_slope)) {
return est->phase_slope;
}
if (isfinite(est->rtt)) {
return est->rtt;
}
return NAN;
}
7.2 距离 → LED 档位映射(带滞回)
阈值表 led_bar_bounds[] 是卷尺实测校准出来的(第 8 节),单位为 phase_slope 原始读数。档位逻辑分两步:先算目标档位,再做滞回—— 靠近时立即点亮(响应快),远离时要超过阈值 10% 才熄灭(防抖动):
/*
* Thresholds in *phase_slope units*, calibrated against a tape measure
* (midpoints between measured medians):
* actual 0.2/0.5/1/2/4 m -> phase_slope 1.52/1.86/2.18/4.65/8.54
*/
static const float led_bar_bounds[LED_BAR_NUM_LEVELS] = { 1.7f, 2.0f, 3.4f, 6.5f };
static int led_bar_level;
static int dist_to_level(float dist)
{
for (int i = 0; i < LED_BAR_NUM_LEVELS; i++) {
if (dist < led_bar_bounds[i]) {
return LED_BAR_NUM_LEVELS - i;
}
}
return 0;
}
static void led_bar_update(float dist)
{
int new_level;
if (!isfinite(dist)) {
return;
}
new_level = dist_to_level(dist);
if (new_level > led_bar_level) {
/* Getting closer: light up immediately. */
led_bar_level = new_level;
} else if (new_level < led_bar_level) {
/* Getting farther: require crossing the bound plus hysteresis. */
float bound = led_bar_bounds[LED_BAR_NUM_LEVELS - led_bar_level];
if (dist > bound * (100 + LED_BAR_HYSTERESIS_PCT) / 100.0f) {
led_bar_level = new_level;
}
}
dk_set_leds((uint32_t)((1 << led_bar_level) - 1));
}
dk_set_leds() 来自 NCS 的 DK 库(dk_buttons_and_leds.h),样例原本就调用 了 dk_leds_init(),4 颗 LED 对应位掩码 bit0~bit3,level 颗灯就是 (1 << level) - 1。
7.3 挂载点:在打印日志处顺手更新 LED
样例里每次测距结果都会进 distance_estimates_print(),在函数末尾加一行即可:
static void distance_estimates_print(uint8_t ap)
{
cs_de_dist_estimates_t distance_on_ap = get_distance(ap);
/* …原有 LOG_INF 打印… */
led_bar_update(fuse_distance(&distance_on_ap));
}
7.4 心跳:未连接时 LED1 慢闪
样例原本用 LED1 做"已连接"常亮指示,我们把它改成搜索中心跳: 一个 500ms 的 Zephyr 定时器,只要还没连上 Tag 就翻转 LED1; 连上之后 LED 全部交给距离条管理:
static void heartbeat_handler(struct k_timer *timer)
{
static bool led_on;
ARG_UNUSED(timer);
if (connection == NULL) {
led_on = !led_on;
dk_set_leds(led_on ? DK_LED1_MSK : 0);
}
}
static K_TIMER_DEFINE(heartbeat_timer, heartbeat_handler, NULL);
在 main() 里 dk_leds_init() 之后启动:
dk_leds_init();
k_timer_start(&heartbeat_timer, K_MSEC(HEARTBEAT_PERIOD_MS),
K_MSEC(HEARTBEAT_PERIOD_MS));
8. 阈值校准方法
CS 样例不做任何距离校准(查了 NCS 的 cs_de 距离估计库和样例源码确认), 输出是原始估计值,存在固定偏移;且贴脸距离(< 10cm)处于天线近场区, 相位法会失真。所以阈值不能照抄"理想米数",必须实测。
方法:Tag 装电池、保持固定天线朝向,用卷尺放在 5 个定点,每个点采约 150 个 样本取中位数:
| 0.2 m | 1.23 | 1.52 | 1.97 |
| 0.5 m | 1.80 | 1.86 | 2.28 |
| 1.0 m | 1.46 | 2.18 | 2.07 |
| 2.0 m | 1.82 | 4.65 | 6.58 |
| 4.0 m | 3.92 | 8.54 | 8.79 |
观察:
phase_slope 全程单调递增——这是"距离条"功能唯一需要的性质
ifft 在 0.5m → 1m 时读数反而下降(1.80 → 1.46),近距离不可靠
RTT 整体漂移大,只配做兜底
阈值取相邻测量点的中点,得到 {1.7, 2.0, 3.4, 6.5},对应实际距离大约 0.4 / 0.9 / 1.4 / 2.8 米。
对演示类应用,"单调 + 校准阈值"比"绝对精度"更重要。如果要输出真实米数, 还需要做多点拟合标定 + 补偿天线群延迟,那是另一个课题了。
9. 实测效果
拿着 Tag 从 DK 旁边匀速走远到 4~5 米、停 2 秒、再走回,50 秒 499 个样本 (原始数据 tools/cs_walk.csv):

红色 phase_slope 曲线平滑地走出一个"山峰",往返基本对称
LED 表现:走远时 4 → 3 → 2 → 1 → 0 逐颗熄灭,走回时逐颗点亮, 阈值边界附近没有抖动(滞回生效)
10. 踩坑记录
两块 Initiator 会抢同一个 Tag。CS 连接是 1:1 的。调试时 LM20-DK 也跑 着 initiator 固件,结果它先扫到 Tag 并连上,L15-DK 怎么都连不上。 演示时确保范围内只有一个 initiator。
Tag 插在 DEBUG OUT 上时,烧录会烧到 Tag 而不是 DK(调试器路由被 切换)。烧 DK 前务必先拔 Tag。
DK 串口是 vcom0(COM34)不是 vcom1。NCS 的样例 console 默认在 uart20,对应枚举出来的第一个虚拟串口。
nrfutil runner 用 --dev-id 指定序列号;J-Link runner 在多个调试器 在场时也必须显式 --dev-id,否则直接失败。
Windows 跨盘符构建:源码在 D 盘、NCS 在 C 盘时,用 mklink /J 建 junction 把工程挂进 NCS workspace(见 5.1)。
样例断连即重启:initiator 和 reflector 样例的 disconnected_cb 都会 sys_reboot(SYS_REBOOT_COLD),掉线后自动重新扫描/广播,演示时很省心, 但看日志时要意识到这一点。
我要赚赏金
