1. BLE 协议栈概述
各位好,我是老李。调试蓝牙项目这么多年,有个问题一直让我头疼——GATT 特征读写回调用着用着就内存泄漏了。你排查半天,最终发现根子往往埋在整个协议栈的哪一层里。所以今天第一课,咱们先把 BLE 协议栈的底子铺清楚,尤其是 GATT 层到底在哪儿干活的。
1.1 从物理到应用——分层不是闹着玩的
BLE 协议栈,说白了就是一套把数据从芯片天线搬到 App 里面去的规矩。我习惯把它理解成「四层楼」:
- Controller(控制器)——最底层,管射频、定时、收包发包。
- Host(主机)——中间核心,管连接、拆包、安全、服务发现。
- Application(应用)——你的代码,读写特征值、处理通知。
- HCI(主机控制器接口)——Controller 和 Host 之间的通道,实际可以是 UART、USB 或者 SPI。
你想想看,如果你直接怼物理层写应用,那不得疯?分层把复杂度藏起来了,但每层之间的回调、缓冲区、内存分配也就成了泄漏的温床。
核心观点:GATT 位于 Host 层之上,它不直接管蓝牙怎么连,只管「怎么读怎么写」这台蓝牙设备上的数据。
1.2 每一层都在干什么?
我列个表,方便你对照着看。曾有同事问我:「我写个 notify 回调,怎么也跟 LL 层有关系?」——你看了表就明白了。
| 协议层 | 主要职责 | 泄漏风险点 |
|---|---|---|
| PHY(物理层) | 2.4G 射频调制、解调 | 一般无泄漏(硬件直接处理) |
| LL(链路层) | 连接状态机、重传、跳频 | 少量的 packet buffer 泄漏 |
| HCI | 命令/事件透传、数据分片 | HCI command buffer 未释放 |
| L2CAP | 分段重组、通道管理 | SDU 缓冲区滞留 |
| ATT | 属性操作(Read/Write/Notify) | 属性值实际占用内存 |
| GATT | 服务/特征发现、回调事件分发 | 【经典泄漏点】注册回调后忘记注销、上下文对象未释放 |
1.3 GATT 层到底特殊在哪?
你从表里能看出来,GATT 层是个「协管员」。它自己不存数据,但它负责调度数据的走向。举个例子:
- 当手机发来一个「Write Request」——GATT 收到 ATT 层的
ATT_WRITE_REQ事件,然后根据 handle 找到对应的特征,调用你注册的write_callback。 - 如果你的 callback 里
malloc了缓冲区,却因为流程异常没free——泄漏了。而且 GATT 层在粘合时可能会额外分配内存来拼装参数,这点很容易被忽略。
我个人的排查习惯:遇到回调泄漏,先看 GATT 层的事件处理函数——它有没有把参数值深拷贝一份传给你?那份拷贝是谁负责释放?很多芯片 SDK 的文档都写得很模糊。
1.4 避坑指南:协议栈版本差异
不同厂商的 BLE 协议栈实现略有不同。我踩过的坑是:某家芯片把 GATT 回调里的 connection_handle 做成了指针,你如果不按文档说的 memcpy 出来,它就会在下一帧被覆盖。结果你以为回调参数没变,实际内存早乱了。
- TI CC13xx/CC26xx:GATT 回调在 ICall 上下文中执行,需要特别注意堆栈大小和消息释放。
- Nordic nRF5:softdevice 自动管理 ATT 缓冲区,但 GATT 事件中的
ble_gatts_evt_t是栈上变量,必须在回调退出前复制或用完。 - ESP32 Bluedroid:GATT 事件分派在主循环,注意锁、队列和动态分配。
看清你的芯片,再谈排查。
1.5 为什么一定要理解这层架构?
因为泄漏不会只出现在你写的回调函数里。你想想看:
- 调用
sd_ble_gatts_hvx()通知数据——这个 api 内部会走 GATT → ATT → L2CAP → HCI,每层都可能分配临时内存。 - 如果中途连接断开了,系统是否清理了正在传输中的通知缓冲区?有些协议栈只清理了 ATT 层,GATT 层还在等 ACK,结果泄漏了。
- 你的 write callback 里收到一个大数据包,GATT 层可能做了分片合并,合完后的那块内存是否交给应用了?如果应用没释放,那就算协议栈的 bug。
警告: 千万不要以为「GATT回调泄漏」只是应用层的事。我曾遇到过因为 L2CAP 的 MTU 协商失败导致 ATT 缓冲区一直挂在队列里,最后 GATT 层的 pending request 永远无法释放。查了两天才定位到。
好了,这一章就聊到这儿。协议栈分层好比你的工具箱——每层工具不同,但只有搞清楚哪个螺丝刀对应哪个螺丝,漏油了才知道拧哪里。下一课咱们会深入 GATT 的特征注册机制,看看回调到底是怎么被「栓」上去的。