OTA固件升级实战:DFU流程设计、分包策略、断点续传、校验与回滚

OTA升级,说白了就是给设备“空中换脑”。做BLE开发这么多年,我踩过最深的坑就是固件升级。设备升级到一半断连了,变砖了,用户骂娘了——嗯,这些我都经历过。今天咱们就把DFU(Device Firmware Update)这件事彻底聊透。

一、DFU流程设计:别让设备死在半路上

一个靠谱的DFU流程,至少要考虑四个阶段:准备、传输、校验、生效。我个人习惯把流程画成状态机,每个状态都有明确的进入条件和退出条件。

核心流程:

  1. 握手阶段:手机发送升级请求,设备回复当前固件版本、最大包长、可用存储空间
  2. 传输阶段:手机分包发送固件数据,设备每收到一包回复ACK
  3. 校验阶段:全部发完后,设备计算CRC32/MD5,与手机端对比
  4. 生效阶段:校验通过,设备标记新固件有效,重启进入新固件

这里有个关键点:设备端必须预留两个固件区。一个跑当前固件,一个存新固件。我曾经见过某厂商只用一个区,升级失败直接变砖,那叫一个惨。

DFU状态机流程图 IDLE(空闲) HANDSHAKE(握手) TRANSFER(传输) VERIFY(校验) 校验失败 → 回滚 ACTIVATE(生效)

二、分包策略:MTU不是你想的那样

BLE一次能传多少数据?很多人张口就来“20字节”。其实这是老黄历了。Android 4.3以后支持MTU协商,默认23字节,但可以协商到512字节。不过,实际能用的数据长度 = MTU - 3(ATT头)

我建议的分包策略是这样的:

MTU大小 有效载荷 分包大小建议 说明
23 20 16 留4字节给序列号和命令字
100 97 92 留5字节做协议头
512 509 500 留9字节做完整协议头

我的经验:不要用满MTU。留几个字节做协议头,包含包序号、总包数、数据长度。这样设备端可以校验包的连续性,发现丢包可以请求重传。

分包时还要注意一个事:每个包必须独立可校验。我习惯在每个包里放一个CRC16,设备收到后先校验单包完整性,再拼装。这样即使中间有包损坏,也能精确定位到哪个包出了问题。

三、断点续传:用户不是每次都有耐心等

一个100KB的固件,用20字节的MTU传,要发5000多个包。万一传到第4000个包时断连了,用户得重头再来?那用户体验就太差了。

断点续传的核心就一句话:设备端记录已收到的最大连续包序号。手机重连后,先发一个查询命令,设备回复“我已收到第N包”,手机从N+1包继续发。

// 设备端伪代码
class DfuManager {
    private var receivedPackets = mutableSetOf<Int>()
    private var maxContinuousSeq = 0
    
    fun onPacketReceived(seq: Int, data: ByteArray): Boolean {
        // 校验单包CRC
        if (!verifyPacketCrc(data)) return false
        
        receivedPackets.add(seq)
        
        // 更新最大连续序号
        while (receivedPackets.contains(maxContinuousSeq + 1)) {
            maxContinuousSeq++
        }
        
        // 写入Flash
        writeToFlash(seq, data)
        
        return true
    }
    
    fun getProgress(): Int {
        return maxContinuousSeq  // 返回已确认的进度
    }
}

注意:设备端要处理乱序包。BLE底层虽然保证单连接内的顺序,但重连后可能从不同位置开始。我建议设备端维护一个位图(Bitmap),标记哪些包已收到,这样即使乱序也能正确恢复。

断点续传还有一个隐藏坑:Flash磨损。如果每次收到包都写Flash,一个固件升级可能写几千次。Flash的擦写寿命通常只有1万到10万次。我建议的做法是:先收到内存缓冲区,攒够一页(通常是256字节或4096字节)再批量写入。

四、校验与回滚:最后的防线

固件传完了,不代表就安全了。我见过最离谱的事故:固件传输过程中,手机端内存被回收,发出去的包和实际计算CRC的包不一致——结果设备刷了个坏固件,直接变砖。

校验分三层:

  1. 单包校验:每个包带CRC16,设备收到即校验
  2. 整体校验:全部传完后,设备计算整个固件的CRC32或MD5,与手机端对比
  3. 签名校验:如果安全要求高,可以用RSA/ECDSA签名,防止固件被篡改

回滚机制是最后一道保险。我的设计思路是这样的:

// 固件分区布局
// | Bootloader | Firmware_A | Firmware_B | Config |
//
// Bootloader 负责判断启动哪个固件
// Config 区记录:当前活动固件、新固件状态、升级尝试次数

enum class FirmwareSlot {
    A, B
}

enum class FirmwareStatus {
    VALID,      // 固件正常
    PENDING,    // 新固件待校验
    FAILED      // 固件损坏
}

class Bootloader {
    fun selectFirmware(): FirmwareSlot {
        val statusA = readConfig().statusA
        val statusB = readConfig().statusB
        
        return when {
            statusA == VALID && statusB == VALID -> FirmwareSlot.A  // 默认启动A
            statusA == VALID -> FirmwareSlot.A
            statusB == VALID -> FirmwareSlot.B
            else -> FirmwareSlot.A  // 都坏了?死马当活马医
        }
    }
    
    fun onBootSuccess() {
        // 标记当前固件为VALID
        writeConfig(currentSlot, VALID)
    }
    
    fun onBootFailed() {
        // 标记当前固件为FAILED,下次启动另一个
        writeConfig(currentSlot, FAILED)
        // 增加尝试次数,超过3次则停止尝试
        incrementRetryCount()
    }
}

回滚触发条件:

  • 整体校验失败 → 自动回滚到旧固件
  • 新固件启动后3秒内无心跳 → 看门狗复位,Bootloader检测到启动失败,回滚
  • 用户手动触发回滚(通过特定按键组合或手机指令)

我曾经在一个项目中,设备升级后蓝牙连不上了。用户以为变砖了,其实只是蓝牙初始化顺序有问题。幸好有回滚机制,设备在3次启动失败后自动切回旧固件,用户重新升级就解决了。嗯,从那以后我特别重视“启动失败检测”这个环节。

五、实战中的那些坑

最后分享几个我踩过的坑,希望能帮你省点时间:

  • MTU协商时机:不要在连接后立刻协商MTU,等1-2秒让GATT服务发现完成。否则可能协商失败,一直用23字节的MTU,传输速度慢得让人抓狂。
  • 写操作间隔:连续写Notify特征值时,每个写操作之间至少间隔20-30ms。Android的BLE栈有内部队列,写太快会丢包。我一般用Handler做延迟发送。
  • 设备端缓冲区:设备端接收缓冲区要足够大,至少能缓存一个Flash页的数据。否则边收边写,速度跟不上,容易丢包。
  • 电量检测:升级前一定要检查设备电量。低于30%就拒绝升级,否则升级到一半没电了,那真是叫天天不应。

我的建议:做OTA升级,一定要在开发阶段就加入“升级失败模拟”测试。拔电源、断蓝牙、发坏数据——把这些场景都测一遍,才能保证上线后不出大问题。

OTA升级这件事,说难不难,说简单也不简单。核心就是四个字:稳、准、快、省。稳——流程可靠,准——数据准确,快——传输高效,省——资源节约。把这四个字吃透了,你的DFU方案就不会出大问题。


公众号:蓝海资料掘金营,微信deep3321