iOS BLE MTU 详解:蓝牙一次发送多少字节?

3 阅读12分钟

在 iOS 蓝牙开发中,经常会遇到下面这些问题:

  • 为什么有些设备可以发送 182 字节?
  • MTU 是 185,为什么实际数据长度却是 182?
  • iOS 是否可以主动设置 MTU?
  • OTA 固件升级时,应该按照多少字节进行分包?
  • maximumWriteValueLength(for:)返回的数值可靠吗?

这些问题本质上都与 BLE 的MTU、ATT 协议以及数据分包机制有关。

本文将基于 Swift 和 CoreBluetooth,详细介绍 BLE MTU 的基本原理,并给出动态分包、数据发送和 OTA 升级相关的示范代码。


一、什么是 MTU?

MTU 的全称是:

Maximum Transmission Unit,最大传输单元。

它表示在某一层通信协议中,单个数据包能够承载的最大数据长度。

在 BLE 开发中,我们通常讨论的是:

ATT_MTU

ATT_MTU 表示一个 ATT 协议数据包允许使用的最大长度。

BLE 设备建立连接后,客户端和服务端可以交换各自支持的接收 MTU,最终使用双方数值中的较小值。Bluetooth 规范要求 MTU 交换结果取客户端接收 MTU 与服务端接收 MTU的最小值。

例如:

iPhone 支持的 MTU:185
蓝牙外设支持的 MTU:247

最终 ATT_MTU:185

如果外设只支持 23:

iPhone 支持的 MTU:185
蓝牙外设支持的 MTU:23

最终 ATT_MTU:23

因此,MTU 不是由某一端单独决定的,而是受到双方能力限制。


二、ATT、GATT 和 L2CAP 的关系

在分析 MTU 之前,先简单理解 BLE 数据通信中的几个协议层级:

App 应用层
    |
GATT:定义服务、特征值以及读写流程
    |
ATT:负责具体的属性读写、通知和响应
    |
L2CAP:负责数据通道、分段和重组
    |
Link Layer:蓝牙链路层
    |
Physical Layer:物理无线传输

GATT

GATT 是 Generic Attribute Profile 的缩写。

在 CoreBluetooth 中,我们经常使用的这些概念都属于 GATT:

CBService
CBCharacteristic
readValue(for:)
writeValue(_:for:type:)
setNotifyValue(_:for:)

GATT 基于 ATT 提供服务发现、特征值读写、通知和指示等能力。

ATT

ATT 是 Attribute Protocol 的缩写。

它负责真正的属性数据传输,例如:

写请求
写响应
读取请求
读取响应
Notify 通知
Indicate 指示

L2CAP

ATT 数据会通过 L2CAP 通道传输。

L2CAP 还负责协议复用、分段和重组,因此 ATT_MTU 并不等同于最终在无线链路上发送的物理数据包大小。一个较大的 ATT 数据包仍可能在更底层被拆分。


三、为什么 BLE 经常一次只能发送 20 字节?

BLE 默认 ATT_MTU 通常为:

23 字节

但是这 23 字节并不能全部用来存放业务数据。

一次普通的 GATT Write 或 Notify 数据包,需要占用 3 字节作为 ATT 协议头:

1 字节:Opcode,操作码
2 字节:Attribute Handle,属性句柄

因此,真正能够用于传输特征值数据的长度为:

23 - 3 = 20 字节

也就是说:

ATT_MTU:23 字节
协议头:3 字节
有效数据:20 字节

Bluetooth GATT 规范规定,普通特征值写入一次最多携带 ATT_MTU - 3 字节。

这就是很多早期 BLE 项目中“一次只能发送 20 字节”的原因。

需要注意:

20 字节不是 BLE 永久不变的最大值,而只是 ATT_MTU 为 23 时的有效数据长度。


四、为什么有时 MTU 会变成 185?

在支持更大 MTU 的 iPhone 和外设之间,连接后可能协商出大于 23 的 ATT_MTU。

很多项目中经常看到:

ATT_MTU:185

按照普通 Write 和 Notify 的协议开销计算:

185 - 3 = 182 字节

因此:

MTU 是 185
实际单次特征值数据可能是 182 字节

