黄山派串口通信远程固件升级实现

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

串口也能玩转远程升级?黄山派上的固件空中接力实战

你有没有遇到过这样的场景:一台部署在偏远变电站的嵌入式设备,突然需要紧急修复一个安全漏洞。运维人员驱车两小时赶到现场,打开机柜却发现——没有网口,Wi-Fi模块也坏了,唯一能用的只有那个不起眼的调试串口。

这时候,如果只能返厂烧录固件,不仅成本高昂,还可能因停机造成更大损失。 但如果你掌握了串口远程升级这门“手艺”,一根USB转TTL线、一个Python脚本,就能完成一次“空中手术”

今天我们就来聊聊,在资源有限、网络缺失的环境下,如何利用最原始也最可靠的通信方式——串口,为像“黄山派”这样的国产RISC-V平台实现安全可控的远程固件更新。这不是简单的 printf("Hello World") 级别操作,而是一套完整的工程级解决方案。


为什么是串口?不是Wi-Fi也不是4G?

提到“远程升级”,很多人第一反应是OTA(Over-The-Air)。确实,无线方案听起来更酷,但在真实工业现场,事情往往没那么理想:

  • 某些高压配电房为了电磁兼容性,屏蔽了所有无线信号;
  • 工业网关虽然支持以太网,但布线成本高,老旧产线根本没法改;
  • NB-IoT模块单价动辄三四十元,对于几块钱的传感器节点来说太奢侈;
  • 更别说还有信号干扰、连接不稳定、认证失败等各种玄学问题。

而串口呢?它几乎存在于每一台嵌入式设备上,无论是用于调试还是日志输出。 只要留出TX/RX两个引脚,再加个电平转换芯片,就能打通一条稳定的数据通道

更重要的是,串口通信简单、低功耗、抗干扰能力强,特别适合长距离传输(配合RS-485可达1200米)。而且由于不接入公网,天然规避了空中劫持的风险——毕竟没人会拿个蓝牙嗅探器蹲在工厂电缆沟里抓包吧?

所以你看, 串口不是落后,而是另一种形式的“高级”:极简、可靠、可预测 。在追求高可用性的系统中,有时候“老派”的技术反而更值得信赖。


从零开始搭架子:Bootloader 是怎么“醒”过来的?

任何远程升级的第一步,都不是传数据,而是让设备进入“待命状态”。这就得靠 Bootloader ——系统启动时跑的第一个程序。

你可以把它想象成手机的“Recovery模式”:正常情况下你用App,但当你想刷机时,就得先进Recovery。同理,我们希望黄山派上电后能判断:“这次我是该直接跑用户程序,还是等着收新固件?”

启动模式选择:用什么当“开关”?

常见的做法有几种:

  • 检测GPIO电平 :比如某个按键被按下时拉低某个IO;
  • 读取RTC备份寄存器 :写入特定魔数作为标志;
  • 检查Flash特定地址的内容

其中, RTC备份寄存器是最优雅的选择 ,因为它掉电不丢(只要纽扣电池还在),且不影响主程序逻辑。

#define UPGRADE_MAGIC_NUM   0x5A5A
#define BACKUP_REG_0        RTC_BKP_DR1

uint32_t backup_register_read(int reg) {
    return *(__IO uint32_t *)(&RTC->BKP0R + reg);
}

void backup_register_write(int reg, uint32_t val) {
    *(__IO uint32_t *)(&RTC->BKP0R + reg) = val;
}

然后在系统复位前,由应用层设置这个标志:

// 用户程序中触发升级
void request_firmware_update(void) {
    backup_register_write(BACKUP_REG_0, UPGRADE_MAGIC_NUM);
    NVIC_SystemReset();  // 软重启
}

Bootloader启动时读一下这个值,如果是 0x5A5A ,就知道该进升级流程了。

💡 小技巧:记得清标志!否则下次上电还会误判。别问我怎么知道的 😅


