简介:面向使用华大单片机(HC32L136、HC32F030、HC32L130 等)进行产品固件远程升级的嵌入式开发者,这份 DEMO 提供了可落地的 IAP 在线升级方案,包含 BOOT 程序、APP 程序与上位机三部分,可用于解决设备出厂后固件更新、现场维护和批量升级问题,也适合作为低功耗 MCU bootloader 学习的参考工程。压缩包共 282 个文件、约 3.29MB,以 C 源文件与头文件为主体,辅以 Keil、IAR 等工程的链接与配置文件,另有 bin、hex 等固件产物和批处理脚本,覆盖编译、下载、版本切换等环节;HC32L(F)17X、HC32L(F)19X 等系列也可参照移植。已有 3528 人学习下载。读者可据此理清 BOOT 与 APP 的分区跳转关系、上位机与 MCU 的通信流程、升级包校验与异常回退思路,并结合多套工程模板比较不同工具链的配置差异,快速搭建自己的远程升级验证环境。
1. 设备装在配电柜里,改一次固件要跑三百公里
去年有个做采集终端的团队找到我,设备已经铺到几十个点位,主控是 HC32L136,采样逻辑里一个边界条件写错了,改一版固件就得派人带着仿真器上门。他们最初的方案是留一个调试串口让现场人员拿笔记本重烧,结果柜门一锁,连排针都摸不到。后来把这套远程在线 IAP 升级跑通:设备常驻一个 bootloader,上位机通过 4G 透传模块把固件分包发下去,校验通过后自己跳进新 App。同一套代码,改改 Flash 起始地址和扇区大小,HC32F030、HC32L130 也能直接复用。它适合谁?做小批量工业设备、仪表、无线终端的团队,MCU 资源不宽裕,又没条件上 Linux 做 A/B 分区,但确实需要远程升级这条路。
2. 方案总览:64KB Flash 怎么切、状态机怎么走、三颗芯片怎么共用一套代码
HC32L136、HC32F030、HC32L130 都是 Cortex-M0+ 内核的 64KB Flash 型号,RAM 从 4KB 到 8KB 不等。这个资源量决定了,不能照搬 Linux 那套双分区加回滚的做法——两个 50KB 的 App 区根本放不下。我一般会先把三个问题定死:Flash 怎么切、掉电以后靠什么判断 App 是完整的、三颗芯片之间差异用什么方式隔离;这三件事定完,剩下的代码就是照着填。
2.1 Flash 分区:Bootloader、App、信息页的三段切法
我的默认切法是 12KB 给 bootloader,50KB 给 App,末尾 2KB 放信息页和参数区。bootloader 里塞得下时钟初始化、串口收发、Flash 驱动、协议解析和跳转逻辑,用 -Os 编译下来大概 8~10KB,留一点余量给后面加加密校验。App 从 0x3000 起,末尾 0xF800 之前全部归它,链接脚本按 50KB 卡死,不允许越界。
| 区域 | 起始地址 | 大小 | 内容 |
|---|---|---|---|
| Bootloader | 0x00000000 | 12 KB | 时钟、串口、Flash 驱动、协议栈、跳转 |
| App | 0x00003000 | 50 KB | 业务固件,向量表放在首 192 字节 |
| 信息页 | 0x0000F800 | 512 B | magic、版本号、镜像长度、CRC32、状态字、启动计数 |
| 参数区 | 0x0000FA00 | 1.5 KB | 校准系数、设备序列号、通信参数 |
信息页单独占一个扇区,是整个方案里最关键的地方。它只有 512 字节,写一次要么全成功要么全失败(擦除后整页写 0xFF,再按字写目标值),所以我把「App 是否有效」这个结论收敛成一次单扇区写入,避免了“写到一半掉电导致状态字和 CRC 对不上”的玄学问题。
如果板子上本来就挂了一颗 SPI Flash 存日志,我更推荐把升级包先落到外部存储,整包校验通过后再往内部 Flash 搬。这样最坏情况是内部 App 一点没动,设备还能跑老固件,代价是多花几十毫秒读外部 Flash。要不要走这条路,看你的 BOM 和现场返修成本,不必要为了“架构好看”硬上。
2.2 升级状态机:把“有效”这件事收敛到最后一次写
状态机只有六个状态,但顺序不能乱。核心原则是先置无效、再动 App、最后置有效,中间任何一步掉电,bootloader 都必须能识别出 App 不可用并停在升级模式等待重传,而不是硬跳进去跑飞。
/* upg_info.h —— 信息页结构,固定 32 字节,方便按字写 */ typedef enum { ST_IDLE = 0x00, /* 从未升级过,出厂状态 */ ST_ERASING = 0x11, /* 已开始擦除 App 区,App 一定不可用 */ ST_RECEIVING = 0x22, /* 正在收数据 */ ST_VERIFYING = 0x33, /* 数据收完,整包校验中 */ ST_VALID = 0x5A, /* 校验通过,可以跳转 */ ST_INVALID = 0xA5 /* 明确作废,等待重新升级 */ } upg_state_t; typedef struct { uint32_t magic; /* 固定 0x4843324C,防止空片误判 */ uint32_t version; /* 版本号,主版本 << 16 | 次版本 */ uint32_t length; /* App 镜像实际字节数 */ uint32_t crc32; /* 整包 CRC32,不含本信息页 */ uint8_t state; /* upg_state_t */ uint8_t boot_count; /* 启动计数,用于失败回滚 */ uint8_t reserved[10]; } upg_info_t;关键在于写入顺序:收到升级命令后第一件事是把state改成ST_ERASING并立即写进信息页,然后才去擦 App 区。太多人反过来做——先擦再写标志,结果擦到一半掉电,信息页里还留着上一版的ST_VALID,bootloader 一上电就把半截固件当好的跳进去,直接死在 HardFault。boot_count是我后来加的,App 启动后第一件事把它清零,如果连续三次上电都没清掉,bootloader 就判定新固件启动失败,回退到升级模式。
2.3 三颗芯片的差异,全部压到一个头文件里
HC32L136 和 HC32L130 是低功耗系列,HC32F030 偏通用,三者的 Flash 编程时序、时钟门控和外设基地址有差别,但内核和中断控制器是一样的。移植时我不改驱动层,只在一个board_config.h里做条件编译,把地址、页大小、时钟源这些硬编码全部抽出来。这样一份 bootloader 工程能用三个宏编出三个目标,比维护三份代码省事得多。
/* board_config.h —— 三颗芯片共用一套代码,只在这里做差异化 */ #if defined(BOOT_CHIP_HC32L136) #define APP_BASE_ADDR 0x00003000u #define INFO_PAGE_ADDR 0x0000F800u #define FLASH_SECTOR_SIZE 512u #define BOOT_UART_BAUD 115200u #elif defined(BOOT_CHIP_HC32F030) #define APP_BASE_ADDR 0x00003000u #define INFO_PAGE_ADDR 0x0000F800u #define FLASH_SECTOR_SIZE 512u #define BOOT_UART_BAUD 115200u #elif defined(BOOT_CHIP_HC32L130) #define APP_BASE_ADDR 0x00003000u #define INFO_PAGE_ADDR 0x0000F800u #define FLASH_SECTOR_SIZE 512u /* 小容量型号可能是 1KB,务必核对手册 */ #define BOOT_UART_BAUD 9600u /* 该型号常配低速无线模块 */ #endif #if !defined(APP_BASE_ADDR) #error "未选择目标芯片,检查编译宏 BOOT_CHIP_xxx" #endif页大小这一项是最容易翻车的地方。同一个系列里,64KB 型号多数是 512 字节一页,个别小容量型号是 1KB,如果直接照抄 512 去算地址,擦除会跨页,把相邻区域的数据一起抹掉。移植到新芯片时的第一件事,是翻 Flash 章节确认页大小、擦除单位、编程单位(按字还是按半字),而不是先编译。
3. Bootloader 主循环:收包、写 Flash、跳转三件事
bootloader 看着复杂,剥掉协议之后其实就三件事:上电先判断 App 能不能跳、跳不了就进升级模式收包、收完校验完再跳。真正吃时间的是异常分支——包丢了怎么重传、写失败怎么回退、掉电了下次上电走哪条路。这一章按执行顺序把主链路拆开。
3.1 上电 300ms 内的判断逻辑
上电后先做三重判断:magic 对不对、状态是不是ST_VALID、整包 CRC 是否匹配。三个都过才允许跳转,缺一个就进升级模式。这里加了一个 300ms 的窗口期,用来收上位机发来的“进入升级”握手包——设备刚上电时 4G 模块还在注网,窗口给短了会错过,给长了又拖慢正常启动,300ms 是实测下来比较稳的值。
static void boot_main(void) { upg_info_t info; flash_read(INFO_PAGE_ADDR, (uint8_t *)&info, sizeof(info)); /* 三重校验:magic + 状态 + 整包 CRC,缺一不可 */ if (info.magic == UPG_MAGIC && info.state == ST_VALID && info.length > 0u && info.length <= APP_MAX_SIZE && crc32_flash(APP_BASE_ADDR, info.length) == info.crc32) { if (wait_upgrade_cmd(300u)) { /* 窗口期内收到升级握手则放弃跳转 */ enter_upgrade_mode(&info); } jump_to_app(APP_BASE_ADDR); } /* 没有有效 App、或者主动要求升级,都落到这里 */ enter_upgrade_mode(&info); }crc32_flash是边读边算的,50KB 数据在 24MHz 主频下大概 200ms 左右,放在启动阶段能接受。如果嫌慢,可以只校验头部和末尾各 1KB 做快速判断,整包校验留到升级流程里做,但那样就失去了“防止 App 区被意外改写”的保护,我倾向于宁可慢一点。wait_upgrade_cmd里只解析握手包,其它包一律丢弃,避免上位机在设备上电前就开始发数据造成状态错乱。
3.2 跳转前的现场清理:关中断、复位外设、设 MSP
跳转本身只有四行代码,但前面要做的清理很多。M0+ 跳转的实质是把栈顶指针和复位向量从 App 区取出来,赋给 MSP 和 PC。如果此时还有中断挂着,或者 SysTick 还在跑,跳过去之后第一次中断就会跳回 bootloader 的向量表,直接变成 HardFault。
typedef void (*app_entry_t)(void); static void jump_to_app(uint32_t app_base) { uint32_t sp = *(volatile uint32_t *)(app_base); uint32_t pc = *(volatile uint32_t *)(app_base + 4u); /* 栈顶必须落在 SRAM 范围,复位向量必须落在 App 区,否则认为镜像是坏的 */ if ((sp & 0x2FFE0000u) != 0x20000000u) return; if (pc < app_base || pc >= INFO_PAGE_ADDR) return; __disable_irq(); /* 关全局中断,必须在改 MSP 之前 */ SysTick->CTRL = 0u; /* 停掉滴答定时器 */ for (uint32_t i = 0u; i < 8u; i++) { NVIC->ICER[i] = 0xFFFFFFFFu; /* 关掉所有外设中断 */ NVIC->ICPR[i] = 0xFFFFFFFFu; /* 清掉挂起标志 */ } uart_deinit_all(); /* 串口、DMA、定时器全部复位 */ SCB->VTOR = app_base; /* 向量表指向 App */ __set_MSP(sp); ((app_entry_t)pc)(); }sp & 0x2FFE0000 == 0x20000000这个判断看着别扭,但非常必要:空片的时候 Flash 读出来全是 0xFF,MSP 会被设成 0xFFFFFFFF,一执行就进 HardFault 死循环,连串口都救不回来。加上这个判断,空片设备会老老实实停在升级模式等固件。uart_deinit_all不是可选项,我见过因为 bootloader 里开了串口接收中断、跳转前没关,App 跑起来后每收一个字节就进一次 bootloader 的中断服务函数,现象是“偶发死机”,查了两天才定位到。
3.3 协议帧:32 字节定长头加变长数据
协议没有走 Modbus 这类通用标准,而是自己定的短帧结构,原因是 4G 透传模块的 MTU 和分包行为不可控,帧越短越容易重传成功。帧头固定 6 字节:0xAA 0x55同步字、命令字、序号、数据长度、负载、CRC16。数据包负载按 512 字节对齐,正好是一个扇区,收一包写一页。
| 命令 | 方向 | 参数 | 返回 |
|---|---|---|---|
| 0x01 握手 | 上位机 → 设备 | 无 | 当前版本号、状态字 |
| 0x02 擦除 | 上位机 → 设备 | 镜像总长度、CRC32 | 应答或错误码 |
| 0x03 数据 | 上位机 → 设备 | 包序号、512B 负载 | 应答序号 |
| 0x04 校验 | 上位机 → 设备 | 无 | 整包 CRC 比对结果 |
| 0x05 跳转 | 上位机 → 设备 | 无 | 无(设备直接复位) |
| 0x06 取消 | 上位机 → 设备 | 无 | 状态字置 INVALID |
接收机用状态机逐字节推进,不用缓冲区攒整包,RAM 只占一个 512 字节的页缓存加十几个字节的状态变量。M0+ 上 8KB RAM 听着不少,但 App 一旦用上协议栈和环形缓冲,余量就没那么宽裕了,bootloader 这边能省则省。
typedef enum { S_H1, S_H2, S_CMD, S_SEQ, S_LEN, S_DATA, S_CRC } rx_state_t; static void proto_feed(uint8_t byte) { static rx_state_t st = S_H1; static uint8_t len, idx; static uint16_t crc; switch (st) { case S_H1: if (byte == 0xAAu) { crc = 0xFFFFu; st = S_H2; } break; case S_H2: st = (byte == 0x55u) ? S_CMD : S_H1; break; case S_CMD: g_frame.cmd = byte; crc = crc16_update(crc, byte); st = S_SEQ; break; case S_SEQ: g_frame.seq = byte; crc = crc16_update(crc, byte); st = S_LEN; break; case S_LEN: g_frame.len = byte; crc = crc16_update(crc, byte); idx = 0u; st = (byte > 0u) ? S_DATA : S_CRC; break; case S_DATA: g_frame.data[idx++] = byte; crc = crc16_update(crc, byte); if (idx >= g_frame.len) st = S_CRC; break; case S_CRC: /* 低字节在前,高字节在后 */ if (((crc & 0xFFu) == byte) && ((crc >> 8) == g_frame.crc_hi)) { on_frame_ready(&g_frame); } st = S_H1; break; default: st = S_H1; break; } }S_CRC那一支为了省篇幅只写了一部分,实际实现里要收两个字节再比较。丢包处理交给上层的序号机制:设备收到 0x03 后回一个带相同序号的 ACK,上位机等 500ms 没收到就重发同一包,连续三次失败就中止升级并保留当前状态。这套机制在 4G 网络下比 TCP 重传快得多,实测 50KB 固件在信号一般的现场大约 40 秒传完。
3.4 写 Flash:解锁、擦除、按扇区写、写完回读
Flash 驱动是在原厂库上包了一层,函数名按你手上的驱动改就行,关键是理解调用顺序:解锁 → 擦除(按扇区)→ 编程(按字或半字)→ 回读校验。擦除和编程期间总线会 stall,如果此时有中断触发、而中断服务函数在 Flash 里,取指就会失败,所以整段操作必须关中断。
int flash_write_page(uint32_t addr, const uint8_t *buf, uint32_t len) { if (addr % FLASH_SECTOR_SIZE != 0u || len != FLASH_SECTOR_SIZE) return -1; __disable_irq(); if (flash_unlock() != 0) { __enable_irq(); return -2; } if (flash_erase_sector(addr) != 0) { /* 擦除失败不要重试整片 */ flash_lock(); __enable_irq(); return -3; } for (uint32_t i = 0u; i < len; i += 4u) { /* 该系列按字编程,4 字节对齐 */ uint32_t word = (uint32_t)buf[i] | ((uint32_t)buf[i + 1u] << 8) | ((uint32_t)buf[i + 2u] << 16) | ((uint32_t)buf[i + 3u] << 24); if (flash_program_word(addr + i, word) != 0) { flash_lock(); __enable_irq(); return -4; } } flash_lock(); __enable_irq(); /* 回读比对,Flash 写失败不一定会返回错误码,必须自己验 */ return (memcmp_flash(addr, buf, len) == 0) ? 0 : -5; }参数检查里len != FLASH_SECTOR_SIZE看着很死板,但这是故意的:分包大小和扇区大小必须严格一致,否则跨页写入会出问题。回读校验那一步不能省,某些批次的芯片在电压偏低时编程会“成功返回但数据是错的”,只有回读才能发现。返回负值给协议层,协议层把它映射成错误码回给上位机,上位机收到后重发当前页,不重新擦除整片——这一点很重要,重擦整片会让已经传完的 40KB 白费。
4. App 侧要改的三处:链接地址、VTOR 和升级入口
bootloader 写完之后,App 不改是跑不起来的。最常见的现象是:烧进去能跑,一接中断就死;或者串口能发不能收。原因基本都是链接地址没挪、向量表没搬。这一章把 App 侧必须动的三个地方列清楚,其它代码一行都不用碰。
4.1 链接脚本把 App 挪到 0x3000
Keil 下改 IROM1 的起始地址为 0x3000、Size 改成 0xC800;IAR 下改 icf 里 ROM 的 start 和 size;GCC 改 ld 的 MEMORY 段。三者的本质是同一件事:告诉链接器,这段代码将来会被放在 0x3000 开始的位置,所有绝对地址跳转都按这个基址算。
/* app.ld —— GCC 版本,起始地址必须和 bootloader 里的 APP_BASE_ADDR 一致 */ MEMORY { FLASH (rx) : ORIGIN = 0x00003000, LENGTH = 50K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 8K } SECTIONS { .isr_vector : { KEEP(*(.isr_vector)) } > FLASH /* 向量表不能被裁掉 */ .text : { *(.text*) *(.rodata*) } > FLASH .data : { *(.data*) } > RAM AT > FLASH .bss : { *(.bss*) *(COMMON) } > RAM }KEEP(*(.isr_vector))这一句是必须的。开了-ffunction-sections -Wl,--gc-sections之后,链接器觉得向量表里那些函数没人显式调用,会把整个表当垃圾回收掉,编出来的 bin 前 192 字节全是空洞,bootloader 跳过去直接跑飞。这个问题在 Keil 下表现为“分散加载文件里没写向量表的固定位置”,IAR 下表现为 icf 里少了place at address mem:0x3000 { readonly section .intvec }。
4.2 main 开头的两行:VTOR 和版本信息
App 的第一条业务代码之前,必须把向量表重定位到 0x3000。M0+ 的 VTOR 寄存器是可写的,但复位后默认是 0,也就是指向 bootloader 的向量表。不设的话,App 里所有中断都会跳到 bootloader 的 handler 上去,现象是串口收不到数据、定时器不进中断、看门狗喂不上。
int main(void) { SCB->VTOR = 0x00003000u; /* 必须在任何中断使能之前 */ SystemCoreClockUpdate(); board_init(); /* 时钟、GPIO、串口 */ uart_send_version(); /* 回应上位机的 0x01 握手 */ upg_clear_boot_count(); /* 启动成功,清掉回滚计数 */ app_main(); }upg_clear_boot_count放在初始化的最后、业务主循环之前比较合适。放太早,外设初始化失败也会被当成启动成功,回滚机制就失效了;放太晚,业务逻辑卡住不返回,同样清不掉。我一般在外设初始化完成、第一帧数据发出去之后再清,这样能覆盖绝大多数启动失败场景。
4.3 触发升级:写信息页标志再复位,别直接跳函数
很多人的第一反应是在 App 里直接调用 bootloader 的跳转函数,省一次复位。这么做的坑在于:App 已经初始化了一堆外设、开了中断、占了栈空间,直接跳过去 bootloader 是在一个“脏”环境里跑,串口可能收不到数据,Flash 操作时可能有中断插进来。正确做法是把请求写进信息页,然后NVIC_SystemReset(),让芯片走一次完整的复位流程。
void request_upgrade(void) { upg_info_t info; flash_read(INFO_PAGE_ADDR, (uint8_t *)&info, sizeof(info)); info.state = ST_IDLE; /* 标记为待升级,bootloader 会进升级模式 */ info.magic = UPG_MAGIC; flash_write_info(&info); /* 单扇区擦写,内部已带关中断和回读 */ uart_send_ack(CMD_ENTER_UPGRADE); delay_ms(50); /* 等最后一个字节发完,别用 while 死等 */ NVIC_SystemReset(); }delay_ms(50)是给串口发送移位寄存器留时间,如果上位机是靠“收到应答后开始发固件”来同步的,复位太快会导致应答只发了一半。有独立看门狗的话,复位前记得喂一次,否则可能复位到一半被看门狗打断,虽然最终还是会复位,但时序会变得不可预测。
4.4 打包脚本:bin 加头部、算 CRC、生成升级包
上位机拿到的不能是裸 bin,得带长度和 CRC。我用一个十几行的 Python 脚本做转换,输出格式和信息页结构对应,现场人员只需要选文件、点开始,不用关心内部细节。
import struct, zlib, sys def pack(src, dst, version): data = open(src, 'rb').read() assert len(data) <= 50 * 1024, "固件超过 50KB,检查链接脚本" crc = zlib.crc32(data) & 0xFFFFFFFF # 32 字节头:magic, version, length, crc32, state, 其余填 0 header = struct.pack('<IIIIB11x', 0x4843324C, version, len(data), crc, 0x5A) open(dst, 'wb').write(header + data) print(f"packed {len(data)} bytes, crc=0x{crc:08X}") if __name__ == '__main__': pack(sys.argv[1], sys.argv[2], int(sys.argv[3], 16))struct.pack里用了小端格式<,和 M0+ 的字节序一致,信息页可以直接按结构体读。assert那行是最后一道防线,固件超过 50KB 在链接阶段就该报错,但如果有人手动改了 ld 文件,这里能拦住。生成出来的包直接喂给上位机,上位机不需要再算 CRC,只管按 512 字节切包发就行。
5. 联调避坑:五个把设备变砖的瞬间
这套东西在实验室跑通只要一天,在现场稳定要两周。下面五条都是我在实际项目里踩过的,现象、原因、解决办法按原样记下来,遇到类似症状可以先照着排查。
5.1 擦除时看门狗咬人,App 区被擦了一半
现象:升级进度到 60% 左右设备突然重启,再连上去发现 App 区前 20KB 是 0xFF、后面还是旧固件,整机变砖。原因:bootloader 里没关独立看门狗,单扇区擦除在低温下要 40ms 以上,连续擦 40 个扇区超过 1.6 秒,看门狗 1 秒就复位了。解决办法有两个,一是在进入升级模式时先关狗、升级完再开;二是每擦 8 个扇区喂一次狗,把擦除拆成小批。我倾向前者,bootloader 里本来就不跑业务,关狗风险可控,但要在协议里加一条“升级超时 30 秒自动复位”的兜底,防止卡死在擦除循环里出不来。
5.2 升级后第一次正常,第二次上电死在 HardFault
现象:升级完能跑,断电再上电就死,接仿真器看到 PC 停在 0x00000000 附近。原因:App 的main里设了 VTOR,但SystemInit在main之前执行,中间这段时间如果 SysTick 已经使能,中断会跳回 bootloader 的向量表;bootloader 那边跳转前虽然设过 VTOR,但 App 的启动文件又把 SCB 相关寄存器复位了。解决办法是两边都设——bootloader 跳转前设一次,App 的SystemInit末尾再设一次,宁可重复也不要漏。这个坑很隐蔽,因为仿真器全速跑的时候往往看不出来,只有断电冷启动才复现。
5.3 空片上电直接死循环,串口没反应
现象:新板子第一次上电,电流正常但串口一条日志都没有,像是跑飞了。原因:Flash 全 0xFF,bootloader 读出来的“栈顶”是 0xFFFFFFFF,跳转函数没做合法性判断,直接__set_MSP(0xFFFFFFFF),下一条指令就进 HardFault,HardFault 的向量也在 0xFFFFFFFF,形成死循环。解决办法就是 3.2 节里那段地址判断,栈顶必须落在 0x20000000 段内、复位向量必须落在 App 区。加完之后空片设备会老老实实停在升级模式,上位机一连就能识别。
5.4 485 半双工方向切换太慢,偶发丢包
现象:升级过程总是传到 30%~70% 之间失败,重传三次能过,但升级时间翻倍。原因:驱动 DE 引脚拉低的时机用的是 TXE 标志,TXE 只表示发送数据寄存器空了,最后一个字节还在移位寄存器里没发完,这时候切方向会把最后一个字节截断。解决办法是等 TC 标志再拉低 DE,或者干脆在发送完成后延时一个字节时间。另外接收端也要注意,DE 拉低到使能接收之间留 1~2 个比特的余量,否则第一字节的起始位会丢。
5.5 链接长度写错,升级把校准参数覆盖了
现象:升级成功,但设备的校准系数全变成 0xFF,采样值全飘。原因:App 的链接脚本里 LENGTH 写成了 52K,编译出来的 bin 正好 51KB,越过了 0xF800 的信息页边界,把参数区擦了。链接器不会报错,因为 52K 在 64KB 里是合法范围,它不知道你在 0xF800 还放了东西。解决办法是在 ld 里显式留出信息页和参数区,把 FLASH 的 LENGTH 卡死在 0xC800,同时在打包脚本里加长度断言。改完之后还要在 CI 里跑一次“编译产物大小检查”,超过就红灯,别等现场发现。
6. 二次开发:从“能升级”到“敢批量推”
单台设备能升级,和一千台设备敢不敢一起推,是两码事。我后面做的增强主要围绕三件事:包小一点、失败能退、别人拿不到固件。
包小一点,最直接的是块级差分。bsdiff 这类算法在 M0+ 上跑不动,但按 512 字节扇区做哈希比对、只传变化的页,实现起来只有几十行代码:上位机把新旧固件按扇区切块,逐块算 CRC16,把相同的块标 0、不同的块连着数据发下去,设备端按索引写入。实际项目里改一个 bug 通常只动几百字节,50KB 的包能压到 3KB 以内,4G 流量和升级时间都省一个数量级。
失败能退,靠的是boot_count。App 启动后清零,bootloader 每次上电先加一,加到 3 还没被清掉就判定新固件有问题,把状态字改回ST_INVALID,停在升级模式等重推。这个机制的前提是信息页写入足够可靠,所以每次改状态都要回读确认,写失败就重试一次,还失败就放弃、保持原状态。
别人拿不到固件,最省事的做法是镜像签名而不是加密。M0+ 上跑 AES 解密 50KB 大概要几百毫秒,还占 1KB 多 RAM;换成 HMAC-SHA256 只校验头部和整包摘要,计算量小得多,效果是“没密钥的人改不了包、也造不出合法包”。密钥烧在信息页的保留区,出厂时写入,bootloader 校验时读出来用。真要防逆向,那得靠芯片本身的读保护,软件层面做到这一步就够了。
| 增强项 | 实现成本 | 收益 | 适用场景 |
|---|---|---|---|
| 块级差分 | 中,约 200 行 | 包体积降到 10% 以下 | 流量敏感、升级频繁 |
| 启动计数回滚 | 低,信息页加一个字节 | 新固件起不来能自动退 | 所有远程升级场景 |
| 镜像签名 | 中,需要上位机配合 | 防止伪造包 | 对外发货、渠道分散 |
从那以后我每次改 Flash 分区或者链接脚本,都强制先跑一遍“断电十次”的暴力测试:升级到随机进度直接拔电,再上电看设备是不是还活着、能不能重新升级。十次里只要有一次进不了升级模式,就说明状态机还有漏洞,宁可回去改代码也不放行。这套流程听着笨,但比在现场被客户追着问“设备怎么全黑了”要轻松得多。希望帮到你。
本文还有配套的精品资源,点击获取