在物联网硬件开发中,通过手机对蓝牙设备做固件升级是常见的售后与迭代手段。iOS平台没有开放经典蓝牙的免MFI通道,因此基于低功耗蓝牙的OTA几乎都依赖Core Bluetooth框架。整套流程的本质是把待烧录的二进制文件,按照设备固件规定的DFU协议,切分成若干小包,通过写特征值的方式逐包投递,并在结束时让设备自行校验。不同于有线烧录,空中升级面临信道不稳定、MTU受限、设备端RAM紧张等约束,所以协议设计和客户端状态机必须足够健壮。

DFU协议基础与蓝牙服务发现
DFU协议可以理解为设备端与App端约定的一套“对话规则”。典型的BLE DFU服务会暴露一个特定的UUID,例如 Nordic 的 0xFE59,或者厂商自定义的 128 位服务。在该服务下至少有控制点(Write)和数据点(Write Without Response)两个特征。控制点用来发送开始指令、设置MTU、查询状态;数据点用来灌入固件字节流。iOS在连接外设后,必须先调用 discoverServices 与 discoverCharacteristics,把这两个特征缓存到本地,否则后续写操作会抛错。
很多团队在第一次联调时会忽略广播包里的服务UUID过滤。正确做法是在 CBCentralManager 的扫描方法里传入包含DFU服务UUID的数组,这样只会唤醒处于升级模式的设备,避免连到正常工作的从机。设备进入DFU模式通常靠组合按键或特定Notify指令触发,App侧应在收到“已进入DFU”的回调后再发起 discoverServices,否则可能读到旧固件的业务服务而误判。
协议层面,一帧控制命令往往包含操作码、参数长度与负载。比如操作码 0x01 表示开始DFU,后面跟固件大小与CRC32初值。设备回应的ACK包若携带错误码,客户端要能映射到具体异常,如“固件太大”“校验不匹配”。这种命令响应模型比单纯写数据更可靠,也方便做超时与重试。
固件分包传输与Core Bluetooth写策略
BLE 4.0/4.2的默认MTU是23字节,除去ATT头3字节和写指令头,单包有效负载常只有20字节。即便iOS协商到更大的MTU(如185),设备端Flash写缓冲也可能只接受固定小块。因此客户端要把固件读入 NSData,用 subdataWithRange 按 stride 切片。下面示例以20字节为例做简单分包:
// 假设 self.firmwareData 为完整固件
NSUInteger stride = 20;
NSUInteger total = self.firmwareData.length;
for (NSUInteger offset = 0; offset < total; offset += stride) {
NSUInteger len = MIN(stride, total - offset);
NSData *packet = [self.firmwareData subdataWithRange:NSMakeRange(offset, len)];
[self.dfuPeripheral writeValue:packet
forCharacteristic:self.dfuDataChar
type:CBCharacteristicWriteWithoutResponse];
}
上面代码用无响应写提升吞吐,但代价是链路层可能丢包。更稳妥的方案是混合使用:每发 N 包(如10包)切到带响应的写一次,或在控制点轮询设备已接收偏移。Core Bluetooth的 writeValue:forCharacteristic:type: 当类型为 CBCharacteristicWriteWithResponse 时会触发 didWriteValueForCharacteristic 回调,可在此推进发送游标,形成类似TCP的停等机制。
实际项目中还要处理App进入后台导致的蓝牙任务挂起。iOS对后台BLE吞吐有严格限制,建议在前台完成传输,或用 beginBackgroundTaskWithExpirationHandler 争取数十秒缓冲。此外,设备端若校验某包失败,应通过控制点Notify回传错误偏移,客户端从该偏移重传即可,不必从头再来。
校验和验证与升级完整性保障
固件传完并不代表升级成功。设备端一般会在收到“结束指令”后,对写入Flash的内容计算CRC32或SHA256,并与App开场时下发的值比对。iOS侧也应在发送前自己算一遍,避免传了损坏的本地文件。下面展示Objective-C计算CRC32的简化片段:
#import <zlib.h>
- (uint32_t) crc32OfData:(NSData *)data {
uint32_t crc = crc32(0L, Z_NULL, 0);
crc = crc32(crc, data.bytes, (uInt)data.length);
return crc;
}
// 使用:
uint32_t localCrc = [self crc32OfData:self.firmwareData];
// 将 localCrc 按小端放入控制包发送给设备
除了整体校验,分包阶段也可以加每包校验和。比如每20字节末尾补1字节异或值,设备收到先验证该字节,错误则丢弃并申请重传。这种做法增加少量开销,却能迅速隔离信道误码,比等到最后才发现几KB全错要节省时间。
升级收尾时,App应通过控制点发送“激活并重置”指令,设备重启后脱离DFU服务、加载新固件。客户端随后监听新服务的广播,确认版本号特征已变化,才提示用户成功。若设备回连后仍是旧版本,多半是校验失败回滚,此时需记录日志并引导用户重试,切忌盲目循环烧写导致Flash磨损。
常见误区与工程化建议
一个典型误区是认为无响应写越快越好,于是在循环里不加延迟狂发几百包,结果设备端RF缓冲溢出、单向丢包率飙升。正确做法是参考设备规格书的推荐间隔,或利用 canSendWriteWithoutResponse 属性(iOS 11+)判断链路是否可继续发送,它会在底层缓冲有空位时返回 YES。
另一个坑是固件版本兼容。DFU协议若有大版本变更,旧设备不认识新操作码,App应内置多套指令模板,根据设备回传的协议版本号切换。同时把校验算法、分包大小做成可配置参数,通过云端下发,避免每次改包体就发版。日志方面,建议把每包发送时间、ACK延迟、错误偏移写入本地文件,方便现场复现蓝牙环境下的偶发失败。
Core_BluetoothDFUOTA修改时间:2026-08-18 23:10:35