双区存储设计:不怕断电“变砖”

说到升级,最怕的就是中途断电导致固件损坏,设备彻底“变砖”。那怎么办?答案是: 永远不要覆盖正在运行的代码

我们把Flash分成两个区域:

区域 地址范围 功能说明
Bootloader 0x08000000 ~ 0x0800FFFF 引导程序,永不更改
App Area 0x08010000 ~ … 用户程序存放区

这样,Bootloader始终安全,哪怕App区写坏了,下次还能重新进来接收固件。

但这还不够。更进一步的做法是采用 A/B双APP分区 ,类似Android系统的无缝升级机制:

  • 当前运行的是A区;
  • 升级时写入B区;
  • 校验成功后标记B为有效,下次从B启动;
  • 下次升级再轮换回来。

这样一来,即使升级失败,也可以自动回滚到旧版本,真正做到“无感降级”。

不过对于资源紧张的小容量MCU(比如128KB Flash),双APP可能不太现实。这时候至少要做到: 升级前先擦除目标扇区,并在校验通过后再跳转执行


UART初始化:不只是波特率对就行

说到底,串口就是两条线:TX发,RX收。但在实际项目中,光配好波特率远远不够。

来看看黄山派常用的CH32V系列或GD32VF103这类RISC-V芯片的UART配置要点:

void uart_init(void) {
    // 1. 开启时钟
    rcu_periph_clock_enable(RCU_GPIOA);
    rcu_periph_clock_enable(RCU_USART0);

    // 2. 配置PA9(TX), PA10(RX)为复用推挽输出
    gpio_init(GPIOA, GPIO_MODE_AF_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_9);
    gpio_init(GPIOA, GPIO_MODE_IN_FLOATING, GPIO_OSPEED_50MHZ, GPIO_PIN_10);

    // 3. 设置波特率115200
    usart_baudrate_set(USART0, 115200);

    // 4. 数据格式:8N1
    usart_word_length_set(USART0, USART_WL_8BIT);
    usart_stop_bit_set(USART0, USART_STB_1BIT);
    usart_parity_config(USART0, USART_PM_NONE);

    // 5. 使能接收中断
    usart_interrupt_enable(USART0, USART_INT_RBNE);
    nvic_irq_enable(USART0_IRQn, 2, 0);

    // 6. 启动UART
    usart_enable(USART0);
}

几个容易踩坑的地方:

  • GPIO模式必须正确 :TX要是AF_PP(复用推挽),RX要是浮空输入;
  • 中断优先级要合理 :别被其他高优先级任务阻塞太久,否则缓冲区溢出;
  • FIFO处理 :如果没有硬件FIFO,建议配合DMA使用,减少CPU负担;
  • 波特率误差 :某些主频下无法精确生成115200,偏差超过2%就可能丢帧,务必查手册确认容忍范围。

📊 实测建议:在72MHz主频下,常用波特率误差表:

波特率 实际值 误差
9600 9615 +0.16%
19200 19230 +0.16%
115200 115384 +0.16%
921600 923076 +0.16%

看似很小,但在噪声环境下累积起来也会出问题。


自定义协议设计:别让数据“飞”丢了

很多人一开始直接裸发 .bin 文件,结果发现偶尔升级失败。原因很简单: 串口不是理想信道,它会丢数据、错位、粘包

所以我们需要一套轻量但健壮的传输协议。下面这套我在多个项目中验证过的结构,你可以直接拿去用:

+--------+----------+--------+-------------+---------+--------+
| SOF(2) | TYPE(1)  | SEQ(2)| LEN(2)       | PAYLOAD(N) | CRC(2) |
+--------+----------+--------+-------------+---------+--------+

字段说明:

  • SOF :帧头,固定 0xAA55 ,用来同步和定位;
  • TYPE :包类型,如 0x01=开始 , 0x02=数据 , 0x03=结束
  • SEQ :序列号,从0递增,防重放攻击;
  • LEN :负载长度,便于解析;
  • PAYLOAD :实际内容,可能是命令或固件块;
  • CRC16 :整个包(含头部)的校验和。

