17、USB DFU与固件升级:DFU协议概述、Android端实现DFU Host、固件包校验与安全升级、实战:通过USB升级STM32/ESP32固件

说到USB DFU,我脑子里第一个蹦出来的画面,是几年前在产线调试时,一台设备刷成砖,现场所有人都盯着我。嗯,那时候我就意识到——固件升级这件事,看着简单,坑是真不少。

DFU,全称Device Firmware Upgrade,说白了就是通过USB给设备更新固件。你想想看,嵌入式设备越来越多,总不能每次升级都拆机接烧录器吧?DFU就是干这个的——让用户插上USB线,就能把新固件刷进去。

17.1 DFU协议概述

DFU协议是USB设备类规范的一部分,标准定义在USB Device Class Specification for DFU中。它定义了一套标准的命令集,让USB Host(比如你的Android手机)能够控制设备进入升级模式、传输固件数据、校验并重启。

我个人习惯把DFU协议拆成三层理解:

  • 传输层:基于USB控制传输(Endpoint 0),使用标准的USB请求格式。
  • 命令层:定义了一套DFU专用命令,比如DNLOAD(下载)、UPLOAD(上传)、GETSTATUS(获取状态)等。
  • 状态机层:设备端维护一个状态机,管理从空闲到编程再到完成的整个流程。

DFU协议的核心命令,我整理了一个表格,方便你对照:

命令代码方向说明
DNLOAD0x01Host→Device下载固件数据块
UPLOAD0x02Device→Host上传固件数据块(用于备份或校验)
GETSTATUS0x03Host→Device获取设备当前状态和进度
CLRSTATUS0x04Host→Device清除错误状态
GETSTATE0x05Host→Device获取状态机当前状态
ABORT0x06Host→Device中止当前操作
小提示:DFU协议中,DNLOAD命令每次传输的数据块大小由设备在描述符中声明。我建议你读取这个值,不要硬编码。曾经有个项目,我偷懒写死了64字节,结果换了颗芯片就翻车了。

17.2 Android端实现DFU Host

Android作为USB Host,实现DFU升级,核心就是通过USB Host API与设备通信。流程大致如下:

  1. 检测USB设备插入,获取设备权限。
  2. 读取设备描述符,确认设备支持DFU接口。
  3. 通过控制传输发送DFU命令,进入编程模式。
  4. 分块发送固件数据,每发一块查询一次状态。
  5. 发送完成指令,设备重启运行新固件。

下面是一个简化的代码片段,展示如何发送DNLOAD命令:

// 通过USB控制传输发送DFU DNLOAD命令
UsbDeviceConnection connection = usbManager.openDevice(device);
connection.claimInterface(intf, true);

// 构造DNLOAD请求
// bmRequestType: 0x21 (Host to Device, Class, Interface)
// bRequest: 0x01 (DNLOAD)
// wValue: 块序号(从0开始)
// wIndex: 接口号
// wLength: 数据长度
byte[] firmwareBlock = getFirmwareBlock(blockNum);
int result = connection.controlTransfer(
    0x21,       // 请求类型
    0x01,       // 请求码:DNLOAD
    blockNum,   // 块序号
    intf.getId(), // 接口号
    firmwareBlock,
    firmwareBlock.length,
    1000        // 超时时间(毫秒)
);

// 发送后必须查询状态
byte[] statusBuf = new byte[6];
connection.controlTransfer(0xA1, 0x03, 0, intf.getId(), statusBuf, 6, 1000);
// 解析statusBuf,检查bStatus字段是否为0(OK)
注意:Android的USB Host API要求你在主线程之外执行控制传输。我曾经在UI线程直接调用,结果ANR弹窗教我做人了。建议用AsyncTask或HandlerThread。

这里有个细节——DFU协议要求每个DNLOAD命令后,必须跟一个GETSTATUS命令。为什么?因为设备需要时间擦除Flash、写入数据。你不查状态就直接发下一块,数据大概率会丢。我在项目中遇到过这个问题,排查了整整一个下午才发现是状态查询没做对。

17.3 固件包校验与安全升级

固件升级最怕什么?刷到一半断电、固件包被篡改、刷了错误的版本。这些问题轻则设备变砖,重则造成安全隐患。

我个人习惯在固件包中嵌入以下信息:

  • 固件头:包含魔数、版本号、硬件平台ID、固件长度、校验和。
  • 固件体:实际的二进制数据。
  • 签名:使用私钥对固件头+固件体进行签名。

校验流程我画了一张图,方便你理解:

固件包校验与安全升级流程 1. 读取固件包 2. 解析固件头 3. 验证魔数+版本 4. 计算固件体哈希 5. 验证签名 6. 通过 → 开始升级 失败 → 拒绝升级

在Android端,校验固件包的代码大致如下:

// 校验固件包签名
public boolean verifyFirmware(byte[] firmwareData, PublicKey publicKey) {
    try {
        // 解析固件头(前64字节)
        ByteBuffer buffer = ByteBuffer.wrap(firmwareData);
        int magic = buffer.getInt();
        int version = buffer.getInt();
        int hwPlatform = buffer.getInt();
        int firmwareLen = buffer.getInt();
        byte[] storedHash = new byte[32];
        buffer.get(storedHash);
        byte[] signature = new byte[256];
        buffer.get(signature);

        // 检查魔数
        if (magic != 0xDFU1) return false;

        // 计算固件体哈希
        byte[] firmwareBody = Arrays.copyOfRange(firmwareData, 320, 320 + firmwareLen);
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] computedHash = digest.digest(firmwareBody);

        // 验证哈希匹配
        if (!Arrays.equals(storedHash, computedHash)) return false;

        // 验证签名
        Signature sig = Signature.getInstance("SHA256withRSA");
        sig.initVerify(publicKey);
        sig.update(firmwareBody);
        return sig.verify(signature);
    } catch (Exception e) {
        return false;
    }
}
核心要点:安全升级不是可选项,而是必选项。哪怕你的设备不联网,只要有人能物理接触USB口,固件就可能被篡改。签名验证是最基本的防线。

17.4 实战:通过USB升级STM32/ESP32固件

理论说完了,咱们来点实际的。我以STM32和ESP32为例,讲讲Android手机怎么给它们升级固件。

17.4.1 升级STM32

STM32内置了系统存储器(System Memory),里面有一段出厂固化的Bootloader。当BOOT0引脚拉高、BOOT1拉低时,芯片上电就会进入DFU模式。这时候,Android手机作为Host,就能通过DFU协议给它刷固件。

具体步骤:

  1. 将STM32的BOOT0接3.3V,BOOT1接GND,上电或复位。
  2. Android端通过USB检测到设备,读取接口描述符,确认DFU接口。
  3. 发送DFU命令,进入编程模式。
  4. 分块发送固件(STM32的DFU固件通常是.srec或.hex格式,需要解析成二进制)。
  5. 发送完成后,设备自动跳转到用户Flash执行。
经验之谈:STM32的DFU Bootloader有个特点——它要求固件地址从0x08000000开始。如果你用IAR或Keil生成的hex文件,地址可能不对。我习惯先用Python脚本把hex转成bin,再按地址偏移处理。

17.4.2 升级ESP32

ESP32的情况稍微不同。它没有内置DFU Bootloader,但Espressif提供了esp-dfu库,可以在ESP32上实现DFU协议。或者,你也可以用ESP32自带的OTA分区,通过USB转串口实现升级。

我个人更推荐用esp-dfu方案,因为它直接走USB,不需要额外的串口芯片。流程如下:

  1. 在ESP32固件中集成esp-dfu组件,编译时启用DFU支持。
  2. 设备上电后,检测USB是否连接。如果检测到Host请求进入DFU模式,则切换到DFU状态机。
  3. Android端发送DFU命令,分块传输固件。
  4. 固件写入OTA分区(通常是分区表里的ota_0或ota_1)。
  5. 升级完成后,设备重启,从新分区启动。

这里有个坑——ESP32的Flash写入速度有限,我建议每发送一个数据块(比如1024字节),就查询一次状态,不要连续猛发。曾经有个项目,我为了追求速度,把块大小设成4096,结果ESP32频繁超时,升级成功率不到60%。后来改成1024字节,成功率直接拉到99%以上。

警告:升级过程中不要拔出USB线!虽然DFU协议有错误恢复机制,但如果在擦写Flash中途断电,设备很可能变砖。我建议在Android端加一个电量检测,电量低于30%时提示用户先充电。

最后说一句——DFU升级这件事,看着是技术活,其实更是细心活。协议本身不复杂,但边界情况特别多。你想想看,不同芯片的Flash特性不同,不同USB控制器的兼容性不同,甚至不同USB线的质量都会影响升级成功率。我的建议是:先在小范围测试,确认稳定后再推给用户。

好了,这一章的内容就到这里。如果你在实际项目中遇到DFU相关的问题,欢迎随时交流。


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