拓冰建站拓冰建站
首页 / 资讯中心 / 正文

BLE开发核心:GATT中的属性、特征与描述符详解

做BLE开发绕不开GATT尤其是当你从广播Advertising和扫描Scanning阶段走到连接Connection之后才能真正直面GATT。简单说GATT就是BLE连接状态下所有数据交互的“规则手册”它规定了设备之间如何用统一的方式读写数据。很多初学者在这个阶段容易卡住因为GATT里涉及一堆概念——属性、特征、描述符名字相近又互相嵌套看协议栈源码时被handle、UUID、permission绕得头晕。这篇我直接把这些概念拆开讲清楚用实际开发中遇到的例子来对照帮你把GATT这套逻辑彻底理顺。不管你是做嵌入式端的蓝牙固件还是写Android/iOS端的BLE应用只要你在做连接后的数据传输这篇文章都适合你。理解属性、特征、描述符之间的关系之后你看协议文档的效率、调问题的速度都会明显不一样。1. GATT在BLE协议栈中的位置与作用1.1 从广播到连接GAP与GATT的分工BLE协议栈从下到上大致是物理层、链路层、L2CAP、ATT/GATT再往上才是应用层。GAPGeneric Access Profile负责设备的发现与连接管理解决的是“怎么让两个设备互相看见并建立连接”的问题而一旦连接建立数据怎么组织、怎么读写、怎么通知就是GATTGeneric Attribute Profile的职责。你可以这样理解GAP像是一楼前台负责登记访客、安排见面GATT像是一套标准化的文件柜系统见面之后所有文件怎么归档、怎么调阅、怎么标注全按它的规则来。GATT是基于ATTAttribute Protocol定义的更高层规范ATT解决的是“属性怎么读怎么写”GATT在ATT之上规定了属性如何组织成服务、特征和描述符赋予这些属性业务含义。开发中常见的困惑是明明连接成功了但客户端发送读写请求却得不到预期数据或者服务发现Service Discovery后拿到的UUID列表跟预期不符这些问题多半就是GATT层组织不合理导致的。先认清GATT在整个协议栈里的身份后面排查问题时才知道该盯哪一层。1.2 为什么GATT要把数据搞这么复杂很多从传统串口或自定义协议转过来的开发者第一次看到GATT的层级结构会吐槽传个数据而已用得着这么多层封装吗答案是用得着。BLE设备生态太庞大了没有统一标准的话不同厂商的设备之间几乎无法互通。GATT引入属性、特征、描述符这套结构核心目的是解决几个问题寻址标准化任何数据都有唯一的句柄和UUID客户端可以用统一方式定位并操作。权限管理每条数据都可以独立设置读、写、加密权限精细控制谁能在什么条件下访问。业务抽象把传感数据、控制指令、状态信息等封装成“特征”并通过描述符补充说明数据的格式和用法。事件驱动支持通知Notification和指示Indication服务端可以在数据变化时主动推送客户端无需持续轮询。这套设计虽然让初学者觉得繁琐但在实际项目里它带来的规范化和可维护性远超学习成本。比如做心率带定义心率测量特征和传感器位置特征后任何主设备只要按照GATT规范来都能解析出数据不用关心心率带是谁家做的。2. 属性AttributeGATT的最小数据单元2.1 属性的四个组成部分详解在GATT体系里一切数据最终都以属性为最小单位存在。一个属性由四个部分组成句柄Handle、类型Type、值Value和权限Permissions。句柄16位整数在同一个设备内唯一标识一个属性范围是0x0001到0xFFFF。客户端后续对该属性的所有读写操作都要带上这个句柄。类型UUID标识这个属性是什么。16位UUID是蓝牙SIG标准定义的比如设备名0x2A00、外观0x2A01128位UUID一般是厂商自定义的服务或数据。类型决定了属性的语义。值实际的数据内容。长度可变具体含义由UUID和特征定义来决定比如设备名属性的值是一串UTF-8字符串电量属性的值是单字节百分比。权限决定这个属性能否被读、能否被写以及是否需要加密或认证。这里需要注意ATT层的权限和GATT层的属性权限是两层的东西实际代码里经常要一起判断。属性的这四个部分缺一不可理解它们后再看特征声明就会顺畅很多因为特征本质上也是一组按特定规则组织的属性。2.2 句柄Handle的分配策略与实战经验句柄不是随便分配的。在属性表中句柄必须从0x0001开始连续编号每个服务占用的句柄区间构成了该服务的边界。客户端通过服务发现拿到“属性分组”本质上就是根据句柄范围圈出哪些属性属于同一个服务。实际开发中一个常见的坑是自定义服务时如果手动指定了不连续的句柄或者服务内部插入了一个不属于任何特征的属性会导致部分手机协议栈解析异常。我见过一个案例开发者为了在服务里放厂商私有信息直接往属性表里塞了一个“裸属性”不属于任何特征也没有特征声明结果iPhone端发现服务后能拿到数据某些Android机型上却直接解析崩溃。所以哪怕你只是加一条厂商信息也别绕开特征、描述符的框架。GATT这套结构虽然“重”但是所有主流协议栈都按这套结构来解析钻空子最终是给自己挖坑。正常情况下的句柄分配方式应该是0x0001 Primary Service Declaration (服务声明) 0x0002 Characteristic Declaration (特征声明) 0x0003 Characteristic Value (特征值) 0x0004 Characteristic Descriptor (描述符如CCCD)整个属性表按顺序排列服务、特征、值、描述符的句柄自然递增客户端用“Find Information”和“Read By Type”请求就能把这张表完整拉下来。2.3 属性权限不只是读和写属性权限分为三类分别是访问权限、加密权限和认证权限。访问权限就是读/写/读写/不可访问加密权限决定是否需要链路已加密认证权限决定是否需要配对或绑定过。实际项目里最常见的权限错误是把加密权限设置得太高导致未配对设备无法读取数据。比如有些开发者把自定义特征设置为“需要加密写”本意是防止未授权写入但后来发现Android客户端正常、iOS客户端却一直写不进去。因为iOS在配对流程和缓存策略上跟Android差异很大一旦设备没有触发配对流程链路加密状态就达不到要求。我的经验是能不用加密权限就不用敏感数据交给上层应用层加密更灵活。BLE链路加密只能保护“空中传输”真到了安全性设计层面设备认证、数据完整性校验这些都得单独考虑。权限设置越复杂连接状态机就越容易出问题调试成本直线上升。3. 特征Characteristic属性到业务数据的跨越3.1 特征声明的结构特征是GATT里真正承载“业务数据”的单元。一个特征由三部分构成特征声明Characteristic Declaration本质上也是一个属性值是固定的结构。特征值Characteristic Value真正存放业务数据的属性。若干描述符Descriptor可选的补充属性。特征声明的值由三段组成特征属性Characteristic Properties、特征值句柄Value Handle、特征UUID。例如一个可读可写的特征其声明值可能是这样的0x0A 0x03 0x00 0x2A | | | | | -- 特征UUID (0x2A00, 设备名) | -- 特征值句柄 (0x0003) -- 特征属性 (0x0A 可读 可写)特征声明本身也是属性所以它也有自己的句柄和权限。客户端做服务发现时通过读特征声明就能获取特征值所在句柄和这个特征支持哪些操作。3.2 Characteristic Properties 到底在说什么特征属性是一个位图字段由多个标志位组合而成。核心的几位是Broadcast允许在广播包里广播该特征值。Read允许客户端读取特征值。Write Without Response允许客户端写入但不需要服务端回确认适合大吞吐量控制场景。Write普通写入客户端写完后服务端会回一个响应。Notify服务端主动通知客户端不需要回确认适合周期性数据。Indicate指示服务端通知后客户端必须回确认适合可靠传输场景。Authenticated Signed Writes带签名的写入。Extended Properties表示还有扩展属性描述符里面会有更多细节。这个位图很容易被忽略但很多疑难问题都出在它身上。比如服务端明明有Notify能力但特征声明里的Notify位没置1客户端调用setCharacteristicNotification后却收不到数据排查半天发现是这里的问题。3.3 特征值的缓存陷阱特征值是客户端读写数据的目标。这个值本身可以变长最大长度由MTU决定默认MTU是23字节扣除ATT头之后单次有效载荷只有20字节。如果特征值长度超过单次传输能力标准做法是用多个特征值分段配合或者用“长期特征值”Long Characteristic Value的Read Blob请求分段读取。开发中的另一个陷阱是很多协议栈会缓存特征值句柄和UUID的对应关系。改过一次服务端的特征结构后客户端连接时还沿用旧的缓存导致读到的句柄正确但UUID对不上或者直接服务发现失败。这在开发调试阶段尤其常见。我习惯在改完GATT服务后把手机端应用的蓝牙缓存强制清掉再测不然老数据会误导判断。4. 描述符Descriptor让特征更完整4.1 CCCD最常打交道的描述符CCCDClient Characteristic Configuration Descriptor的UUID是0x2902是BLE开发中出现频率最高的描述符。它的作用是让客户端配置特征的Notify或Indicate功能是否开启。CCCD的值是两个字节很多开发者第一次看一脸懵。平时我们说的“开启通知”本质就是向这个描述符写入特定的值不通知也不指示0x0000启用通知Notification0x0001启用指示Indication0x0002需要强调两个容易出错的地方。第一CCCD必须依赖Notify或Indicate如果一个特征既不支持通知也不支持指示那这个特征就不该有CCCD描述符写了反而是画蛇添足。第二CCCD是每个客户端独立的服务端在判断是否发送通知时不是看全局开关而是要看当前这个连接对应的CCCD值。iOS开发中有个经典坑部分开发者以为调用了setNotifyValue(true)就万事大吉其实iOS CoreBluetooth框架内部帮你写好了CCCD但Android的setCharacteristicNotification并不是每次都自动写CCCD需要手动调writeDescriptor。很多从iOS转Android的开发者都会在这个地方卡一下。4.2 常见的其他描述符CCCD是使用最多的描述符但其他几个也值得了解Characteristic User Description0x2901人类可读的特征描述比如“Temperature”。实际上很少被客户端主动读取更多是给调试工具看的。Characteristic Presentation Format0x2904定义特征值的数据格式比如是UINT8还是SINT16单位是什么缩放系数是多少。Characteristic Aggregate Format0x2905多个特征值如何聚合解释用得很少。Extended Properties0x2900扩展的属性位当特征声明里的扩展属性位置位时真正的标志位放在这个描述符里。Characteristic Extended Properties0x2906用来描述“可靠的写入”或“可写的辅助数据”一般项目用不到。描述符最核心的价值是“把数据的解释权交给客户端”。比如心率服务的心率测量特征通过Heart Rate Measurement这个UUID客户端就知道怎么解析但具体到“传感器触点是否接触皮肤”这种信息还是要依赖特征值里的标志位而不是描述符。描述符更多是告诉客户端“这个特征长什么样、怎么用”不是塞业务数据的地方。4.3 描述符与特征的关系从属性角度看描述符和特征值是平级的属性都挂在特征“这棵小树上”。一个特征的结构在属性表里是这样组织的特征声明属性 (Characteristic Declaration) | -- 特征值属性 (Characteristic Value) | -- 描述符属性 (Descriptor 1, 可选) | -- 描述符属性 (Descriptor 2, 可选)客户端在服务发现时往往通过“先读特征声明再枚举该特征后面的属性直到遇到下一个特征声明或服务声明”这种方式来确认哪些描述符属于当前特征。也就是说描述符本质上就是“特征的元数据”它没有独立业务意义必须依附于某个特征。实际编码中常见的错误是属性表里写了Description字符串但没在特征声明中把“可写描述符”相关的标志置位导致有些主设备看不到这个描述符。所以每一项描述符的引入都要回到特征声明的可选项里去核对避免“存了但找不到”的尴尬。5. 属性的逻辑关系从属性到特征再到描述符5.1 一张完整的GATT表长什么样把前面所有的概念串起来看一个典型的BLE服务比如带Notify的自定义数据服务在属性表里大致长这样句柄类型UUID值说明0x0001服务声明0x180D服务UUID心率服务0x0002特征声明0x2803属性0x10、值句柄0x0003、UUID 0x2A37心率测量特征声明0x0003特征值0x2A37心率数据客户端读到的数据0x0004描述符0x29020x0000CCCD0x0005特征声明0x2803属性0x02、值句柄0x0006、UUID 0x2A38体感传感器位置特征声明0x0006特征值0x2A381(手腕)传感器位置这个表换个角度来看就是GATT协议的“关系数据库”属性是行数据每个属性有唯一句柄。特征是业务数据的集合由“声明属性 值属性 描述符”组成。描述符是特征的可选元数据。多个特征组合成一个服务服务之间通过UUID区分。5.2 客户端如何遍历这张表实际编程中客户端一般不直接操作原始属性而是通过系统的GATT封装接口去发现。流程大致是连接成功 → 触发服务发现 → 得到服务列表 → 遍历每个服务的特征 → 遍历每个特征的描述符。如果你自己写主机协议栈会发现本质就是一连串的ATT请求Read By Group Type按UUID 0x2800Primary Service读所有服务。Read By Type按UUID 0x2803Characteristic Declaration读特征。Read By Type按UUID 0x2902CCCD找描述符。理解了这张表你就能理解为什么有些设备“服务发现慢”——如果属性表特别长客户端要发很多次ATT请求才能拉完MTU太小还会进一步放大这种问题。优化手段通常是减少不必要特征、合并数据以及提高初始MTU。5.3 为什么这套分层设计是合理的回到最初的疑问为什么属性、特征、描述符要分层我个人的理解是这套分层把“硬件无关的数据存储”和“业务相关的数据含义”分离了。属性层是“通用存储”不管你是谁都能通过句柄UUID来读写任意单元。特征层是“业务抽象”把零散属性聚合成有业务含义的数据点比如一个温度特征底层可能用两个属性存储整数和小数部分但业务端感知到的只是一个温度值。描述符层是“自我描述”让应用知道这个特征支持什么、怎么解释。这种分层最直接的好处是兼容性。SIG定义了标准服务任何符合规范的设备都能互相理解厂商自定义时只需要确保自己的属性表结构合法不用重新设计一套协议。对开发者的启发是设计自家服务时尽量沿着“服务 → 特征 → 描述符”的思路走不要想着摔开框架搞“裸属性”否则看似简化了开发实际上面临的兼容性问题会让你怀疑人生。6. 常见问题排查与踩坑实录6.1 服务发现失败或特征读不出来出现这种问题第一件事是看服务端的属性表结构。常见的结构性错误有句柄不连续、服务声明前的属性没有服务UUID、特征声明指向的值句柄不存在。这些都会导致客户端解析异常。另一个容易被忽略的因素是MTU协商。如果服务端把所有特征都设置为长特征值但实际连接MTU协商不到期望值客户端使用Read Blob分段读取时某些协议栈实现得并不完善可能只读了第一段就放弃了。我遇到过Android端读128字节长的自定义特征一直返回数据截断后来发现是MTU协商没生效把MTU调到512后就一切正常。建议在调试阶段先用手机端的通用BLE调试工具比如nRF Connect或LightBlue检查属性表看是不是符合预期再写业务逻辑。直接跳到业务代码里调问题的范围会变大很多。6.2 写入失败权限、长度、属性标志位三层检查写入失败是BLE开发里最常见的问题排查时我建议按三层来查第一权限层。确认属性权限是否允许写入是否要求加密或认证当前连接是否满足。代码45BLE协议栈返回的“不支持的写入”很多是权限不够导致的。第二长度层。确认写入包长度没有超过当前MTU。很多写入失败发生在客户端写大包时因为ATT层单包不够承载而栈不支持分段写入。第三标志位层。服务工作正常、权限也对但写入还是失败就要回头查特征声明里的属性标志位看是否真的置了Write或Write Without Response位。三者对齐后才不会出现“明明能连上但发不了指令”的情况。6.3 收不到通知先查CCCD再查MTU最后查属性标志位“连接成功、服务发现成功、也调用了设置通知的API但就是收不到消息”。这个问题在Android上特别多。排查顺序建议是用调试工具看CCCD的值是否已经写为0x0001或0x0002。查看特征声明里的Notify/Indicate标志位是否置位。确认服务端在发送通知时是否真的走的是特征值句柄而不是其他句柄。确认MTU是否够大万一通知的数据量超过单包接收端是否实现了分段接收。我之前遇到过一种罕见情况服务端开了Notify也写对了CCCD但通知偶尔收不到。最后定位到是底层驱动在连接事件间隔较大时通知被延后了应用层长时间收不到就主动超时。这类问题已经不属于GATT范畴但在排查时不要死盯着GATT随时向上层状态机和调度逻辑发散。6.4 排查工具与效率心得做GATT调试我一般固定两套工具nRF Connect手机端和Wireshark BLE dongle抓包分析。手机端的工具用来快速验证属性表结构和读写操作抓包工具是用来确认空中报文定位是主机问题、从机问题还是协议栈实现问题。调试时有个习惯很管用每次修改GATT服务后记录一次属性表的golden version即预期正确版本然后在真机上用nRF Connect导出一份实际值两边diff一下。很多隐蔽问题都藏在“我以为服务端是这样实际调出来是那样”的差异里。另外尽量在开发早期就让主端手机和从端设备两家并行调试不要等到联调阶段才暴露GATT定义问题。过一次完整的删除配对、清缓存、重新发现服务、读特征、写特征、开通知的流程能及早发现绝大多数结构性问题。7. 写在最后的几点建议这篇文章里的内容大部分来自我这几年做BLE相关的固件和应用的经验。能完整理解属性、特征、描述符这三层关系再去看蓝牙协议文档、看芯片厂商SDK、看功能包源码都会顺手很多。如果你正在做自己的BLE服务设计我的建议是先从需求出发把业务数据按“特征值粒度和通知方式”拆好再回头补齐描述符和权限设计。不要一上来就堆一大堆特征后面才发现维护成本和兼容性都撑不住。少数几个设计清晰的特征远比十几个乱糟糟的特征更可靠。GATT这套东西初看繁琐用久了会发现它最大的价值就是“让不同设备之间可以对话”。你踩过的坑越多越能体会到规范的价值。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门