举个例子,发送一块1024字节的固件数据,偏移量为0x2000:

import struct
import crcmod

crc16 = crcmod.mkCrcFun(0x18005, rev=True, initCrc=0xFFFF)

def pack_data_packet(seq, offset, data):
    header = struct.pack('<HBBHH', 0xAA55, 0x02, seq & 0xFF, len(data) + 4)
    payload = struct.pack('<I', offset) + data  # 偏移量+数据
    packet = header + payload
    crc = crc16(packet)
    packet += struct.pack('<H', crc)
    return packet

收到后,MCU端做如下检查:

  1. 是否以 0xAA55 开头?
  2. 包长是否合法?(避免越界)
  3. CRC校验是否通过?
  4. 序列号是否连续?(防止漏包)

任一环节失败,都返回 NACK ,要求重传。

🔁 重传策略建议:超时时间设为 (包大小 / 波特率) × 3 ,例如1KB@115200bps ≈ 70ms,超时设为200ms比较稳妥。


接收状态机:别让中断把你绕晕

在Bootloader里处理串口数据,最容易犯的错误就是:在中断里干太多事。

正确的做法是—— 中断只负责搬数据,主循环负责解析

我们可以用一个简单的状态机来管理接收流程:

typedef enum {
    WAIT_SOF,
    WAIT_HEADER,
    WAIT_PAYLOAD,
    WAIT_CRC
} recv_state_t;

uint8_t rx_buffer[128];
uint8_t temp_byte;
recv_state_t state = WAIT_SOF;
int pos = 0;
uint16_t expected_len;

void uart_rx_isr(void) {
    if (usart_flag_get(USART0, USART_FLAG_RBNE)) {
        temp_byte = usart_data_receive(USART0);  // 立即读走
        process_byte(temp_byte);  // 投喂给状态机
    }
}

process_byte() 函数根据当前状态逐步拼接数据:

void process_byte(uint8_t byte) {
    switch (state) {
        case WAIT_SOF:
            if (pos == 0 && byte == 0x55) pos++;  // 先等低字节
            else if (pos == 1 && byte == 0xAA) {
                state = WAIT_HEADER;
                pos = 0;
            } else {
                pos = 0;
            }
            break;

        case WAIT_HEADER:
            rx_buffer[pos++] = byte;
            if (pos >= 6) {  // SOF(2)+TYPE(1)+SEQ(1)+LEN(2)
                uint16_t sof = *(uint16_t*)rx_buffer;
                expected_len = *(uint16_t*)(rx_buffer + 4);
                if (sof != 0xAA55 || expected_len > 1024) {
                    state = WAIT_SOF;
                    pos = 0;
                    return;
                }
                state = WAIT_PAYLOAD;
                pos = 0;
            }
            break;

        case WAIT_PAYLOAD:
            rx_buffer[pos++] = byte;
            if (pos >= expected_len) {
                state = WAIT_CRC;
                pos = 0;
            }
            break;

        case WAIT_CRC:
            rx_buffer[pos++] = byte;
            if (pos >= 2) {
                handle_complete_packet();
                state = WAIT_SOF;
                pos = 0;
            }
            break;
    }
}

你看,整个过程像流水线一样,每来一个字节就往前推进一步。一旦拼完整包,就交给 handle_complete_packet() 处理。

⚠️ 注意:一定要限制最大包长,防止缓冲区溢出。别小看这个问题,我见过因为一个异常字节导致堆栈被冲毁的案例。


写Flash之前:这些细节决定成败

终于收到数据了,是不是马上往Flash里写?慢着!有几个关键点必须确认:

1. 必须先擦除,再写入

Flash的特性是:只能将1→0,不能将0→1。所以写之前必须先擦除(全变1),否则会出现数据错误。

