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这块,我建议用非对称签名。为什么?因为对称密钥一旦泄露,整个升级体系就废了。

具体做法:

  1. 云端用私钥对镜像的哈希值签名
  2. ECU里预置公钥
  3. 升级时,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区,写完了再切换。

具体流程:

  1. Bootloader从Backup区启动
  2. 下载新固件到Backup区
  3. 校验通过后,设置启动标志
  4. 重启,从Backup区启动
  5. 如果启动失败,回滚到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)时,触发一个中断。

中断处理函数里做什么?

  1. 立即停止Flash擦写操作
  2. 保存当前升级状态
  3. 关闭所有外设
  4. 进入低功耗模式

这里有个细节:从检测到掉电到完全断电,一般只有几毫秒的时间。所以中断处理函数必须非常精简,不能做任何耗时操作。我见过有人想在掉电中断里写日志,结果日志还没写完,电就断了,反而把Flash搞坏了。

嗯,说到这儿,我想起一个真实案例。有个项目,升级过程中突然断电,结果ECU再上电就起不来了。查了半天,发现是Bootloader在掉电中断里尝试写Flash,结果写到一半没电了,把Bootloader自己的代码区给写坏了。从那以后,我定了个规矩:掉电中断里只做状态保存,绝不碰Flash操作

好了,固件升级原理这块,核心就是这三个点。镜像结构是基础,Flash驱动是手段,掉电保护是底线。把这三点吃透了,OTA架构的底层你就稳了。下一章咱们聊聊升级策略和回滚机制,那个更烧脑,但也更有意思。