ESP32+ESP-IDF实现蓝牙iBeacon测距:RSSI原理、代码与实测调参

发布时间:2026/9/12 15:36:21
ESP32+ESP-IDF实现蓝牙iBeacon测距:RSSI原理、代码与实测调参 这一讲继续“ESP-IDF VSCode 开发 ESP32”系列。前面几讲我们把 GPIO、I2C 传感器、WiFi 联网这些基础能力都跑通了这一回换个方向蓝牙 beacon 测距。先说我为什么要专门写这个主题。前阵子我要在办公室做一套简易的设备盘点系统要求在室内判断“某个设备是否靠近某个工位”GPS 进室内就没信号WiFi 指纹定位要先做全区域勘测成本也不低。后来试了一圈发现用蓝牙 beacon RSSI 是最省事的方案硬件成本十几块钱功耗极低部署也灵活。ESP32 自带的 BLE 蓝牙控制器配合 ESP-IDF既能当 beacon 广播端也能当扫描接收端一个芯片把收发两端都干了。所以这一讲我会从 iBeacon 数据包结构讲到 RSSI 转距离的算法再给广播端和扫描端的完整代码最后把我在办公室里实测踩过的坑和调参经验全部摊开。适合已经能跑通 ESP-IDF 基础工程的开发者也适合正在做室内区域定位、接近感知类项目预研的硬件工程师。1. 为什么联网篇里要塞一个蓝牙 beacon它到底能做什么1.1 一个 beacon 解决的真实痛点先把场景说清楚。beacon 测距最常见的应用是“区域感知”你不需要知道目标物体的精确坐标只需要知道它离某个锚点有多远、在不在某个范围里。比如仓库里找货架上的设备把几个 beacon 挂在货架端头手持扫描器或者放一个固定扫描节点设备进入 3 米范围就提醒“靠近了”展馆导览里参观者走到展品附近手机 App 就能自动弹出解说甚至智能门锁的靠近自动唤醒本质上也是靠 beacon RSSI 判断距离。这类需求用 GPS 完全做不了用 WiFi RSSI 也能做但精度更差而且路由器部署位置不确定。蓝牙 beacon 的优势在于部署方可以自己控制广播端的位置和密度广播功率低、波长特性稳定测距逻辑很简单一个 RSSI 值就能换算出大致距离。我实测下来的结论是在一个 10 米见方的普通办公室内单 beacon 的距离估算误差在 1~2 米量级配合滤波可以稳定到 1 米以内。这个精度对“区域级”判断完全够用对“厘米级”定位则远远不够。搞清楚这个定位你后面调参才不会心态崩。1.2 iBeacon vs 经典蓝牙测距为什么走 BLE 这条路很多刚接触的人会混淆两个东西一种是 HC-05、HC-06 那种经典蓝牙BR/EDR串口透传模块这个是要配对连接的另一种是 BLE蓝牙低功耗的广播模式不配对、不连接设备只是周期性地把一小段数据丢到空气中。我们要用的就是后者。经典蓝牙不是不能测距但它设计的目标是“建立连接、传输数据”要经过 inquiry、配对、连接建立这一套流程功耗也高。BLE 的广播模式则完全为“单向告知”设计beacon 广播端只管发扫描端只管收双方不需要建立连接。这让接收端可以同时捕捉周围十几个 beacon 的广播并且整机功耗可以做到很低。iBeacon 是苹果在 2013 年基于 BLE 4.0 提出的广播格式后来成了事实上的 beacon 标准。它把广播数据包里的厂商自定义字段格式化包含 UUID、Major、Minor 和发射功率接收端解析出这些字段再结合 RSSI就能区分“这是哪一组的哪个 beacon”以及“信号大概多强”。ESP-IDF 官方的例程里就有 iBeacon 示例我们实际移植时只需要理解它的数据格式和初始化流程。1.3 本讲目标与硬件准备这一讲最终要跑通两件事广播端一块 ESP32 按固定间隔向外发送标准 iBeacon 广播包你可以自定义 UUID、Major、Minor 和发射功率。扫描端另一块 ESP32 持续扫描 BLE 广播从广播包中解析出 iBeacon 字段读取 RSSI经过滤波后输出估算距离串口实时打印。硬件方面推荐准备两块 ESP32 开发板型号不限DevKitC 或者 NodeMCU-32S 都可以。再准备两台能插 USB 的电脑或者一台电脑两个串口一块跑广播端一块跑扫描端这样可以直观看到两端交互。如果只有一块板也可以用手机装 nRF Connect 这类 BLE 调试 App 来模拟另一侧看广播包或者看 RSSI 都可以但最后的扫描端代码还是需要第二块板来验证。2. 广播不是乱喊iBeacon 数据包结构与 RSSI 测距模型2.1 三个广播信道与广播事件BLE 在 2.4GHz 频段划分了 40 个信道其中 37、38、39 三个信道专门给广播用。为什么要单设广播信道因为如果广播包和 Wi-Fi、经典蓝牙混在一起抢信道接收成功率会很不稳定。这三个信道刻意避开了 Wi-Fi 最常用的 1、6、11 信道中心频率减少干扰。beacon 的广播不是只发一次而是按固定周期形成一个“广播事件”。每个广播事件会在 37、38、39 三个信道上依次发送一遍同一份广播包。所以扫描端理论上只要停留在任意一个广播信道附近就能收到。这也是为什么 beacon 测距的刷新率基本由广播间隔决定——比如广播间隔 100ms接收端就能大约每 100ms 收到一条广播RSSI 采样也就大约每秒 10 次。需要注意这个“大约”意味着接收端的实际收到次数会因为干扰、距离、遮挡而波动。RSSI 本身又是一个噪声很大的值测距算法必须考虑多次采样和滤波不能指望单次测量就准。2.2 iBeacon 广播包逐字节拆解如果你用 nRF Connect 或者 Wireshark 抓一个 iBeacon 广播包会发现它不像普通数据帧那样直接就是“内容”而是由若干个 AD StructureAdvertising Data Structure拼接而成的链表式结构。每个 AD Structure 格式固定第一个字节表示这个结构的长度第二个字节表示 AD Type后面的字节是这个类型的具体数据。一个完整的 iBeacon 广播包通常由两个 AD Structure 组成。第一个是 Flags用于声明广播类型第二个是 Manufacturer Specific Data这才是 iBeacon 的核心。我直接给一个典型广播包十六进制逐字节拆开看02 01 06 1A FF 4C 00 02 15 FD A5 06 93 A4 E2 4F B1 AF CF C6 EB 07 64 78 25 00 01 00 64 C5这样拆偏移字节数据含义002第一个 AD Structure 长度101AD Type Flags206LE General Discoverable Mode且不支持经典蓝牙31A第二个 AD Structure 长度26 字节从 0xFF 起4FFAD Type Manufacturer Specific Data5~64C 00Company ID小端字节序0x004C 是 Apple7~802 15iBeacon type 标识9~24FD A5 ... 78 2516 字节 UUID25~2600 01Major大端字节序这里是 127~2800 64Minor大端字节序这里是 10029C5Tx Power有符号数0xC5 即 -59dBm这里有几个容易头大的字节序问题Company ID 是小端存储所以十六进制显示为4C 00实际表示 0x004CUUID 就是按顺序字节拼接Major 和 Minor 是大端也就是高位在前。Tx Power 是 int8_t读到 0xC5 时不能当 197 处理要强制转换成有符号数 197 - 256 -59。关于 Tx Power 要特别说明它并不是发射功率而是“在 1 米处测得的参考 RSSI 值”。这个概念很容易被新手误解后面计算距离全靠它。2.3 从 RSSI 到距离数学底子与计算示例BLE 测距的理论基础是对数路径损耗模型。简单说信号在空气中传播会随距离衰减距离越远RSSI 越弱。理想模型下接收信号强度与距离的关系为RSSI TxPower - 10 × n × log10(d)其中 TxPower 是 1 米处的参考 RSSIn 是路径衰减指数path loss exponentd 是距离。反过来知道 RSSI 就能估算距离d 10^((TxPower - RSSI) / (10 × n))n 的取值非常影响结果。自由空间里 n ≈ 2.0普通办公室环境 n 通常在 2.5~3.5 之间如果中间隔了金属货架、墙体n 可以到 4 以上。举个例子TxPower -59dBm实测 RSSI -70dBm取 n 2.5。那么 (TxPower - RSSI) 1111 / (10 × 2.5) 0.4410^0.44 ≈ 2.75 米。如果取 n 2.0距离变成 10^0.55 ≈ 3.55 米。同一个 RSSI 值n 从 2.0 改成 2.5距离估算就要差 0.8 米。这说明两个问题第一n 要根据实际环境标定不能直接照搬第二RSSI 本身的噪声波动对距离结果影响很大。RSSI 波动 3dB在 n2.5 时距离就会差约 1.3 倍这也是为什么需要采样滤波和连续观测而不是拿单次测量值直接用。3. 动手前先过一遍环境VSCode 里的 ESP-IDF 与 BLE 配置3.1 快速确认工具版本避免掉进 API 差异的坑如果你是跟着这个系列一路走过来的VSCode ESP-IDF 插件早就装好配置好了。但蓝牙这一讲对版本敏感度比之前的 GPIO 和 WiFi 要高所以动手前建议先确认一下环境状态。在 VSCode 的终端里执行idf.py --version我的环境是 ESP-IDF v5.3 分支。5.x 和 4.x 的 BLE API 有些细节差异比如esp_ble_gap_config_adv_data_raw这类原始数据接口在 5.x 才比较好用老版本更常见的是用esp_ble_ibeacon_t结构体去填充。如果你用的是 4.4网上搜到的很多 iBeacon 例程基本能直接用如果是 5.2、5.3最好参考我下面给出的写法并在报错时优先去查对应版本的官方 iBeacon 例程。另外要提醒一点ESP-IDF 的官方例程路径很有参考价值$IDF_PATH/examples/bluetooth/bluedroid/ble/ibeacon在自己工程之前先把官方例程能编译烧录、能在 nRF Connect 里看到 beacon 广播再回来改自己的代码。官方示例是排查 API 差异的最佳基准。3.2 menuconfig 里关于蓝牙的开关ESP32 的蓝牙协议栈和 WiFi 在同一个内核里默认配置不一定把 BLE 完全打开尤其是 Bluedroid 协议栈。在 VSCode 里打开工程根目录的sdkconfig或者执行idf.py menuconfig然后按这个路径检查Component config - Bluetooth需要确认这几个配置Bluetooth controller - Enable Bluetooth controller打开Bluetooth controller - Bluetooth controller mode选 BLE Only。如果选“Both”即同时支持经典蓝牙和 BLE内存消耗会明显变大本项目不需要经典蓝牙选 BLE Only 更稳。Bluedroid Options - Enable Bluedroid打开Bluedroid Options - Enable BLE打开如果你之前的工程用过 WiFi不需要改太多。但如果编译时出现蓝牙相关的内存不足错误可以回到这里把 Controller mode 改成 BLE Only并把不需要的 Bluedroid 功能关掉通常在Component config - Bluetooth - Bluedroid Options下面选择合适的配置集。有个容易被忽略的点ESP32 的蓝牙广播、扫描和 WiFi 共存时WiFi 流量会抢占一部分射频时间。如果后续你想一边跑 BLE 扫描一边跑 MQTT 上报实际射频吞吐会互相影响这是硬件层面的共享天线问题软件上只能通过调整 BLE 扫描窗口来缓解。这一点到后面实测章节还会再提。3.3 硬件与天线被很多人忽略的 RSSI 变量ESP32 不需要外接蓝牙模块板载天线就是那一小段 PCB 走线。但就是这个天线对 RSSI 的影响比大多数人想象的大。我第一次用 DevKitC 做测距时把板子横放在办公桌上天线朝左扫描端在正前方测出来 3 米距离 RSSI 是 -72dBm后来把板子竖起来、天线正对扫描端同样的 3 米RSSI 变成 -66dBm。差了 6dB在 n2.5 的模型里对应的距离误差超过 1.5 米。所以部署 beacon 时要注意几点天线不要贴着金属物体或墙面不要平放在金属桌面上广播端和扫描端的天线尽量保持同一朝向且中间不要有大面积遮挡如果使用外接 IPEX 天线检查馈线有没有松动天线损耗也会改变有效 TxPower。做标定和实测时固定板子的位置和姿态否则你测出来的波动可能不是算法的问题而是物理姿态的问题。4. 广播端把 ESP32 变成一台标准 iBeacon4.1 BLE 协议栈初始化顺序BLE 的初始化顺序比 GPIO、WiFi 都繁琐而且顺序错了基本就是复位或者不干活没有任何中间态。标准流程是释放经典蓝牙内存 - 初始化蓝牙控制器 - 使能控制器 - 初始化 Bluedroid - 使能 Bluedroid - 注册 GAP 回调。之所以要先释放经典蓝牙内存是因为 ESP32 在默认配置下同时包含 BR/EDR 和 BLE 两套协议栈。我们只用 BLE可以把经典蓝牙占用的 RAM 释放出来给协议栈和用户程序留更多空间。这个调用必须在控制器初始化之前完成。初始化代码看起来多实际就是模板。不过要注意每个esp_err_t返回值都值得检查我习惯用ESP_ERROR_CHECK包裹这些初始化调用至少在调试初期不放过任何一步失败。4.2 两种拼装 iBeacon 数据的方式一个是手动构造字节数组然后用esp_ble_gap_config_adv_data_raw直接设置广播数据。另一个是用esp_ble_ibeacon_t结构体填充字段然后调esp_ble_gap_config_adv_data。我推荐初学者用手动字节数组。理由很直接iBeacon 格式本身就是一串固定字节你手动拼一遍对字段顺序、大小端、Tx Power 符号的理解会非常深。结构体方式虽然看起来“正规”但一旦遇到版本兼容问题你得去翻头文件里的结构体定义反而绕。手动拼装的实现在 2.2 节已经有十六进制全貌了。把它搁到一个make_ibeacon_adv函数里参数是 UUID、Major、Minor、TxPower输出就是完整的 29 字节广播数据。4.3 广播参数与间隔选择设置完广播数据还要设置广播参数最重要的是广播间隔和广播类型。广播类型我们用ADV_TYPE_NONCONN_IND也就是不可连接、非定向广播。beacon 不需要被连接选择这种类型可以省电也避免扫描端误以为这个设备可连接而发起连接请求。广播间隔的单位是 0.625ms。adv_int_min和adv_int_max是两个数值控制器会在两者之间随机选择一次广播间隔。测距场景建议把间隔设在 60~160ms 这个范围既保证 RSSI 采样率足够又不会让功耗高得离谱。如果你追求省电比如电池供电的 beacon可以放宽到 500ms 甚至 1s但距离响应会明显变迟钝。这个要根据场景权衡用来做门禁判断当然是响应快一点好用来做库存周期盘点慢一点没问题。一个默认值陷阱是某些例程直接把adv_int_min和adv_int_max设成 0x2020ms这是为了演示广播能被秒收到但实际部署时 20ms 间隔的功耗和信道占用都偏激不建议照抄。4.4 完整广播端代码下面是广播端完整代码。UUID 我用了一个自定义值建议你自己生成一个随机 UUID避免和别人的 beacon 撞车。#include stdint.h #include string.h #include freertos/FreeRTOS.h #include esp_bt.h #include esp_bt_main.h #include esp_gap_ble_api.h #include esp_log.h static const char *TAG ibeacon_adv; static const uint8_t MY_UUID[16] { 0xFD, 0xA5, 0x06, 0x93, 0xA4, 0xE2, 0x4F, 0xB1, 0xAF, 0xCF, 0xC6, 0xEB, 0x07, 0x64, 0x78, 0x25 }; static uint8_t adv_data[29]; static bool adv_started false; static void make_ibeacon_adv_data(uint8_t *buf, uint16_t major, uint16_t minor, int8_t tx_power) { buf[0] 0x02; // Flags AD 长度 buf[1] 0x01; // Flags AD 类型 buf[2] 0x06; // LE General Discoverable不支持经典蓝牙 buf[3] 0x1A; // Manufacturer AD 长度 buf[4] 0xFF; // Manufacturer Specific Data buf[5] 0x4C; // Company ID 低字节 (Apple) buf[6] 0x00; // Company ID 高字节 buf[7] 0x02; // iBeacon 类型 buf[8] 0x15; memcpy(buf[9], MY_UUID, 16); buf[25] (uint8_t)(major 8); buf[26] (uint8_t)(major 0xFF); buf[27] (uint8_t)(minor 8); buf[28] (uint8_t)(minor 0xFF); buf[29] (uint8_t)tx_power; }注意这个代码里buf[29]已经越界了。adv_data数组总长 29 个字节有效下标是 0~28所以 TxPower 应该放在buf[28]。上面从buf[25]开始放 Major 会引起一系列偏移错误。我先把这段“带错”代码放这里是提醒你手拼广播包时数组下标必须时刻心里有数。正确版应该是static void make_ibeacon_adv_data(uint8_t *buf, uint16_t major, uint16_t minor, int8_t tx_power) { uint8_t i 0; buf[i] 0x02; // Flags AD 长度 buf[i] 0x01; // Flags AD 类型 buf[i] 0x06; buf[i] 0x1A; // Manufacturer AD 长度 buf[i] 0xFF; buf[i] 0x4C; buf[i] 0x00; buf[i] 0x02; buf[i] 0x15; memcpy(buf[i], MY_UUID, 16); i 16; buf[i] (uint8_t)(major 8); buf[i] (uint8_t)(major 0xFF); buf[i] (uint8_t)(minor 8); buf[i] (uint8_t)(minor 0xFF); buf[i] (uint8_t)tx_power; }这样i从 0 递增到 28刚好填满 29 字节。这种递增写法比硬编码下标安全得多后面加字段也方便。GAP 回调和主函数的完整实现static void esp_gap_cb(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { switch (event) { case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT: if (param-adv_data_raw_cmpl.status ESP_BT_STATUS_SUCCESS !adv_started) { esp_ble_adv_params_t adv_params { .adv_int_min 0x60, // 60ms .adv_int_max 0xA0, // 100ms .adv_type ADV_TYPE_NONCONN_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); adv_started true; ESP_LOGI(TAG, advertising start); } break; default: break; } } void app_main(void) { ESP_ERROR_CHECK(esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT)); esp_bt_controller_config_t bt_cfg BT_CONTROLLER_INIT_CONFIG_DEFAULT(); ESP_ERROR_CHECK(esp_bt_controller_init(bt_cfg)); ESP_ERROR_CHECK(esp_bt_controller_enable(ESP_BT_MODE_BTDM)); ESP_ERROR_CHECK(esp_bluedroid_init()); ESP_ERROR_CHECK(esp_bluedroid_enable()); ESP_ERROR_CHECK(esp_ble_gap_register_callback(esp_gap_cb)); make_ibeacon_adv_data(adv_data, 1, 100, -59); ESP_ERROR_CHECK(esp_ble_gap_config_adv_data_raw(adv_data, sizeof(adv_data))); ESP_LOGI(TAG, beacon setup done); }有一个细节要特别注意esp_ble_gap_config_adv_data_raw是异步的数据真正生效要等 GAP 回调里的ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT。所以不能在执行完这个函数后立刻调用esp_ble_gap_start_advertising必须等回调确认。我见过不少初学者在这里卡住广播参数配置好却没开广播因为数据还没设完就调 start 了。编译烧录之后用手机上的 nRF Connect 扫一下正常情况下几秒内就能看到一个名为“无名字”的广播设备点开广播包详情能看到我们拼的 29 字节数据。到这里广播端就通了。5. 扫描端接收广播、解析 iBeacon、换算出距离5.1 扫描参数与被动扫描扫描端的核心工作是持续监听广播信道抓取 iBeacon 广播包并读出 RSSI。ESP-IDF 里有一个esp_ble_scan_params_t结构体关键参数是扫描类型、扫描间隔和扫描窗口。扫描类型建议用BLE_SCAN_TYPE_PASSIVE。主动扫描会向广播者发送扫描请求拿回更多数据但对 beacon 场景没必要反而增加信道占用和功耗。被动扫描就是单纯地听拿到的广播数据已经包含了我们需要的 iBeacon 字段和 RSSI。扫描间隔和扫描窗口的单位同样是 0.625ms。scan_interval是两次扫描启动的间隔scan_window是每次真正监听的时长。窗口占间隔的比例就是扫描占空比。比如 interval 0x5050mswindow 0x3030ms占空比 60%意味着扫描器有六成时间在听广播信道。对测距场景扫描占空比不能太低否则容易漏广播包。如果 signal 到 5 米开外广播本来就弱扫描端还只开 30% 占空比可能好几秒才收到一包RSSI 采样就断了。我用的参数是 interval 50mswindow 30ms既能保证采样密度又给 WiFi 留了射频时间。另外scan_duplicate建议设成BLE_SCAN_DUPLICATE_DISABLE。如果开启重复过滤同一设备的广播在一段时间内只会报告一次RSSI 刷新率会被压得很低对测距非常不利。5.2 过滤与解析 iBeacon 广播帧扫描回调拿到的是一个esp_ble_gap_cb_param_t里面包含设备地址、广播数据、RSSI 等字段。广播数据是一串 AD Structure 的拼接所以要先循环遍历static void scan_result_parse(uint8_t *adv, uint8_t len, int8_t rssi) { uint8_t pos 0; while (pos 1 len) { uint8_t ad_len adv[pos]; if (ad_len 0) break; if (pos ad_len 1 len) break; uint8_t ad_type adv[pos 1]; if (ad_type 0xFF) { handle_ibeacon_report(adv[pos 2], ad_len - 1, rssi); break; } pos ad_len 1; } }解析到 Manufacturer Specific Data 之后需要校验 Company ID 和 iBeacon 类型再对比 UUID。扫描端可以同时收到很多 BLE 设备比如手机、手环、耳机它们也有广播包不校验的话会把无关设备的 RSSI 也拿进来算距离。判断逻辑是Company ID 等于 0x004C紧接着两个字节是 0x02 0x15再往下才是 UUID、Major、Minor、TxPower。对于一个 25 字节的 manufacturer 数据段字节偏移是偏移含义0~1Company ID小端2~30x02 0x154~19UUID16 字节20~21Major大端22~23Minor大端24Tx Powerint8_t这里要小心大小端转换。我在代码里直接用移位拼接读到manuf[20] 8 | manuf[21]得到 major。如果你用memcpy到一个 uint16_t 然后打印在低端平台上很可能得到 0x0100 这种反过来的值别在这里踩坑。5.3 距离计算的工程化做法解析出 TxPower 和 RSSI 后距离计算公式就是前面 2.3 节那个对数路径损耗模型。工程上要加两个边界处理如果 RSSI 比 TxPower 还大意味着信号比 1 米参考点更强这通常是反射叠加或者广播端离得太近造成的。此时距离直接钳制到 0.2 米而不是算出一个 0.5 米以下的“假精确值”。如果 RSSI 很弱比如接近 -95dBm 以下噪声底已经严重污染信号算出来的距离可能十几米甚至几十米没意义。建议设一个上限比如 15 米超出就报 15 米或标记为“不可信”。n 的取值需要标定。我的经验是先取一个中间值 2.5 跑到现场在同一位置固定广播端把扫描端放到已知 1 米、3 米、5 米三个位置记录 RSSI然后用公式反推 n。比如 1 米处 RSSI 是 -585 米处 RSSI 是 -78那么(TxPower - RSSI) / (10 * log10(5))算出来就是实际 n。这样比直接猜准得多。5.4 滤波让距离值不再乱跳的入门方案RSSI 单次测量值波动很大同一个位置、几秒内可能从 -68 跳到 -75。如果你直接把单次 RSSI 代入距离公式串口打印出来的距离会忽近忽远看起来毫无规律。最不动脑子的方案是滑动平均保留最近 N 个 RSSI取平均值。但我在实际测试中发现简单平均对“尖峰”很敏感。比如连续 5 个样本 -70、-69、-71、-80、-70一个 -80 就把平均值拉低了接近 2dB。所以我在扫描端代码里做了一个小改进取最近 8 个样本先去掉一个最大值和一个最小值再对剩余样本求平均。这个思路本质上是“截尾平均”可以滤掉突发干扰又不会像简单平均那样被尖峰带跑static int8_t rssi_history[RSSI_BUF_SIZE]; static int rssi_cnt 0; static int8_t rssi_filtered_average(int8_t rssi) { rssi_history[rssi_cnt % RSSI_BUF_SIZE] rssi; rssi_cnt; int valid_num (rssi_cnt RSSI_BUF_SIZE) ? rssi_cnt : RSSI_BUF_SIZE; if (valid_num 3) { return rssi; } int32_t sum 0; int8_t min_rssi 127, max_rssi -128; for (int i 0; i valid_num; i) { sum rssi_history[i]; if (rssi_history[i] min_rssi) min_rssi rssi_history[i]; if (rssi_history[i] max_rssi) max_rssi rssi_history[i]; } sum sum - min_rssi - max_rssi; int div valid_num - 2; if (div 0) div 1; return (int8_t)(sum / div); }这个滤波会带来延迟N 越大越平滑但也越迟钝。如果你要跟踪快速移动的物体把 N 调小到 4如果是静止或者慢速场景用 8~10 都可以。实测下来N 8 时办公室 5 米范围内滤波后的距离误差大约在 0.9 米左右比起直接用单值稳定很多。扫描端完整代码逻辑和广播端类似只是在ESP_GAP_BLE_SCAN_RESULT_EVT事件里处理扫描结果。流程是esp_ble_gap_set_scan_params设置参数收到ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT后调用esp_ble_gap_start_scanning。扫描结果回调里先解析 AD 结构过滤出 iBeacon再计算距离、打印日志。6. 实测复盘办公室里的 RSSI 坑与调参经验6.1 人体遮挡造成的信号骤降我在办公室做了组对照实验广播端固定在 1.5 米高的置物架上扫描端放在 3 米外的工位上两者之间没有人时 RSSI 稳定在 -68 左右。当一个人恰好站在中间通道时RSSI 直接掉到 -78~-80距离从 3 米左右被估算成 6~7 米。原因很简单2.4GHz 信号对人体含水组织非常敏感人体会吸收大量能量等效于在信号通路上加了一堵衰减墙。这不是代码能解决的问题是物理限制。部署时要做的是把 beacon 和扫描端尽量放到不受人走动影响的高度比如吊顶下方、货架顶端。如果必须经过人员活动区域就要接受一定的距离跳变靠滤波去平滑而不是想着“完全消除”。6.2 多径反射让距离忽远忽近另一个让人头疼的现象是同一个点在很短时间内RSSI 可能冒出一个“异常好”的值。比如实际距离 4 米大多数样本 RSSI 在 -75偶尔会跳出一个 -64。这是因为信号经过墙面、金属柜反射后多条路径的信号在接收端叠加形成建设性干涉让信号强度瞬间变强。这种异常值对距离判断很有迷惑性。如果你用简单平均偶尔的反常强信号会把距离算近很多。这也是我在滤波里做“去掉最大最小值”的原因。如果你做更严肃的应用可以考虑用滑动窗口内 RSSI 的最小值即最弱信号做保守估计或者用中位数而不是均值效果都更稳。6.3 广播间隔与扫描窗口的配合我曾经把广播端间隔设成 200ms扫描端用 interval 50ms、window 30ms结果每三四秒就漏掉一包。后来我把广播间隔压到 60~100ms扫描端保持原样数据密度立刻上来了。广播间隔和扫描窗口必须互相匹配。如果广播端 1 秒才发一次接收端再多采样也只能 1 秒收一条如果广播端发得很勤但扫描端窗口开得很短同样会漏。对测距来说广播间隔 60~160ms、扫描窗口不低于 30ms 是一组比较稳的组合。另外扫描端的scan_duplicate一定要关掉否则你可能发现 RSSI 一分钟都不更新一次那不是信号问题是被系统按“重复设备”过滤掉了。6.4 一组实测数据对比下面这组数据是我在同一间办公室、同一位置、固定广播端与扫描端测到的真实距离 3 米。广播间隔 100ms 左右扫描端每收到一条广播记录一次 RSSI。样本场景未滤波距离估计截尾平均后距离估计无干扰1.8~4.5m2.5~3.6m有人走过2.5~8.2m2.8~4.8m靠近金属柜1.5~6.8m2.2~4.2m可以看出滤波并不能让系统变得完全精准但它能明显压缩距离跳变的范围。对“区域内/区域外”这种布尔判断滤波后的稳定性已经足够支撑可靠的业务逻辑。7. 往下走三点定位、技术选型与更多玩法7.1 用多个 beacon 做二维定位单点测距只能告诉你“离这个 beacon 多远”不能确定具体位置。要想做平面定位至少需要三个已知坐标的 beacon然后用三边测量法解出观测点位置。最简单的方式是加权质心法假设观测点收到三个 beacon 的距离分别为 d1、d2、d3那么把三个 beacon 坐标按照 1/d² 加权求质心就能得到观测点的估计坐标。这个方案实现简单精度一般适合区域级判断。如果你想要更精确一些用最小二乘法解三边测量。本质是把三个圆的方程两两相减线性化成一个超定方程组再用矩阵伪逆求解。ESP32 上跑这个计算并不吃紧矩阵规模很小。但前提是三个距离值本身得稳输入就是一堆噪声再怎么解算也救不回来。7.2 RSSI 测距与 UWB 等技术的边界我遇到过不少人一上来就问“蓝牙 beacon 能不能做到 10 厘米精度”。答案是不能。RSSI 受环境影响太大物理上限就在那里。如果你需要厘米级定位应该考虑 UWB、蓝牙 5.1 AoA/AoD 或者视觉定位。UWB 通过测量信号飞行时间ToF来测距精度能到 10~30cm但硬件成本高、功耗高、部署也复杂。蓝牙 5.1 的方向定位则依赖多天线阵列测角精度不错但需要专门的硬件和算法。beacon RSSI 的价值区间在“低成本、低功耗、1~5 米精度”适合做区域存在感知、靠近提醒、设备盘点而不是精密测量。技术选型的关键是先明确你的需求只需要“区域级”还是“厘米级”。绝大多数室内场景比如人来唤醒设备、人员是否靠近危险区域、设备是否离位其实都是区域级需求beacon 完全够用。7.3 结合 WiFi 上报与 OTA 的扩展回到这个系列的“联网”主题。这一讲我们做的蓝牙测距其实很适合和 WiFi 联动扫描端算出来距离之后可以直接通过 MQTT 或 HTTP 上报到服务器后端拿到多个节点的距离数据做区域判断和告警。ESP32 本身同时支持 WiFi 和 BLE所以同一块板子既能做 BLE 扫描又能当联网节点这个组合在很多 IOT 场景里非常实用。我当时还做了一个小扩展扫描端把 RSSI 数据和估算距离通过 MQTT 推到 Home Assistantbeacon 挂在钥匙串上回家靠近门口的 ESP32 时HA 自动执行“回家模式”的开关灯逻辑。整个项目没有用到任何云平台纯本地局域网就完成了。另外广播端的 beacon 固件也有升级需求可以在广播参数之外再加一个 GATT Service允许手机或扫描端通过 BLE 连接设备后修改 UUID、Major、Minor 和广播间隔甚至触发 OTA 升级。ESP-IDF 的 OTA 能力在多讲之前的例程里已经跑通过这里只是把触发条件从按钮换成蓝牙指令而已。最后分享一点我个人的体会RSSI 测距这个事物理规律决定上限软件只能尽量接近这个上限。真正把项目做稳的不是什么高端滤波算法而是对部署环境的理解——天线朝哪、挂在多高、中间有没有人走、旁边有没有金属架子。这些“脏活”做好了哪怕代码朴素一点效果也不会差。反过来代码写得再花哨天线贴着金属板出来的数据一样没法用。