而且擦除是以“扇区”为单位的。比如GD32VF103的扇区大小是1KB或4KB不等,你不能只擦几百字节。

#define FLASH_SECTOR_SIZE   1024

void flash_erase_if_needed(uint32_t addr, uint32_t size) {
    uint32_t start_sector = addr / FLASH_SECTOR_SIZE;
    uint32_t end_sector = (addr + size - 1) / FLASH_SECTOR_SIZE;

    for (int i = start_sector; i <= end_sector; i++) {
        fmc_page_erase(0x08010000 + i * FLASH_SECTOR_SIZE);
    }
}

2. 写入前校验:这片地干净吗?

即使刚擦过,也可能因为电压不稳导致某些位没清零。建议在写入前读一遍,确保全是 0xFF

bool is_sector_clean(uint32_t addr, uint32_t len) {
    uint8_t *p = (uint8_t*)addr;
    for (int i = 0; i < len; i++) {
        if (p[i] != 0xFF) return false;
    }
    return true;
}

3. 按字对齐写入,别随便cast

很多MCU要求写Flash时必须按字(word)进行,即每次写32位。如果你直接memcpy一个非对齐的buffer,可能会触发总线错误。

void flash_write_word(uint32_t addr, uint32_t data) {
    fmc_word_program(addr, data);
}

// 安全写入任意长度数据
void safe_flash_write(uint32_t addr, const uint8_t *buf, uint32_t len) {
    for (int i = 0; i < len; i += 4) {
        uint32_t word = *(const uint32_t*)(buf + i);
        flash_write_word(addr + i, word);
    }
}

如何防止“半截子”升级?

还有一个隐藏风险:用户程序还没完全写完,突然断电了,这时候重启会发生什么?

答案是:很可能跑飞。因为程序头(向量表)已经变了,但后面的代码不完整。

解决办法是在最后写一个“完成标志”。

比如我们在Flash末尾预留几个字节:

#define UPDATE_STATUS_ADDR  (0x08010000 + APP_MAX_SIZE - 16)
#define STATUS_PENDING      0x00000000
#define STATUS_COMPLETE     0xCAFEBABE

升级流程变为:

  1. 收到全部数据 → 写入App区;
  2. 计算整体CRC32并与包内携带的对比;
  3. 若一致,则在 UPDATE_STATUS_ADDR 写入 STATUS_COMPLETE
  4. 否则保持 PENDING 状态。

下次启动时,Bootloader检查这个状态:

if (get_boot_mode() == BOOT_MODE_NORMAL) {
    uint32_t *status = (uint32_t*)UPDATE_STATUS_ADDR;
    if (*status != STATUS_COMPLETE) {
        uart_puts("Incomplete update detected. Entering recovery mode...\r\n");
        receive_firmware_via_uart();  // 自动进入恢复流程
    }
}

这样一来,哪怕最后一次写入失败,也能自动唤醒Bootloader等待重传,而不是盲目跳转导致崩溃。


加点“甜头”:让用户知道进度

升级是个黑盒过程,用户不知道是卡住了还是在跑。加点反馈会让体验好很多。

方案一:LED呼吸灯表示忙碌

void update_led_blink(void) {
    static uint32_t last_toggle = 0;
    if (millis() - last_toggle > 200) {
        gpio_bit_toggle(GPIOC, GPIO_PIN_13);  // PC13接LED
        last_toggle = millis();
    }
}

每收到一包就刷新一次心跳。

方案二:回传百分比

可以在ACK响应中带上已接收比例:

// 发送ACK + 进度
uint8_t resp[4];
resp[0] = PKT_ACK;
resp[1] = (received_size * 100) / total_size;  // 百分比
uart_send(USART0, resp, 2);

PC端工具就可以显示进度条了:

[==========          ] 45%

方案三:蜂鸣器提示关键节点

  • 一声短响:进入升级模式;
  • 两声短响:接收完成;
  • 长鸣:校验失败。

