嵌入式Bootloader与IAP/OTA升级实战解析
做嵌入式开发的人几乎都躲不开 Bootloader 这个词。它是芯片上电后最先运行的程序也是整个固件体系的入口。无论你是在调试一块 STM32、一颗国产 HC32、还是基于 ESP32 做联网产品最终都会遇到引导程序、升级方式、Flash 分区这几个绕不开的话题。这篇文章想把这些概念一次性讲透从 Boot ROM 到 User Bootloader再到 IAP 和 OTA我会结合自己做过的项目和踩过的坑把里面的门道、流程和细节拆开来看。这篇文章适合刚入手嵌入式、第一次接触 bootloader 开发的工程师也适合已经做过一版启动代码、但总被中断移向、CRC 校验、断电恢复这些问题折磨的人。我会尽量用大白话还原实际调试现场把那些文档里不会写清楚的东西也讲明白。读完之后你应该能自己规划一套升级方案也能看懂市面上常见的开源 bootloader 代码。1. Bootloader 到底是什么从一次上电说起很多人一开始会把 bootloader 理解成一个用来升级程序的工具这个理解没错但不完整。bootloader 的字面意思是引导加载程序它要做的事情比升级复杂得多初始化硬件、准备运行环境、决定执行哪个应用程序、必要时接收新固件并写入 Flash。在嵌入式系统里它就像是操作系统的内核和系统服务之间的那个最先跑起来的程序。1.1 上电后芯片的第一件事Boot ROM芯片上电之后内部逻辑会先执行一段固化在硅片里的代码这段代码叫 Boot ROM。它不是在 Flash 里而是芯片出厂时写死在 ROM 里的用户无法修改。Boot ROM 的作用通常有两个一是初始化最基本的时钟和启动引脚二是根据引脚电平或选项字节决定从哪个存储介质启动。以 STM32 为例BOOT0 和 BOOT1 引脚的电平组合决定了启动模式。从主 Flash 启动、从系统存储器也就是内置 bootloader启动、还是从 SRAM 启动这些都是 Boot ROM 检查完引脚后做出的选择。很多新手第一次接触串口下载程序时把 BOOT0 拉高再复位其实就是让 Boot ROM 跳到了系统存储器里的内置 bootloader然后通过 USART 或 USB 接口把程序写到 Flash。这个环节对用户是不可见的但它决定了后面所有事情的起点。这里有个容易被忽略的细节Boot ROM 本身不具备升级应用固件的能力它只是提供了一个最基本的引导入口。真正的灵活升级还得靠用户自己写的 User Bootloader。这也是为什么很多芯片虽然内置了串口下载功能产品量产之后还是要自己做一套引导程序的原因——内置方案通常只支持特定接口没法做加密、校验、版本回退更没法通过无线网络把固件推给用户。1.2 User Bootloader出厂后真正的管家User Bootloader 是用户自己编写并烧录到 Flash 里的一段程序通常放在 Flash 的最开头也就是地址 0x08000000对 STM32 而言。复位之后芯片从中取出复位向量然后执行用户 bootloader 的启动代码。之后做什么完全取决于你的设计。最常见的做法是bootloader 先检查一个标志位比如某个备份寄存器、Flash 特定地址里的魔数、或者外部按键状态。如果标志表示需要进入升级模式就跳转到升级流程等待接收新固件如果标志表示没有升级需求就直接跳转到应用程序的入口地址去执行。这个跳转不是简单的函数调用而是要通过修改 MSP主堆栈指针和 PC程序计数器来完成。对于量产设备User Bootloader 还会做更多事检查应用区的 CRC 校验值是否正确、判断固件版本是否合法、在发现 App 损坏时自动恢复出厂固件、甚至支持多固件备份。可以说User Bootloader 是整个设备的生命周期管理者它决定了产品在出厂后还能不能跟上软件迭代。1.3 为什么需要两级引导有的产品会设计成Boot ROM 出厂引导 User Bootloader 用户引导两级结构。为什么不直接用一层因为芯片厂商的 Boot ROM 是固定的它不可能知道你产品需要什么样的升级协议、加密算法、分区策略。而 User Bootloader 透明地放在 Flash 里你可以随意修改、升级、甚至通过 bootloader 自己来更新另一个 bootloader。二级引导还有一个非常实际的意义容错。如果 User Bootloader 本身设计得足够轻量它出问题的概率极低那么整个设备最脆弱的环节就只剩 App 区。即使 App 刷坏了bootloader 还在设备就可以通过串口、USB、网络等方式恢复。这也是为什么砖头设备很多时候并不是真的死透了只是缺少一个可靠的 bootloader。我见过不少产品为了省那 8KB Flash把 bootloader 功能全部塞进 App 里导致升级失败后设备只能返厂。省下的空间和时间远不如一块稳定可靠的引导区带来的收益。现在绝大多数 Flash 都是 256KB 起步拿出一页或几页来做 bootloader非常值得。2. IAP 与 OTA升级这件事的两种形态接下来是 IAP 和 OTA。这两个缩写的出现频率极高很多人以为它们差不多实际上它们是两个层面的概念。IAPIn-Application Programming是在应用中进行编程的能力OTAOver-The-Air是通过空中接口进行升级的渠道。简单说IAP 是手段OTA 是场景。OTA 必然依赖 IAP但 IAP 不一定走无线它走串口、USB、CAN 都可以。2.1 IAP 的完整流程和设计要点IAP 的核心在于应用运行时也能改写 Flash。区别于 ICP通过烧录器在电路编程和 ISP通过芯片内置 bootloader 编程IAP 是在用户程序里嵌入了一段 Flash 擦写驱动由用户程序自己完成固件更新。一个典型的 IAP 升级流程是这样的应用 App 收到升级指令把新固件分包接收并暂存在一个临时缓冲区或外部 Flash 中。校验完整性和版本信息确认没有错误。跳转到 User Bootloader并把升级请求标志告诉它。Bootloader 从暂存区读取固件擦除 App 区逐页写入新固件。写入完成后校验整个 App 区的 CRC然后跳转到新 App 中执行。这里有一个关键点为什么要跳回 bootloader 再写 Flash因为如果 App 一边运行一边擦写自己的代码区一旦擦除动作破坏了正在执行的代码系统立刻跑飞。所以更稳妥的做法是 App 只负责收数据、存数据真正执行擦写的是 bootloader。这样做还有一个好处bootloader 里可以做回退保护万一新固件是坏的它还能恢复上一次的可用版本。设计 IAP 时中断向量偏移是个绕不开的坑。把 App 放在 0x08020000 这类非零地址之后如果工程里没有设置中断向量表偏移App 一旦产生任何中断CPU 就会跳到 Flash 开头寻找中断向量结果执行的还是 bootloader 的中断服务函数导致所有外设异常。STM32 上可以用SCB-VTOR APP_ADDRESS来重定位向量表但要注意这个操作必须在 App 的启动早期完成而且要在任何中断开启之前做。Cortex-M0 系列没有 VTOR 寄存器如 STM32F0 和部分国产芯片就得通过启动文件里的__attribute__((section(VECTOR_TABLE)))配合链接脚本来解决或者把向量表复制到 SRAM再修改向量表的地址。具体芯片原理不同但核心思想是一样的让 CPU 能找到正确的中断向量。2.2 OTA 在 IAP 之上多了什么OTA 升级就是在 IAP 的技术基础上把传输通道从有线改成无线。对 Wi-Fi 设备而言通常是从服务器或云平台下载固件包对蜂窝模组而言可能是通过 TCP、MQTT、HTTP 下载对 BLE 设备而言则是通过手机 App 分片传输。传输通道变了带来三个新问题数据包可能丢失、传输可能中断、版本管理变得更加复杂。所以 OTA 在 IAP 之外还需要引入断点续传或重传机制。很多嵌入式工程师第一次做 OTA 时直接用 IAP 的思路把接收到的每个数据包都立刻写进 Flash。如果网络稍微一抖动就会有一个扇区写了一半设备上的旧固件已经被擦掉新固件又没写完直接变砖。好的做法是先把整个固件包下载到临时分区比如外部 SPI Flash 或内部一个保留区域下载完做整体校验再触发 bootloader 进行一次性拷贝。这就是下载-校验-切换三步走。还有一个容易忽略的点固件包格式。OTA 下载的往往不是纯粹的 bin 文件而是带头部信息的包里面包含设备类型、目标版本、固件大小、CRC 或 SHA-256 摘要、签名信息。Bootloader 在写入之前必须解析这些头部并逐项验证。否则哪天服务器推送了一个和当前硬件不兼容的固件又恰好没做校验设备就可能出现各种诡异问题。2.3 分区规划与升级策略Flash 分区的规划直接决定升级策略的灵活性。最基础的分区格式是两个区bootloader 区 App 区。这只能做单备份升级也就是直接覆盖。升级过程中断电、失败设备就会停留在不可用状态需要重新走一遍升级流程才能恢复。想提升可靠性可以加一个临时下载区把新固件先存到临时区校验通过后再由 bootloader 搬到 App 区。这样即使 App 区在拷贝过程中断电临时区的固件还在下次上电 bootloader 可以继续完成搬迁。更进一步的策略是双 A/B 分区有两个独立的 App 区分别存放旧版本和新版本。Bootloader 记录当前启动的是哪一个升级时把新固件写到不活跃的那一侧写完后切换启动项。如果新版本启动失败bootloader 可以自动回退到旧版本实现真正的无缝升级。我在实际产品里见过很多折中方案在资源受限的 MCU 上双分区往往太奢侈于是大家就用下载区 备份区 App 区的组合。备份区不一定每次都更新只有在新版本验证稳定后才把当前 App 拷到备份区。这样既保留了回退能力又不会让 Flash 占用翻倍。3. 实操手写一个最小可用的 User Bootloader理论讲完就该动真格的了。下面我以 STM32 系列为例从 Flash 划分、工程配置到关键代码带大家一起实现一个最小可用的 User Bootloader。这套思路同样适用于 HC32L136、GD32、AT32 等国产芯片差别只在 Flash 地址、扇区大小和库函数名。3.1 芯片选型与 Flash 划分假设我们用 STM32F103C8T6它有 64KB Flash扇区大小为 1KB小容量品牌或按页划分具体看手册。但要注意实际很多芯片号称 64KB后面 16KB 可能是只读或影印区真正可写的往往只有 48KB 左右。设计之前一定要查清 Flash 容量和页结构避免算错地址。我们做如下划分Bootloader 区起始地址 0x08000000长度 16KB0x08004000 之前。App 区起始地址 0x08004000长度 32KB。标志位区放在 Flash 靠近末尾的一个独立页存储升级标志、App 状态、CRC 值等。为什么 bootloader 要 16KB一个功能完善的 bootloader 其实用不到多大但考虑到以后要加加密、校验、打印日志预留一点空间更从容。App 区 32KB 看起来不大但对于很多小型物联网设备已经够用。如果不够就把 Flash 容量更大的芯片放进来。划分好地址之后要同步修改两套工程的链接脚本。Bootloader 工程的 Flash 起始地址保持默认 0x08000000长度设为 0x4000App 工程的 Flash 起始地址改为 0x08004000长度设为 0x8000。这一步最容易出错的地方是忘记修改 App 工程的 IROM 地址导致编译出来的 App 仍然从 0 地址开始跳转后直接跑飞。我就见过有人折腾了两天最后发现只是 linker 脚本里少改了一个数字。3.2 关键代码片段与实现细节先看 bootloader 跳转到 App 的核心代码#define APP_ADDR 0x08004000 #define APP_FLAG_ADDR 0x0800FC00 // 独立页存放标志 typedef void (*pFunction)(void); void jump_to_app(void) { uint32_t app_sp *(volatile uint32_t *)APP_ADDR; uint32_t app_pc *(volatile uint32_t *)(APP_ADDR 4); pFunction app_entry; // 简单检查栈顶地址是否合理 if ((app_sp 0xFFF00000) ! 0x20000000) { // 栈顶指针不在 SRAM 范围内说明 App 区没有有效程序 return; } __disable_irq(); // 移除外设中断避免跳转后残留中断触发 for (int i 0; i 8; i) { NVIC-ICER[i] 0xFFFFFFFF; } __enable_irq(); app_entry (pFunction)app_pc; __set_MSP(app_sp); app_entry(); }这段代码做了三件事。一是从 App 区的第一个字读取新的栈顶指针MSP第二个字读取复位向量地址二是粗校验栈顶地址是否处于 SRAM 范围这是最常用的有没有程序判断方法三是跳转前彻底关闭所有中断并清空 NVIC 中断挂起。很多人忘了关闭中断导致跳转瞬间还有串口中断在排队新 App 还没跑起来先把中断服务函数执行了一遍然后出现各种莫名问题。App 侧要做的工作主要是设置中断向量表偏移。以下代码放在 App 启动的早期#define APP_ADDR 0x08004000 // 必须在 main 函数最开始或者在 SystemInit 之前执行 SCB-VTOR APP_ADDR;对于 Cortex-M0/M0 芯片没有 VTOR 寄存器则需要使用启动文件里预留的向量表重映射机制。比如在链接脚本里把向量表放到 SRAM 起始处启动时把 Flash 中的向量表拷贝过去再设置一个保留的 SYSCFG 配置寄存器。具体实现因芯片而异但核心思路一样让 CPU 在任何中断发生前就找到新的向量表。3.3 IAP Boot 里定义的变量复位后会怎样这个问题热搜词里有一条“iap boot里面定义的变量复位后会怎样”这个问题问得很细但也是很多人踩过的坑。我在调试 bootloader 时也遇到过。先明确一点只要发生了系统复位比如看门狗复位、复位引脚复位、上电复位Cortex-M 内核会重新从 Flash 起始地址开始执行同时把 SRAM 里的内容清空或保持不定状态。所以 bootloader 里定义的全局变量复位后一律回到未初始化状态也就是编译时指定的初值。如果 bootloader 的某个变量保存了升级进度、接收了多少字节这类信息在复位之后这些值会丢失。这就意味着如果你想在复位后还能记住某些状态不能依赖普通变量必须使用备份寄存器、Flash 特定区域或者 RTC 后备寄存器来保存。更隐蔽的问题是不经过复位直接从 App 跳回 bootloader 时SRAM 中已有的变量值是什么状态这时没有复位变量仍然保留旧值也就是 App 运行时写入的那些数据。很多 bootloader 依赖一个复位原因标志来判断自己是冷启动还是 App 跳转过来的合理做法是在跳转前把一个魔数写入备份寄存器或特定 RAM 地址bootloader 启动后检查这个魔数。如果直接依赖某个局部变量而这个变量又恰好位于被覆盖的栈区就可能读到残值导致误判。另外像 IAP 中经常用iap_buffer这样的大数组如果定义在 bootloader 里复位后它会被初始化为零如果是全局数组且初始化了但如果你指望它保存上一次下载的数据那就错了。真正可靠的临时存储区应该放在 Flash 的一个独立分区里或者让 App 先把固件写到外部存储再把控制权交给 bootloader。这也是我们前面反复强调的下载区设计。4. 常见问题与排查技巧实录最后这部分我把我这些年做 bootloader 和升级功能遇到的典型问题整理成一份排查手册。这些问题在论坛上反复出现但官方文档很少给出直接答案。4.1 中断向量偏移惹的祸现象是App 单独跑没问题一旦从 bootloader 跳转过去串口一收到数据就死机或者定时器中断异常重入甚至进 HardFault。十有八九是向量表重定位没生效。排查步骤很简单先确认 App 的链接脚本里起始地址是否真的改到了0x08004000。用地图文件或者反汇编看编译产物如果复位向量还是在0x08000000附近说明链接脚本改漏了。其次检查代码里有没有设置 VTOR有些芯片设置 VTOR 之前还需要解锁写保护。最后如果你用的是 HAL 库注意 SystemInit 函数内部可能已经对向量表做了一些默认设置最好在 SystemInit 之后再覆盖 VTOR。STM8S003F3P6 这种 8 位芯片没有 VTOR 概念它的中断向量表是固定在 Flash 首部的。很多人说STM8S003F3P6 bootloader 无法使用中断其实是因为 App 的向量表没有跟着偏移过去。STM8 的做法是在编译时用tiny vector段或者把向量表整体放到 bootloader 能够重定向的位置。由于 STM8 架构比较老实现方式各编译环境差异很大我的建议是先查你用的烧录器/编译器是否支持向量表重映射。如果不支持就考虑在 APP 中使用软件中断代替硬件外设中断或者牺牲一点性能用查询模式代替中断接收。4.2 串口升级失败擦写顺序和临时区问题另一个高发问题是串口升级到一半失败板子变砖。最常见的原因是擦写顺序设计得不够安全。有人在收到第一个包的时候就立刻擦除整个 App 区然后边收边写结果网络或者线缆一抖动包丢了后面的固件再也写不完整设备就成了砖。正确的顺序应该先把固件包完整地收下来放在临时缓冲区外部 Flash 或内部未用分区收完再整体校验校验通过后再擦除 App 区并写入。如果临时区空间够大甚至可以把旧固件先备份一份然后写新固件最后切换标志。这样除了升级中间断电这种极端情况其他情况下设备都能自恢复。我自己遇到过一种很隐蔽的失败串口工具发送数据时波特率在传输过程中出现了微小偏差导致 bootloader 收到大量帧错误。本以为是硬件问题后来发现是目标板的时钟用了内部 RC温度一变化频率就偏了。这个问题排查了好几天。建议在 bootloader 初始化串口时加入波特率校准或者直接用外部晶振不要省那点成本。4.3 ESP32 OTA 与手机端 App 调用的差异很多做物联网的朋友会直接用 ESP32它的 OTA 机制比裸机 MCU 更完善但也会有新坑。ESP-IDF 里提供了esp_ota_ops接口支持原厂 OTA 和自定义分区表。第一次做时最容易混淆的是全量镜像和差分镜像。OTA 全量包就是一个完整的可启动固件差分包则依赖当前版本只能从指定版本升级。如果你下载了错误版本的差分包升级后大概率起不来。MTK Android 12 上调用 App 升级 OTA 的做法则又不同——Android 系统 OTA 涉及 A/B 分区、动态分区、擦除用户数据等一堆机制。嵌入式工程师不太需要深入 Android 分区细节但如果你负责的模组跑的是 Android那你必须清楚直接调用系统升级 API 后系统会自行处理重启和升级你的外部通信逻辑要做相应的暂停和恢复处理。不要在升级过程中继续下发控制命令更不要在升级期间写入共享文件否则很容易导致升级包校验失败。对于 ESP32 的 OTA我个人建议开启 rollback 功能也就是新固件启动后要主动向系统报告运行正常如果没有报告重启后自动回退到上一个版本。这一步能极大减少远程升级带来的无人值守风险。另外ESP32 的默认分区表里 OTA 数据区很小做 A/B 升级时一定要把两个 App 分区都设得足够大否则一个版本稍微大一点就会导致编译时溢出。4.4 Bootloader 升级和 App 崩溃的连带关系还有一个经验是bootloader 本身其实很少升级但很多人总希望 bootloader 也能通过 OTA 升级。这里要特别注意安全边界。如果 bootloader 可以被远程覆盖一旦升级失败整个设备就失去了自我恢复能力。所以我做产品时的原则是bootloader 默认不支持在线升级只有通过专用工具和物理接口才能更新它。App 区则可以随便折腾哪怕每天升十次都行因为随时能回退。有时候 App 运行中崩溃并不一定是 bug也可能和 bootloader 有关。比如 App 启动时没有正确关闭外设bootloader 里设置好的 DMA 或者看门狗还在跑App 初始化顺序又恰好没有覆盖这部分配置就会造成随机死机。跳转前把外设全部复位、关闭所有中断、看门狗停掉是必须要做的清理动作。我的习惯是在跳转前执行一遍相当于“软件复位”的序列把 RCC 时钟和外设寄存器恢复到默认状态这样 App 就和冷启动的环境完全一致避免各种历史遗留状态干扰。另外分区里的 CRC 保存方式也值得提一句。不要在每次升级时都把 CRC 写在一个固定 Flash 页要考虑到 Flash 擦写寿命。小容量 MCU 的某个页可能被持续擦写几十万次就损坏了解决办法是使用两个页交替保存升级计数和 CRC或者只在关键状态变化时才写 Flash减少擦写频率。最后再分享一个小技巧在 bootloader 的开头加一个稍长的延时比如 500ms同时用一颗 LED 指示当前状态。这个延时看起来是浪费实则是救命稻草——用户在设备上电时如果发现固件不对可以立刻断电或者在延时期间通过按键强制进入下载模式。对于不支持断电续传的设备这个窗口期能避免很多不必要的返厂维修。我个人做了这么多年嵌入式最深的体会是一个可靠的 bootloader 不是功能越多越好而是越简单越好。它只需要做好引导、校验、跳转这三件事越复杂越容易藏 bug。至于升级协议、断点续传、多版本管理这些完全可以放在 App 层和上位机配合去实现。每当你在产品启动流程里遇到诡异问题不妨先静下心从头把 bootloader 的启动链路过一遍很多时候答案就藏在那一行不起眼的 VTOR 配置里。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →