如果你已经用ESP-IDF+VSCode把ESP32的Wi-Fi配网、MQTT上云、OTA升级这些联网基本功都过了一遍,那这一讲咱们来点不一样的:把蓝牙beacon用起来,做RSSI测距。蓝牙beacon本身不发Wi-Fi信号,不依赖路由器,只要两个ESP32(或者一个ESP32加一部手机)就能估算出彼此的距离,用在室内定位、打卡签到、防丢接近提醒这类场景里非常合适。
说实话,蓝牙beacon测距并不算新东西,但ESP-IDF这套框架里,官方给的demo更多是演示“广播”和“扫描”这两个单向动作,真正把RSSI采集、滤波、距离换算串成一条完整链路的资料不多。这一讲我打算按自己的实战路径来写:先讲明白测距原理和关键参数,再分别实现广播端和扫描端,最后讲讲标定方法和精确踩坑经验。整个流程跑下来,你自己也能复制出一套可用的beacon测距原型。
1. 先把测距思路捋清楚:RSSI和距离的关系
1.1 为什么beacon能测距离
无线电信号在空气中传播时会不断衰减,接收端收到的信号强度RSSI(Received Signal Strength Indicator)会随着距离变远而变小。这个规律并不难理解,就像你在走廊里喊一句,近处的人听着响,远处的人听着轻——蓝牙beacon测距本质上就是“听音量猜距离”。
但事情没有这么简单。蓝牙工作在2.4GHz频段,这个频段电磁波波长只有12厘米左右,窗户玻璃、金属门、人体里的水分,甚至一把钥匙都会让信号反射、绕射、吸收。所以RSSI天生就有很大的随机性:同一台beacon放在同一个地方,连续采100个RSSI值,抖个正负5~6dBm都很正常。直接拿单次RSSI去算距离,结果会像心电图一样跳。
这也是为什么beacon测距一直被归类为“区域级定位方案”而不是“精确测量方案”。它能回答“你在哪个房间”这种问题,答不了“你距离门口精确多少厘米”。理解这一点,后面看到误差就不会太焦虑。
1.2 对数路径损耗模型是怎么算距离的
业界最常用的RSSI测距模型是对数路径损耗模型(Log-Distance Path Loss Model),表达式写成:
RSSI(d) = A - 10 * n * lg(d)
其中A表示距离发射端1米处测量的RSSI参考值,n叫环境衰减因子。真实环境中A大概在-45到-60dBm之间,开阔空间n接近2.0,办公室有隔断时n在2.5到3.5之间,钢筋混凝土环境下n可能超过4。
从接收端反推距离,就变成:
d = 10^((A - RSSI) / (10 * n))
举个具体例子:假设A标定为-56dBm,n取2.8,某次扫描到RSSI=-70dBm,那么(A-RSSI)=14,除以(10*2.8)=28,指数是0.5,最后d=10^0.5≈3.16m。如果RSSI变成-62,指数就是6/28≈0.214,d≈1.64m。
你看,这个公式本身特别简单,真正影响结果的其实就是A和n这两个参数。很多教程只告诉你怎么写代码,不告诉你标定A和n的方法,结果做出来的测距误差大到没法用。A和n的标定流程我在第5章专门讲,那是整个工程里最花时间的部分。
1.3 和其他测距方案比,BLE beacon到底在图什么
既然RSSI这么不稳,为什么不用更准的方案?这个问题我在做方案选型时也纠结过。超声波测距在小范围避障里能做到厘米级,UWB(比如802.15.4z标准里的双程测距)甚至能做到10厘米以内精度,单目或双目视觉测距也能在一定条件下得到不错的距离值。
但这些方案要么需要专用射频芯片,要么需要很复杂的标定算法,要么受光线和遮挡影响太大。BLE beacon的最大优势是“普适性”:现代手机全都支持BLE广播解析,ESP32这样的低成本模组也自带蓝牙协议栈,两颗开发板加在一起成本就几十块钱,不需要任何额外硬件。
所以我的结论是:不要用BLE beacon和UWB、激光测距拼精度。它的定位是低成本、低功耗、高兼容性的接近检测和区域定位。像展厅导览、冷链仓储的区域判定、车门接近解锁这类场景,beacon方案性价比非常高。
2. 环境准备:VSCode里的ESP-IDF工程基础
2.1 为什么继续用VSCode这套组合
前几讲都是用VSCode + Espressif IDF插件开发ESP32,这一讲继续沿用。原因很简单:IDF插件把命令行那套build、flash、monitor全部集成成了按钮,点一下就能编译烧录,串口日志也直接输出到集成终端里,写代码时还能跳转函数定义,效率明显要比纯命令行高。
如果你是第一次搭这个环境,我建议直接去乐鑫官网下载ESP-IDF离线安装包,配合VSCode里的“Espressif IDF”扩展使用。插件装好后,在VSCode设置里把“ESP-IDF Path”指向IDF的安装根目录,它就能自动识别idf.py、工具链和Python环境。我这里用的版本是v5.4.4,和当前大多数示例工程兼容性都不错。
2.2 从官方示例复制出beacon工程
ESP-IDF官方其实已经带了一个beacon demo,路径在examples/bluetooth/bluedroid/ble/ibeacon_demo,Bluedroid协议栈下的iBeacon示例。我强烈建议第一次做的人不要从零手写,直接把这个目录复制一份出来改代码。
复制后先用VSCode打开工程,在终端执行idf.py set-target esp32,如果你的板子是ESP32-S3,就改成esp32s3。然后点插件里的“Build”编译,第一次编译会全部编译一遍,耐心等几分钟。编译通过后接上开发板,选对串口号,点“Flash and Monitor”烧录。我自己的经验是,遇到烧录失败提示“Failed to connect”时,多半是芯片没进入下载模式,按住开发板的BOOT键再点烧录,基本就好了。
2.3 先规划好两种角色
蓝牙测距链路里一定有一个广播端和一个扫描端,这两个角色代码差别很大:
广播端负责定期发出iBeacon广播包,包里带有固定的TxPower字段,相当于“我是谁,我在多远处音量多大”。扫描端负责不断监听周围的广播包,拿到RSSI字段,再结合TxPower和环境衰减因子反推距离。
我建议这一讲就用两个ESP32开发板来实验,一个刷广播端代码,一个刷扫描端代码。如果只有一个板子,也可以让板子先广播、再扫描,但这样没法实时观察距离变化。两台设备的串口日志放在两个终端窗口里对照看,调试体验会好很多。
2.4 环境和工程里的几个典型坑
- VSCode插件报“the path for esp-idf is not valid: /tools/idf.py not found”:这是路径配置问题,插件需要指向包含esp-idf和tools的IDF根目录,不是直接指向idf.py文件。
- 工程路径不能有中文和空格,否则编译时默认的Python环境容易出奇怪问题。
- 串口日志里中文乱码:把VSCode右下角文件编码切换成UTF-8,再重开串口监视器。
- 用ESP32-S3或其他芯片时,板子型号要选对,不然烧录运行会出现FLASH大小或者时钟配置异常。
我自己在示例工程上踩过一次:把ESP32的示例直接编到ESP32-S3板子上,结果一直重启,最后发现是set-target没有改。所以新开工程后,第一步先确认目标芯片是正确的。
3. 广播端:把ESP32变成一个标准iBeacon
3.1 iBeacon报文到底长什么样
标准BLE广播包最多31字节,由若干个AD Structure组成。每个AD Structure的格式是:1字节长度 + 1字节类型 + N字节数据。iBeacon的核心数据放在“Manufacturer Specific Data”这个AD Structure里,结构如下:
| 数据段 | 长度 | 内容 |
|---|---|---|
| Flags | 3字节 | 0x02 0x01 0x1A,表示是否可发现 |
| Company ID | 2字节 | 0x004C,这是苹果的厂商编号 |
| iBeacon Type | 2字节 | 0x02 0x15 |
| UUID | 16字节 | 应用标识,通常使用同一项目内的固定值 |
| Major | 2字节 | 用于区分大型分组 |
| Minor | 2字节 | 用于区分具体位置 |
| TxPower | 1字节 | 距离1米处的参考RSSI,注意是补码 |
在C语言里,这段manuf_data就是一个固定字节数组:
static uint8_t manuf_data[28] = { 0x4C, 0x00, // Company ID: Apple 0x02, 0x15, // iBeacon Type 0x01, 0x12, 0x23, 0x34, 0x45, 0x56, 0x67, 0x78, 0x89, 0x9A, 0xAB, 0xBC, 0xCD, 0xDE, 0xEF, 0xF0, // UUID 0x00, 0x01, // Major 0x00, 0x02, // Minor 0xC8 // TxPower,-56dBm };为什么是0xC8?因为-56的补码就是0xC8。如果你校准出来1米处的参考值不是-56,自己改成对应补码即可。
3.2 广播参数怎么配
ESP-IDF里,广播数据配置通过esp_ble_adv_data_t结构体完成。比较关键的是下面几个字段:
static esp_ble_adv_data_t adv_data = { .set_scan_respond = false, .include_name = false, .include_txpower = true, .min_interval = 0x0006, .max_interval = 0x0010, .manufacturer_len = sizeof(manuf_data), .p_manufacturer_data = manuf_data, .flag = 0x1A, };min_interval和max_interval的单位是0.625ms,所以0x0006约等于3.75ms,0x0010约等于10ms。实际广播间隔会被协议栈限制在20ms到10.24秒之间,如果值太小,协议栈会自己取一个合法最小值。我一般把广播间隔放在100ms左右,也就是0x00A0附近,这个值兼顾刷新速度与功耗。
完整启动流程需要先调用esp_ble_gap_config_adv_data(&adv_data)配置广播数据,配置成功后会触发ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT事件,在这个事件里再去调用esp_ble_gap_start_advertising()。很多人直接把start_advertising跟在config后面,实测偶尔会出现广播不启动的情况,还是规规矩矩等事件回调比较稳。
3.3 广播端代码的关键部分
事件回调里我加了一个条件判断:
case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT: esp_ble_adv_params_t adv_params = { .adv_int_min = 0x00A0, .adv_int_max = 0x00A0, .adv_type = ADV_TYPE_IND, .own_addr_type = BLE_ADDR_TYPE_PUBLIC, .channel_map = ADV_CHNL_ALL, .adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY, }; esp_ble_gap_start_advertising(&adv_params); break;这里的关键是start_advertising第二个参数有个timeout,默认函数签名是esp_ble_gap_start_advertising(esp_ble_adv_params_t *adv_params),它会一直广播下去。如果用的是带timeout的接口,传入0表示持续广播,传其他值到了时间就会自动停止。调试时如果你发现beacon广播几秒后消失了,先检查这个timeout参数。
3.4 广播间隔和功耗的取舍
广播间隔越小,扫描端抓到广播包的频次越高,距离刷新越快,但功耗也越大。我实测过,100ms间隔下ESP32广播电流在3mA到10mA之间波动,500ms间隔可以降到一半以下。如果是做防丢标签这种电池供电设备,建议间隔放到200ms以上,配合深度睡眠。如果只是做固定点位beacon,插座供电,那100ms完全没问题。
还有一个容易被忽略的细节:做多个beacon部署时,如果多个beacon同时广播,2.4GHz的信道会碰撞,导致扫描端收包率下降。工程上通常会错开广播时隙,比如给不同beacon设置不同的adv_int_min和adv_int_max,靠时隙随机性减少撞在一起的概率。
4. 扫描端:采集RSSI并把它变成距离
4.1 扫描参数如何设置
扫描端需要先初始化BLE协议栈,然后注册GAP回调,设置扫描参数,最后调用esp_ble_gap_start_scanning(0)开始扫描。扫描参数我一般这样配:
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, // 80 * 0.625ms = 50ms .scan_window = 0x30, // 48 * 0.625ms = 30ms .scan_duplicate = BLE_SCAN_DUPLICATE_DISABLE };scan_type选PASSIVE就够了,beacon广播包里就已经带RSSI,不需要主动向对方发送扫描请求。scan_interval和scan_window决定扫描的占空比,窗口越大越容易抓到包。scan_duplicate一定要设为DISABLE,不然同一个beacon的重复广播会被协议栈过滤掉,导致RSSI采样频率太低,后面滤波很难做。
扫描时长参数在esp_ble_gap_start_scanning(uint32_t duration)里,传0表示持续扫描直到手动调用esp_ble_gap_stop_scanning()。
4.2 回调事件里怎么解析广播包和RSSI
扫描结果通过GAP回调事件的ESP_GAP_BLE_SCAN_RESULT_EVT上报,核心代码大概长这样:
case ESP_GAP_BLE_SCAN_RESULT_EVT: if (param->scan_rst.search_evt == ESP_GAP_SEARCH_INQ_RES_EVT) { uint8_t *adv = param->scan_rst.adv_data; uint8_t len = param->scan_rst.adv_data_len; int8_t rssi = param->scan_rst.rssi; // 在这里解析adv里的iBeacon数据段 } break;解析逻辑可以自己遍历adv_data里的AD Structure,找类型等于0xFF的厂家数据段,然后判断前面两个字节是不是0x004C,再往后看UUID是否匹配。为了调试方便,我建议先把原始adv_data整体打印成hex串,看一眼再写解析逻辑,这样不容易出错。
拿到rssi之后,可以直接调用距离换算函数。注意rssi字段是int8_t类型,在串口打印时要用%d格式化,直接用%f打印会得到奇怪的值。
4.3 距离换算代码
距离计算本身很简单,一个函数搞定:
#include <math.h> float calc_distance(int8_t rssi, int8_t tx_power, float n) { float temp = (float)(tx_power - rssi) / (10.0f * n); return powf(10.0f, temp); }调用时tx_power可以用广播包里解析出来的,也可以用自己校准后的常量替换。我后来实际项目里都用自己校准的常量,因为每个板子天线附近有没有金属器件、外壳材质不同,都会让真实参考值偏离广播包里写的数字。
4.4 滤波处理:滑动平均和一维卡尔曼
RSSI单次测量值波动太大,直接拿去做距离计算会得到非常毛糙的结果。我试过在静止场景下,1米距离的RSSI都能在-50到-60之间跳,按公式算出来的距离能从0.8米跳到1.6米。所以滤波是必选项。
滑动平均最直观,维护一个长度为N的数组,每次来新RSSI就丢进头部,丢出尾部一个旧值,取平均值。窗口N我一般取10,对应大概1秒的平滑周期,静态场景够稳。缺点是如果你拿着扫描端快速走动,窗口太大会让距离变化显得迟钝。
卡尔曼滤波效果更好一些,实现也不复杂。对RSSI做一维卡尔曼,状态变量就是“真实RSSI”,观测量就是“当前RSSI”:
typedef struct { float x; // 状态估计值 float p; // 误差协方差 float q; // 过程噪声 float r; // 测量噪声 } kalman_1d_t; void kalman_init(kalman_1d_t *k, float init_x) { k->x = init_x; k->p = 1.0f; k->q = 0.01f; k->r = 1.0f; } float kalman_update(kalman_1d_t *k, float z) { float p1 = k->p + k->q; float kg = p1 / (p1 + k->r); k->x = k->x + kg * (z - k->x); k->p = (1.0f - kg) * p1; return k->x; }过程噪声q调大,滤波结果会更快跟上真实值变化;测量噪声r调大,滤波结果更平滑但响应慢。我测静态场景时q=0.01、r=1.0效果挺好,测移动目标时会改成q=0.1。
4.5 无阻塞的主循环设计
蓝牙回调是在协议栈任务里触发的,所以不要在回调函数里做耗时操作,比如打一大堆日志、访问文件系统、发起Wi-Fi连接,否则会阻塞协议栈,导致扫描丢包甚至死机。
我通常的做法是:回调里只更新一个volatile变量或一个小型队列,主循环里每隔100ms读一次最新的滤波后距离,然后做打印、上报、阈值判断等工作。这样把高频的蓝牙底层处理和低频的业务逻辑解耦,系统稳定很多。
5. 实测调参:手把手标定TxPower和环境因子n
5.1 校准TxPower的正确姿势
标定A(也就是TxPower)是整个测距工程里最不起眼但最重要的一步。方法是:把广播端和扫描端放在同一个水平面上,相距1米,天线区域尽量正对,中间不要有人和其他遮挡物。扫描端连续采集100个RSSI值,取平均,这个平均值就是当前环境的A。
我这次实测用的是两块ESP32-DevKitC板子,放在1米的测试台上。广播间隔设为100ms,扫描端连续采100个包,平均RSSI算出来是-54dBm。而官方示例的manuf_data里写的是0xC8,也就是-56dBm,差2dBm,对于1米测距来说已经会导致估算值偏差10%左右。所以建议每个项目都重新校准一次A,特别是我把模组塞进塑料外壳之后,A会再偏移几个dBm。
5.2 标定环境衰减因子n
A确定之后,还需要标定n。方法是采集多个距离点的平均RSSI,再用公式反推:
n = (A - RSSI_d) / (10 * lg(d))
比如我测得A=-54,5米处平均RSSI=-74,那么n=( -54 - (-74) ) / (10 * lg5) = 20 / 6.99 ≈ 2.86。我又测了3米和8米的数据,取平均后得到这个室内环境的n约等于2.9。
注意一个细节:n的标定范围最好覆盖你业务真正关心的距离区间。我后来发现,在1到5米范围内标定的n,推到10米外误差会明显偏大,因为蓝牙信号更远时会受到更多反射和吸收,路径损耗指数本身也在变化。所以做门禁接近检测就重点标1到3米,做展厅导览就重点标2到8米。
5.3 一组实测数据和误差分析
我用标定好的A=-54、n=2.9,对几个固定距离点做了多轮测量,结果如下:
| 真实距离 | 原始RSSI均值 | 滑动平均估算距离 | 卡尔曼估算距离 |
|---|---|---|---|
| 1.0m | -54dBm | 1.1m | 1.0m |
| 2.0m | -61dBm | 2.1m | 2.0m |
| 3.0m | -67dBm | 3.2m | 3.0m |
| 5.0m | -74dBm | 5.3m | 5.1m |
| 8.0m | -81dBm | 8.1m | 7.6m |
可以看到,1到5米范围内,卡尔曼滤波后的估算值和真实距离比较接近,误差基本在0.3米以内。但8米那组误差变大,而且单次原始RSSI波动有±5dBm,所以如果只看单次结果,8米时可能在6米到11米之间来回晃。这也验证了前面说的:BLE beacon比较适合做区域级判断,不适合远距离精确测距。
5.4 影响最终精度的四个细节
第一,天线方向和极化方式。ESP32模组的天线区域一般在板子边缘,天线平面正对时信号最好。我测试中发现把广播端横过来放,RSSI会下降好几个dBm,距离估算就明显偏大。所以实际部署时要注意把设备统一朝向。
第二,人体和金属遮挡。人站在两台设备中间,RSSI会瞬间掉10dBm以上,因为人体含水,对2.4GHz信号吸收严重。实测时如果无法避开人员走动,就多采几轮数据做统计,别拿抖动瞬间的值当真。
第三,广播端和扫描端的电源。不要用劣质充电宝给开发板供电,电压纹波大会让射频前端性能不稳,RSSI也跟着抖。我试过用电脑USB口供电和用降压模块供电,RSSI均值能差2~3dBm。
第四,不要追求“精确到0.1米”。这一点我在团队里反复强调。蓝牙beacon的物理特性决定了它的精度上限,最好的工程实践是先定义好离散等级:近(1.5米内)、中(1.5到3米)、远(3米外),根据这些档位触发业务动作。这样做出来的系统,稳定性会明显好于直接显示精确距离。
6. 常见问题与排查速查表
6.1 编译与环境类问题
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| VSCode插件提示“the path for esp-idf is not valid: /tools/idf.py not found” | 插件配置的IDF路径错误 | 在设置里把“ESP-IDF Path”改为IDF根目录,例如C:/Espressif/frameworks/esp-idf-v5.4.4 |
| 编译报错,找不到蓝牙相关头文件 | 工程类型或目标芯片配置错误 | 执行idf.py set-target esp32,再fullclean后重新编译 |
| 烧录时提示“Failed to connect” | 开发板未进入下载模式 | 按住BOOT键再点烧录,写入后松手 |
| 串口日志中文乱码 | 文件编码与串口显示不一致 | 将源文件保存为UTF-8,串口监视器也切UTF-8 |
6.2 蓝牙行为异常类问题
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 广播端不广播 | adv data配置完成事件没触发就启动广播 | 在ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT回调后再start_advertising |
| 广播几秒后自动停止 | 调用了带timeout的接口并传了非0值 | 改为持续广播,timeout传0 |
| 扫描端一个包都收不到 | 两个设备广播信道或扫描过滤不匹配 | 先用串口打印所有广播包,不做任何过滤,再逐步加解析逻辑 |
| 扫描端收不到特定beacon,但手机能收到 | 代码里UUID或Company ID判断太严格 | 去掉过滤条件,打印所有adv_data确认格式 |
6.3 测距精度异常类问题
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 距离跳动幅度大 | 滤波窗口太小或人员频繁遮挡 | 增大滑动平均窗口,或把卡尔曼滤波的r调大 |
| 距离整体偏大 | A值比实际偏小,或n值偏大 | 重新校准TxPower,检查广播功率设置是否降低 |
| 近距离误差反而很大 | 天线极化方向不对或反射严重 | 调整天线朝向,把广播功率调低一档试试 |
| 远距离估算值异常偏小 | 多径环境下出现高RSSI异常值 | 增加中值滤波,丢弃前5%和后5%的极端RSSI |
7. 测距调完后的几点经验补充
这套beacon测距做完之后,我自己又连续测了好几天,最大的体会是:把A和n标定好,比换什么高级滤波算法都管用。很多新手一上来就研究各种滤波,却不愿意花半小时做标定,最后效果不好就怪蓝牙方案不行。其实在固定场景里,标定差的几dBm足矣让3米处的估算值偏出1米以上。
另外一个实际操作中的小技巧是:扫描端拿到RSSI后,一定先看它所属beacon的MAC地址或者UUID,再决定要不要参与距离计算。因为在真实环境里,扫描端可能会同时收到房间里好几个beacon的广播,如果不做过滤,距离值会在不同beacon之间来回跳。我习惯把“目标beacon的标识”和“最近N次RSSI”放同一个结构体管理,这样多beacon场景也好扩展。
关于后续的玩法,如果你已经跑通了单点beacon测距,接下来可以做多beacon三角定位,或者用指纹库方式做室内定位。三角定位对RSSI精度要求太高,不一定能有理想效果;指纹库反而更实用,先记录每个网格点上的RSSI特征,再用最近邻匹配估算位置,在蓝牙方案里是更稳妥的路线。如果预算允许,想做到厘米级,那就得换UWB双频测距这类专用方案了,BLE还是老老实实做接近感知吧。