3. ATT/GATT 层优化:MTU 大小协商、减少握手次数、批量数据传输策略

好,咱们进入第三章节。前面聊了物理层和链路层,那些是底层硬功夫。但真正跟应用开发者打交道的,是 ATT 和 GATT 这两层。

很多工程师调吞吐量,上来就改连接间隔、调射频功率,折腾半天发现瓶颈其实在 ATT 层。我见过一个项目,明明物理层能跑 1Mbps,实际应用层只有 30KB/s,查到最后发现是 MTU 没协商,每次传 20 字节,还带一堆握手。你说冤不冤?

这一章,咱们就把 ATT/GATT 层的三个核心优化点讲透:MTU 大小协商、减少握手次数、批量数据传输策略。

核心观点:ATT/GATT 层的优化,本质上是把「小包频繁交互」变成「大包批量传输」。每减少一次握手,就省下一个连接间隔的延迟。

ATT/GATT 层优化知识体系 ATT/GATT 层优化 MTU 大小协商 减少握手次数 批量数据传输 MTU 交换请求 ATT_MTU 协商 Write Command Notification Prepare Write 优化效果对比 默认 MTU=23 → 实际有效载荷 20 字节 协商后 MTU=247 → 有效载荷 244 字节,吞吐量提升 12 倍 配合 Write Command + Notification,再减少 50% 握手开销

3.1 MTU 大小协商:从 20 字节到 244 字节

先问个问题:你知道 BLE 默认一次能传多少数据吗?

答案是 20 字节。这是 ATT_MTU 默认值 23 减去 3 字节 ATT 头部后的结果。20 字节能干什么?传个温度值还行,传个固件升级包?那得传到天荒地老。

我个人习惯,任何 BLE 项目的第一步就是做 MTU 协商。别管你的应用需不需要大包,先协商到最大再说。为什么?因为协商本身几乎没有成本,但带来的收益是实打实的。

实战技巧:MTU 协商应该在连接建立后立即发起,不要等应用层需要传数据时才做。我一般在连接回调里直接调用 MTU 交换请求,这样后续所有操作都能用上大 MTU。

3.1.1 协商流程

MTU 协商走的是 MTU Exchange Request 和 MTU Exchange Response。客户端发一个请求,告诉服务端「我能接收的最大 MTU 是 X」,服务端回复「我能接收的最大 MTU 是 Y」,最终双方取较小值。

举个例子:

  • 手机(Client)发:我能收 512 字节
  • 设备(Server)回:我能收 247 字节
  • 最终 ATT_MTU = min(512, 247) = 247

有效载荷就是 247 - 3 = 244 字节。相比默认的 20 字节,提升了 12 倍。

注意:MTU 协商是双向的。你发出去的 MTU 请求值,代表你本地 ATT 层能接收的最大包长。不要为了追求大 MTU 而设置一个硬件不支持的值,否则会导致收包溢出。我曾经见过有人把 MTU 设成 1024,但芯片的 BLE 缓冲区只有 512,结果收包直接崩了。

3.1.2 代码示例:MTU 协商(基于 Zephyr)

/* 连接回调中发起 MTU 协商 */
static void connected(struct bt_conn *conn, uint8_t err)
{
    if (err) {
        printk("连接失败 (err %d)\n", err);
        return;
    }

    printk("连接成功,发起 MTU 协商...\n");

    /* 请求 MTU 为 512,实际值取决于对端支持 */
    int ret = bt_gatt_exchange_mtu(conn, 512);
    if (ret) {
        printk("MTU 协商失败 (ret %d)\n", ret);
    }
}

/* MTU 协商完成回调 */
static void mtu_updated(struct bt_conn *conn, uint16_t mtu)
{
    printk("MTU 协商完成: %d\n", mtu);
    printk("有效载荷: %d 字节\n", mtu - 3);
}

3.2 减少握手次数:用无响应操作替代有响应操作

MTU 协商完了,是不是就万事大吉了?

不是。你想想看,就算 MTU 到了 247,如果你每次写数据都用 Write Request,那每发一个包就要等对端回一个 Write Response。一个连接间隔 7.5ms,一来一回就是 15ms,吞吐量直接砍半。

我建议:能用无响应的操作,就别用有响应的。

3.2.1 操作类型对比

操作类型 ATT Opcode 是否需要响应 适用场景
Write Request 0x12 是(必须等 Response) 关键配置、需要确认的写入
Write Command 0x52 否(发完即走) 批量数据、流式数据
Read Request 0x0A 是(必须等 Response) 读取特征值
Notification 0x1B 否(发完即走) 服务端主动推送数据
Indication 0x1D 是(必须等 Confirmation) 需要确认的事件通知

看到没?Write Command 和 Notification 都不需要响应。这意味着你可以在一个连接间隔内连续发多个包,而不需要停下来等确认。

实战数据:在一个项目中,我用 Write Command 替代 Write Request,配合 MTU=247,吞吐量从 45KB/s 提升到了 180KB/s。原因很简单:每个连接间隔能塞 4-5 个包,而不是 1 个。

3.2.2 避坑指南:无响应操作的可靠性

你可能会问:不用响应,丢包了怎么办?

