
1. 项目概述从零到一构建一个健壮的BLE GATT服务在物联网设备开发中蓝牙低功耗BLE因其低功耗和广泛的设备支持成为了短距离无线通信的首选。而通用属性配置文件GATT则是BLE设备之间进行数据交换的“通用语言”。很多开发者尤其是刚接触嵌入式蓝牙开发的同行常常觉得GATT协议栈是个黑盒知道怎么调用API让设备跑起来但一旦遇到数据吞吐量上不去、连接不稳定或者安全策略失效等问题就感到无从下手。我过去在基于TI CC26xx系列芯片开发可穿戴和智能家居设备时也踩过不少坑。比如一个简单的传感器数据通知在实验室测试时一切正常到了真实环境中随着连接设备增多设备会莫名其妙地重启又或者为了实现一个需要高安全级别的特征值读写代码写了一大堆却发现安全机制根本没生效。这些问题根源往往不在于芯片性能而在于对GATT协议栈内部工作机制的理解不够深入特别是对特征值读写流程、安全权限控制以及动态内存管理这三个核心环节的把握不到位。本文将以TI的BLE-Stack SDK为例抛开官方文档中那些泛泛而谈的介绍直接深入到simple_gatt_profile.c和gattservapp_util.c这样的核心源码文件中结合我实际项目中的调试记录拆解GATT应用开发中的关键实践。我会重点分享如何设计一个高效、可靠的特征值读写流程避免阻塞协议栈如何正确配置和使用认证与授权构建真正的安全防线以及如何管理协议栈动态申请的内存防止内存泄漏导致系统崩溃。这些内容都是你在数据手册和快速入门指南里看不到的“实战干货”。2. GATT服务与特征值数据模型的基石与设计哲学在深入代码之前我们必须统一对GATT数据模型的基本认知。GATT采用客户端-服务器Client-Server架构服务器通常是我们开发的嵌入式设备维护一个属性表Attribute Table这个表就是设备所有功能和数据的蓝图。2.1 属性表的层级结构与设计考量这个属性表是一个有序的列表每一项都是一个属性Attribute由四个核心部分组成句柄Handle属性的唯一地址相当于数组索引。客户端通过句柄来寻址特定的属性。类型UUID标识这个属性是什么。例如0x2800代表“主要服务声明”0x2A19代表“电池电量”。权限Permissions定义了客户端如何访问此属性读、写、通知等以及所需的安全级别无、未认证、已认证、已授权。值Value属性所承载的实际数据。服务、特征值和描述符都是一种特定类型的属性它们以层级结构组织服务Service一个或多个特征值的逻辑集合代表一个完整的功能单元如电池服务、设备信息服务。特征值Characteristic服务中的实际数据点是数据交互的核心如电池电量百分比。描述符Descriptor提供关于特征值的额外元信息。其中客户端特征值配置描述符CCCD至关重要它用于使能或禁用该特征值的通知Notify或指示Indication。在TI的协议栈中这个属性表通常在一个.c文件如simple_gatt_profile.c中以一个gattAttribute_t类型的常量数组形式静态定义。设计时的第一个关键决策就在这里如何规划句柄范围虽然协议栈会自动分配句柄但预先规划有助于调试。例如你可以约定句柄1-10用于通用访问配置文件GAP11-20用于通用属性配置文件GATT21-50用于你的第一个自定义服务以此类推。当使用蓝牙嗅探器抓包时你能快速定位到问题出在哪个服务上。2.2 特征值属性权限与属性的精妙配合一个特征值在属性表中实际上由多个属性条目构成通常至少包括特征值声明Characteristic Declaration属性类型为0x2803其值字段包含了该特征值的属性Properties如读、写、通知和该特征值值的句柄。特征值值Characteristic Value属性类型为该特征值自定义的UUID存放实际数据。这里的权限Permissions字段是安全控制的第一道闸门。客户端特征值配置描述符CCCD可选如果特征值属性中包含GATT_PROP_NOTIFY或GATT_PROP_INDICATE则必须包含此描述符供客户端配置订阅。让我们看一个TI示例中的关键细节。在simple_gatt_profile.c中特征值5的定义如下// Characteristic Value 5 { { ATT_BT_UUID_SIZE, simpleProfileChar5UUID }, GATT_PERMIT_AUTHEN_READ, // 权限仅允许已认证的客户端读取 0, simpleProfileChar5 },这里的GATT_PERMIT_AUTHEN_READ就是权限标志。它意味着如果一个客户端尝试读取这个特征值但连接尚未经过认证即未完成带MITM保护的配对协议栈的GATT服务器层会直接拦截该请求并回复错误码ATT_ERR_INSUFFICIENT_AUTHEN (0x41)而根本不会去调用你在profile中注册的读回调函数simpleProfile_ReadAttrCB。实操心得很多开发者在这里会困惑为什么自己写的读回调函数没有被触发首先就应该检查该特征值值的权限标志。权限检查发生在协议栈内部早于应用层回调。GATT_PERMIT_READ开放读取、GATT_PERMIT_AUTHEN_READ需认证、GATT_PERMIT_AUTHOR_READ需授权是三扇不同安全级别的大门务必根据数据敏感性正确选用。3. 特征值读写流程深度解析应用层与协议栈的协同理解了静态的数据模型我们来看动态的数据流。特征值的读写是GATT交互的核心这个过程涉及应用层、Profile层和协议栈GATT层的紧密协作。3.1 写入流程从空中包到应用处理当一个远程客户端如手机App发起一个“写请求”时完整的处理链条如下协议栈接收与解析BLE控制器收到空中数据包传递给主机协议栈。ATT层解析出写命令、目标句柄和要写入的数据。权限检查协议栈检查目标属性特征值值的写入权限如GATT_PERMIT_WRITE。如果权限不足直接回复错误流程终止。调用写回调权限通过后协议栈调用该属性所在Profile注册的写回调函数如simpleProfile_WriteAttrCB。应用层处理在写回调函数中我们通常做两件事数据校验与存储检查数据长度、范围是否合法然后将数据存入应用层变量如simpleProfileChar4。触发后续动作根据业务逻辑可能需要触发一个动作比如控制一个LED或者准备一个回复。这里有一个至关重要的设计原则最小化协议栈上下文中的处理时间。协议栈回调函数simpleProfile_WriteAttrCB是在协议栈的任务Task上下文中执行的。如果在这里执行耗时操作如复杂的计算、阻塞式传感器读取会阻塞协议栈处理其他事件如连接间隔事件、其他ATT请求导致连接不稳定甚至断开。TI的示例代码给出了最佳实践static void SimpleBLEPeripheral_processCharValueChangeEvt(uint8_t paramID) { switch (paramID) { case SIMPLEPROFILE_CHAR3: // 1. 从Profile获取新值 uint8_t newValue; SimpleProfile_GetParameter(SIMPLEPROFILE_CHAR3, newValue); // 2. 在应用上下文中执行耗时操作如更新LCD LCD_WRITE_STRING_VALUE(Char 3:, (uint16_t)newValue, 10, LCD_PAGE4); break; } }注意看它不是在写回调里直接更新LCD而是通过paramID触发了一个应用层的事件SIMPLEPROFILE_CHAR3。这个事件被放入应用层的消息队列随后在应用任务的主循环SimpleBLEPeripheral_processAppMsg中被处理。这样耗时的LCD操作就在应用任务的上下文中安全执行不会阻塞协议栈。3.2 读取流程与“Get/Set”抽象层读取流程与写入类似但通常更简单因为主要是返回当前存储的值。这里重点要提的是TI协议栈引入的“Get/Set”函数抽象层这在实际开发中极大地提高了代码的模块化和可维护性。对于每个特征值Profile会提供一对SimpleProfile_GetParameter和SimpleProfile_SetParameter函数。这对函数的作用是封装数据访问将特征值值的存储位置是全局变量还是结构体成员与访问方式隐藏起来。集中逻辑控制在SetParameter函数中可以集中处理通知/指示的触发逻辑。以设置特征值4为例应用层代码非常简洁uint8_t charValue4 4; SimpleProfile_SetParameter(SIMPLEPROFILE_CHAR4, sizeof(uint8_t), charValue4);而在simple_gatt_profile.c内部的SimpleProfile_SetParameter函数中则完成了所有繁重的工作bStatus_t SimpleProfile_SetParameter(uint8_t param, uint8_t len, void *value) { bStatus_t ret SUCCESS; switch (param) { case SIMPLEPROFILE_CHAR4: if (len sizeof(uint8_t)) { // 1. 存储值 simpleProfileChar4 *((uint8_t*)value); // 2. 关键检查并发送通知 GATTServApp_ProcessCharCfg(simpleProfileChar4Config, simpleProfileChar4, FALSE, simpleProfileAttrTbl, GATT_NUM_ATTRS(simpleProfileAttrTbl), INVALID_TASK_ID, simpleProfile_ReadAttrCB); } break; } return ret; }GATTServApp_ProcessCharCfg这个调用是精髓所在。它会检查该特征值的CCCD是否已被客户端使能即是否订阅了通知。如果已使能它会自动构造一个通知或指示PDU并通过协议栈发送出去。这意味着应用层在更新一个特征值后完全无需关心“是否需要通知客户端”以及“如何构造通知包”这些底层细节只需调用SetParameter剩下的由GATTServApp这个服务应用辅助模块搞定。这大大简化了应用层逻辑。避坑指南如果你发现自己调用了SetParameter但手机端没收到通知请按以下顺序排查客户端是否成功写入了CCCD通常为0x2902描述符的值0x0001使能通知0x0002使能指示这是前提。在SetParameter中确认你调用了GATTServApp_ProcessCharCfg并且传入的charCfg数组此处为simpleProfileChar4Config指针正确。检查GATTServApp_ProcessCharCfg的返回值看内存分配或发送是否失败。4. 高级特性队列写入与内存管理实战当你的应用需要传输的数据量超过单个ATT_MTU默认23字节有效载荷约20字节时或者你想确保一系列写操作的原子性时基础的单次写入就不够用了。这时就需要用到队列写入。4.1 队列写入的原理与配置队列写入规范中称为“准备写入与执行写入”允许客户端发送多个“准备写入请求”这些请求在服务器端被缓存在一个队列中直到客户端发送一个“执行写入请求”服务器才一次性将所有队列中的数据原子性地应用到目标特征值上。如果客户端发送“取消写入”则队列被清空所有准备写入的数据被丢弃。这对于更新一个较长的配置文件如几十字节的设备名称或者确保多个相关特征值同时生效非常有用。在TI协议栈中这个队列的大小默认是5。考虑到默认MTU这最多可以缓存约90字节的数据。你可以通过以下API调整队列深度uint8_t queueSize 10; // 将队列大小设置为10 GATTServApp_SetParameter(GATT_PARAM_NUM_PREPARE_WRITES, sizeof(uint8_t), queueSize);这里有一个关键限制队列内存是从堆HEAPMGR中动态分配的。如果你将队列大小设置得过大可能会耗尽宝贵的堆内存导致其他需要动态内存的操作如发起通知失败。因此你需要根据项目最坏情况下的需求在MAX_NUM_PREPARE_WRITES和可用的堆空间之间做出权衡。通常在app_ble.c或app_cfg.h中定义HEAPMGR_SIZE来调整堆大小。4.2 GATT过程的内存管理防止内存泄漏的黄金法则这是BLE协议栈开发中最容易出错、也最难调试的部分之一。任何需要通过空中发送的ATT PDU比如通知、指示、读/写响应其载荷缓冲区都需要动态分配内存。协议栈的GATTServApp_ProcessCharCfg帮我们隐藏了这部分细节但如果你需要直接调用底层的GATT_Notification()或GATT_Indication()函数就必须手动管理内存。核心规则是谁分配谁释放协议栈成功发送则它释放失败则你必须释放。TI的gattservapp_util.c中的gattServApp_SendNotiInd函数展示了标准流程// 1. 尝试为通知载荷分配内存 noti.pValue (uint8 *)GATT_bm_alloc(connHandle, ATT_HANDLE_VALUE_NOTI, GATT_MAX_MTU, len); if (noti.pValue ! NULL) { // 2. 分配成功填充数据 status (*pfnReadAttrCB)(connHandle, pAttr, noti.pValue, noti.len, 0, len, GATT_LOCAL_READ); if (status SUCCESS) { noti.handle pAttr-handle; // 3. 发送通知 status GATT_Notification(connHandle, noti, authenticated); } // 4. 关键如果发送失败status ! SUCCESS必须手动释放内存 if (status ! SUCCESS) { GATT_bm_free((gattMsg_t *)noti, ATT_HANDLE_VALUE_NOTI); } } else { // 内存分配失败 status bleNoResources; }为什么必须这么做因为GATT_Notification()是一个非阻塞函数。它把通知消息放入协议栈的发送队列后就返回了。如果返回SUCCESS (0x00)意味着协议栈已经接管了这块内存并会在发送完成后或出错时在协议栈上下文中释放它。如果返回其他错误如blePending表示暂时没有HCI缓冲区意味着协议栈没有接管内存仍然由应用层持有你必须调用GATT_bm_free来释放它否则就会发生内存泄漏。血泪教训我曾在一个需要高频发送传感器指示Indication需确认的项目中忽略了检查GATT_Indication()的返回值。在连接信号不佳时blePending错误频发而我未释放内存。运行几个小时后设备堆内存耗尽任务无法分配消息队列而挂起最终看门狗复位。调试时通过监控堆内存水位才定位到问题。因此务必处理GATT_Notification/Indication的所有非零返回值并释放内存。5. 应用层与协议栈的事件交互注册与处理有时应用层需要感知协议栈内部的一些特殊事件以做出更精细的控制。例如当协议栈因为缺乏HCI缓冲区而无法立即发送ATT响应时应用可以决定重试策略。TI协议栈提供了GATT_RegisterForMsgs()函数允许应用任务注册接收额外的GATT消息。在simple_peripheral.c的simple_peripheral_processGATTMsg函数中我们可以看到三个典型用例的处理5.1 处理ATT响应发送失败blePending当协议栈的GATT服务器因为暂时没有可用的HCI缓冲区而无法发出ATT响应时它会向注册的应用发送一个状态为blePending的消息。if (pMsg-hdr.status blePending) { // 没有可用的HCI缓冲区。让我们尝试在下一个连接事件中重传响应。 if (HCI_EXT_ConnEventNoticeCmd(pMsg-connHandle, selfEntity, SBP_CONN_EVT_END_EVT) SUCCESS) { // 首先释放任何未决的响应如果有 SimpleBLEPeripheral_freeAttRsp(FAILURE); // 保存响应消息以便重传 pAttRsp pMsg; // 先不要释放此消息 return (FALSE); } }这里的处理逻辑是注册在下一个连接事件结束时接收回调HCI_EXT_ConnEventNoticeCmd届时再尝试重新发送pAttRsp这个未完成的响应。这是一种流量控制和错误恢复机制。如果超过30秒ATT事务超时时间仍未完成协议栈会向上层报告bleTimeout错误。5.2 处理ATT流控制违规如果连接的远端设备违反了ATT的请求-响应或指示-确认的流控制规则例如在未对上一个指示进行确认的情况下又发起了新的读请求协议栈会通知应用。else if (pMsg-method ATT_FLOW_CTRL_VIOLATED_EVENT) { // ATT流控制被违反。所有后续的ATT请求或指示都将被丢弃。 // 通知应用以便其决定是否断开连接。 DISPLAY_WRITE_STRING_VALUE(FC Violated: %d, pMsg-msg.flowCtrlEvt.opcode, LCD_PAGE5); }这是一个严重错误通常意味着对端设备的协议栈实现有缺陷。应用层可以选择记录日志或者直接断开连接GAPRole_TerminateLink以保护自身。5.3 处理MTU更新事件当客户端与服务器协商出一个新的、更大的MTU后应用会收到ATT_MTU_UPDATED_EVENT事件。else if (pMsg-method ATT_MTU_UPDATED_EVENT) { // MTU大小已更新 DISPLAY_WRITE_STRING_VALUE(MTU Size: %d, pMsg-msg.mtuEvt.MTU, LCD_PAGE5); }对于需要传输大量数据的应用如OTA升级这是一个重要事件。你可以在此时调整后续数据分包的大小或者重新评估队列写入的配置以充分利用更大的MTU提升吞吐量。6. GATT安全机制从认证到授权的纵深防御安全不是可选项而是必选项。GATT在特征值属性层面提供了精细的权限控制构成了数据安全的第一道防线。6.1 认证协议栈自动完成的握手如前所述通过在特征值权限字段设置GATT_PERMIT_AUTHEN_READ或GATT_PERMIT_AUTHEN_WRITE即要求连接必须经过认证。认证过程即带MITM保护的配对完全由协议栈的GAP Bond Manager处理。应用层只需要在初始化时正确配置配对模式如密码输入、数字比较等并在配对过程中通过回调函数提供必要的输入输出如显示密码、确认匹配。一旦配对成功链路加密密钥建立协议栈会自动允许后续的读写请求通过并调用应用层的回调函数。应用层无需在回调函数中再次检查连接是否已认证因为未认证的请求根本到不了这里。6.2 授权应用自定义的额外关卡授权是比认证更高级别的安全概念。它用于实现业务逻辑层面的权限控制。例如一个智能门锁的特征值可能允许所有已认证的家庭成员读取锁的状态认证但只有管理员才能发送开锁指令授权。授权需要应用层参与决策。首先在定义特征值时使用GATT_PERMIT_AUTHOR_READ或GATT_PERMIT_AUTHOR_WRITE权限。然后必须在Profile的回调结构体中注册一个授权回调函数CONST gattServiceCBs_t simpleProfileCBs { simpleProfile_ReadAttrCB, // 读回调 simpleProfile_WriteAttrCB, // 写回调 simpleProfile_authorizationCB // 授权回调必须提供 };当客户端访问一个需要授权的特征值时协议栈会先完成认证检查如果要求认证然后调用应用层的授权回调函数。在这个回调函数里你可以实现任何自定义的授权逻辑比如检查一个存储在设备内的授权令牌列表或者与云端进行一次验证。static bStatus_t simpleProfile_authorizationCB(uint16 connHandle, gattAttribute_t *pAttr, uint8 opcode) { // 这是一个示例实现真实用例需要更复杂的逻辑来判断设备是否被授权 if (clientIsAuthorized(connHandle)) { // 假设的授权检查函数 return SUCCESS; // 授权成功 } else { return ATT_ERR_INSUFFICIENT_AUTHOR; // 授权失败返回错误码0x08 } }关键警告授权回调函数在协议栈上下文中执行必须快速返回绝不能在这里执行网络请求、复杂的加解密等耗时操作。如果需要应该像处理特征值写入一样设置一个标志将耗时的授权检查移到应用任务中去处理。重要提示如果你为特征值设置了GATT_PERMIT_AUTHOR_*权限但没有提供授权回调函数协议栈将返回ATT_ERR_UNLIKELY (0x0E)错误。这个错误码非常晦涩难以调试。TI官方也强烈建议只要使用了授权权限就一定要实现授权回调哪怕最初只是直接返回SUCCESS。7. 安全连接与绑定管理实战蓝牙4.2引入了LE安全连接采用了更强大的椭圆曲线加密算法。GAP Bond Manager模块封装了配对、加密、绑定的复杂流程极大减轻了应用负担。7.1 配对模式的选择与配置选择哪种配对模式取决于设备的安全需求和I/O能力。TI的GAPBondMgr通过几个关键参数来决定GAPBOND_PAIRING_MODE是否发起配对。GAPBOND_MITM_PROTECTION是否要求中间人保护。GAPBOND_IO_CAPABILITIES设备的输入输出能力无、仅显示、仅输入、显示是/否等。GAPBOND_SECURE_CONNECTION是否只使用安全连接。例如配置一个要求MITM保护、支持安全连接、并具备“显示是/否”IO能力的设备进行数字比较配对uint8_t pairMode GAPBOND_PAIRING_MODE_INITIATE; uint8_t mitm TRUE; uint8_t ioCap GAPBOND_IO_CAP_DISPLAY_YES_NO; uint8_t scMode GAPBOND_SECURE_CONNECTION_ONLY; GAPBondMgr_SetParameter(GAPBOND_PAIRING_MODE, sizeof(uint8_t), pairMode); GAPBondMgr_SetParameter(GAPBOND_MITM_PROTECTION, sizeof(uint8_t), mitm); GAPBondMgr_SetParameter(GAPBOND_IO_CAPABILITIES, sizeof(uint8_t), ioCap); GAPBondMgr_SetParameter(GAPBOND_SECURE_CONNECTION, sizeof(uint8_t), scMode);配置完成后当连接建立GAPBondMgr会自动发起配对流程。应用层需要做的就是实现并注册回调函数如passcodeCB在需要时显示密码或确认数字匹配。7.2 绑定与SNV存储实现无感重连配对解决了本次连接的加密问题而绑定则将配对产生的长期密钥LTK保存到非易失性存储器在TI协议栈中通常是SNV区域。这样设备下次重连时可以直接使用存储的密钥加密链路无需用户再次配对实现无缝体验。启用绑定非常简单uint8_t bonding TRUE; GAPBondMgr_SetParameter(GAPBOND_BONDING_ENABLED, sizeof(uint8_t), bonding);绑定信息存储在SNV中一个完整的绑定记录包括对端地址、LTK、IRK、CSRK、CCCD状态等。默认最多存储10个绑定记录。当记录存满时行为由GAPBOND_LRU_BOND_REPLACEMENT参数决定如果为TRUE则自动替换最久未使用的记录如果为FALSE则无法添加新记录除非手动删除。开发注意事项SNV空间规划一个绑定记录可能占用上百字节。你需要根据GAP_BONDINGS_MAX和记录大小评估项目所需的SNV空间通过OSAL_SNV定义确保不会与其他使用SNV的功能如自定义持久化数据冲突。绑定状态处理在配对状态回调pairStateCB中首次配对并绑定成功你会收到GAPBOND_PAIRING_STATE_COMPLETE。后续与已绑定设备重连并加密成功时你会收到GAPBOND_PAIRING_STATE_BONDED。应用层可以根据不同状态更新UI例如对于已绑定设备可以跳过“等待配对”的界面。8. 常见问题排查与性能优化实录基于上述原理和实践我将项目中遇到的一些典型问题及解决方案整理成下表供大家快速排查问题现象可能原因排查步骤与解决方案手机能发现服务但读/写特征值失败1. 特征值权限不足如需要认证但未配对。2. 特征值属性Properties未包含读/写标志。3. 应用层读/写回调函数返回错误。1. 使用蓝牙调试工具如LightBlue查看错误码0x01无效句柄、0x02读不被允许、0x05认证不足等。2. 检查特征值声明中的charProps是否包含GATT_PROP_READ或GATT_PROP_WRITE。3. 在应用层回调函数中打印日志确认函数被调用及返回值。通知Notify无法触发1. 客户端未成功写入CCCD值非0x0001。2.GATTServApp_ProcessCharCfg未被调用或传入参数错误。3. 内存分配失败无法发送通知PDU。1. 确认客户端写入CCCD的操作成功可抓包确认。2. 在SimpleProfile_SetParameter函数中设置断点确认GATTServApp_ProcessCharCfg被调用且charCfg指针指向正确的CCCD状态数组。3. 检查堆内存大小HEAPMGR_SIZE如果频繁发送通知考虑增大堆或优化发送频率。设备运行一段时间后死机或重启1.内存泄漏特别是GATT_Notification/Indication失败后未释放内存。2. 应用层消息队列满导致任务阻塞。3. 协议栈任务因某种原因挂起。1.重点检查所有直接调用GATT_Notification/Indication的地方确保对非SUCCESS返回值调用GATT_bm_free。2. 监控应用层消息队列使用率增大队列深度或优化消息处理速度。3. 启用协议栈内部分析功能如TI的UART_printf调试查看协议栈任务是否在正常运行。配对过程失败1. 两端设备的配对参数IO能力、MITM要求、SC支持不兼容。2. 密码输入错误或用户未确认。3. 链路层数据包丢失严重。1. 对照蓝牙核心规范Vol 3, Part H的配对矩阵检查双方GAPBondMgr的配置。2. 在passcodeCB回调中打印日志确认密码生成和传递流程正确。3. 检查射频环境确保RSSI良好尝试缩短连接间隔。已绑定设备重连后仍需配对1. 绑定信息未成功保存到SNV。2. SNV数据损坏或版本不兼容。3. 对端设备删除了绑定信息。1. 确认GAPBOND_BONDING_ENABLED已设置为TRUE且配对过程成功完成收到COMPLETE状态。2. 检查SNV分区是否被其他操作意外擦除。可考虑在SNV操作后增加校验。3. 对端设备如手机的蓝牙系统可能清除了配对记录。性能优化小技巧MTU协商在连接建立后尽早发起MTU交换请求GATT_ExchangeMTU使用更大的MTU如247字节可以显著提升大数据量传输的效率减少协议开销。连接参数优化根据应用场景调整连接间隔、从机延迟和监控超时。对于需要快速响应的设备如遥控器使用较短的连接间隔如15-30ms对于低频数据采集设备可以使用较长的间隔如1-2s以节省功耗。批量数据发送对于需要发送大量数据如传感器历史日志不要使用多个独立的通知。可以设计一个特征值用于“启动传输”另一个特征值用于“读取数据块”。客户端通过写请求启动然后通过一系列带偏移量的读请求来分块获取数据这样更可靠且易于控制流量。