一、固件升级的几种通信方式

做嵌入式开发这么多年,我接触过的升级方式少说也有七八种。每种方式都有自己的脾气,选对了事半功倍,选错了...嗯,我吃过不少亏。

先看一张总览图,把几种主流方式的关系理清楚:

MCU 固件升级 UART / I2C / SPI USB CAN 以太网 无线 (WiFi/BLE) 图:MCU固件升级常见通信方式一览

1.1 UART 串口升级

UART 是最基础、最常用的升级方式。说白了,就是三根线(TX、RX、GND)搞定一切。我最早做 Bootloader 就是从 UART 入手的,那时候用的还是 STM32F103,一个串口中断加一个状态机,就能把固件稳稳当当地传进去。

UART 升级的典型流程:

// 伪代码:UART Bootloader 主循环
while (1) {
    uint8_t byte = UART_ReceiveByte();
    switch (state) {
        case WAIT_HEADER:
            if (byte == 0xAA) {  // 帧头
                state = WAIT_LENGTH;
                checksum = 0;
            }
            break;
        case WAIT_LENGTH:
            packet_len = byte;
            state = WAIT_DATA;
            break;
        case WAIT_DATA:
            buffer[data_idx++] = byte;
            checksum += byte;
            if (data_idx == packet_len) {
                state = WAIT_CRC;
            }
            break;
        case WAIT_CRC:
            if (byte == checksum) {
                Flash_Write(buffer, packet_len);
                UART_SendByte(0x55);  // 应答
            }
            state = WAIT_HEADER;
            break;
    }
}
我的经验:UART 升级最怕波特率不匹配。我曾经在一个项目里,生产线上 10 台设备有 3 台升级失败,查了两天才发现是晶振偏差导致波特率漂移。后来我强制在升级前先做一次波特率自动检测,问题就解决了。

1.2 I2C 与 SPI 升级

I2C 和 SPI 通常用在板级升级场景。比如主控 MCU 通过 I2C 给传感器 MCU 升级,或者通过 SPI 给无线模块升级。

这两种方式有个共同特点:主从结构。主机发起升级请求,从机被动接收。我个人习惯用 SPI 做升级,因为速度快、协议简单。I2C 虽然线少(只要两根),但协议复杂一些,而且有地址冲突的风险。

特性 I2C SPI
线数 2 (SCL, SDA) 4 (SCK, MOSI, MISO, CS)
速度 100kHz ~ 3.4MHz 可达 50MHz+
寻址方式 7位/10位地址 片选信号
适用场景 从设备升级 高速批量升级
注意:SPI 升级时,如果从机数量多,片选引脚会占用大量 GPIO。我曾经在一个 8 从机的项目里,不得不改用 GPIO 扩展芯片来管理片选信号。

1.3 USB 升级

USB 升级是消费类产品的标配。用户插上 U 盘或者连接电脑,就能自动升级。USB 升级有两种常见模式:

  • DFU (Device Firmware Upgrade):USB 官方标准协议,很多 MCU 原生支持
  • MSC (Mass Storage Class):把设备模拟成 U 盘,拖拽固件文件即可

我个人更推荐 DFU 模式,因为它有完整的错误校验和恢复机制。MSC 模式虽然用户操作简单,但文件系统容易出问题——我就遇到过用户拔掉 U 盘时固件只写了一半的情况。

1.4 CAN 升级

CAN 总线在工业控制和汽车电子中非常常见。CAN 升级的特点是:多节点、远距离、高可靠。一个 CAN 网络上可能有几十个节点,你需要设计好升级的广播机制和冲突处理。

CAN 升级的帧结构通常这样设计:

// CAN 升级数据帧格式
// ID: 0x100 - 升级命令
// ID: 0x101 - 数据包
// ID: 0x102 - 应答

typedef struct {
    uint32_t id;        // CAN ID
    uint8_t  data[8];   // 数据段
    uint8_t  dlc;       // 数据长度
} CAN_Frame;

// 升级命令帧
CAN_Frame cmd_frame = {
    .id = 0x100,
    .data = {0xAA, 0x55, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00},
    .dlc = 8
};

关键点:CAN 每帧最多 8 字节数据,所以大固件需要分包传输。我一般每包放 4 字节有效数据,剩下 4 字节放序号和校验,这样接收端可以方便地做重传请求。

1.5 以太网升级

以太网升级是高端工业设备的标配。通过 TCP/IP 协议栈,你可以实现:

  • HTTP 升级:设备内置 Web 服务器,浏览器上传固件
  • FTP/TFTP 升级:从服务器拉取固件
  • MQTT 升级:通过物联网协议推送固件

