☰
BLE ATT协议深度解析:属性结构、PDU事务与安全权限
2026/10/14 15:57:34 网站建设 项目流程

做BLE开发的人,十有八九一开始都会把ATT和GATT混在一起说。我当初也是从"先跑通一个服务端Demo"的阶段过来的,服务建好、回调写好,能连能读能写就觉得完事了。直到有次用BLE抓包工具看了一次空口报文,才发现真正在链路上一个包一个包跑的,全是ATT层的东西,GATT那些service、characteristic的概念反倒是"逻辑上的上层框架"。这篇就把ATT(Attribute Protocol)规范从头到尾拆一遍,把协议栈位置、属性结构、PDU事务、服务发现流程、安全权限、调试排障这些实际开发真正用得到的部分串起来。适合正在写BLE外设固件、做手机端蓝牙SDK集成,或者想彻底搞懂"为什么一个属性读出来是0x05错误"的人。

1. ATT在BLE协议栈中的位置:GATT之前、L2CAP之上的那一层到底在干嘛

1.1 先分清ATT和GATT,不然后面全乱

很多资料里"GATT"出现频率远高于"ATT",导致大家默认GATT就是蓝牙数据交互的全部。实际上蓝牙核心规范里,ATT是一个独立的协议层,GATT是建立在ATT之上的"配置文件框架"。打个比方:ATT像一栋楼的管道系统,规定水怎么流、阀门怎么开;GATT则规定了每个房间该放什么家具、门牌号怎么写。没有管道,家具搬不进去;没有GATT规划,管道里灌什么水没有统一规则。

从协议栈角度看,数据链路是这样排列的:物理层和射频 → 链路层(LL) → HCI(可选,取决于芯片实现) → L2CAP → ATT → GATT。ATT跑在L2CAP的一个固定信道上,信道ID是0x0004,这是规范写死的,不需要额外协商。GATT本身不产生报文,它调用ATT的接口来读写属性;你在抓包工具里看到的Read Request、Write Response、Notification,这些PDU全是ATT层的。

有一段时间我很执着于一个问题:"GATT到底在哪一层?"后来在某个嵌入式社区的讨论里看到一个说法,深以为然:GATT是CPU里的用户程序,ATT是内核驱动。程序通过驱动访问寄存器,GATT通过ATT访问属性。驱动坏了程序报错,属性权限不对,GATT回调返回错误码,就是这么个关系。

1.2 为什么蓝牙低功耗非要单独设计一个ATT协议

传统蓝牙(BR/EDR)时代,属性交互走的是SDP和GAP那套,又重又复杂。BLE定位是低功耗、小数据包、突发传输,所以ATT被设计得很"薄":一个属性就是一条扁平记录,没有树形结构、没有复杂查询语法,只有叠加上去的句柄索引。这样带来的好处是:

  • 属性表可以用一个连续数组存,按句柄索引查找的时间复杂度是O(1),对小内存的MCU非常友好;
  • 协议上每个事务都有明确的请求-响应或命令-通知语义,状态机简单,不容易死锁;
  • 报文头开销最小。一个普通读请求就3个字节(操作码1字节+句柄2字节),对空口带宽极其抠门。

这也解释了为什么BLE的属性值默认只能传20个字节——默认ATT_MTU是23字节,扣掉PDU头,留给有效载荷的就这么点。这个"抠"是刻在协议骨子里的,不是实现缺陷。

1.3 抓包时如何一眼区分ATT帧和GATT帧

用抓包工具看BLE通信时,很多人会看到GATT出现在解码树上一头雾水。实际上协议分析工具的展示逻辑通常是:链路层收到L2CAP包 → 判断CID是0x0004 → 按ATT解析PDU → 如果PDU里携带的服务UUID能对应到已知GATT规范,就额外标注"这个属性属于xxx服务"。所以你在工具里看到的"GATT"标签,本质上还是ATT报文,只是解析器帮你做了联想。

我自己判断的习惯是:先看L2CAP的CID字段是不是0x0004,再看ATT操作码。只要操作码在规范定义的范围里,不管工具里写的是GATT还是ATT,处理方式都一样。工具把PDU对应的服务名称标出来只是方便人眼阅读,别被这个误导,以为GATT和ATT在空口是两种包。

