ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于W25Q64的STM32双备份Bootloader与OTA升级实战

基于W25Q64的STM32双备份Bootloader与OTA升级实战 STM32F103VET6这个型号玩过的人都知道512KB Flash、64KB RAM、100个引脚算是在F1系列里配置比较到位的。但最近有个项目场景让我不得不重新审视它——设备已经部署到现场固件需要远程迭代又不能在OTA升级失败时把设备搞成砖。我查了一圈资料发现大多数人用F103做Bootloader都是老老实实把整个F103内部Flash一分为二Bootloader占一段Application占一段出问题就完了。后来我从一个做车载电子的朋友那里拿到一个思路内部Flash保持整块给Application把Bootloader搬到外部W25Q64上运行再做双份Application备份。这个思路很有意思我实际搭了一套方案验证今天把完整的实战过程和代码解析记录下来。先说清楚我为什么要用W25Q64。F103VET6虽然有512KB Flash但Bootloader如果只占20KBApplication最多也就490KB可用这在很多场合够用但一旦想加双备份内部Flash就彻底不够了。W25Q64是颗64Mbit8MB的SPI NOR Flash某宝几块钱一颗8MB空间足够塞下两到三份固件。更重要的是W25Q64是按扇区擦除的4KB一个扇区做OTA升级时擦写控制比内部Flash更灵活而且SPI接口占用引脚少几乎不挤占原本的外设资源。我需要提前说明外部Flash做的Bootloader和传统内部Flash方案有一个本质区别STM32上电后默认从0x08000000取复位向量想让CPU去执行外部Flash里的Bootloader要么通过芯片自带的boot引脚引导要么在内部Flash放一个极小的加载器把外部Flash代码搬过来。我这套方案选择后者——内部Flash前端只放4KB左右的G0引导段负责初始化SPI、加载外部Flash中的Bootloader到内部SRAM或者直接跳转执行。这样内部Flash腾出大面积给ApplicationBootloader本体和双备份固件都住在W25Q64里。1. 系统整体设计与双备份机制拆解1.1 为什么非要做双份Application备份设备固件升级最大的风险不是升级过程本身而是升级到一半断电。SPI Flash在写入过程中突然掉电可能留下一个半擦除半写入的残缺镜像如果Bootloader只有单份Application可供选择设备就彻底变砖只能返厂。双备份的核心思想就是永远保留一份已知能运行的旧版本写入新版本失败时立马回滚。我这个方案里W25Q64的空间规划是这样的0x000000 - 0x00FFFF64KBBootloader本体与参数区0x010000 - 0x03FFFF192KBApplication A0x040000 - 0x06FFFF192KBApplication B0x070000 - 0x07FFFF64KB运行标志与升级暂存区每份固件都预留了192KBF103VET6的Application绝大部分都能塞下。如果实在超过192KB可以把W25Q64换成W25Q128128Mbit16MB空间规划等比放大即可。完整代码我放在文末的关键模块里下面先讲清楚设计思路再贴码不然你直接抄过去也调不明白。Bootloader每次上电会读取运行标志区的数据里面记录了三项关键信息当前应从A还是B启动、A的固件CRC是否有效、B的固件CRC是否有效。如果A有效就跳A如果A无效B有效就跳B两个都无效就进入串口命令模式等待重新烧写。1.2 W25Q64相对于内部Flash的取舍很多人问我F103VET6内部512KB Flash理论上也能做双备份单份固件按200KB算两份就是400KB加上Bootloader 20KB完全放得下费什么劲去外挂W25Q64这个问题问到了点子上。确实空间上完全可行。但你在实际项目里会遇到几个绕不开的坑第一STM32内部Flash的擦写次数标称1万次W25Q64标称10万次。OTA升级如果频繁内部Flash寿命先到头。第二内部Flash擦写期间CPU是阻塞的如果升级过程中突然断电损坏的是芯片内部存储故障排查特别麻烦。第三也是最关键的——内部Flash被擦写时应用程序代码正跑在这个Flash上就会出现取指冲突虽然厂商设计了RWW特性但F1系列不支持你只能把升级代码放在RAM里执行复杂度一下就上去了。外挂W25Q64之后Application还是跑在内部Flash上Bootloader写入外部Flash完全不干扰Application的运行这就是物理隔离带来的稳定性优势。1.3 这套方案的应用场景和适合人群你如果做的是量产消费电子产品比如智能家居网关、小型运动控制器需要支持远程升级且对成本敏感的这套方案很合适。如果你是刚接触Bootloader开发的学生或者转行工程师想在F103上做一版能拿得出手的OTA框架这套方案也能让你少走很多弯路。不适合的场景也有比如你的Application固件超过512KB或者对启动时间极敏感外部Flash的加载时间比内部Flash慢这种极端场景建议另选方案。启动时间多说一句我的实测数据是4MHz SPI读取模式下Bootloader从外部Flash加载到SRAM再跳转大约耗时82ms这在绝大多数意义上是可接受的。2. 硬件连接与W25Q64基础驱动2.1 引脚分配与硬件连接F103VET6有3个SPI外设我选SPI1挂在APB2总线上时钟频率最高36MHz。引脚分配如下功能引脚说明SPI1_SCKPA5时钟我配置成18MHz的稳定运行频率SPI1_MISOPA6主入从出W25Q64的DO脚SPI1_MOSIPA7主出从入W25Q64的DI脚SPI1_CSPA4片选软件控制低有效W25Q64_WPPA2写保护禁用状态接高电平W25Q64_HOLDPA3保持引脚禁用状态接高电平这里有个值得注意的细节W25Q64的HOLD引脚在拉低时芯片会暂停对外通信并保持当前状态。很多人忽略了这颗引脚直接悬空结果在某些环境下SPI通信偶发异常排查半天找不出原因。我的做法是WP和HOLD都通过10K电阻上拉到3.3V确保芯片始终处于正常工作模式。如果你用的是W25Q64FV这颗料把HOLD拉高这个操作几乎是必须的否则静电或者干扰很容易让Flash进入保持状态。另外W25Q64的供电电压是2.7V到3.6VSTM32F103VET6的VDD是2.0V到3.6V两者可以直接共用3.3V电源轨。不过要注意SPI信号线的电平也是3.3V如果后续要接5V的单片机比如Arduino Uno要加电平转换不能直接连否则长期运行有损坏风险。2.2 SPI初始化参数的关键选择SPI的配置有几个参数直接影响W25Q64的通信稳定性我在初始化时的配置如下void SPI1_Init(void) { GPIO_InitTypeDef gpio; SPI_InitTypeDef spi; RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1 | RCC_APB2Periph_GPIOA, ENABLE); gpio.GPIO_Pin GPIO_Pin_5 | 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_6; gpio.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, gpio); gpio.GPIO_Pin GPIO_Pin_4; gpio.GPIO_Mode GPIO_Mode_Out_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; 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_4; spi.SPI_FirstBit SPI_FirstBit_MSB; SPI_Init(SPI1, spi); SPI_Cmd(SPI1, ENABLE); }SPI时钟分频我选的是4分频APB2时钟是72MHz所以SPI时钟是18MHz。可能有人问W25Q64标称支持104MHz的时钟我为什么把频率压到18MHz因为在F103上SPI1最高就是36MHz72/218MHz留了50%的余量。实际测试中36MHz甚至72MHz的SPI时钟在某些走线较长的杜邦线连接方式下波形畸变严重而18MHz的波形非常干净数据读取几乎不出错。如果是PCB板载布线可以上到36MHz能换来更快的固件传输速度如果是面包板、杜邦线搭的测试环境就老实待在18MHz。SPI的模式选择也有讲究。W25Q64支持模式0CPOL0CPHA0和模式3CPOL1CPHA1我用的是模式0这也是最常见的选择。千万不要把CPOL和CPHA配错否则读出来的数据会错位表现为读ID返回0xFF或者乱码。2.3 W25Q64基础读写驱动的完整实现这一部分我直接贴代码你按照顺序抄到工程里就能用。先说核心的读写字节函数这是所有上层操作的基石uint8_t SPI_ReadWriteByte(uint8_t data) { while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) RESET); SPI_I2S_SendData(SPI1, data); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) RESET); return SPI_I2S_ReceiveData(SPI1); }这个函数干的事就是同时完成发送和接收。SPI是全双工的主设备向从设备发一个字节的同时必定会从从设备收一个字节。发送的时候要等TXE标志置位意思就是发送缓冲区空了接收的时候要等RXNE标志置位意思就是接收缓冲区有数据了。这个等待过程不能省略少了任何一个while循环通信时序就会错乱。然后是读芯片ID。我调试驱动时最先跑的就是这个函数ID正确代表SPI通了之后再去做别的操作uint16_t W25Q64_ReadID(void) { uint16_t id 0; W25Q64_CS_LOW(); SPI_ReadWriteByte(0x90); // Read Manufacturer/Device ID SPI_ReadWriteByte(0x00); // dummy byte SPI_ReadWriteByte(0x00); // dummy byte SPI_ReadWriteByte(0x00); // dummy byte id SPI_ReadWriteByte(0x00) 8; id | SPI_ReadWriteByte(0x00); W25Q64_CS_HIGH(); return id; }如果这颗芯片是W25Q64这个函数应该返回0xEF17。EF是Winbond的厂商标识17是W25Q64的设备号。我调试的时候见过各种妖蛾子返回值什么0xFFFF、0x0000、0xEF13都有下面逐一分析原因。0xFFFF基本就是SPI通信没通查MISO是不是没接或者CS一直处于高电平没拉低。0x0000说明SCK没正常工作芯片根本没响应。0xEF13是W25Q32的设备号说明你手头的板子上焊的其实不是W25Q64而是W25Q32这种乌龙我在客户那见过好几次贴片料被厂里换料了。扇区擦除函数我这里也先给出后面其他代码会依赖它void W25Q64_EraseSector(uint32_t sector_addr) { W25Q64_WaitBusy(); W25Q64_CS_LOW(); SPI_ReadWriteByte(0x06); // Write Enable W25Q64_CS_HIGH(); W25Q64_CS_LOW(); SPI_ReadWriteByte(0x20); // Sector Erase (4KB) SPI_ReadWriteByte((sector_addr 16) 0xFF); SPI_ReadWriteByte((sector_addr 8) 0xFF); SPI_ReadWriteByte(sector_addr 0xFF); W25Q64_CS_HIGH(); W25Q64_WaitBusy(); }擦除是NOR Flash的一个核心操作特性NOR Flash的写入只能把1写成0想把0恢复成1只能靠擦除操作。而且擦除的最小单位是扇区4KB不能只擦一个字节。这跟你用U盘NAND Flash不太一样NAND的最小擦除单位是块Block通常更大。在程序逻辑设计上记住这个原则任何写操作前先确认目标扇区已经是擦除状态否则会出现数据叠加错误。最典型的错误现象就是写进去的固件数据出现莫名其妙的0xFF变成0x00的情况那多半是没擦干净。写数据页编程函数一次最多写256字节这是W25Q64的硬件限制。W25Q64的页是256字节跨页写入会导致数据绕回页开头把之前写的数据覆盖掉。所以我的驱动里做了跨页判断void W25Q64_WritePage(uint32_t addr, uint8_t *buf, uint16_t len) { uint16_t i; uint16_t remain_in_page 256 - (addr % 256); if (len remain_in_page) { len remain_in_page; } W25Q64_WaitBusy(); W25Q64_CS_LOW(); SPI_ReadWriteByte(0x06); // Write Enable W25Q64_CS_HIGH(); W25Q64_CS_LOW(); SPI_ReadWriteByte(0x02); // Page Program SPI_ReadWriteByte((addr 16) 0xFF); SPI_ReadWriteByte((addr 8) 0xFF); SPI_ReadWriteByte(addr 0xFF); for (i 0; i len; i) { SPI_ReadWriteByte(buf[i]); } W25Q64_CS_HIGH(); W25Q64_WaitBusy(); }读数据函数相对简单因为读操作不会触发放电和擦除的等待速度也快得多void W25Q64_ReadData(uint32_t addr, uint8_t *buf, uint32_t len) { uint32_t i; W25Q64_CS_LOW(); SPI_ReadWriteByte(0x03); // Read Data SPI_ReadWriteByte((addr 16) 0xFF); SPI_ReadWriteByte((addr 8) 0xFF); SPI_ReadWriteByte(addr 0xFF); for (i 0; i len; i) { buf[i] SPI_ReadWriteByte(0xFF); } W25Q64_CS_HIGH(); }注意读数据时每读一个字节主设备都需要发送一个假字节我传的0xFF这是SPI全双工通信的特点。发送的0xFF会被从设备忽略但从设备会在这个时钟周期内把当前字节的数据放到MISO上从而被主设备接收。如果你在循环里图省事直接调用SPI_ReadWriteByte(0x00)效果一样因为W25Q64在读模式下不关心你发的是什么数据。写使能0x06这个命令容易被忽略但极其重要。W25Q64在上电后默认是写保护状态任何一个写操作页编程、扇区擦除、芯片擦除之前都必须先发写使能命令否则写操作会被静默忽略。如果写完数据读回来发现全是0xFF八成就是漏了这个操作。另外0x06命令需要单独拉低CS发送然后拉高CS不能和后面的编程命令连在一起否则写使能不生效。总有些人贪快想把两条命令一次发出去这是完全错误的做法。2.4 写保护与状态寄存器的那点事W25Q64内部有个状态寄存器其中BP4-BP0这几位用来设置写保护区域。如果不小心配置了这些位芯片的某些区域会处于保护状态写操作直接失效。在Bootloader代码里我每次初始化后都会执行一次解除写保护void W25Q64_Unprotect(void) { W25Q64_CS_LOW(); SPI_ReadWriteByte(0x06); // Write Enable W25Q64_CS_HIGH(); W25Q64_CS_LOW(); SPI_ReadWriteByte(0x01); // Write Status Register SPI_ReadWriteByte(0x00); // clear BP bits W25Q64_CS_HIGH(); }除非你的产品有特殊需求比如防止固件被现场工具非法读取建议每次都执行这个函数。我有一次在调试过程中写完固件后掉电再上电后发现固件校验总是失败排查了半天才发现是上一轮测试代码里设置了BP4位把整片Flash都保护起来了导致后续所有写操作都失败。这个问题在开发阶段非常隐蔽因为你不会每次上电都去查状态寄存器。3. Bootloader核心逻辑与G0引导段解析3.1 启动流程的全链路梳理这套双备份方案的完整启动链条是上电 - 内部Flash G0引导段0x08000000 - 初始化SPI - 读取W25Q64 Bootloader - 跳转执行 - Bootloader读取运行标志 - 根据标志选择A或B - 校验CRC - 跳转ApplicationG0引导段是整个系统里最小巧的一段代码我把它控制在4KB以内放在内部Flash的最开头。它做的事情非常简单初始化必要的时钟、初始化SPI1、从W25Q64的0x000000地址读取Bootloader这段代码已经预先烧录在外部Flash中的首256字节数据确认它是合法的可执行程序然后执行跳转。有人可能会问为什么不让Bootloader直接放在内部Flash里因为我想把内部Flash空间尽量留给Application。有人又会问那G0这一段从内部Flash启动Application中断向量表冲突怎么办答案是分开处理——G0只负责启动BootloaderApplication的中断向量表从内部Flash的0x080010004KB偏移处开始Bootloader跳转到Application之前会把SCB-VTOR设置为Application的中断向量表地址。注意F103只有Cortex-M3内核支持VTOR寄存器可以直接重映射中断向量表。如果你对跳转的细节感兴趣G0引导段的关键跳转代码如下typedef void (*pFunction)(void); void JumpToBootloader(uint32_t addr) { uint32_t jump_addr; pFunction jump_func; // 从外部Flash读取Bootloader栈顶地址 W25Q64_ReadData(addr, (uint8_t *)jump_addr, 4); // 简单校验栈顶地址是否合理RAM范围 if (jump_addr 0x20000000 || jump_addr 0x20010000) { return; // 非法地址不跳转 } // 读取复位向量 W25Q64_ReadData(addr 4, (uint8_t *)jump_addr, 4); if (jump_addr 0x08000000 || jump_addr 0x08010000) { return; // 非法复位向量不跳转 } jump_func (pFunction)jump_addr; // 设置主栈指针 __set_MSP(*(volatile uint32_t *)addr); // 跳转 jump_func(); }这里有个特别重要的细节跳转前必须关闭全局中断否则跳转过程中一个中断涌入CPU去查询中断向量表而此时向量表还没有切换到目标程序就会导致HardFault。正确做法是在跳转函数入口处执行__disable_irq()跳转完成后由目标程序自己重新初始化中断并开启。3.2 Bootloader的主体状态机Bootloader本体写在W25Q64的0x000000地址它的逻辑比G0复杂得多。我把它设计成状态机typedef enum { BOOT_CHECK 0, BOOT_UPDATE, BOOT_JUMP, BOOT_ERROR } BootState;BOOT_CHECK检查运行标志区确认A和B两份固件的CRC有效性确定要启动哪一份。BOOT_UPDATE如果标志区表明有升级请求通过串口接收新固件写入备用分区成功后更新标志失败则回滚标志。BOOT_JUMP启动选中的Application。BOOT_ERROR两个固件都不可用进入串口命令行模式等待主机强制烧写。因为Bootloader运行在外部SPI Flash上它没法像普通程序那样直接在Flash里取指执行STM32不支持从外部SPI Flash执行代码所以我做了一步加载操作。Bootloader本体的可执行代码存储在W25Q64的0x000000区域上电后由G0段将其搬到内部SRAM的0x20000000区域执行。这就是为什么Bootloader不能设计得太大——F103VET6有64KB SRAM我限制Bootloader大小在20KB以内剩下40KB留给G0段搬运时的临时缓冲和应用数据的暂存。不过这里可以透露一个更进阶的做法。如果你不想让Bootloader代码加载到SRAM执行可以使用F103的FSMC接口外挂一块并行NOR Flash把Bootloader直接放到并行NOR Flash的映射地址这样就能直接取指执行。但FSMC在F103VET6上会占用大量引脚而且并行NOR Flash的价格比SPI Flash贵不少我最终没有采用这个方案。3.3 Application的中断向量表与编译配置这一部分是很多人第一次做Bootloader Application最容易踩坑的地方。你的Application工程在编译时必须把ROM起始地址配置成0x08001000而不是默认的0x08000000。如果你用的是Keil这个设置在Options for Target - Target - IROM1里改Start地址填0x08001000Size填0x0007F000512KB减4KB。如果你用的是IAR需要在链接器的配置里设置-DROM_START0x08001000。改完编译地址后你的Application还有一个关键点要处理中断向量表重映射。虽然F103支持VTOR寄存器但有些早期版本芯片或者部分环境下VTOR设置不生效保险做法是在main函数最开头手动重映射#define APP_BASE_ADDR 0x08001000 int main(void) { // 重映射中断向量表到Application基地址 SCB-VTOR APP_BASE_ADDR; // ... 后续初始化 }如果你忘了做这个重映射后果是编译烧录后程序直接跑飞或者一进中断就死机。因为CPU默认从0x08000000偏移处找中断向量表而那个地址现在放的是G0引导段的代码两者完全不匹配。另外还有一个坑Application里的Flash写操作。Application如果有本地保存参数到内部Flash的需求要注意Flash操作地址不能落在G0引导段区域0x08000000到0x08000FFF否则会把引导段覆盖掉下次上电系统就起不来了。我在Application工程里约定参数存储区域从0x0807F000开始这是内部Flash的最后4KB扇区和G0引导段离得最远互相干扰的概率最小。不过这也意味着一件事Application不能随意修改G0引导段和Bootloader所占用的外部Flash区域擦除外部Flash扇区前需要做好地址过滤。3.4 固件头部格式与完整性校验双备份机制能够正确运行完全依赖固件头部的可靠设计。固件在校验前要有足够的元数据来判断版本、长度、校验值。我的固件头部总共48字节结构如下typedef struct { uint32_t magic; // 魔数固定为0xA5A5A5A5 uint32_t version; // 固件版本号递增 uint32_t firmware_len; // 固件数据长度不含头部 uint32_t crc32; // 固件数据CRC32校验 uint32_t timestamp; // 编译时间戳 uint8_t reserved[24]; // 保留字节 } FirmwareHeader;这个头部在生成固件时就打好了。我写了一个Python脚本用来给编译生成的BIN文件添加头部、计算CRC并打包成最终可以烧录的升级文件。有完整一套工具链后续升级发布时跑一下脚本就行不用手动改二进制。CRC32的算法我用了标准查表法Python端有zlib库的crc32函数可以直接调用MCU端我加上查表法实现两端算出来的值必须一致。这里有一个小细节CRC计算覆盖的是固件数据部分从头部结束到文件末尾不包含头部本身。这样校验逻辑很简单先解析头部取firmware_len和crc32然后对整个数据区重新计算CRC并比较。3.5 Bootloader跳转前的最后一道检查Bootloader在跳转到Application之前的几十毫秒内需要完成一串安全检查任何一个不满足都不能跳。我整理成如下顺序读取目标固件头部验证magic是否为0xA5A5A5A5。验证firmware_len是否在合理范围内比如16KB到192KB之间。对整个数据区计算CRC32与头部crc32字段比对。检查目标固件的中断向量表是否合法栈顶地址在RAM范围内复位向量在内部Flash范围内。前三条保证固件的完整性和正确性第四条防止跳转到一个非法地址导致HardFault。只有全部通过Bootloader才真正执行跳转。这一套检查下来耗时大概几十毫秒完全在可接受范围内。4. 双备份升级流程与关键代码实现4.1 升级流程的完整链路我把升级流程设计成四个阶段从Bootloader的视角来看阶段一Bootloader检查标志区发现升级标志位为1表示有新的固件等待写入备用分区。阶段二Bootloader通过串口我用的USART1波特率115200接收新固件边接收边写入当前不活跃的Application分区如果当前A有效就写B反之写A。接收过程中按block写入每收一个块256字节就写入W25Q64的一页并记录当前进度。阶段三传输完成后Bootloader对目标分区做一次完整的CRC校验。如果校验通过更新运行标志区将目标分区标记为有效旧分区标记为待更新。如果校验失败清除升级标志保留原分区不动下次上电依然运行旧固件。阶段四Bootloader跳转执行新固件。新固件运行后在应用层发一个启动成功的心跳给Bootloader写一个标志位Bootloader在下一次检查时看到这个标志就把旧分区标记为可覆盖完成一个完整的升级周期。这个流程的核心优势是任何时候掉电都不会影响当前正在运行的固件。升级写入的是备用分区运行分区始终是好的。只有新固件写入完整并且应用启动成功后备用分区才转正旧分区才被释放。4.2 固件接收的状态机实现串口接收固件这一段我用的方法是逐字节中断接收 状态机解析。完整逻辑如下typedef enum { FRAME_WAIT_HEAD 0, FRAME_RECV_DATA, FRAME_RECV_END } FrameState; typedef struct { FrameState state; uint8_t block_buf[256]; uint16_t block_len; uint32_t total_recv; uint32_t dest_addr; uint32_t firmware_len; } UpgradeContext; UpgradeContext upgrade_ctx; void USART1_IRQHandler(void) { uint8_t ch; static uint8_t cmd 0; if (USART_GetITStatus(USART1, USART_IT_RXNE)) { ch USART_ReceiveData(USART1); switch (upgrade_ctx.state) { case FRAME_WAIT_HEAD: if (ch 0xAA) { // 帧头 upgrade_ctx.state FRAME_RECV_DATA; upgrade_ctx.block_len 0; } break; case FRAME_RECV_DATA: upgrade_ctx.block_buf[upgrade_ctx.block_len] ch; if (upgrade_ctx.block_len 256) { // 写一个块到W25Q64 W25Q64_WritePage(upgrade_ctx.dest_addr upgrade_ctx.total_recv, upgrade_ctx.block_buf, 256); upgrade_ctx.total_recv 256; upgrade_ctx.block_len 0; // 发送ACK给上位机 USART_SendData(USART1, 0x55); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); } break; default: break; } } }这个状态机的核心思想是流水线化上位机发送固件块256字节MCU每收到一个完整的块就立刻写入Flash然后回一个ACK上位机收到ACK后再发下一个块。这种简单地协议好在实现容易、容错性高——一旦某个块传输失败上位机没收到ACK上位机可以重发当前块MCU重复写一次也不影响数据正确性W25Q64在已经写入的位置再写只要数据相同就没问题。帧头、帧尾的设计比较简单如果你的传输环境更复杂可以加上长度字段、CRC字段、重传机制做成完整的帧协议。我在这个项目里用的是裸串口点对点传输干扰可控就不额外加复杂协议了。4.3 升级标志区的读写与防掉电设计双备份能不能可靠工作就看标志区的读写策略是否稳妥。标志区我放在W25Q64的0x070000起始占用64KB但实际只使用前4KB一个扇区。这个扇区存储的数据包括typedef struct { uint32_t boot_count; // 启动次数计数 uint32_t app_a_status; // A分区状态0无效1有效2当前运行 uint32_t app_b_status; // B分区状态同上 uint32_t upgrade_flag; // 升级标志0无升级1有升级等待写入 uint32_t magic; // 标志区有效性魔数0x5A5A5A5A } BootFlag;每次Bootloader启动时会把这个结构体从W25Q64第0x070000地址读出校验magic如果magic不正确就初始化默认值并写回。任何一次状态的更改都会先擦除扇区然后整体重写这24字节数据。擦除和重写之间如果掉电会导致整个扇区变成全0xFFBootloader读到的magic自然不对就会走默认初始化流程启动分区A。这个行为是设计上可以接受的——大不了升级标志丢失但系统还能启动旧固件。如果想要更保险可以把标志区做双份备份一份在0x070000一份在0x071000每次先写主区再写备份区读取时先读主区magic不对再读备份区。我这次项目里没做双份因为升级中断的概率本身很低但如果你做的是工业现场设备建议加上成本只是多擦一个扇区的事。4.4 上位机发送程序的实现思路MCU端写好了上位机也得有配套工具。我用Python写了一个简单的发送脚本完整逻辑如下import serial import struct import time def send_firmware(port, bin_path, block_size256): with open(bin_path, rb) as f: data f.read() # 添加固件头部后重新打包 # 注意头部实际是在编译阶段打好的这里直接读取整个文件 total len(data) sent 0 ser serial.Serial(port, 115200, timeout2) # 发送升级启动命令 ser.write(b\x5A\xA5) time.sleep(0.1) # 逐块发送 while sent total: block data[sent:sentblock_size] if len(block) block_size: block block b\xFF * (block_size - len(block)) # 填充 ser.write(b\xAA) # 帧头 ser.write(block) ack ser.read(1) if ack ! b\x55: print(fblock {sent//block_size} ack error, retry...) continue sent block_size progress sent * 100 // total print(fprogress: {progress}%, end\r) print(\ndone.)这里最需要注意的点是最后一个块如果不足256字节需要用0xFF填充到整块。因为W25Q64的页编程要求一次写入256字节如果不足会跨页或者写不进去。填充0xFF是安全的因为0xFF是Flash擦除后的默认值填充它不会破坏固件数据——当然前提是你的固件长度不是正好256的倍数如果正好是就不需要填充。脚本里我加了ACK超时重发机制上位机发一个块后如果没有在2秒内收到ACK就重新发送当前块。这个机制在面对长距离传输、干扰较大的场景时非常有用。实际使用中115200波特率下这个脚本传完192KB固件大约需要23秒完全在可接受的范围内。5. 调试实录我踩过的坑和排查方法5.1 SPI读出来的数据全0xFF怎么办这个问题几乎每个人都会遇到一次。我那次排查的路径是这样的先用逻辑分析仪抓SCK、MISO、MOSI、CS四根线的波形。如果发现CS一直处于高电平查代码里是不是没有在操作前拉低CS如果CS有低电平脉冲但MISO始终没有输出有效数据查MISO是不是没接对引脚PA6如果波形都正常但数据就是0xFF查W25Q64的供电是不是稳定。有一次我遇到一个隐蔽问题W25Q64的供电脚旁边没有加0.1uF的去耦电容导致芯片在上电瞬间供电不稳定进入了一个奇怪的未定义状态。后来在VCC和GND之间加了一颗0.1uF电容问题就消失了。这个问题的排查经历让我养成了一个习惯——任何新做的STM32小板子Flash芯片附近必须预留去耦电容位置哪怕不焊也要留焊盘。还有一个可能W25Q64这颗芯片本身是翻新料引脚氧化或者内部已经损坏。我遇到过一批号称全新原装的W25Q64上机后十颗里有三颗读ID异常后来换了一家供应商才解决。嵌入式开发有个玄学规律芯片这东西省的那几毛钱最后都会以调试时间补回来。5.2 跳转到Application后跑到HardFault这个坑百分之百是中断向量表的问题。我的排查步骤是先确认Application工程的IROM1起始地址是否改成了0x08001000。如果Keil里没改生成的目标代码还是从0x08000000起始而那个地址现在放的是G0引导段跳过去肯定跑飞。再确认Application的main函数开头有没有设置SCB-VTOR。F103的Cortex-M3内核支持VTOR寄存器但寄存器地址是0xE000ED08。如果你不知道这个地址也没关系STM32标准外设库里提供了一个宏SCB-VTOR直接赋值即可。最后确认跳转前是否关闭了全局中断。我遇到过一个情况跳转前USART1的中断正挂着Bootloader跳到Application后Application的中断处理里USART1的中断优先级分组还没初始化导致中断仲裁异常。这种问题很难排查因为复位后全局中断默认关闭Application在main开头重新初始化中断时如果优先级分组设置和Bootloader不一致就会出现各种奇怪现象。我的做法是在跳转前强制关闭全局中断并把所有外设的时钟除了SPI1都复位一遍减少对Application的影响。5.3 CRC校验失败但不是通信问题有一次我在升级固件时传输全程没有报错但CRC校验就是失败。最后发现是Bootloader接收固件时把最后一个块的长度计算错了——我用了firmware_len % 256来计算最后一个块的实际长度结果在长度恰为256整数倍时这个表达式算出来是0程序就跳过了最后一块的校验逻辑。这个问题只有在固件大小恰好为256倍数时才会出现属于典型的边界条件bug。修复方式是增加一个判断如果firmware_len % 256 0最后一个块的实际长度就是256而不是0。这种边界条件bug在Bootloader这种需要长期稳定运行的代码里尤其致命因为它不在常规测试路径上往往在最不该出问题的时候突然出现。5.4 现场升级掉电后的恢复实测我特地做了掉电恢复测试升级到一半的时候突然断电然后在不同阶段重新上电记录系统行为。测试结果如下擦除备用分区时断电重启后引导段正常Bootloader发现升级标志存在但备用分区数据不完整CRC校验失败回滚到当前分区运行。写入固件一半断电同上备用分区数据不完整回滚。写入完成CRC校验通过后断电重启后Bootloader发现备用分区有效升级标志还在切换到备用分区运行新固件。更新标志区时断电标志区可能被擦除全0xFFBootloader的默认恢复逻辑启动分区A。这四种场景都验证了系统的最终状态是至少有一份固件能正常启动——这正是双备份设计的终极目标。如果你测试时发现某个场景下系统起不来了十有八九是标志区处理逻辑出了问题优先排查。5.5 Bootloader编译优化的一个注意点Bootloader因为要从外部Flash拷贝到SRAM执行编译时要特别注意两个优化选项一是把代码段定位到SRAM这通常需要修改链接脚本Keil里可以通过分散加载文件来实现二是关闭编译器的代码重定位优化否则链接时生成的地址不是SRAM地址运行时会出现PC指向非法区域。如果嫌麻烦不想在SRAM里执行Bootloader还有一个走捷径的办法把Bootloader编译为位置无关代码PIC这样它在任何地址都能运行。但Cortex-M3的PIC支持并不完美某些操作比如地址常量访问会出问题我一般不推荐在MCU上折腾PIC。5.6 通用问题速查表现象排查方向解决方案SPI读ID返回0xFFFFSPI通信未建立检查接线、CS电平、时钟极性/相位SPI读ID返回0x0000SCK信号异常检查SCK引脚配置、SPI时钟是否使能擦除后读数据还是旧值写保护未解除执行Unprotect检查BP位写入数据读回错位SPI模式不匹配确认CPOL0、CPHA0跳转后HardFault中断向量表未设置main开头执行SCB-VTOR APP_BASE_ADDR固件校验总失败边界长度计算错误检查长度是否256整数倍的特殊处理升级中途断电变砖标志区丢失确认默认恢复逻辑指向有效固件串口传输偶尔丢帧波特率误差或地线问题共用电源地检查波特率精度6. 完整工程结构与后续扩展建议6.1 工程代码组织最终我的工程代码分了三个Keli工程STM32F103VET6_Project/ ├── G0_BootLeader/ -- 内部Flash引导段4KB以内 │ ├── Core/ │ ├── Hardware/ │ │ ├── spi.c │ │ └── w25q64.c │ └── main.c │ ├── Bootloader/ -- 外部Flash容器内搬到SRAM执行 │ ├── App/ │ │ ├── boot_state.c │ │ ├── upgrade_proto.c │ │ ├── crc32.c │ │ └── serial.c │ └── main.c │ └── Application/ -- 用户业务代码内部Flash起始0x08001000 ├── BSP/ ├── App/ ├── System/ │ └── main.c └── ...G0_BootLeader是最简单的工程编译出来后烧录到内部Flash的0x08000000。Bootloader和Application是两个独立工程编译产物分别是BIN文件通过Python脚本添加头部后格式化为升级包。首次量产烧录时我写的烧录流程是通过ST-Link烧录G0_BootLeader到内部Flash的0x08000000。通过串口工具将Bootloader BIN传输到W25Q64的0x000000地址。通过串口工具将Application固件传输到W25Q64的0x010000A区。设置运行标志区的app_a_status为有效。后续OTA升级时只需要经过步骤3而且写到的是B区彻底避免了量产烧录和升级流程混杂的问题。6.2 这方案的性能表现到底如何我把项目的核心指标实测数据整理出来项目数值备注Bootloader大小18KB编译后BIN文件实测G0引导段大小3.8KB控制在4KB以内启动到Application时间约120ms含CRC校验与跳转W25Q64擦除速度约300ms/扇区实测值W25Q64写入速度约80KB/s页编程连续写入192KB固件升级耗时约23秒串口115200波特率下SRAM占用约21KBBootloader执行期间升级耗时如果觉得太长可以把串口波特率提升到460800甚至921600理论传输时间能压缩到三分之一左右。但要注意波特率超过230400后一些廉价的USB转串口芯片容易出现丢字节的现象传输出错率随之上升。如果在工业现场我更推荐115200这个保守值稳定压倒一切。6.3 从双备份到真正的OTA系统W25Q64双备份Bootloader是OTA的基础框架但严格意义来说它只是一个可靠烧录机制还没有和网络打通。如果要变成真正的OTA系统可以在Application里增加一个WiFi模块或者4G模块下载固件到外部Flash的暂存区然后设置升级标志并软复位让Bootloader接管后续的固件写入流程。这种架构的优势是固件下载和固件安装分时进行避免下载过程中因为网络中断导致Flash反复擦写。我做这个双备份方案的时候特意把固件写入逻辑设计成可以在Bootloader阶段执行方便后续接入各种通信模块。如果你后续要加网络升级只需要在Application里实现下载固件 - 暂存到W25Q64预留的暂存区 - 设置升级标志 - 软复位这几步Bootloader端几乎不用改动。6.4 我给后来者的一些建议整套方案做下来我最深的体会是Bootloader开发看起来很底层很硬核但真正决定质量的是状态管理和异常恢复逻辑。很多人在SPI驱动上花了两周调波形却在升级掉电恢复这个最关键的场景上只留了一个极简单的判断这在产品化的时候是会出大问题的。如果你是从零开始做这个方案建议按这个顺序推进第一周搞定W25Q64驱动SPI通信稳定读写正常第二周做G0引导段和Bootloader跳转目标是能从外部Flash把程序跑起来第三周做Application双分区和固件校验第四周做升级协议和上位机工具最后留一周专门做掉电、异常、边界测试。千万不要急着把升级协议做完前面的基础如果打不牢后面改起来全是返工。最后分享一个小技巧。调试Bootloader和Application的跳转逻辑时可以在Bootloader的跳转语句前加一个串口打印输出准备跳转的地址和校验结果。我第一次做的时候加了这个打印后来排查HardFault问题节省了大量时间因为你至少知道Bootloader走到了哪一步是根本没有跳转还是跳转后立刻挂掉。这种关键节点打日志的思路虽然朴素但在嵌入式调试中永远不过时。
返回列表