1. 这不是“蓝牙通信”,而是用信号强度做物理世界的尺子
很多人第一次看到“ESP32 蓝牙 beacon 测距”这个标题,下意识会以为是要让两个 ESP32 通过蓝牙互相传数据、发指令——这完全跑偏了。Beacon 测距的本质,是把蓝牙广播信号当成一把看不见的软尺,靠接收端捕获到的信号强度(RSSI)反推发射源距离。它不建立连接、不交换数据包、不握手确认,整个过程就是:Beacon 设备持续广播一个固定格式的短报文(比如 iBeacon 或 Eddystone 格式),你的 ESP32 作为扫描器,只负责“听”——听这个广播有多响,然后根据“越近越响、越远越弱”的物理规律,估算出大概距离。
我最早在做一个仓库资产定位项目时踩过坑:直接拿手机 App 扫描 ESP32 发出的 beacon,App 显示“距离 2.3 米”,结果拿卷尺一量,实际是 5.7 米。后来才明白,这个数字根本不是精确测量值,而是基于 RSSI 和预设的“参考距离 1 米处的信号强度”(即 TX Power)算出来的理论值。而 TX Power 在不同天线设计、PCB 布局、外壳材质甚至电池电量变化时,波动可能高达 ±8 dBm。换句话说,你看到的“2.3 米”,其实是“在当前环境、当前硬件状态下,最符合信号衰减模型的那个估算值”。它不是测绘级精度,但对“货架 A 区有设备靠近”、“工位附近有人停留超 3 分钟”这类场景,已经足够可靠。
这也是为什么标题里强调“ESP-IDF + VSCode”——因为 Arduino IDE 的 BLE 库对 beacon 广播参数控制太粗放,TX Power 基本固定,无法校准;而 ESP-IDF 提供了底层寄存器级的射频配置能力,配合 VSCode 的 IntelliSense 和调试支持,才能真正把 RSSI 数据采集、滤波、建模、映射这一整条链路闭环起来。你不是在写一个“能连上蓝牙”的程序,而是在搭建一套微型无线测距传感器系统。关键词里反复出现的 “vscode” 和 “esp-idf”,恰恰说明开发者需要的不是黑盒 API,而是可追溯、可调参、可验证的完整工具链。
2. 从广播帧结构开始:为什么 iBeacon 是工业现场的默认选择
Beacon 测距的起点,不是代码,而是空中飘着的那几十个字节。所有主流 beacon 协议(iBeacon、Eddystone、AltBeacon)都基于 Bluetooth LE 的 ADV_IND 广播包,但它们的 payload 结构决定了后续解析的难易度和兼容性。我们以 Apple 主导的 iBeacon 为例,它的广播数据格式是严格定义的:
| 字段 | 长度 | 示例值(十六进制) | 说明 | |--------------|------|---------------------|------| | Flags | 2 | 02 15 | BLE 标准标志位,固定 | | Company ID | 2 | 4C 00 | Apple 公司 ID | | Type | 1 | 02 | iBeacon 类型标识 | | Subtype | 1 | 15 | iBeacon 子类型 | | UUID | 16 | E2 C5 6D B5 DF FB 48 8A 90 13 C8 21 31 48 22 D7 | 128 位唯一标识符,区分不同 beacon 组 | | Major | 2 | 00 01 | 主分组号,如楼层号 | | Minor | 2 | 00 02 | 次分组号,如房间号 | | TX Power | 1 | C5 | 参考距离 1 米处的 RSSI 值(有符号整数,C5 = -59 dBm) |这个结构的关键在于TX Power 字段。它不是发射功率的绝对值,而是“当接收器距离发射器 1 米时,理论上应该收到的 RSSI 值”。比如C5(十六进制)=-59(十进制),意味着:如果把手机贴着 beacon 放,RSSI 应该接近 -59 dBm。这个值是 beacon 硬件出厂前用标准暗室标定的,也是后续测距公式distance = 10^((TX_Power - RSSI) / (10 * N))中的核心参数(N 是路径损耗因子,通常取 2~4)。如果你用 ESP32 自己发 beacon,却没手动设置这个 TX Power,ESP-IDF 默认会填一个保守值(比如 -59),但你的实际硬件可能因为天线效率高,1 米处 RSSI 实测是 -45 dBm——这会导致所有距离计算整体偏大 3 倍以上。
相比之下,Eddystone 协议虽然支持 URL 和 UID 等更灵活的 payload,但它的 TX Power 字段是可选的,很多开源实现直接忽略,导致测距模型失去基准。而 AltBeacon 作为开源替代方案,虽然结构类似 iBeacon,但 Company ID 是FF FF,兼容性不如 Apple 生态广泛。所以,在工业现场部署时,我坚持用 iBeacon 格式:一是手机、Windows、Linux 的 BLE 扫描工具都原生支持,排查问题不用额外装驱动;二是 UUID/Major/Minor 的三级编码体系,能直接对应到产线工位编号(如 UUID=产线A, Major=工位01, Minor=传感器01),运维人员看一眼日志就知道哪个点位异常;三是 TX Power 字段强制存在,倒逼你在固件里做真实标定。
提示:ESP-IDF 中设置 iBeacon 广播的 TX Power,不能只改
adv_data结构体里的字节。必须同步调用esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, ESP_PWR_LVL_N12)设置射频发射功率等级,并用esp_ble_gap_set_adv_params()配置广播间隔和信道。三者不匹配,你看到的 TX Power 字段值和实际空中信号强度就是两回事。
3. VSCode + ESP-IDF:如何让 RSSI 数据从“毛刺”变成“可用曲线”
在 VSCode 里写完esp_ble_gap_start_advertising(),烧录进 ESP32,用手机 App 扫到 beacon 是第一步;但真正进入实战,你会发现原始 RSSI 值像心电图一样疯狂跳变:-45, -62, -38, -57, -41…… 这不是代码 bug,而是 BLE 物理层的宿命。2.4 GHz 频段本就拥挤,Wi-Fi 路由器、微波炉、甚至隔壁工位的 USB 3.0 线缆都会造成瞬时干扰;再加上人体遮挡、金属反射造成的多径效应,单次 RSSI 误差 ±10 dBm 都算正常。VSCode 的价值,不在于写代码快,而在于它让你能像调试电路一样,实时观察、分析、修正这个“噪声中的信号”。
我的标准工作流是三步走:
第一步:用idf.py monitor抓原始日志,确认数据源头。
在gap_event_handler()里,当收到ESP_GAP_BLE_SCAN_RESULT_EVT事件时,不要直接打印scan_rst->rssi,而是把整个扫描结果结构体 dump 出来:
ESP_LOGI(TAG, "Beacon from %02x:%02x:%02x:%02x:%02x:%02x, RSSI=%d, AdvDataLen=%d", scan_rst->bda[0], scan_rst->bda[1], scan_rst->bda[2], scan_rst->bda[3], scan_rst->bda[4], scan_rst->bda[5], scan_rst->rssi, scan_rst->adv_data_len); if (scan_rst->adv_data_len > 0) { ESP_LOG_BUFFER_HEX("AdvData", scan_rst->adv_data, scan_rst->adv_data_len); }这样你能在串口日志里看到真实的广播数据十六进制流,对照 iBeacon 结构逐字节验证 TX Power 是否是你设定的值。我曾遇到一次诡异问题:日志显示 TX Power 是C5,但实测 1 米 RSSI 是 -42 dBm。最后发现是 PCB 天线馈点焊接虚焊,导致射频能量泄漏,实际发射功率比标称高 13 dBm——这种硬件级问题,只有看到原始广播帧和实测 RSSI 的偏差才能定位。
第二步:在 VSCode 里启用Cortex-Debug插件,对 RSSI 数组做内存快照。
新建一个rssi_history[100]数组,每收到一次有效 beacon 扫描结果,就把 RSSI 值存进去。在 VSCode 的 Debug 视图中,右键该数组 → “Add to Watch”,设置断点在存入第 100 个值之后。运行时暂停,你能直接看到这 100 个原始 RSSI 值的分布直方图(VSCode 会自动渲染为柱状图)。如果发现大量值集中在 -80 dBm 以下,说明环境干扰严重,需要调整扫描窗口(scan_params.window)或切换到信道 37/38/39(BLE 广播专用信道,比数据信道干净)。
第三步:用 Python 脚本离线分析滤波效果,再固化到固件。
把idf.py monitor的日志重定向到文件rssi_raw.log,用以下脚本快速验证滤波算法:
import numpy as np import matplotlib.pyplot as plt # 读取日志中的 RSSI 值(假设每行末尾是 "RSSI=-52") rssi_list = [] with open('rssi_raw.log') as f: for line in f: if 'RSSI=' in line: rssi = int(line.split('RSSI=')[-1].split(',')[0]) rssi_list.append(rssi) # 对比三种滤波:均值、中值、指数加权 raw = np.array(rssi_list) mean_filtered = np.convolve(raw, np.ones(10)/10, mode='valid') median_filtered = np.array([np.median(raw[i:i+10]) for i in range(len(raw)-9)]) ewma = [raw[0]] for i in range(1, len(raw)): ewma.append(0.3 * raw[i] + 0.7 * ewma[-1]) plt.plot(raw[:200], label='Raw', alpha=0.6) plt.plot(mean_filtered[:190], label='Mean', linewidth=2) plt.plot(median_filtered[:190], label='Median', linewidth=2) plt.plot(ewma[:200], label='EWMA', linewidth=2) plt.legend() plt.ylabel('RSSI (dBm)') plt.xlabel('Sample Index') plt.show()实测下来,中值滤波(Median Filter)对脉冲噪声(如 Wi-Fi 突发干扰)抑制最强,但响应慢;指数加权移动平均(EWMA)兼顾实时性和平滑度,α=0.3 是我的黄金参数。最终我把 EWMA 逻辑写进 ESP32 固件,用float rssi_ewma = 0.3f * current_rssi + 0.7f * rssi_ewma_prev;实现,内存占用仅 4 字节,CPU 开销几乎为零。
注意:不要在中断上下文(如 GAP event handler)里做复杂计算。把 RSSI 值放入队列,用独立的任务(
xTaskCreate())去处理滤波和距离换算。否则广播扫描会被阻塞,丢包率飙升。
4. 距离换算的陷阱:为什么“1 米标定”必须在你的目标环境中完成
所有教程里写的测距公式distance = pow(10, (tx_power - rssi) / 10 / n),看起来简单,但n(路径损耗因子)这个参数,是横亘在理论和现实之间最大的鸿沟。教科书说n=2是自由空间理想值,但你的车间、办公室、仓库,n可能是 3.2、4.1、甚至 5.8。这个值不是查表得来的,而是必须用你的 beacon、你的 ESP32、在你的实际部署位置,用卷尺一米一米量出来。我见过太多项目,因为偷懒用网上抄来的n=2.5,导致 3 米外的告警误触发率高达 40%。
标定的正确姿势,是做一条“RSSI-距离”实测曲线。准备一根 5 米长的非金属卷尺(避免金属尺影响信号),把 beacon 固定在起点,ESP32 扫描器沿直线匀速移动,每 0.5 米停 10 秒,记录 100 个滤波后的 RSSI 值。重复 3 次,取中位数。最终你会得到一组数据:
距离(m): 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0 RSSI(dBm): -32, -45, -51, -56, -59, -63, -66, -68, -71, -73把这个数据导入 Excel,X 轴距离,Y 轴 RSSI,添加趋势线,选择“幂函数”拟合。Excel 会给出公式y = a * x^b,其中b就是你的实际n值(注意符号,RSSI 随距离增大而减小,所以b是负数,n = -b)。在我的一个金属货架仓库项目中,拟合结果是y = -28.5 * x^-3.7,所以n=3.7,而不是默认的 2.0。
但更进一步,我发现单一n值仍有缺陷:近距离(<1.5 米)多径效应强,RSSI 波动大;远距离(>4 米)信号已接近接收灵敏度底噪(ESP32 约 -95 dBm),信噪比低。于是我把距离区间分段建模:
- 近场(0.5–1.5 米):用线性插值。因为在此区间,RSSI 与距离基本呈线性关系,
distance = k1 * rssi + b1,k1和b1由实测两点确定。 - 中场(1.5–4.0 米):用幂函数
distance = pow(10, (tx_power - rssi) / 10 / n),n取拟合值。 - 远场(>4.0 米):直接返回
>4.0,不计算具体数值。因为此时 RSSI 接近 -85 dBm,±3 dBm 的误差就会导致距离估算翻倍,毫无意义。
这个分段逻辑,我直接硬编码在 ESP32 固件里:
float calculate_distance(int rssi_filtered) { const int tx_power = -59; // 实际标定值 if (rssi_filtered >= -40) { // 近场,rssi > -40 dBm return 0.5f + (rssi_filtered + 40) * 0.02f; // 线性映射:-40→0.5m, -30→0.7m } else if (rssi_filtered >= -75) { // 中场 float n = 3.7f; // 仓库实测值 return powf(10.0f, (tx_power - rssi_filtered) / (10.0f * n)); } else { // 远场 return 4.0f; // 返回阈值,不外推 } }这样做的好处是,固件体积增加不到 200 字节,但距离估算的稳定性提升了一个数量级。上线后,原来每天 20 次的误告警,降到每周不到 1 次。
关键经验:标定时,务必关闭所有无关蓝牙设备(包括你的手机热点、智能手表),并确保 beacon 和 ESP32 之间无遮挡。我曾因没关同事的蓝牙耳机,导致标定曲线在 2 米处出现异常凸起——那个耳机恰好在 2 米处形成了信号反射点。
5. 工程化落地:如何让测距结果真正驱动业务逻辑
写出让 ESP32 正确算出“距离 2.3 米”的代码,只完成了 30% 的工作。剩下的 70%,是如何让这个数字在真实业务中产生价值。我参与过的三个典型场景,展示了不同的工程化思路:
场景一:AGV 小车防撞(高实时性要求)
需求:两台 AGV 相距 <1.5 米时,必须立即降速至 0.1 m/s。
挑战:RSSI 更新频率受广播间隔限制(iBeacon 最小 100ms),而 AGV 行驶速度可能达 1 m/s,100ms 内已移动 10cm。
解法:不依赖单次测距,而是构建“距离趋势”状态机。
- 定义状态:
SAFE(距离 >2.0m)、WATCHING(1.5–2.0m)、ALERT(1.0–1.5m)、EMERGENCY(<1.0m) - 状态迁移条件:连续 3 次扫描结果都落入下一区间,才切换状态(防抖)
- 动作:进入
ALERT时启动电机缓刹;进入EMERGENCY时切断动力电源(硬件看门狗强制复位)
这样,即使某次 RSSI 因干扰跳变,也不会触发误动作。状态机逻辑用switch-case实现,内存占用极小。
场景二:会议室 occupancy 检测(低功耗优先)
需求:检测会议室是否有人,精度要求不高,但 ESP32 电池要撑 6 个月。
挑战:持续扫描 BLE 耗电巨大(>10mA),无法用纽扣电池。
解法:用 beacon 的“被动唤醒”机制。
- 让员工手机安装轻量 App,进入会议室时自动广播一个特定 UUID 的 beacon(不连接,纯广播)
- ESP32 设置为“低功耗扫描模式”:每 10 秒醒 20ms 扫一次,其余时间深度睡眠(<10uA)
- 扫到指定 UUID,立刻切换到高精度测距模式,持续 30 秒,若 30 秒内距离始终 <3 米,则上报“occupied”
- 离开时,手机 App 检测到 GPS 位置变更,自动停止广播
实测单节 CR2032 电池续航达 7.2 个月,远超预期。
场景三:产线工位电子围栏(多 beacon 融合)
需求:工人进入危险区域(如 CNC 机床旁)时,声光报警;但需区分“路过”和“停留”。
挑战:单个 beacon 测距在边界处抖动大,易误报。
解法:部署 3 个 beacon 形成三角定位,用 RSSI 加权质心法。
- 在机床三面各装一个 beacon(B1/B2/B3),UUID 相同,Major/Minor 编码位置
- ESP32 同时扫描三个 beacon,得到
d1/d2/d3 - 计算加权坐标:
x = (d1*x1 + d2*x2 + d3*x3) / (d1+d2+d3),y同理 - 判定:坐标落入预设多边形围栏内,且持续 5 秒,才触发报警
这样,即使某个 beacon 被临时遮挡,其他两个仍能提供粗略位置,系统鲁棒性大幅提升。
这三个案例的共同点是:测距本身只是传感器输入,真正的价值在于它如何与业务规则、硬件约束、用户体验耦合。VSCode 的优势在于,你能用同一个调试环境,同时查看 AGV 的电机 PWM 波形、会议室的电流消耗曲线、产线围栏的坐标热力图——所有这些,都源于最初那行rssi = scan_rst->rssi;。
6. 那些没人告诉你的“玄学”细节:天线、外壳、固件版本的隐性影响
在 ESP32 蓝牙测距项目里,最后 10% 的精度提升,往往来自那些 datasheet 里不会写的细节。这些细节不决定项目成败,但决定了你能否把“能用”变成“好用”。
第一,PCB 天线的接地铜皮,比天线形状本身更重要。
ESP32-WROOM-32 的 PCB 天线,标准参考设计要求天线下方必须是完整、无分割的 GND 铜皮,且尺寸至少 15mm × 15mm。但我见过太多工程师为了节省面积,把 GND 铜皮切成几块,或者在天线下方走信号线。结果是:实测 1 米 RSSI 比标称值低 6 dBm,且方向性严重畸变——正前方信号强,侧面衰减陡峭。解决方法很简单:在嘉立创打板时,勾选“天线区域禁止铺铜”选项,然后手动在天线下方画一块 20mm × 20mm 的纯 GND 区域,用 0.3mm 宽的走线引出,绝不穿越。
第二,塑料外壳的厚度和介电常数,会系统性偏移 RSSI。
ABS 塑料(εr≈2.5)和 PC 塑料(εr≈2.9)对 2.4 GHz 信号的衰减差异可达 1.2 dB。这意味着,如果你用 ABS 外壳标定的 TX Power 值,换到 PC 外壳上,所有距离估算会整体偏大 15%。我的做法是:在最终量产外壳模具确认后,用同一套硬件,在新外壳里重新做一次 1 米标定,把新的 TX Power 值写死在固件里。别嫌麻烦,这比后期软件补偿更可靠。
第三,ESP-IDF 版本对 BLE 射频校准的影响,被严重低估。
ESP-IDF v4.4 之前,esp_ble_tx_power_set()设置的功率等级,实际输出功率与标称值偏差可达 ±3 dBm。v4.4 引入了新的射频校准流程,在idf.py build时会自动生成phy_init_data.bin,大幅改善一致性。但如果你用的是旧版 SDK 编译的固件,刷到新硬件上,校准数据不匹配,TX Power 就会漂移。解决方案:永远用与硬件匹配的 ESP-IDF 版本(官网明确标注支持的芯片型号),并在sdkconfig中开启CONFIG_ESP_PHY_CALIBRATION_AND_DATA_STORAGE=y。
第四,VSCode 的 C/C++ 插件版本,会影响结构体对齐,进而破坏广播包解析。
这是个隐藏极深的坑。某些旧版 C/C++ 插件(<1.12.0)在解析esp_ble_gap_cb_param_t结构体时,会错误地按 4 字节对齐,导致adv_data指针偏移 2 字节,memcpy时把广播数据拷贝错位。现象是:你明明在adv_data里看到4C 00 02 15,但解析出的 UUID 前 4 字节却是乱码。修复方法:在c_cpp_properties.json中强制指定intelliSenseMode为"gcc-arm",并确保compilerPath指向 ESP-IDF 自带的xtensa-esp32-elf-gcc,而非系统全局 GCC。
这些细节,没有一篇官方文档会专门讲,但每一个都足以让一个本该 2 天调通的测距功能,卡在第 5 天还在抓耳挠腮。它们不是“高级技巧”,而是资深工程师的日常——知道哪里有坑,并提前绕开。
我在产线上调试一个 AGV 防撞系统时,连续 3 天 RSSI 数据异常,最后发现是车间新装的 LED 工矿灯驱动器,其开关电源在 2.4 GHz 有强烈谐波辐射。换了带屏蔽罩的驱动器,问题迎刃而解。所以,当你觉得“代码没问题”,请先检查物理世界:天线有没有被螺丝压住?外壳有没有金属镀层?周围有没有新添的电子设备?蓝牙测距,一半是代码,一半是电磁学。