做蓝牙 beacon 测距这个功能,其实是去年给一个室内定位项目做技术选型时被逼出来的。当时需求很明确:室内实现"米级"定位,预算压得死,UWB那套贵得离谱,AOA到达角方案又得定制阵列天线,算下来只有 RSSI 测距这条路最现实——ESP32 几乎人手一块,蓝牙 beacon 几块钱一个,ESP-IDF 原生就带 BLE 协议栈,成本几乎可以忽略不计。
这一讲是"ESP-IDF + VSCode 开发 ESP32 联网篇"的第六讲,专门聊蓝牙 beacon 测距。我会从原理讲到代码,再到实际调试中那些文档里不会写的坑,完整走一遍。适合已经会基本的 ESP-IDF 工程创建、想在室内定位、防丢提醒、人员考勤这类场景里用蓝牙做距离感知的朋友。老规矩,不废话,直接上干货。
1. 为什么选蓝牙 beacon 测距:方案对比与核心原理解读
1.1 几种蓝牙测距方案横向对比
先理清一个容易混淆的概念:蓝牙测距不是一个方案,而是分了好几条技术路线,选错了后面全白干。
第一类是RSSI 信号强度测距,也是本讲的主角。原理特别朴素——信号在空中传播会衰减,离得越近信号越强,离得越远信号越弱,拿信号强度反推距离。优点是什么?不需要额外硬件,beacon 端只要能广播就行,接收端 ESP32 打开扫描接口就能拿到 RSSI 值,成本几乎为零。缺点也很明显:信号在室内会被墙壁、人体、金属家具反射和吸收,RSSI 波动非常剧烈,精度只能做到 1~5 米这个水平。
第二类是AOA 到达角测距,就是蓝牙 5.1 引入的测向功能。它靠阵列天线接收信号时不同天线振子之间的相位差来计算信号到达角度,配合多个基站做三角定位,精度可以到厘米级。但代价是接收端必须用专用阵列天线硬件,目前只有少数几家芯片厂商在做,成本比普通 BLE 模块高一大截,而且算法授权、天线校准都是坑。
第三类是TOF 飞行时间测距,蓝牙协议栈里其实没有原生支持,实际项目里基本会被 UWB(超宽带)替代。UWB 测距精度能做到 10 厘米级,抗干扰能力也强,但一颗 UWB 芯片十几块钱起步,基础设施成本直接劝退很多项目。
从落地角度讲,RSSI 测距虽然精度不是最强,但它是唯一一种"只要有蓝牙就能跑"的方案。这个特性决定了它特别适合两种场景:一种是预算敏感、精度要求没那么变态的业务——比如展馆里判断参观者大概站在哪个展区,误差两三米完全能接受;另一种是"粗定位 + 区域判断"的组合——比如检测到某个位置放了信标,就知道设备进入了对应区域,这时候压根不需要精确距离,只要一个稳定可重复的信号阈值就够了。
1.2 从 RSSI 到距离的数学换算
拿到 RSSI(Received Signal Strength Indicator,接收信号强度指示)之后,怎么变成一个具体距离?业界最常用的是对数距离路径损耗模型:
[ RSSI = A - 10n \cdot \lg(d) ]
反推距离就是:
[ d = 10^{\frac{A - RSSI}{10n}} ]
这里有两个关键参数:
- A:距离发射端 1 米处测到的 RSSI 绝对值。经验值一般取 59~60,也就是 -59dBm 左右。但这个值不是固定的,不同 beacon 的发射功率、天线增益、外壳材质都会影响它,严谨的做法是拿到实物后在 1 米处实测。
- n:环境衰减因子。开阔空间取 2.0,普通办公室隔断多一点的取 2.5~3.0,走廊、仓库这种狭长空间或者障碍物密集的环境要到 3.5~4.0。这个值直接决定距离曲线的斜率,取错了测出来的距离会系统性偏大或偏小。
打个比方,RSSI 测距就像你在黑夜里看一个灯泡——你能大概判断灯离你多远,但你不知道灯泡本身的瓦数(A 值),也说不清中间隔了几层雾(n 值)。所以工程上拿到一个新 beacon,第一件事就是做标定实验,而不是直接套公式。
另外有个细节很多人会忽略:在 BLE 广播包里的 TX Power 字段代表的是 1 米处的 RSSI 参考值。iBeacon 广播包最后一个字节就是 TX Power,有些实现直接用这个值替代 A 参数,会比拍脑袋填 -59 靠谱得多。后面解析协议时我会讲怎么把这个值读出来。
1.3 iBeacon 广播包结构解剖
这里以 iBeacon 协议为例,因为它是目前市面占比最高的 beacon 格式——苹果定义,众多信标厂商都在兼容。一个完整的 iBeacon 广播包,在 BLE 链路层的数据部分大概长这样:
02 01 1A 1A FF 4C 00 02 15 [UUID 16字节] [Major 2字节] [Minor 2字节] [TX Power 1字节]拆开看:
02 01 1A:AD Structure 1,Length=2,Type=0x01(Flags),Value=0x1A(表示 LE General Discoverable Mode + BR/EDR Not Supported)。1A FF:AD Structure 2,Length=0x1A(即 26 字节),Type=0xFF(Manufacturer Specific Data,厂商自定义数据)。4C 00:Company ID,0x004C,这是 Apple 的公司标识,注意是小端字节序发送的,所以代码里判断时要反过来。02 15:iBeacon 类型标识(0x02)+ iBeacon 数据长度(0x15,即 21 字节)。- 接着是 16 字节 UUID、2 字节 Major、2 字节 Minor、1 字节 TX Power。
Major 和 Minor 怎么用?通常用 UUID 区分"应用",Major 区分"区域/楼层",Minor 区分"单个信标点"。比如一个商场项目:UUID 固定为商场的应用标识,1 楼所有 beacon 的 Major=1,2 楼 Major=2,每个具体洗手间入口的 Minor 再单独编号。这样软件层拿到广播包,三层信息一拆完,连查询数据库的过程都省了。
解析广播包的本质就是遍历 ADV 数据里的 TLV(Type-Length-Value)结构,找到 Type=0xFF 的厂商段,再校验 Company ID 和 iBeacon 标识。逻辑不复杂,但细节决定成败——字节序、偏移量、长度校验,哪个错了解析出来都是乱码。
2. 开发环境搭建:VSCode + ESP-IDF 的准备细节
2.1 环境版本选择与插件配置
工欲善其事必先利其器。ESP-IDF 的开发方式这几年变化挺大,早期大家用 Eclipse 插件,后来官方力推 VSCode 插件,现在又多了个 IDF VSCode Extension 的图形化配置界面,整体体验已经比当年舒服太多了。
我的建议是直接用乐鑫官方的 Espressif IDF 插件,别自己折腾命令行编译加第三方插件拼凑。插件的核心能力是帮你管理多个 ESP-IDF 版本、自动配置环境变量和工具链路径、内置串口监视器和构建任务。装完插件后,第一次使用时它会弹窗让你选择 IDF 的安装方式:
- Express 模式:插件自动下载某个固定版本的 ESP-IDF 和全部工具链,适合新手上路。
- Existing 模式:指定你手动安装好的 IDF 目录,适合已经用命令行环境、不同项目需要不同 IDF 版本的老手。
我自己习惯用 Existing 模式,因为同时维护着 v4.4 和 v5.1 两套环境,老项目不能乱动,新项目直接上 v5.x。这里有个重要提醒:不同版本的 ESP-IDF 蓝牙接口有差异,比如 v4.x 里还能直接调esp_ble_scan_start(),到 v5.x 就改名成esp_ble_gap_start_scanning()了。网上很多教程代码跑不起来,十有八九是版本对不上。本讲工程基于ESP-IDF v5.1,API 以这个版本为准。
VSCode 本身除了官方 C/C++ 插件外,我强烈建议加装Cortex-Debug——如果你不打算用 JTAG 调试器就算了,但用 ESP32 的朋友迟早要面对硬 BUG,到时候图形化断点调试比串口打印效率高一个量级。
2.2 创建工程与常用配置项
插件装好后,创建一个新的 ESP-IDF 工程有两条路径。如果你用命令行走过一遍流程,会更理解背后发生了什么:
idf.py create-project ble_beacon_rssi cd ble_beacon_rssi idf.py set-target esp32 idf.py menuconfig在 menuconfig 里依次确认这几个开关:
Component config → Bluetooth → Bluetooth:必须置为 Enabled。Bluetooth controller → Bluetooth Controller Mode:BLE Only。默认是 Bredr/LE Dual 模式,如果你只做 beacon 接收不涉及经典蓝牙,选 BLE Only 可以省下大量内存。Bluetooth Host → Bluedroid Options → BLE Scanned Device Store:如果扫描设备数量可能很多,把存储条数调大,默认的 10 条很容易丢数据。
这里我要专门说一下经典蓝牙的问题。ESP32 是双模蓝牙,同时支持 BR/EDR(经典蓝牙)和 BLE。beacon 测距只用到 BLE,所以可以在初始化控制器时释放掉经典蓝牙占用的内存:
esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT);这行代码必须在初始化蓝牙控制器之前调用。释放出来的内存大概有 190KB 左右,对 ESP32 这种内存不算宽裕的芯片来说,省下来的空间可以用来增大扫描缓存或者做后续的滤波运算。我第一次做的时候没管这个,结果后面加了个 TLS 功能直接内存不够,排查了半天才发现是蓝牙协议栈吃掉了太多 RAM。
2.3 工程目录与模块划分
我习惯把 beacon 相关代码拆成独立模块,而不是全堆在 main 里:
ble_beacon_rssi/ ├── main/ │ ├── CMakeLists.txt │ ├── main.c # 应用入口,创建定时器任务 │ ├── ble_scanner.c # BLE 初始化、扫描配置、回调处理 │ ├── ble_scanner.h │ ├── ibeacon_parser.c # iBeacon 数据解析 │ ├── ibeacon_parser.h │ ├── rssi_filter.c # 滑动滤波和距离转换 │ └── rssi_filter.h └── CMakeLists.txt从功能上讲,BLE 扫描回调里最忌讳的就是做重活——回调跑在蓝牙协议栈的上下文中,如果你在里面做滤波、打印大量日志、甚至调用 WiFi API,轻则丢扫描结果,重则触发任务看门狗。正确的做法是回调里只做数据拷贝,把原始广播帧和 RSSI 值丢进一个队列,由专门的用户任务去解析和计算。队列深度一般设 20~50 就够了,太深会积压旧数据导致测距实时性变差。
3. beacon 扫描与协议解析代码实现
3.1 BLE 初始化与扫描参数配置
第一步是初始化蓝牙控制器和协议栈。标准流程大概三步:释放不需要的内存、初始化控制器、初始化 Bluedroid 主机。
esp_err_t ble_scanner_init(void) { esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT); esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT(); esp_bt_controller_init(&bt_cfg); esp_bt_controller_enable(ESP_BT_MODE_BLE); esp_bluedroid_init(); esp_bluedroid_enable(); esp_ble_gap_register_callback(gap_event_handler); return esp_ble_gap_set_scan_params(&ble_scan_params); }这里最容易踩的坑是顺序。esp_bt_controller_init必须在esp_bluedroid_init之前,反了直接断言报错。另外插件的示例工程默认可能把蓝牙配置成 A2DP 音频模式,那个配置跑 beacon 扫描会白白浪费几百 KB 内存。
接下来是扫描参数,这个参数配得好不好直接影响扫描效率和丢包率:
static esp_ble_scan_params_t ble_scan_params = { .scan_type = BLE_SCAN_TYPE_ACTIVE, // 主动扫描,可以拿到扫描响应帧 .own_addr_type = BLE_ADDR_TYPE_PUBLIC, .scan_filter_policy = BLE_SCAN_FILTER_ALLOW_ALL, // 不过滤,只做白名单过滤的场景另说 .scan_interval = 0x50, // 单位 0.625ms,这里约 50ms .scan_window = 0x30, // 单位 0.625ms,这里约 30ms .scan_duplicate = BLE_SCAN_DUPLICATE_DISABLE // 不去重,保证每个广播包都能收到 };逐项解释一下。scan_type选主动扫描,除了接收广播包还会主动向 beacon 发扫描请求,从而拿到广播响应帧——这能让你多收集一些数据,对解析某些只把关键数据放扫描响应里的 beacon 很有必要。scan_interval和scan_window的关系是:窗口占间隔的比例越高,扫描越"密集",越不容易漏包,但也越费电。0x50 和 0x30 的比例是 60%,实际测试下来在 5 米范围内不漏包,功耗也还能接受。最后那个scan_duplicate参数特别关键,默认是 DISABLE,意味着同一台 beacon 的每个广播包都会触发一次回调。如果你误开了去重,可能会看到 RSSI 好长时间不变一次,因为协议栈认为"同一个设备的重复包"直接被丢掉了。
3.2 扫描回调处理与数据入队
回调函数是 BLE 扫描的核心枢纽,所有扫描结果都会从这个事件进来:
static void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { switch (event) { case ESP_GAP_BLE_SCAN_RESULT_EVT: handle_scan_result(¶m->scan_rst); break; case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: esp_ble_gap_start_scanning(0); // 参数设置完成,开始扫描,0 表示持续扫描 break; default: break; } }ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT这个事件提醒我们:esp_ble_gap_set_scan_params()是异步的,参数设置完事之后才会触发这个事件,这时候才能去调用esp_ble_gap_start_scanning()。很多人把这两个调用写在一起,结果第二次set_scan_params还没生效就开始扫了,行为完全不可预期。
到了handle_scan_result这一步,同样不是直接干解析,而是做一层过滤后入队:
static void handle_scan_result(esp_ble_gap_cb_param_t *scan_rst) { if (scan_rst->search_evt != ESP_GAP_SEARCH_INQ_RES_EVT) { return; // 非扫描结果事件直接忽略 } // 这里只做浅层判断:是不是 BLE 广播,有没有厂商数据段 uint8_t adv_len = scan_rst->adv_data_len; if (adv_len < 30) { return; // 一个 iBeacon 广播包最少 30 字节左右,太短直接丢弃 } raw_scan_item_t item; memcpy(item.adv_data, scan_rst->ble_adv, adv_len); item.adv_len = adv_len; item.rssi = scan_rst->rssi; if (xQueueSend(scan_queue, &item, 0) != pdTRUE) { ESP_LOGW("scanner", "scan queue full, packet dropped"); } }注意scan_rst->rssi是int8_t类型,单位是 dBm,直接带负数。这里顺手提一句:esp_ble_gap_cb_param_t里的ble_adv指针保存的是完整广播数据(包含前 6 字节 MAC 地址),而adv_data_len是广播数据长度,不是总长度。取数据的时候别把 MAC 也当广播数据一起解析了,否则偏移量全错。
3.3 解析 iBeacon 数据帧
核心解析函数在独立的ibeacon_parser.c里,逻辑是标准的 TLV 遍历:
typedef struct { uint8_t uuid[16]; uint16_t major; uint16_t minor; int8_t tx_power; int8_t rssi; } ibeacon_info_t; bool parse_ibeacon(const uint8_t *adv_data, uint8_t adv_len, int8_t rssi, ibeacon_info_t *out) { uint8_t idx = 0; while (idx < adv_len) { uint8_t len = adv_data[idx]; if (len == 0) break; uint8_t type = adv_data[idx + 1]; if (type == 0xFF && len >= 23) { // 厂商数据段,长度至少 23 = 26 - 1(type) - 2(company) // 校验 Company ID = 0x004C(小端序) if (adv_data[idx + 2] == 0x4C && adv_data[idx + 3] == 0x00) { // 校验 iBeacon 标识:0x02 0x15 if (adv_data[idx + 4] == 0x02 && adv_data[idx + 5] == 0x15) { const uint8_t *p = adv_data + idx + 6; memcpy(out->uuid, p, 16); out->major = (p[16] << 8) | p[17]; out->minor = (p[18] << 8) | p[19]; out->tx_power = (int8_t)p[20]; out->rssi = rssi; return true; } } } idx += (len + 1); // 跳过一个 AD structure } return false; }这段代码里有几个细节值得展开。
第一个是长度判断。len字段代表的是包含 Type 和 Value 在内的长度,不含 Length 自身。iBeacon 厂商数据段里 Value 部分有 25 字节(Company ID 2 字节 + iBeacon 标识 2 字节 + UUID 16 字节 + Major 2 字节 + Minor 2 字节 + TX Power 1 字节),加上 Type 自己 1 字节,所以 len 至少 26。代码里写成len >= 23是因为后面还有两次偏移判断,实际进入数据区后又检查了0x02 0x15,双重保险。
第二个是小端字节序陷阱。Major 和 Minor 在广播包里是高字节在前(大端序),所以要用(p[16] << 8) | p[17]拼回来。TX Power 是单字节有符号数,直接强转int8_t。
第三个是索引推进策略。一个 AD structure 的总占用是1(len) + 1(type) + (len - 1)(value),所以跳转步长是len + 1。这里必须用这个公式,不能简单idx += 2,因为每个 structure 的长度不一样。我曾经见过有人直接用固定偏移 30 去取 UUID——在 iBeacon 恰好排在前两段时能跑通,但一旦广播包结构调整,数据全错。
4. RSSI 滤波与距离换算:从数据到距离的最后一公里
4.1 干这行必会的滑动窗口滤波
拿到 RSSI 原始值后,你绝对会怀疑人生。同一台 beacon 放在 3 米处,RSSI 会在 -55dBm 到 -65dBm 之间来回蹦,跳幅 10 个 dB 是常态。如果直接把这种原始值套进公式,算出来的距离会在 2 米和 5 米之间乱跳,根本没法用。
所以滤波这步省不了。实测下来,滑动平均滤波和指数加权滤波效果最实用,代码简单,效果可控(注意规避 Kalman 那个坎儿,先用移动平均平抑信号,再用指数平滑做实时响应)。
滑动平均的思路是维护一个最近 N 个 RSSI 的窗口,输出窗口内平均值。N 太小滤波效果差,N 太大响应太迟钝。我自己测试下来取5~8比较平衡,大概对应 0.3~0.5 秒的窗口,既能压住毛刺,又不会让你在走动时感觉距离值"黏住"。
指数加权滤波(EMA)是另一种流派,实现更省内存:
static float rssi_ema = -60.0f; // 初始值给个经验值 float rssi_filter_ema(float new_rssi) { rssi_ema = rssi_ema * 0.7f + new_rssi * 0.3f; return rssi_ema; }系数怎么选?0.7/0.3意味着每次更新时,新值占 30% 权重,旧值占 70%。这个比例对 beacon 测距比较合适——既不会因为单次突变大幅跳动,又能在设备真正移动时快速跟踪。想更平滑就把 0.3 调成 0.2,想更灵敏就调成 0.4。注意这个滤波有一个隐藏前提:不同 beacon 的 RSSI 要分开滤波。每个 beacon 建一个滤波器实例,不能共享同一个rssi_ema,否则多台设备一混,距离全乱。工程上我会建立一个以 MAC 地址为 key 的哈希表,哈希表里存每个 beacon 的滤波状态。
4.2 标定 A 值和 n 值:别偷懒,实测才是王道
公式里的 A 和 n 直接决定了最终距离的绝对精度。网上很多教程直接给 A=-59, n=2.0,然后跑出来的距离偏差半层楼,这是很正常的——因为不同牌子的 beacon 发射功率差异非常大,外壳损耗也不一样。
正确的标定流程其实很简单:
- 把 beacon 固定在开阔空间(走廊尽头或者停车场的空旷角落),旁边不要有金属物体。
- 手机装上任意一个能显示 RSSI 的 BLE 扫描 App,ESP32 这边可以把扫描后的 RSSI 通过串口打印出来,两边对照着来。
- 在 1 米处测量 30 秒,取平均 RSSI,绝对值就是 A 值。我实测过某品牌的 beacon,A 值是 -56,跟默认 -59 差了 3 个 dB——这个差值在 10 米处会变成至少 1.5 米的距离误差。
- 然后在 5 米、10 米处分别测量,用公式反推 n 值:[ n = \frac{A - RSSI_{dist}}{10 \cdot \log_{10}(dist)} ] 多做几个距离点取平均,n 值就出来了。
讲个实操细节:标定的时候最好人离开测量路径。人体含有大量水分,对 2.4GHz 信号吸收非常严重,你站在测量设备和 beacon 之间,RSSI 瞬间掉 5~8 个 dB,标出来的数据全报废。
4.3 距离换算和场景修正
滤波后的 RSSI 和标定好的参数都齐了,就可以正式算距离:
#include <math.h> float rssi_to_distance(float rssi, float a, float n) { return powf(10.0f, (a - rssi) / (10.0f * n)); }这里有个不少人会忽略的问题:这个公式在近距离(低于 0.5 米)会严重失真。理论上 RSSI 在 1 米处就饱和了,再近信号强度基本不变,但公式计算出来的距离会趋向 0 甚至负数(对数函数穿模)。所以工程上要做输出限幅,比如:
if (distance < 0.3f) distance = 0.3f; if (distance > 50.0f) distance = 50.0f;另外一个常见修正场景是带符号问题。有些代码写RSSI = abs(rssi),然后距离公式变成pow(10, (abs_rssi - a) / (10*n)),这和上面的写法是等价的,但如果你混着用就完蛋了——一会儿带负号一会儿不带,算出来的距离差出好几倍。我的建议是全程保留 RSSI 的 dBm 带符号值,写进日志里也直观,别给自己添乱。
4.4 多 beacon 定位时怎么用距离值
简单说下多 beacon 的场景。单个 beacon 只能告诉你"距离某个点大约多远",但有了 3 个以上 beacon 的距离,就可以做三角定位。ESP32 作为接收端,每收到一个 beacon 的广播包,就更新一次对应 beacon 的距离,然后按距离排序,取最近的 3 个做三边测量。核心公式是解方程组:
[ (x - x_i)^2 + (y - y_i)^2 = d_i^2 ]
不过实际工程里,最小二乘比直接解方程组更抗干扰。RSSI 测出来的 d_i 误差很大,直接解方程组往往无解,最小二乘可以求一个"误差平方和最小"的坐标。这套算法在 ESP32 上跑没有性能压力,但要考虑收敛速度和浮点精度。
如果距离数据还是不够稳定,最后一招是查表修正——实测几个固定距离点(1 米、2 米、3 米、5 米、8 米),把计算距离和实际距离做成映射表,中间用线性插值。这招牺牲了一点通用性,但换来的精度提升非常可观,尤其在环境固定的室内场景下,比调公式参数更直接。
5. 调试中常见的坑与实测经验
5.1 扫描不到设备的排查清单
扫描不到 beacon 是最常见的问题,通常集中在三个层面。
第一是广播类型匹配问题。beacon 一般用的是可连接广播或不可连接广播,ESP32 的扫描接口不做类型过滤时全部能收到。但是如果 beacon 配置成了定向广播,只针对特定 MAC 地址,那 ESP32 就收不到了。排查方法:用手机上的 BLE 扫描 App 先确认 beacon 是不是在正常广播,如果手机也看不到,那铁定是 beacon 端配置问题。
第二是协议栈没初始化成功。ESP-IDF 的蓝牙初始化失败时往往不会打印明显错误,而是直接跳过扫描回调。我在代码里加了两个防护——初始化后立刻用日志打印esp_bt_controller_get_status()的状态,扫描启动后再打印esp_ble_gap_get_scan_status(),两个状态确认无误才继续后面的逻辑。
第三是扫描窗口和间隔配得比例太低。scan_window远小于scan_interval时,扫描器大部分时间在"睡觉",非常容易漏掉瞬间飞过的广播包。beacon 的广播间隔一般是 100ms~1s,如果你扫描窗口只有 10ms,一个广播包发过来你没在听就是没在听,错过就没了。把窗口比例提到 50% 以上能明显改善漏包。
5.2 距离跳变严重时的调参顺序
距离结果忽远忽近,别一上来就怀疑算法,按下面这个顺序排查:
- 确认 RSSI 本身有多稳。串口打印滤波前的原始 RSSI,如果原始值就在 3dBm 以内波动,那滤波没问题,问题在公式参数。
- 复测 A 值。A 值偏了 1 个 dB,10 米处距离误差约 23 厘米(n=2 时),这个系统性偏差肉眼可见。
- 调整 n 值。如果近距离基本准、远距离系统性偏大或偏小,那就是 n 的问题。偏大说明环境衰减比预期的严重,把 n 调大 0.3~0.5 再试。
- 检查周围有没有 WiFi 路由器或微波炉。2.4GHz 频段是公共频段,WiFi 和微波炉都会干扰 BLE 信号。如果现场 WiFi 流量特别大,RSSI 底噪会被抬高,这种情况神仙算法也救不了,只能考虑换信道或者加信号均值窗口。
5.3 数据格式相关坑位整理
我整理了调试期间踩过的几个数据格式坑,做成速查表方便大家对照。
| 现象 | 根本原因 | 解决办法 |
|---|---|---|
| 解析出的 UUID 全是 0xFF | 没按 TLV 遍历,直接用固定偏移读数据 | 改用 while 循环遍历 AD structure,先找 0xFF 段 |
| Major/Minor 数值和配置对不上 | 大小端字节序搞反了 | 用(p[16]<<8) | p[17]的方式拼接 |
| RSSI 一直是一个固定值不变化 | 开启了scan_duplicate去重 | 把scan_duplicate设为BLE_SCAN_DUPLICATE_DISABLE |
| 距离经常跳出 100 米 | 没做输出限幅,公式在近饱和区失真 | 加上最小/最大距离限幅 |
| 多台 beacon 的距离互相串 | 滤波状态共用了一个变量 | 按 MAC 地址区分每个 beacon 的滤波器 |
5.4 实测数据参考
最后放一组我在普通办公室实测的数据供大家参考。条件:过道宽度约 2 米,两侧有工位隔断,beacon 放在过道尽头,接收端沿过道后退测量。
| 实际距离 (m) | 原始 RSSI (dBm) | EMA 滤波后 (dBm) | 计算距离 (m) |
|---|---|---|---|
| 1 | -57 | -58 | 1.1 |
| 3 | -66 | -65 | 2.8 |
| 5 | -72 | -73 | 5.4 |
| 8 | -79 | -78 | 8.2 |
| 12 | -85 | -86 | 12.9 |
标定的 A 值是 59,n 值 2.3。整体误差在 1 米以内,5 米内表现最好,超过 10 米后波动明显加大。如果你的需求精度要求更高,建议在同一位置放置多个 beacon 接收或者做时间维度的多次采样再取平均,效果比单纯调参数强。
6. 个人心得:几个有用的扩展方向
这个 beacon 测距工程做到后期,我已经不满足于"测个距离"了。几个扩展方向分享给你。
一是结合 ESP32 的低功耗模式做电池供电的定位标签。beacon 扫描的功耗大头在射频接收,如果做成"扫 5 秒休眠 25 秒"的占空比模式,用一块 18650 电池供电能跑大半年。ESP32 的深度睡眠加定时唤醒非常成熟,这套机制对我们后面做资产追踪项目帮了大忙。
二是把距离值通过 MQTT 上报到服务器做进入/离开检测。beacon 测距本身不产生业务价值,但一旦连上网,把距离数据传到后端,就可以做很多事了——比如展柜前游客停留时间统计、仓库人员禁区告警、养老院老人房间定位。这正好接上"联网篇"的脉络,ESP32 的 WiFi 和 BLE 是同时工作的,测距离的同时把数据传出去完全无压力。
三是注意 beacon 的电池寿命管理。市面很多 beacon 用 CR2032 纽扣电池,广播功率 0dBm、广播间隔 100ms 的话,大约能撑 6~12 个月。如果项目规模超过 50 个 beacon,务必提前规划电池更换策略,否则后期运维成本会超出你的想象。我的做法是在 beacon 选择上优先挑支持远程配置广播功率和间隔的型号,比如 nRF51822 方案的(不过市面上更多的是 TI CC2541 / DA14580 方案的),前期省 20% 电量,后期省 80% 的腿脚功夫。
最后的最后,再分享一个调试小技巧:在解析代码里加一个"原始广播帧打印"开关。平时关掉,遇到解析数据对不上时,打开它把收到的完整十六进制广播帧打出来,和协议文档逐字节对照。我见过太多人对着"解析成乱码的 UUID"抓瞎半天,其实就是因为少了这步——协议这东西,纸上谈兵永远发现不了字节序的魔幻。