嵌入式固件升级这件事,做开发板联调时往往没人当回事,程序烧进去能跑就行。等设备真正部署到现场、数量上到几百台之后,升级立刻变成一座大山:拆机、接线、插烧录器,想想就头疼。我自己就是从“拿烧录器怼板子”一路走过来的,后来在某量产项目里被逼着把升级机制从头做了一遍,踩了不少坑,也把原理摸透了。
这篇博文不讲虚的,就围绕嵌入式固件升级的完整链路来拆:Flash 分区、Bootloader 跳转、升级包校验、掉电保护、失败恢复,再到实际调试中怎么定位问题。适合正在做量产设备、远程升级功能,或者准备把升级机制集成进现有产品的工程师,也适合刚入门、对 Bootloader 和 IAP 只有模糊概念的嵌入式新手。
1. 升级功能的地基:Flash 分区与 Bootloader 启动流程
1.1 为什么不能把整个 Flash 都交给 App
很多第一次做升级功能的人会问:我的 App 就放在 0x08000000 起始地址,Bootloader 放在哪?这里有个最基本的认知——只要你想实现“在设备上直接更新 App”,Flash 就必须被划分出至少两个区域:一个放 Bootloader,一个放 App。
Bootloader 本身也是一段固件,它负责两件事:上电后判断要不要升级、以及把控制权交给 App。如果把 Bootloader 和 App 放在同一块区域,或者 App 从 0 地址开始,那么升级时一旦写坏了 App,整个 Flash 都是不可用的状态,机器直接变砖,只能拆机救。所以 Bootloader 必须放在 Flash 的起始位置,不能被 App 覆盖。
我以一个 Cortex-M 内核、Flash 总容量 512KB 的 MCU 为例,做一个最基础的三分区布局:
/* Flash 地址布局示例 */ #define ADDR_BOOTLOADER 0x08000000 /* Boot 区,64KB */ #define ADDR_APP 0x08010000 /* App 区,256KB */ #define ADDR_BACKUP 0x08050000 /* 备份区/升级缓存区,192KB */为什么 Boot 区要给 64KB?因为 Bootloader 本身只做跳转、校验和接收固件的工作,代码量一般不会太大。但你要考虑到以后可能往 Boot 里加功能,比如加密解密的完整实现、更复杂的通信协议、甚至双 Boot 的冗余,空间留足一点很必要。App 区的大小要根据当前固件体积来定,最好留出 30% 以上的余量,别卡得死死的。
1.2 Bootloaders 的启动判定到底在判什么
Boot 区上电后的执行顺序不能乱,我整理成一个简单流程:
- 初始化必要的硬件:时钟、串口/网络等通信接口、Flash 控制器。
- 读取固定的升级标志位位置,判断是否有“待升级”的请求。
- 如果有升级请求,进入升级模式,接收固件包并写入 App 区或备份区。
- 如果没有升级请求,则校验 App 区固件的有效性(长度、校验值、栈顶指针合法性)。
- 校验通过,跳转进入 App;校验失败,留在 Boot 模式等待重新升级。
这个流程里最容易出错的地方是升级标志位的存放位置。很多早期项目直接把标志放在 App 区的固定偏移处,结果 App 擦除后标志也没了,Boot 根本不知道要升级。正确的做法是在 Boot 区内部划分一个专门的参数存储区,用几个 Magic 值加状态值来表示升级意图。
/* 升级标志存入 Boot 区的专用参数段 */ #define APP_UPDATE_FLAG_ADDR 0x0800FC00 #define MAGIC_WAIT_UPDATE 0xA5A5A5A5 /* Boot 在跳转前检查该标志 */ uint32_t flag = *(volatile uint32_t *)APP_UPDATE_FLAG_ADDR; if (flag == MAGIC_WAIT_UPDATE) { enter_update_mode(); /* 进入升级流程 */ } else { jump_to_app(); /* 正常启动 App */ }1.3 Boot 区与 App 区为什么不能放同一个工程里编译
这一点我踩过坑,当时为了省事,把 Boot 和 App 放在同一个工程里,靠条件编译区分,结果链接地址一直对不上。原因在于 Boot 和 App 必须是两套独立的映像,各自的链接脚本指定不同的起始地址,编译出的 hex 文件也互相独立。如果你把两个功能编进同一个 elf,那 App 的地址偏移会被编译器搞乱,中断向量表也会错位。
正确做法是建立两个独立的工程目录,共享一些公共头文件,但编译产物分开。Boot 用boot.ld链接脚本,App 用app.ld链接脚本。App 工程里的 Flash 起始地址必须明确设置成 0x08010000 这样的偏移地址,否则后面中断向量表的偏移设置就是空谈。
2. App 工程里的两个关键点:中断向量偏移与跳转前准备
2.1 VTOR 偏移为什么必须和 App 起始地址一致
Cortex-M 内核有一个中断向量表偏移寄存器 VTOR,它告诉 CPU 中断向量表在 Flash 里的实际位置。芯片上电默认值是 0x08000000,也就是 Boot 区所在的位置。
如果你直接在 App 里使能了任何中断(定时器、串口、滴答时钟),而 VTOR 没改,那中断来了 CPU 还是会去 0x08000000 处的向量表找入口。那里放的是 Boot 的向量表,它把每个中断入口都映射到 Boot 的代码地址,结果就是你明明写了串口中断处理函数,却永远进不去,甚至直接跑飞。
App 工程的system_init或者main最开始的地方,必须显式设置 VTOR 到 App 的基地址:
/* App 代码起始,必须在任何外设中断使能之前执行 */ SCB->VTOR = ADDR_APP; /* 0x08010000 */这里有个细节:不是所有 Cortex-M 芯片都叫 SCB,也不是所有芯片都能随意改 VTOR。比如某些 M0 内核芯片没有 VTOR,需要靠 Boot 跳转时设置好、或者用固件库的特殊方式处理。项目选型后先查芯片手册的“System Control Block”一章,确认内核是否支持。我的项目用的是 M3 内核的某款国产 MCU,就直接操作 SCB,简单粗暴,问题不大。
2.2 跳转函数怎么写才是对的
Boot 跳转 App 时,核心动作是两个:设置主栈指针 MSP 为 App 向量表首元素的值,设置 PC 为向量表里第二个元素(复位入口)的值。
typedef void (*AppEntry)(void); void jump_to_app(void) { uint32_t app_entry_addr; uint32_t app_sp; AppEntry app_entry; /* 从 App 区首地址读取初始栈顶指针 */ app_sp = *(volatile uint32_t *)ADDR_APP; /* 从 App 区首地址 + 4 读取复位入口地址 */ app_entry_addr = *(volatile uint32_t *)(ADDR_APP + 4); /* 校验栈顶地址是否在 RAM 范围内,防止异常指针 */ if ((app_sp & 0xFFFF0000) == 0x20000000) { __disable_irq(); __set_MSP(app_sp); /* 重新设置主栈指针 */ app_entry = (AppEntry)app_entry_addr; app_entry(); /* 跳转,不再返回 */ } }那段栈顶指针合法性校验非常关键。如果 App 区是空的、或者被擦除到一半,首元素是 0xFFFFFFFF,那跳转进去就是 HardFault。加上校验后,Boot 就不会往一个垃圾地址跳,而是选择停留在 Boot 模式等待修复,至少保证设备还能被“救回来”。
2.3 跳转前必须处理“半路状态”的外设和中断
我第一次写跳转函数时只设了 MSP 就跳,结果 App 里某些外设初始化一直异常,查了很久才发现是 Boot 阶段已经打开了滴答定时器和串口中断,跳转前没有关闭,App 初始化时中断状态完全错乱。
跳转前至少要做这几件事:
- 关全局中断,
__disable_irq()。 - 把 Boot 用到的外设时钟关掉或者复位,比如串口、定时器、DMA。
- 如果开了 SysTick,把它停掉。
- 清掉 pending 的中断位。
原因其实很简单:中断控制器 NVIC 里还挂着未处理的中断请求,跳转后 App 还没来得及初始化中断处理函数,一个悬空的中断就触发,CPU 找不到正确入口,直接卡死。
void prepare_jump_environment(void) { SysTick->CTRL = 0; /* 关闭 SysTick */ USART_DeInit(USART1); /* 复位 Boot 阶段的串口 */ __disable_irq(); /* 关闭全局中断 */ }3. 升级包不是“新固件二进制”那么简单
3.1 升级包头部该放什么字段
很多新手一开始就是把编译出来的.bin文件直接发给设备写进 Flash,以为完事大吉。实际上你至少要自己封装一层格式,最简版本也得包含这几项:
typedef struct { uint8_t header[4]; /* 固定魔数,如 0xAA 0x55 0x00 0x01 */ uint32_t package_len; /* 整包长度,头部 + 数据 */ uint32_t bin_len; /* 固件实际长度 */ uint32_t version; /* 版本号,高16位主版本,低16位次版本 */ uint32_t crc32; /* 整个固件数据的 CRC32 */ uint32_t reserved; /* 预留,可用于目标地址标识 */ } __attribute__((packed)) update_package_header_t;头部之后紧接着固件二进制数据。这里的魔数非常重要,它用来在接收端快速过滤掉误发或错误的 bytes 流。我设计的协议里,发送端先发 4 字节 Mulligan 魔数,设备收到后才把后续内容当升级数据对待,否则直接丢弃。这能避免串口或者网络调试口误发文件时设备进入奇怪的升级状态。
3.2 CRC32 只能防空包,签名才防伪造
CRC32 校验可以判断数据在传输过程中有没有被改错,但防不了恶意篡改——攻击者完全可以把固件内容替换掉,再重新算一个 CRC 填进去。面向消费类产品或者具备联网 OTA 能力的设备,固件包至少要加上 SHA256 摘要,更进一步应该用非对称签名算法签名。
实际开发中,签名校验的实现需要 Boot 里集成一套加解密库,这会给 Boot 的代码体积和 Flash 占用带来压力。我的做法是分层处理:Boot 只验证固定算法摘要,强安全场景让升级工具把固件签名,Boot 内置公钥验证后再接受升级。
提示:如果你的设备通过公网升级,千万别省签名这一步。CRC32 校验在公网传输场景下只能算最基础的自检,不能作为安全边界。
3.3 版本号不能乱写,要参与升级判定
版本号不是摆设,它直接决定要不要升级。我在项目里遇到过一个很尴尬的问题:运维人员反复点升级,设备每次都先擦除 App 区、再写入同样版本的固件。如果此时升级过程中断电,设备就变成 Boot 模式,结果只能现场处理。
正确做法是升级流程开始前,先在 Boot 或者 App 里对比当前运行版本和待升级版本,只有新版本才允许进入擦写流程。为了方便强制升级某些紧急补丁,可以在头部加一个force_upgrade标志,小版本不回退、大版本不回退的规则由升级工具侧控制。
版本号建议用大版本、小版本、补丁号三段式,用字节或者 uint32 存储都可以,关键是必须支持单调递增的比较,别用字符串比较版本号,容易出 “1.10 比 1.9 小” 这种低级事故。
4. 掉电保护与失败恢复:变砖的最后防线
4.1 升级标志位到底要先写还是后写
这是一个直接决定“掉电会不会变砖”的问题。我的原则是:擦除和写入 App 区之前,先在 Boot 区写入一个“准备更新”的标志;写入动作完成后,再清除这个标志。
原因很容易理解:Flash 的擦写动作不是瞬间完成的,如果先擦 App 区,擦到一半掉电,App 区就没有完整固件;此时如果 Boot 没有升级标志,上电后会尝试跳转到一个坏掉的 App 区,校验失败后只能进入 Boot 等待。如果 Boot 有升级标志,上电后就留在升级模式,还可以重新接收新固件。
/* App 侧升级前先调用 */ void request_update(void) { write_boot_flag(MAGIC_WAIT_UPDATE); reset_system(); } /* Boot 升级完成后 */ void finish_update(void) { clear_boot_flag(); jump_to_app(); }有一点要特别注意:标志位存储区要尽量避免反复擦写同一个扇区,Flash 擦写次数有限,长期频繁升级会缩短寿命。可以用一个单独的扇区,每次写不同的位置、以最新地址作为有效标志,或者用磨损均衡的思路,把标志位分散写在扇区内的多个地址上。
4.2 有备份区就做 A/B 双区,没备份区就做保守策略
如果在 App 区之外还留了一个备份区,最稳妥的升级路径是:先把新固件写到备份区,校验通过后再整体拷贝到 App 区,或者让 Boot 直接指向新固件所在分区。这就是常说的 A/B 双区方案,升级失败时旧版本完全不受影响,还能直接回滚。
如果 Flash 容量有限,必须原地写 App 区,那要守住的底线是:
- 必须保留一个完整的旧版本校验信息备份。
- 擦写顺序是擦一个新的扇区、写一个新的扇区,不能一口气把整个 App 区全擦掉。
- 升级完成后必须做一次全量回读校验,再清除升级标志。
我见过一种极端方案:把 App 区前半部分擦掉写新固件,如果中途失败了,后半部分还是旧固件,前后拼接出来的东西根本无法运行。这种“混合固件”比全空更危险,因为它可能侥幸通过部分校验。所以没有备份区时,宁可让设备卡在 Boot 模式,也不要拼凑运行。
4.3 掉电恢复流程要怎么设计
所谓掉电恢复,是设备意外断电后重新上电,Boot 必须自动完成的一次修复过程。我的设计流程是:
- 上电,先读升级标志和 App 区的固件有效性标志。
- 如果升级标志存在,但 App 区校验未通过,说明上次升级中途掉电,自动进入升级接收模式。
- 如果升级标志存在,且 App 区校验通过,说明升级其实已经完成了,只是标志没清掉,清除标志后正常跳转。
- 如果升级标志不存在,App 区校验通过,正常启动。
- 如果升级标志不存在,App 区校验失败,进入恢复模式,等待外部设备通过串口或者网络重新发送固件。
这个流程的逻辑是:一切以“App 区是否完整可用”为最高判断标准,升级标志只是辅助信息。有了这套逻辑,设备在绝大多数掉电场景下都能自己恢复,而不是变砖。
5. 我在实际项目中踩过的坑与完整排查链路
5.1 坑一:跳转后立刻 HardFault,查了半天是 VTOR 没生效
现象是 Boot 正常打印日志,调用app_entry()后,App 里最开始几行代码能跑,但一旦打开串口中断就死机。
排查链路:
- 先用调试器看 SCB->VTOR 的值,发现还是 0x08000000。
- 打开 App 的链接脚本,发现 App 的 FLASH 起始地址居然还是 0x08000000,没有改成 0x08010000。
- 改掉链接脚本里的起始地址,重新编译,再在
SystemInit里设 VTOR。
这个坑的本质是:链接脚本决定 App 的地址,而 VTOR 中断向量表偏移必须和链接脚本一致,两边任何一个没改都会出问题。后来我用一个编译期断言检查两者是否匹配,避免了再次踩坑。
5.2 坑二:明明擦除成功,写进去运行却乱飞
某次升级后设备能启动,但运行过程随机死机。排查链路:
- 升级流程里是先把 32KB 固件按页写入 App 区。
- 写入循环里用的是普通 C 循环,没有关中断。
- 写入 Flash 期间,串口中断触发,跳到 Flash 操作打断。
- Flash 写入被中断切进去再回来,部分数据没写完,固件被写坏。
解决方法是写 Flash 期间关全局中断,或者把写入动作放到临界区。更稳妥的做法是准备一块 RAM 缓冲区,先把要写的一页数据从外部接口收全、校验好,再一次性关中断写入。这种设计会成倍减少 Flash 写入时的中断窗口。
5.3 坑三:OTA 分包传输后合并出错,尾包丢了
设备通过串口接收升级包时,上位机把整个固件分包发送,每包 512 字节。第一版代码里,接收端对包号的判断逻辑是if (seq == 0) start_new_update();,然后按seq * 512偏移写入缓存。
问题出在最后一段:固件总长度不是 512 的整数倍时,最后一包数据只有 130 字节,接收端按完整分组的逻辑等待下一包,最后一包迟迟不到,升级流程一直等待直到超时。
排查之后定位到这几点:
- 发送端必须告诉接收端整包总长度。
- 接收端根据总长度判断最后一包的字节数,不能简单等一个 512 字节的满包。
- 收到最后一包后立即触发整体校验和 Flash 写入,而不是继续等。
修正后的状态机不再依赖“每个包都是固定长度”的假设,而是严格根据bin_len计算收包边界,之后再没出过尾包丢失的问题。
5.4 升级功能的验证必须模拟掉电
我经常看到有人只做正常升级路径的测试,升级一次成功就觉得完事了。但升级功能的核心可靠性恰恰在异常路径:升级到一半断电、传输过程丢包、Flash 写入失败、版本回退被拒绝。
建议在开发板上做一套自动测试脚本,反复执行这个循环:
- 固件升级工具发送升级包,升级到其中某个扇区时,人为给板子断电。
- 重新上电,观察 Boot 是否进入恢复模式。
- 再次升级,这次让完整的包送达,确认设备可以恢复正常。
- 用旧版本固件覆盖新版本,确认版本判断逻辑正确。
我最初听到“要模拟掉电”觉得矫情,后来发现一次掉电就能逼出好几处逻辑漏洞。无论你的升级设计看着多完备,没有掉电测试的验证闭环,都不敢说是稳定的。
最后再分享一点个人体会:工程上做嵌入式固件升级,最难的不是任何一行代码,而是把所有异常路径想明白。Flash 的操作顺序、标志位的写入时机、跳转前的现场清理,任何一个细节做得不够彻底,都会在未来某个现场变成一台变砖设备。留足校验逻辑、多做异常测试,这些时间永远不会白费。