常见的理论对应关系如下:

ATT_MTUATT 协议头特征值有效数据
23320
1853182
2473244

不过,185 并不是 Apple 对所有 iPhone、所有系统版本和所有外设承诺的固定值。

不同情况下,最终可写长度可能受到以下因素影响:

  • iPhone 机型;
  • iOS 系统版本;
  • 外设蓝牙芯片;
  • 外设协议栈;
  • 外设固件配置;
  • 写入类型;
  • 双方协商结果。

因此,开发中不应该直接写死:

let packetSize = 20

也不应该直接写死:

let packetSize = 182

正确做法是通过 CoreBluetooth 动态获取当前连接允许的最大写入长度。Apple 提供的 maximumWriteValueLength(for:) 返回指定写入类型下,单次能够发送的最大数据字节数。


五、iOS 如何进行 MTU 协商?

在 iOS 中,CoreBluetooth 没有向 App 提供类似下面这样的公开接口:

requestMTU(185)

MTU 协商由系统蓝牙协议栈处理。

作为 iOS 开发者,通常不需要也无法手动指定 ATT_MTU。连接建立后,应当通过下面的接口获取当前可以使用的最大写入长度:

let maxLength = peripheral.maximumWriteValueLength(for: .withoutResponse)

或者:

let maxLength = peripheral.maximumWriteValueLength(for: .withResponse)

这两个结果不一定相同,因此应该根据实际使用的写入类型分别获取。


六、Swift 获取最大写入长度

连接外设并发现写入特征值后,可以这样获取:

func printMaximumWriteLength(peripheral: CBPeripheral) {
    let withResponseLength = peripheral.maximumWriteValueLength(
        for: .withResponse
    )

    let withoutResponseLength = peripheral.maximumWriteValueLength(
        for: .withoutResponse
    )

    print("With Response 最大长度:(withResponseLength)")
    print("Without Response 最大长度:(withoutResponseLength)")
}

例如,某次连接可能输出:

With Response 最大长度:182
Without Response 最大长度:182

也可能出现其他数值。

业务代码应该相信 API 返回结果,而不是根据设备型号猜测 MTU。


七、检查特征值支持的写入类型

获取最大长度之前,还要确认目标特征值支持哪一种写入方式:

func printWriteProperties(characteristic: CBCharacteristic) {
    if characteristic.properties.contains(.write) {
        print("支持 Write With Response")
    }

    if characteristic.properties.contains(.writeWithoutResponse) {
        print("支持 Write Without Response")
    }
}

常见区别如下:

写入类型特点是否有写入回调常见场景
.withResponse可靠性较高,速度较慢控制指令、关键配置
.withoutResponse速度较快,需要流量控制没有逐包成功响应OTA、文件、大数据传输

使用 .withResponse 时,外设会对写入结果进行响应;使用 .withoutResponse 时,不会提供写入成功确认。


八、根据最大写入长度动态分包

下面封装一个 Data 分包方法:

import Foundation

extension Data {

    /// 按指定长度拆分 Data
    /// - Parameter size: 每个数据包的最大长度
    /// - Returns: 分包后的 Data 数组
    func splitIntoPackets(size: Int) -> [Data] {
        guard size > 0, !isEmpty else {
            return []
        }

        var packets: [Data] = []
        var offset = 0

        while offset < count {
            let packetLength = min(size, count - offset)
            let range = offset..<(offset + packetLength)
            let packet = subdata(in: range)

            packets.append(packet)
            offset += packetLength
        }

        return packets
    }
}

调用示例:

let maxLength = peripheral.maximumWriteValueLength(
    for: .withoutResponse
)

let packets = firmwareData.splitIntoPackets(size: maxLength)

print("最大写入长度:(maxLength)")
print("分包数量:(packets.count)")

假设固件大小为 1000 字节:

最大长度为 20:需要 50 个包
最大长度为 182:需要 6 个包

MTU 增大后,可以明显减少分包数量和协议交互次数。


九、不要直接循环发送所有数据包

下面这种写法看起来简单,但不推荐:

for packet in packets {
    peripheral.writeValue(
        packet,
        for: characteristic,
        type: .withoutResponse
    )
}

