MTU 优化策略:如何协商和利用更大的 MTU 来提升吞吐量

很多做 BLE 开发的兄弟,一上来就急着调射频参数、改连接间隔,结果吞吐量死活上不去。其实有个最基础、也最容易忽略的环节——MTU。说白了,MTU 就是 ATT 层一次能扛的数据包大小。你想想看,如果 MTU 只有 23 字节,你一次只能发 20 个有效字节,那再快的连接间隔也白搭。

我记得刚入行那会儿,有个项目要做 OTA 升级,每次传固件都要 40 多秒,用户投诉说太慢。我折腾了三天,最后发现 MTU 根本没协商,用的还是默认值。改完之后,直接降到 12 秒。嗯,从那以后,MTU 优化就成了我每个项目的必选项。

MTU 与 ATT 层的关系

先理清一个概念:MTU 到底是谁的 MTU?

在 BLE 协议栈里,L2CAP 层负责把上层数据封装成一个个 PDU(协议数据单元)。ATT 层的数据,最终要交给 L2CAP 去传输。L2CAP 有个最大传输单元,就是 MTU。ATT 层能发多大的数据包,完全取决于 L2CAP 愿意帮你扛多大的包。

默认情况下,ATT MTU 是 23 字节。这 23 字节里,ATT 头部占了 3 个字节(1 字节 Opcode + 2 字节 Handle),所以实际有效载荷只有 20 字节。这就是为什么你写代码时,一次 Write 最多只能写 20 个字节。

核心公式:

ATT 有效载荷 = ATT MTU - 3(ATT 头部)

ATT MTU = L2CAP MTU - 4(L2CAP 头部)

所以:ATT 有效载荷 = L2CAP MTU - 7

举个例子,如果你协商到 247 字节的 MTU,那 ATT 有效载荷就是 247 - 3 = 244 字节。一次能发 244 个字节,跟 20 字节比,吞吐量直接翻了 12 倍。你想想看,这差距有多大?

MTU 协商流程

MTU 不是你想用多大就能用多大的。它需要客户端和服务器端协商。这个协商过程,是通过 Exchange MTU Request / Response 这对命令完成的。

流程其实很简单:

  1. 客户端发起请求:客户端告诉服务器,我这边能支持的最大 RX MTU 是多少。
  2. 服务器回应:服务器告诉客户端,我这边能支持的最大 RX MTU 是多少。
  3. 取最小值:双方协商后的 MTU = min(客户端 RX MTU, 服务器 RX MTU)。

这里有个坑,我踩过。有些芯片的协议栈,默认不支持 MTU 协商。你得手动调用 API 去触发。比如 Nordic 的 nRF5 SDK,你得在连接建立后调用 sd_ble_gattc_exchange_mtu_request()。如果你忘了调,那整个连接周期都只能用 23 字节的 MTU。

注意:MTU 协商必须在连接建立后尽快进行。有些协议栈在连接参数更新之后,会重新初始化 MTU 为默认值。我遇到过这种情况,协商完 MTU 后,又调了一次连接参数更新,结果 MTU 被重置了,数据传着传着就断了。排查了半天才发现是这个问题。

如何选择最优 MTU 值

MTU 不是越大越好。为什么?因为 BLE 的物理层一次能发的数据量是有限的。在 LE 1M PHY 下,一个连接事件最多能发几个包,取决于连接间隔和射频时间。

我个人习惯,会先看芯片的硬件能力。大部分主流芯片(Nordic、TI、Dialog)都支持 247 字节的 MTU。但有些低成本的芯片,比如某些国产的 BLE SoC,可能只支持到 128 字节甚至更小。

另外,还要考虑应用场景:

场景 推荐 MTU 理由
OTA 升级 / 大文件传输 247 字节 最大化吞吐量,减少传输次数
传感器数据上报(小数据) 23 字节(默认) 数据量小,没必要协商,省电
音频 / 语音流 247 字节 需要连续高速传输
双向实时控制 128 字节 平衡吞吐量和延迟

我曾经在一个项目中,把 MTU 设成了 512 字节(有些芯片支持扩展 MTU)。结果发现,虽然单次传输的数据量大了,但连接间隔必须拉长,因为射频时间不够用。最终算下来,吞吐量反而下降了。所以,MTU 的选择要跟连接间隔、PHY 类型一起考虑。

MTU 对吞吐量的影响

我们来算一笔账。假设连接间隔是 7.5ms,LE 1M PHY,一个连接事件里能发 6 个包(这是理论最大值,实际受射频环境影响)。

如果 MTU 是 23 字节:

  • 每个包有效载荷:20 字节
  • 每个连接事件:6 × 20 = 120 字节
  • 每秒连接事件数:1000 / 7.5 ≈ 133 个
  • 理论吞吐量:120 × 133 ≈ 15.96 KB/s

如果 MTU 是 247 字节:

  • 每个包有效载荷:244 字节
  • 每个连接事件:6 × 244 = 1464 字节
  • 每秒连接事件数:1000 / 7.5 ≈ 133 个
  • 理论吞吐量:1464 × 133 ≈ 194.7 KB/s

你看,MTU 从 23 提升到 247,吞吐量提升了 12 倍。这就是为什么我说 MTU 优化是性价比最高的优化手段。

实战技巧:在调试阶段,我习惯在日志里打印协商后的 MTU 值。如果发现 MTU 还是 23,那说明协商没成功。这时候要检查:

  • 客户端是否调用了 MTU 请求 API?
  • 服务器端是否注册了 MTU 回调?
  • 双方芯片是否支持更大的 MTU?