2. 剖析属性的核心结构:句柄、类型、值、权限四要素怎么协同

2.1 属性是"句柄+UUID+值"的扁平记录,为什么这么设计

ATT协议里,服务器侧维护一张属性表,每个属性由四个要素构成:

要素长度说明
句柄(Handle)2字节属性在表中的索引,范围0x0001到0xFFFF,0x0000非法
类型(Type)2或16字节UUID,描述这个属性是什么
值(Value)可变属性携带的数据,受MTU限制
权限(Permissions)逻辑字段决定谁能读、谁能写、是否需要加密等

这里最反直觉的一点是:句柄不是固定不变的"身份证号",而是每次连接建立后,客户端临时用来定位属性的"内存地址"。属性在服务器上按句柄从小到大连续排列,客户端通过句柄范围发起查询;但客户端不能假设某设备的0x0010句柄永远是同一个属性,服务器固件版本一变,属性增删,句柄就变。

所以真正的"属性身份证"是UUID,句柄只是不稳定的索引。这一点在写手机端代码时特别重要。有些开发者图省事,把服务端某个服务的句柄直接硬编码在App里,固件一升级就全废。正确姿势永远是先动态发现,再基于UUID操作,句柄只在发现之后当作临时引用。

2.2 UUID的类型差异:16位、32位、128位,为什么大多数场景用16位

UUID是属性的类型标识。规范定义了三类:16位、32位和128位。16位和32位UUID是从128位"蓝牙基础UUID"推导出来的,基础UUID是0000xxxx-0000-1000-8000-00805F9B34FB,把xxxx位置换成16位值即可。比如服务发现要读的主服务声明类型0x2800,完整128位形式就是00002800-0000-1000-8000-00805F9B34FB。

标准服务和特征都由蓝牙联盟分配了16位UUID,比如电池服务是0x180F、电量特征是0x2A19。自定义服务如果不想走联盟申请,可以直接造一个128位UUID,常见做法是用在线UUID生成器产生一个随机值,然后前后固定成规范格式。

那空口上传16位还是128位?协议规定属性类型这两个长度都允许,PDU里会根据具体消息格式携带。但有一个性能细节:用128位UUID做服务发现时,每次查询报文多14个字节,MTU不够就得分成多次。这是我建议能用16位就用16位的原因——不是省存储,是省空口时间、省电。尤其在做低功耗设备时,每一次空口传输都是电池消耗。

2.3 权限不是摆设:读权限、写权限、加密、认证、授权的完整关系

属性权限是ATT规范里最容易被忽略但坑最多的地方。它的作用分两层:一是协议层的访问控制,二是应用层的业务逻辑。规范层面,权限分为可读、可写以及"是否需要加密链路""是否需要认证""是否需要授权"这些附加要求。

实际开发中有个很容易混淆的点:GATT层的特征属性(Characteristic Properties)和ATT层的属性权限是两个独立的东西。特征属性只是对外声明"这个特征支持读、支持写、支持通知",真正的读写拦截发生在ATT服务器实现里。有些SDK的API把两者绑在一起封装了,就容易让人以为是一个配置项。我见过不止一次,特征属性声明了Read,但属性权限里忘了开读权限,客户端一读就返回错误码0x02(Read Not Permitted)。

权限的优先级也要清楚:协议层的"是否加密""是否认证"检查,永远排在"是否可读可写"之前。也就是说,一个属性即使声明了可读,如果它要求加密链路而当前链路没加密,客户端收到的是0x0F(Insufficient Encryption),而不是正常的读响应。回调里判断业务逻辑之前,先看这些安全类错误码,能省很多排查时间。

3. 四种ATT PDU的语义与事务流程:请求、命令、通知、指示的真实行为区别

3.1 ATT操作码全览,先建立整体印象

ATT协议在一开始就定义了完备的PDU集合,所有的交互都基于这个表。我把实际开发最高频的几类列出来:

