上周在客户现场排查一块跑 RT-Thread 的板子,现象特别诡异:上电后大约 5% 的概率卡死,按复位键大概率能起来,但偶尔又起不来。用调试器挂上看,有时候死在启动文件里,有时候死在 main 之前的库初始化,还有时候跑进 RTOS 调度之后才崩。折腾了一下午,最后定位到是一颗外置电源芯片的使能时序在低温下不满足规格,导致内核电压建立缓慢,Cortex-M 在复位释放后有一段窗口期非法访问总线。这个案例让我再次确信:嵌入式固件的问题,十有八九要回到启动流程、异常现场和升级链路这三件事上去找答案。
这也是本专栏把这三块内容放在一起的原因。如果你做过一段时间 MCU 或 SoC 开发,应该会发现一个规律——很多看起来毫无头绪的 bug,本质上都是对"芯片上电后究竟按什么顺序执行、出错后现场在哪、固件怎么从旧版本安全切换"这三条线索理解得不够深。这篇文章是专栏的第三篇连载,接上篇留下的思考题,我按实际项目里最常遇到的几个场景来展开:先是 Cortex-M 和 RT-Thread 的启动初始化全流程拆解,再对比 i.MX6 这类带 uboot 的 SoC 启动链路,接着讲 HardFault 发生后的具体定位手段,最后把 OTA 从分区规划、签名验签到失败回滚的整体工程做法完整梳理一遍。文末会把上篇布置的思考题逐题解析,参考答案都是基于真实工程实践,不是教科书套话。
如果你是刚转嵌入式、想系统搞懂固件底层逻辑的开发者,建议从第一节顺序读;如果你已经在负责产品维护,可以直接跳到故障定位和 OTA 两节,这两部分会以踩坑视角为主,尽量帮你少走弯路。
1. 从向量表到调度器:Cortex-M 启动流程里的每一条路都要心里有数
1.1 复位后的前三笔事务:SP、PC、VTOR
Cortex-M 内核的启动流程和传统 ARM7/ARM9 不一样,它不是上电后直接跳去执行 bootloader 代码,而是由硬件自动完成一个"取数"动作。芯片复位释放后,内核会从地址 0x00000000 处读取初始栈指针 SP,从地址 0x00000004 处读取复位向量,也就是 Reset_Handler 的地址,然后跳过去执行。这个机制很多内核文档里一句话就带过了,但实际项目中恰恰是这里容易埋雷。
如果你把向量表放在片内 Flash 起始地址,0x00000000 对应的是 Flash 首地址,这没问题。但一旦你启用了 BootLoader,APP 的向量表往往不在 0x00000000,而是偏移到 0x00008000、0x00010000 这类位置。这时如果 APP 的启动代码里没有重新设置 VTOR(向量表偏移寄存器,地址 0xE000ED08),中断一发生,内核还是会去 0x00000000 取向量,取到的就是 BootLoader 的向量表,中断处理必然错乱。这个坑我见过太多次,尤其是从标准库工程迁移到 CubeMX 工程时,SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET这行代码被优化掉或者写错位置,导致中断完全不可用。
再看 Reset_Handler 内部,它一般做四件事:拷贝 RW 段、清零 ZI 段、调用 SystemInit 初始化时钟、跳转 main。MDK 环境下,这些动作大多由 __main 库函数自动完成,启动文件里只需要调用__main即可。GCC 环境下则是_start配合链接脚本完成同样的工作。这里我想提醒一点:不要迷信"启动文件是编译器自动生成的,不用管"。当你用到自定义链接脚本、将代码放在外部 SDRAM、或做 bootloader + app 双区运行时,启动文件里段拷贝的前提条件、栈指针的初始值、以及是否需要提前初始化外部存储控制器,这些都会直接影响系统能不能跑起来。RT-Thread 的 bsp 里已经帮你处理好了大部分情况,但理解它为什么这么写,比背下来更重要。
1.2 RT-Thread 的启动初始化:从 main 到调度器的路线图
RT-Thread 启动流程相对清晰,它把大部分初始化工作封装在了rtthread_startup()函数里。用户代码里的 main 函数通常只做两件事:调用rtthread_startup(),然后返回。真正的工作从rt_hw_board_init()开始,这里完成系统时钟、堆内存初始化,紧接着是定时器、调度器、信号量等内核组件初始化,最后创建主线程,由rt_application_init()调用用户main的线程版本,再启动调度器。
有几个点需要特别留意。第一,rt_hw_board_init()里通常会用rt_system_heap_init()设置系统堆的起始地址和大小。如果外部 RAM 在此时还没有完成初始化,而你又把堆放在外部 RAM,那系统在第一次分配内存时就会 HardFault。第二,调度器启动之前,rt_assert里的断言、定时器初始化不能使用会阻塞的线程原语。第三,main 线程的栈大小默认值在某些 bsp 里设置得偏小,如果你在 main 里定义了较大的局部变量或递归调用较深,就会发生栈溢出,而 RT-Thread 的栈溢出检测默认情况下只在开启RT_USING_DEBUG时才比较敏感。
从 Cortex-M 到 RTOS 的过渡,还有一个底层细节值得展开。Cortex-M 的线程模式和处理模式分别对应运行 RTOS 线程和中断/异常。RT-Thread 在启动第一个线程时,会触发 SVC 异常,借助异常返回机制将内核从处理模式切换到线程模式,同时选择使用 PSP(进程栈指针)。如果你需要做底层调试,特别是看线程切换现场时,不理解 EXC_RETURN 代表什么,会很难判断当前 CPU 正在用哪个栈、处于什么模式。这一块我会在第三节故障定位里继续深入。
1.3 启动流程中常见的"时序病":时钟、引脚与供电
启动流程表面上是代码问题,实际上很多是硬件时序问题。上面那个客户案例是最典型的例子:内核供电没有在复位释放前稳定到工作电压,导致 CPU 取第一条指令时总线读到乱码,表现就是随机卡死在不同阶段。排查这类问题,不要一开始就死磕代码,而是先看供电时序、复位信号释放时间、以及时钟稳定时间。逻辑分析仪或者示波器同时抓三路信号:电源轨、NRST、主时钟输出,能快速确认是否是时序问题。
另一个常见的启动期故障是引脚复用冲突。芯片上电瞬间,所有 IO 默认状态是浮空或者上拉,如果某个引脚正好控制外部设备的关键使能信号,在 main 初始化之前被异常拉高或拉低,设备可能进入错误状态。比如你用 PA8 做外部 Flash 的片选,但 Flash 是低有效片选,上电瞬间 PA8 处于浮空,电平不确定,Flash 可能被误选中并响应了错误的 SPI 指令,导致后续通信句柄拿不到正确 ID。解决方法是选用默认电平正确的引脚,或者在外部加上下拉电阻,或者在 startup 阶段尽快把关键引脚配置为确定状态。
2. SoC 级启动链路:i.MX6 的 IVT 和 uboot 到底比 MCU 复杂在哪
2.1 Boot ROM 的行为:i.MX6 的 IVT 与启动设备选择
从 MCU 跨越到应用处理器(SoC),最明显的区别就是:Cortex-M 的启动过程由内部 Flash 的向量表直接接管,而 i.MX6 这类芯片没有内部 Flash,它内置了一段 Boot ROM,上电后由 Boot ROM 先运行,然后根据 BOOT_CFG 引脚或 FUSE 的配置,从 SD/eMMC、NOR Flash、NAND、USB 等设备中加载下一级程序。
i.MX6 的 Boot ROM 会先在存储介质中寻找 IVT(Image Vector Table)。IVT 是一个固定格式的结构体,包含自校验信息、跳转地址、DCD 地址、Boot Data 地址等字段。Boot ROM 读到 IVT 后,会根据 DCD(Device Configuration Data)来完成 DDR 控制器的初始化,然后再把外部的 bootloader 镜像拷贝到指定内存地址并跳转执行。这个流程在 i.MX6UL 等型号上尤为典型。千万注意,DCD 如果写得不对,DDR 训练失败或时序参数错误,整个系统就会卡在 Boot ROM 阶段,而且多数情况下串口没有任何输出,很容易被误判为 "芯片没贴上"。
如果你用的是 NXP 官方 Yocto 或 MCUXpresso SDK,这些细节已经被编译工具链封装好了。但在做产品定制时,比如修改板级 DDR 容量、更换 SDRAM 颗粒型号,你必须同步修改 DCD 参数,否则启动到一半就死。实测经验是:先把官方评估板的 DCD 跑通,再逐项改为自己板子的参数,一次只改一项,每改完就验证一次内存读写稳定性,不要同时动多项参数。
2.2 uboot 的启动流程:SPL 与 FIT 镜像
与 MCU 上的 RT-Thread 启动相比,uboot 要分的层更多。i.MX6 上典型的启动链是 Boot ROM -> SPL(如果启用)-> uboot -> 内核。SPL(Secondary Program Loader)是一段精简版 uboot,主要在 DDR 还未初始化时负责最基础的板级初始化,然后把完整 uboot 从启动介质拷贝到 DDR 中。board_init_f 阶段负责时钟、串口、DDR 等最小系统初始化,board_init_r 阶段则是完整的设备模型,加载环境变量、解析设备树,最后通过 bootm 命令启动 Linux 内核。
搞 uboot 启动,最容易出问题的点往往是设备树和外设驱动。系统启动到 uboot 后,如果串口没有输出,先查 uboot 的 baudrate 与终端是否一致、调试串口对应的设备树节点是否 disable 状态。uboot 中saveenv的环境变量如果设置了错误的 bootcmd,比如内核镜像路径不存在,会出现"卡死在 Hit any key to stop autoboot"之后无响应的假死现象。排查时优先使用printenv检查变量,ls mmc 1:2 /boot确认镜像存在,再执行boot代替自动启动,逐步缩小范围。
开个不算偏题的玩笑,从 MCU 转到带系统 SoC 开发的工程师,最容易犯的错就是把 MCU 的"裸机思维"搬到 uboot 里:总觉得上电后应该直接进入用户 main,看到 uboot 界面就以为系统已经启动。实际上 uboot 只是个引导程序,它的职责是准备好运行环境、跳进内核,真正可靠的标准是"打出 bootargs 传递正确、设备树加载成功、内核开始打印"。在调试阶段,建议打开bootargs里earlyprintk和ignore_loglevel,会让崩溃前的最后输出明显清楚很多。
2.3 MCU 启动与 SoC 启动的对照表
为了帮大家快速建立整体图景,我把两种系统的启动关键差异整理成了一张对照表。实际项目里无论是做 RTOS 还是跑 Linux,这张表都值得贴在工位上。
| 对比项 | Cortex-M MCU(如 STM32、RT-Thread) | SoC(如 i.MX6 + uboot + Linux) |
|---|---|---|
| 启动源头 | 芯片内部 Flash 固定地址向量表 | 芯片内 Boot ROM 根据引脚/FUSE 选择介质 |
| 第一阶段代码 | Reset_Handler(启动文件) | Boot ROM 加载 SPL 或直接加载 uboot |
| 存储介质 | 内部 Flash,可选外部 SPI Flash | SD/eMMC、NAND、NOR、USB 等 |
| 是否需要 DDR | 可选,取决于应用 | 必须,DRAM 初始化是启动前置条件 |
| 二级加载器 | 可无,或用户自写 bootloader | uboot SPL / uboot |
| OS 加载 | RTOS 镜像直接烧录,无动态加载 | 支持 FIT/设备树/ramdisk,架构更复杂 |
| 常用调试手段 | 调试器断点、HardFault 现场、串口 | uboot 命令、设备树 overlay、内核 printk |
理解这张表之后,你会发现一个规律:无论是 MCU 还是 SoC,启动流程的核心从来不是"把代码跑起来",而是"在正确的时间点把硬件资源初始化到可用状态"。启动期崩溃的排查思路是同一套逻辑,差别只在排查工具的手段上。
3. 故障定位方法论:HardFault 现场,比起"猜"更重要的是"还原"
3.1 HardFault 处理函数不打印,等于白搭
很多工程师在 Cortex-M 上做故障定位时,第一反应是"用调试器看当前 PC 在哪"。这个做法在开发调试阶段有效,但到了产线故障、客户现场、设备偶发死机时,根本没有调试器可挂。真正的工程化做法是:从项目初期就写好一个足够好用的 HardFault 处理函数,把异常现场保存到内存或 Flash,再通过串口上报。只有把"定位手段"前置到这个程度,遇到偶发问题时才有东西可查。
HardFault_Handler 里最核心的任务是抓取压栈的寄存器。Cortex-M3/M4 在进入异常时,硬件会自动把 xPSR、PC、LR、R12、R3~R0 这八个寄存器压栈,如果使能了 FPU,还会一并压入浮点寄存器。关键在于:你需要判断当时用的是 MSP 还是 PSP。判断依据是 LR 里的 EXC_RETURN 值,0xFFFFFFF9 表示使用 MSP,0xFFFFFFFD 表示使用 PSP,0xFFFFFFF1 则表示处于 Handler 模式。拿到正确的栈指针后,从栈帧里解析出 PC 和 LR,就能反推故障发生的位置。
3.2 一次真实的 HardFault 定位过程:野指针调用与栈回溯
分享一个实际例子。某产品在持续运行 5~8 小时后偶发死机,串口在死机前最后一条日志是某条传感器数据更新。按照"先抓现场"的思路,我们在 HardFault_Handler 里打印了 MSP/PSP、EXC_RETURN、栈帧里的 PC 和 LR,以及栈顶附近 32 个字的原始数据。重启后看到打印,故障 PC 指向了一个不可能的地址——0xFFFFFFFF 附近,明显是函数指针被踩坏。
通过 CMBacktrace 的自动栈回溯功能,我们拿到了更完整的调用链,发现 PC 是从一个任务回调入口跳过去的,而该回调的注册应该发生在初始化阶段。顺着调用链检查,最终定位到一块内存越界写:某个结构体数组的索引在极端时序下超界,正好把回调函数指针区域覆盖了。这个案例说明一个方法论:HardFault 的 PC 不一定直接指向 bug 所在行,但它一定是"第一现场"。拿到 PC/LR 后,优先在反汇编窗口里看这条指令附近访问了哪个地址、调用了哪个函数,然后沿着 LR 回溯调用链,比漫无目的地搜索日志有效得多。
3.3 栈顶标记与内存边界检查:把故障消灭在"可检测"范围
HardFault 定位得再准确,也属于事后补救。更高级的做法是在系统设计时就让故障更早暴露。常见的两个手段是栈填充标记和内存保护单元(MPU)。
栈填充标记的思路很简单:在系统初始化时,把任务栈区域全部填充为固定值,比如 0xDEADBEEF 或 0xCD。定期或在任务切换时,扫描栈空间末尾一段区域,如果发现填充值被改写,说明栈已经溢出到了一定深度。RT-Thread 的rt_thread_delay等调度点会检查栈顶标记,但这个机制只能检测溢出后的情况。很多团队会再加一个"水位线"统计,实时确认每个任务实际使用了多少栈,这能帮你在开发阶段就选对栈大小,而不是上线后靠偶发死机来教训。
MPU 的用法更硬核:把关键数据区域、外设寄存器区域、栈区配置成不同的访问权限和内存属性。比如把任务栈所在区域设置为"写入后立即产生 MemManage 异常"的权限,越界访问会在发生时直接被捕获,而不是等几小时后系统慢慢崩坏。STM32 的某些系列支持 MPU 的 privilege 属性和不可执行属性,对于防止"数据区被当代码执行"这类问题非常有效。不过 MPU 配置要特别小心区域重叠和总线操作类型,我在项目中见过因为 MPU 配置不当导致 CPU 正常工作也被异常打断的情况,所以做 MPU 裁剪前一定要先读懂 ARM 内存模型。
3.4 中断中崩溃的额外陷阱:双错误和总线错误
如果 HardFault 发生在中断处理函数内,问题会更棘手一些。这种情况下,栈帧结构、压栈的寄存器来源、以及对嵌套异常的处理,都和处理线程模式下的异常不同。一个特别常见的坑是:在中断处理函数里触发了总线错误(比如访问外部外设地址时总线未使能),引发 HardFault,如果 HardFault 处理函数本身又访问了非法外设,就会触发双错误(Double Fault),最终进入 Lockup 状态。Lockup 是芯片彻底死掉的状态,调试器只能通过复位恢复。
避免 Lockup 的一个实用措施是:在 HardFault_Handler 里不要做复杂操作,只做最必要的现场保存,然后死循环等待外部看门狗复位或人为介入。后续通过非易失存储的上报数据来定位。另外,如果你用到了外部存储控制器,建议把对应外设的时钟开关状态也保存到现场里,很多总线错误其实是因为时钟被关闭了还去访问寄存器。
4. OTA 升级工程化:从全量包、差分包到加签验签与失败回滚
4.1 先搞清分区表:OTA 的根,不在于"下载"而在"摆放"
OTA 升级最核心的设计决策不是用哪颗芯片、跑什么协议,而是 Flash 分区表怎么分。很多项目第一次做 OTA 时,只划分了 bootloader 区和 app 区,然后把下载的固件直接覆盖 app 区。这种方式最大的问题是:升级过程中一旦断电或写入失败,app 区既不是完整的新固件,也不是完整的旧固件,设备变砖。
工程上至少要有三块区域:Bootloader 区、APP 区、Download(下载缓存)区。升级流程是先把新固件下载到 Download 区,做完整性和签名校验,校验通过后再把新固件搬运或切换到 APP 区。更进一步的方案是 A/B 分区(或者说双槽方案):设备维护两份可运行的 APP,分别叫 slot A 和 slot B,Bootloader 根据标志位决定从哪个 slot 启动。升级时往当前不用的 slot 写入新固件,写完后更新标志位,重启后 Bootloader 从新 slot 启动,如果新固件运行失败,可以由 Bootloader 自动回退到旧 slot。这种方案的代价是 Flash 占用翻倍,但对于要求高可靠性的产品,这笔账是值得的。
分区表大小要根据具体 Flash 容量精打细算。以 16MB SPI Flash 为例,我建议这样分配:Bootloader 区 64KB,APP A 区和 APP B 区各 4MB(或者不启用双槽时留一个 download 区 4MB),标志/参数区 64KB,剩余部分做文件系统或数据存储。标志区建议单独放在一个扇区,并使用多份备份和三态标志(待升级、可启动、启动失败),防止标志区写入一半时断电导致状态混乱。
4.2 升级流程设计:下载、校验、切换三步之间必须有的保护
OTA 的完整流程可以抽象成三句话:先下载到安全区域,再校验完整性,最后做切换。但每一句背后都有大量细节。
下载阶段,我推荐用断点续传和分段校验。常见做法是固件包按 256 字节或 1024 字节分块,每一块带 CRC32 或基于块号的校验值,接收方逐块校验后写入 Flash。如果中间断网或断电,重新启动后可以从最后一块有效位置继续,而不是从头下载。在资源受限的 MCU 上,这个分块写入还能和 Flash 的扇区大小对齐,减少读改写操作,延长 Flash 寿命。
校验阶段,完整性校验(比如 SHA-256)只解决"数据有没有损坏"的问题,不解决"数据是不是可信的问题"。如果你面对的是可以物理接触设备的恶意攻击者,或者升级包可能被替换的场景,必须加上签名验签。常用的做法是:用 ECDSA P-256 或 RSA-2048 对固件摘要做签名,Bootloader 里烧录对应的公钥,升级时先验签再安装。工程上要注意,公钥一旦被替换,整个信任链就崩溃了,所以公钥区最好由 Bootloader 写保护,并提供防回滚机制——版本号只能递增,禁止从高版本降回低版本,否则攻击者可以把设备降级到带漏洞的旧固件上。
切换阶段,细节更多。如果采用 Single Slot + Download 区设计,搬运固件时要考虑"搬一半断电"的情况,因此搬运前要把标志区写成"升级中",搬运完成后再写成"升级完成,可启动"。Bootloader 在每次启动时读到"升级中"标志,就知道上一次升级没有结束,可以用 Download 区残留的数据再试一次,或者直接选择回退到旧版本。如果采用 A/B 分区,切换就是改一个 slot index,然后复位。之后新固件启动后,应用层要主动上报"运行正常",Bootloader 才会把新 slot 标记为 confirmed,否则下一次重启会回退。
4.3 ESP32 与 STM32:两种平台 OTA 落地的典型差异
ESP32 的 OTA 机制在 MCU 里算做得比较完善的。它内部有专门的 ota_data 分区来记录当前启动的是哪个 app slot,esp_ota_ops和esp_https_ota库封装好了下载、校验、切换、回滚的完整流程。使用 ESP32 做产品时,我建议优先用官方例程改,而不是自己从头写 OTA。官方库处理了很多边缘情况,包括分片写入 NVS、标记 app 为 invalid 实现回滚等。
STM32 没有统一官方 OTA 方案,多数项目要靠自己落地。关键差异在于 STM32 的向量表偏移必须正确设置,并且 Bootloader 跳转 App 时要关闭全局中断、恢复默认栈顶、重新设置 VTOR。这些步骤顺序如果错了,App 启动后中断异常无法解释。STM32 的 IAP 通常通过 UART/USB/CAN 接收固件,下载到片内 Flash 或外部 SPI Flash。我更推荐把新固件先放到外部 SPI Flash,校验通过后再写入内部 Flash,因为内部 Flash 的擦写次数有限,直接边下载边写内部 Flash 风险太高。
4.4 汽车嵌入式场景的额外要求:加签验签只是起点
汽车嵌入式软件的 OTA 对安全性和一致性的要求更高。除了签名验签,还要考虑固件加密传输、安全启动、刷新会话管理、以及和诊断协议(比如 UDS)的配合。汽车 ECU 做刷写时,不太可能像 MCU 那样直接操作一个 UART 下载固件,而是先建立诊断会话,做安全访问认证,再通过 34/36/37 服务完成请求下载、数据传输、退出传输三个步骤。
这一层我虽然没有在纯 MCU 产品里全部落地过,但和 Tier1 合作时学到的一个重要思路是:加签验签只是起点,真正难的是如何在被测设备上建立一个"认证-授权-传输-存储-执行-回滚"的完整链。对应到普通嵌入式产品,至少要做到:公钥不可被应用层篡改、固件包有独立版本号、升级状态掉电可恢复、回滚后有日志可查。这些要求不是汽车独有的,只是汽车行业把标准量化得更明确,其他行业可以拿来当参考。
4.5 OTA 实测中的三个反直觉教训
第一,下载协议用 HTTPS 比自定义 TCP + 校验更省心,因为 TLS 本身提供了完整性保护,还可以通过 CA 证书防止中间人替换固件包。第二,升级过程中的看门狗要处理好,很多方案在写入 Flash 时因为耗时过长触发看门狗复位,导致升级永远失败。解决方式是升级期间暂停喂狗或延长看门狗超时,但暂停喂狗时要有配套的死循环保护,比如超时强制失败回滚。第三,稳定的 OTA 版本切过去不代表万事大吉,建议新固件在运行 24 小时后才允许被 "confirmed",否则某些时序问题或内存泄漏会导致设备在刚升级后表现正常、运行半天后崩溃,此时再回滚已经晚了。
5. 上篇思考题完整解析:四道题,覆盖启动、崩溃与升级的真实场景
上篇专栏留了四道思考题,不少读者在评论区给了自己的答案,有些思路很好,有些则踩了典型的坑。下面逐题展开我的分析和参考答案,不保证是唯一正确答案,但每道题的思考路径都来自实际项目验证。
5.1 为什么 Cortex-M 复位后要从 0x00000000 取栈指针,而不是直接跳 C 语言的 main?
标准答案:Cortex-M 的硬件设计基于"中断向量表在地址 0"的模型。地址 0x00000000 存放初始栈顶,地址 0x00000004 存放复位向量,这是架构约定。硬件复位后先把栈顶加载进 MSP,是为了让后续的 C 函数调用有栈可用,因为 C 语言运行的前提是栈已经初始化。如果直接跳 main,main 函数第一条指令使用栈时(比如保存局部变量),MSP 还是未知状态,必然异常。
这道题更深层的意义在于提醒你:启动文件不是可有可无的模板代码。GCC 环境下如果你做了-nostartfiles并且自己实现了_start,一定要记得设置栈指针。很多嵌入式开发者第一次做 freestanding 环境时在这里翻车。
5.2 当 BootLoader 跳转到 APP 时,为什么要先关中断、再设置 VTOR?
标准答案:如果不关闭全局中断,跳转过程中一旦产生中断,CPU 会拿着 BootLoader 的向量表去处理中断,而中断服务函数可能已经在 App 中被重新实现了,地址不匹配,轻则中断丢失,重则跳飞到非法地址 HardFault。设置 VTOR 是为了让内核知道新向量表的位置,只有向量表切过去后,中断才能按 App 的设计运行。
实际操作中有一点经常被忽略:跳转前要把 SysTick、外设中断等所有在 BootLoader 阶段打开的中断源全部关闭。我曾经遇到过 BootLoader 里开了 UART 接收中断,跳转时没有关,App 启动后不断收到残留字符产生莫名其妙的中断行为。真正稳妥的跳转流程是:关全局中断 -> 关闭外设时钟(可选) -> 设置主栈指针 -> 设置 VTOR -> 重新使能中断。
5.3 中断里的 HardFault 和普通线程里的 HardFault,现场还原有什么不同?
标准答案:区别在栈指针和栈帧来源。普通线程模式下发生异常,压栈使用的是 PSP,通过 LR 的 EXC_RETURN(0xFFFFFFFD)可以判断;中断/异常处理过程中再发生异常,压栈使用的是 MSP,EXC_RETURN 为 0xFFFFFFF9。如果故障发生在更高优先级异常抢占后,栈中可能嵌套多层栈帧,解析时要从当前 SP 逐层向外找,不能只取最近一层。
另外一个容易忽略的问题是:如果使能了 FPU,压栈的寄存器还包含浮点寄存器组(S0~S15 等),此时栈帧长度和普通异常不同,解析 PC 的偏移量要对应调整。否则打出来的 PC 是错的,定位结论自然也不对。
5.4 为什么不建议在升级时直接覆盖 App 区?
标准答案:覆盖写是一个不可逆的破坏过程。写入过程中一旦掉电或擦写异常,App 区就变成半成品,Bootloader 既无法跳转新版本,也无法回退旧版本。而使用 download 缓存区加标志位的方式,整个过程可中断、可恢复、可回滚。Bootloader 每次启动时先查标志:如果发现上次升级未完成,可以重新尝试从缓存区恢复,或者直接启动旧版本。这比任何"断电保护"都更可靠。
如果还想更稳妥,可以把标志区设计成三个独立扇区轮换写入,避免频繁擦写同一扇区导致磨损不均。实测下来,批量产线上即使偶发升级断电,只要标志区设计对了,返修率几乎可以降到零。这也是我每次做 OTA 时最先检查的一项。
从启动流程到故障定位,再到 OTA 工程化,这三块内容表面上看是三个独立主题,实际是一条完整的工程逻辑链:启动代码管的是"系统怎么活着",异常定位管的是"活着出问题时怎么查账",OTA 管的是"怎么安全地换一种活法"。我自己在带团队时,会要求每个固件工程师拿到一块新板子的第一周必须做三件事:画启动流程图、写一个 HardFault 现场打印函数、给板子规划一个合理的 Flash 分区表。这三件事做完,后面大部分疑难杂症都会好查很多。下一期打算聊聊嵌入式设备的内存管理方法,包括内存池设计、内存泄漏检测和 RTOS 下的内存调优,如果你们有特别想看的主题,也可以直接评论区告诉我,我按照实际项目经验来安排。