1. 蓝牙beacon测距的总体设计思路
开讲之前先说个事:这个系列的每一讲,我都在尽量控制篇幅,结果每讲写出来都能赶上小论文。倒不是因为废话多,而是ESP-IDF这套框架里,很多看起来“一行搞定”的功能,真要讲清楚它背后的机制,就得把协议栈、事件循环、内存管理这些前置知识一并说透。这一讲蓝牙beacon测距更是如此,它涉及的不仅是API调用,还有信号传播模型、天线增益补偿、多径效应等一系列射频层面的问题,所以我会尽量按照“原理→代码→实测→调参”的顺序来写,保证你不仅能把例程跑起来,还能在项目里真正用上。
1.1 为什么选用BLE beacon方案做测距
先回答一个我经常被问到的问题:ESP32本身支持Wi-Fi、经典蓝牙(BR/EDR)和低功耗蓝牙(BLE)三种无线方式,为什么测距偏偏选beacon?
- 经典蓝牙虽然也有RSSI(Received Signal Strength Indicator,接收信号强度指示)接口,但它的工作模式和连接机制决定了它的RSSI采样并不适合做连续测距。经典蓝牙建立连接后,设备之间是点对点通信,测距的前提是“先配对成功”,这天然限制了应用场景。你想做一个商铺室内定位的信息推送,总不能让顾客先跟你的广播盒子配对再收消息吧?
- Wi-Fi的RSSI也在ESP32上轻松可得,但Wi-Fi是高频大带宽系统,受环境影响更剧烈,而且Wi-Fi扫描一次需要几百毫秒到数秒,做连续测距的时间粒度很差。
- BLE采用了广播(Advertising)模式,设备不需要建立连接,扫描端就能持续收到广播报文,并从物理层直接拿到RSSI值。广播报文本身还带时间戳、设备地址、自定义数据段,这对做定位系统尤其友好。
也就是说,BLE beacon天然就是为“低功耗、单向、广播”场景设计的。配合ESP32自带的BLE控制器,扫描和广播可以分时复用,一个模组既能当信标,又能当扫描器,做原型验证成本极低。
1.2 RSSI测距的原理与短板
RSSI测距的根在一个对数距离路径损耗模型,工程上最常见的公式是:
距离d = 10 ^ ((A - RSSI) / (10 * n))
其中A是距离发射端1米处测得的RSSI参考值,n是路径损耗指数。这个公式把无线信号在空间中的衰减近似成对数线性关系:离得越近RSSI越大,离得越远RSSI越小。
这个模型听起来很简单,实际用起来问题很大。室内环境下多径效应、人体遮挡、墙体反射都会导致RSSI剧烈波动,同一个位置静止不动,1秒钟内采到的RSSI都可能出现±6dBm的抖动。按公式反算距离,可能3米的实际距离一会儿算成1.8米,一会儿算成4.5米。
所以我在这一讲的实现里没有直接拿单次RSSI去求距离,而是引入了滑动窗口滤波和加权修正。这一套组合下来,静态测距的稳定性能提升到一个勉强可用的程度。纯RSSI测距的绝对精度确实有限,但它的价值在于“相对距离的趋势判断”和“区域内粗定位”,用在防丢提醒、人员接近检测、区域分割这些场景里是完全够用的。
2. 开发环境准备:ESP-IDF与VSCode的工程配置
2.1 从零搭建ESP-IDF + VSCode环境的关键步骤
这一讲先假设你已经装好了ESP-IDF,如果还没装好的同学,建议先去乐鑫官网下载离线安装包,Windows用户直接一键安装,选一个没有中文和空格的路径,比如D:\esp\esp-idf。装完之后系统会生成一个“ESP-IDF Command Prompt”快捷方式,先跑一下这个命令行工具,确认idf.py可用。
VSCode这边的配置,我个人强烈建议安装乐鑫官方提供的Espressif IDF扩展,而不是手动改JSON配置文件。因为这个扩展会自动识别你本机的ESP-IDF安装路径,同时在VSCode里集成编译、烧录、串口监视器、GDB调试等功能,省去了大量手工配置。
你装好扩展后打开命令面板(Ctrl+Shift+P),搜索“ESP-IDF: Configure ESP-IDF Extension”,会弹出两个选项:“USE EXISTING SETUP”和“DOWNLOAD ESP-IDF TOOLS”。如果你刚才已经装过完整工具链,直接选USE EXISTING SETUP,然后在输入框里定位到esp-idf文件夹下的export.bat文件即可。扩展会读取环境变量,自动绑定编译器、烧录器、OpenOCD等工具。
2.2 创建工程与配置menuconfig
打开VSCode后,按F1输入“ESP-IDF: Show Example Projects”,可以基于官方例程创建工程。不过beacon测距并不是官方例程的标准模板,我们需要用空工程起手。
先创建空工程,然后在main目录下改CMakeLists.txt:
idf_component_register(SRCS "main.c" INCLUDE_DIRS ".")之后在主程序中包含:
#include <stdio.h> #include "esp_log.h" #include "nvs_flash.h" #include "esp_bt.h" #include "esp_bt_main.h" #include "esp_gap_ble_api.h" #include "esp_gattc_api.h"然后在menuconfig中打开两个关键配置:
idf.py menuconfig进入Component config → Bluetooth → Bluedroid,勾选Bluedroid Enable,然后把Classic Bluetooth取消(或者保留也行,不影响BLE单独使用)。再进入Controller选项,把BLE Scan Duplicate Filter关掉。这个重复过滤在beacon扫描场景下是个坑,打开之后你会发现同一个广播地址的包会被过滤掉,导致扫描频率比预期低很多,测距数据点根本不够用。
3. 广播端与扫描端的核心代码实现
3.1 广播端:构造标准的iBeacon或自定义Beacon报文
beacon有多种格式,最经典的是苹果的iBeacon,但iBeacon的数据结构固定,Payload里包含UUID、Major、Minor、TxPower四个字段。如果你只需要简单测距而不需要跟iOS生态交互,完全可以用自定义格式的beacon广播,把RSSI校准值直接写进厂商自定义段,扫描端解析更灵活。
广播端的核心代码其实就三部分:
static esp_ble_adv_params_t adv_params = { .adv_int_min = 0x20, // 最小广播间隔 20ms .adv_int_max = 0x20, // 最大广播间隔 20ms .adv_type = ADV_TYPE_NONCONN_IND, .own_addr_type = BLE_ADDR_TYPE_PUBLIC, .channel_map = ADV_CHNL_ALL, .adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY, }; static uint8_t adv_data[31] = { 0x02, 0x01, 0x06, // Flags 0x1A, 0xFF, 0x00, 0x16, // 厂商自定义段头 0x02, 0x01, 0x02, 0x03, 0x04, 0x05, // 自定义标识 0x64, // 1米处RSSI校准值(十进制100,即-100dBm) // 后面是你自己的业务数据 };这里有个细节:广播数据最长31字节,超出部分要放到scan_response_data里,扫描端通过主动扫描(active scan)才能拿到。如果你用的是NONCONN_IND这种不可连接的广播类型,扫描端只能被动接收广播包,所以所有关键数据必须塞进广播段本身。
3.2 扫描端:注册GAP回调并提取RSSI
扫描端的核心是注册事件回调函数,接收ESP_GAP_BLE_SCAN_RESULT_EVT事件:
static void esp_gap_cb(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { switch (event) { case ESP_GAP_BLE_SCAN_RESULT_EVT: { esp_ble_gap_cb_param_t *scan_result = (esp_ble_gap_cb_param_t *)param; switch (scan_result->scan_rst.search_evt) { case ESP_GAP_SEARCH_INQ_RES_EVT: process_beacon(&scan_result->scan_rst); break; default: break; } break; } default: break; } }注意scan_rst结构体里有个rssi字段,单位是dBm,范围一般是-100到0。取到这个值之后,还需要同时拿到广播地址和广播数据,才能判断这个包是不是你要找的那个beacon。
有一点需要提醒:扫描回调是在Bluetooth Controller的任务上下文中执行的,不要在回调里做滤波、算法运算、日志打印等耗时操作。我见过很多新手直接把一个ESP_LOGI扔进回调里,结果日志一多,扫描直接卡死重启。正确做法是回调里只拷贝数据,用队列(xQueueSend)把数据丢给一个低优先级任务去处理。
3.3 距离换算:从RSSI到米的完整计算
拿到RSSI之后,按前面的对数损耗模型反算距离:
float rssi_to_distance(float rssi, float tx_power, float n) { return powf(10.0f, (tx_power - rssi) / (10.0f * n)); }tx_power是广播端1米处测得的RSSI参考值(A值),一般取-59到-65之间,这个需要实测标定,不能照抄别人例程里的数值。n是路径损耗指数,开阔空间取2.0,办公室环境取2.5~3.0,金属货架密集的环境取3.5以上。
算出来之后,千万别急着用。单次RSSI算出来的距离抖动非常夸张,我建议至少做两层处理:
第一层是滑动窗口均值滤波,维护一个长度为10的环形缓冲区,每次来新数据先把最旧的丢弃,然后求平均值:
#define FILTER_WINDOW 10 static float rssi_history[FILTER_WINDOW]; static uint8_t rssi_index = 0; static uint8_t rssi_count = 0; float rssi_filter(float new_rssi) { rssi_history[rssi_index] = new_rssi; rssi_index = (rssi_index + 1) % FILTER_WINDOW; if (rssi_count < FILTER_WINDOW) rssi_count++; float sum = 0; for (int i = 0; i < rssi_count; i++) { sum += rssi_history[i]; } return sum / rssi_count; }第二层是距离域的中值滤波,把滤波后的RSSI还原成距离,再取连续5次距离的中位数。中值滤波对突发的信号跳变(比如有人从中间走过)有很强的抑制作用,和均值滤波配合使用效果显著。
4. 参数标定与实测数据复盘
4.1 实测场景:办公室走廊的线性测距
我拿这套实现做了几轮实测,测试场景是普通办公楼走廊,宽度大概2.5米,两侧有金属门的门框,头顶是石膏板吊顶。广播端是ESP32模组,固定在走廊尽头,扫描端是另一块ESP32开发板,沿着走廊走动记录RSSI。
测试时我用了三种方法来估算距离:
| 方法 | 1米处实测 | 3米处实测 | 5米处实测 | 8米处实测 |
|---|---|---|---|---|
| 单次RSSI直接换算 | 0.94m | 2.35m | 4.01m | 6.89m |
| 滑动窗口均值滤波 | 1.05m | 2.91m | 4.56m | 7.12m |
| 均值+中值双层滤波 | 1.02m | 2.96m | 4.88m | 7.54m |
可以看到,距离越远,误差越大。这符合对数模型的数学特性:RSSI单位误差在远处对应的距离误差更大。所以如果你要做5米以上的测距,建议适当增加滤波窗口长度,但窗口过长又会引入明显的响应延迟,这个取舍要结合你的应用场景来定。
4.2 标定A值和n值的实操心得
前面提过A值是1米处的RSSI参考值,这个值我建议你在目标环境下实地采集,而不是用数据手册或者网上例程里的默认值。具体做法是:把广播端固定好,扫描端放在距离1米整的位置,连续采集50个RSSI,取平均值作为A值。
n值的标定复杂一些,你需要采集至少两个距离点的RSSI实测值,然后用公式反推:
n = (A - RSSI_d) / (10 * log10(d))
例如我采集到1米处RSSI均值为 -62 dBm,3米处RSSI均值为 -75 dBm,代进去:
n = (-62 - (-75)) / (10 * log10(3)) = 13 / 4.77 ≈ 2.72
这个n值表示该环境的衰减系数偏高,和办公室走廊这种多反射环境是吻合的。标定完成后把这组参数固化到代码里,再辅助滤波,测距效果会好很多。
另外还有一个实操细节:ESP32开发板的天线方向会影响RSSI。PCB板载天线的增益在某个方向上有明显凹陷,我在实测中把广播天线和扫描天线都朝向同一方向时,整体RSSI约高出4~6dBm。所以固定安装时尽量保持天线方向一致,测试时也要注意这一点。
5. 踩坑记录与排查经验速查
5.1 只扫到自己:address过滤逻辑写反了
第一次调试扫描端时,我把beacon的MAC地址判断写成了“跳过该地址”,结果看到的是自己的广播不停报出来,目标beacon一个都没有。排查半天才发现是把过滤条件取反了。如果你也遇到类似数据异常情况,先别急着怀疑射频问题,用日志把每个包的MAC地址、广播数据段、RSSI都打印出来,人眼确认一遍数据流再分析。
5.2 广播间隔与扫描窗口的关系
我把广播端的广播间隔设成了20ms(ADV_INT_MIN=0x20对应20ms),扫描端的扫描窗口设成了标准值。实际测试中,部分扫描到的广播报文RSSI抖动特别剧烈,我发现这和ESP32内部采样时刻有关,广播包在射频端被检测到的时间和扫描窗口的边界重合时,采样可能不完整。
后来我把扫描窗口周期调成与广播间隔不成整数倍的关系,比如固定扫描窗口为60ms,扫描周期为260ms,数据波动就明显改善了。
5.3 日志打印导致扫描卡顿
扫到的beacon数量一多(比如周围有其他手机和设备的BLE广播),如果每个包都打日志,串口输出会成为瓶颈,反而把Controller的任务拖慢。我后来的做法是:只对目标beacon的MAC地址匹配通过后才打日志,其他包直接丢弃。这样日志量可控,且不影响扫描性能。
5.4 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 扫描不到任何beacon | BLE未初始化,或重复过滤器开启 | 检查nvs_flash初始化,关闭duplicate filter |
| RSSI值固定在某个值不变 | 滤波窗口数据未刷新,或回调里阻塞 | 检查队列处理,避免长时间打印 |
| 距离计算结果为负数 | A值大于RSSI导致指数为正 | 检查A值标定,确认单位是dBm |
| 两台设备互相找不到 | 广播类型选择错误 | 使用ADV_TYPE_NONCONN_IND确认参数设置正确 |
| 只有靠近到1米内才触发 | n值偏大或A值偏小 | 重新标定环境参数 |
| 手机能看到ESP32,但ESP32扫不到手机 | 手机广播周期太长 | 手机锁屏后广播会停止,属正常现象 |
5.5 关于ESP-IDF版本差异的提醒
ESP-IDF从4.x到5.x,BLE相关的API有一些调整。最典型的是原来的esp_ble_gap_set_scan_params()函数在5.x版本里加了参数检查,如果传入的扫描窗口大于扫描周期,会直接返回错误码。另外新版IDF对Stack内存管理做了更严格的校验,比如CONFIG_BT_BLUEDROID_PINNED_TO_CORE默认值变了,多核环境下回调任务所在核会发生迁移。
我写这讲的时候用的ESP-IDF v5.1.2,代码里涉及的API在这版本上测试通过。如果你用的版本比较旧,编译报错时优先查一下对应版本的esp_gap_ble_api.h头文件,看看函数签名是否有变化,这比在网上搜报错信息来得快得多。
仅凭这点实现,beacon测距在室内定位的局限性还是清楚的:单点RSSI做不出多高的绝对精度,但配合三点定位或多点指纹,就能搭出一套比较实用的室内位置感知系统。后面如果大家有兴趣,我再单独写一讲基于beacon的三点定位实现,把多个扫描端的RSSI数据汇聚到服务器端做位置解算,那就是另一个层面的工程问题了。