
做嵌入式产品最怕什么不是功能调不完而是产品已经出货现场一台设备突然死机、App跑飞或者用户远程升级升到一半断了电设备直接变砖。我去年做的一个基于STM32F103VET6的采集终端就踩过这种坑后面痛定思痛花了两个星期把原来的单区IAP方案整个推翻改用W25Q64外部Flash做BootloaderApplication双备份才真正把远程升级这最后一公里的风险堵住。这套方案的核心思路很直接STM32F103VET6内部虽然有512KB Flash但要在同一个芯片里同时放下Bootloader和可回滚的Application再预留OTA下载缓冲区空间其实非常紧张。所以我干脆把固件存储从内部Flash挪到外挂的W25Q64上利用它的8MB容量做A/B双分区配合Bootloader的启动决策逻辑实现任何时候都有至少一个可用App版本的效果。这篇文章就把这套方案的架构设计、分区规划、核心代码和联调过程中踩过的坑完整记录下来给正在做STM32 Bootloader、OTA升级或者刚接触W25Q64这类SPI NOR Flash的朋友一个能直接落地的参考。1. 为什么双备份要做在W25Q64外部Flash上1.1 F103VET6的Flash资源账STM32F103VET6是F1大容量系列里的顶配型号之一内置512KB Flash和64KB SRAM。乍一听512KB很大但你认真规划IAP方案时就会发现这个容量做单区App绰绰有余做双备份就相当尴尬了。Bootloader至少要放下载协议栈、Flash驱动、外部Flash驱动和引导逻辑起步占用30~60KB剩下448KB左右给App用。假如固件本身就有300KB你再想把当前运行版本和待升级版本同时放在内部Flash做A/B那预留空间按两倍算得600KB内部Flash根本容不下。这时候外挂Flash就成了一个很自然的选择。W25Q64是一颗非常成熟的SPI NOR Flash容量8MB电压范围2.7~3.6V和STM32F103VET6共用3.3V电源轨接口只需要四根线CLK、MISO、MOSI、CS。MCU端随便挂一路SPI就能驱动驱动代码在各大平台已经被写烂了可靠性和兼容性都有充分验证。8MB的空间足够放下多个版本的固件还能额外划出状态区、参数区、日志区设计上非常从容。这里也顺便说清楚一个概念IAP和Bootloader经常混着说但IAPIn Application Programming是指应用运行过程中可以编程FlashBootloader则是专门负责引导和升级的那段独立程序。本文里这套方案Bootloader负责启动决策、校验、搬移和跳转App负责下载固件到外部Flash暂存区两边各管一段职责非常清晰。1.2 双方案对比为什么单区IAP不够稳我最早用的方案是典型的单区IAPBootloader在0x08000000App从0x08010000开始升级时App通过串口把新固件直接写入内部Flash覆盖自己。这个方案代码写起来确实快但有一个致命问题内部Flash擦写过程中一旦断电或者通信中断正在运行的App已经被擦掉一半新固件又没写完设备就只能乖乖躺在变砖区里等人返厂。哪怕你加再多握手协议、重传机制都改变不了覆盖写入本身就是破坏性操作这个事实。对比一下几种常见方案的取舍方案内部Flash占用掉电恢复能力实现复杂度适用场景单区IAP直写BootloaderApp差升级中断大概率变砖低可靠性要求不高的产品内部Flash双区备份BootloaderApp×2好但Flash容量翻倍中App很小、Flash足够大的场景外部Flash A/B双备份BootloaderApp×1好掉电也能自动回滚中高App较大、需要多版本冗余的复杂产品这套方案能实现掉电恢复核心在于内部Flash上方永远保留着一份正在运行且验证通过的固件直到新固件完整校验通过、写入内部Flash并重新引导成功之后才把上一版标记为可回滚备份。旧固件和新固件在外部Flash里各自有独立区域互不干扰配合状态区里的标志位Bootloader每次上电就知道该走正常启动还是回滚恢复路径。2. 分区规划与启动/升级两条主流程2.1 一张分区表把空间安排明白动手写代码之前先把整个存储空间的分区定义清楚。分区不清楚后面所有逻辑都是乱的。我最终使用的分区如下分区所在介质地址范围大小用途Bootloader区内部Flash0x08000000~0x0800FFFF64KB启动决策、升级校验、App搬移App运行区内部Flash0x08010000~0x0807FFFF448KB当前实际运行的Application备份区A外部W25Q640x000000~0x06FFFF448KB上一版确认可用的固件备份暂存区B外部W25Q640x070000~0x0DFFFF448KB新下载固件的暂存区状态区外部W25Q640x0E0000~0x0E0FFF4KB魔数、版本号、CRC、状态标志日志区外部W25Q640x0E1000~0x0FFFFF124KB升级日志、故障记录内部Flash的App运行区放的是当前跑的固件外部Flash的备份区A放的是上一版可用固件暂存区B放的是新下载尚未生效的固件。这个设计保证了正在运行和即将生效两个版本永远不同时占用内部Flash也就没有空间冲突的顾虑。状态区是关键中的关键Bootloader启动时第一件事就是从这里读取魔数、版本号和状态位。2.2 Bootloader的启动决策逻辑状态区的状态位我定义了三个宏READY表示当前内部Flash里的App是完整可运行的正常跳转PENDING表示有新固件已下载完成、还没提交Bootloader需要先校验再决定提交还是回滚ROLLBACK表示上次运行的新版本不可用需要强制恢复旧版本。Bootloader每次上电复位的决策流程是这样的第一步检查外部Flash状态区魔数是否合法。如果不合法说明这是出厂状态或者状态区被清空过直接跳转内部App。第二步读取boot_status。如果是PENDING对暂存区B的固件做CRC32校验和版本合法性检查。校验通过先将内部Flash当前App整个备份到备份区A再把暂存区B内容搬入内部Flash最后把状态区更新为READY复位启动新App。校验失败把状态区改成ROLLBACK然后从备份区A恢复旧版本。第三步如果是ROLLBACK直接执行恢复流程从备份区A把上一版固件搬回内部Flash再将状态改为READY。第四步如果状态是READY先校验内部Flash App区CRC通过就跳转不通过就尝试从备份区A恢复。先备份旧版本再用新版本覆盖运行区这个顺序是整个方案的灵魂。反过来写先擦掉内部Flash旧App再写新App最后才备份旧版本一半途中断电的话旧版本备份可能也是残缺的那才叫真正的灾难。2.3 App升级的上层交互流程在App端完整的升级动作包是这样走的App通过串口、网络或SD卡接收到新固件包按256字节对齐的块写入W25Q64暂存区B写完一个扇区前先做擦除。整包接收完成后App计算整个固件的CRC32和上位机下发的CRC值比对一致以后把PENDING标志和版本信息写入外部Flash状态区最后复位设备进入Bootloader。这里有一个重要的边界设计App自己完全不操作内部Flash的App区它的升级动作仅限于接收固件、写入外部Flash、设置状态标志。内部Flash的擦写和搬移全部交给Bootloader完成。这样App代码里就不需要包含复杂的内部Flash操作逻辑减小了App自身出问题的概率也让Bootloader和App的依赖关系降到最低。无论下载过程多慢、多容易中断在Bootloader真正开始写内部Flash之前现场正在运行的固件完全没有被动过一根汗毛。最坏情况只是丢失一次下载任务重来一遍即可。上位机下发的数据帧我也做了固定封装帧头、命令、块序号、数据长度、数据区、CRC32尾部校验一帧最多256字节数据短帧补齐长度。App端边收边写收到完整包后统一校验省去很多边界判断。3. W25Q64读写与内部Flash搬移的底层要点3.1 W25Q64驱动中容易踩的细节W25Q64的SPI驱动本身不难网上代码一抓一大把但你要真拿来做Bootloader这种可靠性要求极高的场景有几个细节必须自己写对。第一个是命令时序。W25Q64的JEDEC ID读取指令是0x9F读数据指令0x03页编程指令0x02扇区擦除指令0x20。所有命令字和地址都是高字节先行SPI模式0或模式3都可以我习惯用模式0CPOL0CPHA0因为大部分国产替代芯片默认支持模式0兼容性最好。第二个是先擦后写的铁律。NOR Flash的特性是位可以从1写成0但不能从0写成1所以每次写入新数据之前必须先把目标扇区擦除把位都恢复成1。很多新手直接对同一地址连续两次页编程第二次的数据怎么都对不上就是没擦除导致的。第三个是忙碌状态。擦除和页编程都是非阻塞操作芯片内部执行期间不响应外部数据必须轮询状态寄存器1的BUSY位等它清零了才能发下一条命令。我见过有人漏掉WaitBusy写完后立刻去读结果读回来的全是旧数据凭空多调了两天。SPI速率方面F103的SPI1最高可以到18MHz而W25Q64手册标称的时钟上限是104MHz瓶颈完全在MCU端。不过我不推荐上来就把分频系数压低Bootloader和App共用一套SPI初始化逻辑如果板子走线长、信号质量一般高频SPI容易出现随机错位。我在量产板上用8分频也就是约2.25MHz的SCK实测读写完整固件只多花了几百毫秒换来的是稳定性明显提升。3.2 从W25Q64往内部Flash搬移的正确姿势从外部Flash往内部Flash搬固件是整个流程里风险最高的一段。STM32F103VE属于大容量产品内部Flash页大小是1KB擦除粒度按页来写入前必须先解锁、擦除、再写入。我把搬移代码设计成边读边写边校验的流水线从W25Q64读256字节写入内部Flash当前页再把写入后的内容读回来和源数据比对一致才继续下一段。这样即使中途出错也能第一时间定位到是哪个地址开始坏的。搬移时还有一个技巧内部Flash擦除只擦新版本实际占用的那些页不要对整个App区做全片擦除。因为擦除是逐页的未擦的页数据还在万一搬移中断旧App的剩余内容还保留着配合备份区A里的完整旧版本回滚的成功率更高。实际操作中我按固件长度对齐页边界先算出需要占用多少页再逐个擦除把风险窗口压缩到最小。4. 关键代码逐段拆解从W25Q64驱动到跳转App4.1 W25Q64基础驱动先给SPI底层发送函数和GPIO定义这是整个外部Flash操作的基石#define CS_LOW() GPIO_ResetBits(GPIOA, GPIO_Pin_4) #define CS_HIGH() GPIO_SetBits(GPIOA, GPIO_Pin_4) uint8_t SPI_SendByte(uint8_t byte) { while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) RESET); SPI_I2S_SendData(SPI1, byte); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) RESET); return SPI_I2S_ReceiveData(SPI1); }SPI1初始化模式0、MSB First、8分频、软件NSSvoid W25Q64_Init(void) { GPIO_InitTypeDef gpio; SPI_InitTypeDef spi; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE); gpio.GPIO_Pin GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7; gpio.GPIO_Mode GPIO_Mode_AF_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); gpio.GPIO_Pin GPIO_Pin_4; gpio.GPIO_Mode GPIO_Mode_Out_PP; GPIO_Init(GPIOA, gpio); GPIO_SetBits(GPIOA, GPIO_Pin_4); spi.SPI_Direction SPI_Direction_2Lines_FullDuplex; spi.SPI_Mode SPI_Mode_Master; spi.SPI_DataSize SPI_DataSize_8b; spi.SPI_CPOL SPI_CPOL_Low; spi.SPI_CPHA SPI_CPHA_1Edge; spi.SPI_NSS SPI_NSS_Soft; spi.SPI_BaudRatePrescaler SPI_BaudRatePrescaler_8; spi.SPI_FirstBit SPI_FirstBit_MSB; SPI_Init(SPI1, spi); SPI_Cmd(SPI1, ENABLE); }读ID和读数据uint16_t W25Q64_ReadID(void) { uint8_t buf[2]; CS_LOW(); SPI_SendByte(0x9F); buf[0] SPI_SendByte(0xFF); buf[1] SPI_SendByte(0xFF); CS_HIGH(); return (buf[0] 8) | buf[1]; } void W25Q64_ReadBytes(uint32_t addr, uint8_t *buf, uint32_t len) { CS_LOW(); SPI_SendByte(0x03); SPI_SendByte((addr 16) 0xFF); SPI_SendByte((addr 8) 0xFF); SPI_SendByte(addr 0xFF); while (len--) { *buf SPI_SendByte(0xFF); } CS_HIGH(); }写使能、等待空闲、页编程、扇区擦除这几步每次写操作前缺一不可void W25Q64_WriteEnable(void) { CS_LOW(); SPI_SendByte(0x06); CS_HIGH(); } void W25Q64_WaitBusy(void) { uint8_t status; CS_LOW(); SPI_SendByte(0x05); do { status SPI_SendByte(0xFF); } while (status 0x01); CS_HIGH(); } void W25Q64_PageProgram(uint32_t addr, uint8_t *buf, uint16_t len) { W25Q64_WaitBusy(); W25Q64_WriteEnable(); CS_LOW(); SPI_SendByte(0x02); SPI_SendByte((addr 16) 0xFF); SPI_SendByte((addr 8) 0xFF); SPI_SendByte(addr 0xFF); for (uint16_t i 0; i len; i) { SPI_SendByte(buf[i]); } CS_HIGH(); W25Q64_WaitBusy(); } void W25Q64_EraseSector(uint32_t addr) { W25Q64_WaitBusy(); W25Q64_WriteEnable(); CS_LOW(); SPI_SendByte(0x20); SPI_SendByte((addr 16) 0xFF); SPI_SendByte((addr 8) 0xFF); SPI_SendByte(addr 0xFF); CS_HIGH(); W25Q64_WaitBusy(); }值得注意的是页编程一次最多256字节数据跨页时要分多次调用否则地址会自动回卷到页首把已经写好的数据覆盖掉。我在App的接收模块里就是按256字节为一块正好一帧一块天然避开了跨页问题。4.2 CRC32校验实现固件完整性校验我用的是CRC32Bootloader和App两端用同一份实现。这里给出一个逐位计算的版本便于理解原理。如果你追求速度可以提前用多项式0xEDB88320生成256项查表结果完全一致。uint32_t CRC32_Calc(const uint8_t *buf, uint32_t len) { uint32_t crc 0xFFFFFFFF; for (uint32_t i 0; i len; i) { crc ^ buf[i]; for (int j 0; j 8; j) { if (crc 1) { crc (crc 1) ^ 0xEDB88320; } else { crc 1; } } } return ~crc; }我在固件包格式里做了统一的约定文件开头4字节是固件长度接着是固件二进制数据末尾4字节是对前面整段数据算出来的CRC32。Bootloader解析时先读长度字段再按长度计算CRC再和末尾4字节比对。这样就不会因为对齐、补零这些问题导致两边算出来的值不一致。4.3 Bootloader主流程与状态机Bootloader的main函数不算复杂核心就是状态分支处理#define BOOT_MAGIC 0xA5A5A5A5 #define BOOT_STATUS_READY 0x00 #define BOOT_STATUS_PENDING 0x01 #define BOOT_STATUS_ROLLBACK 0x02 #define APP_RUN_ADDR 0x08010000 #define EXT_APP_A_OFFSET 0x000000 #define EXT_APP_B_OFFSET 0x070000 int main(void) { /* 时钟、串口、SPI、W25Q64 初始化 */ Board_Init(); /* 出厂状态魔数不存在直接跳App */ if (W25Q64_ReadMagic() ! BOOT_MAGIC) { JumpToApp(APP_RUN_ADDR); while (1); } uint8_t status W25Q64_ReadBootStatus(); if (status BOOT_STATUS_PENDING) { if (W25Q64_VerifyImage(EXT_APP_B_OFFSET) 0) { /* 校验通过先备份当前App到备份区A */ Flash_BackupCurrentApp(EXT_APP_A_OFFSET); /* 把新固件搬进内部Flash */ Flash_CopyExtToInternal(EXT_APP_B_OFFSET, APP_RUN_ADDR); /* 更新状态 */ W25Q64_WriteBootStatus(BOOT_STATUS_READY); NVIC_SystemReset(); } else { /* 新固件损坏从备份区A恢复 */ Flash_RestoreAppFromExt(EXT_APP_A_OFFSET); W25Q64_WriteBootStatus(BOOT_STATUS_READY); } } else if (status BOOT_STATUS_ROLLBACK) { Flash_RestoreAppFromExt(EXT_APP_A_OFFSET); W25Q64_WriteBootStatus(BOOT_STATUS_READY); } /* READY状态下校验内部App坏了就从备份区A恢复 */ if (CRC32_CheckInternalApp() ! 0) { Flash_RestoreAppFromExt(EXT_APP_A_OFFSET); } JumpToApp(APP_RUN_ADDR); while (1) { } }这段逻辑没有奇技淫巧但每个分支都是按异常场景推导出来的。写这套代码时我每写一个分支就问自己一句如果此刻断电会发生什么迁移初始化时断电、备份到一半断电、写App区到一半断电这几条路径在真实环境里我都用电源开关实际模拟过。4.4 搬移与跳转App的关键代码从外部Flash复制固件到内部Flash的函数采用分页读、分页写、逐段校验void Flash_CopyExtToInternal(uint32_t ext_off, uint32_t dst_addr) { uint8_t buf[256]; uint32_t len W25Q64_GetImageLength(ext_off); uint32_t offset 0; uint32_t page_addr; FLASH_Unlock(); while (offset len) { /* 内部Flash页对齐地址 */ page_addr (dst_addr offset) 0xFFFFFC00; FLASH_ErasePage(page_addr); for (uint32_t i 0; i 1024 offset len; i 256) { W25Q64_ReadBytes(ext_off offset, buf, 256); for (uint32_t j 0; j 256; j 4) { FLASH_ProgramWord(dst_addr offset j, *(uint32_t *)(buf j)); } offset 256; } } FLASH_Lock(); }跳转App是Bootloader的收尾动作也是新手最容易翻车的点typedef void (*AppEntry)(void); void JumpToApp(uint32_t app_addr) { uint32_t app_sp *(volatile uint32_t *)app_addr; uint32_t app_pc *(volatile uint32_t *)(app_addr 4); /* 检查栈指针是否落在RAM区防止跳到无效地址 */ if ((app_sp 0xFFFF0000) ! 0x20000000) { return; } /* 先关全局中断 */ __disable_irq(); /* 清空NVIC所有已使能的中断和挂起中断 */ for (uint32_t i 0; i 8; i) { NVIC-ICER[i] 0xFFFFFFFF; NVIC-ICPR[i] 0xFFFFFFFF; } /* 重新设置主栈指针 */ __set_MSP(app_sp); /* 跳转 */ AppEntry jump (AppEntry)app_pc; jump(); }为什么跳转前不光要关中断还要把所有NVIC中断源清掉因为Bootloader在运行过程中可能开过串口中断、SPI中断或定时器这些中断源如果在跳转的瞬间挂着Pending而App没有对应的ISR一开中断就会直接HardFault。我在第一次联调时就因为这个吃过亏后来干脆在跳转函数里统一清一遍NVIC省心很多。4.5 App工程侧的配套修改Bootloader这边改完了App工程也得配合。如果用Keil开发在Options for Target的Target页里把IROM1起始地址从0x08000000改成0x08010000Size改成0x70000。然后在App的main函数最前面显式重定位向量表SCB-VTOR 0x08010000;这句话必须在所有外设时钟初始化之前执行否则中断向量表还是指向Bootloader区域的旧表发生任何中断都会跑飞。如果你的App是从旧工程复制过来的务必检查工程里的IROM1配置有没有真的改掉光在代码里设置VTOR而IROM1没改编译出来的镜像地址仍然是0x08000000开头跳过去一样是乱的。调试时可以用J-Link直接把Bootloader和App的hex合并烧录只要App的hex里地址已经是0x08010000烧录器会按地址自动放到正确位置不需要额外操作。5. 联调排错实录5个让人失眠的问题5.1 跳转后系统跑飞大概率是向量表问题跳转后设备完全没反应或者一进中断就HardFault第一个要查的就是App的向量表重映射。用调试器挂上看App运行起来的SCB-VTOR值是不是0x08010000。如果不是说明重映射没生效。另一个隐蔽情况是App里用了第三方库或RTOS库里的SystemInit或启动文件在某些场景下会修改VTOR导致你设置的偏移被覆盖这种问题要顺着启动流程一步步查。5.2 W25Q64写入后读出来的数据不对写入后读回数据不一致十有八九不是芯片坏了而是踩了NOR Flash的先擦后写红线。W25Q64写操作前目标扇区必须是已擦除状态否则就会发生位的0写不回1的经典错误。还有擦除和编程都是非阻塞操作芯片内部忙的时候不响应外部指令必须等BUSY位清零后再发下一条命令。我建议在PageProgram和EraseSector里面都放上WriteEnable和WaitBusy不要想着在外面统一处理后面加代码的人容易漏。5.3 CRC校验怎么都算不对CRC算不对多数时候不是算法问题而是长度边界的问题。如果固件尾部有对齐填充字节或者长度字段本身没有包含CRC附加区那两边算出来的结果一定不一致。我的做法是统一协议固件包前4字节放长度接着是固件数据末尾4字节放对前面所有数据计算的CRC32。Bootloader只在拿到长度字段后才开始计算从根上规避了差几个字节的纠纷。5.4 搬移固件时突然断电测试不管代码写得多严谨现场总有断电的极端情况。我专门做了几轮断电模拟测试在搬移进度10%、30%、70%、95%四个点随机拉闸重新上电后Bootloader都正确检测到PENDING状态、校验失败、从备份区A恢复旧固件设备没有一次变砖。这个结果让我对这套方案彻底放心了。如果你也要做类似测试记得把断电点写进测试用例而不是只在搬移结束以后测因为最危险的就是写了一半的那个瞬间。5.5 SWD调试口被复用导致的联调不联有一次我在调App时发现设备跑起来以后J-Link就连接不上了。查了半天发现是App里把PA13、PA14两个SWD引脚复用成了普通GPIO导致调试器失联。后来我在App代码里保留了SWD引脚的默认功能只有正式量产固件才会通过配置宏关闭调试口。如果你也碰到仿真器突然连接不上先检查一下代码里有没有对SWD引脚做复用操作别一上来就怀疑芯片烧了。6. 这套方案留下的几个设计习惯做完这个项目后我最大的收获不是那几千行代码而是几个设计习惯。第一凡是涉及Flash读写的操作一律先规划好断电恢复路径再动手代码里的每一个状态分支都必须自问这个状态下断电会怎样。第二Bootloader和App的职责要彻底分离App只负责下载和存储Bootloader负责校验和搬移边界清晰以后两端可以独立测试联调时减少很多互相扯皮。第三也是最重要的一点别嫌分区规划繁琐。花一天时间把分区、状态机、异常分支画清楚后面能省下一周的调试时间。这套架构后来我在另外两个项目里直接复用了换芯片时只需调整Flash大小和页大小参数其余逻辑几乎没动。如果你也在做类似的升级方案强烈建议你先把分区图画出来再动手写代码磨刀不误砍柴工。