ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

BLE广播包解析实战:TLV、AD Type与厂商数据逐字节拆解

BLE广播包解析实战:TLV、AD Type与厂商数据逐字节拆解 手头有个自研传感器的项目设备端为了省电干脆不上 GATT所有状态就靠 BLE 广播包每 500 ms 往三个信道喊一遍温度、电量、告警位全塞在那 31 个字节里。上位机这边只能被动听不能连、不能读特征值唯一的信息入口就是广播包。第一次把抓到的 hex 打印出来时我人是懵的02 01 06 0A 09 4D 79 44 65 76 69 63 65 31 03 03 AA FE一串看起来毫无规律的字节哪一位是标志位、哪一段是名字、哪个字段表示发射功率完全看不出来。更坑的是同一款设备有的把名字写在主广播里有的丢进扫描响应还有的把长度字段写错一位标签解析器直接吐出乱码。BLE 广播包的信息解析说白了就是把长度 类型 数据这条 TLV 规则吃透再对照蓝牙技术联盟公开的 AD Type 分配表逐段翻译。听起来朴素但真正落到产品上涉及字节序、扫描响应拼接、扩展广播、重复过滤、地址类型、RSSI 粗测距一堆细节。这篇文章面向的是正在做 BLE 设备对接、抓包分析、广播数据自解析的开发者——不管你是刚拿到一块模块在跑 demo还是已经在量产项目里被厂商自定义数据折磨过下面这些逐字节的拆解和踩坑记录应该都能直接用上。1. 31 字节的物理结构广播包到底长什么样1.1 三个广播信道和 PDU 类型决定了谁在喊、喊给谁低功耗蓝牙把 2.4 GHz 频段切成 40 个信道每个信道 2 MHz 间隔、1 MHz 带宽其中第 37、38、39 信道专门留给广播使用中心频率分别是 2402 MHz、2426 MHz、2480 MHz。之所以把广播信道安排在这三个位置是为了避开 Wi-Fi 常用的 1、6、11 信道主瓣降低同频干扰。设备广播时会在三个信道轮流发送同一份数据扫描方只要在任意一个信道上保持接收就能在几十毫秒内捕获到。广播包的协议数据单元PDU头部第一个字节里低 4 位是 PDU 类型这个字段直接决定广播包的性质PDU 类型值名称连接可被扫描请求典型用途0x00ADV_IND可连接是通用可连接设备0x01ADV_DIRECT_IND可连接定向否快速回连已知主机0x02ADV_NONCONN_IND不可连接否纯广播传感器、信标0x04SCAN_RSP否否应答扫描请求补充数据0x06ADV_SCAN_IND不可连接是不可连但支持被扫描0x07ADV_EXT_IND视情况视情况BLE 5 扩展广播入口搞清楚这张表的价值在于如果你对接的是一个信标类设备它大概率只发 ADV_NONCONN_IND你花时间去做连接尝试是徒劳的反过来如果设备发的是 ADV_IND 但你只想读数据不连接那也没问题扫描照样能拿到完整的 AdvData。同一个字节里还有 TxAdd、RxAdd 两位用来标记后面的地址是公共地址还是随机地址——这一点非常关键后面讲 MAC 轮换时会再用到。1.2 PDU Header、AdvA、AdvData 的逐字节拆解传统Legacy广播包的完整结构是2 字节头部 6 字节广播者地址AdvA 最多 31 字节广播数据AdvData。也就是说一个广播 PDU 最大 39 字节其中真正归你自由支配的只有 31 字节。这 31 字节就是所有解析工作的舞台也是为什么厂商在塞数据时那么精打细算。拿前面那串 hex 举例如果是从空口抓下来的完整 PDU前两个字节是头部接着 6 个字节是地址剩下的才是 AdvData。但大多数手机 App比如 nRF Connect和 SDK 的扫描回调直接给你 AdvData省掉了头部和地址这也是新手最容易混淆的地方同一台设备抓包软件里看到的字节数和 App 里显示的对不上往往就是差在这 8 个字节上。还有一个隐形成本常被忽略AdvData 的长度字段本身要占 1 字节每个 AD 结构还要额外花 1 字节写类型。所以你在算预算的时候不能只算有效载荷。举几个实测数字Flags 结构02 01 06占 3 字节有效信息只有 1 字节完整本地名 MyDevice19 字符0A 09 ...占 11 字节一个 16 位 UUID03 03 AA FE占 4 字节一个 128 位 UUID占 18 字节一个就能吃掉六成空间我做过一个夸张的对比某款国产手环的广播包里塞了 3 个 128 位 UUID、1 个厂商自定义数据段和 1 个 TxPower加起来 31 字节一个不剩连名字都只能放到扫描响应里去。所以当你在解析时发现名字字段怎么没有先别怀疑解析代码去数一下长度预算八成是空间不够被厂商主动砍掉了。2. AD Structure 的 TLV 读法长度字段是第一道门槛2.1 长度字段统计的是类型 数据不是数据广播数据由若干个 AD 结构首尾相接拼成每个结构固定三段式1 字节长度、1 字节类型、N 字节数据。这里的长度值等于 N 1也就是把类型字节也算进去了。这个设计经常被吐槽但它的好处是解析器可以在不知道类型的情况下安全跳过读长度 → 跳过读长度 → 跳过天然支持向前兼容未知类型。我把这个规则翻译成一句人话长度字节告诉你从类型字节开始往后数几个字节是本段的内容。用第一段02 01 06验证长度 0x02类型 0x01数据 06类型加数据正好 2 字节。再用名字段0A 09 4D 79 44 65 76 69 63 65 31验证长度 0x0A 10类型 0x09后面 9 个字节是 ASCII 的 MyDevice1类型加数据 10 字节对得上。解析循环里最容易出问题的两个点一是遇到长度为 0 时必须立刻停止因为剩余空间通常是零填充继续解析会读到一堆无意义的 0x00二是要做越界检查i 1 length len就直接判为截断。我见过某家厂商的固件在广播数据末尾多写了一个长度值但没跟数据导致解析器读到越界内存在 Android 上表现为偶尔崩溃排查了两天才定位到——从那以后我的解析器必定带边界保护宁可报数据异常也不越界读。def parse_ad_payload(payload: bytes): result [] i, n 0, len(payload) while i n: length payload[i] if length 0: break # 零填充后面全是无效数据 if i 1 length n: result.append({offset: i, error: length 越界数据被截断}) break ad_type payload[i 1] ad_data payload[i 2:i 1 length] result.append({ offset: i, type: hex(ad_type), raw: ad_data.hex( ), }) i 1 length return result这段代码只有十几行但上面那两个break是我在三个不同项目里用血换来的。建议直接抄走。2.2 常用 AD Type 的速查表和数据布局蓝牙技术联盟把 AD Type 全部编号公开在 Assigned Numbers 文档里这份文档不需要注册任何账号就能直接下载建议存一份到本地随查。实际项目里高频出现的其实就那么十几个类型值名称数据布局要点0x01Flags1 字节位域见 2.3 节0x02 / 0x03不完整 / 完整 16 位 UUID 列表每 2 字节一个小端0x06 / 0x07不完整 / 完整 128 位 UUID 列表每 16 字节一个需整体反转0x08 / 0x09缩写 / 完整本地名UTF-8 字符串无结束符0x0ATx Power Level1 字节有符号整数单位 dBm0x12从机连接间隔范围最小 2 字节 最大 2 字节单位 1.25 ms0x1616 位 UUID 的服务数据前 2 字节 UUID其余自定义0x19Appearance2 字节小端表示设备外观类别0x1AAdvertising Interval2 字节小端单位 0.625 ms0x1BLE 设备地址6 字节地址 1 字节地址类型0x20 / 0x2132 位 / 128 位 UUID 的服务数据同上用 UUID 前缀区分来源0x27LE 支持的特性位域反映控制器能力0x29 / 0x2A / 0x2BPB-ADV / Mesh Message / Mesh BeaconMesh 组网相关0x2C / 0x2D / 0x30BIGInfo / Broadcast_Code / Broadcast_Name广播音频Auracast相关0xFF厂商自定义数据前 2 字节为公司标识符其余全靠约定这张表里 0x12 和 0x1A 的单位换算值得单独说一句连接间隔用 1.25 ms 做步进所以读到18 00 28 00意味着最小 0x0018 × 1.25 30 ms最大 0x0028 × 1.25 50 ms广播间隔用 0.625 ms 做步进读到A0 00就是 0x00A0 × 0.625 100 ms。这两个单位的区别在于前者是连接后的通信节奏后者是广播本身的节拍生产测试时经常需要反过来验证固件有没有按需求配置用这两个字段一对就知道。2.3 两个完整实例的逐步拆解光看表格容易晕直接上两段真实数据逐字节推演。第一段某自研传感器的广播数据02 01 06 0A 09 4D 79 44 65 76 69 63 65 31 03 03 AA FE02 01 06长度 2类型 0x01Flags数据 0x06 0000 0110。bit1 置位表示LE 通用可发现模式bit2 置位表示不支持 BR/EDR也就是这颗芯片是纯低功耗蓝牙不具备经典蓝牙能力。0A 09 4D 79 ... 31长度 10类型 0x09完整本地名9 个字节解码出字符串 MyDevice1。03 03 AA FE长度 3类型 0x03完整 16 位 UUID 列表数据AA FE按小端读出是 0xFEAA补全成 128 位后是0000FEAA-0000-1000-8000-00805F9B34FB这是 Eddystone 信标格式的服务 UUID。整段共 18 字节还剩 13 字节空间。如果这是个真实产品厂商通常会拿这 13 字节再塞一条厂商自定义数据或者一条服务数据。第二段用 iBeacon 格式填充的厂商自定义数据02 01 1A 1A FF 4C 00 02 15 E2 C5 6D B5 DF FB 48 D2 B0 60 D0 F5 A7 10 96 E0 00 01 00 02 C5Flags 的值是 0x1A 0001 1010bit1通用可发现、bit3控制器同时支持 LE 和 BR/EDR、bit4主机同时支持都置位bit2 没有置位说明这是一个双模设备。这个值是苹果设备的经典写法。1A FF ...长度 0x1A 26类型 0xFF厂商自定义数据后面 25 字节的内容依次是公司标识符4C 00小端读出 0x004CApplesubtype02 150x02 表示信标0x15 表示后续长度 21 字节16 字节 UUIDE2C56DB5-DFFB-48D2-B060-D0F5A71096E0major00 01minor00 02最后 1 字节C5按有符号数解析是 -59代表 1 米处的参考信号强度。整段加起来 30 字节刚好在 31 字节的红线内。我特意挑这个例子是因为它同时踩到了两个坑点一是厂商自定义数据里的小端解析二是 TxPower 的有符号处理。C5如果按无符号读会变成 197RSSI 测距的公式直接算出一个荒谬的距离。3. UUID 字节序和厂商数据解析代码最常翻车的两处3.1 16 位 UUID 补零成 128 位为什么字节是反的蓝牙在空口传输 UUID 时用的是小端序而人类书写 UUID 用的是大端。这个错位导致一个非常反直觉的现象16 位 UUID 0xFEAA 在空中是AA FE你把它反转过来读成 0xFEAA再补上蓝牙基础 UUID 前缀0000xxxx-0000-1000-8000-00805F9B34FB得到最终结果。如果忘了反转你会得到一个 0xAAFE 的幽灵 UUID然后开始怀疑厂商乱写文档。128 位 UUID 更狠它需要把整个 16 字节数组反转一次再按 4-2-2-2-6 的分组加连字符。正确做法是先把字节整体倒序然后按字符串切片格式化def uuid128_from_le(b: bytes) - str: h bytes(reversed(b)).hex() return f{h[0:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]} def uuid16_from_le(b: bytes) - str: v int.from_bytes(b, little) return f0000{v:04X}-0000-1000-8000-00805F9B34FB提示很多蓝牙 SDK 在上层已经替你做了反转比如 Android 的ScanRecord.getServiceUuids()返回的就是标准形式。什么时候需要自己动手只有在你直接操作原始字节数组的时候。混用两种来源的数据时一定要先确认这一层。我在一个项目里被这个问题坑了整整半天App 侧用系统 API 拿到的是正常 UUID上位机用 Python 解析原始广播数据得到的是反的两边一对比死活对不上最后发现是上位机的反转逻辑写在了错误的位置——先补前缀再反转结果把基础 UUID 也一起转乱了。教训就是反转只能作用在空口那 2 或 16 个原始字节上任何拼接都要放在反转之后。3.2 厂商自定义数据没有标准只能靠公司标识符和逆向猜测0xFF 类型是自由发挥区唯一的约束是前 2 字节必须是蓝牙技术联盟分配的公司标识符后面想写什么就写什么。这就意味着除了极少数的公开格式iBeacon、Eddystone、部分厂商的固件升级广播绝大多数厂商数据段你只能靠逆向。我的标准流程是这样第一步读公司标识符确认设备归属第二步把剩余字节按常见模式做排除法——如果是固定长度的周期数据找哪几个字节在变如果是传感器上报通常有 1 到 2 字节的固定包头第三步做对照实验改变设备的一个状态比如按一下按键、拔掉充电器观察哪几个字节跟着变。常见的公司标识符里0x004C 是 Apple0x0006 是 Microsoft0x0075 是 Samsung0x0059 是 Nordic Semiconductor0x00E0 是 Google。完整的列表还是要以官方 Assigned Numbers 文档为准因为编号是持续在新增的。我在实测中遇到过一个设备公司标识符写的是 0xFFFF——这明显是固件工程师随手填的占位值遇到这种就只能当私有协议处理别指望能对上任何官方数据库。还有一个细节厂商数据段的长度不固定所以千万别用读到第 20 字节就是电量这种硬编码偏移。稳妥的做法是先按结构解析出完整的数据段字节再在这个子数组内做偏移计算这样即使厂商在开头多加了一个字节你的偏移逻辑也不会全盘错位。服务数据0x16 / 0x20 / 0x21比厂商数据稍微规范一点因为它带了 UUID 前缀能明确表示这是哪个服务的数据。Eddystone 就是典型它用 0x16 类型UUID 是 0xFEAA后续第一个字节是帧类型——0x00 表示 UID 帧10 字节命名空间 6 字节实例 ID0x10 表示 URL 帧1 字节 URL 方案前缀 压缩后的地址0x20 表示 TLM 帧2 字节电池电压毫伏值 2 字节温度 4 字节广播计数 4 字节开机秒数0x30 表示 EID 帧。TLM 帧的温度字段是 8.8 定点格式前 8 位整数、后 8 位小数解析时要右移 8 位取整数部分再拿低 8 位除以 256 取小数部分——这个格式我第一次见的时候也愣了一下因为蓝牙平时很少用定点小数。4. 扫描响应、扩展广播与 31 字节的天花板4.1 ADV_IND 和 SCAN_RSP 是一对缺一个数据就不完整31 字节放不下所有信息时标准做法是把主广播和扫描响应搭配使用ADV_IND 里放关键字段Flags、UUID、部分厂商数据SCAN_RSP 里放补充字段完整名字、Appearance、其余数据。扫描方必须发 SCAN_REQ 主动请求才能拿到扫描响应。如果用被动扫描那部分数据永远看不到。这个机制带来的解析要求是你得把 ADV_IND 和 SCAN_RSP 的解析结果按同一个设备地址合并。同一台设备可能出现名字在主广播里、但 Appearance 在扫描响应里两边拼起来才是完整画像。我在上位机代码里维护了一个以设备地址为键的字典每次收到任意类型的广播包就做一次归并而不是收到一条解析一条就上报否则前端会看到同一台设备的记录闪来闪去。主动扫描和被动扫描的性能取舍也得留意。主动扫描要额外发请求包、收响应包功耗明显更高如果只是读传感器数据、不需要名字用被动扫描把功耗压下来是更实际的选择。我做过实测在同一个接收端上主动扫描的电流比被动扫描高出约三成如果接收端本身是电池供电比如便携式网关这个差异很要命。还有个常见现象值得预告某些设备的扫描响应会携带一个和主广播里不同的名字这通常是厂商在做固件版本区分或者配网状态提示。不要以为是解析错了把两个名字都记下来往往能发现有用的信息。4.2 BLE 5 扩展广播和周期广播改变了容量规则BLE 5.0 引入扩展广播之后31 字节的限制只对传统广播包成立。扩展广播的做法是在 37/38/39 这三个主信道上发一个ADV_EXT_IND作为引子里面包含 AuxPtr 字段指向某个数据信道上的AUX_ADV_IND包后者可以携带最多 254 字节的广播数据再配合链式包还能更长。好处显而易见代价是扫描方必须支持扩展广播并且要能在主信道和数据信道之间做跳转同步。我第一次用经典款 ESP32 的 Arduino 蓝牙库去扫一个蓝牙 5.2 的音频设备时就掉进了这个坑能扫到设备名但拿不到任何厂商自定义数据因为那些数据全在 AuxPtr 指向的辅助包里而当时的扫描库只解析了主信道上的 ADV_EXT_IND。换到支持扩展扫描的芯片比如 ESP32-C3、ESP32-S3之后数据就全了。所以如果你的产品要对接的是新设备芯片选型阶段就该确认扩展广播的支持情况别等到联调才发现。周期广播是另一条线它允许设备以严格固定的节拍持续广播配合 BLE 音频的 BIGInfo、Broadcast_Code 和 Broadcast_Name 这几个 AD Type就构成了广播音频Auracast的基础。这几个类型在普通消费级产品里还不算常见但如果你在做音频相关的对接看到2C、2D、30这三个类型值不要当成未知数据跳过。Mesh 相关的几个类型也值得一提。0x29PB-ADV是 Mesh 配网用的承载包0x2A是 Mesh 消息0x2B是 Mesh 信标——信标里又带着 Mesh 网络的 UUID、OOB 信息和 URI 哈希。如果你抓到的广播里有这几个类型说明设备已经处于配网或组网状态。而 Mesh 1.1 引入的远程配网走的是另一套承载抓包时只盯这几个类型是不够的还需要结合连接态的抓包一起看。5. 抓包工具和代码落地从 App 观察到自己写解析器5.1 手机 App 和 Wireshark 抓包分别该盯什么快速验证阶段手机端工具是效率最高的。打开扫描界面后重点盯这几个位置设备列表里的 RSSI 实时值判断信号强度和距离趋势、展开后的 AD Structure 面板逐条列出每个 AD 结构和类型名、以及 Raw 十六进制区这是 AdvData 的原文。我的习惯是先看有没有 0x09 完整名字再看有没有 0xFF 厂商数据最后看有没有 0x16 服务数据——这三项基本能覆盖九成设备的信息入口。需要注意的是手机端工具显示的地址格式和原始字节顺序。有些 App 把地址反过来写从高字节到低字节有些按空口顺序写两者看起来差别不大但实际是镜像的。判断方法很简单连续扫描两次看地址有没有变化如果变化那很可能是随机地址RPA设备在周期性地轮换身份地址。这个特性对隐私是好事但对按 MAC 过滤设备的方案是灾难——你的白名单会在几分钟内全部失效。正确做法是按照厂商数据中的固定标识比如序列号字段做匹配而不是依赖地址。空口抓包就得靠专用嗅探器加桌面端协议分析软件的配合了。这类方案的原理是嗅探器固件把自己伪装成扫描器跟住目标设备的广播节拍同时跟随连接跳频。桌面端的协议树会按分层展开广播包的每个字段把 PDU 头部、地址、每条 AD 结构都解析成人话还能和原始字节对照。这套组合最大的价值在于两边都能看协议树告诉你字段含义字节区告诉你真实编码对着看几次之后你就能脱离工具自己读 hex 了。注意嗅探器的固件版本要和桌面端软件的版本匹配版本错配时会出现能收到包但协议树全是 unknown的现象。我第一次搭环境时在这上面浪费了一个下午。5.2 手撸一个带容错的广播包解析器工具看明白了接下来得把逻辑固化到自己的代码里毕竟产品里不可能挂个桌面软件。下面这份 Python 实现是我在几个项目里迭代出来的版本核心特点是对未知类型保持宽容、对异常长度做保护、对多字节字段统一走小端。import struct AD_NAMES { 0x01: Flags, 0x02: Incomplete 16-bit UUIDs, 0x03: Complete 16-bit UUIDs, 0x06: Incomplete 128-bit UUIDs, 0x07: Complete 128-bit UUIDs, 0x08: Shortened Local Name, 0x09: Complete Local Name, 0x0A: Tx Power Level, 0x12: Slave Connection Interval Range, 0x16: Service Data 16-bit UUID, 0x19: Appearance, 0x1A: Advertising Interval, 0x1B: LE Device Address, 0x1C: LE Role, 0x20: Service Data 32-bit UUID, 0x21: Service Data 128-bit UUID, 0x27: LE Supported Features, 0x29: PB-ADV, 0x2A: Mesh Message, 0x2B: Mesh Beacon, 0x2C: BIGInfo, 0x2D: Broadcast_Code, 0x30: Broadcast_Name, 0xFF: Manufacturer Specific Data, } FLAG_BITS [ (0, LE Limited Discoverable), (1, LE General Discoverable), (2, BR/EDR Not Supported), (3, Simultaneous LEBR/EDR Controller), (4, Simultaneous LEBR/EDR Host), ] def uuid16_from_le(b): return f0000{int.from_bytes(b, little):04X}-0000-1000-8000-00805F9B34FB def uuid128_from_le(b): h bytes(reversed(b)).hex() return f{h[0:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]} def decode_ad(t, d): if t 0x01 and d: return [n for bit, n in FLAG_BITS if d[0] (1 bit)] if t in (0x08, 0x09): return d.decode(utf-8, errorsreplace) if t in (0x02, 0x03, 0x14): return [uuid16_from_le(d[i:i 2]) for i in range(0, len(d) - 1, 2)] if t in (0x06, 0x07): return [uuid128_from_le(d[i:i 16]) for i in range(0, len(d) - 15, 16)] if t 0x0A and d: return struct.unpack(b, d[:1])[0] # 有符号单位 dBm if t 0x19 and len(d) 2: return f0x{int.from_bytes(d[:2], little):04X} if t 0x1A and len(d) 2: return int.from_bytes(d[:2], little) * 0.625 # 单位 ms if t 0x12 and len(d) 4: lo, hi struct.unpack(HH, d[:4]) return (lo * 1.25, hi * 1.25) if t 0x16 and len(d) 2: return {uuid: uuid16_from_le(d[:2]), payload: d[2:].hex( )} if t 0xFF and len(d) 2: return {company_id: f0x{int.from_bytes(d[:2], little):04X}, payload: d[2:].hex( )} return d.hex( ) def parse_adv(payload: bytes): out, i, n [], 0, len(payload) while i n: length payload[i] if length 0: break if i 1 length n: out.append({error: truncated, offset: i}) break t payload[i 1] d payload[i 2:i 1 length] out.append({type: hex(t), name: AD_NAMES.get(t, Unknown), value: decode_ad(t, d), raw: d.hex( )}) i 1 length return out调用的方式就是用上面那串 iBeacon 数据喂进去raw bytes.fromhex(02011A1AFF4C000215E2C56DB5DFFB48D2B060D0F5A71096E000010002C5) for item in parse_adv(raw): print(item)输出里你会看到 Flags 展开成三段可读描述、厂商数据的公司标识符是 0x004C、payload 是02 15 E2 C5 ... C5。剩下的信标字段UUID、major、minor、参考功率就在 payload 内部按固定偏移取——这也是我在 3.2 节强调先切出子数组再算偏移的实际落地。5.3 端侧解析的差异ESP32、Android、iOS 各有一套脾气如果解析工作要跑到设备端理解各平台的脾气比写代码本身更重要。ESP32 上用 Arduino 蓝牙库时扫描回调会直接给你一个设备对象可以通过它拿到负载指针和长度class AdvCallbacks : public BLEAdvertisedDeviceCallbacks { void onResult(BLEAdvertisedDevice dev) override { Serial.printf(MAC%s RSSI%d\n, dev.getAddress().toString().c_str(), dev.getRSSI()); uint8_t* p dev.getPayload(); size_t len dev.getPayloadLength(); size_t i 0; while (i len) { uint8_t l p[i]; if (l 0 || i 1 l len) break; uint8_t type p[i 1]; if (type 0x09 || type 0x08) { Serial.printf(name%s\n, std::string((char*)p i 2, l - 1).c_str()); } i 1 l; } } };这里有两个必须记住的开关一是扫描前要关掉重复过滤否则同一个设备数据变了也不会再回调二是地址类型要单独读光看地址字符串分不清是公共地址还是随机地址。另外前面提过经典 ESP32 的这套回调对扩展广播支持有限做新设备对接时要有心理准备。Android 侧相对规范扫描结果里直接提供了原始字节数组以及按类型切好的接口包括厂商数据按公司标识符索引、服务数据、服务 UUID 列表等。如果只是读标准字段用这些接口就够了但如果要解析厂商自定义的私有格式还是得拿原始字节自己走一遍因为系统的接口只按公司标识符做了分组不会帮你解释里面的内容。Android 上还有个权限与功耗的坑扫描需要相应的定位权限不同版本要求不同高频扫描会显著耗电长时间跑的产品建议用低占空比的扫描模式配合批量上报。iOS 侧最需要提前知道的是地址不可见。系统不暴露设备的硬件地址只给一个在特定宿主上稳定的标识符所以按 MAC 分组这条路线在 iOS 上直接不成立你得用广播数据里的自有标识来做设备区分。同时 iOS 侧拿到的是已经解析过的键值结构厂商数据的原始字节还是能拿到值里不含公司标识符需要你自己补但空口原文的整体形态是看不到的。这一点对调试有影响在 iOS 上排查为什么解析不对往往要回到 Android 或嗅探器上对比原始字节。6. 解析过程中的典型故障与排查链路6.1 名字读不出来或者只剩半截这是最高频的问题。按我的排查顺序先数长度预算——把广播数据里每个 AD 结构占用的字节加起来看是不是已经贴着 31 字节了如果满了名字被砍是正常行为去扫描响应里找。再确认扫描方式——被动扫描拿不到扫描响应切主动扫描试试。第三步检查名字的类型值0x08 是缩写名、0x09 是完整名有些解析器只处理了 0x09遇到 0x08 就显示空。最后才怀疑编码——名字字段是 UTF-8不带结束符如果厂商用了 GBK 编码的中文名用 UTF-8 读会得到一串替换字符。我遇到过一款国产设备名字写成中文解码后是一堆问号改成合适的编码方式才读出来。顺手提一句名字长度上限是 248 字节扩展广播下但传统广播里基本不可能放这么长的名字。排查表格整理如下现象优先怀疑验证方式名字完全为空空间不足 / 放在了扫描响应数总字节数切主动扫描名字只有前几个字符广播数据被硬截断检查长度字段与剩余空间名字是乱码 / 问号编码不是 UTF-8按 GBK 或 ASCII 重试名字每次不一样多台同型号设备按地址区分或看厂商数据里的序列号6.2 广播数据读到的总是旧值如果你在解析动态广播数据比如传感器的实时读数发现上位机拿到的值一直不变十有八九是重复过滤在作祟。扫描栈默认会对相同设备的重复广播做去重只在数据变化时上报——但有些实现判断相同的方式比较简单只比较地址或者只比较前若干字节结果数据变了也不上报。解决办法是明确关掉重复过滤让每一包都回调上来再在应用层自己做采样和去重。代价是回调频率变高需要评估功耗和 CPU 占用。我在一个环境监测项目里就吃过亏网关跑了两小时温度一直是 22.5 度不变查了半天固件最后发现是扫描端的过滤把新包全丢了。关掉过滤后数据立刻活了过来。另一个容易混淆的点是广播间隔和数据更新频率的关系。广播间隔 100 ms 不代表数据每 100 ms 更新一次很多设备是缓慢采集、快速广播读数是几秒才变一次。判断方法很简单把同一设备连续几百包的时间戳和数值都记录下来看数值变化的周期而不是看包到达的周期。6.3 拿经典蓝牙模块去扫 BLE 设备注定一无所获经典蓝牙和低功耗蓝牙虽然都叫蓝牙但从发现机制到数据交互完全是两套东西。经典蓝牙用的是查询—查询响应流程低功耗蓝牙用的是广播—扫描流程两者的信道划分、包格式、协议栈都不一样。市面上常见的串口透传模块大多是经典蓝牙走 SPP 协议它们既不会发出 BLE 广播包也扫不到 BLE 设备的广播。如果你手头是这类模块却想解析 BLE 广播那第一步就得换硬件选支持低功耗蓝牙的芯片。反过来也是一样如果一个设备是纯 BLE 的用经典蓝牙的查询流程永远找不到它。判断一个设备属于哪一类最快的办法是看它的广播包里 Flags 字段的 bit2——置位表示不支持 BR/EDR也就是纯低功耗设备。这个判断我在对接陌生设备时几乎每次都用。6.4 用 RSSI 做测距先接受它只能当参考广播包里没有独立的距离字段能做到近似测距的唯一依据是接收信号强度。理论公式是按对数路径损耗推导的距离 10 ^ ((参考功率 - 实测 RSSI) / (10 × n))其中参考功率就是 0x0A 类型里那个 TxPower按惯例表示设备在 1 米处的信号强度实测里常见 -59 dBm 这个值。n 是环境衰减指数不同场景差别很大场景环境衰减指数 n备注空旷视距2.0理论自由空间值办公室2.7 ~ 3.5隔断、桌椅、人体遮挡仓库 / 工厂3.0 ~ 4.5金属货架反射严重举例算一次参考功率 -59 dBm实测 RSSI -75 dBmn 取 2.5则指数为 16 / 25 0.64距离约 4.4 米。换个场景把 n 改成 3.5同样的 RSSI 算出来只有 2.9 米。同一个信号强度估算结果差了一半——这就是 RSSI 测距的真实精度水平。想让它稍微靠谱一点有三个可做的动作一是对最近若干次采样取中位数而不是平均值中位数对突发干扰更鲁棒二是把人体遮挡和天线朝向的变化纳入考虑实测中人体遮挡造成的衰减能到 10 dB 以上相当于把估算距离放大好几倍三是做现场标定在已知距离上采集参考功率而不是照抄广播里的 TxPower 值——很多厂商填这个值的时候并没有实测。如果项目对测距精度有硬要求RSSI 这条路基本走不通需要考虑基于到达角或到达时间差的方案那已经超出广播包解析的范畴了需要专用硬件支持。我个人的经验是RSSI 适合做近/远的二值判断和区域级定位比如判断设备是否在某个房间附近别指望它能给出准确到米级的距离。6.5 地址轮换导致的设备消失问题前面提过随机地址这里补充一个真实的排查案例。有一个网关侧的统计功能显示设备在线数量每分钟波动一度以为是设备掉线重连。逐个分析之后发现地址字段每 15 分钟左右换一次而统计逻辑是按地址去重的同一台设备被当成了不同设备。处理方式有两种一是如果设备广播里带了厂商自定义的序列号或 MAC 副本用这个字段做身份识别二是如果设备支持解析私有地址通过配对时交换的身份解析密钥来还原真实身份但这需要配过对或者有带外渠道拿到密钥。实际项目里更常见的是第一种因为简单可靠。顺带解释一下怎么从地址本身看出类型随机地址的高两位决定性质11开头是静态随机地址整个上电周期不变01开头是可解析私有地址会周期轮换00开头是不可解析私有地址完全无法关联。抓包时看到01开头的地址就该主动想到这个设备会换马甲。另外广播 PDU 头部里的 TxAdd 位和地址类型字段也能交叉验证两处信息一致才说明解析没问题。7. 一点实际使用中的体会从第一次对着 hex 发懵到现在我处理过的广播包少说也有几十种设备最深的体会是广播包解析这件事的技术门槛并不高难的是对信息不完整的容忍。你需要接受厂商会写错长度、会填占位符、会把名字藏进扫描响应、会在固件升级后悄悄改字段偏移。所以我在任何涉及广播解析的项目里都会做三件事把原始十六进制全量落盘留档方便事后回溯解析层对每个字段做独立的防御性判断任何一个字段解析失败都不能影响其他字段以及给每一段解析加一个十六进制原文的日志这样出问题的时候至少能人工读一遍。最后分享一个特别省时间的小技巧。当你面对一个完全陌生的厂商数据段时先别急着猜格式把设备放在那里连续收一千包用脚本统计每个字节位置的取值分布。如果某个位置始终不变它大概率是版本号或固定包头如果某个位置在缓慢单调增加那多半是计数器或者时间戳如果某个位置在 0 到 100 之间跳变配合毫伏量级可以往电压或温度上猜。这套统计法我用过至少十次每次都能在半小时内把主要字段的语义摸出来。
返回列表