操作码名称方向是否有应答
0x01Error Response服务器→客户端它是应答
0x02 / 0x03Exchange MTU Request / Response双向有
0x04 / 0x05Find Information Request / Response客户端→服务器有
0x08 / 0x09Read By Type Request / Response客户端→服务器有
0x0A / 0x0BRead Request / Response客户端→服务器有
0x0C / 0x0DRead Blob Request / Response客户端→服务器有
0x10 / 0x11Read By Group Type Request / Response客户端→服务器有
0x12 / 0x13Write Request / Response客户端→服务器有
0x16 / 0x17Prepare Write Request / Response客户端→服务器有
0x18 / 0x19Execute Write Request / Response客户端→服务器有
0x52Write Command客户端→服务器无
0x1BHandle Value Notification服务器→客户端无
0x1D / 0x1EHandle Value Indication / Confirmation服务器→客户端有

这个表值得收藏。因为后面所有的问题,包括"为什么写数据没反应""为什么通知发不出去",追根究底都能在"这个PDU到底有没有应答"上找到答案。

3.2 Read Request完整事务:字节级拆解一次读操作

读属性的标准流程是这样的:客户端构建Read Request,L2CAP层封装后发往服务器,服务器查找属性、检查权限、返回Read Response或Error Response。这是一个严格的"一问一答"事务,中间没有任何异步。

我刚入门时喜欢把报文字节一个一个对着规范看,这个习惯帮了大忙。这里模拟一次读某个16位属性值的完整报文:

Read Request: 0A 10 00 Read Response: 0B 48 65 6C 6C 6F

第一行:0x0A是读请求操作码,0x10 00是句柄0x0010的小端表示。第二行:0x0B是读响应操作码,后面5个字节是属性值("Hello"的ASCII)。注意整个报文长度是6字节,小于默认MTU的23字节,所以一次能读完。

如果属性值超过20字节,客户端就不能再用Read Request了,要改用Read Blob Request,指定起始偏移量,一次读一部分。协议对最大属性长度没有硬性限制,但受MTU和偏移量两次字节限制,实际能读到的上限大约也就是MTU允许的范围。这个设计很像操作系统里用偏移量读写文件,理解了这个,后面看长特征值读写就不会懵。

3.3 Notify和Indicate的区别,以及它们和"读"的本质不同

服务器想主动推送数据,有两条路:Notification(0x1B)和Indication(0x1D)。前者发完就完,不需要客户端确认;后者发出去之后,客户端必须回一个Confirmation(0x1E),服务器收到确认才知道这次推送完成。

为什么要有这种区分?本质是对可靠性的取舍。Notification少一次往返,吞吐高、省电,但服务器完全不知道客户端有没有收到。Indication多一次确认,保证双方状态一致,适合丢一个包会产生业务问题的场景,比如测量数据的关键事件。用生活化的话说,Notification像发微信消息不看回执,Indication像发挂号信必须签收。

规范里还有一个细节:让客户端使能Notify或Indicate,是通过写CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR(简称CCCD,类型0x2902)实现的。CCCD的值是2字节的位图,第0位对应Notify,第1位对应Indicate。如果这个描述符没被置位,服务器即使调用通知接口,底层也不会发出任何PDU。这是新手最常踩的坑之一——服务端明明调用了发送函数,抓包却一个包都没有,十有八九是CCCD没置位或者置错了位。

3.4 长属性写入的全过程:Prepare Write+Execute Write

写长数据(超过默认MTU能承载的20字节)不能一次写完,规范设计了"准备写+执行写"两阶段事务。Prepare Write Request把数据分成若干片,每片都带句柄和偏移量,服务器逐片暂存;数据全发完后,客户端发一个Execute Write Request,带上0x01标记提交,服务器才真正落盘。如果要取消,就发Execute Write Request,标记置0x00。

这个机制很像数据库的事务概念。Prepare阶段是DML操作,Execute阶段是提交事务,执行前还能回滚。当初我调试OTA固件升级时,分包写大文件就全靠这套机制。很多SDK封装了writeLong接口,但底层走的还是这套PDU,所以抓包时看着一串Prepare Write不要慌,那是正常的。

