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 字段。广播包的负载结构大致如下:
| 字段 | 长度(字节) | 说明 |
|---|---|---|
| 广播类型标志 | 3 | 02 01 1A,表示 BLE 通用广播、仅广播模式 |
| Manufacturer ID | 2 | 0x004C,苹果的公司标识 |
| iBeacon 类型与长度 | 2 | 02 15,表示后面是 21 字节 iBeacon 数据 |
| UUID | 16 | 应用标识,同一项目共用一个 UUID |
| Major | 2 | 区域分组,比如表示楼层 |
| Minor | 2 | 信标编号,比如表示第几号信标 |
| TX Power | 1 | 距离发射器 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,代入公式反推 n:
n = (A - RSSI_5m) / (10 * lg(5))
比如 A 是 -59,5 米处平均 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) | 平均 RSSI(dBm) | 最小 RSSI | 最大 RSSI | 标准差 |
|---|---|---|---|---|
| 1 | -56 | -51 | -64 | 2.8 |
| 3 | -70 | -63 | -82 | 4.2 |
| 5 | -78 | -70 | -90 | 5.6 |
| 8 | -88 | -80 | -98 | 6.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 dBm,8 米处的标准差从 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 到云端就行,整体架构也就增加一个发布函数,后面如果大家感兴趣我再开一篇专门讲数据上云和可视化呈现。