刚入蓝牙Mesh这一摊的时候,我踩过最典型的一个坑:手机端明明已经通过代理连上了某个节点,GATT连得稳稳的,可上层就是收不到任何Mesh消息。折腾了一整天,最后才发现问题出在第七章“Mesh GATT Services”里那个不起眼的Proxy过滤机制上。当时真想把这页规范打印出来贴工位上。
所以如果你正在啃《Mesh Profile_v1.0》规范,或者正在调试PB-GATT配网、开发手机端配网工具、做网关类的代理节点,这一章基本决定了你调试效率的上限。第七章“Mesh GATT Services”看着只有两个服务,背后的协议细节却藏了不少坑。这篇文章就顺着规范第七章的骨架,把Mesh GATT服务涉及的UUID、特征值、Proxy协议、SAR分段、过滤器这些内容拆开讲清楚,同时把我实际调试中遇到的坑一起列出来。
1. 为什么Mesh非得在GATT上“开小门”:那点广播瓶颈撑不起配网
1.1 广播中继的天花板:手机为啥“听不见”Mesh节点
Mesh网络最底层的承载是PB-ADV和广播中继,节点之间通过BLE广播包互传。广播机制天生有个短板:一次只能传20多字节的有效数据,而且扫描窗口、扫描间隔、后台限制这些因素都会影响接收概率。手机这种通用设备,系统级蓝牙扫描并不总是全速跑,尤其屏幕熄灭或者App进了后台,广播包丢失率会急剧上升。你在嘈杂的射频环境里扫描一个Mesh网络,MTU和重发逻辑再好也无济于事。
所以在Mesh体系里,广播承载只适合节点与节点之间的短距离低功耗通信,而手机想稳定接入Mesh网络做配网、诊断、节点控制,光靠广播基本不现实。这也是Mesh Profile规范单独用整整一章来规定GATT相关服务的根本原因。
1.2 两个GATT服务各管一段:配网前和配网后
第七章把GATT服务分成两类:Mesh Provisioning Service(简称MESH_PROV)和Mesh Proxy Service(简称MESH_PROXY)。MESH_PROV负责的是设备还没入网时,Provisioner通过GATT连接给未配网设备做配网;MESH_PROXY负责的是设备已经入网后,为那些不适合直接监听广播的客户端(手机、网关、调试器)提供一条GATT通道,让它们能以连接方式收发Mesh网络消息。
配网阶段走PB-GATT承载,用的是MESH_PROV服务;配网完成后的日常通信走Proxy协议,用的是MESH_PROXY服务。两者不是同一个服务也不是同一套特征,很多初学者容易把它们混为一谈,后面我会把每个服务的UUID和特征值属性掰开讲清楚。
1.3 读第七章之前需要先明确的一个边界
第七章虽然命名为“Mesh GATT Services”,但它不是一份GATT层的完整开发手册。它只规定了服务和特征值的行为、PDU格式和承载转换逻辑。至于GATT连接怎么建立、MTU怎么协商、连接参数怎么配置,规范假设你已经熟悉BLE协议栈底层。
我建议读这章的时候,手里同时准备三个东西:一份Mesh Profile v1.0文档、一份蓝牙核心规范里关于Attribute Protocol和L2CAP的章节、再加一台能抓包的设备。抓包工具一定要有,后面很多坑光看代码是看不出来的。
2. MESH_PROXY服务解剖:UUID、特征值属性与连接模型
2.1 服务UUID和两个特征值的分工
MESH_PROXY服务的UUID是0x1828,要注意这是16位UUID,在蓝牙技术联盟申请分配的。它包含两个特征值:
| 特征值 | UUID | 属性 | 方向 |
|---|---|---|---|
| Mesh Proxy Data In | 0x2ADD | Write Without Response(部分实现兼容Write) | 客户端 → 服务端 |
| Mesh Proxy Data Out | 0x2ADE | Notify | 服务端 → 客户端 |
Data In是客户端向服务端发送数据的通道,Data Out是服务端向客户端推送数据的通道。所有要通过代理转发的Mesh网络消息,都封装成Proxy PDU之后,从这个服务进出。
我见过一些项目在实现时自作主张把Data In做成普通的Write(带响应)属性,虽然GATT层面也能写,但这并不符合规范对吞吐的要求。规范里面明确Data In至少应该支持Write Without Response,因为配网和Proxy场景下,连续快速发送控制消息的场景很多,每一包都等ATT响应会卡住整条链路。
2.2 Write Without Response为什么是“唯一”选择
为什么这里必须用Write Without Response而不是普通Write?这样说吧,一条GATT Write操作,客户端要等服务器返回ATT响应,一来一回至少多个RTT;而Write Without Response只需要客户端把包发出去,底层链路层有ACK机制保证传输可靠性,但ATT层不需要等待应用响应。对于Proxy链路来说,上层Mesh协议本身有序列号、重传和确认机制,不需要ATT层的逐包确认。这两层职责要是混在一起,反而会拖累吞吐。
实际用手机测试的时候,同样一段数据,用带响应的Write发送和用Write Without Response发送,在连接间隔稍微放大一点的场景下,吞吐差距不止两倍。无线环境本身就不稳定,能少一次往返就少一次。
2.3 Proxy Server与Proxy Client的角色边界
运行MESH_PROXY服务并处于已入网状态的节点,就是Proxy Server。连接它的手机、网关、调试工具,就是Proxy Client。Proxy Server收到Client通过Data In发来的Proxy PDU后,解开封装,如果是Network PDU,就把这个网络包放进Mesh广播网络里,让相邻节点继续中继;反过来,Server从Mesh网络里收到目标地址匹配的消息,也会封装成Proxy PDU,通过Data Out通知给Client。
需要注意的一个工程细节:Proxy Server可以同时挂多个Proxy Client,但规范对分段消息的转发有说明,服务端在给不同客户端发送分段消息时,要避免把不同分段交织混发。建议实现上给每个客户端维护独立的发送队列,串行处理分段消息。这点很多自研协议栈容易忽略,一旦两个客户端同时接收分段数据,重组就全乱了。
3. MESH_PROV服务:PB-GATT配网承载的专用通道
3.1 MESH_PROV的特征设计
Mesh Provisioning Service的UUID是0x1827,也是16位UUID。它同样有两个特征值:
| 特征值 | UUID | 属性 | 方向 |
|---|---|---|---|
| Mesh Provisioning Data In | 0x2ADB | Write Without Response | Provisioner → 未配网设备 |
| Mesh Provisioning Data Out | 0x2ADC | Notify | 未配网设备 → Provisioner |
这个服务的生命周期很有意思:它只在设备处于未配网状态时存在。设备一旦完成配网,进入Provisioned状态,MESH_PROV服务就应该停掉,设备重启后也不再广播这个服务。你可以把它理解成一把“一次性钥匙”,只用来打开入网这扇门。
3.2 配网PDU怎么“塞”进GATT连接
PB-GATT承载的传输对象是Provisioning Protocol里的各种PDU,比如Invite、Capabilities、Start、Public Key、Confirm、Random、Data等。这些PDU用GATT特征值直接承载。需要注意,配网PDU里有几个长度比较大的,比如公钥交换是64字节,配网Data也可能超过默认ATT MTU 23字节。如果设备还是默认23字节MTU,一包根本放不下,底层就会拆成多个ATT包。
好在规范在Provisioning阶段没有额外定义一套复杂的SAR分段逻辑,工程上依赖GATT的MTU协商和协议栈底层自动分包就够了。前提是你得把MTU协商上去,别停留在默认值。我在实际测试中就遇到过未配网设备广播了自己的MTU能力,但手机端没有做MTU请求,导致64字节公钥被拆成好几包,时序一紧张就偶发失败。
3.3 和Proxy Service的本质区别
MESH_PROV和MESH_PROXY虽然长得像,但使用场景完全不同:
- MESH_PROV是配网前用的,服务端是未配网设备,传输的是Provisioning PDU。
- MESH_PROXY是配网后用的,服务端是已配网节点,传输的是Proxy PDU,里面主要是Network PDU。
- MESH_PROV只有Provisioner连上它,配网流程结束后使命完成;MESH_PROXY则是长期服务,只要节点开着代理功能,就一直存在。
如果把这两个搞混,最常见的现象就是:配网已经完成了,手机还执着地去找MESH_PROV服务,结果发现扫不到,然后怀疑设备进不了配网模式。实际上设备已经入网了,服务已经从广播里消失了。
4. 代理协议拆包:Proxy PDU格式、SAR分段和消息类型
4.1 第一个字节背后的信息
MESH_PROXY服务上传送的数据,是一个个Proxy PDU。Proxy PDU的第一个字节是分段的SAR字段,同时也是完整消息的消息类型字段,这是初学者最容易绕晕的点。高2位表示SAR状态,低6位表示消息类型或段序号,具体取决于SAR状态。
| SAR值(高2位) | 含义 | 低6位的作用 |
|---|---|---|
| 00 | 完整消息(Complete Message) | 表示消息类型 |
| 01 | 分段消息的第一段(First Segment) | 表示段序号 |
| 10 | 分段消息的中间段(Continuation Segment) | 表示段序号 |
| 11 | 分段消息的最后一段(Last Segment) | 表示段序号 |
段序号占低5位,范围是0到31。所以一个被分段的消息最多可以有32个分段。对于Mesh网络里常见的Network PDU来说,这个数量绰绰有余;哪怕将来要承载更长的配网PDU,32段也够用。
4.2 四种Message Type各管一摊
当SAR为00(完整消息)时,第一个字节的低6位就是消息类型。规范定义了四种:
| 类型值 | 名称 | 用途 |
|---|---|---|
| 0x00 | Network-PDU | 承载网络层PDU,Proxy转发的主要对象 |
| 0x01 | Mesh-Bearer | 承载Mesh Bearer上的消息,如配网承载内容 |
| 0x02 | Proxy Configuration | 代理配置消息,用于过滤器的设置 |
| 0x03 | Provisioning-PDU | 直接承载配网PDU,用于经代理转发配网流程 |
之前调试时我看过一个很迷惑的抓包:Data In特征上收到的第一个字节是0x02,我当时以为是网络消息类型,结果怎么解析都不对。翻规范才发现0x02是Proxy Configuration,是客户端在配过滤器。理解这四种类型后,很多解析问题就迎刃而解。
4.3 分段重组与ATT MTU的那笔账
为什么要分段?因为ATT MTU限制了单包能承载的数据量。默认MTU是23字节,扣除ATT操作码和句柄占用的3字节,一次写操作最多带20字节用户数据;再扣除Proxy PDU自己占用的1字节SAR头,实际上每个分段最多只能放19字节。所以只要消息长度超过19字节,就必须拆包。
每个分段长度不应当超过(ATT_MTU - 4)字节,这是规范明确建议的。我建议产品实现时把连接MTU尽量协商到最大,比如247字节,这样每个分段能放243字节,绝大多数Network PDU可以一条完整消息直接发出去,不需要分段,还能省掉接收端重组的时间。MTU协商不上来,明明是完整消息,愣是被拆成两包,出问题的概率直线上升。
5. 代理配置和过滤器:怎么连上了还是收不到消息
5.1 过滤机制的原理
Proxy Server为了不让Client被Mesh网络里的广播风暴淹没,设计了过滤器(Filter)。过滤器决定哪些Network PDU会被Server转发给Client。
它的规则是:Server拿到一个Network PDU后,读取PDU里明文的DST目的地址,然后用这个地址去匹配过滤器。过滤器有两种工作模式:白名单和黑名单。白名单模式下,只有目的地址在白名单里的消息才会被转发;黑名单模式下,目的地址在黑名单里的消息不会被转发。
这里必须强调一句:过滤器匹配的是目的地址,不是源地址。有一个常见误解是我想收来自某个节点的消息,就往过滤器里加那个节点的源地址,结果怎么都收不到。因为Server判断的是这条消息要去哪,不是它从哪来。
5.2 四条配置指令的组合使用
Proxy Configuration消息(类型0x02)里第一个字节是Opcode,后面跟参数。完整的操作码如下:
- 0x00:Set Filter Type,设置过滤器类型,参数1字节(0x00白名单/0x01黑名单)
- 0x01:Add Addresses to Filter,添加地址,参数为2字节一个的地址列表
- 0x02:Remove Addresses from Filter,移除地址
- 0x03:Set Filter Type and Add Addresses,一步完成设置类型和添加地址
如果只想配置白名单并添加两个地址,可以构造一个完整的Proxy PDU,比如:
02 03 00 01 00 0C 00这里第一个字节0x02表示完整消息且类型是Proxy Configuration,第二个字节0x03是组合操作码,第三个字节0x00表示白名单,后面0x0100和0x000C就是两个2字节地址。实际地址要按你自己的Mesh单播地址和组播地址填。
5.3 初始化代理过程的典型顺序
一个典型Client连上Proxy Server之后,应该先做一件事:设置过滤器。因为规范里说得很清楚,过滤器初始状态是一个“空的白名单”,也就是默认不转发任何消息。你如果连上后什么都不做,Server不会主动推任何东西过来,这不是连接坏了,是规则本身就是空的。
合理的初始化顺序是:
- 连接MESH_PROXY服务,扫描到0x1828。
- 使能Data Out的Notification(写CCCD)。
- 协商MTU,尽量提到较高值。
- 发一条Proxy Configuration,把过滤器类型设为白名单,并把本机单播地址和感兴趣的组播地址加进去。
- 完成之后,Server才会开始转发匹配的Network PDU。
我自己的习惯是把“设置过滤器”这一步放在建立连接的早期,而不是等业务消息发不出去的时候才想起。nRF Mesh这类成熟App连上节点后,实际上也是自动做了过滤器的初始化,你只是看不见而已。
6. 工程实践排错:MTU、抓包、常见协议实现错误
6.1 MTU协商不上去导致的“拆包玄学”
产品里MTU协商不上去是常态,尤其是第三方协议栈默认值保守的时候。MTU协商不上去,表现就是:偶尔一条消息能收到,偶尔收到半截,甚至有时候重组超时被丢。我见过同事排查了半天,最后发现是Client没发Exchange MTU请求,两边都是默认23字节,64字节的公钥被拆成4段,重新组装的时序稍微一乱就完蛋。
建议在GATT连接建立之后立刻发起MTU交换请求,别等业务需要了再去查。在Zephyr里一般是bt_gatt_exchange_mtu,nRF5 SDK里也有对应API。配网场景尤其要优先处理这一步,配网PDU普遍偏大。
6.2 调试Mesh GATT链路的方法与工具
调试这两个服务,我推荐两条路:硬件抓包器加Wireshark,或者直接用手机端nRF Connect的GATT工具做手动读写。
nRF Connect能看到0x1828服务的两个特征值。我经常这么干:先写CCCD使能Notification,然后给Data In发一条Proxy Configuration(记得第一个字节用0x02开头),再观察Notification里是否有数据。如果啥都没有,优先怀疑过滤器和MTU。如果要用Wireshark抓包,建议把“Mesh Proxy PDU”解析打开,这样第一字节的SAR和消息类型会自动解析出来,省去自己手算。
调试时有个小技巧:看Notification数据流的第一个字节,如果一直是0x00开头,说明多半是Network PDU;如果看到0x02开头,说明是配置消息。别傻乎乎地把0x02当作网络消息解析。
6.3 常见实现错误和安全注意
一个很常见的错误:多个Proxy Client同时连接Proxy Server,多路分段消息并发,如果Server不为每个Client单独管理SAR状态,重组缓冲区就会互相污染。规范对分段传输的串行化要求要落实到代码里,给每个Client一个独立发送队列。
另一个容易被忽略的点是安全要求。规范和实际产品都建议,MESH_PROXY和MESH_PROV服务的特征值访问必须在加密连接上进行;配网阶段的公钥和认证值如果是在明文链路上传的,附近设备抓包就能直接看到。我们做产品的时候,至少要求连接达到LE Secure Connections级别的加密,必要时再加MITM保护。这一条在规范第七章里虽然只是安全要求的一部分,但工程上影响很大。
另外提一句:适配“配网完成后停用MESH_PROV、启用MESH_PROXY”这个状态切换,建议加一个延时和重试机制。现实中很多节点是手机配网后立刻掉线,原因就是服务切换得太急,Client还没来得及重新发现0x1828服务,连接就断了。留一个几百毫秒的窗口,能让手机端顺利从配网状态切到代理连接状态。
最后再分享一个实践心得:我每次调试Mesh GATT链路,都会先确认三件事——服务UUID找对没有(0x1827还是0x1828)、MTU协商到多少、过滤器配了哪些地址。这三件事一确认,八成的问题都已经解决了。剩下两成,靠抓包看第一字节基本也能定位。第七章篇幅不长,但只要抠透这一章,配网和代理这两条链路就算真正攥在手里了。