也有一个细节在实操中很要命:Prepare Write的每一片长度,由当前协商好的MTU决定,不只是简单的"MTU减3"。因为每个Prepare Write PDU里除了操作码1字节外,句柄占2字节、偏移量占2字节,剩下才是数据区。如果MTU传进去算错,轻则分包数据错位,重则客户端发完直接卡死等不到Execute Write。我现在的习惯是封装一个公共工具函数,把所有ATT PDU的开销统一算好,避免每个业务单独算。

4. 属性发现与分组机制:手机App拿到服务列表之前发生了什么

4.1 服务发现的首要动作:Read By Group Type和分组语义

设备和手机建立连接之后,手机端第一件事通常是遍历属性表,看看这台设备提供哪些服务。这个动作不走"读某个属性",而是走Read By Group Type Request:客户端要求服务器按照指定的属性类型(0x2800主服务声明)做分组返回,一个分组就是一段连续句柄范围内所有类型相同的属性。

Read By Group Type Request: 10 01 00 FF FF 00 28 Read By Group Type Response: 11 06 01 00 06 00 00 18 // 服务1:句柄0x0001~0x0006,UUID 0x1800 07 00 09 00 0F 18 // 服务2:句柄0x0007~0x0009,UUID 0x180F

第三行0x10是操作码,0x01 00是起始句柄,0xFF FF是结束句柄,0x00 28是"主服务声明"类型0x2800。响应里06是每个属性条目的长度(句柄2字节+结束句柄2字节+UUID 2字节),后面每个条目6字节。注意,分组返回的"结束句柄"是这个服务的属性表在物理上的终点,不是服务里最后一个特征的句柄,而是整个服务占用的句柄段的尾部。因为服务内部还有特征声明、特征值、描述符等一堆属性,所以服务声明属性和最后的特征描述符之间,被一组连续的其他类型属性占着,Read By Group Type把这一整段当成一个组返回。

4.2 特征发现:Read By Type和Find Information的配合

拿到服务的句柄范围后,客户端要找出这个服务里有哪些特征。特征是"特征声明"属性(类型0x2803)+"特征值"属性(自定义UUID)的复合概念。客户端接着发出Read By Type Request,指定范围是服务句柄段,类型是0x2803:

Read By Type Request: 08 01 00 06 00 03 28 // 类型0x2803,范围0x0001~0x0006 Read By Type Response: 09 07 02 00 02 06 00 0F 18 // 特征声明在句柄0x0002,值句柄0x0006,特征UUID 0x180F(电池)

这里能看到特征声明的值格式:1字节特征属性位图+2字节特征值句柄+2或16字节UUID。特征属性位图的各个bit分别表示这个特征支持读、写、通知、指示等能力。这一步拿到的是"这个服务里有这些特征,各自的值在哪个句柄",但还拿不到特征值的权限信息。权限只能靠实际读才能试出来,这是ATT的一个设计特点——属性权限不单独声明,只能通过一次实际访问的响应来判断。

特征值句柄和特征声明句柄之间通常还夹着其他属性,所以客户端需要再发一次Find Information Request,枚举这一段范围的所有属性,把特征值、描述符全部找出来。特别地,如果某个特征有Notify或Indicate能力,它的CCCD(0x2902)也会在这一步被找到,App后续要在这里写入使能位。

4.3 句柄空洞、句柄重叠与分组结束判定:三个最容易出问题的点

服务发现过程中,最容易被忽略的是句柄空洞问题。属性表不是连续无空档的:一个服务的句柄段可能是0x0001到0x0006,下一个服务从0x0010才开始,中间0x0007到0x000F是空置句柄。设备出厂固件里通常会把这些空句柄的位置保留出来,方便后续功能追加。客户端发现逻辑如果写得太蠢,把空洞当作某个服务的内部属性,读响应可能会返回0x01(Invalid Handle),然后整个枚举流程被打断。

我的处理原则是:服务发现必须严格按响应里的结束句柄推进,永远不要假设"上一个服务的结束句柄+1就是下一个服务的开始句柄"。Read By Group Type响应自己会给出下一段的起始位置,照着返回数据走就行。

还有一种情况是句柄重叠。理论上规范禁止两个服务共享同一段句柄,但实际某些第三方固件实现为了省空间,会把次要服务和主服务定义在重叠段里。这会导致客户端在状态机里重复处理属性,甚至出现死循环。我遇到这种情况的习惯是:客户端侧加超时保护和访问次数上限,一旦连续N次查同一段范围返回相同结果,就判定固件异常,主动断开连接,而不是无限循环。

