1. 从智能手环说起:为什么你需要了解ATT和GATT?
如果你用过智能手环、蓝牙体重秤或者那些小小的防丢器,那你已经和蓝牙低功耗(BLE)打过交道了。你可能觉得,不就是手机连上设备,然后数据就传过来了吗?但作为开发者,或者一个喜欢折腾硬件的极客,如果只停留在“能连上”的层面,那可就错过了BLE最精彩的部分。我刚开始做BLE项目时,也踩过不少坑:为什么我的设备手机搜不到?为什么数据有时能读有时不能?为什么别人的手环耗电那么低,我的设备几天就没电了?
后来我发现,问题的根源往往不在代码写得怎么样,而在于对底层协议的理解不够深。这就好比开车,你只知道踩油门和刹车,却不了解发动机和变速箱的工作原理,一旦车子出点小毛病,你就只能干瞪眼。在BLE的世界里,ATT(属性协议) 和 GATT(通用属性规范) 就是这套动力总成的核心。它们是所有BLE应用数据通信的基石,理解了它们,你才能真正“驾驭”你的蓝牙设备。
简单来说,你可以把BLE设备想象成一个提供各种信息的小商店。ATT协议规定了顾客(客户端,比如你的手机)如何向店员(服务器,比如你的手环)询问商品信息、购买商品或者订阅新品到货通知。它只关心最基础的“问”和“答”的流程。而GATT协议则像是这家商店的商品陈列规范和组织架构图。它定义了商店里有哪些“服务区”(比如“健康监测区”、“设备信息区”),每个服务区里有哪些具体的“商品”(特征),比如“心率数据”、“电量信息”,以及这些商品该如何被查询和交易。
很多新手开发者一上来就直接调用手机系统提供的GATT API,比如 discoverServices() 或者 readCharacteristic(),感觉好像很简单。但一旦遇到连接不稳定、数据解析错误、功耗异常这些问题,就会一头雾水。这是因为你没有理解,这些高级API的每一次调用,底层其实都在进行一系列ATT协议的请求和响应。只有摸清了ATT和GATT这对“黄金搭档”是怎么协同工作的,你才能写出稳定、高效、省电的BLE应用,也能在调试时,快速定位问题到底出在协议栈的哪一层。
所以,这篇文章,我就想和你像朋友聊天一样,掰开揉碎地讲讲ATT和GATT。我会用大量的生活类比和实际代码片段,让你不仅明白它们是什么,更能知道在实际项目中怎么用,怎么避坑。无论你是正在开发智能硬件的嵌入式工程师,还是编写手机App的应用开发者,相信这些内容都能给你带来实实在在的帮助。
2. ATT协议:BLE数据通信的“基础语法”
2.1 ATT的核心思想:一切皆属性
让我们先抛开所有复杂术语,想象一下ATT协议设计者的初衷。在资源极其有限的低功耗设备上(可能只有几KB的RAM),要实现可靠的数据交换,协议必须足够简单、高效。ATT协议给出的答案就是:把设备所有可供访问的数据,都抽象成一个个“属性”(Attribute)。
什么是属性呢?它就是一个最基本的数据单元,包含三个核心部分:
- 句柄(Handle):一个16位的数字(0x0001到0xFFFF),相当于属性的门牌号。在设备内部,ATT协议就靠这个句柄来快速定位到具体的数据。它是唯一的。
- UUID(通用唯一识别码):一个128位(或缩写的16位)的ID,用来标识这个属性是什么类型的数据。比如,0x2A37这个16位UUID就代表“心率测量值”。UUID决定了数据值的格式和含义。
- 值(Value):属性里存储的实际数据。它可以是一个字节的电量百分比,也可以是十几个字节的传感器数据包。
- 权限(Permissions):规定了谁能以什么方式访问这个属性。比如“只读”、“可写”、“需要加密认证才能读”等。
你可以把整个BLE设备看作一本厚厚的、带编号的记事本(句柄)。每一页(属性)上都贴了一个标签(UUID)说明这一页记录的是什么信息(比如“姓名”、“温度”),然后页面上写着具体的内容(值)。ATT协议就是一套如何翻阅、查询、修改这本记事本的规则。
这种设计的精妙之处在于它的极简和统一。无论你的设备是心率带、温湿度计还是智能门锁,所有数据交互都被抽象成对属性的“读”、“写”、“通知”等几种有限的操作。这大大降低了协议栈的复杂度和实现成本。我当初在设计一个自定义的传感器节点时,就深刻体会到这种简洁带来的好处:我只需要定义好我的数据用哪些UUID,然后把数据填到对应的属性值里,一个基础的、可被手机发现和读取的BLE设备就成型了。
2.2 ATT的对话模型:请求与响应
ATT协议采用严格的客户端-服务器(Client-Server)模型,并且通信总是由客户端主动发起。服务器(通常是传感器、手环等外设)被动地持有那本“属性记事本”。客户端(通常是手机、平板等中心设备)则主动发起查询。
ATT定义了几种最基础的操作原语,它们就像简单的对话口令:
- 读请求(Read Request):客户端说:“请把第0x0015页(句柄)的内容念给我听。” 服务器必须回复一个 读响应(Read Response),里面包含那页的内容(值)。如果那页不存在或无权访问,服务器则回复一个错误响应。
- 写请求(Write Request) 和 写命令(Write Command):两者都是客户端要求修改某一页的内容。关键区别在于,写请求需要服务器确认(回复一个写响应),而写命令不需要确认。这有点像寄信,写请求是“挂号信”,你必须收到回执才放心;写命令是“平信”,发出去就不管了。后者更快,但不保证对方收到。在实际应用中,配置设备的参数(如报警阈值)通常用需要确认的写请求,而连续发送的控制命令(如控制LED闪烁)可能用不需要确认的写命令以提升速度。
- 通知(Notification)和指示(Indication):这是BLE实现低功耗的关键特性,允许服务器主动向客户端推送数据。当客户端对某个属性“订阅”后,服务器一旦有数据更新,就可以直接发送出来。
- 通知:服务器说:“嘿,第0x0015页的内容更新了,现在是XX。” 它不要求客户端回复确认。丢了就丢了,适合连续、非关键的数据流,如实时心率。
- 指示:服务器说:“注意!第0x0016页有重要更新,内容是YY。” 客户端必须回复一个确认消息。这保证了重要信息(如设备报警信号)的可靠送达。
这里有一个我踩过的坑:早期我总把通知和指示混用。在一个需要可靠传输设备状态变更的项目中,我用了通知,结果在信号偶尔不佳的环境下,手机端会丢失一些关键状态切换信息,导致App和设备状态不同步。后来换成指示,问题就解决了。所以,选择通知还是指示,取决于你对数据可靠性的要求。
为了让你更直观地看到ATT的原始交互,我们可以看看一个虚拟的通信过程。假设手机(客户端)想读取手环(服务器)的电量(句柄0x0012,假设值是90%,即0x5A):
客户端 -> 服务器: ATT_Read_Request(句柄=0x0012)
服务器 -> 客户端: ATT_Read_Response(值=0x5A)
就是这么简单直接的一次问答。所有的GATT高级操作,最终都会被拆解成类似这样的一个或多个ATT指令序列在空中传输。理解这一点,是进行深度调试的基础。当你用蓝牙嗅探器抓取空中包时,看到的正是这些最原始的ATT PDU(协议数据单元)。
3. GATT协议:给属性加上“组织架构”
3.1 从混乱到有序:GATT的层次化设计
如果只有ATT,BLE也能工作,但会非常难用。想象一下,你面对一本有几百页、但页码随机、标签各异的记事本(属性表),你怎么快速找到你想要的信息?你需要一个目录、一种分类方法。GATT协议就是为ATT的这本“记事本”建立的标准化“图书馆分类法”和“图书目录”。
GATT在ATT提供的扁平属性列表之上,定义了一个清晰的层次化结构。这个结构由三层组成:
- 服务(Service):这是最高级别的组织单元,代表设备的一个完整功能。比如,一个智能手环可能提供“电池服务”、“设备信息服务”、“心率服务”、“运动数据服务”。每个服务由一个唯一的服务UUID来标识。服务本身不包含数据,它只是一个容器。
- 特征(Characteristic):这是GATT中实际承载数据的核心单元,位于服务内部。一个服务可以包含一个或多个特征。每个特征本质上就是一个ATT属性,但它被赋予了更明确的角色和一组相关的属性。例如,在“心率服务”里,可能包含“心率测量特征”、“身体位置特征”等。
- 描述符(Descriptor):描述符是特征的“元数据”或“配置项”,它是对特征的进一步描述或配置。它本身也是一个ATT属性。最常用、最重要的描述符就是 CCC(Client Characteristic Configuration Descriptor,客户端特征配置描述符)。客户端通过向这个描述符写入特定的值(如0x0001),来启用或禁用该特征的通知/指示功能。
我们可以用一张图和一个表格来清晰地展示这个结构:
GATT层次结构图(以心率服务为例):
GATT Profile(设备整体)
│
├── 电池服务 (UUID: 0x180F)
│ └── 电池电量特征 (UUID: 0x2A19)
│ ├── 值(属性,存着电量百分比,如 0x5A 代表90%)
│ └── 描述符(如CCC,用于配置通知)
│
├── 设备信息服务 (UUID: 0x180A)
│ ├── 厂商名称特征 (UUID: 0x2A29)
│ └── 固件版本特征 (UUID: 0x2A26)
│
└── 心率服务 (UUID: 0x180D)
├── 心率测量特征 (UUID: 0x2A37)
│ ├── 值(属性,存着心率数据和传感器状态)
│ ├── CCC描述符(客户端通过写这里来订阅心率数据)
│ └── 特征描述描述符(可选,用文字描述这个特征)
│
└── 身体传感器位置特征 (UUID: 0x2A38)
└── 值(属性,存着位置代码,如 0x01 代表手腕)
GATT如何映射到ATT属性表:
| GATT 元素 | 对应的 ATT 属性(句柄举例) | 属性类型 (UUID) | 属性值 (Value) |
|---|---|---|---|
| 心率服务 (Service) | 句柄 0x0010 | 主服务声明 (0x2800) | 心率服务UUID (0x180D) |
| 心率测量特征 (Characteristic) | 句柄 0x0011 | 特征声明 (0x2803) | 特征属性(可通知) + 特征值句柄(0x0012) + 特征UUID(0x2A37) |
| 特征值 (Value) | 句柄 0x0012 | 心率测量UUID (0x2A37) | 实际的心率数据 (如 0x16 0x3C,代表心率60) |
| CCC描述符 | 句柄 0x0013 | 客户端特征配置 (0x2902) | 客户端写入的配置值 (如 0x0001 启用通知) |
从这个映射表你可以看到,GATT通过引入几种具有特殊含义的“声明属性”(UUID为0x2800, 0x2803等),像书签一样插入到ATT的属性表中,从而将一堆杂乱无章的属性值,组织成了有层次、可理解的服务和特征结构。
3.2 服务发现:手机如何“认识”你的设备
当你用手机蓝牙扫描并连接上一个BLE设备后,手机App第一步要做的就是 服务发现(Service Discovery)。这个过程完全是GATT层定义的,但底层是通过一系列ATT的“读”或“查找”请求完成的。
这个过程通常是这样的:
- App调用
discoverServices()。 - 手机系统(GATT客户端)会向设备(GATT服务器)发送一个 ATT“按类型读取请求”,查找UUID为 主服务声明(0x2800) 的所有属性。这相当于问:“你这本记事本里,所有标记为‘章节开始’的书签都在哪?”
- 设备回复一个列表,里面包含了每个“主服务声明”属性所在的句柄及其值(值就是服务的UUID)。
- 手机根据返回的服务UUID,就能知道这个设备提供了哪些标准服务(如0x180D是心率服务)。
- 对于每一个发现的服务,手机会继续发起 ATT“按类型读取请求”,查找UUID为 特征声明(0x2803) 的属性,从而发现该服务下的所有特征。
这个过程听起来有点绕,但幸运的是,对于应用开发者来说,iOS的Core Bluetooth或者Android的BluetoothGATT API都把这些复杂的步骤封装好了。你只需要监听回调,就能拿到一个结构清晰的 BluetoothGattService 和 BluetoothGattCharacteristic 对象列表。但是,理解这个过程对于调试至关重要。比如,如果服务发现失败,你就需要排查是ATT连接没建立好,还是设备端的GATT服务器数据库配置有误。
4. ATT与GATT的协同实战:以心率监测为例
理论讲得再多,不如看一个完整的实战流程。我们就以最常见的蓝牙心率带和手机健身App为例,看看ATT和GATT是如何无缝配合,完成从连接到实时数据传输的。
4.1 连接与角色确立
首先,心率带(作为GATT服务器)广播自己的存在,广播包里可能包含心率服务的UUID(0x180D),方便手机快速过滤。手机上的健身App(作为GATT客户端)扫描并发起连接。连接建立后,一个ATT承载(逻辑链路)就在它们之间创建好了。此时,ATT层的基础通信通道已经就绪。
4.2 GATT层的服务与特征发现
接下来,App开始GATT层的探索:
- 发现主服务:App通过GATT API发起服务发现。底层手机会发送ATT请求,读取所有UUID为0x2800的属性。心率带回复:“我在句柄0x0010处有一个主服务,它的UUID是0x180D(心率服务)。”
- 发现特征:App接着请求发现心率服务下的所有特征。手机发送ATT请求,查找在句柄0x0010之后、下一个服务之前的,所有UUID为0x2803的属性。心率带回复:“在句柄0x0011处有一个特征声明,它的特征值句柄是0x0012,特征UUID是0x2A37(心率测量),属性是可通知的。”
至此,App已经知道了心率测量数据存储在服务器的句柄0x0012位置,并且支持通知。
4.3 ATT层的配置与数据流
现在,App想要实时接收心率数据:
- 启用通知:App需要告诉心率带:“请把句柄0x0012的数据变化通知我。” 这个配置是通过写入CCC描述符实现的。App首先需要找到这个描述符(通常紧跟在特征值句柄之后,即0x0013)。然后,App调用GATT的
setCharacteristicNotification(characteristic, true),底层实际上会执行一个 ATT写请求(Write Request),向句柄0x0013写入值0x0001(启用通知)。心率带收到后回复一个ATT写响应,确认配置成功。 - 接收数据流:配置成功后,每当心率带检测到新的心跳,它就会在ATT层主动发起一个 Handle Value Notification PDU,其中包含句柄0x0012和最新的心率数据值(比如
0x16 0x3C表示心率60)。这个通知包不需要手机确认,直接发送。手机的ATT层收到后,解析数据,并通过GATT回调(如onCharacteristicChanged)将解析好的心率值传递给App界面显示。
整个流程中,GATT层定义了“心率服务”、“心率测量特征”、“CCC描述符”这些逻辑概念和它们之间的关系。而ATT层则忠实地执行了底层的“读属性找服务”、“写描述符配置”、“收通知包传数据”这些具体的通信动作。两者一表一里,共同完成了高效的数据交互。
5. 开发中的关键抉择与避坑指南
理解了原理,最终还是要落到开发上。在实际项目中,关于ATT和GATT,有几个关键的选择和常见的“坑”需要特别注意。
5.1 自定义服务 vs. 标准服务
蓝牙技术联盟(SIG)定义了大量标准的 GATT服务 和 特征(如电池服务0x180F、设备信息0x180A)。使用标准服务的好处是互操作性极强。任何遵循标准的手机App,不需要任何特殊协议,就能识别并读取你设备的电量、厂商信息等。我强烈建议,只要标准服务能满足需求,就优先使用它。
当你需要传输自定义数据(比如特定的传感器融合数据、控制命令)时,才需要定义自定义服务。这时,你需要自己生成一个128位的UUID(确保全球唯一),并定义其特征的结构。虽然灵活,但这意味着你需要为你的设备开发专用的App。在定义自定义特征时,要仔细设计其属性(读、写、通知、指示)和权限(是否需要加密、认证),这直接关系到安全性和功耗。
5.2 通知 vs. 指示:可靠性 vs. 效率的权衡
这是协议选择上的一个经典权衡:
- 使用通知(Notify):当你需要高频、连续、允许少量丢失的数据流时。比如实时的心率、加速度计数据、音频流。它不产生确认包,空中传输效率更高,功耗也更低。
- 使用指示(Indicate):当你需要确保关键指令或状态变更可靠送达时。比如设备关机命令、报警触发信号、固件升级包传输。每次发送都需要对方确认,保证了可靠性,但增加了延迟和功耗(因为多了确认包)。
一个常见的优化模式是混合使用。例如,在智能家居场景中,温度传感器可以每10秒用通知发送一次常规读数;而当温度超过危险阈值时,则立即发送一个指示进行报警,确保主人手机一定能收到。
5.3 连接参数优化:被忽视的功耗关键点
很多开发者只关注数据本身,却忽略了 ATT连接参数 对功耗的巨大影响。连接参数在连接建立时由主设备(手机)提议,从设备(手环)接受或协商。主要包括:
- 连接间隔(Connection Interval):两个数据包之间最短的时间。间隔越短,数据吞吐越快,但功耗越高。心率监测可能设20ms,而一周才同步一次数据的温湿度计可以设到几秒。
- 从设备延迟(Slave Latency):允许从设备跳过多少个连接事件而不唤醒监听。这是实现超低功耗的关键。如果你的设备大部分时间没有数据要发,可以设置一个较大的延迟,让它长时间休眠。
不合理的连接参数是导致BLE设备耗电快的元凶之一。我建议在开发阶段,一定要用手机端的蓝牙调试工具(如LightBlue、nRF Connect)去查看和尝试修改连接参数,找到性能和功耗的最佳平衡点。在嵌入式端,你也可以在代码中响应主设备的连接参数更新请求,给出你期望的参数范围。
5.4 调试技巧:从空中包洞察一切
当你遇到连接不稳定、数据收不到等玄学问题时,最有效的手段就是抓取空中包(Air Sniffing)。使用专业的蓝牙嗅探器(如Nordic的nRF Sniffer、Ellisys的蓝牙分析仪),你可以看到原始的ATT PDU在空中飞舞。
通过分析抓包文件,你可以清晰地看到:
- 连接是否成功建立。
- 服务发现过程是否完整,有没有ATT错误码(如“句柄无效”、“属性未找到”)。
- 写入CCC描述符的写请求是否成功。
- 服务器是否如期发出了通知包。
- 连接参数是多少,有没有在通信。
有一次,我遇到一个设备在部分安卓手机上无法启用通知的问题。通过抓包发现,在某些手机上,App发送的启用通知的写请求,其数据格式(字节序)不符合规范,被设备端拒绝了。而在iOS和另一些安卓机上,格式是正确的。问题根源在于不同手机厂商的蓝牙协议栈实现有细微差异。最终,我们调整了设备端固件对写请求的解析逻辑,使其更兼容,问题得以解决。没有空中包分析,这种问题几乎无法定位。
6. 总结与展望:构建更健壮的BLE应用
聊了这么多,我们可以再回头看看ATT和GATT的关系。ATT是基石,是语言本身,定义了词汇(属性)和基本语法(请求/响应)。GATT是建立在语言之上的文章结构和文体规范,它定义了如何组织这些词汇(服务/特征)来写成一篇有意义的、别人能读懂的文章(可互操作的应用)。
对于开发者来说,我们大多数时间是在和GATT API打交道,享受它带来的结构化便利。但当你想要深入优化性能、降低功耗、解决棘手的兼容性bug时,对ATT协议的理解深度,就决定了你能走多远。它让你能从更底层去思考问题,比如:这个操作底层发出了几个ATT包?有没有更高效的方式?这个错误码在ATT层意味着什么?
随着物联网设备越来越复杂,对BLE的运用也在不断深化。例如,基于ATT的长数据分包传输机制,可以传输超过单个MTU(默认23字节)的大数据块,常用于传输图片或进行OTA固件升级。再比如,通过精心设计自定义的GATT服务层级,可以实现复杂的多设备组网和场景联动。
我的经验是,在启动一个新的BLE项目时,不要急于写代码。先花时间用纸笔或工具画出设备的GATT数据库结构图,明确每个服务、特征、描述符的UUID、属性和权限。然后,思考数据流:哪些数据是手机主动读的?哪些是设备主动推的?推的数据用通知还是指示?连接参数大概设多少?把这些设计清楚,后续的开发会顺畅得多,也能避免很多架构上的返工。
最后,多动手实践,多使用调试工具。理论知识只有在实际的电波中穿梭、在真实的调试日志里碰撞,才能真正变成你的能力。希望这篇长文能帮你拨开ATT和GATT的迷雾,在蓝牙低功耗的世界里,更自信地构建出稳定、高效、用户体验出色的产品。
6164



被折叠的 条评论
为什么被折叠?



