
先把结论放在前面这一讲咱们用一个周末就能做完效果是ESP32在VSCode里用ESP-IDF扫描低功耗蓝牙beacon解析出iBeacon广播包里的UUID、Major、Minor和发射功率再结合接收信号强度RSSI算出距离串口打印出来。整个过程不需要额外的蓝牙模块ESP32自带的BLE控制器就够了。这篇文章适合已经跑通过ESP-IDF环境、点过灯的读者。你要是还卡在环境搭建建议先回去把前面几讲的基础过一遍再来啃这个。当然如果你用的是Arduino思路也通用只是API不同我把ESP-IDF的实现细节讲清楚之后你也能照葫芦画瓢。1. 为什么用蓝牙beacon测距方案选型前的三个判断1.1 室内定位的几种常见技术怎么选做室内定位或者近场感知大家第一反应可能是UWB、WiFi RSSI、蓝牙RSSI这几个方向。UWB精度能做到厘米级但硬件成本高终端支持还没全面普及WiFi RSSI依赖现有路由器的部署位置指纹库维护起来头疼蓝牙beacon的优势在于低功耗、低成本、生态成熟而且手机和嵌入式设备天然支持BLE哪怕不做专门的网关普通ESP32就能扫描周围信标。如果你只需要粗糙的位置感知比如判断“人走进了哪个房间”“设备靠近了哪个工位”“导览到哪个展品附近”蓝牙beacon测距完全够用。它的精度通常在2到5米范围内达不到厘米级但胜在布点灵活一个CR2032电池能撑半年以上改造现有场地也不用拉线。1.2 ESP32做接收端的底气在哪ESP32系列芯片从出厂就内置了完整的蓝牙协议栈支持BLE 4.2甚至BLE 5.0不同型号有差异不需要外挂蓝牙芯片。这点对做产品原型特别友好一个模组解决WiFi和蓝牙两件事PCB面积和BOM成本都省下来了。另外ESP32在ESP-IDF框架下对蓝牙GAP层的接口封装得比较清晰扫描、广播、连接管理都有现成的API。底层逻辑虽然复杂但我们做应用层调用并不需要深究HCI命令。尤其是它的RSSI是硬件上报的扫描事件里直接带出来省去了很多中间转换。1.3 ESP-IDF比Arduino强在哪用Arduino开发蓝牙beacon扫描也能做库很成熟几行代码就能拿到广播包。但项目一旦复杂起来比如要同时跑MQTT、OTA、传感器采集和蓝牙扫描Arduino的FreeRTOS后台机制有时候就不够清晰了。ESP-IDF是乐鑫官方的物联网开发框架基于FreeRTOS任务调度、内存管理、事件循环、日志系统都是工程级的。最关键的是ESP-IDF允许你精确控制BLE扫描参数比如扫描窗口、扫描间隔、主动扫描还是被动扫描、重复过滤策略这些对beacon扫描的实时性和功耗影响非常大。Arduino的BLE库也能设置一部分但可调范围不如原生API。后续你要是想把这套代码迁移到ESP32-C3或者ESP32-S3上IDF工程基本可以无缝切换。2. 开发环境准备VSCodeESP-IDF从零到位2.1 安装ESP-IDF插件的最省事路径现在新用户开发ESP32我的建议是直接用VSCode的Espressif IDF插件不要再去手动折腾命令行工具链了。你去VS Code扩展市场搜“ESP-IDF”装那个由Espressif官方发布的插件版本号前缀是1.x的就行。装完插件按F1打开命令面板输入“ESP-IDF: Configure ESP-IDF Extension”。这里有个容易踩坑的点如果你电脑上之前装过ESP-IDF命令行版本插件会问你选择已有的IDF还是下载新的。建议直接用“Express”模式让插件帮你下载一个集成好的工具链省得环境变量各种冲突。下载过程中如果卡住多半是网络问题需要重试几次或者换成设置HTTPS代理的方式。路径上不要有中文和空格我习惯放在C:\esp\这类纯英文短路径下。网上有人反馈IDF文件装到了C盘用户目录半天找不到其实插件右下角状态栏会显示当前IDF版本和Workspace文件夹点开就能看到具体路径。另外补充一句最简单直接的下载途径仍然是去乐鑫官网拿到离线安装器国内网络环境下成功率更高。2.2 创建工程和menuconfig里的三个关键配置命令面板输入“ESP-IDF: Create Project”选择一个模板工程选“hello_world”就行后面我们自己改代码。芯片型号在左下角设置里选ESP32、ESP32-S3、ESP32-C3都可以前提是你板子上确实那颗芯片。编译之前打开menuconfig这里能用UI方式配置蓝牙相关选项。三个关键配置务必确认Component config - Bluetooth - Bluetooth广播并选择Bluedroid。Component config - Bluetooth - Bluedroid Options勾选SPP之外的Classic Bluetooth可以不要但我们这边只要BLE所以这套配置里确认BLE相关项被启用即可。Component config - Bluetooth - Bluedroid Options - BLE确保勾选了GATT、GAP等基础选项。实际工程里如果你用的官方模板默认可能只启用了WiFi蓝牙没开。在menuconfig里把蓝牙总开关打开后编译时会多出很多蓝牙相关源文件链接时间会变长属正常现象。2.3 工程结构里和蓝牙相关的文件一个ESP-IDF工程的核心目录结构差异不大我们需要关心的文件有这些main/我们自己写的业务代码放这里比如main.c。CMakeLists.txt用于告知构建系统需要编译的源文件和依赖组件。sdkconfig由menuconfig生成记录了所有配置项不要手动去改它。managed_components如果你后面加载了组件管理器托管的第三方组件会出现在这里。蓝牙beacon扫描这个功能不需要引入额外的第三方组件只要ESP-IDF自带的bt组件即可。你在main目录下的CMakeLists.txt里确认requires字段包含了bt就行。3. iBeacon广播协议与RSSI测距原理3.1 iBeacon广播包逐字节拆解苹果定义的iBeacon数据格式是BLE广播包里的一个厂商自定义数据段。一个完整的BLE广播包由多个AD structure组成每个structure的结构是长度 类型 数据。iBeacon嵌入在类型为0xFF的厂商自定义数据里厂商ID是苹果的0x004C。整体数据布局是这样的厂商ID2字节4C 00注意小端序实际显示为0x004C表示Apple。iBeacon类型标识1字节02。iBeacon数据长度1字节15十进制21表示后面21字节是iBeacon数据。Proximity UUID16字节用来区分不同的beacon部署项目比如同一个商场所有信标共用一个UUID。Major2字节用于区分一组信标比如同一UUID下的不同楼层。Minor2字节用于区分同一个Major下的不同信标比如同一楼层的具体点位。Measured PowerTX Power1字节带符号整数表示距离信标1米处测得的接收信号强度值单位dBm。所以拿到一个广播包你只要在AD structure里定位到4C 00 02 15这串特征字节后面的字段就能按固定偏移解出来。3.2 RSSI测距公式到底怎么用蓝牙beacon测距的本质是信号在空气中传播时会衰减距离越远接收端测到的RSSI值越小负得越多。理论和实测经验之间常用一个对数距离路径损耗模型来表达这个公式里几个变量的含义如下公式的分母上有两个关键参数A和n。A表示参考距离为1米时测得的RSSI理想值也就是iBeacon广播里那个TX Power字段附近的一个实测参考值n是环境衰减因子开阔空间约2.0到2.5普通办公室隔断环境下2.5到3.5工业场景或金属货架密集区域可以到3.5以上。为什么强调标定因为每颗beacon的实际发射功率都存在个体差异你放在1米处测出来的A未必等于广播里写的TX Power。我之前遇到过一批信标广播里写的TX Power是-59实际1米处均值是-64差5个dB就是这5个dB导致计算距离直接偏离了将近一倍。所以生产环境部署前一定要拿信号接收器对每个点位做现场校正。3.3 滤波测距精度靠它拉回来RSSI是所有无线信号里最不稳定的物理量之一。多径反射、人体遮挡、收发天线朝向、同频段干扰都会让RSSI在短时间内跳动10到15个dB。跳这么一下算出来的距离可能从1米蹦到4米。应对手段主要有三种滑动平均滤波维护一个固定长度的RSSI历史窗口每次进来新值就剔除最旧的值然后求平均。实现简单适合大部分场景窗口长度通常在10到20。中值滤波取历史窗口中间值能把偶尔出现的极端跳变剔除得更彻底。卡尔曼滤波状态模型建好后效果最好但实现复杂度高还要调协方差参数。我实际用下来滑动平均加合理范围内剔除异常值已经能满足绝大多数应用。卡尔曼滤波更适合运动目标追踪或者连续定位输出如果你只是判断几个固定点位的远近没必要上。4. 代码实现从扫描到测距的完整流程4.1 初始化BLE并启动扫描代码里最先要做的是初始化蓝牙协议栈。esp_err_t ret; esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT); // 禁掉经典蓝牙只保留BLE esp_bt_controller_config_t bt_cfg BT_CONTROLLER_INIT_CONFIG_DEFAULT(); ret esp_bt_controller_init(bt_cfg); if (ret) { ESP_LOGE(TAG, ESP32蓝牙控制器初始化失败: %s, esp_err_to_name(ret)); return; } ret esp_bt_controller_enable(ESP_BT_MODE_BLE); if (ret) { ESP_LOGE(TAG, 蓝牙控制器使能失败: %s, esp_err_to_name(ret)); return; } ret esp_bluedroid_init(); if (ret) { ESP_LOGE(TAG, Bluedroid初始化失败: %s, esp_err_to_name(ret)); return; } ret esp_bluedroid_enable(); if (ret) { ESP_LOGE(TAG, Bluedroid使能失败: %s, esp_err_to_name(ret)); return; }这里有个细节esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT)可以释放经典蓝牙占用的内存。beacon扫描只需要BLE经典蓝牙是多余的调用这行能让后续内存分配宽松不少。接着注册GAP回调函数并设置扫描参数esp_ble_gap_register_callback(esp_gap_cb); 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 0x50, // 扫描间隔单位625us0x50约50ms .scan_window 0x30, // 扫描窗口单位625us0x30约30ms .scan_duplicate BLE_SCAN_DUPLICATE_DISABLE // 关闭重复过滤 }; esp_ble_gap_set_scan_params(ble_scan_params);扫描间隔和扫描窗口是一组联动参数。窗口决定了单次扫描持续多久间隔决定了下一次扫描什么时候开始。窗口越大能捕获到的广播包概率越高但功耗也越高。beacon的广播间隔通常是100ms到1s我这边扫描窗口设了30ms、间隔50ms配合长时间的扫描周期实际测试下来一次完整的beacon广播事件不会漏掉。scan_duplicate建议设成DISABLE不然同一设备重复上报会被过滤掉。我们做滤波需要对同一个beacon连续采样如果开了重复过滤每秒才拿一两个RSSI样本滤波就没意义了。初始化完成后在GAP回调里启动扫描是标准姿势case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: esp_ble_gap_start_scanning(0); // 第二个参数0表示持续扫描不设时长 break;4.2 解析广播包中的iBeacon字段GAP回调里事件类型ESP_GAP_BLE_SCAN_RESULT_EVT就代表拿到了一个广播事件。判断子事件为ESP_GAP_SEARCH_INQ_RES_EVT后广播数据、RSSI、设备地址都在param-scan_rst结构体里。完整解析函数我建议单独写一个方便复用。逻辑上就是遍历所有AD structure逐个判断类型和特征字节typedef struct { uint8_t uuid[16]; uint16_t major; uint16_t minor; int8_t tx_power; } ibeacon_info_t; bool parse_ibeacon(const uint8_t *adv, uint8_t len, ibeacon_info_t *info) { uint8_t i 0; while (i len) { uint8_t ad_len adv[i]; if (ad_len 0) { break; } uint8_t ad_type adv[i 1]; if ((ad_type 0xFF) (ad_len 0x1A)) { const uint8_t *d adv[i 2]; if ((d[0] 0x4C) (d[1] 0x00) (d[2] 0x02) (d[3] 0x15)) { memcpy(info-uuid, d[4], 16); info-major (d[20] 8) | d[21]; info-minor (d[22] 8) | d[23]; info-tx_power (int8_t)d[24]; return true; } } i ad_len 1; } return false; }可能有读者要问为什么判断条件里要求ad_len 0x1A因为iBeacon的完整厂商数据是Apple公司ID的2字节加类型和长度的2字节加21字节数据合计25个字节十六进制是0x19。但有些beacon厂商会额外塞一点别的数据比如设备名称或电量信息所以判断只要这段AD structure的长度够容纳iBeacon必要字段就行宁可多不能少。如果你的beacon是其它格式比如Google的Eddystone判断逻辑就要换成0xAA 0xFE的特征码原理一模一样。4.3 RSSI平滑滤波与距离计算RSSI滤波我用了一个简单的环形缓冲区加滑动平均#define RSSI_HISTORY_SIZE 15 #define RSSI_ABNORMAL_THRESHOLD 15 static int8_t rssi_history[RSSI_HISTORY_SIZE]; static uint8_t rssi_idx 0; static uint8_t rssi_count 0; int8_t rssi_smooth_filter(int8_t new_rssi) { // 异常值剔除和当前均值差值超过阈值就丢弃 if (rssi_count 0) { int32_t sum 0; for (int i 0; i rssi_count; i) { sum rssi_history[i]; } int8_t avg (int8_t)(sum / rssi_count); int diff abs(new_rssi - avg); if (diff RSSI_ABNORMAL_THRESHOLD) { return avg; // 丢弃这次异常跳变 } } rssi_history[rssi_idx] new_rssi; rssi_idx (rssi_idx 1) % RSSI_HISTORY_SIZE; if (rssi_count RSSI_HISTORY_SIZE) { rssi_count; } int32_t sum 0; for (int i 0; i rssi_count; i) { sum rssi_history[i]; } return (int8_t)(sum / rssi_count); }异常值剔除的逻辑有必要说明一下。室内环境下蓝牙信号偶尔会因为人体靠近或者金属物体遮挡瞬间衰减到-90甚至-100但下一秒又恢复正常。如果直接把异常值扔进滑动平均滤波结果会被带偏好几秒。我设置了和当前均值的差值阈值超过15dB就丢弃实测下来RSSI输出的稳定性提高了一个档次。距离计算直接套对数距离模型#include math.h float calc_distance(int8_t rssi, int8_t tx_power, float env_factor) { return powf(10.0f, (tx_power - rssi) / (10.0f * env_factor)); }这里tx_power就是iBeacon里的Measured Power字段rssi是滤波后的信号强度env_factor就是环境衰减因子n默认传2.5。如果广播播放的是iBeacon标准协议这个字段通常和我们实测1米处的RSSI接近直接用它做A值在大多数室内场景够用。4.4 完整代码与串口输出效果组合起来扫描回调的完整处理逻辑是这样的static void handle_ble_adv_data(uint8_t *adv, uint8_t adv_len, int8_t rssi, uint8_t *bda) { ibeacon_info_t beacon; if (!parse_ibeacon(adv, adv_len, beacon)) { return; // 不是iBeacon广播包忽略 } int8_t smooth rssi_smooth_filter(rssi); float dist calc_distance(smooth, beacon.tx_power, 2.5f); ESP_LOGI(TAG, MAC: MACSTR, MAC2STR(bda)); ESP_LOGI(TAG, UUID: %02x%02x%02x%02x-%02x%02x-%02x%02x, beacon.uuid[0], beacon.uuid[1], beacon.uuid[2], beacon.uuid[3], beacon.uuid[4], beacon.uuid[5], beacon.uuid[6], beacon.uuid[7]); ESP_LOGI(TAG, Major: %d Minor: %d, beacon.major, beacon.minor); ESP_LOGI(TAG, RSSI: %d dBm, Distance: %.2f m, smooth, dist); }串口的输出格式我故意设计成了“一行闭合键值对”的样式后面你如果要把数据传到MQTT或者阿里云物联网平台解析这个字符串会非常方便。实际调试时用ESP_LOGI输出的日志在ESP-IDF Monitor里可以直接看到全局系统日志时间戳哪天你加了多个任务要排查时序问题这个时间戳能救命。顺带提一嘴如果你想同时扫描多个beacon并分别输出距离建议维护一张beacon表以MAC地址为key存每个beacon的滤波状态、最近距离和更新时间。单机同时扫描几十个beacon完全没问题但要注意串口日志刷太快会拖慢系统调试的时候输出放在循环里加个节流比如每秒最多打印3次。5. 实测数据与误差分析5.1 测试场景与标定方法测试场地我选了一个普通办公区走廊宽度约2米两侧有金属门框一端放了3个beacon高度1.2米间隔约3米。接收端ESP32放在小推车上从0.5米开始往后移动每到固定距离停下来采样30秒取平均。正式测距前先做一步简单标定在离beacon正好1米的位置连续采集100个RSSI样本取平均值。比如测出来是-63.5而广播包里的TX Power是-62那就以实测值为准重新调calc_distance里的参考值。同时在5米处再采一组数据反推一下环境因子n公式稍作变形就行这个值可以从测试数据的对数坐标图里拟合出来但我通常在5米处采到RSSI后直接代入求n如果你手头没有5米这种大距离的场地条件那就用默认值2.5起步然后拿实测值不断校正。经验是n在室内一般落在2.2到3.2之间误差容忍度不要太苛刻。5.2 实测数据记录标定后我在1米、2米、3米、5米、8米这几个距离点上做了静止采样滤波后取均值结果大概是这样距离的误差表现让我有个明显感受3米以内相对误差控制在20%左右超出5米后误差快速扩大。这符合对数路径损耗模型的特性——距离越远相同RSSI波动对应的距离误差越大。也就是说这个方案做近距离姿态判断或者区域级定位够用但你要精确报出“距离3.1米”这类数字得叠加多个参考节点做三边定位才有意义。5.3 提升精度的四个小技巧实测下来几个细节对精度影响很大固定接收端高度和天线朝向。ESP32板载天线是全向天线但人体遮挡对信号衰减很明显手握住板子测出的RSSI直接往下掉5到8dB实验时把设备架起来别拿手里。增加采样次数。采样时间从10秒拉长到30秒均值更稳。对静态场景来说这不算缺点但如果你的应用目标是移动中实时测距那就要在实时性和精度之间做权衡。用多包平均再加滤波。我的程序里滤波窗口是15测试时发现如果windows窗口从15减到5输出的跳动幅度会明显上升从15加到30回复变慢但输出非常平稳。具体窗口大小你可以写配置文件动态调方便在不同现场切换参数。部署密度不要过低。做定位系统时单点beacon覆盖半径建议控制在5米内beacon间距3到5米布一个这样即使个别点RSSI跳变周围节点还能互相纠偏。6. 常见问题与排查技巧实录6.1 扫不到beacon的检查顺序这是问得最多的问题。我的排查顺序是从底层往上层一层层看确定beacon在正常工作。手机上下载一个nRF Connect先看看手机能不能扫到那个beacon。如果手机也扫不到大概率是beacon没配置好或者没电了。确定ESP32蓝牙初始化成功。看启动日志里有没有报错特别留意esp_bt_controller_init和esp_bluedroid_enable的返回码。检查扫描参数。如果你用的是主动扫描有些beacon设备不支持扫描响应包但广播包本身一定能扫到真不行就把scan_type改成PASSIVE或者两种都试试。确认广播过滤没有误伤。如果你的代码里做了UUID过滤先试一下不过滤只要能扫到设备再慢慢加过滤逻辑。补一个常见坑ESP32的扫描事件里广播类型和广播地址类型是需要关心的但一般做beacon扫描不需要连接设备所以不用纠结这个地址是不是公共地址。如果你在回调里读设备名没读到大概率是因为广播包本身没包含设备名beacon为了省电通常只发厂商自定义数据不附带设备名。6.2 RSSI忽大忽小怎么办RSSI跳动是常态不是故障。先确认你采集的数据源是不是经过滤波的。如果滤波后仍有明显毛刺检查是不是有无线设备在附近比如USB 3.0接口、微波炉、隔壁房间的大功率WiFi路由器都会对2.4GHz频段造成干扰。另外注意接收天线的摆放位置。ESP32的天线区域位于板子一端这部分贴到金属物体上信号会剧烈变化。把板子放在塑料支架上天线区域朝上或者朝外能有效减少环境对天线的影响。还有一个容易忽略的点你的beacon如果同时支持连接和广播而某个设备已经和它建立了连接那么beacon的广播间隔会自动拉长甚至暂停广播。这在调试时容易被误判成扫描问题实际是beacon被别的东西占用了。6.3 编译、烧录与VSCode环境问题开发环境方面的报错我挑几个高频的整理一下。The path for ESP-IDF is not valid: /tools/idf.py not found.这通常是ESP-IDF插件设置里填的IDF路径不对或者你选择的路径指向了esp-idf子目录而不是真正的IDF根目录。打开VSCode的设置搜idf.espIdfPath确认路径直接包含了tools/idf.py这个文件所在的层级。如果是用Express模式安装的插件会把路径自动配置好尽量不要手动去改它。编译到一半报Error: ...fqbn: esp32:esp32:esp32s3 using board...这个信息有时在PlatformIO或Arduino环境里出现说明编译系统是通过FQBN指定开发板的。如果你在ESP-IDF扩展里看到这个多半是你不小心用了Arduino扩展的命令来编译IDF工程。记得编译时用命令面板里的ESP-IDF: Build不要在Arduino的编译按钮里点。烧录时连接不上串口先确认串口号在设备管理器里存在。如果存在但一直连接失败按住板子的BOOT键再点烧录看到进入下载模式后再松手。老款ESP32开发板经常有这个问题新板子加了自动下载电路就不用管了。VSCode下离线安装ESP-IDF选好了路径但工作时组件还是被装到C盘这个坑很隐性。Espressif的IDF组件管理器会把下载的managed components放到工程目录但工具链本身会和IDF放一起。如果你不想装到C盘一个办法是安装时选择自定义路径一个办法是装完了把整个.espressif文件夹挪到D盘然后用idf_tools.py脚本重新设置环境变量。命令行处理起来不难但新手容易绕进去所以我通常建议安装阶段就一次性规划好路径。6.4 几个我踩过的大坑写代码过程中我遇到过几个挺迷的问题在这里一并说下。第一个是esp_ble_gap_register_callback必须在esp_bluedroid_enable之后调用。顺序反了回调注册会失败扫描事件永远到不了你的处理函数但程序不会崩你会看到一个“扫描启动成功但就是没结果”的怪现象。第二个是esp_ble_scan_params_t里的scan_duplicate字段不同IDF版本默认值存在差异。旧版本默认是BLE_SCAN_DUPLICATE_ENABLE新版本默认是DISABLE。如果你要做连续RSSI采样建议显式赋值不要靠默认。第三个是日志输出速率。如果你在扫描回调里直接打印每一包数据而周围beacon数量又多串口会被刷爆导致整个系统响应变慢甚至看门狗超时。解决方法是加打印节流比如每100ms才输出一次最新值中间的数据只更新内存变量。第四个和硬件有关。ESP32开发板种类很多有些板子的板载USB转串口芯片不稳定特别是高波特率下烧录和日志会断断续续。遇到烧录失败优先降波特率到115200试试别一上来就怀疑代码。还有如果你的板子同时接了外部电源又插着USB容易造成串口芯片电平混乱这个问题我在用劣质充电宝供电时遇到过多次后来统一用USB口供电问题就没再出现。我在实际项目里最后落地的时候把beacon测距的代码放在了一个独立任务里设置好优先级和栈深度再用消息队列把测距结果发给主控任务处理。这样即使蓝牙扫描偶发问题也不会影响其它业务逻辑。后续如果你要做多beacon定位、违规进入区域告警或设备巡检打卡这套结构都能直接扩展。测距精度不满足需求时记得回来重新标定一遍环境因子我用这个公式处理过很多现场问题最有效的步骤永远是标定、标定、再标定。