这些细节看似微不足道,但在现场排查问题时非常有用。


安全加固:别让别人随便刷你的设备

现在我们的升级流程已经很稳了,但还不够“安全”。万一有人拿到串口权限,恶意刷入木马固件怎么办?

两个层次可以加强:

1. 传输加密(防窃听)

虽然串口不易被监听,但也不能完全排除物理接触的可能性。可以用AES-CBC对Payload加密:

from Crypto.Cipher import AES

key = b'your-32-byte-key-here-1234567890'
cipher = AES.new(key, AES.MODE_CBC, iv=b'16-byte-initialization-vector')

encrypted_payload = cipher.encrypt(pad(payload))

MCU端用轻量级AES库解密即可。注意RAM占用,推荐使用TinyAES这类内存友好的实现。

2. 固件签名(防篡改)

这才是重点。我们可以用RSA-2048对整个固件做签名,Bootloader用公钥验证。

流程如下:

  1. 出厂前烧录公钥到Flash保护区;
  2. 每次发布固件时,用私钥生成签名文件;
  3. 升级时,将签名随固件一起发送;
  4. Bootloader收到后,用公钥验证签名是否匹配。
bool verify_signature(const uint8_t *firmware, size_t len, 
                      const uint8_t *signature, size_t sig_len) {
    // 使用mbedTLS或自研RSA验证
    return rsa_verify(PUBLIC_KEY, firmware, len, signature, sig_len);
}

🔐 提示:公钥可以明文存储,但要防止被修改。某些芯片支持OTP(一次性编程)区域,适合存放密钥。

有了签名机制,即使黑客拿到了你的设备和通信协议,也无法伪造合法固件,极大提升了系统安全性。


批量升级?写个自动化脚本就够了

单台设备手动操作没问题,但如果要维护上百台设备呢?

我们可以做个简单的批量升级工具:

import serial.tools.list_ports
import time

def find_huangshan_devices():
    ports = serial.tools.list_ports.comports()
    huangshan_ports = []
    for port in ports:
        if "CH340" in port.description or "USB Serial" in port.hwid:
            huangshan_ports.append(port.device)
    return huangshan_ports

def upgrade_single_device(port, firmware_path):
    try:
        ser = serial.Serial(port, 115200, timeout=2)
        send_upgrade_command(ser)
        send_firmware_chunks(ser, firmware_path)
        result = wait_for_result(ser)
        ser.close()
        return result == 'SUCCESS'
    except Exception as e:
        print(f"{port}: {e}")
        return False

# 主流程
devices = find_huangshan_devices()
success_count = 0

for dev in devices:
    print(f"Upgrading {dev}...")
    if upgrade_single_device(dev, "v2.1.bin"):
        print(f"✅ {dev} updated successfully")
        success_count += 1
    else:
        print(f"❌ {dev} failed")

print(f"\nSummary: {success_count}/{len(devices)} succeeded")

插上一堆USB转TTL,一键批量升级,效率提升十倍不止。


写在最后:技术的价值在于解决问题

看到这儿,你可能会觉得:“这也太复杂了,不就是传个文件吗?”

但正是这些看似琐碎的细节,决定了一个系统是“能用”还是“好用”。

  • 一个CRC校验,让你在嘈杂车间也能顺利完成升级;
  • 一个双区设计,避免一次意外断电带来几千块的维修成本;
  • 一个签名机制,守住产品的安全底线,不让竞争对手轻易复制。

真正的嵌入式工程师,不是只会点亮LED的人,而是能在资源极限中构建可靠系统的匠人

而串口远程升级,正是这种能力的集中体现:它不炫技,不依赖高端硬件,却能在关键时刻救场。就像一把瑞士军刀,朴素,但永远值得信赖。

下次当你面对一台“无法联网”的设备时,别急着放弃。拿起那根尘封已久的串口线,也许奇迹就在下一秒发生。⚡

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值