ESP32蓝牙beacon测距实战:RSSI衰减模型与滤波算法详解
做蓝牙beacon测距这个选题是我在完成ESP32联网系列前面几讲之后临时插进来的。原因很简单连着有三位读者在后台问“能不能用ESP32做室内定位”“两个ESP32怎么测距离”而且都提到了iBeacon。这确实是个实用且容易上手的场景——不需要额外硬件一块ESP32开发板就能既当信标广播又能做扫描接收端。与其零散地回消息不如把这一讲完整写出来。我会直接把发送端和接收端的代码都拆开讲把距离计算背后的信号衰减模型也说明白最后附上我在实际环境里标定出来的参数和踩过的坑。无论你是准备做一个防丢器、展馆导览还是想初步验证蓝牙定位的可行性这一篇应该能帮你少走不少弯路。1. 为什么拿蓝牙beacon做测距成本、精度与场景的三方权衡先说个反直觉的结论蓝牙beacon测距的绝对精度并不高理想环境下能到1到3米就不错了复杂室内环境里5米误差也很常见。但这并不妨碍它在很多实际项目里被大规模使用。原因就俩字便宜。一个beacon硬件几块钱到几十块钱手机和绝大多数物联网设备原生支持BLE不需要额外网关部署一个房间几十个点位的成本也远比UWB或者激光方案低。1.1 三种常见近距离测距方案的选型对比方案典型精度成本量级功耗抗多径能力适用场景蓝牙beacon RSSI1~5米极低极低较弱室内定位、导览、防丢、 proximity 检测UWB10~30厘米高较高强车钥匙、高精度定位、门禁WiFi RTT1~2米中较高中等室内导航、资产跟踪UWB精度是高但一颗UWB芯片的成本是ESP32的好几倍更别说配套的天线和调试时间。WiFi RTT依赖路由器和AP支持部署时限制比较多。相比之下ESP32自带BLE一个几十块的开发板就能完成信标和扫描的双重角色方案原型验证的成本几乎可以忽略。这是我在实际项目中最终选择beacon方案的核心原因。1.2 beacon测距真正适合的业务场景从我的经验看beacon测距最成熟的落地点不在“精确测距”而在“区域判定”。比如盲道导航里的路口提示、博物馆里走到某个展品附近自动触发语音讲解、仓库里判断货物是否接近出库口。这些场景都不需要知道“具体距离是2.3米还是2.8米”只需要判断“是不是进入了某个半径范围”beacon的精度绰绰有余。另外beacon测距常和信号滤波算法搭配使用。RSSI原始数据抖动非常大同一位置静止时波动可以达到10dB以上换算成距离可能差出一倍。后面我会专门讲怎么标定参数、怎么做平滑滤波。这些内容在实际项目里比协议本身更影响最终体验。2. iBeacon数据包结构与RSSI衰减模型算距离前必须搞懂的两件事使用beacon测距核心是理解两个问题beacon广播的数据里到底带哪些信息以及RSSI值如何换算成距离。前者决定你能否解析出有效数据后者决定你算出来的距离有没有参考价值。2.1 iBeacon广播包逐字节拆解一个标准的iBeacon广播包是BLE广播中常见的ADV_IND类型完整数据如下02 01 1A 1A FF 4C 00 02 15 UUID (16字节) Major (2字节) Minor (2字节) Tx Power (1字节)前面三组是固定的。02 01 1A表示长度2字节、类型为Flags、值为1A代表LE General Discoverable Mode 和 BR/EDR Not Supported。1A FF 4C 00 02 15表示长度26字节、类型为Manufacturer Specific Data厂商ID是4C 00Apple后面跟着iBeacon的标识02 15。接下来是核心业务字段16字节的UUID用于区分你的beacon属于哪个应用或哪个项目Major和Minor各2字节通常用于区分区域编号和具体点位编号比如Major1表示1楼Minor101表示1楼第101号设备。最后一个字节是Tx Power表示距离beacon 1米处测得的RSSI参考值。2.2 对数距离路径损耗模型RSSI测距的数学基础是无线电信号在自由空间中的衰减特性。电磁波在传播过程中能量会随距离增加而衰减工程上常用对数距离路径损耗模型来拟合RSSI A - 10 * n * lg(d)其中A是距离1米处的信号强度参考值就是iBeacon里的Tx Power字段n是路径损耗指数d是距离。反过来由RSSI推算距离的公式就是d 10 ^ ((A - RSSI) / (10 * n))n的取值很关键。理想自由空间里n2室内有遮挡和反射的环境里n通常在2.5到4之间。走廊环境可能接近2.3摆放密集家具的办公室可能到3.5以上。这个值必须实测标定不能套用别人的参数否则误差会大得离谱。2.3 A值和n值的标定方法标定流程并不复杂。找个比较空的开阔场地把beacon固定在一个高度我习惯1.2米模拟桌面高度接收端分别放在1米、2米、3米、5米、8米、10米处采集RSSI每个点采200个样本取平均值。得到六组数据后带入公式反推。可以用Excel做散点图添加趋势线选择对数拟合拟合出来的斜率就是10n截距就是A。我自己标定的一组典型数据是走廊环境A-59dBm、n2.3办公室环境A-62dBm、n3.1。后面实测时我就直接用这两个参数做距离换算效果比用网上的通用参数好很多。3. VSCode下ESP-IDF开发环境配置这一讲涉及的几个关键点ESP-IDF的开发环境搭建前面几讲已经详细说过这里不再重复。但既然这一讲要涉及蓝牙功能有几个环境层面的点必须单独提一下因为它们直接影响你能否顺利编译和烧录beacon相关代码。3.1 选择NimBLE协议栈而非BluedroidESP32的BLE协议栈在ESP-IDF里有两种选择Bluedroid和NimBLE。Bluedroid功能完整支持经典蓝牙和BLE同时使用但代码体积大、内存占用高。NimBLE是专为资源受限设备设计的开源BLE协议栈代码精简API风格也更现代。beacon功能只需要广播和扫描这些都是BLE最基础的能力用NimBLE完全够。在menuconfig里设置Component config - Bluetooth - Bluetooth controller - NimBLE同时关闭Bluedroid可以节省大约几十KB的RAM。对于需要同时跑WiFi和HTTP服务器的项目来说省下来的内存对稳定性很有帮助。3.2 编译过程中容易卡住的两个位置实际编译时新手最常遇到两个问题。第一个是找不到idf.py。VSCode的ESP-IDF插件在创建工程时需要在命令面板里执行ESP-IDF: Set Expression来设置IDF_PATH环境变量。如果提示/tools/idf.py not found说明插件找不到ESP-IDF的安装目录。解决办法是在VSCode设置里搜索idf.espIdfPath手动指定到ESP-IDF的实际安装路径。第二个是Windows系统下路径中包含空格导致的报错。ESP-IDF默认安装在C:\Users\用户名\esp这类路径下没问题但如果你的用户名是中文或者路径中有空格部分工具链可能会发生意外。建议直接把ESP-IDF安装到类似D:\esp-idf这种纯英文短路径下能省很多事。3.3 离线安装插件时的一个隐藏坑有读者反馈过在VSCode中离线安装ESP-IDF插件后即使手动选择了安装路径espressif工具文件仍然装到了C盘。这是插件在初始化时强制使用默认目录的已知行为。解决思路是先让插件完成一次在线初始化哪怕网速很慢后续通过修改settings.json中的idf.toolsPath把工具链迁移到其他盘。实际项目中我推荐直接在命令行使用ESP-IDF的export.bat配合VSCode的终端使用这样环境变量的变更立即可见不容易出现IDE和终端环境不一致的问题。这一讲后续的代码示例在命令行终端里也能直接编译烧录。4. 发送端实现将ESP32配置为iBeacon广播器先看发送端。把一块ESP32开发板变成一个标准的iBeacon信标核心工作就是构造广播数据然后启动广播。整个过程大概只需要几十行代码适合作为理解NimBLE广播机制的最佳入门实践。4.1 广播数据的构造与UUID配置使用NimBLE API开发时直接按照前面介绍的iBeacon包结构填充数据即可。完整的广播数据构造代码如下#include string.h #include esp_log.h #include nvs_flash.h #include nimble/nimble_port.h #include nimble/nimble_port_freertos.h #include host/ble_hs.h #include host/util/util.h static const char *TAG beacon_adv; // 自定义UUID这里用ESP32官方例程中常见的测试UUID static uint8_t beacon_uuid[16] { 0xfd, 0xa5, 0x06, 0x93, 0xa4, 0xe2, 0x4f, 0xb1, 0xaf, 0xcf, 0xc6, 0xeb, 0x07, 0x64, 0x78, 0x25 }; static void start_ble_advertising(void) { struct ble_gap_adv_params adv_params; struct ble_hs_adv_fields fields; uint8_t beacon_data[25]; int rc; // 广播数据总长度为 2(flags) 2(长度类型) 2(厂商ID) 2(iBeacon标识) 16(UUID) 2(Major) 2(Minor) 1(Tx Power) uint8_t pos 0; beacon_data[pos] 0x02; // AD length beacon_data[pos] 0x01; // AD type: Flags beacon_data[pos] 0x1A; // LE General Disc. BR/EDR Not Supported beacon_data[pos] 0x1A; // AD length: 26 bytes following beacon_data[pos] 0xFF; // AD type: Manufacturer Specific Data beacon_data[pos] 0x4C; // Company ID (Apple) low byte beacon_data[pos] 0x00; // Company ID (Apple) high byte beacon_data[pos] 0x02; // iBeacon indicator beacon_data[pos] 0x15; // iBeacon indicator length memcpy(beacon_data[pos], beacon_uuid, 16); pos 16; beacon_data[pos] 0x00; // Major high byte beacon_data[pos] 0x01; // Major low byte (区域编号) beacon_data[pos] 0x00; // Minor high byte beacon_data[pos] 0x64; // Minor low byte (点位编号100号) beacon_data[pos] 0xC5; // Tx Power: -59 dBm 实际含义见下方说明 memset(fields, 0, sizeof(fields)); fields.flags BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP; fields.adv_data beacon_data; fields.adv_data_len sizeof(beacon_data); rc ble_gap_adv_set_fields(fields); if (rc ! 0) { ESP_LOGE(TAG, 设置广播数据失败 rc%d, rc); return; } memset(adv_params, 0, sizeof(adv_params)); adv_params.conn_mode BLE_GAP_CONN_MODE_NON; adv_params.disc_mode BLE_GAP_DISC_MODE_GEN; adv_params.itvl_min BLE_GAP_ADV_ITVL_MS(100); adv_params.itvl_max BLE_GAP_ADV_ITVL_MS(150); rc ble_gap_adv_start(BLE_OWN_ADDR_PUBLIC, NULL, BLE_HS_FOREVER, adv_params, NULL, NULL); if (rc ! 0) { ESP_LOGE(TAG, 启动广播失败 rc%d, rc); return; } ESP_LOGI(TAG, iBeacon 广播已启动UUIDfda50693-a4e2-4fb1-afcf-c6eb07647825, Major1, Minor100); }代码里Tx Power写的是0xC5也就是十进制的197但实际含义是带符号的-59dBm。这是因为iBeacon协议规定Tx Power用有符号数表示存在一个字节里。写数据时用二进制的补码形式扫描端解析时需要转成int8_t才正确。4.2 广播间隔与广播功率对测距的影响广播间隔是影响测距体验的重要参数。间隔越短扫描端收到的数据包越多RSSI也就越稳定但功耗相应上升。我用itvl_min100ms和itvl_max150ms的组合实际扫描时样本量足够做滤波电池供电场景下可以接受。广播功率同样影响RSSI的绝对值。ESP32的常规配置支持从-12dBm到9dBm的调节范围通过esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, ESP_PWR_LVL_P9)设置。当功率调高时同样距离下RSSI会更强。关键是广播功率必须与标定时的功率保持一致否则前面标定的A值就直接失效。4.3 完整的程序入口程序入口需要初始化NVS、NimBLE协议栈并完成事件回调的注册。代码结构如下void ble_store_config_init(void); static void on_sync(void) { start_ble_advertising(); } static void ble_host_task(void *param) { nimble_port_run(); nimble_port_freertos_deinit(); } void app_main(void) { int rc; ESP_ERROR_CHECK(nvs_flash_init()); nimble_port_init(); ble_hs_cfg.sync_cb on_sync; ble_hs_cfg.store_status_cb ble_store_util_status_rr; ble_store_config_init(); nimble_port_freertos_init(ble_host_task); }这里有一点值得展开on_sync回调会在协议栈与控制器同步完成后被调用表示BLE协议栈已经就绪。此时才能安全地启动广播。很多初次使用NimBLE的开发者在app_main里直接调用广播函数导致协议栈未就绪而启动失败这是一个很典型的坑。5. 接收端实现扫描beacon并实时计算距离接收端的逻辑更复杂一些但也是测距真正价值所在。核心流程分三步启动扫描、在事件回调里解析beacon广播包、将RSSI代入衰减公式计算距离。5.1 使用BLE_GAP_EVENT_DISC事件捕获广播NimBLE的扫描是在GAP层完成的通过ble_gap_disc_start启动周期性扫描广播包数据通过事件回调返回。关键代码static void print_uuid(const uint8_t *uuid, uint16_t len) { for (int i 0; i len; i) { ESP_LOGI(TAG, %02x, uuid[i]); } } static int beacon_scanner_event_handler(struct ble_gap_event *event, void *arg) { switch (event-type) { case BLE_GAP_EVENT_DISC: { struct ble_hs_adv_fields fields; int rc ble_hs_adv_parse_fields(fields, event-disc.data, event-disc.length_data); if (rc ! 0) { break; } // 提取RSSI值 int8_t rssi event-disc.rssi; // 查找厂商自定义广播数据 if (fields.mfg_data ! NULL fields.mfg_data_len 25) { uint8_t *mfg fields.mfg_data; // 检查是否为iBeacon格式厂商ID 4C 00后续为 02 15 if (mfg[0] 0x4C mfg[1] 0x00 mfg[2] 0x02 mfg[3] 0x15) { uint8_t uuid[16]; memcpy(uuid, mfg[4], 16); uint16_t major (mfg[20] 8) | mfg[21]; uint16_t minor (mfg[22] 8) | mfg[23]; int8_t tx_power (int8_t)mfg[24]; ESP_LOGI(TAG, 发现iBeacon: major%d minor%d rssi%d tx_power%d, major, minor, rssi, tx_power); } } break; } default: break; } return 0; }ble_hs_adv_parse_fields是NimBLE自带的广播字段解析函数它会自动拆分不同AD Type的字段并在fields结构体中给出对应的指针。注意mfg_data指向的厂商数据是从AD Structure的数据部分开始的不包含长度和类型字节所以数组下标需要从0开始计算。5.2 找到目标UUID后代入距离公式解析出tx_power和rssi后接下来就是距离计算。前面说过核心公式是d 10 ^ ((A - RSSI) / (10 * n))其中A从beacon包里读出tx_power即可n则需要根据环境配置。我这里把计算封装成一个函数static float calc_distance(int8_t tx_power, int8_t rssi, float n) { if (rssi 0) { return -1.0f; // 无效值 } // 使用原始RSSI直接计算不强制取绝对值 // tx_power作为1米处的参考信号强度 float ratio (float)(tx_power - rssi) / (10.0f * n); return powf(10.0f, ratio); }有一点要特别注意公式里的tx_power - rssi是有符号运算如果tx_power-59、rssi-70那么差值就是11而不是-11。只有当发射功率和接收RSSI都以同样的带符号格式处理计算结果才不会出错。我最初因为直接用powf(10, (abs(rssi)-abs(tx_power))/...)这种写法距离偶尔会出现负数的异常结果排查了很久才意识到是符号问题。5.3 多组RSSI样本的平滑滤波处理原始RSSI的抖动问题前面提过这里直接给出两种我在项目中实测有效的滤波方案。滑动窗口平均是最简单的方案#define RSSI_WINDOW_SIZE 20 float rssi_history[RSSI_WINDOW_SIZE]; int rssi_index 0; float rssi_sum 0; float rssi_filter_add(float new_rssi) { rssi_sum - rssi_history[rssi_index]; rssi_history[rssi_index] new_rssi; rssi_sum new_rssi; rssi_index (rssi_index 1) % RSSI_WINDOW_SIZE; return rssi_sum / RSSI_WINDOW_SIZE; }窗口大小选20左右对应约2到3秒的采样时间实践中既能有效平滑抖动又不会让距离更新显得太迟钝。如果你想追求更好的动态响应可以尝试一阶低通滤波static float rssi_filtered -65.0f; float rssi_lpf(float new_rssi, float alpha) { rssi_filtered alpha * new_rssi (1 - alpha) * rssi_filtered; return rssi_filtered; }alpha取0.3左右时平滑效果适中取0.1时数据非常平稳但响应慢适合静止场景取0.5以上则跟随性好但抖动依然明显。实际部署时建议把这两种函都实现根据业务动态调整平滑强度。6. 实测标定在不同环境里收集数据并调整衰减系数代码逻辑跑通之后真正的挑战才刚开始。RSSI的可靠性高度依赖标定。这节我会分享一次完整的标定数据包括我在走廊和办公室环境下的实测结果。6.1 增加Log输出辅助标定为了标定方便我在接收端代码里临时加入了以下日志输出ESP_LOGI(TAG, CALIB: dist_actual%d rssi%d filtered_rssi%.1f calc_dist%.2fm, actual_dist, rssi, filtered_rssi, calc_distance(tx_power, filtered_rssi, n));通过串口监视器记录下每个测试点的数据存成CSV格式。excel进行数据处理时比较方便。6.2 走廊环境标定结果测试条件走廊宽度约2米两侧是墙壁和少量金属门。beacon固定在1.2米高的三脚架上。接收端手持在1.2米高度正对beacon。每个距离点采集约200个RSSI样本取平均值。实际距离米平均RSSIdBm原始RSSI波动范围dBm1-59-55 ~ -632-66-61 ~ -713-71-66 ~ -765-78-72 ~ -838-84-79 ~ -8910-87-82 ~ -92从这组数据反推A接近-5910n约等于29所以n约等于2.9。有意思的是这个值比我预想的要高——原因是走廊虽然空旷但狭长空间导致的波导效应和地面反射反而加剧了小尺度衰落。用A-59, n2.9重算各点的误差控制在0.3到0.8米之间。6.3 家居与办公环境下的巨大差异同样的beacon放在普通办公室里数据立刻变了。会议室、工位隔断、金属文件柜都会反射无线电信号。实测在5米这个点上平均值达到-85dBm左右比走廊环境低了7dB。如果把走廊环境标定的参数用在办公室里计算出来的距离会偏大一倍以上几乎不可用。因此我的建议是项目部署前必须实地采集数据、实地标定A和nbeacon安装高度统一天线方向尽量一致接收端如果长期固定比如固定在墙上就以那个位置做标定条件允许的话部署完成后再用真实点位验证一轮距离误差。室内定位项目的前期踩点工作很大程度就是这些标定和验证省不掉。7. 踩坑记录天线方向、标准功率和扫描回调里的三个坑最后这部分是我在实际开发过程里最想分享的。代码本身并不难真正让调试过程变得曲折的往往是几个看起来不起眼的细节。7.1 天线方向导致的信号盲区ESP32开发板的天线是PCB天线通常做在板子边缘。如果你的板子竖直放置天线的辐射方向图并非全向而是出现明显的“苹果”形状——从天线侧面垂直方向辐射最强天线顶端和底部反而最弱。实测中同样是2米距离beacon天线垂直朝上时的RSSI约为-65dBm水平摆放时变为-72dBm垂直指向接收端时甚至掉到-78dBm。相差超过10dB对距离计算结果影响巨大。解决办法是统一固定方式所有beacon在实际部署时都保持相同的朝向并且尽量避免天线正对或背对接收端。包装外壳是塑料还是金属也会影响信号这项工作最好在最终外壳里完成。7.2 Tx Power的实际含义不是“发射功率”初学者最容易混淆的一点是iBeacon包里的Tx Power字段并不是实际的发射功率而是“距离1米处的参考RSSI值”。这两个概念差别很大。假设设备实际发射功率是4dBm1米处测得的RSSI是-60dBm那Tx Power字段就应该写0xC4-60。如果误把4dBm写入Tx Power接收端会把1米处信号强度理解为4dBm任何实际距离下的RSSI都没有4那么高最后计算出来的距离会大得离谱。正确的标定方式是把beacon放在距离接收端恰好1米的位置采集RSSI平均值把这个平均值写入Tx Power字段。这个过程最好在生产前完成因为每一台设备的射频性能略有差异。7.3 扫描回调里RSSI0的问题我在初版代码里踩过一个非常隐蔽的坑部分广播包的回调事件中event-disc.rssi会等于0。其实这是BLE协议栈在解析广播包时遇到部分信道数据不完整时的特殊表现。如果不对这个值做过滤计算距离时会出现一个异常大的值干扰滤波结果。解决方式是在解析RSSI后立即判断if (event-disc.rssi 0) { break; }只处理有效RSSI值的逻辑。实测中这种异常包大约占总数量的2%~5%不严重但不处理确实会影响滤波效果。作为收尾分享一个我后来在多个项目中反复使用的技巧接收端扫描到beacon后不要只显示单一距离值而是同时输出原始RSSI、滤波后RSSI和计算距离。这样在部署现场排查问题时能一眼看出是信号波动导致的短时异常还是设备安装位置本身就有问题。这一讲的内容到这里基本覆盖了beacon测距从原理到落地的完整链路剩下的就是拿一块ESP32开发板实际跑一遍然后根据你所在环境的数据去修正参数了。