以太网升级最大的优势是远程升级。设备部署在现场,工程师坐在办公室就能完成升级。但这也带来了安全风险——你想想看,如果升级包被截获或者篡改,后果不堪设想。

我的建议:以太网升级一定要加签名验证。我习惯用 RSA-2048 对固件签名,MCU 端用公钥验签。虽然会多占几 KB 的 Flash,但安全性提升了一个量级。

二、升级协议设计原则

不管用哪种通信方式,升级协议的设计都有一些共通的原则。这些原则是我踩过无数坑之后总结出来的,希望能帮你少走弯路。

2.1 可靠性优先

升级过程中最怕什么?写到一半断电了。一旦发生这种情况,设备可能变砖。所以协议设计的第一原则就是可靠性。

我常用的可靠性措施:

  • 双备份区:保留一个可启动的旧固件,新固件写入备份区,校验通过后再切换
  • 逐包校验:每收到一包数据就做 CRC 校验,失败立即重传
  • 超时重传:发送方等待应答,超时后重发,最多重试 3 次
  • 断点续传:记录已写入的扇区号,下次升级时从断点继续

2.2 协议分层设计

好的升级协议应该分层。我一般分三层:

  1. 物理层:处理字节的收发,与具体通信方式相关
  2. 传输层:处理分包、排序、重传、校验
  3. 应用层:处理固件版本、签名验证、启动切换

这样做的好处是:换通信方式时,只需要改物理层,上面两层基本不用动。我曾经把 UART 升级改成 CAN 升级,只花了半天时间就搞定了。

2.3 协议帧结构设计

一个标准的升级数据帧,我习惯这样设计:

// 通用升级帧结构
// | 帧头(2B) | 长度(1B) | 类型(1B) | 序号(2B) | 数据(NB) | 校验(2B) | 帧尾(2B) |

#define FRAME_HEADER    0xAA55
#define FRAME_TAIL      0x0D0A

typedef struct {
    uint16_t header;    // 帧头 0xAA55
    uint8_t  length;    // 数据长度
    uint8_t  type;      // 帧类型: 0x01=数据, 0x02=命令, 0x03=应答
    uint16_t seq;       // 序号
    uint8_t  data[256]; // 数据
    uint16_t crc;       // CRC16 校验
    uint16_t tail;      // 帧尾 0x0D0A
} __attribute__((packed)) UpgradeFrame;

设计要点:

  • 帧头和帧尾用于同步,防止数据错位
  • 序号用于排序和重传
  • CRC 校验覆盖整个帧(除帧头帧尾)
  • 使用 __attribute__((packed)) 防止编译器对齐

2.4 状态机设计

升级过程本质上是一个状态机。我常用的状态定义:

typedef enum {
    UPGRADE_IDLE,           // 空闲
    UPGRADE_START,          // 开始升级
    UPGRADE_TRANSMITTING,   // 传输中
    UPGRADE_VERIFYING,      // 校验中
    UPGRADE_COMMITTING,     // 提交(切换启动区)
    UPGRADE_COMPLETE,       // 完成
    UPGRADE_ERROR           // 错误
} UpgradeState;

每个状态都有对应的超时处理。比如在 TRANSMITTING 状态,如果 5 秒内没收到新数据包,就自动回退到 IDLE 状态,并上报超时错误。

2.5 版本管理与兼容性

升级协议本身也要考虑版本兼容。我见过最惨的案例:新 Bootloader 改了协议格式,结果旧版上位机发过来的数据全解析错了,整批设备全部变砖。

我的做法是:在协议帧里加一个协议版本号字段。Bootloader 启动时先协商版本,如果版本不匹配,就使用兼容模式或者拒绝升级。

血的教训:协议版本号一定要放在帧头的固定位置,不管协议怎么变,这个字段的位置和含义永远不变。这样即使协议升级了,Bootloader 也能正确解析版本号,做出相应处理。

三、总结

固件升级方式的选择,说白了就是场景决定方案

  • 开发调试阶段:UART 最方便,三根线搞定
  • 板级从设备升级:I2C 或 SPI,看速度和线数需求
  • 消费类产品:USB DFU,用户体验好
  • 工业现场:CAN 或以太网,可靠性和远程能力是关键

协议设计上,记住三个关键词:可靠、分层、兼容。把这三点做好了,你的升级系统就能经得起各种极端情况的考验。

嗯,这一章就到这里。下一章我们会深入 Bootloader 的具体实现,从 Flash 分区开始讲起。


专注资料整理