串口通信中实现远程固件升级协议

AI助手已提取文章相关产品:

串口通信中实现远程固件升级协议

你有没有遇到过这样的场景:设备已经部署在千里之外的野外基站,突然发现一个关键 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)

这是最原始但也最稳定的流控方式:

  1. 发送方发出一包
  2. 启动定时器等待 ACK
  3. 收到 ACK → 发下一包
  4. 超时未收到 → 重发当前包(最多 3 次)
  5. 达到最大重试次数 → 返回失败

伪代码大概是这样:

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 版本号

工作流程如下:

  1. 开始升级 → 写入状态为“升级中”
  2. 每收到一包 → 更新已接收大小
  3. 全部接收完毕 → 计算总体 CRC 并保存
  4. 设置“升级成功”标志
  5. 重启 → 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 签名

流程如下:

  1. 开发者用私钥对固件计算签名
  2. 将签名附加在固件末尾一起下发
  3. Bootloader 用内置公钥验证签名合法性
  4. 验证通过才允许写入
// 伪代码
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),仅供参考

您可能感兴趣的与本文相关内容

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值