ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

STM32F103 AB分区OTA远程固件升级实战:Bootloader到App完整方案

STM32F103 AB分区OTA远程固件升级实战:Bootloader到App完整方案 最近做了一整轮的STM32F103远程固件升级从最底层的Bootloader到App端改造再到上位机传输工具把AB双分区OTA这条路从头到尾走了一遍。网上聊STM32 OTA的帖子不少但很多都停在“给你一个Demo”的层面换个芯片、换个Flash容量就不知道怎么调了。这篇教程我不只给代码而是把所有“为什么这么做”的取舍逻辑都讲透尽量做到你拿着文章就能在F103上完整复现一版真正能用的AB分区OTA方案。这套方案最核心的价值就一句话升级过程中就算断电、断连、新固件跑不起来设备也永远不会变砖。因为AB分区给设备留了一条“旧固件还能启动”的后路。接下来我从方案设计、Bootloader实现、App改造、传输协议到问题排查一个一个拆开讲。1. 方案选型与整体设计1.1 为什么是STM32F103和AB分区STM32F103是我在量产项目里用得最多的MCU之一Cortex-M3内核、最高72MHz主频、Flash从64KB到512KB都有覆盖性价比高、资料多、上手快拿来做OTA这事儿再典型不过。这次教程以100脚的STM32F103ZET6为例来讲因为它有512KB Flash空间宽裕同时也给出64KB的C8T6布局版本方便小项目直接抄作业。OTA升级的方案我见过不少有最简单的单分区方案Bootloader直接擦除整个App区再写入新固件优点是Flash占用少缺点是擦除过程中一旦断电设备就直接变砖。也有带外部Flash的方案固件先存到外部SPI Flash再做交换这对没有内部大Flash的芯片比较合适但硬件成本和多一层驱动复杂度是绕不开的。AB分区方案是这几类里安全性最顶的。它把App区拆成两个独立分区A分区和B分区。当前运行的是A那升级就往B写写完校验没问题下次启动就切到BB跑不起来就自动回滚到A。整个过程永远有一个“已知能跑”的固件保底这是它在汽车ECU、物联网设备、安卓系统里都被广泛采用的根本原因。1.2 Flash空间怎么分分区的核心原则就三条Bootloader区域要够用每个App分区要装得下编译出来的固件FOTA参数区必须有独立的空间且不能被App覆盖。以STM32F103ZET6512KB Flash为例我实际使用的分配方案是这样的区域起始地址大小用途Bootloader0x0800000032KB引导程序、升级逻辑App A0x08008000128KB当前运行固件默认App B0x08028000128KB备份固件/升级目标FOTA参数区0x080480004KB升级标志、版本、CRC预留区0x08049000剩余用户数据、日志这个方案里ByBootloader留了32KB足够放下串口驱动、Flash驱动、FOTA协议栈和状态机App分区各128KB对绝大多数F103应用“编译出来的固件在128KB以内”是够用的。如果项目是64KB的C8T6布局就压缩成Bootloader 16KB、App A 20KB、App B 20KB、参数区4KB。要特别提醒的是Bootloader的32KB不是随便定的它是编译期决定的。你写Bootloader工程时如果代码超出32KB链接阶段会直接报错反过来App分区也是一样。所以分区大小要根据实际编译产物的大小动态调整不要一开始卡太死。1.3 完整升级流程设计这套AB OTA的状态机大概是这样跑通的设备当前从A分区运行上位机通过UART发送“开始升级”命令命令里包含新固件的版本号、二进制长度和CRC32校验值。App收到命令后在FOTA参数区写入“请求升级”标志和目标分区这里就是B分区然后执行软复位。Bootloader启动后读到“请求升级”标志进入升级模式开始通过UART接收固件数据边收边擦写B分区。固件全部接收完成后Bootloader对B分区的完整数据做CRC32校验校验通过就把当前活跃分区切换为B并把状态置为“等待确认”。Bootloader跳转到B分区。B分区的App正常启动后向上位机上报版本号或者上位机主动查询版本确认没问题后下发“确认升级”指令App把状态置为“确认完成”。如果B分区的App起不来看门狗会复位系统Bootloader发现状态还是“等待确认”说明新固件没被确认能跑于是自动回滚到A分区。这套流程把A/B分区的“先备份再切换最后回滚”的思路体现得清清楚楚。Bootloader端对应实现跳转、下载、校验、切换App端对应实现请求升级和确认逻辑两边耦合度低后续想扩展其他通信方式CAN、以太网、WiFi也只需要替换传输层的驱动。2. Bootloader端核心实现2.1 Flash驱动与FOTA参数区管理Bootloader的Flash驱动主要是两个操作整页擦除和半字写入。STM32F103的内部Flash写入有几个硬性限制必须按16位半字对齐编程擦除必须按页ZET6是2KB一页擦写期间不能有中断对Flash的并发访问而且操作前必须解锁Flash寄存器。解锁的正确姿势是往Flash_KEYR寄存器依次写入0x45670123和0xCDEF89AB。擦除页操作需要设置FLASH_CR的PER位和对应页地址然后置STRT位启动擦除编程操作设置PG位后写入半字数据。每次操作完必须等待BSY位变成0才算完成。写App时这些逻辑会被反复调用我封装了三个最基本的接口Flash_ErasePage、Flash_WriteHalfWord、Flash_EraseAndWriteBuffer。FOTA参数区我用一个结构体来管理typedef struct { uint32_t magic; // 固定魔数0x5A5AF0F0用于鉴别有效性 uint32_t active_slot; // 当前活跃分区0分区A1分区B uint32_t upgrade_state; // 升级状态0无1请求升级2等待确认 uint32_t target_slot; // 本次升级目标分区 uint32_t fw_version; // 新固件版本号 uint32_t fw_size; // 新固件字节数 uint32_t fw_crc32; // 新固件整体CRC32校验值 uint32_t param_crc; // 对整个结构体的CRC32校验 } fota_boot_cfg_t;结构体本身只占32字节但F103的Flash一页就有2KBZET6所以每次写参数区都要先擦除整页再写不能做字节级修改。这也是为什么要把参数区单独放在一个页里避免和代码混在一起导致误擦写。为了防断电生产级做法是把参数区做双备份写参数时交替写两个页哪个页的magic和param_crc都正确并且saved序号更新就用哪个页。在教程版本里我只用一个页但在文末会提示这个坑。读取参数区时如果magic不对或者param_crc不对说明参数区是脏数据Bootloader应该按出厂默认值处理默认从A分区启动。2.2 跳转函数与向量表重映射的细节Bootloader的最终任务是跳转到活跃分区App。这里最容易出问题的地方有两个一个是向量表一个是MSP主堆栈指针。Cortex-M3内核通过向量表定位中断入口。CPU复位后从0x08000000开始取向量表Bootloader本身就在0x08000000所以没问题。但跳转到0x08028000的AppB分区时向量表就变了地址0x08028000处存放的是AppB的栈顶地址0x08028004存放的是AppB的复位函数地址。如果不把VTOR寄存器改成0x08028000中断来了CPU还是去0x08000000找向量App的所有中断就全废了。跳转函数的核心逻辑我封装成下面这段void jump_to_app(uint32_t app_addr) { uint32_t msp_value *(volatile uint32_t *)app_addr; uint32_t reset_vector *(volatile uint32_t *)(app_addr 4); // 简单的地址合法性检查防止跳到非法的SRAM/Flash地址 if ((msp_value 0x2FFE0000) ! 0x20000000) { return; } __disable_irq(); // 关闭全局中断 SysTick-CTRL 0; // 停止SysTick SysTick-LOAD 0; SysTick-VAL 0; // 清空所有挂起和使能的中断 for (int i 0; i 8; i) { NVIC-ICER[i] 0xFFFFFFFF; NVIC-ICPR[i] 0xFFFFFFFF; } __set_MSP(msp_value); // 设置App的栈顶 SCB-VTOR app_addr; // 切换向量表 typedef void (*reset_fn)(void); reset_fn go (reset_fn)reset_vector; go(); // 跳转 }这里有个细节跳转前把SysTick停了把NVIC里面使能的、挂起的中断全清了。如果不做这步App启动后拿着一个“半配置”的中断控制器状态中断随时可能乱掉。这也是很多初版Bootloader跳转后程序跑飞、中断异常的头号原因。另一个容易被忽略的点是跳转前不需要刻意把栈切回Bootloader的栈因为函数返回地址本身就在栈里跳转后App会覆盖栈指针之前的栈内容就废了。2.3 Bootloader主流程与状态机Bootloader的主函数逻辑比想象中简洁核心就是检查参数区状态决定是去下载固件还是直接跳转App。int main(void) { // 基础硬件初始化时钟、串口、LED uart1_init(115200); fota_boot_cfg_t cfg; fota_boot_cfg_read(cfg); if (cfg.upgrade_state FOTA_STATE_REQUEST) { // 进入升级下载模式 fota_start_download(cfg); } // 如果状态是“等待确认”说明上次新固件没确认回滚 if (cfg.upgrade_state FOTA_STATE_PENDING_SWITCH) { if (cfg.active_slot SLOT_A) cfg.active_slot SLOT_B; else cfg.active_slot SLOT_A; cfg.upgrade_state FOTA_STATE_NONE; fota_boot_cfg_write(cfg); } uint32_t app_addr (cfg.active_slot SLOT_A) ? APP_A_ADDR : APP_B_ADDR; jump_to_app(app_addr); }这段逻辑里比较关键的是“等待确认”状态的回滚处理。当App被跳转后如果没正常运行看门狗会把系统复位Bootloader再次启动时就会看到upgrade_state还是“等待确认”说明新固件没有被App确认过。这时候Bootloader把active_slot切回另一个分区也就是回滚到升级前的固件。这个机制是整个AB方案安身立命的根本。升级下载模式fota_start_download内部就是状态机的核心等待初始化命令、接收数据、校验CRC、切换分区。后文第4节会详细展开。3. App端改造与工程配置3.1 Keil工程链接地址怎么改App最重要的一件事就是让编译器知道“我编译出来的固件是要跑在0x08008000或0x08028000的”不是跑在0x08000000。如果你用Keil MDK打开Options for Target切到Target选项卡把IROM1的Start改成0x08008000Size改成0x20000128KB。如果用的是GCC工具链修改链接脚本里的FLASH起始地址和长度即可。这一步直接决定固件里的所有绝对跳转地址、向量表位置、常量表位置改错一个字App都无法启动。另外关于向量表偏移我建议在App的system_stm32f10x.c中这样处理Cortex-M3的VTOR寄存器就在0xE000ED08标准库的SystemInit函数里已经有一段根据VECT_TAB_OFFSET设置VTOR的逻辑但你如果没定义VECT_TAB_OFFSET它默认就是0等于没设。最省事、最不容易漏的做法是在Keil的编译宏Define里加上VECT_TAB_OFFSET0x08008000这样SystemInit启动时就会自动把VTOR设置到App的向量表地址。同时为了双保险我在App的main函数开头又显式设置了一次int main(void) { SCB-VTOR APP_FLASH_ADDR; // 0x08008000 HAL_Init(); SystemClock_Config(); // ...其余外设初始化 }这种“编译宏 显式赋值”的双保险能规避一些极端情况下SystemInit执行前后发生中断又找不到向量表的问题。3.2 App端怎么接收升级请求App端不需要做复杂的升级逻辑它只负责两件事检测到升级请求然后把控制权交给Bootloader。我采用UART作为演示通信方式。App运行过程中UART中断持续监听数据收到完整的一帧升级初始化命令后解析出版本、长度、CRC32然后把FOTA参数区写好bool app_ota_request(uint32_t version, uint32_t size, uint32_t crc32) { fota_boot_cfg_t cfg; fota_boot_cfg_read(cfg); cfg.upgrade_state FOTA_STATE_REQUEST; cfg.target_slot (cfg.active_slot SLOT_A) ? SLOT_B : SLOT_A; cfg.fw_version version; cfg.fw_size size; cfg.fw_crc32 crc32; fota_boot_cfg_write(cfg); NVIC_SystemReset(); // 软复位交给Bootloader return true; }这里有个非常关键的小细节App在写入参数区之前一定要先检查写进去的值能不能读回来。我对Flash操作比较保守写完后立刻回读校验一遍确保参数区真的写成功了再复位否则宁可报错误也不触发复位防止参数区数据损坏导致Bootloader误判。3.3 App启动后的确认机制AB方案里“确认”这一步决定了新固件是否被正式激活。我设计的方式是Bootloader跳转到新App后App正常跑起来先向上位机发送一条“启动成功版本号V1.2”的消息上位机收到后下发“确认升级”命令App收到后把参数区的upgrade_state改成CONFIRMED写回Flash。这样下次重启Bootloader看到CONFIRMED就会认为当前分区是合法且已确认的不再做回滚。如果产品的通信链路里没有上位机来确认可以用看门狗“自确认”App启动后跑一个延时任务比如延时5秒如果中间没有发生看门狗复位就把状态改成CONFIRMED。这个方案适合纯离线产品。但要注意这个“自确认”会存在一个比较尴尬的情况如果固件在运行30分钟后才出问题它已经被确认过了系统不会自动回滚。所以“自确认”只能防住启动阶段的故障。因此我更推荐上位机主动确认的方式尤其是工厂和产线场景。4. 固件传输协议与下载状态机4.1 自定义UART分包协议格式Bootloader和上位机之间怎么传固件我定义了一套非常简单的二进制帧协议。帧结构如下字节偏移内容说明00xAA帧头110x55帧头22命令字0x01初始化 / 0x02数据 / 0x03结束 / 0x04查询版本3-4包序号大端序从0递增5-6数据长度本帧数据域长度最大10247~N数据域根据命令不同内容不同N1~N2CRC16对前面所有字节的CRC16校验为什么是CRC16而不是CRC32因为每帧数据量不大CRC16在误码率和资源占用之间平衡得很好。整包固件校验仍然用CRC32由帧头和初始化命令里的CRC32字段来保证。两层校验单帧校验保证传输无错包整体CRC32保证数据包完整性。上位机发送固件时按1KB一包切分。Bootloader每收到一包先校验帧头、CRC16再根据包序号判断是不是顺序到达如果序号不连续说明有丢包直接回复NAK要求重传。一包校验通过后才写Flash。固件传输过程中如果上位机连续发了很多包但Bootloader没回复ACK上位机就进入等待超时重传逻辑这样保留了一个简单的流控和纠错机制。4.2 Bootloader下载状态机Bootloader的下载模式内部我维护了一个状态机大概有这几个状态WAIT_INIT、WAIT_DATA、DATA_VERIFY、SWITCH_SLOT、DONE。WAIT_INIT状态下Bootloader等待上位机发0x01初始化命令。初始化命令的数据域包含版本号、固件总长度、整体CRC32。Bootloader收到后先擦除目标分区的所有页然后回复ACK状态切到WAIT_DATA。WAIT_DATA状态下Bootloader接收0x02数据帧。每包数据写入到当前偏移位置写完后更新偏移回复ACK包序号加一。这个状态里我还加了一个超时计数器如果3秒内没收到任何合法帧就判定上位机断连自动放弃本次升级状态回退并跳转到旧固件。这个超时机制非常重要它保证了Bootloader不会被一个挂死的上位机卡死在升级模式里。0x03结束命令触发DATA_VERIFY状态。Bootloader把运行过程中累计写入的字节数和CRC32与初始化命令里声明的值比对一致就把active_slot切换为target_slotupgrade_state置为PENDING_SWITCH然后复位让主流程跳转到新App。不一致就回复NAK并把状态机重置回WAIT_INIT等待上位机重新开始。4.3 Flash写入的掉电保护和校验策略升级过程中最怕掉电掉电那一刻可能正在擦页或者写半字Flash里的数据是残缺的。AB分区的定位就是让这种残缺的升级不伤害到当前正在运行的固件。所以Bootloader的设计中写入顺序和参数区切换顺序是核心。我把“写入目标分区”和“切换active_slot”完全拆开。目标分区比如B分区随便擦、随便写写坏了最多是个残废的B分区但active_slot的切换必须放在整体CRC32校验通过之后。只要active_slot没切过去Bootloader下次启动永远还会从A分区拉起旧固件。这种设计天然就是掉电安全的。另外在具体写Flash时我做了两个优化一是按页擦除每收到一包数据先检查当前页是否已擦除未擦除就先擦这一页再写入而不是把所有页一股脑先擦完。这样做的好处是升级中途掉电后已经擦除但还没写入的页可能变成0xFF这些空白页其实不影响重新升级而且更省时间。但要注意App分区大小必须对页大小对齐否则最后一包数据可能跨页。二是每次写完一个半字都检查FLASH-SR的BSY位和EOP标志确认操作真正完成后再继续。我早期为了图省事写完直接去写下一个地址结果偶发数据错位排查了几个晚上才发现是没等BSY。这种低级错误是最耗费调试时间的。4.4 参数区双备份的实战建议前文提到参数区单页方案在极端情况下有风险。举例Bootloader正在写入active_slot字段突然断电这一页上半部分是新的下半部分是旧的。如果上电后读到的magic正确但param_crc错误单页方案会当作默认值处理默认从A启动。这对大多数场景没太大问题但如果当时的活跃分区其实是B一断电就退回到A了产品行为可能不符合预期。我最终的方案是把FOTA参数区做成双页镜像两个页各保存一份fota_boot_cfg_t每份带序号。Bootloader读参数时读两页谁的序号新、谁的有效校验通过就以谁为准。写入时写到序号较旧的那一页。这个方案能扛住写入中途掉电上电后总有一份完整有效的参数。双页参数区在代码上大概多写30行逻辑但可靠性提升非常明显。量产OTA产品我强烈建议直接这么做不要只留一个页。5. 常见问题与调试经验整理5.1 跳转到App后程序跑飞或中断失效这是Bootloader项目实施中遇到最多的现象。表象是Bootloader跳转后App的main函数没执行、或者执行一半卡死、或者外设中断触发后直接进HardFault。排查顺序我一般这样走先确认跳转地址对不对。用调试器看一下跳转时取的msp_value和reset_vector确认它们和App编译生成的向量表前8字节一致。再确认App的链接地址。打开App编译生成的map文件看SystemInit和main的地址是否在0x08008000范围内如果还在0x08000000附近说明链接脚本没改App根本不是按分区地址编译的。再确认向量表。App启动后跑到main的第一行读一下SCB-VTOR看是不是0x08008000不是就对了。最后确认中断清除。Bootloader跳转前是否关闭全局中断、清除NVIC的状态漏这一步会使App的中断配置基于一个脏状态运行表现非常诡异。我遇到过一种很隐蔽的情况Bootloader和App都用标准外设库Bootloader初始化了UART1并开了串口中断跳转前没有关闭UART1外设的时钟。App启动后UART1的中断状态还是旧的串口一触发就误入HardFault。所以跳转前清NVIC和关外设时钟必须做全套。5.2 CRC32校验失败怎么排查升级过程中出现整体CRC32校验失败优先怀疑三个地方固件包本身不对、传输过程丢包/错包、写入Flash时写错位置。固件包本身不对最常见的情况是上位机把Bootloader的二进制打包进去或者拿hex文件当成bin文件传导致数据里夹杂着地址信息。所以我强制要求固件传输用bin文件不能用hex或者elf。在Keil里从User选项卡配置fromelf生成bin文件时也要确认生成的文件大小和App编译产物一致。传输丢包/错包可以依赖协议里的CRC16来发现。如果发现某一帧CRC错误Bootloader回NAK上位机重传这是正常的。真正异常的是上位机发了100包Bootloader只收到90包但每包的CRC都正确。这通常是因为Bootloader的串口接收缓冲区太小或者中断优先级配置导致丢帧。写入Flash时写错位置多半是因为偏移计算错误。我建议在Bootloader里写一个自检函数升级完成后把B分区的前64字节和上位机固件包的前64字节做一次逐个字节比对能快速暴露偏移问题。当然这只对第一包有效整体还是靠CRC32。5.3 升级后设备无法启动的快速定位设备升级完成后跳转到新App但整个系统起不来LED不闪、串口无输出。这种情况先不要急着怀疑App代码先看Bootloader有没有成功跳转。可以在Bootloader跳转前和App很早期的位置各放一个GPIO翻转作为“心跳”用示波器或者逻辑分析仪一看就知道程序走到哪一步了。还有一种可能是App工程的启动文件没有正确适配。STM32F103标准库的启动文件有两个主要版本针对中小容量芯片的hd和针对大容量芯片的xd。如果你用的是ZET6512KB Flash属于大容量但startup文件用的是小容量的Flash配置就会错乱App大概率起不来。这个坑在早期建工程时就要避免。另外App跑起来后别忘了初始化看门狗。如果App启动太慢Bootloader已经启动了看门狗App如果长时间没喂狗系统会被反复复位看起来就像升级失败。我建议Bootloader在跳转到App前把看门狗关闭由App按自己的节奏重新初始化、重新喂狗。这样两个固件之间的看门狗边界是干净的。5.4 调试工具和实测心得这套AB OTA我最终用了一个很朴素的方式来做端到端验证一块STM32F103ZET6核心板一个USB转TTL模块一个上位机Python脚本。Python脚本负责读bin文件、按1KB分包、计算CRC32、通过串口发送同时打印每包ACK状态和整体进度。这个方案成本极低但测试效果非常直观。在真实调试中有几个经验值得分享串口波特率不要一开始就用921600建议先用115200把流程跑通再逐步提高。高速率下如果USB转TTL模块质量一般容易出现单字节错位排查起来很闹心。擦写Flash期间串口中断里不要做耗时操作否则容易丢帧。我的处理是串口中断只把数据拷到环形缓冲区主循环里处理帧解析、Flash写入。FOTA参数区写入失败的概率虽然低但一定要做写后回读校验。一旦参数区损坏最坏情况是设备永远从默认分区启动或者不断升级回滚表现比死机还难查。建议在Bootloader里保留一段带版本号的串口日志上电后先打印Bootloader版本和当前活跃分区这样远程问题至少能从日志里快速判断设备处于什么状态。我在实际测试中最惊险的一次是把B分区写好后故意在跳转前的最后一个环节拔电。重新上电后发现Bootloader仍旧从A分区启动固件运行正常那一刻真切感受到了这套方案的价值。如果你也在设计一个对稳定性要求比较高的IoT设备或工业控制器AB分区OTA绝对值得认真投入。这个方案后续可以扩展的方向很多传输层换成CAN、以太网、WiFi模块上位机换成云端加上固件签名和加密验签就能直接用于产品化了。你也可以在这个基础上加一个外部Flash保存固件包走“下载到外部Flash—校验—再复制到内部Flash”的流程这又是另一套更复杂的工程迭代了。
返回列表