ESP32 BLE Beacon 测距实战:从 RSSI 到距离估计的完整实现
1. 测距需求从哪来为什么偏偏选 BLE beacon先说清楚这个项目解决什么问题。ESP32 这颗芯片最吸引人的地方就是同时带 WiFi 和双模蓝牙市面上大量室内定位、展馆导览、养老院防走失、仓库物资追踪的项目本质上都是靠蓝牙 beacon 完成的。我不需要让手机跟设备建立连接只要让设备不断向外广播一段数据接收端拿到广播包里的 RSSI信号强度指示值再套一个经验公式换算就能估出大概距离。这就是 beacon 测距最朴素也最实用的玩法不需要额外加 UWB 硬件成本极低一颗几块钱的 ESP32 就能覆盖很大一片区域。这一讲是联网篇的第六讲前面已经搞定了 WiFi 连接、HTTP、MQTT 这些偏通讯协议的内容这次回到蓝牙侧。其实 WiFi 也能测距但 RSSI 波动比蓝牙还厉害而且 BLE 广播的功耗远比 WiFi 扫描低接收端代码也简单得多。如果你做的是蓝牙网关、手环定位、室内导航这类项目beacon 测距是性价比极高的起步方案。本文的代码基于 ESP-IDF v5.x开发环境是 VSCode 配合官方 ESP-IDF 插件工程结构、API 用法我会一步步拆开讲保证你能直接照着抄。提示beacon 测距的精度通常是米级想做到厘米级不太现实。先摆正预期它解决的是大概在哪个区域、离信标多远的问题不是精确坐标定位。2. 方案选型与整体设计思路2.1 为什么不用经典蓝牙或 WiFi经典蓝牙BR/EDR传输速率高但配网流程繁琐手机端要先扫描配对更适合传输音频或大数据包不适合做被动感知。WiFi 扫描也能拿到 AP 的 RSSI不过 WiFi 扫描一次要几百毫秒信号抖动明显而且很多 ESP32 工程里 WiFi 和蓝牙会抢占射频资源同时跑会出现吞吐率下降。BLE beacon 广播模式正好卡在中间广播间隔可以做到 100ms接收端不做任何连接动作代码量极少响应快。从功耗角度看beacon 广播端用 ESP32 其实有点浪费如果只是发广播包nRF52832 或 ESP32-C3 更合适。但如果你本来就要在 ESP32 上做网关接收端和广播端共用同一套编译工具链不需要额外学一套平台项目周期能压缩不少。我个人的建议是原型验证用 ESP32 完全没问题量产时再把纯广播端换成更便宜的 BLE SoC软件侧只要照搬广播配置即可。2.2 根据应用场景拆解需求接项目前先回答三个问题再开始写代码广播端与接收端是一对一还是多对多。一对一可以直接在接收端写死设备地址过滤多对多场景则需要给每个 beacon 分配不同的 UUID 或 Major/Minor 值接收端做分类识别。距离范围要求。室内走廊里面3 到 5 米范围内识别信标就够了停车场这种大空间可能要求能测到 10 米左右。测距范围直接决定广播发射功率和接收端扫描窗口的配置。信号更新频率。做防走失是希望能及时预警更新频率越快越好做资产盘点10 秒更新一次也无所谓。广播间隔越短扫描端拿到的样本越多滤波效果越好功耗也会上升。这三个问题确定好了后面的具体参数就有的放矢不会出现写完一版代码再去凑参数的尴尬。3. 开发环境准备VSCode 与 ESP-IDF 的常见坑3.1 VSCode 离线安装 ESP-IDF 插件时的目录问题很多新手卡在环境上特别是网络条件不好的时候离线安装 ESP-IDF 插件会遇到一个现象明明在插件设置里选了 ESP-IDF 的安装路径实际运行时工具链还是往 C 盘 User 目录下的 .espressif 里塞。这是因为离线安装包里把工具链路径写死到了用户目录插件没有权限去移动已经释放的文件。我的处理办法是先让插件以默认方式安装完所有组件确认环境变量 IDF_PATH 指向的路径正确然后直接把整个 .espressif 文件夹剪切到 D 盘再手动修改系统环境变量 IDF_PATH 和 PATH 里的对应路径。改完后打开 VSCode 命令面板执行 ESP-IDF: Doctor 检查看到所有组件都打勾就成功了。3.2 创建工程前必须确认的版本信息打开 VSCode按 F1 输入 ESP-IDF: Show Examples Projects随便挑一个例程编译。这一步不是为了看代码而是确认组件缓存和 Python 环境正常。如果例程编译报错先检查 VSCode 终端里能不能直接执行 idf.py。执行不了多半是环境变量没生效重启 VSCode 再试。这些基础问题处理干净再创建自己的工程不然排查问题时很难分清是你代码的问题还是环境的问题。创建工程建议直接用 ESP-IDF: Create Project from Template选择 hello_world 模板命名成 esp32_ble_beacon。模板会帮你把 CMakeLists 和 sdkconfig 都建好比自己从零写省很多事。4. beacon 广播协议与 RSSI 测距原理4.1 iBeacon 广播包结构拆解beacon 技术最普及的标准是苹果的 iBeacon它本质上是把一段特定格式的数据塞进 BLE 广播帧的 Manufacturer Specific Data 字段。广播包的负载结构大致如下字段长度字节说明广播类型标志302 01 1A表示 BLE 通用广播、仅广播模式Manufacturer ID20x004C苹果的公司标识iBeacon 类型与长度202 15表示后面是 21 字节 iBeacon 数据UUID16应用标识同一项目共用一个 UUIDMajor2区域分组比如表示楼层Minor2信标编号比如表示第几号信标TX Power1距离发射器 1 米处的 RSSI 校准值接收端拿到广播包后先校验 UUID 是不是自己的再读 Major、Minor 区分具体信标最后取出 TX Power 和实时 RSSI 做距离换算。这个协议本身就是开放的不用非得买苹果的认证模块ESP32 上完全可以自己组包发送。4.2 TX Power 校准值和环境衰减系数怎么定距离换算的核心公式是d 10 ^ ((A - RSSI) / (10 * n))这里面 A 表示距离发射器 1 米时测到的 RSSI 值n 是环境衰减因子。自由空间里 n 约等于 2.0室内有墙壁、金属障碍物时 2.5 到 4.0 都很常见。A 值不要照着网上的例子抄因为不同开发板的射频前端和天线设计差别很大同一块板子发射功率配置不同也会影响 A。正确做法是把板子放到距离接收端正好 1 米的位置持续采样 1 分钟取平均值作为 A。实测下来ESP32 开发板在 0 dBm 发射功率、空旷环境下的 A 值通常在 -55 到 -65 dBm 之间。n 值更需要实测率定。方法是在 1 米处记录 RSSI再走到 5 米位置记录 RSSI代入公式反推 nn (A - RSSI_5m) / (10 * lg(5))比如 A 是 -595 米处平均 RSSI 是 -78那 n (-59 - (-78)) / (10 * 0.699) 2.72。这就是这个环境的衰减系数配置进代码里测距就准得多。4.3 原始 RSSI 为什么抖动厉害先做滤波RSSI 受多径效应、人体遮挡、WiFi 同频干扰影响单次采样值波动可以到 10 dBm 以上直接套公式算出来就是两三米之间的跳变体验极差。解决方法分两级一是滑动窗口均值缓存最近 20 个 RSSI 样本求平均二是一阶低通滤波每次新值进来按比例混合。两级叠加以后距离输出基本稳定在 0.5 米以内的波动。我习惯用动态窗口RSSI 突变超过 8 dBm 时自动清空窗口避免大幅跳变被平均到新的真实位置。5. 广播端代码实现细节5.1 芯片初始化和蓝牙协议栈启动在 ESP-IDF 下跑 BLE第一步是初始化控制器和协议栈。核心代码如下esp_err_t ret; esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT); esp_bt_controller_config_t bt_cfg BT_CONTROLLER_INIT_CONFIG_DEFAULT(); ret esp_bt_controller_init(bt_cfg); if (ret ! ESP_OK) { ESP_LOGE(TAG, bt controller init failed: %s, esp_err_to_name(ret)); return ret; } ret esp_bt_controller_enable(ESP_BT_MODE_BLE); if (ret ! ESP_OK) { ESP_LOGE(TAG, bt controller enable failed: %s, esp_err_to_name(ret)); return ret; } ret esp_bluedroid_init(); if (ret ! ESP_OK) { ESP_LOGE(TAG, bluedroid init failed: %s, esp_err_to_name(ret)); return ret; } ret esp_bluedroid_enable(); if (ret ! ESP_OK) { ESP_LOGE(TAG, bluedroid enable failed: %s, esp_err_to_name(ret)); return ret; }特别注意第一行ESP32 默认是双模蓝牙同时初始化 BR/EDR 和 BLE 会占用更多 RAM。只跑 BLE 时先调用esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT)释放经典蓝牙的内存后续运行才更稳定。API 里的 BT_CONTROLLER_INIT_CONFIG_DEFAULT 宏默认参数就可以不太需要手动改模式。5.2 组 iBeacon 广播包的正确姿势组包最容易出错的地方是字节序。UUID 是 16 字节的大端序Major 和 Minor 是大端序存储TX Power 是有符号数。有人说为什么广播出去手机收到的数据不对十有八九是结构体打包时字段对齐或字节序搞错了。我建议直接用 uint8_t 数组手动填不放结构体uint8_t adv_data[30] { 0x02, 0x01, 0x1A, // flags: 支持 BLE 通用发现 0x1A, 0xFF, 0x4C, 0x00, // manufacturer specific, Apple Company ID 0x02, 0x15, // iBeacon type and length // 16-byte UUID 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, // Major, big-endian 0x00, 0x01, // Minor, big-endian 0x00, 0x02, // TX Power, 1m RSSI reference, signed (uint8_t)(-59 0xFF) };设置广播数据时还有一步很容易忽略ESP-IDF 要求先配置原始广播数据再单独配置扫描响应数据然后开始广播顺序不能反。如果主广播数据放不下完整信息可以把设备名称放到扫描响应数据里这样既能被发现又不会超出广播包 31 字节的限制。esp_ble_gap_config_adv_data((uint8_t *)adv_data, sizeof(adv_data)); // 如果需要扫描响应数据再调用 esp_ble_gap_config_scan_rsp_data_raw()5.3 广播参数与发射功率调优广播间隔直接决定接收端采样频率。我一般设为 100ms功耗和实时性平衡得比较好。紧急定位场景可以压到 40ms长期部署建议 200ms 以上。广播通道默认 37/38/39 三个都发接收端才能在不同环境选择最优通道。发射功率对应寄存器值ESP32 的 BLE 支持 0 dBm 到 9 dBm 几档esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, ESP_PWR_LVL_P3);设置发射功率后前面说的 TX Power 校准值也要重新标定因为不同档位下 1 米处的 RSSI 不一样。这里的 P3 等级大约是 0 dBm如果你的板子天线增益差建议直接测试后在代码里写死对应的 A 值。6. 接收端扫描与测距实现6.1 GAP 事件驱动模型的思考方式BLE 扫描是典型的事件驱动模型初始化回调后扫描结果都在回调函数里处理。这种写法跟普通顺序执行代码不一样容易让新手懵。写回调函数时脑海里要有一条事件线注册回调 - 设置扫描参数 - 启动扫描 - 收到扫描结果事件 - 从结果里提取 RSSI 和广播数据 - 保存/处理 - 扫描停止事件 - 再启动扫描。手动暂停再重启扫描是为了防止扫描参数失效。有些 ESP-IDF 版本里一次扫描周期结束后如果不重新设置参数后续扫描结果可能不回调代码稳定性就差了。所以我在扫描停止事件里直接再调一次esp_ble_gap_start_scanning形成循环扫描。6.2 扫描参数与回调处理实用代码扫描参数里scan_type设成被动扫描PASSIVE我们不主动发扫描请求省电且不影响 beacon 广播端own_addr_type用公网地址省去地址解析的复杂度。核心代码如下static esp_ble_scan_params_t ble_scan_params { .scan_type BLE_SCAN_TYPE_PASSIVE, .own_addr_type BLE_ADDR_TYPE_PUBLIC, .scan_filter_policy BLE_SCAN_FILTER_ALLOW_ALL, .scan_interval 0x50, .scan_window 0x30, .scan_duplicate BLE_SCAN_DUPLICATE_DISABLE };回调函数里重点关注ESP_GAP_BLE_SCAN_RESULT_EVT事件类型为ESP_GAP_SEARCH_INQ_RES_EVT时数据在param-scan_rst结构体里case ESP_GAP_BLE_SCAN_RESULT_EVT: if (evt_param-scan_rst.search_evt ESP_GAP_SEARCH_INQ_RES_EVT) { esp_bd_addr_t addr evt_param-scan_rst.bda; int8_t rssi evt_param-scan_rst.rssi; uint8_t *adv evt_param-scan_rst.ble_adv; uint8_t len evt_param-scan_rst.adv_data_len; // 在这里解析 adv 数据提取 iBeacon 字段并记录 rssi } break;一个重要经验默认的免费扫描回调拿到的ble_adv地址空间在事件结束后就无效了如果要跨事件保存广播数据必须memcpy到全局缓冲。很多内存踩踏问题就是这么来的。6.3 距离换算与动态滤波完整实现查到 iBeacon 广播数据中的 TX Power即 A 值结合当前 RSSI就能算出距离。完整处理函数typedef struct { int8_t rssi_history[20]; uint8_t history_len; int8_t filtered_rssi; } beacon_filter_t; static int32_t last_rssi_average 0; static float calc_distance(int8_t tx_power, int8_t rssi, float env_factor) { int8_t raw_rssi rssi; // 滑动滤波 int32_t sum 0; // 假设 circular 缓冲存储最新的20个值 for (int i 0; i filter.history_len; i) { sum filter.rssi_history[i]; } filter.filtered_rssi (int8_t)(sum / filter.history_len); float ratio (float)(tx_power - filter.filtered_rssi) / (10.0f * env_factor); return powf(10.0f, ratio); }实际使用时滤波窗口和衰减系数我都做成 menuconfig 可配置项这样在调试现场不用改代码重编译直接idf.py menuconfig就能调。写死在代码里的版本每改一次环境参数就要烧一次固件相当浪费时间。7. 实测数据与参数调优记录7.1 不同距离下的 RSSI 样本统计我在一条普通室内走廊里做过一组测试发射端固定接收端分别在 1m、3m、5m、8m 位置采样每个点位收 200 个包。统计结果如下实际距离m平均 RSSIdBm最小 RSSI最大 RSSI标准差1-56-51-642.83-70-63-824.25-78-70-905.68-88-80-986.3对应算出来环境衰减系数 n 大约在 2.9 到 3.4 之间比纯空旷环境大不少主要是走廊两侧是金属材质墙体反射比较明显。肉眼可见的规律是距离越远RSSI 方差越大测距误差也越大。所以我不建议在超过 10 米的场景继续用这个方案误差已经到 3 米以上参考意义不大。7.2 同环境不同位置的误差放大效应把同一组参数带到另一个房间测试发现 1 米和 3 米处误差不大5 米后误差明显增大。原因很直接环境变了n 值和多径条件都变了还用原来的固定系数自然不准。工程上可以采用查表法把 1 到 10 米每隔 1 米在目标点位标定一组 (RSSI, distance) 对应值估计距离时直接查表插值。这个方法规避了固定公式对环境的敏感代价是部署初期要多花半小时做标定。7.3 发射功率与测量精度的关系发射功率调高后接收端测到的 RSSI 绝对值整体抬升信噪比变好远距离波动也明显减小。我把发射功率从 0 dBm 调整到 6 dBm8 米处的标准差从 6.3 dBm 降到了 4.1 dBm测距稳定性提升了一个档次。代价是功耗上升电池供电场景需要综合权衡。调试阶段我建议直接把发射功率拉到最高先把算法逻辑跑通最后再根据实际功耗和距离需求降档。8. 常见问题与排查技巧实录8.1 iBeacon 数据手机能看到但自己收不到这是一个典型问题。手机蓝牙扫描能发现 beacon但 ESP32 接收端一直不打印广播内容。先检查扫描去重策略scan_duplicate如果设成BLE_SCAN_DUPLICATE_ENABLE同一个设备短期内只回调一次而 beacon 几乎一直在广播所以看起来像是收不到。调成BLE_SCAN_DUPLICATE_DISABLE并手动控制扫描周期即可。另一个可能原因是广播数据没走 iBeacon 格式手机端 app 自动适配了其他协议代码里就得重点检查 Manufacturer ID 和 type 字段是否匹配。8.2 RSSI 波动巨大距离显示像抽风波动大不一定是代码问题先确认供电。ESP32 开发板用劣质 USB 线供电时射频前端电压不稳RSSI 很容易出现整体漂移。换一根短线或者改用 5V 电源适配器波动通常能缓解一大半。其次看天线方向ESP32 的 PCB 天线对方向敏感板子旋转 90 度RSSI 能差 6 dBm 以上测试时必须固定板子朝向。8.3 内存不足导致随机重启蓝牙协议栈默认占用的 RAM 不少如果还在同一个工程里开了 WiFi 或大缓冲区编译链接时会提示内存溢出。用esp_bt_mem_release系列 API 释放不用模块别忘了我前面说的esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT)仅此一项就能省出 30KB 左右的 RAM。还有一点如果关闭 BLE 的蓝牙 HCI 日志和调试打印也能省一部分内存。8.4 回调里做耗时操作导致扫描异常回调函数里做串口打印、浮点运算、甚至是外部 flash 写入都会阻塞蓝牙协议栈的处理线程轻则丢包重则触发看门狗复位。正确的做法是回调里只做数据拷贝和计时把 RSSI 滤波、距离计算、上报逻辑全部放到主循环或独立任务里。我习惯在回调里用环形队列缓冲扫描结果主循环定时拉取处理实验证明这样的架构在多个 beacon 同时广播时也稳定。8.5 多 beacon 同时广播时如何区分设备多 beacon 场景下接收端拿到大量广播包区分方式有两种按广播数据里的 UUID Major Minor 区分这是标准做法。按 MAC 地址区分但如果两个 beacon 用了同一个 MAC就会冲突需要手动修改。在代码层面我用一个结构体表记录每个 beacon 的最新 RSSI 和滤波状态收到广播包时先查表没有就动态添加。这样接收端就能同时跟踪十几个 beacon 的距离变化测试时在串口助手配合下面的参数一秒输出一次盯一段时间就能看出各路信标的距离变化趋势。9. 写在最后的调试心得这个项目做到最后最大的体会是蓝牙 beacon 测距的方案并不复杂它的核心难点不在 RF 原理而是在工程细节上。环境校准、滤波策略、设备地址冲突、回调时序关系每一样都能让最终效果相差很远。如果是第一次做建议先用手机下载一个 BLE 扫描工具把你要用的 A 值和 n 值亲手测出来再往代码里填。不要拿着网上的现成参数就跑不同天线性能和周围环境会让结果完全不可信。调试时串口助手配合蓝牙扫描工具双开一边看代码输出一边对着手机实时数据问题定位会快很多。另外有朋友问我能不能把 beacon 测距跟前面的 WiFi MQTT 上报串起来做一个上报到服务器的室内定位 demo。完全可以接收端把解析出的 Major、Minor、距离通过 MQTT push 到云端就行整体架构也就增加一个发布函数后面如果大家感兴趣我再开一篇专门讲数据上云和可视化呈现。