MTU 与 ATT 层操作的配合

MTU 协商好了,不代表你就能直接用。ATT 层的操作(Read、Write、Notify、Indicate)都要遵循 MTU 的限制。

比如,你要用 Write Command 写一个长数据。如果数据长度超过 MTU - 3,你就得拆包。拆包逻辑怎么写?我一般这样处理:

// 伪代码:拆包发送
uint16_t mtu = get_att_mtu();
uint16_t payload_size = mtu - 3;  // ATT 头部占 3 字节
uint16_t offset = 0;

while (offset < data_length) {
    uint16_t chunk_size = min(payload_size, data_length - offset);
    send_att_write_command(handle, data + offset, chunk_size);
    offset += chunk_size;
    // 注意:这里可能需要加延迟,避免缓冲区溢出
    delay_ms(10);
}

这里有个细节:Notify 和 Indicate 也受 MTU 限制。Notify 的 ATT 头部也是 3 字节,所以有效载荷也是 MTU - 3。Indicate 需要确认,所以吞吐量会比 Notify 低一半左右。

我建议,如果应用层不需要确认,优先用 Notify。如果必须保证可靠性,再用 Indicate。但 Indicate 的吞吐量会打折扣,你要提前评估。

MTU 协商的代码实现

以 Nordic nRF5 SDK 为例,MTU 协商的代码大概长这样:

// 客户端发起 MTU 请求
static void on_connected(ble_evt_t *p_ble_evt) {
    uint32_t err_code;
    err_code = sd_ble_gattc_exchange_mtu_request(
        p_ble_evt->evt.gap_evt.conn_handle,
        NRF_SDH_BLE_GATT_MAX_MTU_SIZE
    );
    APP_ERROR_CHECK(err_code);
}

// 服务器端处理 MTU 请求
static void on_gatt_evt(ble_evt_t const *p_ble_evt) {
    if (p_ble_evt->header.evt_id == BLE_GATTS_EVT_EXCHANGE_MTU_REQUEST) {
        uint32_t err_code;
        err_code = sd_ble_gatts_exchange_mtu_reply(
            p_ble_evt->evt.gatts_evt.conn_handle,
            NRF_SDH_BLE_GATT_MAX_MTU_SIZE
        );
        APP_ERROR_CHECK(err_code);
    }
}

注意,NRF_SDH_BLE_GATT_MAX_MTU_SIZE 这个宏在 sdk_config.h 里定义。默认是 23,你要改成 247 或者你需要的值。

我曾经犯过一个低级错误:只改了客户端的 MTU 大小,忘了改服务器端的。结果协商出来的 MTU 还是 23,因为服务器端只支持 23。所以,两端都要配置。

MTU 与连接间隔的协同

MTU 优化不是孤立的。它跟连接间隔、PHY 类型、PDU 大小都有关系。我一般会按这个顺序调优:

  1. 先调 MTU:协商到硬件支持的最大值。
  2. 再调连接间隔:在功耗和吞吐量之间找平衡。
  3. 最后调 PHY:如果硬件支持,切换到 LE 2M PHY,吞吐量再翻倍。

举个例子,我做过一个数据采集项目,要求每秒传输 500 KB 的数据。一开始用默认配置,MTU 23,连接间隔 50ms,LE 1M PHY,吞吐量只有 4 KB/s,完全不够。后来我把 MTU 调到 247,连接间隔降到 7.5ms,PHY 切换到 2M,最终吞吐量达到了 600 KB/s,远超需求。

嗯,这里要注意:连接间隔太小,功耗会飙升。如果设备是电池供电的,你得权衡。我一般建议,连接间隔不要低于 7.5ms,否则射频功耗会占主导。

MTU 优化的常见误区

最后,总结几个我踩过的坑:

  • 误区一:MTU 越大越好。 不一定。如果芯片的缓冲区不够大,大 MTU 会导致内存溢出。我遇到过芯片死机的情况,就是因为 MTU 太大,协议栈的缓冲区爆了。
  • 误区二:协商完 MTU 就万事大吉。 有些协议栈在断连重连后,MTU 会重置。你需要在每次连接建立后重新协商。
  • 误区三:只改一端。 客户端和服务器端都要支持大 MTU,否则协商结果取最小值。
  • 误区四:忽略 ATT 头部开销。 计算有效载荷时,别忘了减去 3 字节的 ATT 头部。

我曾经在一个项目中,把 MTU 设成了 512,结果芯片的 RAM 不够,协议栈分配缓冲区失败,直接报错。后来查了芯片手册,发现最大支持 247 字节。所以,做 MTU 优化前,一定要先看芯片的 datasheet。

MTU 优化决策流程 开始 MTU 协商 客户端发送 Exchange MTU Request 服务器回复 Exchange MTU Response 协商 MTU = min(客户端, 服务器) ATT 有效载荷 = MTU - 3 开始数据传输 注意 必须在连接建立后 尽快发起协商 否则默认 MTU=23

这张图展示了 MTU 协商的完整流程。从客户端发起请求,到服务器响应,再到取最小值,最后算出 ATT 有效载荷。每一步都不能少。

好了,MTU 优化策略就讲到这里。记住,MTU 是 GATT 性能优化的第一道门槛。跨过这道门槛,后面的优化才有意义。