BLE通信核心:GATT与ATT协议在TI协议栈中的实践指南

📅 2026/7/29 10:43:41
BLE通信核心:GATT与ATT协议在TI协议栈中的实践指南
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事件。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事件前又对同一个连接发起了另一个读操作。你必须设计好状态机确保串行化这些操作或者管理好多个并发的操作上下文。bleTimeoutATT事务超时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句柄通常为charValHdl1然后写入0x0001。读取初始值调用GATT_ReadCharValue传入charValHdl。处理响应在应用任务中等待GATT_MSG_EVENT判断method为ATT_READ_RSP从msg.readRsp.pValue中解析心率数据。处理通知如果订阅了通知后续心率数据更新会通过ATT_HANDLE_VALUE_NOTI事件送达。这个过程看似步骤繁多但每个步骤都对应着GATT/ATT协议层的明确操作。通过TI的API我们得以用相对清晰的代码流程实现这套标准的通信逻辑。理解每个API背后的协议原语是写出稳定、高效BLE应用代码的关键。