问得好。我曾经在一个数据采集项目里全用 Write Command,结果发现偶尔有数据丢失。排查了半天,发现是射频干扰导致丢包,但应用层完全不知道。

我的解决方案是:

  • 关键数据(如配置命令、状态切换)用 Write Request,确保可靠
  • 批量数据(如传感器数据流、日志)用 Write Command,追求吞吐量
  • 上层加校验:在应用层加序列号或 CRC,接收方发现丢包后请求重传

小技巧:如果你用 Notification 做数据推送,记得先使能 CCCD(Client Characteristic Configuration Descriptor)。很多新手忘了这一步,结果 Notification 发不出去,还以为是代码写错了。

3.3 批量数据传输策略:Prepare Write + Execute Write

好了,MTU 协商到最大了,也改用无响应操作了。但还有没有更狠的招?

有。Prepare Write 和 Execute Write 这对组合拳,就是为批量传输设计的。

3.3.1 原理

普通的 Write Request 一次只能写一个包。但 Prepare Write 允许你先发多个包(每个包都带偏移地址),最后发一个 Execute Write 一次性提交。这就像 git 的 commit 和 push——你先在本地攒一堆修改,最后一次性推上去。

流程如下:

  1. Client 发 Prepare Write Request(带数据 + 偏移)
  2. Server 回复 Prepare Write Response(确认收到)
  3. 重复步骤 1-2,直到所有数据发完
  4. Client 发 Execute Write Request(0x00 取消 / 0x01 执行)
  5. Server 回复 Execute Write Response

这样做的好处是:多个 Prepare Write 可以连续发送,不需要等每个包的响应。只有最后的 Execute Write 需要等待确认。

3.3.2 性能对比

传输方式 发送 1000 字节所需交互次数 理论吞吐量(7.5ms 连接间隔)
Write Request(MTU=23) 50 次请求 + 50 次响应 = 100 次 ~13 KB/s
Write Request(MTU=247) 5 次请求 + 5 次响应 = 10 次 ~65 KB/s
Write Command(MTU=247) 5 次请求,无响应 ~130 KB/s
Prepare Write(MTU=247,4 包批量) 4 次 Prepare + 1 次 Execute + 1 次响应 = 6 次 ~160 KB/s

看到没?Prepare Write 在需要可靠传输的场景下,比 Write Request 快了将近 2.5 倍。

注意:Prepare Write 不是所有 BLE 芯片都支持。我在 Nordic nRF52 上用得挺顺,但在某国产芯片上发现它根本不响应 Prepare Write Request。所以用之前,先查芯片的 BLE 协议栈文档,确认支持 ATT Prepare Write。

3.3.3 代码示例:Prepare Write 批量传输

/* 使用 Prepare Write 发送 1000 字节数据 */
#define MTU_SIZE    247
#define PAYLOAD_MAX (MTU_SIZE - 3)  /* 244 字节 */
#define DATA_SIZE   1000

static uint8_t data_buf[DATA_SIZE];
static uint16_t offset = 0;

/* 发送 Prepare Write */
void send_prepare_write(struct bt_conn *conn)
{
    while (offset < DATA_SIZE) {
        uint16_t chunk_len = MIN(PAYLOAD_MAX, DATA_SIZE - offset);
        
        struct bt_gatt_write_params params = {
            .func = prepare_write_cb,
            .handle = 0x0012,  /* 特征值句柄 */
            .offset = offset,
            .data = &data_buf[offset],
            .length = chunk_len,
        };

        int ret = bt_gatt_write(conn, ¶ms);
        if (ret) {
            printk("Prepare Write 失败 (offset=%d, ret=%d)\n", offset, ret);
            return;
        }

        offset += chunk_len;
    }

    /* 所有 Prepare Write 发完后,执行 Execute Write */
    send_execute_write(conn);
}

/* Execute Write 回调 */
void send_execute_write(struct bt_conn *conn)
{
    /* 0x01 表示执行写入 */
    uint8_t execute_flag = 0x01;
    
    struct bt_gatt_write_params params = {
        .func = execute_write_cb,
        .handle = 0x0012,
        .offset = 0,
        .data = &execute_flag,
        .length = 1,
    };

    bt_gatt_write(conn, ¶ms);
}

3.4 综合优化策略:把三板斧组合起来

好了,三个优化点都讲完了。但实际项目中,你不能只用一个。我一般这样组合:

  • 第一步:连接后立即协商 MTU 到最大值(247 或 512,看硬件支持)
  • 第二步:批量数据用 Write Command 或 Notification,追求吞吐量
  • 第三步:需要可靠传输的大数据块,用 Prepare Write + Execute Write
  • 第四步:关键控制命令用 Write Request,确保可靠

最终效果:在一个 OTA 固件升级项目中,我用这套组合拳,把传输速度从 25KB/s 提升到了 210KB/s。原来升级一个 256KB 的固件需要 10 秒,现在只要 1.2 秒。用户反馈「感觉像换了个产品」。

嗯,这一章的内容就到这儿。ATT/GATT 层的优化,说白了就是「少握手、发大包、批量提交」。你回去看看自己的代码,是不是还在用 Write Request 传传感器数据?如果是,赶紧改吧。