ESP32蓝牙Beacon RSSI测距实战:从原理到标定
如果你已经用ESP-IDFVSCode把ESP32的Wi-Fi配网、MQTT上云、OTA升级这些联网基本功都过了一遍那这一讲咱们来点不一样的把蓝牙beacon用起来做RSSI测距。蓝牙beacon本身不发Wi-Fi信号不依赖路由器只要两个ESP32或者一个ESP32加一部手机就能估算出彼此的距离用在室内定位、打卡签到、防丢接近提醒这类场景里非常合适。说实话蓝牙beacon测距并不算新东西但ESP-IDF这套框架里官方给的demo更多是演示“广播”和“扫描”这两个单向动作真正把RSSI采集、滤波、距离换算串成一条完整链路的资料不多。这一讲我打算按自己的实战路径来写先讲明白测距原理和关键参数再分别实现广播端和扫描端最后讲讲标定方法和精确踩坑经验。整个流程跑下来你自己也能复制出一套可用的beacon测距原型。1. 先把测距思路捋清楚RSSI和距离的关系1.1 为什么beacon能测距离无线电信号在空气中传播时会不断衰减接收端收到的信号强度RSSIReceived 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标定为-56dBmn取2.8某次扫描到RSSI-70dBm那么(A-RSSI)14除以(10*2.8)28指数是0.5最后d10^0.5≈3.16m。如果RSSI变成-62指数就是6/28≈0.214d≈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_demoBluedroid协议栈下的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变成一个标准iBeacon3.1 iBeacon报文到底长什么样标准BLE广播包最多31字节由若干个AD Structure组成。每个AD Structure的格式是1字节长度 1字节类型 N字节数据。iBeacon的核心数据放在“Manufacturer Specific Data”这个AD Structure里结构如下数据段长度内容Flags3字节0x02 0x01 0x1A表示是否可发现Company ID2字节0x004C这是苹果的厂商编号iBeacon Type2字节0x02 0x15UUID16字节应用标识通常使用同一项目内的固定值Major2字节用于区分大型分组Minor2字节用于区分具体位置TxPower1字节距离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.75ms0x0010约等于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调大滤波结果更平滑但响应慢。我测静态场景时q0.01、r1.0效果挺好测移动目标时会改成q0.1。4.5 无阻塞的主循环设计蓝牙回调是在协议栈任务里触发的所以不要在回调函数里做耗时操作比如打一大堆日志、访问文件系统、发起Wi-Fi连接否则会阻塞协议栈导致扫描丢包甚至死机。我通常的做法是回调里只更新一个volatile变量或一个小型队列主循环里每隔100ms读一次最新的滤波后距离然后做打印、上报、阈值判断等工作。这样把高频的蓝牙底层处理和低频的业务逻辑解耦系统稳定很多。5. 实测调参手把手标定TxPower和环境因子n5.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 标定环境衰减因子nA确定之后还需要标定n。方法是采集多个距离点的平均RSSI再用公式反推n (A - RSSI_d) / (10 * lg(d))比如我测得A-545米处平均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、n2.9对几个固定距离点做了多轮测量结果如下真实距离原始RSSI均值滑动平均估算距离卡尔曼估算距离1.0m-54dBm1.1m1.0m2.0m-61dBm2.1m2.0m3.0m-67dBm3.2m3.0m5.0m-74dBm5.3m5.1m8.0m-81dBm8.1m7.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-86.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%的极端RSSI7. 测距调完后的几点经验补充这套beacon测距做完之后我自己又连续测了好几天最大的体会是把A和n标定好比换什么高级滤波算法都管用。很多新手一上来就研究各种滤波却不愿意花半小时做标定最后效果不好就怪蓝牙方案不行。其实在固定场景里标定差的几dBm足矣让3米处的估算值偏出1米以上。另外一个实际操作中的小技巧是扫描端拿到RSSI后一定先看它所属beacon的MAC地址或者UUID再决定要不要参与距离计算。因为在真实环境里扫描端可能会同时收到房间里好几个beacon的广播如果不做过滤距离值会在不同beacon之间来回跳。我习惯把“目标beacon的标识”和“最近N次RSSI”放同一个结构体管理这样多beacon场景也好扩展。关于后续的玩法如果你已经跑通了单点beacon测距接下来可以做多beacon三角定位或者用指纹库方式做室内定位。三角定位对RSSI精度要求太高不一定能有理想效果指纹库反而更实用先记录每个网格点上的RSSI特征再用最近邻匹配估算位置在蓝牙方案里是更稳妥的路线。如果预算允许想做到厘米级那就得换UWB双频测距这类专用方案了BLE还是老老实实做接近感知吧。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →