一直做 ESP32 的联网项目,Wi-Fi 相关的应用是熟门熟路了,直到有个室内展馆的需求把我按在地上摩擦:参观者走近某个展品到一定距离时,要触发语音讲解,精度不需要厘米级,但要稳定且延迟低。GPS 进室内直接废掉,Wi-Fi 指纹定位又太重,最后我盯上了蓝牙 beacon 方案。于是重新把 VSCode + ESP-IDF 这套环境翻出来,用 ESP32 扫描 BLE beacon 做 RSSI 距离估算,从原理到代码再到实际标定完整跑了一遍。这篇文章就是把这段经历整理出来,包括 iBeacon 帧格式、扫描回调处理、距离换算公式、参数标定方法,还有一堆只有真机实测才会踩到的坑,给做室内定位、防丢提醒、近场交互项目的朋友省点时间。
1. Beacon 测距的底层逻辑:为什么 RSSI 能换算成距离
1.1 室内定位方案对比:为什么偏偏是蓝牙 beacon
做室内距离触发,可选的方案其实有好几个,但每个都有明显的短板。
GPS 在室内的信号衰减非常严重,尤其是混凝土结构的建筑里,定位误差可以到几十米,精确定位基本不用想。UWB(超宽带)方案精度确实高,能做到厘米级,但成本也高,beacon 标签几百块一个,接收端设备也不便宜,对大部分应用来说属于杀鸡用牛刀。Wi-Fi 指纹定位需要先在场地里做密集的指纹采集,现场环境一变(比如移动了货架、关了扇门)指纹数据就失效,维护成本太高。
蓝牙 beacon 在这个场景下刚好卡在中间:beacon 标签几块钱到十几块钱一个,广播功耗极低(一块纽扣电池能用一年以上),手机、嵌入式设备几乎都支持 BLE 扫描。更重要的是,ESP32 本身就带 BLE 硬件,等于接收端零额外成本。所以最终方案很明确:在展品旁边放一个或者多个 BLE beacon,ESP32 作为扫描接收端持续监听,通过接收到的信号强度估算距离,达到阈值就触发动作。
1.2 信号衰减模型:RSSI 和距离不是线性关系
蓝牙 beacon 测距的原理说白了就是一件事:信号在空中传播时会衰减,距离越远,接收端的 RSSI 值越低。但这里面有个关键点,RSSI 和距离之间不是线性关系,而是对数关系。
信号在自由空间里衰减的物理模型中,接收功率和距离的关系大致是:
RSSI = A - 10 * n * log10(d)其中:
- A 表示距离发射端 1 米时测到的 RSSI 值(dBm)
- n 是路径损耗指数,和环境密切相关。自由空间里 n 约等于 2,室内因为有墙壁反射、人体吸收等干扰,n 通常在 2.5 到 4 之间
- d 是距离(米)
为什么要用对数而不是线性?因为信号能量在传播中是按指数规律衰减的。就好比声音,你在 1 米外听到的音量和 2 米外不是差一半,而是差了大概 6dB。蓝牙信号每远离一倍距离,RSSI 大约下降 6dBn 个 dB,这个关系用对数画出来是一条接近直线的曲线,用线性去拟合反而误差更大。
这个公式是后面所有距离换算的基础,理解这一点,后面标定参数时就不会懵。
1.3 iBeacon 帧格式到底长什么样
市面上 BLE beacon 协议有两类主流:iBeacon(苹果提出)和 Eddystone(谷歌提出)。我这次用的是 iBeacon,ESP-IDF 官方示例也是以它做演示的,理解一个,另一个也就顺带通了。
iBeacon 的广播数据包结构是这样的:
| 字段 | 字节数 | 内容 |
|---|---|---|
| Company ID | 2 | 厂商标识,Apple 是 0x004C |
| iBeacon 标识 | 2 | 固定为 0x02 0x15,用于识别这是 iBeacon 帧 |
| UUID | 16 | 区分不同应用或项目 |
| Major | 2 | 区分同一应用下的分组(比如楼层) |
| Minor | 2 | 区分同一分组下的具体设备(比如具体展位) |
| TX Power | 1 | 距发射端 1 米处的 RSSI 参考值(有符号数) |
注意这里的字节序很关键,Major 和 Minor 是大端模式,也就是高字节在前。我在第一次解析时就直接按小端读了,结果数字完全对不上,查了半天才发现是这个问题。
TX Power 字段值得多说两句。它代表的是距离发射端 1 米处应该测到的 RSSI 理论值,由 beacon 标签厂家出厂时标定写入。正常来说这个值在 -59dBm 到 -65dBm 之间。有些文章里的公式用 TX Power 代替 A 值直接用,但实际上,厂家标定的 TX Power 是基于他们自己的测试环境,你的使用环境墙面地面材质、天线摆放方向、接收端不同,实际 1 米处的 RSSI 都会不一样。所以做项目时,最好还是自己实测标定 A 值,不要盲目信任 beacon 里写死的那个 TX Power。
2. VSCode + ESP-IDF 环境准备:BLE 扫描器工程搭建
2.1 工程创建前必须确认的 ESP-IDF 版本
如果你已经装好了 VSCode 和 ESP-IDF 插件,可以直接跳过这段。但如果你像我一样是从零开始,或者准备新开一个项目,有几个版本相关的问题值得先确认。
我这边用的是 ESP-IDF v5.4.4,VSCode 装的是 Espressif IDF 官方插件。在创建工程之前,先把插件安装好,然后在 VSCode 的命令面板里输入ESP-IDF: Configure ESP-IDF Extension,插件会自动检测或者帮你下载指定版本的 ESP-IDF 工具链。这里有个经验:别贪新直接上最新版本,ESP-IDF 的 API 在不同版本间是有变动的,我就遇到过 v5.2 和 v5.4 在蓝牙回调函数参数类型上做微调的情况。如果项目赶时间,建议用官方长期支持的版本,比如 v5.3.x 或 v5.4.x,配套的示例代码也最稳定。
另外要注意,ESP-IDF 插件默认会创建一个工作区,工程路径里尽量不要包含中文和空格,否则编译时可能出现一些莫名其妙的路径问题。
2.2 menuconfig 蓝牙协议栈配置
创建工程时,VSCode 命令面板输入ESP-IDF: Create Project from Template,在模板列表里找bluetooth/bluedroid/ble/ble_ibeacon,这个示例虽然名字是 iBeacon,但它同时包含了广播端和扫描端两种角色,做扫描端可以少写不少初始化代码。
工程创建完成后,第一件事不是写代码,而是用ESP-IDF: Menuconfig打开配置界面,把蓝牙栈搭好。
要修改的配置项:
Component config → Bluetooth → Bluetooth → Enabled Component config → Bluetooth → Bluetooth host → Bluedroid Component config → Bluetooth → Bluedroid Options → Max Classic Bluetooth Connections → 0如果扫描端只用 BLE,不涉及经典蓝牙,把经典蓝牙连接数设为 0 可以省下不少内存。同理,Max BLE Connections也改成 1,因为我们只是扫描端,不建立连接。这样编译出来的固件体积更小,运行时也更稳定。
这里补充一个选择点:ESP-IDF 支持两套蓝牙协议栈,Bluedroid 和 NimBLE。Bluedroid 功能全、兼容性好,官方示例多,但内存占用大;NimBLE 轻量、代码简洁,适合纯 BLE 场景。我这次用的是 Bluedroid,因为要参考官方 ble_ibeacon 示例,用 esp_ble_gap 系列 API 写起来直接。如果你的项目只有扫描这一个功能,NimBLE 也是个不错的选择,但 API 体系和 Bluedroid 不同,代码没法直接照搬。
2.3 扫描模式选择:主动扫描是关键
配置完协议栈,还要在代码里碰到一个容易被忽略的概念:扫描模式。
BLE 扫描分为主动扫描(Active Scan)和被动扫描(Passive Scan)。被动扫描只是干听,只接收广播者周期性发的 ADV_IND 广播包;主动扫描会在收到广播后,额外发送一个 SCAN_REQ 给广播者,请求对方再发一个包含更多数据的扫描响应包(SCAN_RSP)。
那 beacon 数据到底在哪?大部分 iBeacon 直接把广播数据放在 ADV_IND 的 adv data 里,被动扫描也能拿到。但有些广播设备厂商会把一部分数据塞进 SCAN_RSP,这时候如果用被动扫描,就永远拿不到完整数据。所以我的建议是直接用主动扫描,多花一点点功耗,换来的数据完整度是值得的。
扫描参数结构体里相关配置项:
static esp_ble_scan_params_t ble_scan_params = { .scan_type = BLE_SCAN_TYPE_ACTIVE, .own_addr_type = BLE_ADDR_TYPE_PUBLIC, .scan_filter_policy = BLE_SCAN_FILTER_ALLOW_ALL, .scan_interval = 0x100, .scan_window = 0x50, .scan_duplicate = BLE_SCAN_DUPLICATE_DISABLE };scan_interval和scan_window的单位是 0.625 毫秒,所以 0x100(256)约等于 160ms 的扫描间隔,0x50(80)约等于 50ms 的扫描窗口。窗口越大,抓到广播包的概率越高,功耗也越高。对 beacon 这种一般 100ms 广播一次的场景,这个比例覆盖得比较稳。
scan_duplicate这个参数更关键。如果设成 ENABLE,硬件会自动过滤掉重复地址的广播,beacon 是周期性广播,MAC 地址不变,很容易被过滤掉,导致只触发一次回调就再也不更新了。所以扫描 beacon 时务必设成 DISABLE。
3. 扫描与解析:从裸广告数据到完整 iBeacon 信息
3.1 初始化 BLE 主机并设置扫描参数
用 Bluedroid 协议栈,初始化流程比想象中要短,核心就三步:注册回调、设置扫描参数、开始扫描。
esp_err_t ret; esp_bluedroid_init(); esp_bluedroid_enable(); esp_ble_gap_register_callback(gap_event_handler); esp_ble_gap_set_scan_params(&ble_scan_params); ret = esp_ble_gap_start_scanning(30); ESP_LOGI(TAG, "start scanning, ret=%d", ret);esp_ble_gap_start_scanning的参数是扫描持续秒数,传 0 表示持续扫描直到主动调用esp_ble_gap_stop_scanning()。我习惯传 0,靠应用逻辑控制扫描生命周期。
这套流程几乎不需要修改,直接搬进工程就行。要注意的是,esp_bluedroid_enable()之后不能立刻调用esp_ble_gap_*系列的 API,需要等蓝牙控制器真正 ready。实际开发中如果你发现前几步没问题但扫描从来没开始,多半就是这里时序问题。稳妥的做法是在ESP_BT_STATUS相关的事件确认后再触发扫描,不过 ESP-IDF 内部对大部分初始化调用做了队列缓冲,直接链式调用也基本能跑通,只是不推荐。
3.2 扫描回调事件处理流程
核心逻辑全在gap_event_handler里。扫描到广播帧时,系统会触发ESP_GAP_BLE_SCAN_RESULT_EVT事件,代码要在这个事件里辨别当前是哪种搜索结构:
static void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { switch (event) { case ESP_GAP_BLE_SCAN_RESULT_EVT: if (param->scan_rst.search_evt == ESP_GAP_SEARCH_INQ_RES_EVT) { // 处理单次扫描结果 handle_ibeacon_adv(¶m->scan_rst); } else if (param->scan_rst.search_evt == ESP_GAP_SEARCH_INQ_CMPL_EVT) { ESP_LOGI(TAG, "scan completed"); } break; default: break; } }这里容易犯的错是把ESP_GAP_BLE_SCAN_RESULT_EVT和ESP_GAP_SEARCH_INQ_RES_EVT混为一谈,实际上前者是事件类型,后者是当前扫描结果的具体状态。事件里的scan_rst结构体包含了好几个关键字段:
bda:广播者 MAC 地址rssi:本次测得信号强度ble_adv:广告数据缓冲区adv_data_len:adv data 的长度scan_rsp_len:扫描响应数据的长度
注意,ble_adv缓冲区里既有 adv data 又有 scan rsp,需要根据两个长度字段区分区间。我当时就是想当然地认为整个缓冲区都是 adv data,结果解析出了谁都看不懂的乱码。
3.3 解析厂商特定数据段
拿到广告数据后,下一步是从里面把 iBeacon 厂商数据提取出来。BLE 广播数据是按 AD Structure 组织的,每个结构是[长度][类型][数据]三元组,长度字段包含类型字节但不包含长度字节本身。
iBeacon 属于厂商特定数据,AD type 是 0xFF。ESP-IDF 提供了现成接口:
uint8_t *adv_data = param->scan_rst.ble_adv; uint8_t len = param->scan_rst.adv_data_len; uint8_t manu_len = 0; uint8_t *manu_data = esp_ble_resolve_adv_data(adv_data, ESP_BLE_AD_TYPE_MANU_FACTURER, &manu_len);这个函数会在广告数据里自动遍历查找指定类型的 AD Structure,返回数据段指针。找到厂商数据后,再按 iBeacon 格式进一步解析:
typedef struct { uint8_t company_id[2]; // 厂商ID uint8_t ibeacon_type[2]; // iBeacon 固定标识 0x02 0x15 uint8_t uuid[16]; // UUID uint16_t major; // 大端序 uint16_t minor; // 大端序 int8_t tx_power; // 1米处RSSI参考值 } __attribute__((packed)) ibeacon_info_t; if (manu_data != NULL && manu_len >= 25) { ibeacon_info_t *info = (ibeacon_info_t *)manu_data; if (info->company_id[0] == 0x4C && info->company_id[1] == 0x00) { if (info->ibeacon_type[0] == 0x02 && info->ibeacon_type[1] == 0x15) { uint16_t major = (info->major >> 8) | (info->major << 8); uint16_t minor = (info->minor >> 8) | (info->minor << 8); int8_t tx_power = info->tx_power; // 到这里就是一个完整有效的 iBeacon 帧 } } }manu_len >= 25这个判断是必须的,因为一个完整的 iBeacon 厂商数据段最少有 25 字节(2 字节厂商 ID + 2 字节 iBeacon 标识 + 16 字节 UUID + 2 字节 Major + 2 字节 Minor + 1 字节 TX Power)。我之前写的是>= 23,虽然也能通过判断,但容易让后面结构体强制转换时发生越界访问,建议直接写成 25。
3.4 多 beacon 管理:MAC 与 UUID 的双重区分
实际项目里不可能只有一个 beacon,展馆里几十个展位,每个展位放一个,扫描端必须能区分谁是谁。区分逻辑有两层:
第一层是靠 UUID 区分归属。同一个项目的所有 beacon 共享同一个 UUID,解析出来以后先和预置的 UUID 比对,不一致的直接丢弃,这样外来的干扰 beacon 就不会混进来。
第二层是靠 Major/Minor 或者 MAC 地址区分具体设备。这里我建议优先用 MAC 地址做设备 ID,因为 MAC 在扫描结果里本来就带着,不需要额外解析。但要注意,beacon 广播是可以改 MAC 地址的(随机地址类型),如果厂商固件里开了 MAC 随机化,MAC 就会周期性变化,这时候就不能用它做稳定标识,只能靠 Major/Minor 组合来识别。
在代码里,我维护了一个简单的结构体数组:
#define MAX_BEACON_NUM 16 typedef struct { uint8_t mac[6]; uint8_t major[2]; uint8_t minor[2]; int8_t rssi; float distance; uint32_t last_seen; } beacon_node_t; static beacon_node_t s_beacons[MAX_BEACON_NUM];每次扫描到有效 iBeacon 帧,就按 MAC 或者 Minor 值在数组里查找,找到就更新 RSSI 和last_seen,没找到就找空槽位插入。定期检查last_seen,超过 3 秒没更新的节点判定为离线,移除。这个环形缓冲的思路对几十个 beacon 的场景完全够用,不需要上复杂的哈希表。
4. RSSI 到距离:标定、滤波与换算公式实操
4.1 路径损耗公式与参数的物理含义
有了 RSSI 值,下一步就是换算成距离。这里我再强调一遍公式:
distance = 10 ^ ((A - RSSI) / (10 * n))其中 A 是 1 米处测到的 RSSI 参考值,n 是路径损耗指数。
这个公式是从对数衰减模型反推出来的。为什么 RSSI 和距离的关系能用这个公式?因为电磁波在传播中,接收功率与距离的 n 次方成反比(自由空间里 n=2,也就是平方反比关系),用 dB 表示,接收功率就是距离对数的线性函数。反过来,知道 RSSI 就能反推距离。
这里有个新手最容易犯的错:直接把 iBeacon 帧里的 TX Power 当 A 用。TX Power 确实是厂家在 1 米处测出来的 RSSI,但那是针对厂家测试环境、厂家接收设备和无遮挡环境来说的。你的场地地面、墙面、金属装饰、无线干扰源,都会让实际 1 米处的 RSSI 偏离这个标称值。我实测过同一个 beacon,手机在 1 米处测到 -58dBm,ESP32 在同一位置测到 -61dBm,差了 3dB。3dB 看着不大,代入公式算距离误差可能超过三成。所以必须实测标定。
4.2 实测标定 A 值和 n 值的完整过程
标定这事说难不难,但要做对。我用的方法分三步:
第一步,标定 A 值。找一个相对空旷的场地,把 beacon 和 ESP32 都固定在高度 1 米左右的位置(比如用三脚架或者堆箱子),距离正好 1 米(用卷尺量,别目测)。连续采集 50 次 RSSI,取平均值作为 A。为什么要 50 次?因为 RSSI 单次测量波动很大,同一位置连续采可能相差 5 到 8dB,只有取平均才能把随机起伏消掉。
第二步,标定 n 值。把 beacon 移到 3 米处,同样固定高度,连续采 50 次 RSSI 取平均。然后代入公式反求 n:
n = (A - RSSI_3m) / (10 * log10(3))比如实测 A = -61dBm,3 米处平均 RSSI = -74dBm,那 n = (61 - 74) / (10 * 0.477) ≈ 2.72。这个 2.72 在室内场景算正常区间,如果算出来超过 4,说明场地遮挡太厉害,或者有强反射,beacon 测距的可靠性会明显下降。
第三步,验证。把 beacon 放到 2 米处,用标定的参数算距离,看误差在多少。我这边实测下来,2 米处算出来 1.8 到 2.3 之间波动,这个精度对触发类应用已经够用。如果误差超过 0.5 米,建议重新确认场地环境是否过于复杂,或者多增加几个采样点做分段拟合,把场地划分成"近距离段"和"远距离段",用不同的 n 值。
4.3 滑动平均与异常值剔除的组合滤波
RSSI 波动大是测距误差的主要来源,尤其在有人走动、有金属架的室内环境,单次测量值能跳 10dB 以上。如果不滤波直接代入公式,算出来的距离会像心跳一样来回蹦。我试过把原始 RSSI 直接换算距离,1 秒内的输出能从 0.8 米跳到 2.7 米,完全没法用。
最简单的滤波是滑动平均,开一个环形缓冲区,存最近 N 次 RSSI 值,每次取平均:
#define RSSI_FILTER_SIZE 10 static int8_t s_rssi_buf[RSSI_FILTER_SIZE]; static uint8_t s_rssi_idx = 0; static int8_t rssi_filter(int8_t new_rssi) { s_rssi_buf[s_rssi_idx] = new_rssi; s_rssi_idx = (s_rssi_idx + 1) % RSSI_FILTER_SIZE; int32_t sum = 0; for (int i = 0; i < RSSI_FILTER_SIZE; i++) { sum += s_rssi_buf[i]; } return (int8_t)(sum / RSSI_FILTER_SIZE); }但单纯滑动平均有个问题:如果某次测量出现极端值(比如人体正好挡住天线,RSSI 瞬间掉到 -95dBm),平均结果会被拉偏。所以在平均之前,我加了一步异常值剔除:把当前采样值和上一次滤波输出比较,偏差超过 8dB 的就丢弃,不进入滑动窗口。这里 8dB 的阈值不能设得太小,否则正常波动也会被滤掉导致更新迟滞;也不能太大,否则起不到剔除作用。实测下来 8 到 10dB 比较合适。
这一套组合滤波代码量不大,但效果非常明显。加上滤波后,距离输出从"乱跳"变成了"平滑爬升",对触发类应用来说体验完全不同。
5. 数据落地:串口可视化与 OLED 实时显示
5.1 JSON 格式输出串口,方便上位机解析
测距结果最终要给业务逻辑用。我这里分了两条路:一条是开发调试时用串口输出,方便在电脑上实时看数据;另一条是接 OLED 屏幕,现场直接显示。
串口输出我用的是 JSON 格式,一行一条记录:
ESP_LOGI(TAG, "{\"mac\":\"%02X:%02X:%02X:%02X:%02X:%02X\",\"major\":%d,\"minor\":%d,\"rssi\":%d,\"distance\":%.2f}", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5], major, minor, filtered_rssi, distance_m);这里要强调一个坑:ESP-IDF 的ESP_LOGI默认会在日志前面加时间戳和 TAG 前缀,如果上位机要直接按 JSON 解析,得在 menuconfig 里把日志输出格式改成无前缀的,或者上位机解析时跳过前缀部分。我开发时直接用 VSCode 串口监视器看文本,倒是无所谓,但如果你要对着 Python 脚本解析,就得注意这个。
Python 端解析脚本很简单,用json.loads()逐行读取即可,如果日志带了前缀,就先用正则把{...}部分抓出来再解析。
5.2 OLED 实时显示距离
现场演示时不能总拖着电脑,我接了一块 0.96 寸 SSD1306 OLED,走 I2C 接口。这里顺带一提,如果项目里同时挂了其他 I2C 设备(比如温湿度传感器),ESP-IDF v5.x 支持多个 I2C 控制器,可以在 menuconfig 里分别配置两组 SDA/SCL 引脚,互不干扰。我就是把显示和其他传感器分到了两个控制器,省去了地址冲突和总线占用的问题。
OLED 驱动代码用现成的 ssd1306 驱动即可,显示逻辑很简单:每 1 秒刷新一次,第一行显示 Minor 值标识哪个 beacon,第二行显示 RSSI 和换算后的距离:
char line1[32], line2[32]; snprintf(line1, sizeof(line1), "beacon:%d", minor); snprintf(line2, sizeof(line2), "%.1fm rssi:%d", distance_m, filtered_rssi); ssd1306_draw_string(&dev, line1, 0, 0); ssd1306_draw_string(&dev, line2, 0, 16);刷新频率不要太高,OLED 本身刷新带宽有限,而且 100ms 刷一次肉眼也看不出区别。1 秒一次既能直观看到距离变化趋势,又不给主循环增加负担。
5.3 扩展思路:三点定位与防丢提醒
beacon 测距最直接的扩展就是三点定位。原理很简单:已知三个 beacon 的物理坐标(比如在展厅平面图上是固定的),手机或移动端分别测到到三个 beacon 的距离,就能用三边测量法解出自身位置。前提是三个 beacon 位置别摆成一条直线,不然方程组解不出来。ESP32 做接收端时也是一样,相当于移动端,只需要周期性汇总三个方向的距离即可。
另一个我做过的扩展是防丢提醒。给贵重物品贴一个 beacon,人身上带一个 ESP32 手表或者手环,当距离超过预设阈值(比如 5 米)就触发振动或者蜂鸣。距离阈值不要设太死,因为 RSSI 波动在临界点附近会导致频繁触发,建议加迟滞逻辑:超过 6 米才报警,回到 4 米以内才解除,这样不会在边界反复横跳。
6. 实测中的坑与调参经验
6.1 扫描不到 beacon 的排查链路
第一次跑代码时,串口啥都没有,一个 beacon 都扫不到。我当时按这个顺序排查,没走弯路,分享出来:
第一步,确认 beacon 本身在广播。用手机装一个 nRF Connect 或者 Beacon Scanner 应用,看能不能搜到这个 beacon,能搜到就说明硬件没问题,搜不到先检查 beacon 电池和开关。
第二步,确认 ESP32 的配置。menuconfig 里 Bluetooth 有没有 Enable,Bluetooth host 是不是选对了,这些都是最基础但最容易漏的。
第三步,确认扫描参数。这里最隐蔽的是scan_duplicate设置,一旦设成 ENABLE,beacon 的周期广播会被过滤掉,表现就是回调事件只触发一次后就不再响应。
第四步,确认扫描窗口大小。如果scan_window太短,比如小于 10ms,beacon 广播可能正好落在窗口之外。尤其是 Wi-Fi 也在跑的情况下,窗口会被压缩,更不容易抓到。我最后用的是 interval 0x100、window 0x50,实测覆盖效果不错。
6.2 距离跳变严重:从硬件到软件的完整排查
扫到了 beacon,也换算出了距离,但距离值像股票走势图一样上蹿下跳,这是做测距项目百分之百会遇到的问题。我踩完一圈,总结出三个层面的原因:
硬件层面,ESP32 的 PCB 天线是有方向性的。我一开始把 ESP32 板子竖着放在桌面上,beacon 放在正前方,信号倒是稳定。后来为了现场布线方便把板子平放,同样的距离,RSSI 差了 6 到 8dB,距离直接从 1.5 米跳到 2.5 米。所以做标定和部署时,接收天线的朝向和高度必须固定,不要换来换去。
环境层面,人体遮挡和金属反射是两大元凶。展厅现场有人路过和没人路过,同一位置的 RSSI 能差 10dB 以上。金属展架对信号的反射会让某些位置信号增强(多径效应),有些位置信号反而相消变弱。这就是为什么我没法靠一套参数打天下,必须实地标定的原因。
软件层面,滤波参数没有适配到当前环境的波动幅度。前面说的异常值剔除阈值如果设得太小,会把正常的波动也滤掉,导致输出值一直卡在旧值不动。我建议拿到一组实测数据后先看一下 RSSI 的标准差,如果波动在 5dB 以内,窗口 10 次就够;如果波动超过 8dB,窗口拉大到 20 次,同时把剔除阈值放宽到 12dB。
6.3 Wi-Fi 与 BLE 共存干扰
ESP32 一个射频前端要同时服务 Wi-Fi 和 BLE,二者共用天线,同一时刻只能跑一种协议。Wi-Fi 是抢占式的,当 Wi-Fi 在大量收发数据时,BLE 的扫描窗口会被压缩甚至完全挤掉,表现就是 RSSI 采集变得稀疏,距离更新卡顿。
如果项目里 Wi-Fi 和 BLE 同时跑(比如测距同时还要把数据上传服务器),有几个可操作的优化方向:
- 降低 Wi-Fi 工作强度,比如减少 MQTT 上报频率,别一直满速传文件
- BLE 扫描参数调得更激进,窗口加大,弥补被抢占的部分
- 实测下来,把 Wi-Fi 的广播间隔调大、关闭 WiFi 省电模式下的一些主动扫描行为,对 BLE 扫描的干扰会明显减少
我做过一组对照实验:Wi-Fi 空闲时,BLE 扫描到 beacon 的间隔稳定在 120ms 左右;Wi-Fi 高速下载时,这个间隔抖动到 500ms 以上。所以如果你发现距离刷新卡顿了,先别急着怀疑代码,看看是不是 Wi-Fi 占了射频。
另外,ESP32 的 2.4G 射频同时开 Wi-Fi 和 BLE 时,建议把 ESP32 和 beacon 的距离控制在 10 米以内,超过这个距离,接收灵敏度下降,RSSI 波动会更大,测距结果基本不可用。
调参这事,我给个土办法:先在空旷地标一组 A 和 n,然后拿实际场地跑一遍,看距离误差往哪个方向偏。如果算出来的距离普遍偏大,说明实际环境衰减比标定时厉害,就把 n 调大一点;反之调小。不用纠结非要一次性标定完美,beacon 测距本质上是个"够用就好"的东西,对触发类应用来说,阈值附近留够迟滞区间,波动就不至于影响功能。