串口通信中实现远程固件升级协议
你有没有遇到过这样的场景:设备已经部署在千里之外的野外基站,突然发现一个关键 Bug 需要修复,结果只能派工程师坐飞机过去“现场烧录”?😅 这种情况在工业控制、能源监控、农业物联网等领域并不少见。每次升级都像一次“外科手术”,成本高、风险大、效率低。
于是我们开始思考——既然设备已经通过 RS-485 或 TTL 串口连上了主控网关,能不能直接利用这条“现成的路”,把新固件“推送”进去?
答案是: 完全可以!而且比你想象的更简单、更可靠。
今天我们就来聊聊如何在一个资源有限的 MCU 上(比如 STM32F103),基于 UART 实现一套轻量但健壮的远程固件升级机制。不依赖操作系统,不用 Wi-Fi 模块,仅靠一根串线,就能完成安全可靠的 RFU(Remote Firmware Update)。
从启动那一刻说起:Bootloader 是怎么掌控全局的?
MCU 上电后第一件事是什么?不是跑你的
main()
函数,而是从固定地址开始执行指令——这个地址通常是 Flash 起始位置(如
0x08000000
)。这里存放的,就是
Bootloader
。
你可以把它理解为设备的“守门人”。它不负责业务逻辑,但它决定谁有资格上台表演。
它到底能做什么?
- 检查是否有升级请求(比如收到特定命令或标志位被置起)
- 校验当前 Application 是否合法
- 决定是跳转到旧程序运行,还是进入接收模式等待新固件
- 升级完成后擦除标志,防止无限循环重刷
听起来很基础,但正是这种“微小却关键”的角色,构成了整个远程升级系统的信任锚点。
如何安全地跳转到 Application?
这可不是简单的函数调用。我们要做的是“交出系统控制权”——包括堆栈指针、中断向量表和复位入口。
typedef void (*pFunction)(void);
#define APPLICATION_START_ADDR 0x08004000
#define STACK_TOP *(volatile uint32_t*)APPLICATION_START_ADDR
#define RESET_HANDLER *(pFunction*)(APPLICATION_START_ADDR + 4)
void jump_to_application(void) {
if (((*(__IO uint32_t*) APPLICATION_START_ADDR) & 0x2FFE0000) == 0x20000000) {
__set_MSP(STACK_TOP); // 切换主堆栈指针
RESET_HANDLER(); // 调用用户程序的复位服务例程
}
}
这段代码虽然短,但每一步都有讲究:
- 先判断首地址是否指向合法 RAM 区域(防止跳到非法区域导致死机)
- 设置 MSP(Main Stack Pointer),否则新程序一运行就会栈溢出
- 直接调用复位处理函数,模拟一次“软启动”
🛑 注意:跳转前必须关闭所有中断!尤其是 SysTick 和外部中断。否则一旦在初始化过程中触发中断,而向量表还没重映射,系统立马崩溃。
向量表重映射:让中断也能“搬家”
Cortex-M 系列支持动态设置向量表偏移。如果你的应用程序不在
0x08000000
开始,就必须告诉 NVIC 新的向量表在哪:
SCB->VTOR = APPLICATION_START_ADDR;
这一句看似不起眼,却是多区启动的核心。没有它,哪怕 Application 成功加载了,一旦发生中断,CPU 还是会回到 Bootloader 区域找 ISR,后果不堪设想。
数据怎么传?别再用裸发 bin 文件了!
很多人一开始做升级,都是让 PC 端直接发送
.bin
文件内容,MCU 收到就往 Flash 写。听起来没问题,可现实中只要线路稍有干扰,或者中途断电,设备立刻“变砖”。
真正可靠的方案,得有一套完整的 传输协议 来兜底。
我们面对的是什么样的链路?
UART 本身是个“尽力而为”的通道:
- 没有重传机制
- 容易受噪声影响
- 波特率越高越不稳定
- 可能丢包、错序、粘包
所以在设计协议时,我们必须假设:“每一帧数据都可能出错”。
那怎么办?两个字: 分帧 + 应答 。
设计一个极简但有效的协议帧结构
我们可以参考 YMODEM/XMODEM 的思路,但做更贴合嵌入式的裁剪。
#pragma pack(1)
struct firmware_packet {
uint8_t soh; // 帧头,固定为 0x01
uint16_t len; // 数据长度(不含头部和 CRC)
uint32_t addr; // 目标写入地址(可选,用于非连续传输)
uint8_t data[]; // 变长数据体
uint16_t crc; // CRC16-CCITT 校验值
uint8_t eof; // 帧尾,固定为 0x04
};
#pragma pack()
每个字段都很有讲究:
-
soh/eof:帮助接收端识别帧边界,解决粘包问题 -
len:明确知道要收多少字节 -
addr:允许跳跃式写入,适合稀疏更新或差分包 -
crc:强校验,检测误码能力远超 checksum -
使用
#pragma pack(1)确保内存对齐一致,避免跨平台问题
典型的一次交互流程如下:
Host Device
| -- [START_CMD] --> |
| <-- ACK ----------|
| -- DATA_PKG_1 ---> |
| <-- ACK -----------|
...
| -- DATA_PKG_N ---> |
| <-- ACK -----------|
| -- END_CMD -------> |
| <-- REBOOT --------|
是不是很简单?但它已经具备了基本的可靠性保障。
关键机制:停等 ARQ(Stop-and-Wait)
这是最原始但也最稳定的流控方式:
- 发送方发出一包
- 启动定时器等待 ACK
- 收到 ACK → 发下一包
- 超时未收到 → 重发当前包(最多 3 次)
- 达到最大重试次数 → 返回失败
伪代码大概是这样:
for (each packet) {
int retry = 0;
while (retry < MAX_RETRY) {
send_packet(packet);
if (wait_for_ack(TIMEOUT_MS)) break;
retry++;
}
if (retry >= MAX_RETRY) return ERROR_TRANSMIT_FAILED;
}
虽然效率不高(带宽利用率大概只有 30%~50%),但在低速串口上足够用了。毕竟稳定性优先于速度。
CRC 校验怎么选?别再手写低效算法了!
网上很多教程给的 CRC16 实现慢得离谱,还容易出错。其实可以优化一下:
static const uint16_t crc16_table[256] = {
0x0000, 0xC0C1, 0xC181, 0x0140, /* ... 自动生成表 */
// 推荐使用在线生成工具生成完整表格
};
uint16_t crc16_fast(const uint8_t *buf, size_t len) {
uint16_t crc = 0xFFFF;
while (len--) {
crc = (crc >> 8) ^ crc16_table[(crc ^ *buf++) & 0xFF];
}
return crc;
}
预计算查表法比逐位运算快 5~10 倍,非常适合频繁校验的场景。
Flash 写入:小心那些“看不见的坑”
你以为接收到数据就可以直接写 Flash 了吗?Too young.
Flash 存储有个铁律: 必须先擦后写 。而且擦除是以“扇区”为单位,写入是以“页”或“字”为单位。
以 STM32F103CBT6 为例:
- 扇区大小:1KB
- 最小编程单位:半字(16-bit)
- 擦写寿命:约 1 万次
这意味着什么?
👉 即使你只想改一个字节,也必须:
1. 把整个扇区读出来缓存到 RAM
2. 擦除该扇区
3. 修改对应字节后再整块写回去
否则其他数据全没了!
如何避免频繁擦写损耗 Flash 寿命?
一个常见做法是: 累积够一整个扇区再统一擦写 。
比如设定一个 1KB 的缓冲区:
uint8_t sector_buffer[1024];
uint32_t current_write_addr = 0x08004000;
int buffer_offset = 0;
void write_with_buffer(uint32_t addr, uint8_t *data, int len) {
while (len > 0) {
int offset_in_sector = (addr + buffer_offset) % 1024;
int space_left = 1024 - offset_in_sector;
int chunk = min(len, space_left);
memcpy(sector_buffer + offset_in_sector, data, chunk);
buffer_offset += chunk;
data += chunk;
len -= chunk;
// 缓冲区满,执行一次写入
if (buffer_offset >= 1024) {
flush_buffer_to_flash(current_write_addr);
buffer_offset = 0;
current_write_addr += 1024;
}
}
}
这样可以把 N 次擦除合并成一次,极大延长 Flash 寿命。
写完就万事大吉?不,还得验证!
Flash 写入失败的情况并不罕见,尤其是在供电不稳或电磁干扰强的工业环境。
所以建议增加 写后验证 步骤:
int verify_write(uint32_t addr, uint8_t *data, uint32_t len) {
for (int i = 0; i < len; i++) {
if (((uint8_t*)addr)[i] != data[i]) {
return -1; // 验证失败
}
}
return 0;
}
如果发现写入错误,应立即停止升级,并返回错误码给主机。千万别“假装成功”。
断电了怎么办?别让你的设备“死在路上”
这是远程升级最怕的问题: 升级到一半断电,重启后既不能跑旧程序,也没有新程序可用。
怎么破?核心思想只有一个: 状态持久化 + 回滚机制 。
方案一:双备份分区(Double Bank)
如果有条件使用支持双 Bank 的芯片(如 STM32G0、H7 等),可以直接交替写入 Bank1/Bank2:
Bank1: 当前运行版本
Bank2: 下载新版本
→ 成功后切换启动 Bank
→ 失败则保留原 Bank 继续运行
优点是几乎零风险,缺点是需要额外 Flash 空间。
方案二:状态标记法(推荐小容量 MCU 使用)
对于 Flash 小于 64KB 的设备,可以用一个专用页存储状态信息:
| 地址 | 含义 |
|---|---|
| 0x0800FC00 | 升级状态标志(0xABCD 表示正在升级) |
| 0x0800FC04 | 已接收固件大小 |
| 0x0800FC08 | 整体 CRC 值 |
| 0x0800FC0C | 版本号 |
工作流程如下:
- 开始升级 → 写入状态为“升级中”
- 每收到一包 → 更新已接收大小
- 全部接收完毕 → 计算总体 CRC 并保存
- 设置“升级成功”标志
-
重启 → Bootloader 检查状态页
- 若为“成功” → 跳转新程序
- 若为“升级中” → 判断是否继续或回滚
这样一来,即使中途断电,下次上电也能知道:“哦,上次没搞完,要不要接着来?”
💡 提示:可以在 Bootloader 中加入超时判断。比如“升级中”状态持续超过 5 分钟仍未完成,自动回滚并清除标志。
实战技巧:这些细节决定了成败
纸上谈兵容易,落地才见真章。以下是我们在多个项目中总结的经验教训👇
✅ 使用独立的“升级触发”机制
不要依赖串口接收某个字符就进入升级模式。万一误触怎么办?
更好的方式是组合判断:
if ((check_pin_state() == LOW) && (uart_receive_timeout("UPDATE", 3000))) {
enter_dfu_mode();
}
即: 物理按键按下 + 上位机发送命令 才能激活 DFU 模式。双重保险,避免意外触发。
✅ RAM 缓冲区别放栈里!
很多初学者喜欢这样写:
void handle_packet() {
uint8_t buf[1024]; // 放在局部变量 → 在栈上!
uart_read(buf, sizeof(buf));
...
}
问题是:STM32 默认栈大小才几 KB,加上其他函数调用很容易溢出。
正确做法是:
__attribute__((aligned(4))) static uint8_t g_rx_buffer[1024]; // 放在 .bss 区
或者用 DMA+循环缓冲区,进一步降低 CPU 占用。
✅ 加个进度灯,用户体验翻倍
没人喜欢“黑盒操作”。加一个 LED,用闪烁频率表示进度:
- 快闪(10Hz):接收中
- 慢闪(1Hz):等待命令
- 常亮:升级成功
- 熄灭:失败或未开始
哪怕是最简单的视觉反馈,也能让用户心里有底。
✅ 上位机也要聪明一点
Python 脚本示例(简化版):
import serial
import time
import crcmod
crc16_func = crcmod.mkCrcFun(0x11021, initCrc=0xFFFF, rev=False)
def send_packet(ser, addr, data):
packet = bytearray()
packet.append(0x01) # SOH
packet.extend(len(data).to_bytes(2, 'little'))
packet.extend(addr.to_bytes(4, 'little'))
packet.extend(data)
crc_val = crc16_func(packet[1:]) # exclude SOH
packet.extend(crc_val.to_bytes(2, 'little'))
packet.append(0x04) # EOF
ser.write(packet)
# 等待 ACK
start = time.time()
while (time.time() - start) < 2.0:
if ser.in_waiting:
resp = ser.read(1)
if resp == b'\x06': # ACK
return True
return False # timeout
配合简单的进度条输出:
total_packets = len(firmware_data) // 1024
for i in range(total_packets):
sent = False
for retry in range(3):
if send_packet(ser, base_addr + i*1024, chunk):
sent = True
break
print(f"Progress: {i+1}/{total_packets} {'✅' if sent else '❌'}")
if not sent:
raise Exception("Failed to send packet")
更进一步:安全性与可维护性设计
当你真的把这套系统用在产品中,很快就会面临这些问题:
- 能不能防止别人随便刷个恶意固件?
- 怎么确认收到的固件确实是官方签发的?
- 出问题了怎么排查?
这时候就需要引入更高阶的设计。
🔐 固件签名验证(数字签名)
最简单的防篡改方式: RSA + SHA256 签名
流程如下:
- 开发者用私钥对固件计算签名
- 将签名附加在固件末尾一起下发
- Bootloader 用内置公钥验证签名合法性
- 验证通过才允许写入
// 伪代码
bool verify_signature(uint8_t *firmware, int fw_len, uint8_t *signature) {
uint8_t hash[32];
sha256(firmware, fw_len, hash);
return rsa_verify(public_key, hash, signature); // 使用 mbedtls 或自研库
}
虽然对低端 MCU 来说计算开销较大,但只在启动时执行一次,完全可以接受。
🔒 可选加密:AES-CBC 保护敏感固件
如果你的产品涉及商业机密或客户数据,还可以对固件进行加密传输:
// 主机端
encrypted_fw = aes_encrypt(fw_bin, key, iv)
// 设备端
decrypted_fw = aes_decrypt(received, key, iv)
注意:密钥不能硬编码在代码里!建议使用芯片唯一 ID 衍生密钥,或配合 SE(安全元件)管理。
📜 日志记录:让故障不再神秘
可以预留一页 Flash 作为“升级日志区”:
struct update_log {
uint32_t version;
uint32_t timestamp;
uint8_t status; // 0=success, 1=timeout, 2=crc_error, 3=flash_fail
uint32_t fail_addr; // 失败时的地址
};
每次升级结束后追加一条记录。下次连接时可通过串口查询历史状态,快速定位问题。
为什么选择串口而不是 OTA?
你可能会问:现在都 2025 年了,为啥还要折腾串口升级?Wi-Fi 不香吗?
确实,OTA 更方便,但也有它的局限:
| 对比维度 | 串口升级 | OTA 升级 |
|---|---|---|
| 成本 | 极低(已有线路) | 需无线模块(+¥10~30) |
| 功耗 | 极低 | 高(尤其长时间传输) |
| 安全性 | 物理隔离,难远程攻击 | 易受中间人攻击 |
| 可靠性 | 高(工业 RS-485 抗干扰强) | 受信号强度、信道拥堵影响 |
| 适用场景 | 工业现场、调试接口、封闭系统 | 消费类 IoT、智能家居 |
所以你会发现,在电力、轨道交通、石油管道这类领域,串口升级依然是主流。
甚至有些高端设备采用“双通道”策略:
- 日常小版本用串口更新(安全可控)
- 大版本或远程批量升级走 4G/5G OTA
灵活搭配,各取所长。
结语:把复杂留给自己,把简单留给用户
实现一个可靠的远程固件升级协议,本质上是在和不确定性作战。
你要对抗:
- 不稳定的通信链路
- 有限的硬件资源
- 意外断电的风险
- 用户的误操作
但只要你把该做的检查都做了,该加的校验都加了,该设的状态都存了——
最终呈现给用户的,就可以是一句简单的指令:
$ python flash_tool.py --port COM3 --firmware v2.1.bin
[✔] Connecting...
[✔] Entering DFU mode...
[████████████████████] 100%
[✔] Upgrade success! Rebooting...
整个过程无需拆机、无需烧录器、无需专业知识。
而这,正是嵌入式工程的魅力所在:
用最底层的技术,支撑最上层的体验。
💪
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

被折叠的 条评论
为什么被折叠?



