3、固件升级原理:固件镜像结构、Flash驱动与擦写算法、掉电保护机制
好,咱们今天聊点硬核的。固件升级这事儿,说白了就是给ECU换脑子。你想想看,一辆车跑在路上,ECU里跑着几十万行代码,突然告诉你得更新——这活儿要是干砸了,车可就趴窝了。
我个人习惯把固件升级拆成三个核心问题:镜像长什么样?怎么写到Flash里?写到一半断电怎么办? 这三个问题搞明白了,OTA架构的底层逻辑你就拿捏住了。
3.1 固件镜像结构:Header、Body、Signature
先说说镜像结构。我见过不少新手工程师,上来就写升级逻辑,结果镜像格式没定义好,后面兼容性搞得一塌糊涂。其实一个标准的固件镜像,就三部分:头部(Header)、主体(Body)、签名(Signature)。
3.1.1 Header:镜像的身份证
Header里放什么?我一般会放这些信息:
- 魔数(Magic Number):用来快速判断是不是合法的镜像文件。比如0xAA55,或者自定义的"FOTA"字符串。
- 版本号:主版本+次版本+修订号。我在项目里吃过亏,版本号没规划好,导致回滚逻辑写得很痛苦。
- 镜像长度:整个镜像的字节数,用于校验完整性。
- 目标ECU ID:防止把A车的固件刷到B车上。嗯,这个真有人干过。
- 校验算法标识:告诉Bootloader用哪种哈希算法验证。
这里有个小细节——Header本身也要有校验。我习惯在Header末尾加一个CRC32,Bootloader先读Header,校验通过才继续往下走。否则Header坏了,后面全是瞎忙活。
3.1.2 Body:真正的代码和数据
Body就是编译出来的二进制文件。但这里有个坑:编译出来的镜像往往不是连续的。比如你代码段在0x08000000,数据段在0x20000000,中间可能还有空洞。
我建议的做法是:用分段描述符(Segment Descriptor)来组织Body。每个分段描述符包含:
- 目标地址(Target Address)
- 数据长度(Length)
- 数据内容(Data)
这样Bootloader就能按段解析,一段一段地写到Flash里。我在一个项目里见过直接把整个bin文件当Body的,结果Flash地址空间不连续,写一半就崩了。
3.1.3 Signature:防篡改的最后一道防线
Signature这块,我建议用非对称签名。为什么?因为对称密钥一旦泄露,整个升级体系就废了。
具体做法:
- 云端用私钥对镜像的哈希值签名
- ECU里预置公钥
- 升级时,Bootloader用公钥验签
我曾经遇到过一个客户,为了省成本,签名算法用的MD5。结果呢?被中间人攻击,刷了个恶意固件进去,整个ECU直接变砖。所以,签名算法别省,至少用SHA256+RSA2048。
核心要点:镜像结构设计时,Header要轻量但完整,Body要支持分段,Signature要足够强壮。这三者缺一不可。
3.2 Flash驱动与擦写算法
Flash操作是固件升级里最磨人的部分。为什么?因为Flash有物理限制:写之前必须先擦,擦的单位是扇区(Sector),写的单位是页(Page)。而且擦写次数有限,一般10万次左右。
3.2.1 Flash驱动:底层硬功夫
Flash驱动说白了就三个函数:Erase、Program、Read。但实现起来有不少门道。
我举个例子,Erase操作:
// 伪代码:擦除一个扇区
int Flash_EraseSector(uint32_t sector_addr) {
// 1. 检查地址是否合法
if (!IS_VALID_SECTOR_ADDR(sector_addr)) return -1;
// 2. 解锁Flash控制器
FLASH->KEYR = 0x45670123;
FLASH->KEYR = 0xCDEF89AB;
// 3. 设置擦除参数
FLASH->CR |= FLASH_CR_SER; // 扇区擦除模式
FLASH->CR |= (sector_num << 3); // 选择扇区号
// 4. 启动擦除
FLASH->CR |= FLASH_CR_STRT;
// 5. 等待完成
while (FLASH->SR & FLASH_SR_BSY);
// 6. 检查错误
if (FLASH->SR & FLASH_SR_PGERR) return -2;
// 7. 锁定Flash
FLASH->CR |= FLASH_CR_LOCK;
return 0;
}
这里有个关键点:擦除期间绝对不能断电。为什么?因为擦除操作是高压写入,一旦中断,Flash单元可能处于不确定状态,下次读写就全乱了。我见过一个案例,擦除到一半断电,结果那个扇区彻底废了,整个ECU只能换芯片。
3.2.2 擦写算法:效率与安全的平衡
擦写算法,说白了就是怎么安排擦和写的顺序。我常用的策略有三种:
| 策略 | 做法 | 适用场景 |
|---|---|---|
| 全擦全写 | 先擦除所有目标扇区,再逐个写入 | Flash空间充足,升级包较小 |
| 边擦边写 | 擦一个扇区,写一个扇区 | Flash空间紧张,需要节省时间 |
| 差分擦写 | 只擦写有变化的部分 | 升级包很大,但改动很小 |
我个人比较推荐边擦边写。为什么?因为全擦全写有个致命问题:如果擦完所有扇区,但写的过程中断电了,那整个应用区都是空的,ECU直接变砖。边擦边写至少能保证上一个扇区的数据还在。
避坑指南:我曾经在一个项目里用差分擦写,结果因为地址对齐没处理好,写进去的数据全是乱的。后来我加了个地址对齐检查,每次写之前先确认目标地址是页对齐的。这个习惯我一直保留到现在。
3.3 升级过程中的掉电保护机制
掉电保护,这是OTA架构里最考验功力的地方。你想想看,车在行驶中突然升级,结果电瓶亏电了——这时候如果没保护,ECU就废了。
3.3.1 双备份策略:最稳妥的方案
双备份,说白了就是留一手。Flash里同时存两份固件:一份是当前运行的(Active),一份是待升级的(Backup)。升级时只写Backup区,写完了再切换。
具体流程:
- Bootloader从Backup区启动
- 下载新固件到Backup区
- 校验通过后,设置启动标志
- 重启,从Backup区启动
- 如果启动失败,回滚到Active区
这个方案的好处是:升级过程中断电,最多损失Backup区,Active区纹丝不动。代价是Flash空间要翻倍。不过现在车规级Flash容量越来越大,这个代价可以接受。
3.3.2 状态机与恢复点
如果Flash空间不够做双备份,那就得用状态机+恢复点的方案。说白了,就是把升级过程拆成多个原子步骤,每个步骤完成后记录一个状态。
我常用的状态定义:
- IDLE:空闲状态,没有升级任务
- DOWNLOADING:正在下载镜像
- VERIFYING:正在校验镜像
- PROGRAMMING:正在写入Flash
- COMMITTING:正在提交升级结果
每个状态切换前,先把状态值写到Flash的一个专用区域(我习惯叫它升级状态区)。断电重启后,Bootloader先读这个状态区,然后决定下一步怎么走。
举个例子:如果断电发生在PROGRAMMING状态,重启后Bootloader发现状态是PROGRAMMING,就知道上次写了一半,那就得重新擦除并重写。如果状态是COMMITTING,那就说明已经写完了,只是提交没完成,那就直接提交。
重要提醒:状态区本身也要有保护机制。我建议用冗余存储——同一个状态写三份,读取时取多数一致的那个。否则状态区被写坏了,Bootloader就不知道该怎么办了。
3.3.3 电源监测与紧急处理
掉电保护不只是被动应对,还得主动监测。我一般在硬件设计上加一个电源监测电路,当电压低于某个阈值(比如6V)时,触发一个中断。
中断处理函数里做什么?
- 立即停止Flash擦写操作
- 保存当前升级状态
- 关闭所有外设
- 进入低功耗模式
这里有个细节:从检测到掉电到完全断电,一般只有几毫秒的时间。所以中断处理函数必须非常精简,不能做任何耗时操作。我见过有人想在掉电中断里写日志,结果日志还没写完,电就断了,反而把Flash搞坏了。
嗯,说到这儿,我想起一个真实案例。有个项目,升级过程中突然断电,结果ECU再上电就起不来了。查了半天,发现是Bootloader在掉电中断里尝试写Flash,结果写到一半没电了,把Bootloader自己的代码区给写坏了。从那以后,我定了个规矩:掉电中断里只做状态保存,绝不碰Flash操作。
好了,固件升级原理这块,核心就是这三个点。镜像结构是基础,Flash驱动是手段,掉电保护是底线。把这三点吃透了,OTA架构的底层你就稳了。下一章咱们聊聊升级策略和回滚机制,那个更烧脑,但也更有意思。