BLE 4.0开发实战:从GATT分层到Android设备互联全解析

发布时间:2026/10/16 21:00:23
BLE 4.0开发实战:从GATT分层到Android设备互联全解析 简介面向Android开发者的BLE4.0通信实战Demo以蓝牙低功耗标准在Android平台的完整交互流程为主线涵盖设备扫描、Gatt连接、服务发现、特征值读写、通知订阅与断开重连等关键环节适合初窥BLE开发、需要示例工程对照Google官方API的学习者。压缩包共56个文件以Java源码、XML配置与布局、class字节码、PNG图片资源为主另含可直接安装的APK与工程编译产物整体约208KB目录包含src、gen、bin、res等标准结构便于按模块定位代码与资源。目前已有305人学习下载。内容基于BluetoothLeGatt示例改造可重点参考扫描过滤器与ScanSettings的配置、BluetoothGattCallback各回调的处理方式以及setCharacteristicNotification与writeDescriptor配合开启通知的完整写法同时附有工程配置文件与lint检查项帮助减少连接异常和功耗控制方面的常见坑点为自研低功耗蓝牙应用提供可复用的代码骨架。1. BLE 4.0 Demo是个什么水平的东西别把跑通点亮当成蓝牙入门这份BLE 4.0 Demo是一套把广播、连接、服务发现、数据回环四条链路全部打通的最小工程。市面上很多模块商的例程只让你看到广播灯在闪真正把手机 App 和外设连起来收发数据时才会发现协议栈里到处是坑。这套 Demo 的价值在于外设端负责广播和 GATT 服务Android 端负责扫描、连接、读写和订阅通知两端代码可以对照着改。它适合第一次做蓝牙产品的嵌入式工程师也适合要在 App 里对接第三方外设的移动端开发。你能通过改代码理解协议而不是在文档里背协议。2. 协议栈与工程结构从GATT分层看懂服务、特征、UUID三段式的代码映射2.1 GATT分层Service、Characteristic、Descriptor在SDK里分别对应什么BLE 4.0 的应用层交互几乎全部建立在 GATTGeneric Attribute Profile之上。它的层级关系是一个外设Server维护一个或多个 Service每个 Service 下面挂若干 Characteristic每个 Characteristic 可以带一个或多个 Descriptor。Characteristic 是真正承载数据的对象它内部有一个 Value读写和通知操作都作用在 Value 上Descriptor 则是描述这个特征行为的元数据最常见的 0x2902CCCD负责开关通知。/* 典型的 GATT 服务结构体定义SDK 内部通常长这样 */ typedef struct { ble_uuid_t service_uuid; /* 服务 UUID */ uint16_t handle; /* 服务句柄SDK 分配 */ ble_gatt_char_t chars[MAX_CHARS]; /* 服务下的特征数组 */ } ble_gatt_svc_t; typedef struct { ble_uuid_t char_uuid; /* 特征 UUID */ uint16_t handle; /* 特征句柄 */ uint16_t value_handle; /* 特征值句柄 */ uint8_t properties; /* 读/写/通知/指示属性位 */ } ble_gatt_char_t;把结构体写成这样是因为后面注册服务时SDK 需要你提供完整的 UUID 和属性掩码。properties 字段值来自协议定义0x02 表示可读0x04 表示可写0x10 表示支持通知0x20 表示支持指示。调试时如果发现手机端读不到值先去查外设端注册特征时这个属性掩码有没有置位。2.2 广播报文31字节怎么分配才能装下设备名、服务UUID和厂商数据BLE 4.0 的广播数据包在 ADV_IND 中最多携带 31 字节。这 31 字节不是自由格式而是由若干个 AD Structure 组成——每个结构第一字节是长度Length含类型和数据的字节数第二字节是类型AD Type之后才是真正的数据。这个容器非常挤设备名、UUID、厂商自定义数据都往里塞排布顺序和长度必须手动算清楚。static void adv_data_init(uint8_t *adv_buf) { uint8_t idx 0; /* AD Structure 1: Flags长度固定3字节 */ adv_buf[idx] 0x02; /* Length Type Data 共2字节 */ adv_buf[idx] 0x01; /* AD Type: Flags */ adv_buf[idx] 0x06; /* LE General Discoverable, BR/EDR Not Supported */ /* AD Structure 2: 完整设备名 BLE_DEMO */ adv_buf[idx] 1 8; /* 1字节Type 8字节名称 */ adv_buf[idx] 0x09; /* AD Type: Complete Local Name */ memcpy(adv_buf[idx], BLE_DEMO, 8); idx 8; /* AD Structure 3: 128位服务UUID列表 */ adv_buf[idx] 1 16; /* Type UUID 16字节 */ adv_buf[idx] 0x07; /* Complete List of 128-bit Service UUIDs */ idx uuid_encode_128(adv_buf[idx], CUSTOM_SERVICE_UUID); /* 剩余字节可用 0x00 填充 */ }这里有一个容易踩的细节Length 字段的单位是Type Data 的总字节数不包含 Length 自己那一字节。比如设备名字段Type 占 1 字节、名称占 8 字节Length 就是 9写成1 8更直观。广播数据的解析是严格按长度边界走的一旦 Length 写错后面的 AD Structure 全部解析错位就会出现手机能看到设备名但拿不到服务 UUID 的情况。2.3 Demo文件结构每个文件在连接链路里到底干哪一段我拿到这套 Demo 的第一件事是先把文件职责理清楚。它没有把代码堆在一个 main.c 里而是按 BLE 协议的层次拆开了文件划分大致如下文件职责对应协议层main.c初始化时钟、按键、日志启动协议栈应用入口ble_stack_init.c协议栈注册、GAP 参数初始化、连接参数默认值LL / L2CAPadv_config.c广播数据填充、广播参数间隔/类型设置GAPcustom_service.c自定义服务的 UUID、特征添加、回调注册GATT Servergatt_evt_handler.c读、写、通知、MTU 更新事件分发GATT Application主机端 BLEManager.java扫描、连接、服务发现、CCCD 订阅GATT Client这个划分几乎就是照着协议栈的分层来切的。改广播相关内容时只动 adv_config.c改业务逻辑时只动 custom_service.c。如果发现 Demo 代码里所有回调都堆在一个文件里通常说明这个 Demo 只是为了跑通流程做产品时要先把事件分发拆开。3. 外设端搭建广播参数、服务注册与回调函数的落地写法3.1 广播初始化间隔、类型、数据的完整参数列表外设端广播参数的设置直接决定手机能不能发现设备、发现得多快、功耗多少。Demo 里初始化广播的逻辑可以拆成两部分先填好广播数据2.2 中的 adv_data_init再配置广播参数并启动。static void adv_start(void) { ble_gap_adv_params_t adv_params; memset(adv_params, 0, sizeof(adv_params)); adv_params.adv_type GAP_ADV_TYPE_ADV_IND; /* 可连接、非定向 */ adv_params.interval_min 160; /* 间隔下限160 * 0.625ms 100ms */ adv_params.interval_max 240; /* 间隔上限240 * 0.625ms 150ms */ adv_params.channel_mask GAP_ADV_CHANNEL_ALL; /* 37/38/39 全开 */ ble_gap_adv_start(adv_params); }注意广播间隔的单位不是毫秒而是 0.625ms 的整数倍。示例里的 160 就是 100ms240 是 150ms。间隔越短手机越快扫到但广播期间射频一直要醒来平均电流会明显上升。我一般把开发调试阶段的间隔设到 100ms 左右方便手机快速抓到设备到了低功耗产品阶段再拉到 500ms 以上。需要特别注意GAP_ADV_TYPE_ADV_IND这个参数它是可连接非定向广播。如果误配成ADV_NONCONN_IND手电能扫到设备但连接必然失败。3.2 服务与特征注册一个可读可写可通知的自定义UUID服务注册是外设端最核心的部分。Demo 里做了一个自定义服务下面挂了一个具备读、写、通知三种属性的特征。调用 SDK 的 GATT API 时需要把服务 UUID、特征 UUID、属性掩码一次传对。static ble_gatts_char_handles_t char_handles; static void custom_service_init(void) { ble_uuid_t svc_uuid; ble_uuid_t char_uuid; uint8_t uuid_type; /* 注册128位自定义服务UUID */ ble_uuid_decode_128(CUSTOM_SERVICE_UUID, svc_uuid); ble_gatts_service_add(BLE_GATTS_SRVC_TYPE_PRIMARY, svc_uuid, svc_handle); /* 注册特征可读 可写 通知 */ ble_uuid_decode_128(CUSTOM_CHAR_UUID, char_uuid); ble_gatts_characteristic_add( svc_handle, BLE_GATT_CHAR_PROP_READ | BLE_GATT_CHAR_PROP_WRITE | BLE_GATT_CHAR_PROP_NOTIFY, char_uuid, char_handles); }注册完成后SDK 会分配一个char_handles其中包含特征句柄和值句柄。通知操作依赖值句柄——当外设端要主动推数据给手机时SDK 会拿这个句柄去查当前有没有手机订阅了通知。常见错误是注册时漏掉BLE_GATT_CHAR_PROP_NOTIFY结果手机端能读到值但永远收不到推送。建议每次注册完特征后打印一下char_handles的值句柄核对是否非零。3.3 事件回调写入数据进来之后程序实际执行的路数据链路最后能不能闭环取决于事件回调是否挂在正确的位置。SDK 里 GATT 事件是个大回调所有读写和通知事件都从同一个入口进入Demo 的做法是解析事件类型后分发给对应的处理函数。static void on_ble_gatts_evt(ble_evt_t *evt) { switch (evt-type) { case BLE_GATTS_EVT_WRITE: handle_write(evt-data.write.value, evt-data.write.len); break; case BLE_GATTS_EVT_READ: /* 把当前状态填充到读取缓冲区 */ fill_read_buf(); break; case BLE_GATTS_EVT_NOTIFY: /* 手机端订阅/取消通知时触发 */ break; } }写入事件里拿到的value和len就是手机端发过来的原始数据。这里需要注意BLE 单个 ATT 包的体积默认只有 20 字节有效载荷MTU 23 字节减去 3 字节 ATT 头所以数据长度超过 20 字节时接收端会连续收到多个 WRITE 事件Demo 的 handle_write 里要自己拼包。我在这个 Demo 中采用的做法是定义一个 128 字节的环形缓冲区收到一包就先暂存协议帧尾标记到了再统一处理。4. Android主机端权限、扫描过滤与GATT读写的完整闭环4.1 权限配置从Location到BLUETOOTH_SCAN的演进Android 端的 BLE 权限是新手翻车重灾区因为不同系统版本要求完全不一样。Android 6 到 11 之间扫描 BLE 设备需要ACCESS_FINE_LOCATION运行时权限理由是其实现阶段延续了蓝牙扫描可被用于推断位置的安全策略。从 Android 12API 31开始系统单独拆出了BLUETOOTH_SCAN和BLUETOOTH_CONNECT扫码和连接权限分离不再依赖定位权限。!-- AndroidManifest.xml 中按版本声明所需权限 -- uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION android:maxSdkVersion30 /neverForLocation这个属性是 Android 12 新增的声明它告诉系统这个应用扫描蓝牙不是为了定位有些商店审核会检查是否声明了它。要注意的是即便声明了BLUETOOTH_SCAN如果你的 App 仍然运行在 Android 11 以下的设备上ACCESS_FINE_LOCATION依然需要运行时申请。逻辑上要按Build.VERSION.SDK_INT分两套权限请求流程走。4.2 扫描与过滤ScanFilter按UUID锁定目标设备直接调用BluetoothLeScanner.startScan会把周围所有 BLE 设备都扫进来回调里再看设备名去一个个挑效率低而且容易误连。正确做法是构造ScanFilter按服务 UUID 直接过滤。val filter ScanFilter.Builder() .setServiceUuid(ParcelUuid.fromString(CUSTOM_SERVICE_UUID)) .build() val settings ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .setReportDelay(0) /* 实时回调不批量上报 */ .build() bluetoothLeScanner.startScan(listOf(filter), settings, scanCallback)SCAN_MODE_LOW_LATENCY的回调频率最高适合开发调试正式产品里通常用SCAN_MODE_BALANCED减少功耗。setReportDelay(0)表示每发现一个设备立刻回调如果设成大于 0 的值系统会攒一批结果再统一返回。扫描回调里也需要做去重逻辑——同一个设备可能被上报多次我一般用设备地址作为 key 存一个 Set只有首次发现才继续走订阅流程。4.3 GATT连接与数据收发从onServicesDiscovered到CCCD订阅连接和读写不在同一个线程所有 GATT 回调都来自系统 binder 线程。很多新手把BluetoothGatt对象存成全局变量到处乱调结果回调里拿到的 gatt 连接早已断开。Demo 里给出的做法是连接回调里只做状态判断发现服务成功后再拿 gatt 对象去取特征、写值、订阅。private val gattCallback object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState BluetoothProfile.STATE_CONNECTED) { gatt.discoverServices() } else if (newState BluetoothProfile.STATE_DISCONNECTED) { gatt.close() } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status ! BluetoothGatt.GATT_SUCCESS) return val svc gatt.getService(UUID.fromString(CUSTOM_SERVICE_UUID)) val ch svc.getCharacteristic(UUID.fromString(CUSTOM_CHAR_UUID)) /* 第一步开启通知让外设知道手机要收数据 */ gatt.setCharacteristicNotification(ch, true) /* 第二步写CCCD描述符值必须是0x0001 */ val cccd ch.getDescriptor(CCCD_UUID) cccd.value BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(cccd) } }CCCD_UUID是固定值00002902-0000-1000-8000-00805f9b34fb。订阅通知时两步走缺一不可先调setCharacteristicNotification告诉本地 GATT 客户端开始拦截通知再写 CCCD 描述符告诉对端我已经准备好接收。如果顺序反了或漏了一步外设端根本不会开始推数据。还有一种情况writeDescriptor的回调返回GATT_WRITE_SUCCESS但通知仍然不来多半是对端外设的代码里压根没实现通知事件发送。5. 避坑记录五个看起来正常却必翻车的BLE细节5.1 扫描不到或连不上广播标志与权限的双重约束现象手机 App 扫不到外设偶尔扫到了点击连接立即失败状态回调返回 133GATT_ERROR或直接超时。原因外设端的广播类型设成了ADV_NONCONN_IND这是不可连接广播扫描能看到但系统层面拒绝建立连接。另一个原因是广播数据里 Flags 字段AD Type 0x01把 BR/EDR 位置成了 1部分手机协议栈会因此拒绝走 LE 连接流程。权限问题则在 Android 12 以上表现为扫描回调没有任何结果不报错只是静默失败。解决把广播类型改成ADV_INDFlags 置成0x06LE General Discoverable BR/EDR Not Supported。Android 端检查12 以上必须已授予BLUETOOTH_SCAN11 以下必须已授予ACCESS_FINE_LOCATION且定位服务开关要打开——部分国产 ROM 在定位关闭时会把蓝牙扫描结果一并滤掉。5.2 通知收不到CCCD没写0x0001等于白订阅现象外设端的写操作正常手机端也能读特征值但外设调用发送通知的 API 后手机端 onCharacteristicChanged 就是不被回调。原因通知机制依赖一个约定——客户端必须在 CCCD 描述符里写入0x0001表示我要开始监听。这个值不会自动写入必须开发者手动通过writeDescriptor设置。漏掉这一步是 BLE 开发中最常见的失误没有之一。解决在onServicesDiscovered里先setCharacteristicNotification(ch, true)再写 CCCD。注意如果外设端特征还支持指示Indicate则需要写0x0002通知写0x0001两者不能混。用 Wireshark 抓包看链路上有没有 Write Descriptor Request一眼就能确认问题出在谁身上。5.3 数据粘包与丢段MTU23字节下的分包真相现象手机一次写入 60 字节外设端只收到 3 个 WRITE 事件每个 20 字节拼起来发现顺序错乱或丢段有时外设发来 100 字节手机端回调被截断。原因BLE 4.0 的 ATT 层默认 MTU 是 23 字节减去 3 字节 ATT 头部单包数据上限 20 字节。更长的数据在链路层会被拆成多个包发送。协议栈不会帮你重组也不会保证多个 WRITE 事件在应用层的先后到达顺序与数据分片一致。解决通信双方要先明确一个自定义的分包协议帧头里加序号和总长度。外设端每收一包就往缓冲区里对应位置写收满一帧再整体交给业务逻辑。Android 端如果做主机可以在服务发现完成后用requestMtu(247)把 MTU 拉大但外设端必须支持更大 MTU否则请求会被拒绝。5.4 功耗异常广播间隔、连接间隔、从机延迟的参数玄学现象外设用的纽扣电池几天就掉到没电或者连接状态下电流一直在几十毫安级别没有空闲窗口。原因广播间隔设置成 50ms 甚至 20ms射频每几十毫秒醒来一次平均电流轻松到 100µA 以上。连接建立后如果连接间隔固定成 7.5ms而业务数据每分钟才发一次射频依然以 7.5ms 的节奏不停监听功耗完全下不来。解决参数选择要看业务和功耗的平衡。低频数据上报场景广播间隔拉到 500ms连接间隔放到 30ms从机延迟设 4 个周期需要实时双向交互的场景才把连接间隔压到 15ms 以下。调试功耗不要靠猜用电流分析仪抓实际曲线对比每一段射频活动间隔是否和配置吻合。5.5 服务缓存陈旧手机连到了修改前的服务现象外设端代码改了 UUID 或服务结构重新烧录后手机再连接拿到的还是旧服务列表读特征提示找不到。原因Android 系统会把每个设备的 GATT 服务列表缓存在本地。外设端的数据库发生变化后原来的缓存没失效手机直接读了缓存没重新发现服务。解决在服务发现失败或特征查找返回空时显式调用gatt.refresh()系统隐藏方法需要通过反射调用清掉缓存再重新discoverServices()。另外开发阶段改完外设代码后顺手在手机蓝牙设置里解除配对并清掉设备缓存能省掉大量代码改了但没生效的排查时间。6. 进阶技巧用连接参数协商把有效吞吐率提上去默认连接参数是按省电逻辑配置的比如连接间隔 30ms、从机延迟 0。做透传或 OTA 升级时这个参数会让吞吐率低得让人抓狂。BLE 4.0 允许在连接建立后协商新的连接参数Android 端可以直接调用requestConnectionPriority三个档位对应不同的间隔区间CONNECTION_PRIORITY_HIGH会把连接间隔压到 7.5ms~15msBALANCED是 30ms~50msLOW_POWER是 100ms~125ms。/* 大块数据传输前先把连接参数切到高速档 */ gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) /* 传输完成后切回均衡档让出射频资源给其他设备 */ gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_BALANCED)但这里有个容易被忽略的细节requestConnectionPriority只是发起请求实际能不能生效取决于对端协议栈是否接受。外设端如果没实现 L2CAP 的连接参数更新响应这个请求会被静默忽略连个报错都没有。而 iOS 端作为主机时根本不理睬外设发起的参数更新请求这把控制权完全收到了系统手里。所以在做跨平台方案时最可靠的做法是外设端把初始连接参数就配置成目标值而不是等手机来协商。调试连接参数有个很实用的小技巧打开 Android 开发者选项里的“蓝牙 HCI 日志”连一次设备后用 Wireshark 打开生成的 btsnoop 文件过滤LL_CONNECTION_UPDATE_IND消息能看到双方实际协商出来的连接间隔、从机延迟和超时时间。这样做的好处是直接看到链路层真相而不是听代码里怎么声明。从那以后我每次拿到一个新模块或者改完外设代码都会强制走一遍“扫描 → 连接 → 遍历 GATT 服务表 → 核对 UUID 和特征属性 → 订阅通知 → 看抓包确认实际连接参数”这个流程把每一步的结果打印到日志里再动手写业务逻辑。蓝牙这东西一旦链路参数不对代码层面完全看不出问题只能靠流程把黑匣子撬开。这个习惯帮我省了大量返工时间希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询