一、固件升级的几种通信方式
做嵌入式开发这么多年,我接触过的升级方式少说也有七八种。每种方式都有自己的脾气,选对了事半功倍,选错了...嗯,我吃过不少亏。
先看一张总览图,把几种主流方式的关系理清楚:
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;
}
}
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位地址 | 片选信号 |
| 适用场景 | 从设备升级 | 高速批量升级 |
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 升级:通过物联网协议推送固件
以太网升级最大的优势是远程升级。设备部署在现场,工程师坐在办公室就能完成升级。但这也带来了安全风险——你想想看,如果升级包被截获或者篡改,后果不堪设想。
二、升级协议设计原则
不管用哪种通信方式,升级协议的设计都有一些共通的原则。这些原则是我踩过无数坑之后总结出来的,希望能帮你少走弯路。
2.1 可靠性优先
升级过程中最怕什么?写到一半断电了。一旦发生这种情况,设备可能变砖。所以协议设计的第一原则就是可靠性。
我常用的可靠性措施:
- 双备份区:保留一个可启动的旧固件,新固件写入备份区,校验通过后再切换
- 逐包校验:每收到一包数据就做 CRC 校验,失败立即重传
- 超时重传:发送方等待应答,超时后重发,最多重试 3 次
- 断点续传:记录已写入的扇区号,下次升级时从断点继续
2.2 协议分层设计
好的升级协议应该分层。我一般分三层:
- 物理层:处理字节的收发,与具体通信方式相关
- 传输层:处理分包、排序、重传、校验
- 应用层:处理固件版本、签名验证、启动切换
这样做的好处是:换通信方式时,只需要改物理层,上面两层基本不用动。我曾经把 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 启动时先协商版本,如果版本不匹配,就使用兼容模式或者拒绝升级。
三、总结
固件升级方式的选择,说白了就是场景决定方案:
- 开发调试阶段:UART 最方便,三根线搞定
- 板级从设备升级:I2C 或 SPI,看速度和线数需求
- 消费类产品:USB DFU,用户体验好
- 工业现场:CAN 或以太网,可靠性和远程能力是关键
协议设计上,记住三个关键词:可靠、分层、兼容。把这三点做好了,你的升级系统就能经得起各种极端情况的考验。
嗯,这一章就到这里。下一章我们会深入 Bootloader 的具体实现,从 Flash 分区开始讲起。