原因是 iOS 和外设的发送缓冲区都有限。

如果短时间内连续写入大量数据,可能出现:

  • 系统发送队列拥塞;
  • 外设缓存溢出;
  • 数据包丢失;
  • OTA 校验失败;
  • 外设处理速度跟不上;
  • 后续写入长时间无法继续。

对于 .withoutResponse,应该结合:

peripheral.canSendWriteWithoutResponse

以及代理方法:

peripheralIsReady(toSendWriteWithoutResponse:)

进行流量控制。Apple 提供这两个接口,用于判断当前是否还能继续发送无响应写入,以及在发送能力恢复后通知代理。


十、完整的 Swift 动态分包发送工具

下面封装一个 BLE 数据发送器,同时支持:

  • 动态获取最大写入长度;
  • 自动分包;
  • Write With Response;
  • Write Without Response;
  • 系统发送流量控制;
  • 发送进度回调;
  • 错误回调。
import Foundation
import CoreBluetooth

enum BLEDataSendError: LocalizedError {
    case peripheralNotConnected
    case unsupportedWriteType
    case invalidMaximumLength
    case writeFailed(Error)

    var errorDescription: String? {
        switch self {
        case .peripheralNotConnected:
            return "蓝牙外设未连接"

        case .unsupportedWriteType:
            return "当前特征值不支持指定的写入类型"

        case .invalidMaximumLength:
            return "获取到的最大写入长度无效"

        case .writeFailed(let error):
            return "蓝牙数据写入失败:(error.localizedDescription)"
        }
    }
}

/// BLE 大数据分包发送工具
///
/// 注意:
/// CBPeripheral 的 delegate 回调需要转发给该对象。
final class BLEPacketSender {

    /// 发送进度,范围为 0~1
    var onProgress: ((Double) -> Void)?

    /// 发送完成回调
    var onCompletion: ((Result<Void, Error>) -> Void)?

    private weak var peripheral: CBPeripheral?
    private var characteristic: CBCharacteristic?
    private var writeType: CBCharacteristicWriteType = .withoutResponse

    private var packets: [Data] = []
    private var totalPacketCount = 0
    private var sentPacketCount = 0

    /// With Response 是否正在等待回调
    private var waitingForResponse = false

    /// 开始发送数据
    func send(
        data: Data,
        peripheral: CBPeripheral,
        characteristic: CBCharacteristic,
        writeType: CBCharacteristicWriteType
    ) {
        reset()

        guard peripheral.state == .connected else {
            onCompletion?(.failure(
                BLEDataSendError.peripheralNotConnected
            ))
            return
        }

        guard supports(
            writeType: writeType,
            characteristic: characteristic
        ) else {
            onCompletion?(.failure(
                BLEDataSendError.unsupportedWriteType
            ))
            return
        }

        let maximumLength = peripheral.maximumWriteValueLength(
            for: writeType
        )

        guard maximumLength > 0 else {
            onCompletion?(.failure(
                BLEDataSendError.invalidMaximumLength
            ))
            return
        }

        self.peripheral = peripheral
        self.characteristic = characteristic
        self.writeType = writeType

        packets = data.splitIntoPackets(size: maximumLength)
        totalPacketCount = packets.count

        print("发送数据总长度:(data.count)")
        print("单包最大长度:(maximumLength)")
        print("数据包数量:(totalPacketCount)")

        guard !packets.isEmpty else {
            onProgress?(1)
            onCompletion?(.success(()))
            return
        }

        sendNextPackets()
    }

    /// 处理 With Response 写入结果
    ///
    /// 在 CBPeripheralDelegate 中调用。
    func handleDidWriteValue(
        for characteristic: CBCharacteristic,
        error: Error?
    ) {
        guard writeType == .withResponse else {
            return
        }

        guard characteristic.uuid == self.characteristic?.uuid else {
            return
        }

        waitingForResponse = false

        if let error {
            onCompletion?(.failure(
                BLEDataSendError.writeFailed(error)
            ))
            reset()
            return
        }

        updateProgress()
        sendNextPackets()
    }

