说到嵌入式固件升级,我第一反应不是OTA、不是Bootloader、不是差分包,而是那次在客户现场蹲了三天两夜查问题的经历。设备升级后随机性变砖,不是每台都死,而是看心情死。后来定位到是升级中断电导致Flash只写了一半,而旧的App区已经被擦掉了——典型的没有做回滚保护的翻车现场。
固件升级是嵌入式开发里看起来最简单、水最深的一个模块。表面上是把新二进制写入Flash然后重启,实际上涉及存储布局、通信协议、校验算法、异常恢复、安全防护一整套东西。这篇文章我把这个模块拆开讲透,从方案选型到核心代码到排障思路都过一遍,希望能帮你少踩几个坑。适合正在做Bootloader、OTA功能或者想梳理升级流程的开发者参考。
1. 升级方案怎么选:先想清楚再动手
1.1 本地升级还是远程升级
固件升级第一步不是写代码,是想清楚用哪种升级通道。通道选错了,后面全得推翻重来。
本地升级是最基础的形式,通过UART、USB、SPI、I2C、JTAG/SWD这类物理接口把固件传进设备。典型的场景是产线烧录、售后维修、实验室调试。优点是没有网络依赖,协议简单,开发工作量小;缺点你是懂的,得有人跑到现场插线操作,效率极低,设备一旦大规模铺出去,靠本地升级能跑断腿。
远程升级(OTA)这几年几乎是标配了,尤其IoT设备、车载设备、医疗仪器、工业控制器都在上。OTA的通道通常是Wi-Fi、以太网、LoRa、NB-IoT、4G/5G模组,甚至走BLE和ZigBee也有。OTA的意义不只是省了人工,更重要的是能快速修漏洞、推新功能、应对监管合规要求,有些产品本身就是靠不断迭代功能活着的。
我的建议是,哪怕你当前只做本地升级,代码架构也要按OTA的规格去设计。别以为多写几行抽象层是浪费功夫,后面产品一改需求要加OTA时,你会发现当初留的余地救了大命。我在一个项目里见过,原来只做串口升级,代码写死了串口收发逻辑,后来要接Cat.1模组做远程升级,结果整个Flash读写模块都耦合在串口驱动里,改了两周才解耦干净。
1.2 分区规划是最容易返工的部分
Flash分区是整个升级系统里最容易被低估的环节。很多新手上来就一把梭,Bootloader放首地址,App紧接着放,然后呢?没有备份区、没有参数区、没有版本标识区。第一次升级测试就发现问题:升级中途失败,设备起不来了。
常见的分区方案有两种:单分区方案和双分区(A/B)方案。
单分区方案是App就一块区域,新固件直接覆盖写进去,Bootloader只负责跳转。省Flash空间,剩下多少容量都归App用。缺点是升级风险大:写入过程中断电或者固件包损坏,旧固件可能已经被擦掉一半,设备直接变砖。只能再靠Bootloader去等一个新固件重新刷,但前提是Bootloader里的恢复逻辑得够健壮。
A/B双分区方案简单说就是Flash里放两份App镜像,一个活跃分区A,一个备份分区B。升级时往B写新固件,写完整校验通过后就切换分区表、重启,让Bootloader从B启动。如果启动失败或者新固件自检不通过,还能回退到A。代价是要大约两倍的App存储空间,但对很多产品来说,这个成本换可靠性和可回滚能力,非常划算。
还有一种折中方案是把App区拆成两个子区域:App1区放当前运行版本,App2区放新版本。这本质上也是A/B。在Flash空间允许的情况下,我基本都推荐A/B,尤其是做车规、医疗、工控这些不允许长时间停机的场景。消费类小设备Flash抠得紧,那就老老实实用单分区方案,但必须配回滚标志位和救援模式。
参数区(或者叫NV区)也别乱放。升级标志位、当前固件版本号、回滚次数、升级结果记录这些关键数据需要独立的参数区,不能放进App的数据段里。因为App一更新,数据段的地址和内容都可能变,你存的东西可能直接被冲掉。参数区要单独划一块Flash扇区,用掉电安全的写入策略,比如双槽位交替写、带CRC校验,防止写入参数过程中掉电导致参数损坏。
1.3 Bootloader里那些无声的职责
Bootloader是固件升级里的守门员,但它的职责远不止“跳转到App”这么简单。
我理解Bootloader的核心职责有四块:
检查升级请求。设备上电时,Bootloader要判断是否存在一次完整的升级请求。这个判断依据可能是参数区的标志位、外部引脚电平组合、通信接口收到的命令,或者是App区首地址的校验结果。原则是宁可多判断几层,也别只信单一标志。
校验固件合法性。对即将启动的App区域做完整的CRC校验或者签名校验,确认这块区域里是一个完整、有效、没被截断的固件。很多生产事故就是启动时没做校验,Flash里不知道什么时候写坏了,程序跑飞了还不知道为什么。
执行固件搬移(如果是先暂存后搬运的方案)。有些Bootloader接收固件时是先存到外部Flash或者RAM暂存区,启动前再拷到App区。这个搬移过程需要Bootloader自己完成,要做校验、擦写、搬运三步。
管理回滚和恢复。当新固件校验失败或者App启动后快速崩溃(比如看门狗在短时间内没有被喂),Bootloader要能识别到异常,回滚到上一个可用的固件,或者至少进入一个可恢复的下载模式,而不是死在那里。
Bootloader本身也不能写太复杂。它越简单越好,因为Bootloader一旦写出自己的bug,连带App都救不回来,就只有靠烧录器硬刷了。我的经验是Bootloader里不放RTOS、不放文件系统、不放复杂的协议栈,裸金属逻辑最好,甚至中断也尽可能少用。它最重要的品质是可靠,而不是花哨。
2. 固件包设计:格式、校验与安全
2.1 固件包结构设计
固件升级本质上就是一个文件的可靠投递问题,但这个文件不是普通文件,它是要写到微控制器Flash里、直接决定系统行为的二进制代码。所以固件包的结构设计必须既紧凑又足够信息完备。
我常用的固件包格式是“头部+数据+尾部校验”三段结构。
头部字段大概长这样:
typedef struct { uint32_t magic; // 魔数,比如 0xA5A5A5A5,用于快速识别固件包 uint32_t header_crc; // 头部自身的CRC,防止头部字段被改坏 uint32_t version_major; // 主版本号 uint32_t version_minor; // 次版本号 uint32_t build_number; // 构建号,由CI自动递增 uint32_t hardware_ver; // 适用硬件版本,防止刷错硬件 uint32_t app_size; // 固件数据长度 uint32_t image_crc; // 整个固件数据的CRC,这个很关键 uint32_t timestamp; // 编译时间戳,辅助排查 uint32_t reserved[3]; // 预留字段 } firmware_header_t;头部里最容易被忽略的是hardware_ver。我吃过一次亏:同一款板子有V1.1和V1.2两个硬件版本,引脚变了,GPIO初始化完全不同,结果有人把V1.1的固件刷到V1.2的板子上,屏幕亮不起来,还找不到原因。从那以后我在固件包里永远带上硬件版本号,升级前Bootloader先比对硬件兼容性,不匹配直接拒绝升级。
魔数也很重要。它让Bootloader能在极低成本下判断一块Flash区域看起来像不像固件。如果魔数不对,根本不用往下做CRC,直接当作“没有有效固件”处理。
固件包的数据段有两种组织方式:一种是纯粹的原始二进制镜像(raw binary),适合直接从固定地址加载的场景;另一种是带块索引表的格式(类似文件系统),每块有独立CRC和地址信息,适合支持差分升级、断点续传或者按块写入Flash的场景。前者简单、机动,后者灵活、可扩展。我的建议是项目早期用raw binary跑通全流程,等确实要加断点续传、差分包这些高级特性时再重构也不迟。
2.2 校验强度怎么选
固件包拿到手之后,先校验再写入,这是铁律。但校验方式有很多种,选哪种取决于你的场景和吓唬谁的。
CRC32是最基础的完整性校验。它防的是数据在传输中被改坏、被截断、被错位,但对恶意篡改毫无防御力。随便改掉一个字节然后重新算CRC并不难。所以CRC32适合不联网、本地升级、威胁模型低的场景。但即使这样,CRC32也比不做强一万倍。很多变砖问题就是“校验缺失+传输带错”的组合拳造成的。
SHA256类的摘要校验,防的是偶然性损坏的加强版。它的碰撞概率极低,理论上很难出现两份不同数据哈希成同样值的情况,所以能更可靠地识别数据完整性。但SHA256同样不防恶意篡改,因为攻击者可以同时改数据和摘要。
签名校验(RSA、ECDSA)才是真正防篡改的。固件发布方用私钥对固件包(通常是对固件的hash值)签名,Bootloader里预置对应的公钥,校验时用公钥验证签名是否匹配。签名验证通过,意味着这份固件确实来自持有私钥的发布方,且在签名之后没有被改过哪怕一个字节。车规、金融终端、智能门锁这类安全敏感场景,签名校验已经成了标配。
我个人对安全等级的建议是:
| 场景 | 推荐校验方案 |
|---|---|
| 产线本地烧录 | 依赖烧录工具校验即可,Bootloader做CRC32 |
| 非联网OTA | 至少SHA256,预算够就上ECDSA签名 |
| 联网设备OTA | 必须签名校验,建议ECDSA P-256 |
| 金融/车规/安防 | 签名校验+安全启动链+硬件安全单元 |
校验放在哪个环节做也重要。我见过有的团队只在接收固件包时做一次CRC,然后写入Flash后不做校验——照样出问题。Flash擦写过程本身可能出错,尤其电压不稳、温度异常时。所以强烈建议在固件写入Flash完成之后,再从Flash里读出整段数据进行一次完整校验(或者校验每个写入块),确认Flash里的内容与固件包一致,再更新启动标志。这是最后一道闸,不该省。
2.3 升级标志与状态管理
升级过程中需要持久化一些状态,让系统在各种异常情况下都能知道自己该往哪儿走。这个状态不是每次想当然就行,要设计成一套清晰的有限状态机。
我先给状态定义:
typedef enum { OTA_STATE_IDLE = 0x00, // 空闲状态,无升级请求 OTA_STATE_DOWNLOADING = 0x01, // 正在接收固件数据 OTA_STATE_STAGING = 0x02, // 固件暂存完成,待搬移 OTA_STATE_VERIFYING = 0x03, // 正在校验 OTA_STATE_READY = 0x04, // 升级就绪,等待重启进入Bootloader OTA_STATE_UPDATING = 0x05, // Bootloader正在写Flash OTA_STATE_ROLLBACK = 0x06, // 回滚中 OTA_STATE_COMPLETED = 0x07, // 升级完成 } ota_state_t;参数区里至少存:当前状态、目标版本号、升级尝试次数、回滚次数。
升级标志位的设计有两个关键点:
标志位的变更不能“一步到位”式地只写一个值。因为写Flash操作本身可能被打断,你永远不知道跳电瞬间标志位处于什么值。经典做法是写两到三个冗余槽位,每个槽位带CRC或反转校验,读的时候多数表决。
状态转移要具备自恢复能力。也就是说,任何一次上电,Bootloader拿到一个中间状态时(比如DOWNLOADING),应该能自己判断出:“哦,上次升级没完成,那我应该清掉这些中间痕迹,回到空闲状态,或者重新从备份区启动。”系统必须做到任何中间状态都能被合规处理,而不是只能处理几个预设好的完美路径。
升级计数防回滚也常被忽略。有些场景(尤其安全固件)要求不能允许设备从新版本回滚到旧版本,那就要在参数区记录“最低允许版本号”,每次升级前检查新版本号不低于这个值。但这个功能要看产品需求,不是所有设备都需要。
3. 核心流程实现:从接收数据到跳转执行的完整解剖
3.1 固件数据的接收与暂存
固件包的传输方式五花八门,但底层逻辑就两个:边收边写Flash,或者先收完整包再写Flash。
边收边写的优点是内存占用小,尤其MCU的RAM通常只有几十KB到几百KB,你不可能把几百KB的固件全塞进RAM。但边收边写也有大坑:如果传输中断(无线丢包断连、串口断线),你可能已经把部分数据写进了Flash,破坏了旧固件,新固件又没写完。所以边收边写必须配合分区规划里的“双Bank方案”——写到不用的那个区,别碰正在运行的区。
先收完整包再写Flash的方案简单得多,安全得多,但要额外的存储介质。通常是用一颗外部SPI NOR Flash,容量几MB级别,先把几百KB的固件包完整存进去,做一次校验,确认没错,再拷进内部Flash的App区。这个方案的容错性最好,不管传输过程怎么折腾,只要没传到完,内部Flash的旧固件就安然无恙。
我个人做OTA时最常用的组合是:
- 通过通信接口把固件包分块接收到外部Flash的暂存区。
- 接收完毕后对完整的暂存区数据做SHA256或签名校验。
- 校验通过后,把外部Flash里的固件数据按扇区搬到内部Flash的App区。
- App区写入完成后,从App区读回数据进行完整性校验。
- 校验通过,更新升级状态标志,重启进入Bootloader切换启动目标。
分块接收时要注意每块的编号和偏移管理。我做的时候会在固件包开头加一个总长度字段,然后接收端用块索引表去校准。每块数据都带序号和长度,接收端按照序号写入暂存区对应的偏移处。这样就算传输过程中中间丢了几块,也能精准地补传那几块,不用整包重来。
3.2 Flash擦写操作的正确姿势
Flash操作是整个升级系统里最容易翻车的地方,没有之一。我见过太多次把Flash操作当普通内存读写来用,结果系统莫名其妙跑飞。
嵌入式Flash的基本特性要记牢:
- 写之前必须先擦除,擦除是按扇区/块为单位进行的,不能只擦一个字节。
- 擦除后的Flash内容是全0xFF。
- 写入操作通常只能把1写成0,不能把0写成1。所以你要改一个已经写过0的字节,就必须整块擦掉重写。
- 擦除操作耗时较长,典型NOR Flash一个扇区(比如4KB)擦除要几十毫秒到几百毫秒,整片更久。这个时间窗口内系统如果跑飞或掉电,Flash内容可能处于未知状态。
正确擦写一个扇区的流程:
bool flash_write_sector(uint32_t dst_addr, const uint8_t *src, uint32_t len) { uint32_t sector_base = dst_addr & ~(SECTOR_SIZE - 1); uint32_t end_addr = dst_addr + len; // 1. 检查长度不能跨扇区,或者做跨扇区分片处理 if (end_addr > sector_base + SECTOR_SIZE) { return flash_write_sectors(dst_addr, src, len); // 分多个扇区处理 } // 2. 写之前先擦除整个扇区 flash_erase_sector(sector_base); // 3. 逐字或逐半字写入 for (uint32_t offset = 0; offset < len; offset += sizeof(uint32_t)) { uint32_t word; memcpy(&word, src + offset, sizeof(uint32_t)); flash_program_word(dst_addr + offset, word); } // 4. 读回校验 if (memcmp((void *)dst_addr, src, len) != 0) { return false; } return true; }写代码时的几个关键注意点:
在擦写Flash操作期间,CPU不能从同一块Flash取指令执行。如果你正在执行代码的Flash区域正好被擦除,CPU会跑去读0xFFFFFFFF然后执行非法指令,直接HardFault。解决方法是:把擦写Flash的代码放在RAM里执行,或者确保擦写的目标区域与当前代码执行区不在同一个Flash阵列(比如内部Flash和外部Flash交叉操作)。
擦写期间必须关中断,或者至少关掉那些会导致嵌套访问Flash的中断。中断服务函数如果尝试访问正在被擦写区域的代码或数据,同样会出问题。我在做低功耗设备时还踩过坑:擦写Flash到一半,看门狗超时复位了,然后重启后Bootloader发现标志位状态不对,卡死在恢复逻辑里。
不能边擦写边喂狗。擦写一个扇区可能几百毫秒,看门狗一般也就几秒甚至几百毫秒。如果擦写被看门狗打断复位,Flash坏掉的概率极高。我的做法是如果在任务上下文里做升级,就先暂停或者延长看门狗超时时间;如果是裸机在main循环里做,考虑在擦写前喂一次狗,如果擦写时间太长就分扇区擦写,每个扇区间隙喂狗。
3.3 升级完成后如何安全跳转
固件写入完成,校验通过,还要安全地把控制权从Bootloader转到App。这里有几个细节处理不好,就会造成“明明固件写对了,但起不来”的诡异现象。
跳转的核心代码大家可能都写过:
void jump_to_app(uint32_t app_base) { uint32_t app_initial_sp = *(volatile uint32_t *)app_base; uint32_t app_reset_vector = *(volatile uint32_t *)(app_base + 4); // 先关中断、关SysTick,清理外设状态 __disable_irq(); SysTick->CTRL = 0; // 设置主栈指针 __set_MSP(app_initial_sp); // 跳转到App的复位向量 void (*app_entry)(void) = (void (*)(void))app_reset_vector; app_entry(); }但这里有三个必须注意的坑:
跳转前必须确认App的向量表首字是不是有效的栈顶地址。ARM Cortex-M系列的栈顶地址必须落在RAM范围内,不然上电后第一件事就访问非法地址。加个判断:
if (app_initial_sp < RAM_START || app_initial_sp > RAM_END) return;比什么都不检查就傻跳靠谱得多。中断向量表的重定位。App里如果改了向量表偏移(通过SCB->VTOR设置),那还好说。但如果App编译时不知道会被加载到哪个地址,而是由Bootloader跳转进去,那App里的向量表从0x00000000开始,跟实际的加载地址不匹配,任何中断触发都会跑飞。解决方案是要么App在启动早期就按实际加载地址设置VTOR,要么编译时直接用链接脚本把向量表放到正确地址,要么Bootloader在跳转前就给SCB->VTOR赋上新地址。这个坑特别隐蔽,症状是“能跑起来但一按按键就死机”。
跳转前要把全局中断和所有外设中断清理干净。Bootloader可能使能了串口中断、定时器中断、DMA,跳转后App还没有初始化这些外设,中断一来直接就卡死。所以跳转前最好执行一次“强制复位外设”的动作,把相关外设的时钟和中断全部关掉。
3.4 失败回滚与掉电保护
回滚机制做得好不好,直接决定你凌晨三点被电话叫醒的概率。
回滚的触发条件一般是:新固件连续启动失败N次(这个N通过参数区记录),或者App启动后在指定时间内没有完成“自我声明”流程(比如健康检查、外设自检通过后写一个状态位)。Bootloader每次上电时检查上一次启动是否成功,不成功就触发回滚。
A/B双分区方案做回滚相对优雅,把启动目标切回备份区就行。但单分区方案做回滚就麻烦多了,因为旧固件可能已经被覆盖。这就是为什么很多单分区设备只能做“救援模式”:保留一个最小的Bootloader固件下载模式,手机器重新刷。所以我再次强烈建议,在做产品原型前先评估Flash容量够不够A/B分区,别等到量产了才补。
掉电保护也是必须的设计。升级过程中可能随时掉电,系统要能优雅处理。两个层面:
第一,硬件层面最好有掉电检测引脚(Power Loss Detector),在电源电压跌落到危险值之前先触发一个中断,系统抓紧把关键的升级状态标志写进参数区,标记“当前正在升级中断”,下次上电时Bootloader看到这个标记就进入恢复逻辑。
第二,软件层面要保证任何时刻掉电都不会让设备永久变砖。这就是前面说的“先写不用的区域、写完再切换标志位”的合法性来源。关键点在于:标志位的更新必须在固件数据完全写入且校验通过之后,不能在写数据之前就改写启动标志。
4. 真实工程里的坑与排查技巧
4.1 升级失败十大常见原因
我把这些年见过的升级故障做了一个速查表,按出现频率排序:
| 序号 | 现象 | 常见根因 |
|---|---|---|
| 1 | 升级后设备不启动 | 固件校验未做或App区写入不完整 |
| 2 | 升级中途断电变砖 | 无回滚设计,直接覆盖活跃分区 |
| 3 | 同版本固件反复提示需要升级 | 版本号比较逻辑写反,或参数区版本没持久化 |
| 4 | 升级后外设工作异常 | 跳转前外设中断未清理干净 |
| 5 | 串口升级经常中途失败 | 串口流控没处理,接收缓冲区溢出丢包 |
| 6 | Flash写入后校验失败 | Flash擦写操作受中断干扰,或电压跌落 |
| 7 | OTA下载很快但一直校验不通过 | 传输层分帧重组逻辑有bug,固件包错位 |
| 8 | 回滚没生效 | 启动失败计数逻辑写错,没记录到参数区 |
| 9 | 升级后App跑飞 | 向量表偏移没设置正确 |
| 10 | 频繁升级后Flash/参数区损坏 | 擦写次数过多,磨损均衡没做 |
这些坑里我最想单独拎出来说的是第三个:同版本反复提示升级。表面现象是设备提示有新版本,升级完重启后还是提示有新版本。排查到最后发现,App在启动时把“当前版本号”读出来去和固件包里的版本号比较,但读取的地址指向的是Bootloader写的一个临时变量,而不是从App头部读的真实版本信息。两边版本号永远对不上,于是设备永远觉得自己是旧版本。这个问题的本质是:版本号的持久化存储定义不清晰。要明确版本信息到底存哪里,是App头部?参数区?还是编译常量?一份代码里只能有一个权威来源。
4.2 排查固件升级问题的思考路径
排查升级问题,思路比工具重要。我梳理一下自己遇到问题时常用的几个步骤:
第一步,先确认“固件包本身有没有问题”。把同一个固件包放到PC端工具里重新计算CRC和版本号,对照设备里读到的数值。如果固件包在主机端就已经不对,那问题大概率在编译打包流程,而不是设备端。
第二步,看设备有没有进入升级模式。很多设备的Bootloader支持强制进入下载模式(比如按住某个键上电、特定引脚拉高)。如果设备完全起不来,先确认Bootloader是否还活着。活着,就能用下载模式重新刷;死了,那就只能开壳烧录器伺候了。
第三步,打开调试日志,能打印串口日志的案例,把升级过程中的关键节点全部打出来:收到多少字节、写入哪个扇区、每扇区写入结果、校验值计算过程、状态机切换顺序。这一步能排除大量“我以为它走到了B步骤,其实它卡在A步骤”的问题。
第四步,检查参数区的状态。参数区里残留的升级状态和计数是宝贵线索。比如“升级尝试次数”一直在增长,说明每次升级都在写到一半的时候出了状况;比如标志位是DOWNLOADING状态但数据区的Magic不符,说明上一次传输根本没完成完整。
4.3 现场运维时的升级实操经验
最后一个部分,聊点更贴近地面的事情:产品已经量产了,设备挂在现场,你远程升级时该怎么操作才能让自己晚上睡得着觉。
分批灰度,先小规模验证。除非是修复高危漏洞,否则不要全量推送。我的习惯是先选出1%或者一小批典型设备升级,观察48小时运行数据,没事再逐步放大比例。这能拦住那种“测试环境正常、生产环境翻车”的典型问题。
升级前固件包做强校验。自动构建系统里,固件打包后立刻计算SHA256,上传到服务器时附带校验值,分发层校验,设备端再校验。任何一环不匹配,直接中止流程,不要尝试“反正能跑”的侥幸方案。
升级过程要支持进度上报。这既是为了让用户看到“还在走”,也是为了让后台知道升级卡在了哪一步。做到级别:当前状态、接收百分比、校验结果、重启时间、新固件启动结果。这些信息汇总到后台,比什么都管用。
升级操作窗口要选准。电池供电的设备,电量低于阈值不要允许升级;正在执行关键任务的设备,任务完成后再升级;有通信链路的设备,信号强度太差不升级。这些都是血泪教训换来的约束条件。
保留一个最稳的手动恢复通道。不管OTA做得多灵光,永远保留一个物理接触式的恢复手段,比如串口升级、烧录器接口。不是所有问题都能远程解决,承认这一点反而会让你在设计OTA方案时更谨慎,也更安全。
回到开头说的那个现场查了三天的案例。那次之后,我把升级模块整体重写了——加了双分区、加了掉电检测、加了启动失败计数和回滚机制。从那以后,同一产品线再没出现过一次升级导致的返修。这个教训值多少钱不好说,但我知道,如果一开始就想清楚“任何时刻都可能断电”这件事,那三天的蹲守完全可以避免。做固件升级,本质上不是写代码,而是对抗不确定性。你每多设一道校验、多留一条退路,深夜电话的铃声响起的概率就小一点。