ESP32 BLE Beacon 测距实战:从 RSSI 到距离估计的完整实现
2026/9/12 12:57:39 网站建设 项目流程

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应用标识,同一项目共用一个 UUID
Major2区域分组,比如表示楼层
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,代入公式反推 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-642.8
3-70-63-824.2
5-78-70-905.6
8-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 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 到云端就行,整体架构也就增加一个发布函数,后面如果大家感兴趣我再开一篇专门讲数据上云和可视化呈现。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询