    /// 系统恢复 Without Response 发送能力
    ///
    /// 在 peripheralIsReady(toSendWriteWithoutResponse:) 中调用。
    func handlePeripheralIsReady(_ peripheral: CBPeripheral) {
        guard peripheral.identifier == self.peripheral?.identifier else {
            return
        }

        sendNextPackets()
    }

    /// 取消发送
    func cancel() {
        reset()
    }

    private func sendNextPackets() {
        guard let peripheral,
              let characteristic else {
            return
        }

        if packets.isEmpty {
            finishSending()
            return
        }

        switch writeType {
        case .withResponse:
            sendWithResponse(
                peripheral: peripheral,
                characteristic: characteristic
            )

        case .withoutResponse:
            sendWithoutResponse(
                peripheral: peripheral,
                characteristic: characteristic
            )

        @unknown default:
            onCompletion?(.failure(
                BLEDataSendError.unsupportedWriteType
            ))
            reset()
        }
    }

    private func sendWithResponse(
        peripheral: CBPeripheral,
        characteristic: CBCharacteristic
    ) {
        guard !waitingForResponse else {
            return
        }

        guard !packets.isEmpty else {
            finishSending()
            return
        }

        waitingForResponse = true

        let packet = packets.removeFirst()

        peripheral.writeValue(
            packet,
            for: characteristic,
            type: .withResponse
        )
    }

    private func sendWithoutResponse(
        peripheral: CBPeripheral,
        characteristic: CBCharacteristic
    ) {
        while peripheral.canSendWriteWithoutResponse,
              !packets.isEmpty {

            let packet = packets.removeFirst()

            peripheral.writeValue(
                packet,
                for: characteristic,
                type: .withoutResponse
            )

            updateProgress()
        }

        if packets.isEmpty {
            finishSending()
        }

        /*
         当 canSendWriteWithoutResponse 为 false 时,
         暂停发送。

         系统恢复发送能力后,会回调:

         peripheralIsReady(toSendWriteWithoutResponse:)

         再继续调用 sendNextPackets()。
         */
    }

    private func updateProgress() {
        sentPacketCount += 1

        guard totalPacketCount > 0 else {
            return
        }

        let progress = Double(sentPacketCount)
            / Double(totalPacketCount)

        onProgress?(progress)
    }

    private func finishSending() {
        onProgress?(1)
        onCompletion?(.success(()))
        reset(keepCompletion: true)
    }

    private func supports(
        writeType: CBCharacteristicWriteType,
        characteristic: CBCharacteristic
    ) -> Bool {
        switch writeType {
        case .withResponse:
            return characteristic.properties.contains(.write)

        case .withoutResponse:
            return characteristic.properties.contains(
                .writeWithoutResponse
            )

        @unknown default:
            return false
        }
    }

    private func reset(keepCompletion: Bool = false) {
        peripheral = nil
        characteristic = nil

        packets.removeAll()
        totalPacketCount = 0
        sentPacketCount = 0
        waitingForResponse = false

        if !keepCompletion {
            // 根据项目需要决定是否清空回调
        }
    }
}

十一、在 CBPeripheralDelegate 中转发回调

创建发送工具:

private let packetSender = BLEPacketSender()

监听进度:

packetSender.onProgress = { progress in
    let percent = Int(progress * 100)
    print("发送进度:(percent)%")
}

监听结果:

packetSender.onCompletion = { result in
    switch result {
    case .success:
        print("数据发送完成")

    case .failure(let error):
        print("发送失败:(error.localizedDescription)")
    }
}

开始发送:

packetSender.send(
    data: firmwareData,
    peripheral: peripheral,
    characteristic: writeCharacteristic,
    writeType: .withoutResponse
)

转发 With Response 回调:

func peripheral(
    _ peripheral: CBPeripheral,
    didWriteValueFor characteristic: CBCharacteristic,
    error: Error?
) {
    packetSender.handleDidWriteValue(
        for: characteristic,
        error: error
    )
}

转发 Without Response 流量恢复回调:

func peripheralIsReady(
    toSendWriteWithoutResponse peripheral: CBPeripheral
) {
    packetSender.handlePeripheralIsReady(peripheral)
}

十二、OTA 升级为什么必须考虑 MTU?

OTA 固件通常会有几十 KB、几百 KB,甚至更大。

假设固件大小为:

