在 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_MTU | ATT 协议头 | 特征值有效数据 |
|---|---|---|
| 23 | 3 | 20 |
| 185 | 3 | 182 |
| 247 | 3 | 244 |
不过,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 实现方式。
关键结论如下:
- BLE 默认 ATT_MTU 通常为 23 字节;
- 普通 Write 和 Notify 会占用 3 字节 ATT 协议头;
- 因此默认有效数据长度通常为 20 字节;
- ATT_MTU 为 185 时,有效数据通常为 182 字节;
- 185 不是所有 iOS 设备的固定值;
- iOS App 无需主动设置 MTU;
- 应使用
maximumWriteValueLength(for:)获取实际最大写入长度; - Without Response 应结合系统流量控制;
- OTA 分包还需要扣除自定义协议头和校验字段;
- OTA 应设计 ACK、超时、重传和 CRC 校验机制。
最重要的一点是:
不要写死 20,也不要写死 182。连接成功后动态获取最大写入长度,再按照业务协议进行分包。
这样才能让 BLE 通信代码兼容更多 iPhone、iOS 版本和外设固件。
如有写错的地方,请大家相互指正进步~