特征读写回调原理:BLE通信的"敲门"机制
读写回调,说白了就是当对端设备要读你的特征值、或者写数据进来时,协议栈怎么通知你的应用代码。这个机制你要是没搞透,后面排查内存泄漏的时候,连数据在哪丢的都不知道。
我记得刚接手一个蓝牙项目时,产品总是运行几天就挂掉。翻代码发现读写回调里到处是malloc,但只有一半的路径有free。嗯,那节课我上的真是刻骨铭心。
触发机制:谁在敲门?
先搞清楚一件事:读写回调不是你自己调用的,是蓝牙协议栈在收到ATT(属性协议)请求后主动调用的。触发条件很简单:
- 读请求(Read Request / Read Blob Request)——对端发起读操作,协议栈找到对应特征句柄,触发你的读回调函数
- 写请求(Write Request / Write Command)——对端要写数据,触发写回调
- 通知/指示(Notification / Indication)——虽然不严格算读写回调,但CCCD(客户端特征配置描述符)变化也会触发回调
关键点:回调函数运行在协议栈的任务上下文里,不是你的主循环。这意味着你不能在里面做阻塞操作,比如等待信号量、大块内存拷贝。我见过有人直接在回调里调用printf打印几十个字节——结果蓝牙断连了。
传递流程:数据从空中到应用层的管道
你想想看,一个无线数据包从对端手机发过来,到你应用代码里,中间经历了多少层?我把这个流程整理成了表格:
| 层级 | 操作 | 数据载体 |
|---|---|---|
| 物理层 | 接收RF符号,解调 | 原始比特 |
| 链路层 | 组包、重传、加密 | LL Data PDU |
| L2CAP | 分段重组、通道分发 | L2CAP帧 |
| ATT | 解析请求操作码,找到句柄 | ATT PDU |
| GATT | 调用注册的回调函数 | 回调参数结构体 |
从表格能看出,GATT回调是最后一公里。协议栈在GATT层根据你注册的句柄和回调指针,直接分发数据。但注意:协议栈不会帮你拷贝数据,它传递的往往是一个指向内部缓冲区的指针。这就是泄漏的温床。
回调函数长什么样?
不同芯片厂商的SDK接口略有差异,但核心逻辑类似。我拿一个常见的框架举例:
// 特征值读回调
static int on_read_cb(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt)
{
// ctxt->om 指向一个 os_mbuf 链,里面是ATTR数据
// 注意:这个 mbuf 的所有权是协议栈的,你不能持有指针
uint8_t value[] = {0x01, 0x02, 0x03};
return os_mbuf_append(ctxt->om, value, sizeof(value));
}
// 特征值写回调
static int on_write_cb(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt)
{
// 从 ctxt->om 里读取数据
uint16_t len = OS_MBUF_PKTLEN(ctxt->om);
uint8_t *data = malloc(len); // 这里malloc了内存!
if (data == NULL) return BLE_ATT_ERR_INSUFFICIENT_RES;
os_mbuf_copydata(ctxt->om, 0, len, data);
// ... 处理数据,但别忘了 free(data)
return 0;
}
我曾经踩过的坑:上面代码里的malloc如果忘了free,每次写操作就泄漏len字节。你不知道对端会疯狂写多少次,尤其是Android手机连接后经常批量写入配置,泄漏速度非常快。我的建议是:要么用静态缓冲区,要么在回调外层统一管理内存池。
传递流程的核心:os_mbuf 与所有权
大多数开源BLE协议栈(如NimBLE、Zephyr、BlueZ的内部)使用mbuf或类似的数据结构来传递数据。它们不复制数据,只传递指针——高性能但危险。你必须在回调返回前处理完数据,或者自己拷贝一份。
为什么会这样?因为协议栈的接收缓冲区是有限的。如果你不处理,它会阻塞后续数据的接收。我习惯在写回调里只做三件事:
- 拷贝数据到应用层缓冲区(如果有必要)
- 发送一个事件标志或消息到应用任务
- 立即返回
这样就不会长时间占用协议栈上下文。至于拷贝后的内存管理,那是应用层的事。
图示:读写回调的完整触发与传递路径
下面这张SVG图把整个过程串了起来,你对照着看更好理解:
避坑指南:这些细节决定你是否泄漏
- 回调内的malloc/free必须成对出现——我习惯在回调入口设一个
bool allocated = false标记,goto cleanup确保释放 - 不要直接返回协议栈mbuf的指针——协议栈可能在返回后立即释放mbuf,你拿着野指针读写就会崩溃
- 写回调完成后要检查返回值——如果返回错误码(如
BLE_ATT_ERR_INSUFFICIENT_RES),但对端不重试,数据就丢了 - CCCD回调也要注意——有些SDK把通知使能/禁用也当作写回调,你如果在那里面分配内存却忘了释放,一样泄漏
我个人习惯的做法:在特征结构体里定义一个固定大小的缓冲区(比如64字节),写回调直接往里面拷贝,然后通过队列发送给应用任务。这样既不用malloc,也不用考虑线程安全问题。对端写入的数据长度如果超过缓冲区,就返回BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN。简单粗暴,但有效。
总结一下关键点
读写回调的本质是协议栈在ATT层收到请求后,查表找到你的函数指针并调用。数据以指针形式传递,你必须在回调返回前用完或拷贝走。泄漏的根源往往是:
- 忘了释放动态分配的内存
- 错误地保留了协议栈内部缓冲区的引用
- 回调执行时间太长导致协议栈内部缓冲区耗尽