5. ATT安全与权限控制:加密、认证、授权在属性访问上的体现

5.1 安全错误码:0x05、0x08、0x0A、0x0F分别代表什么

属性访问返回的安全类错误码,是排查"为什么读不了"最直观的入口。我整理了一个速查表:

错误码名称含义典型场景
0x01Invalid Handle句柄不存在或无意义句柄被拼错、固件版本变了
0x02Read Not Permitted属性不可读配置漏了读权限
0x05Insufficient Authentication需要认证但链路未认证配对后未完成认证流程
0x08Insufficient Authorization应用层授权不足需要用户确认/业务授权
0x0AInsufficient Encryption Key Size加密密钥长度不够配对的密钥长度低于属性要求
0x0FInsufficient Encryption链路未加密属性要求加密但还没配对

很多人在调试时混淆0x05和0x0F。0x0F是说"你这条链路压根没加密";0x05是说"链路加密了,但加密等级不够,属性要求更高等级的认证密钥"。两者的处理路径完全不同:前者触发配对流程就行,后者要检查配对方式是否支持认证(例如是否使用了带MITM保护的配对)。

至于0x08(Insufficient Authorization),它和0x05的关键区别在于:认证(Authentication)是链路层的概念,用密钥证明"你是你";授权(Authorization)是应用层的概念,用业务逻辑决定"你有资格访问这个属性"。实际产品里最常见的例子是:某个设备属性需要用户在App端点击二维码授权后才能读,没授权时服务器就应该返回0x08。这个差别在面试里也是高频问题,搞懂它,等于搞懂了BLE安全模型的一半。

5.2 SMP配对之后,ATT层面到底发生了什么变化

BLE配对和加密由SMP(安全管理协议)负责,但配对完成后,ATT感知到的变化只有一个:链路层的加密状态。如果配对流程正常完成,L2CAP链路会被加密,ATT服务器在收到访问请求时,底层会告诉它"这条链路是加密的、密钥长度是多少、是否经过认证"。属性权限里的安全要求会拿这些链路元数据和当前状态做比较,通过就放行,否则返回对应错误码。

所以有一个常见的认知误区:以为配对成功了,所有加密属性就都能读了。实际上配对只是建了加密链路,属性自身还得声明"要求加密"才行。而且密钥长度这个参数容易被忽略,配对时如果协商出的是7字节密钥,某些要求16字节密钥长度的属性依然会拒绝访问,报0x0A。这类问题在抓包里几乎看不出来,必须查日志里SMP协商的密钥长度日志。

5.3 "配对后仍然读不了属性"的排查链路

我遇到过的最难缠的一类问题,就是"明明配对成功了,App连上来读某些属性还是返回错误"。真实现场往往涉及三个角色:手机端SDK、固件端属性权限、配对的持久化状态。排查我会按下面顺序来:

  1. 用抓包工具确认链路是否真的处于加密状态。有些SDK的"配对"其实只完成了绑定写,但每次重连没做加密恢复,链路还是明文。
  2. 确认属性权限确实要求加密。打开固件源码看属性表定义,是不是把"可读"和"需要加密"分开配置的,漏掉哪一个都会出问题。
  3. 确认配对方式有没有MITM保护。如果属性要求认证,而配对用的是Just Works,那么即使Key生成成功,因为没有认证,依然返回0x05。
  4. 确认密钥长度。查SMP日志和属性要求的密钥长度是否匹配。
  5. 最后才怀疑权限本身。如果所有链路条件都对,还报0x02或0x08,再回头看属性表配置和应用层授权逻辑。

这个顺序几乎覆盖了我遇到过的所有"读不了"问题。反过来看,也是写固件时配置属性权限的检查清单。

6. 排障实录:ATT层调试时最容易踩的坑和判断方法

6.1 硬编码句柄、依赖顺序发现,为什么会让你的代码一夜报废

