单片机BootLoader升级失败:分区、跳转与掉电保护实战 产线上的夹具一次给几十块板子烧录进度条走到 100%重启后屏幕停在开机界面一动不动更气人的是另一种情况——单板在实验室里升级一百次都没事一到客户现场就有一两成直接变成砖。单片机 BootLoader 升级程序失败这类问题我见得太多了真正卡在通信协议上的不到三成剩下七成全挤在三个地方分区边界算错、跳转前的现场没清干净、写入过程没有掉电保护。这三点任何一处出问题症状都长得差不多数据写进去了校验也过了重启就是不跑。下面这篇把这三块拆到底从 Flash 分区怎么切、升级标志怎么存、跳转序列怎么写、擦写参数怎么配一直到现场怎么抓取故障现场给出可以直接抄的代码和可以直接对表的参数。不管你是在做第一个 IAP 功能还是已经被批量返修逼到想推倒重写 BootLoader这些内容应该都能帮你省掉几天时间。文中涉及具体芯片的地方我会用通用描述寄存器名沿用内核架构规范里的叫法换一颗芯片照样能对上号。1. 先给故障分类别一上来就加打印1.1 三类失败的症状其实完全不同BootLoader 升级失败这个说法太笼统实际上它是三种完全不同的病混在一起排查只会浪费时间。第一类是进不去上电直接跑 App或者卡在 HardFault 死循环里连升级命令都收不到主机发什么都是超时。第二类是写不进握手正常、数据能发、进度条也动了但擦除返回错误或者写进去读回来对不上。第三类是跳不回数据全部写完整包 CRC 也对得上重启后 App 就是不运行屏幕黑的、串口没输出。这三类的排查路径几乎没有交集。第一类要去查启动判定逻辑和参数区内容第二类要去看 Flash 控制器状态、写保护、供电和看门狗第三类基本锁定在跳转代码和 App 端的链接配置上。我踩过的坑里很多人一遇到升级失败就去抓通信波形抓了两天发现串口数据干干净净问题在跳转函数里少关了一个 SysTick。所以动手之前先花五分钟做分类比事后加二十条打印有用得多。分类的方法很土但有效拿一块故障板用调试器连上去看 PC 停在哪。停在 BootLoader 的等待循环里就是第一类或第二类停在 HardFault 或者某个莫名其妙的地址就是第三类反复复位去看复位原因寄存器。这一步做完排查范围立刻缩小到三分之一。1.2 从现象反推根因的对照表下面这张表是我自己排查时用的左边是现象中间是高概率根因右边是怎么验证。表里的顺序大致按出现频率排越靠前越常见。现象高概率根因快速验证方法升级完重启仍停在 Boot 等待态升级标志写入失败、参数区被误擦、标志 CRC 不过调试器直接读参数区比对魔数和状态字重启后进 HardFault向量表没重定位、MSP 设置错误、Boot 的中断没关抓 CFSR/HFSR看压栈 PC 落在哪个地址区间升级中途周期性重启擦除耗时长导致看门狗超时擦除前后翻转 IO示波器量高电平持续时间擦除返回失败写保护、读保护选项未解除、地址不在可擦区读选项字节核对擦除地址范围写入后读回不一致未按写粒度对齐、未擦先写、等待周期配置不足写完立刻回读逐字节比对偶发失败重试就好供电跌落、复位不彻底、时序临界测电源纹波增加重试次数与超时小批量正常、大批量异常夹具供电能力不足、接触电阻、时钟偏差分批对比换夹具复测调试器下载能跑Boot 跳转不跑App 没做向量表重定位只靠调试器设置断电冷启动复现观察 VTOR 值这张表覆盖了八九成的现场情况。剩下那一两成通常是真的硬件问题比如 Flash 某个扇区擦写次数到顶了或者电源在低温下起不来这类只能靠换板对比确认。1.3 为什么这个问题的代价比想象中大很多人觉得升级失败大不了重刷一次实际上代价远不止于此。设备装到现场以后维护人员手里没有调试器唯一的升级通道就是这条 BootLoader。一旦它自己出问题设备就彻底失联只能返厂。返修一次的成本是出厂利润的好几倍还不算停机和信誉损失。更要命的是影响范围会放大。实验室里单板测试是点对点、短距离、稳定供电现场是长线缆、多设备并联、电源波动。同一个逻辑缺陷在实验室里出现概率是千分之一到了现场可能是百分之五一批两千台就是一百台返修。所以 BootLoader 的设计目标不是能升级而是在任何异常下都不会变砖。这个目标决定了后面所有的设计取舍为什么要有双备份、为什么标志位要双份冗余、为什么跳转前要校验栈顶值。2. 分区规划与启动决策的整体设计2.1 Flash 怎么切才不留隐患分区是整个方案的地基切错了后面全是补丁。我的习惯是把 Flash 切成五块BootLoader 区、参数区、App 主区、App 备份区、以及一块留给日志或出厂信息的尾部区域。BootLoader 区放在最前面因为复位后内核固定从最低地址取栈顶和入口这块位置没法挪。参数区的位置有讲究。放在 Boot 后面紧挨着好处是地址固定、访问简单坏处是如果 BootLoader 将来要扩容会撞上参数区。所以我一般给 BootLoader 预留出比当前实际占用大一倍的空间宁可浪费几十 KB也不要为了省这点空间在后期做数据搬迁。搬迁意味着老设备升级新 BootLoader 时参数会丢这个操作风险极高能一次规划好就别返工。区域相对起始典型大小用途是否参与升级BootLoader0x00000032 KB启动判定、通信、擦写、跳转原则上不升级参数区 A/B0x0080002 × 2 KB升级标志、版本、进度、复位原因读写频繁需冗余App 主区0x010000120 KB当前运行固件是App 备份区0x02E000120 KB新固件暂存是信息区0x04C0008 KB出厂参数、日志、序列号否App 主区的大小要按未来两年功能增长来估不是按当前编译出来的体积。我见过太多项目一开始 App 只占 40 KB分区给了 48 KB半年后加个图形界面直接爆掉只能改分区表所有已出货设备的参数全部失效。2.2 升级标志该长什么样、存在哪升级标志不是存一个字节就完事。它要承载的信息至少包括当前状态、目标镜像的版本和大小、镜像校验值、已写入进度、失败重试次数。这些字段凑在一起就变成了一个结构体。typedef struct { uint32_t magic; /* 固定魔数用于识别是否初始化过 */ uint16_t hw_version; /* 硬件版本防止刷错型号 */ uint16_t fw_version; /* 固件版本号 */ uint32_t img_size; /* 镜像字节数 */ uint32_t img_crc; /* 整包 CRC32 */ uint32_t written; /* 已写入字节数用于断点续传 */ uint8_t state; /* 见下表状态机 */ uint8_t retry; /* 失败重试计数 */ uint16_t reserved; uint32_t seq; /* 写入序号双份存储取新 */ uint32_t crc; /* 本结构体的 CRC32 */ } boot_info_t;存两份地址错开每份都带自己的 CRC 和递增的 seq。读取的时候两份都算一遍 CRC挑出 CRC 正确且 seq 较大的那一份。这样即使写入过程中掉电把其中一份写坏了另一份还是完好的。参数区擦写次数远高于 App 区所以每次写入都要走擦旧的那一份、写新数据、更新 seq的顺序绝对不能两份一起擦——那是自寻死路。state字段是整个方案的核心我一般定义六种状态状态值含义进入条件下一步动作0空闲出厂或确认完成后正常启动 App1待升级主机下发升级命令擦备份区、接收数据2升级中数据接收开始断点续传3待验证数据写完且校验通过跳备份区试运行4已确认新固件自检通过清除标志正常启动5需回滚试运行超时未确认恢复主区启动state3这一条是解决新固件能跑起来但功能有问题的关键。新固件启动后必须主动把状态改成 4否则下次上电检测到状态还是 3 且重试次数超限自动回滚到旧固件。这个机制救过我几次——有一次新版本在某种配置下初始化失败跑飞设备自己回滚了现场只表现为升级没成功而不是设备变砖。2.3 启动状态机怎么走BootLoader 的主流程其实很短关键在于每一步的判定都要有明确的失败出口。void boot_main(void) { boot_info_t info; boot_cause_t cause get_reset_cause(); /* 记录复位原因 */ if (!info_load(info)) { /* 两份都读不出来按空片处理停在 IAP 等待 */ enter_iap_wait(INFINITE); } switch (info.state) { case ST_IDLE: if (!image_check(APP_MAIN)) { /* 主区镜像无效 */ enter_iap_wait(INFINITE); } jump_to(APP_MAIN); break; case ST_TRIAL: if (info.retry TRIAL_MAX) { /* 试运行失败次数超限 */ info.state ST_ROLLBACK; info_save(info); NVIC_SystemReset(); } info_save(info); jump_to(APP_BAK); break; case ST_ROLLBACK: rollback_restore(); /* 备份区覆盖主区 */ info.state ST_IDLE; info_save(info); NVIC_SystemReset(); break; case ST_PENDING: case ST_WRITING: enter_iap_wait(CONFIG_TIMEOUT); /* 等主机继续升级 */ break; default: info.state ST_IDLE; info_save(info); NVIC_SystemReset(); } }注意enter_iap_wait里要带超时。无限等待意味着设备永远停在 Boot 里不动现场看起来就是开机没反应比跑错固件还难排查。给个三十秒到两分钟的超时超时就尝试恢复主区启动实在不行再复位重试。2.4 双备份到底值不值多占一倍 Flash 换来三件事升级过程掉电可恢复、新固件有问题可回滚、升级期间设备仍能正常运行。这三件事在小批量产品里可能感觉不到价值一旦上了千台规模任何一次变砖都会变成一次出差。如果产品 Flash 实在紧张至少也要保留升级标志 整包校验 掉电断点这三样。哪怕不做双备份只要标志写对了、校验做全了掉电后重新连接还能从断点继续用户感知只是多等几分钟而不是设备报废。双备份是锦上添花断点续传是保命底线预算不够的时候先保底线。3. 跳转这一步六个必做动作与一段能直接用的代码3.1 关中断不只是调一个函数跳转到 App 之前必须把 BootLoader 里打开的所有中断源清干净。很多人只调了__disable_irq()这只能挡住 CPU 响应中断挡不住中断标志位还挂在 NVIC 里。等 App 里重新__enable_irq()的那一瞬间所有挂起的中断会立刻触发而此时对应的外设可能还没初始化、处理函数可能根本不存在直接就飞了。正确的做法是三层都清先关全局中断再把 NVIC 的使能寄存器和挂起寄存器全部写一遍最后把 SysTick 停掉。SysTick 特别容易被忘因为它是内核外设而不是片上外设藏在启动文件里悄悄跑着一旦它触发中断就会跳到 BootLoader 的向量表——如果 VTOR 已经改了那就是跳到 App 向量表的错误位置。__disable_irq(); for (uint32_t i 0; i 8U; i) { NVIC-ICER[i] 0xFFFFFFFFU; /* 清除使能 */ NVIC-ICPR[i] 0xFFFFFFFFU; /* 清除挂起 */ } SysTick-CTRL 0U; SysTick-LOAD 0U; SysTick-VAL 0U;如果 BootLoader 里开了 DMA、串口空闲中断、定时器还要在关闭中断之前把这些外设停掉尤其是 DMA。DMA 是独立运行的关中断对它无效它会在你跳转之后继续往内存里搬数据把 App 刚初始化的缓冲区踩烂。这种故障极难复现因为取决于升级完成后多久发生跳转。3.2 向量表重定位的地址必须对齐VTOR 寄存器存放的是向量表基地址它的低几位是保留位写入非对齐地址会被硬件截断或者触发错误。不同内核保留的位数不一样保守做法是按 512 字节对齐一般 App 的起始地址按扇区对齐天然满足这个条件。重定位要在两个地方都做BootLoader 跳转前做一次App 启动后自己再做一次。原因是调试器可以直接把 App 下载到芯片里运行这条路径不经过 BootLoader如果只靠跳转前的设置调试器下载后一运行就挂。App 端把重定位放在SystemInit里最稳妥因为它在main之前执行早于任何外设初始化。/* App 端 SystemInit 里调用 */ SCB-VTOR APP_BASE_ADDR; __DSB(); __ISB();__DSB和__ISB这两条屏障指令不能省。写 VTOR 之后流水线里可能还缓存着旧向量不加屏障的话紧接着发生的中断仍会跳到旧表。这类问题在低速调试时看不出来量产后才偶发。3.3 外设和时钟要复位到干净状态BootLoader 为了跑通信通常会把主频拉得比较高、把串口和定时器都开起来。App 启动时如果按自己的配置重新初始化很多时候会失败——因为时钟树已经被人改过了PLL 还锁着你再配一遍就冲突。处理方式有两种。一种是跳转前把所有用到的外设和时钟逐个复位切成内部低速时钟关掉 PLL。这种方式干净但代码长每加一个外设就要记得加一句复位容易漏。另一种是把复位动作放到 App 的时钟初始化函数里先做一次完整的时钟树复位再按自己的需求配置。后一种更省心前提是 App 的初始化函数写得够健壮。我倾向于两种都做BootLoader 跳转前做一次粗粒度的时钟降频和外设关闭App 端再做一次完整重配。双保险的代价只有几十行代码换来的是一次都不用去现场救砖。3.4 设置 MSP 时最隐蔽的那个坑设置主栈指针只有一行代码但这一行之后的任何 C 语句都可能出问题。原因是__set_MSP(sp)改变的是当前正在使用的栈函数返回地址和局部变量全在旧栈上。改完之后编译器生成的局部变量访问指令仍然基于新的 SP 去取取到的是垃圾数据。这个问题在不开优化、变量全在寄存器里的情况下可能刚好不出错看起来一切正常。一旦换了编译选项、加了一句打印、或者编译器决定把某个变量放到栈上立刻就崩。排查的时候会怀疑人生因为同一份代码改一个无关紧要的地方就挂了。正确写法是用内联汇编把栈指针和入口地址都锁在通用寄存器里中间不插任何 C 语句static void jump_to_image(uint32_t sp, uint32_t entry) __attribute__((noreturn)); static void jump_to_image(uint32_t sp, uint32_t entry) { __asm volatile ( msr msp, %0 \n bx %1 \n : : r(sp), r(entry) : memory ); __builtin_unreachable(); }bx指令的末尾那一行永远执行不到但要给编译器一个明确的不返回标记否则它会在后面生成栈回收代码把刚设好的 MSP 又改掉。3.5 一段带完整校验的跳转函数把上面这些东西串起来再加两层镜像合法性检查就是一个可以直接用的版本。检查里最关键的是 Thumb 位和栈顶范围这两条前者挡住入口地址出错后者挡住空片和越界。#define APP_BASE 0x08010000U #define APP_MAX_SIZE (120U * 1024U) #define SRAM_BASE 0x20000000U #define SRAM_TOP 0x20020000U static int image_is_valid(uint32_t base) { uint32_t sp *(volatile uint32_t *)(base); uint32_t pc *(volatile uint32_t *)(base 4U); if (sp 0xFFFFFFFFU || pc 0xFFFFFFFFU) return 0; /* 空片 */ if (sp SRAM_BASE || sp SRAM_TOP) return 0; /* 栈顶越界 */ if ((sp 0x3U) ! 0U) return 0; /* 未四字节对齐 */ if (pc base || pc base APP_MAX_SIZE) return 0; /* 入口不在区内 */ if ((pc 0x1U) 0U) return 0; /* Thumb 位缺失 */ return 1; } void boot_jump(uint32_t base) { if (!image_is_valid(base)) { enter_iap_wait(INFINITE); return; } uint32_t sp *(volatile uint32_t *)(base); uint32_t pc *(volatile uint32_t *)(base 4U); __disable_irq(); for (uint32_t i 0; i 8U; i) { NVIC-ICER[i] 0xFFFFFFFFU; NVIC-ICPR[i] 0xFFFFFFFFU; } SysTick-CTRL 0U; SysTick-LOAD 0U; SysTick-VAL 0U; SCB-VTOR base; __DSB(); __ISB(); jump_to_image(sp, pc); }(pc 0x1U) 0U这一条我得单独说一句。Cortex-M 内核只支持 Thumb 指令集所有函数指针的最低位必须是 1跳转时硬件会自动把它清掉当作地址用。如果编译出来的入口地址最低位是 0bx上去会立刻触发 UsageFault。而向量表里的第二个字正常情况下就是入口地址加 1如果这里读出来最低位是 0说明镜像本身有问题或者读错了地址拦下来比跳过去强。3.6 App 端要配合改的几处配置BootLoader 这边做完了App 那边不改照样跑不起来。最少要动三个地方链接脚本里的起始地址、启动文件里的向量表重定位、以及堆栈大小的重新估算。链接脚本改的是 FLASH 起始地址和长度改完之后所有符号地址会自动偏移不用手动改。但要注意栈顶符号也跟着变了如果启动文件里写死了栈顶地址就会对不上。MEMORY { FLASH (rx) : ORIGIN 0x08010000, LENGTH 120K RAM (rwx) : ORIGIN 0x20000000, LENGTH 128K }启动文件里的重定位位置要放在SystemInit调用之后、跳main之前。有些工程把SystemInit放在复位处理函数里那就直接在SystemInit末尾加一行设置 VTOR 的代码。还有一种做法是定义一个宏让链接脚本和代码共用同一个起始地址避免改了一处忘了另一处。堆栈大小需要重新评估因为 BootLoader 和 App 是两套独立的栈。App 的栈顶由它自己向量表的第一个字决定跟 BootLoader 完全无关所以不用担心互相踩踏。但 App 内部的栈大小如果还是按以前的标准给加了新功能之后可能出现栈溢出表现是偶发的 HardFault跟升级流程本身没关系容易被误判成跳转有问题。4. Flash 擦写与升级流程的落地实现4.1 擦除粒度、写对齐与等待周期Flash 的物理特性决定了三件事擦除按页或扇区为单位写入按半字或双字为单位擦除之后所有位都是 1。这三条听着简单写错的人不少。最常见的错误是按字节写。有些芯片支持字节写有些只支持半字对齐写写一个奇数长度的数据包时如果没有补齐最后半个字会写失败或者写进去脏数据。正确做法是把不足一个写单位的数据补齐成 0xFF因为 Flash 擦除后的状态就是全 1补 0xFF 不会改变最终内容。第二个错误是跨页写入没处理。一个数据包的大小通常跟页大小不一致接收缓冲跨在两页之间时如果直接按起始地址顺序写下去写到页边界之后的部分会被硬件拒绝或者写到错误位置。稳妥做法是先把数据攒够一整页再擦再写页内一次写满不做零散写入。第三个错误是等待周期没配。主频从 8 MHz 提到 120 MHzFlash 的读取速度跟不上必须插入等待周期。不配的话读 Flash 会返回错误数据表现是程序随机跑飞或者变量值莫名其妙而且只在高速下出现降频调试时一切正常。这个问题在 BootLoader 里尤其危险因为跳转代码本身就在 Flash 里执行读错了连跳转都跳不对。4.2 分块接收与页缓存的具体做法升级数据是一包一包来的包大小通常几十到几百字节跟 Flash 页大小对不上。我的做法是在 RAM 里开一块跟页大小相同的缓冲收到的数据先往里塞塞满了就擦一页写一页写完了清空继续塞。#define PAGE_SIZE 2048U static uint8_t page_buf[PAGE_SIZE]; static uint32_t buf_len 0U; static uint32_t write_ofs 0U; int upgrade_feed(const uint8_t *data, uint32_t len) { while (len 0U) { uint32_t space PAGE_SIZE - buf_len; uint32_t take (len space) ? len : space; memcpy(page_buf[buf_len], data, take); buf_len take; data take; len - take; if (buf_len PAGE_SIZE) { if (write_ofs % PAGE_SIZE 0U) { if (flash_erase(APP_BAK write_ofs) ! OK) return -1; } if (flash_write(APP_BAK write_ofs, page_buf, PAGE_SIZE) ! OK) return -1; write_ofs PAGE_SIZE; buf_len 0U; progress_save(write_ofs); } } return 0; }这段代码里progress_save的位置很关键。它必须放在每页写成功之后而不是全部写完再存一次。放在每页之后掉电最多损失一页数据重连后从上一页末尾继续重传量很小。放在最后的话掉电等于前功尽弃用户得从头再来一遍。4.3 校验要做成三层别只做一层只做一层校验的方案我见过不少通常是在整包接收完之后算一次 CRC32。这层校验能挡住数据在传输过程中出错但挡不住另外两种情况一是数据包乱序或者丢包但每个包自己的 CRC 都是对的二是刷了错误型号的固件数据本身完好无损。所以我一般做三层。第一层是帧级校验每个数据包带 CRC16校验不过就丢包重传这一层挡传输干扰。第二层是整包 CRC32全部接收完之后对着参数区里记录的期望值算一遍这一层挡拼装错误。第三层是元数据校验检查魔数、硬件版本号、固件长度是否合理这一层挡刷错型号——把 A 型号的固件刷进 B 型号的设备数据一点没坏但跑起来肯定是各种异常。校验层级算法覆盖范围失败后动作帧级CRC16 或校验和单个数据包丢弃该包请求重传整包CRC32全部镜像数据清除标志重新开始元数据字段比对魔数、硬件版本、长度直接拒绝升级硬件版本这层特别值得加。产品线一旦分出多个型号固件混刷是迟早的事。在参数区里存一个硬件版本号升级前比对不匹配直接返回错误码比事后现场排查省事太多。4.4 断点续传怎么做到不重来断点续传的前提是主机和从机对写到哪了有共识。从机这边靠written字段记录主机在握手阶段先问一次当前进度然后从那个偏移继续发。这里有个细节容易忽略written必须按页对齐。假如掉电发生在某一页写到一半的时候这一页的数据是不可信的续传时应该从这一页的起始位置重写而不是从written记录的字节数继续。所以我在保存进度时统一向下取整到页边界续传时整页重写虽然多传了几百字节但逻辑简单不容易错。void progress_save(uint32_t written) { boot_info_t info; if (!info_load(info)) return; info.written written - (written % PAGE_SIZE); /* 对齐到页,重传整页 */ info.state ST_WRITING; info_save(info); }主机侧配合也很重要。续传开始时从机返回当前偏移主机要能从这个偏移开始重新组包而不是傻乎乎地从头发。协议设计的时候就把偏移字段加进握手应答里比后期打补丁容易。4.5 量产夹具升级的几个特殊处理实验室单板升级和产线批量升级是两码事。夹具一次带十几块板子同时擦写 Flash 的瞬时电流是单板的十几倍如果夹具的供电余量不够电压会被拉低表现就是随机几块板子升级失败而且每次失败的位置都不一样。处理办法有三个。一是夹具供电留足余量按峰值电流的两倍配。二是让升级流程分时进行不要所有板子同时擦除可以在启动擦除前加一个基于序列号的随机延时。三是在擦除前后留出足够的稳定时间让电源恢复。还有个问题是空片判定。全新出厂的板子 Flash 全是 0xFFBootLoader 读向量表第一个字得到 0xFFFFFFFFimage_is_valid会返回失败停在 IAP 等待。这是正确行为但产线要知道这个状态是正常的不能当成故障。更好的做法是在参数区里放一个出厂标记没有标记就认为需要首次烧录直接进 IAP 模式并闪灯提示让操作员一眼看出该干什么。4.6 升级完成后的验证不能只看返回值接口返回成功只代表数据写进去了不代表新固件能跑。我的流程里写完数据、校验通过之后还要做一次完整的冷启动验证断电、重新上电、观察启动时间、读取版本号、确认状态字已经从待验证变成已确认。这一步在产线上可以自动化用一个测试治具读串口输出的版本信息。如果新固件启动后没有在规定时间内把状态改成已确认BootLoader 会自动回滚产线读到的就是旧版本号立刻能发现异常。这套机制把看起来升级成功但实际跑不起来的问题挡在出厂之前比等到客户投诉强太多了。5. 现场排查实录与避坑清单5.1 高频问题速查表把几年下来遇到的典型故障整理成表左边是现象右边是踩过的具体原因。这张表建议直接打印贴在工位上排查的时候对着看。序号现象真实原因修复方式1跳转后立刻 HardFault跳转函数里__set_MSP之后还访问了局部变量改用内联汇编传参2偶发死在中断里SysTick 没关跳转后触发中断跳转前清零 SysTick 三个寄存器3调试器下载能跑Boot 跳转不跑App 端没设 VTOR只靠 Boot 设置App 的 SystemInit 里加 VTOR 赋值4入口地址触发 UsageFault向量表第二个字最低位为 0Thumb 位缺失校验pc 1为 0 拒绝跳转5升级到 60% 自动重启擦除耗时超过看门狗超时擦除前喂狗或临时关闭看门狗6升级完重启还是旧版本标志位在数据写完之前就置位掉电后状态不一致先写数据、再校验、最后写标志7参数区数据随机损坏参数区和 App 区地址重叠核对链接脚本与分区表8读到旧的升级标志双份参数区没做 seq 比较读取时取 CRC 正确且 seq 较大者9新固件跑一半就回滚确认动作写在某个可能失败的初始化之后把写已确认提到自检最开始10大批量升级成功率 80%夹具供电不足电压跌落加大电源余量、分时启动第 9 条值得多说一句。新固件启动后要尽快把状态改成已确认不能等到所有外设都初始化完、功能自检都过了再改。因为一旦中间某个环节失败跑飞状态还停在待验证下次上电就会回滚——这本来是保护机制但如果确认动作放得太靠后正常的新固件也可能因为某个非关键外设初始化慢而被误判成失败。5.2 抓取现场故障的四个手段设备到了现场手里没有调试器只能靠预先埋进去的信息。我一般在 BootLoader 里埋四样东西成本不高但管用。第一是复位原因。上电复位、看门狗复位、软件复位、引脚复位原因不同指向的问题完全不同。把原因记录下来每次启动时打印或者存进参数区事后能看到这个设备反复因为看门狗复位立刻就锁定到超时问题上。第二是 HardFault 现场。在 HardFault 处理函数里把 CFSR、HFSR、BFAR 这几个寄存器打出来再看压栈的 PC 值落在哪个地址区间。PC 落在 BootLoader 区说明是 Boot 自己出错落在 App 区说明是 App 出错落在 0 或者 0xFFFFFFFF 附近说明是函数指针飞了。void hardfault_report(uint32_t *frame) { uint32_t cfsr SCB-CFSR; uint32_t hfsr SCB-HFSR; uint32_t pc frame[6]; uint32_t lr frame[5]; log_write(CFSR%08X HFSR%08X, cfsr, hfsr); log_write(PC%08X LR%08X, pc, lr); if (cfsr (1U 1)) log_write(IMPRECISERR); if (cfsr (1U 9)) log_write(IBUSERR); if (cfsr (1U 15)) log_write(UNSTKERR); while (1) { } }第三是 RAM 里的不掉电区。定义一个放在.noinit段的变量复位后不会被启动代码清零可以用来记录上次跑到哪一步。升级过程中每完成一页更新一次出问题时读出来就知道卡在哪。第四是 IO 翻转加示波器。这条最土但最直接擦除前拉高一个引脚擦除后拉低示波器上量出来就是擦除耗时。如果发现某次擦除花了 200 毫秒而看门狗超时设的是 100 毫秒问题一目了然。5.3 十条踩坑记录跳转函数里用局部变量。__set_MSP之后整个函数的栈帧已经失效后面的代码能不能跑全看编译器心情。改成内联汇编一次解决。忘记关 SysTick。它不在外设时钟总线上很多关闭所有外设的代码根本管不到它必须手动写三个寄存器。改了链接脚本没改 VTOR。两个地方要同步改最好抽成一个宏改一处自动生效。Thumb 位没校验。空片时向量表第二个字是 0xFFFFFFFF最低位是 1能通过 Thumb 校验但栈顶检查会拦住它。真正需要警惕的是 Flash 内容部分损坏的情况。擦除时看门狗超时。擦一个大扇区可能要几十毫秒加上循环里其他操作很容易超时。升级前先确认看门狗超时时间不够就临时关掉跳转前再打开。标志位写得比数据早。这是逻辑顺序错误先置位后写数据中间掉电就出现标志说成功、数据不完整的状态下次启动直接跳到半截镜像里跑。参数区和 App 区重叠。链接脚本改了地址参数区忘了往后挪两个区域打架擦参数的时候把 App 开头擦掉了症状是升级后必挂而且挂得很规律。双份参数不做 seq。两份都写成功读的时候随便取一份取到旧的那份读出来的版本号比实际老表现为刚升级完又回退。用调试器下载 App 后不运行。调试器下载不经过 BootLoaderApp 自己如果没设 VTOR中断全乱。这条坑过很多新人表现是我明明下进去了怎么不跑。升级后立刻断电。Flash 写完最后一个字到数据真正稳定中间有个很短的窗口有些芯片需要等待操作完成标志。写完就断电偶尔会丢最后一页。5.4 一个容易被忽略的细节版本号只增不减回滚机制依赖版本号比较。如果允许版本号随意跳回滚就会出现回滚到比当前还新的版本这种荒谬情况。规则定死新固件版本号必须大于当前版本号特殊情况下需要降级走单独的强制降级命令并在参数区里留一条记录。产线上遇到过降级之后又自动回滚、来回折腾三次把设备卡死的情况就是因为版本号比较没做严格。5.5 关于彻底解决这件事的一点个人看法这类问题不存在一劳永逸的解法只存在把失败模式一个个堵死。我的做法是把每一个可能出错的环节都做成可观测的跳转前打印校验结果擦除时翻转 IO写入后回读比对启动后报告复位原因。这些埋点加起来占不了几 KB 代码但每次出问题都能在十分钟内定位到具体环节而不是靠猜。另外一个习惯是保留一份最小可复现工程。把所有与芯片相关的操作抽出来做一个小小的工程只做三件事擦一页、写一页、跳过去。任何一颗新芯片上手先把这个工程跑通再往正式代码里搬。这样能把芯片差异和业务逻辑彻底隔离开出问题的时候一眼就能看出是底层操作不对还是上层流程不对。这套方法用下来我经手的项目里BootLoader 相关的现场故障从最初的每批次都有降到了两年零返修。