1. 项目概述:从概念到代码,理解BLE通信的基石
如果你正在开发基于蓝牙低功耗(BLE)的物联网设备,比如一个智能手环、一个环境传感器或者一个智能门锁,那么你一定会和GATT与ATT这两个协议打交道。它们就像是BLE世界里的“普通话”和“语法规则”,定义了设备之间如何发现、组织和交换数据。没有它们,你的手机App就无法读懂传感器发来的温度数据,也无法向智能灯泡发送开关指令。
很多开发者,尤其是刚接触BLE的朋友,常常觉得协议栈的API文档读起来像天书,参数一堆,回调函数错综复杂,不知道从哪里下手。我刚开始用TI的CC2640做项目时,也对着那份几百页的协议栈用户指南发过愁。但一旦你理解了GATT/ATT的基本模型,再去看那些GATT_ReadCharValue、GATT_WriteCharValue之类的API,就会发现它们其实是对标准协议操作的高度封装,逻辑非常清晰。
本文的目的,就是帮你跨过这个从概念到实践的坎。我不会只复述官方手册里的函数原型,而是会结合我多年在TI BLE协议栈上“踩坑”的经验,带你深入理解GATT和ATT的核心思想,并详细拆解TI协议栈中关键API的使用场景、参数背后的含义,以及那些手册里不会写的、关于内存管理、事件处理和错误排查的“生存技巧”。无论你是要为现有设备开发一个配套的中央设备(Central,比如手机App),还是开发一个外设(Peripheral,比如传感器),这篇文章都能为你提供扎实的参考。
2. GATT与ATT核心概念精讲:不只是缩写
在深入TI的API之前,我们必须把地基打牢。很多人会把GATT和ATT混为一谈,或者说不清它们的区别,这会导致在编程时思路混乱。
2.1 ATT:属性协议——最基础的数据存取层
你可以把ATT想象成一个极其简化的“键值对”数据库。这个数据库里的每一条记录,就叫做一个“属性”(Attribute)。每个属性由三个基本元素构成:
- 句柄(Handle):一个16位的唯一标识符,相当于这条记录在数据库里的主键ID(从0x0001开始)。所有通过ATT协议的操作,最终都是通过这个句柄来定位具体的属性。
- 类型(Type):一个UUID(通用唯一识别码),用来说明这个属性是什么。比如,
0x2A19这个UUID代表“电池电量”这个类型。类型定义了数据的含义。 - 值(Value):属性实际承载的数据,长度可变。这就是我们真正关心和要交换的数据内容。
ATT协议定义了一组非常朴素的操作原语,也就是“动词”,主要包括:
- 读(Read):客户端通过句柄,向服务器请求一个属性的值。
- 写(Write):客户端通过句柄,向服务器写入一个新的属性值。
- 通知(Notify):服务器主动向客户端发送一个属性的值,不需要客户端确认。
- 指示(Indicate):服务器主动向客户端发送一个属性的值,但需要客户端回复一个确认(Confirmation)。
ATT协议只关心“按句柄读写数据”这件事本身,它不关心这些数据是如何组织的,也不关心读写的逻辑顺序。这就引出了GATT。
2.2 GATT:通用属性协议——数据的组织与关系定义层
如果ATT是定义了砖块(属性),那么GATT就是建筑师,它规定了如何用这些砖块搭建出有意义的房间和楼层。GATT在ATT的基础上,定义了一个结构化的数据框架,即“服务(Service)-特征(Characteristic)-描述符(Descriptor)”三层模型。
- 服务(Service):代表设备的一个特定功能单元。例如,“电池服务”、“心率服务”。一个服务包含一个或多个特征。
- 特征(Characteristic):服务中具体的数据点。它是实际进行数据交互的单元。一个特征包含一个值(Value)属性,以及若干个描述符(Descriptor)。特征本身还通过“属性”来声明自己的权限(可读、可写、可通知等)。
- 描述符(Descriptor):用于描述特征的元信息。最常用的是“客户端特征配置描述符”(CCCD)。当你想让服务器主动给你发送通知(Notify)或指示(Indicate)时,你必须通过ATT写操作,向这个CCCD写入
0x0001(启用通知)或0x0002(启用指示)。
GATT和ATT的关系可以这样概括:GATT是使用ATT协议来实现一套更高级、更有组织的通信逻辑的“规范”或“框架”。在代码层面,你调用的GATT_DiscoverPrimaryServices这样的GATT API,其内部最终会分解成一系列ATT_ReadByGroupTypeRequest这样的ATT层操作。
2.3 角色:中央设备与外设
在BLE通信中,有两个核心角色:
- 外设(Peripheral / Server):通常是数据提供者,如传感器、手环。它对外广播自己的存在,并包含一个GATT服务器,里面存放着各种服务和特征。
- 中央设备(Central / Client):通常是数据消费者和控制者,如手机、网关。它扫描并连接外设,然后作为GATT客户端去发现、读取、写入外设服务器上的数据。
TI协议栈中的GAPRole模块就是用来管理设备角色的。你提供的代码片段中的GAPCENTRALROLE_IRK等参数,就是配置中央设备角色行为的。例如,IRK和SRK用于隐私保护和安全连接,是BLE安全机制的一部分。
3. TI BLE协议栈GATT/ATT API深度解析
TI的BLE协议栈(例如用于CC26xx系列的BLE-Stack)对GATT和ATT的API封装得相当友好。它采用了“命令-响应/事件”的异步模型。你调用一个函数发起一个操作(命令),然后协议栈会在后台处理,最终通过ICall消息机制,将一个事件(Event)发送到你的应用任务(Task)中进行处理。
3.1 API调用通用模式与内存管理要点
几乎所有的GATT客户端API都遵循同一个模式,理解这个模式就掌握了所有API的用法。
bStatus_t GATT_SomeFunction(uint16 connHandle, someReq_t *pReq, uint8 taskId);- connHandle:连接句柄。一个中央设备可能同时连接多个外设,这个参数指明操作发生在哪个连接上。
- pReq:指向请求参数结构的指针。这里是第一个关键点:这个结构体及其内部的数据(如要写入的值)通常需要应用程序动态分配内存。官方手册第5.3.5节会强调这一点。你不能使用栈上的局部变量,因为协议栈会在另一个任务(即协议栈任务)中异步使用这个内存。
注意:忘记动态分配
pReq或其内部的pValue是新手最常见的崩溃原因之一。务必使用ICall_malloc或类似的堆分配函数。 - taskId:你的应用任务的ID。当协议栈完成这个操作(无论成功或失败)后,它会向这个任务ID发送一个
GATT_MSG_EVENT事件。 - 返回值:函数的即时返回值。
SUCCESS (0x00)只代表命令被成功接收并排队,绝不代表操作本身已成功完成!真正的结果在后续的事件里。
事件处理流程: 在你的应用任务事件处理函数中,你需要监听GATT_MSG_EVENT。当收到该事件后,解析其中的method字段,它对应着ATT的操作码(如ATT_READ_RSP),然后从msg字段中获取响应数据或错误信息。
3.2 关键客户端命令详解与实战示例
我们挑几个最常用、也最容易出错的API来深入讲解。
3.2.1 服务与特征发现:一切的开始
连接建立后,中央设备首先要做的就是“探索”外设的GATT数据库结构。
GATT_DiscAllPrimaryServices:发现所有主服务。- 内部操作:它发送一个
ATT_ReadByGroupTypeRequest,将属性类型设置为“主服务”的UUID,搜索范围是整个句柄空间(0x0001-0xFFFF)。 - 事件:你会收到一个或多个
ATT_READ_BY_GRP_TYPE_RSP事件。每个事件中的pDataList包含了一组[起始句柄, 结束句柄, 服务UUID]。一个服务可能跨多个句柄。 - 实战技巧:外设的服务列表通常是静态的。你可以在连接成功后一次性完成所有服务发现,并将结果(服务UUID及其句柄范围)缓存起来,避免后续重复查询。
- 内部操作:它发送一个
GATT_DiscAllChars:在已知服务句柄范围内,发现所有特征。- 参数:你需要传入之前发现的服务起始和结束句柄。
- 内部操作:发送
ATT_ReadByTypeRequest,查找类型为“特征声明”的属性。 - 事件:收到
ATT_READ_BY_TYPE_RSP。其pDataList包含特征声明的关键信息:特征属性(可读、可写、可通知等)、特征值句柄、特征UUID。 - 核心备忘:这里返回的“特征值句柄”才是你后续进行读、写、订阅通知操作时真正需要使用的句柄。务必将其保存下来。
3.2.2 数据交互:读、写与订阅
GATT_ReadCharValue:通过已知句柄读取特征值。// 示例:读取句柄为`charValueHandle`的特征值 attReadReq_t *pReq = (attReadReq_t *)ICall_malloc(sizeof(attReadReq_t)); if (pReq) { pReq->handle = charValueHandle; // 之前发现特征时保存的句柄 uint8_t status = GATT_ReadCharValue(connHandle, pReq, myTaskId); if (status != SUCCESS) { ICall_free(pReq); // 如果发送失败,立即释放内存 // 处理错误 } // 如果发送成功,内存会在收到响应事件后,在事件处理函数中释放 }- 事件:
ATT_READ_RSP,其中的pValue就是读取到的数据。注意:单次读取的数据长度受ATT_MTU限制,默认为23字节。如果数据更长,需要使用GATT_ReadLongCharValue。
- 事件:
GATT_WriteCharValue:通过已知句柄写入特征值(需要响应)。- 与
GATT_WriteNoRsp的区别:这是最重要的选择之一。WriteCharValue使用ATT Write Request,服务器必须回复一个Write Response,因此是可靠的。WriteNoRsp使用ATT Write Command,服务器不回复,吞吐量更高,但不可靠。根据蓝牙规范,一个特征必须在其属性中声明支持“Write”或“Write without response”,你才能使用对应的方法。 - 内存管理重点:
attWriteReq_t结构体中的pValue指针也必须指向动态分配的内存,并且你需要正确设置len。
- 与
启用通知/指示:这不是一个单独的GATT API,而是一个组合操作。
- 发现目标特征的CCCD描述符的句柄(通常位于特征值句柄+1的位置,可通过
GATT_DiscAllCharDescs发现)。 - 调用
GATT_WriteCharValue,向该CCCD句柄写入0x0001(启用通知)或0x0002(启用指示)。 - 在应用中调用
GATT_RegisterForInd注册接收指示/通知的回调。之后,当服务器数据变化时,你会收到ATT_HANDLE_VALUE_NOTI或ATT_HANDLE_VALUE_IND事件。
- 发现目标特征的CCCD描述符的句柄(通常位于特征值句柄+1的位置,可通过
3.2.3 连接参数与MTU交换:提升通信效率
GATT_ExchangeMTU:协商最大传输单元。这是连接建立后,客户端应尽早执行的一个优化操作。- 为什么重要:默认ATT_MTU是23字节,减去3字节头部,有效载荷只有20字节。如果你的特征值数据很大,每次读写都要分包,效率极低。通过MTU交换,可以提升到例如247字节,极大提高吞吐量。
- 限制:每个连接只能调用一次。通常由客户端发起,双方取支持的最小值作为实际MTU。
3.3 服务器端API简析
服务器端的API相对较少,因为服务器主要是被动的响应请求。但有两个主动操作的API至关重要:
GATT_Notification:发送通知。无需客户端确认,可能丢失。用于发送不关键但需频繁更新的数据,如传感器实时读数。GATT_Indication:发送指示。需要客户端确认,是可靠的。用于发送关键数据,如警报触发信号。- 参数
taskId的妙用:这个taskId用于接收对应的ATT_HANDLE_VALUE_CFM(确认)事件。这意味着你可以为不同的指示注册不同的处理任务,实现更精细的事件分发。
- 参数
4. GAPRole中央设备角色配置实战
你提供的代码片段正是来自GAPRole中央设备角色的配置部分。这部分配置决定了设备作为扫描者和连接发起者的行为。
GAPCENTRALROLE_IRK(Identity Resolving Key):身份解析密钥。用于解析私密地址(Private Address)。如果设备使用可解析的私密地址来保护隐私,中央设备需要使用对端设备的IRK来解析其真实身份。默认全0表示协议栈在绑定过程中生成一个随机的IRK。GAPCENTRALROLE_SRK(Signature Resolving Key):签名解析密钥。用于在非加密连接上进行数据签名验证(Signed Write)。这是BLE安全中一个较高级的特性。GAPCENTRALROLE_MAX_SCAN_RES:最大扫描结果数量。这个参数非常实用。如果你只关心一个特定设备,可以将其设为1,这样协议栈在收到第一个匹配的广播包后就会停止上报,可以节省应用处理事件的开销。如果设为0,则表示不限制,适用于需要扫描周围所有设备的场景。
回调函数eventCB:这是应用与GAPRole模块交互的生命线。所有连接状态更新(连接建立、断开、参数更新等)都会通过这个回调函数传递给应用。你提供的示例代码展示了最佳实践:将事件放入应用的消息队列进行异步处理,并返回FALSE告知GAPRole“内存由应用稍后释放”,这避免了在回调函数中执行耗时操作导致协议栈阻塞。
5. 开发避坑指南与高级调试技巧
基于TI BLE协议栈开发时,以下经验能帮你节省大量调试时间。
5.1 内存管理:稳定性的基石
- 谁分配,谁释放?遵循一个原则:对于通过
pReq参数传递给协议栈API的内存,如果API返回SUCCESS,则内存由协议栈在内部处理完毕后释放(对于有响应的命令)或立即释放(对于无响应的命令)。如果API返回非SUCCESS(如bleMemAllocError),则必须由应用程序立即调用ICall_free释放。对于从事件(如ATT_READ_RSP)中解析出来的pValue等数据指针,它们指向协议栈内部的内存,应用程序绝不能尝试释放它们。 - 内存池大小:在
ICall和协议栈的配置中,务必根据你同时发起的并发GATT操作数量,配置足够大的消息和动态内存池。否则,在复杂操作下极易返回MSG_BUFFER_NOT_AVAIL或bleMemAllocError。
5.2 状态与错误码处理
blePending:当你收到这个返回值时,意味着上一个同类型的GATT子过程还未结束。例如,你发起了GATT_ReadCharValue,在收到ATT_READ_RSP或ATT_ERROR_RSP事件前,又对同一个连接发起了另一个读操作。你必须设计好状态机,确保串行化这些操作,或者管理好多个并发的操作上下文。bleTimeout:ATT事务超时(30秒)。发生后,该连接上的所有后续GATT消息都无法发送,直到连接重建。这通常意味着对端设备无响应或链路质量极差。应用中需要监控此错误并触发重连流程。ATT_ERROR_RSP事件:这是服务器返回的正式错误。errCode字段是关键,常见的有:0x01(Invalid Handle):句柄无效。检查你的句柄缓存是否过期或错误。0x02(Read Not Permitted)/0x03(Write Not Permitted):权限不足。检查特征的属性(Properties)声明。0x05(Insufficient Authentication)/0x0F(Insufficient Encryption):需要加密或更高级别的安全认证。你需要先触发配对或加密过程。
5.3 性能优化与调试
- 连接参数协商:GAP层的连接参数(连接间隔、从机延迟、监督超时)直接影响功耗和吞吐量。作为中央设备,你可以在连接后发起连接参数更新请求,以平衡外设的功耗需求和你的数据速率需求。
- 使用Sniffer抓包:这是终极调试利器。当逻辑行为与预期不符时,用蓝牙协议分析仪(如TI的Packet Sniffer,配合CC2540 USB Dongle)抓取空中包。你可以清晰地看到每一句ATT请求和响应,直接定位是命令没发出、响应没收到,还是数据内容不对。
- 日志分级:在应用代码中实现详细的日志系统,记录每个API的调用、参数、返回值和收到的事件。在调试时开启DEBUG级别日志,在生产时关闭或只保留ERROR级别日志。
5.4 一个完整的读取特征值流程示例
假设我们要读取一个心率测量特征。
- 发现服务:调用
GATT_DiscPrimaryServiceByUUID,传入心率服务UUID0x180D。收到响应后,缓存服务句柄范围[startHdl, endHdl]。 - 发现特征:调用
GATT_DiscAllChars,传入上一步的句柄范围。遍历响应,找到特征UUID为0x2A37(心率测量)的特征声明,缓存其特征值句柄charValHdl和属性(应包含GATT_PROP_READ或GATT_PROP_NOTIFY)。 - (可选)订阅通知:如果特征支持通知,发现其CCCD句柄(通常为
charValHdl+1),然后写入0x0001。 - 读取初始值:调用
GATT_ReadCharValue,传入charValHdl。 - 处理响应:在应用任务中等待
GATT_MSG_EVENT,判断method为ATT_READ_RSP,从msg.readRsp.pValue中解析心率数据。 - 处理通知:如果订阅了通知,后续心率数据更新会通过
ATT_HANDLE_VALUE_NOTI事件送达。
这个过程看似步骤繁多,但每个步骤都对应着GATT/ATT协议层的明确操作。通过TI的API,我们得以用相对清晰的代码流程,实现这套标准的通信逻辑。理解每个API背后的协议原语,是写出稳定、高效BLE应用代码的关键。