把句柄硬编码到代码里,是ATT调试里最常见但最隐蔽的坑之一。有一个项目,固件在一版升级里为了加功能,在某个服务前插了两个新服务,结果所有老App全部读不到数据,因为老App拿着旧句柄直接发Read Request,新固件里这个句柄对应的属性完全不是原来那个了。

正确的做法是在应用启动时完整走一遍发现流程,把UUID到句柄的映射缓存起来。如果担心发现流程耗时长,可以做一次全量发现,后续按句柄操作。这个一次性的开销和硬编码带来的维护灾难比起来,完全值得。我还见过一种更隐蔽的写法:把服务发现的结果存在静态变量里,连接断开后不清理,下次重连直接拿旧句柄用。BLE的句柄只在单次连接内有效,重连之后属性表可能完全变了,这个缓存必须随连接生命周期一起清掉。

6.2 MTU协商失败和数据发不出去的连锁反应

ATT默认MTU是23字节,没协商过的话,一条Notification最多只能带20字节数据。有些设备的固件代码里没实现MTU Exchange Request的处理逻辑,手机端请求把MTU扩到247,服务器不响应,两边就维持在23。结果就是:App明明成功发送了一个大Attribute,抓包看到被拆成一堆小包,或者干脆发不出去。

判断MTU生效没生效,最直接的办法是抓包看Exchange MTU Request和Response这两个PDU。响应里的RX MTU值就是协商结果,两端的有效MTU取两者较小值。这里有个经验:写固件时,MTU交换处理器一定要在GATT初始化之后立刻可用,最好连第一笔业务数据都还没发之前就完成协商。因为有部分手机端协议栈会默认设备不支持扩展MTU,只要没收到响应,就永远按23字节发包,性能直接打折。

6.3 并发读写、多连接和"超时重传"的边界

ATT是串行协议,一个方向上同时只能有一个"待确认事务"在跑。比如客户端发了Read Request还没收到Read Response,立刻又发一个Write Command(0x52),虽然Write Command不需要响应,但它和Read Request在同一链路上先后排队,某些协议栈实现会在收到Read Response之前直接丢弃或报错。这会导致一个非常难查的现象:代码逻辑看起来没错,抓包也有包,就是业务回调不触发。

解决办法分两端:客户端侧严格保证"发一个等待响应的事务完成后再发下一个",用一个事务状态标记来控制;服务器侧把ATT接入回调做成队列化处理,别在中断上下文里做耗时操作。规范里对ATT事务定时器也有要求:发出请求后如果在一定时间内没收到响应,客户端要主动断开连接或者重置链路,防止卡死。实测中这个超时值通常由上层协议栈管理,应用层不用自己操心,但如果你在裸协议栈上写了非标准实现,就必须自己补上这个超时保护。

多连接并发是另一个容易忽略的点。BLE设备同时连手机和网关的场景越来越常见,两个连接各自有独立的ATT状态机。如果有某个全局缓冲区被两个连接共用,又没加锁,就会出现"设备A写入的数据被设备B的通知覆盖"这种灵异事件。调试时我习惯先在日志里给每个连接打独立标记,把连接ID作为所有ATT相关日志的前缀,不然根本分不清包是哪个连接来的。

6.4 日志打点、协议分析工具和"最小复现"的调试方法论

ATT层调试,我的核心方法论就三件事:日志打点、抓包对照、最小复现。日志要覆盖每次PDU收发,记录操作码、句柄、长度、方向;抓包用来验证空口报文是否符合预期;最小复现则是把周边业务全部摘掉,只保留一个属性和一个客户端操作,看问题是否还存在。

我有一次处理"设备偶尔收不到写命令"的问题,排查了两天,最后发现是上层业务在某个条件下疯狂调用发送接口,把发送队列堵死了。当时如果用最小复现的方案,估计两个小时就能定位。把问题缩小到单个属性、单次操作,再逐步加回上下文,这个方法听着笨,但在复杂的BLE工程里是最稳的。

最后说一个给新手的建议:把蓝牙核心规范的ATT部分打印出来,放在工位旁边,哪怕你背不下来,至少要知道去哪一章查。遇到问题先去查规范原文,再信网上资料,网上很多说法对一半错一半。规范才是排障时唯一不会背叛你的东西。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询