串口也能玩转远程升级?黄山派上的固件空中接力实战
你有没有遇到过这样的场景:一台部署在偏远变电站的嵌入式设备,突然需要紧急修复一个安全漏洞。运维人员驱车两小时赶到现场,打开机柜却发现——没有网口,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端做如下检查:
-
是否以
0xAA55开头? - 包长是否合法?(避免越界)
- CRC校验是否通过?
- 序列号是否连续?(防止漏包)
任一环节失败,都返回
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
升级流程变为:
- 收到全部数据 → 写入App区;
- 计算整体CRC32并与包内携带的对比;
-
若一致,则在
UPDATE_STATUS_ADDR写入STATUS_COMPLETE; -
否则保持
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用公钥验证。
流程如下:
- 出厂前烧录公钥到Flash保护区;
- 每次发布固件时,用私钥生成签名文件;
- 升级时,将签名随固件一起发送;
- 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),仅供参考

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



