特征读写回调原理: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或类似的数据结构来传递数据。它们不复制数据,只传递指针——高性能但危险。你必须在回调返回前处理完数据,或者自己拷贝一份。

为什么会这样?因为协议栈的接收缓冲区是有限的。如果你不处理,它会阻塞后续数据的接收。我习惯在写回调里只做三件事:

  1. 拷贝数据到应用层缓冲区(如果有必要)
  2. 发送一个事件标志或消息到应用任务
  3. 立即返回

这样就不会长时间占用协议栈上下文。至于拷贝后的内存管理,那是应用层的事。

图示:读写回调的完整触发与传递路径

下面这张SVG图把整个过程串了起来,你对照着看更好理解:

GATT特征读写回调触发与传递流程 GATT特征读写回调触发与传递流程 对端设备 ATT请求/命令 空中数据包 蓝牙协议栈 ATT层解析 + 句柄匹配 GATT层查找回调表 注册的回调函数 on_read_cb / on_write_cb 拷贝数据 / 设置事件 调用回调 ⚠️ 内存泄漏常见点 • 回调内malloc未free • 持有协议栈内部mbuf指针 💡 最佳实践 • 回调内只做最小拷贝 • 使用静态缓冲区或内存池

避坑指南:这些细节决定你是否泄漏

  • 回调内的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层收到请求后,查表找到你的函数指针并调用。数据以指针形式传递,你必须在回调返回前用完或拷贝走。泄漏的根源往往是:

  1. 忘了释放动态分配的内存
  2. 错误地保留了协议栈内部缓冲区的引用
  3. 回调执行时间太长导致协议栈内部缓冲区耗尽