200 KB

如果每包只能发送 20 字节:

200 × 1024 ÷ 20  10240 

如果每包可以发送 182 字节:

200 × 1024 ÷ 182  1126 

分包数量差异非常明显。

MTU 较大通常可以减少:

  • 数据包数量;
  • App 层循环次数;
  • 协议头重复开销;
  • ACK 次数;
  • OTA 总传输时间。

但需要注意,MTU 并不是决定 OTA 速度的唯一因素。

OTA 速度还可能受到以下因素影响:

  • BLE 连接间隔;
  • 无线环境;
  • 外设蓝牙芯片性能;
  • 外设 Flash 写入速度;
  • Write With Response 或 Without Response;
  • App 层 ACK 机制;
  • CRC 校验频率;
  • 外设缓冲区大小;
  • 后台运行限制。

因此,不应该仅仅通过提高 MTU 就认定 OTA 一定会明显提速。


十三、OTA 自定义协议头也会占用空间

假设系统返回:

maximumWriteValueLength = 182

但你的 OTA 数据包还包含:

包类型:1 字节
包序号:2 字节
数据长度:2 字节
CRC:2 字节

自定义协议头一共占用:

1 + 2 + 2 + 2 = 7 字节

那么每包真正能够放入的固件数据应为:

182 - 7 = 175 字节

示例:

let maximumWriteLength = peripheral.maximumWriteValueLength(
    for: .withoutResponse
)

let protocolHeaderLength = 7

let firmwarePayloadLength = max(
    0,
    maximumWriteLength - protocolHeaderLength
)

print("每包固件有效长度:(firmwarePayloadLength)")

这里需要区分两层概念:

CoreBluetooth 最大写入长度
        ↓
完整的业务协议包长度

业务协议包长度
        ↓
协议头 + 固件 Payload + 校验值

不要把 CoreBluetooth 返回的最大长度全部当成固件 Payload。


十四、iOS 和 Android 的 MTU 处理有什么不同?

Android 提供了主动请求 MTU 的接口:

bluetoothGatt.requestMtu(247)

而 iOS CoreBluetooth 没有对应的公开 requestMtu 方法。

iOS 的常见处理方式是:

let maxLength = peripheral.maximumWriteValueLength(
    for: .withoutResponse
)

即由系统处理协议协商,App 获取当前允许的最大写入长度。

此外,从 Android 14 开始,当第一个 GATT 客户端请求 MTU 时,Android 蓝牙栈会请求 517,并忽略同一 ACL 连接上的后续 MTU 请求;最终值仍取双方支持能力的较小值。

这对外设固件提出了一个重要要求:

外设必须如实返回自己真正支持的 MTU,不能无条件接受客户端请求的任意长度。

否则可能出现:

  • Android 正常、iOS 异常;
  • 旧系统正常、新系统异常;
  • 小数据正常、大数据失败;
  • OTA 发到一半校验错误;
  • 外设缓冲区溢出或重启。

跨平台 BLE 项目中,应由外设协议栈正确处理 MTU 协商,而不是要求 App 永远使用某个固定值。


十五、总结一下

本文介绍了 iOS BLE 开发中 MTU 的核心原理和 Swift 实现方式。

关键结论如下:

  1. BLE 默认 ATT_MTU 通常为 23 字节;
  2. 普通 Write 和 Notify 会占用 3 字节 ATT 协议头;
  3. 因此默认有效数据长度通常为 20 字节;
  4. ATT_MTU 为 185 时,有效数据通常为 182 字节;
  5. 185 不是所有 iOS 设备的固定值;
  6. iOS App 无需主动设置 MTU;
  7. 应使用 maximumWriteValueLength(for:) 获取实际最大写入长度;
  8. Without Response 应结合系统流量控制;
  9. OTA 分包还需要扣除自定义协议头和校验字段;
  10. OTA 应设计 ACK、超时、重传和 CRC 校验机制。

最重要的一点是:

不要写死 20,也不要写死 182。连接成功后动态获取最大写入长度,再按照业务协议进行分包。

这样才能让 BLE 通信代码兼容更多 iPhone、iOS 版本和外设固件。

如有写错的地方,请大家相互指正进步~