ARTICLE DETAIL

资讯详情

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

STM32F103 AB OTA双分区固件升级与自动回滚实现

STM32F103 AB OTA双分区固件升级与自动回滚实现 做嵌入式产品最怕的就是设备已经出货了固件发现Bug还得拆机回来烧录。你在开发台上焊个SWD口很简单可设备在客户现场、在户外、在没人去的机柜里这时候OTA就成了刚需。这篇文章是我在STM32F103上从零复现AB OTA的完整记录从分区表设计、Bootloader跳转到App侧改造和串口升级协议每一步怎么想、怎么做、踩了什么坑都会写清楚。先解释一下什么叫AB OTA把Flash划分成A、B两个App分区当前固件跑在其中一个槽位升级时把新固件写入另一个槽位写完后通过标志位切换启动分区。这样即使新固件一启动就崩溃Bootloader还能回滚到旧固件设备不会变砖。这个方案很适合对可靠性有要求的场景比如远程抄表、传感器节点、医疗/工业手持设备这类“坏了没法上门焊线”的产品。1. AB OTA是什么为什么要用双分区1.1 传统IAP的痛点传统的IAP升级通常只有一个App区。Bootloader负责接收固件然后覆盖写入当前App区。如果写入过程正常、校验也通过那没问题但真正让人头疼的是App写入完成重启之后发现新固件跑不起来或者启动到一半挂了这时候Bootloader里什么都没有唯一的选择就是重新走一次升级流程。要是现场没人指导或者传输链路不稳定设备就可能一直停在Bootloader里等同于变砖。还有一个更隐蔽的问题升级过程中途断电。Flash擦除到一半、写入到一半整个App区已经面目全非既没有旧固件也没有新固件。这种场景对电池供电的设备来说太常见了——电池没电、用户关机、线松了都可能导致升级中断。AB OTA用双槽位从结构上解决这个问题任何时刻都保留一份已知可用的固件。你要覆盖的永远是不工作的那个槽位正在运行的那个槽位全程不受影响。1.2 A/B方案的原理与优势A/B方案的核心思想是把“更新”这件事做成可回退的事务。设备日常运行在槽位A要升级时新固件写入槽位B写完后修改启动标志让Bootloader下次开机优先尝试槽位B。B能正常启动并运行几秒钟就确认B可用后续一直用BB启动失败Bootloader自动回到A。下次再升级反过来往A里写。这样做有几个直接好处升级中断、断电、写错再启动还是老固件。新固件崩了设备能在几次重启后自动回滚不需要人工干预。可以做到“升级后后悔了也能退回老版本”产品维护上有很大的操作空间。升级过程不阻塞正常运行应用代码可以该干嘛干嘛接收完固件再统一重启。代价也很明显Flash占用翻倍App区从一块变成两块对芯片容量提出了更高要求。所以做这个方案之前先算一笔Flash账。1.3 你需要哪些基础这篇文章默认你具备这些基础会建一个STM32F103的标准库工程能把程序下载到板子上用过串口收发了解Flash的基本操作概念。如果你还不会先去跑一遍GPIO点灯和串口打印再回来看这篇文章会更顺。另外要先明确这颗芯片是Cortex-M3内核中断向量表偏移寄存器SCB-VTOR是存在的这是我们在F103上实现OTA的关键之一。很多老工程师在用F1的时候会误以为它没有VTOR实际上F1有只是不像F4那样被频繁使用。2. 硬件与开发环境准备2.1 最小系统与板卡选型复现这个项目一块STM32F103C8T6最便宜但它的Flash只有64KB做AB OTA会很局促。我建议手头有F103ZET6、F103VET6这类大容量芯片的朋友优先用大容量芯片练习因为512KB的Flash做AB分区非常从容调试起来心态完全不同。如果你只有C8T6也不是不能做只是App区域要压得很紧。我实际算过一笔账64KB Flash分给Bootloader 16KB剩下48KB分成A和B两个槽位每个才24KB。你随便挂个FreeRTOS、加上协议栈、再加上业务逻辑很容易就超了。所以小容量芯片上做ABApp必须精简到极致。建议的硬件准备STM32F103系列开发板一块推荐带板载USB转串口的。USB转串口模块一个最好是带DTR/RTS的方便后面做自动ISP或者简单复位控制。一个按键用来让设备进入Bootloader升级模式也可以用命令触发。USB线、杜邦线若干。一个SWD调试器比如ST-Link V2调试的时候离不开。我自己用的是F103ZET6的板子Flash 512KBRAM 64KB整个项目做下来非常舒服。2.2 标准库开发环境配置F103的开发库选择上我推荐用标准外设库v3.5。热词里经常搜“stm32f103库v3.50下载”说的就是Standard Peripheral Library V3.5.0这是ST官方为F1系列发布的标准库成熟稳定资料最多。HAL库现在虽然流行但对F1这种老芯片来说标准库的代码更精简对Flash操作的底层逻辑也更透明做OTA这类对时序敏感的功能更合适。工程结构建议这样组织Bootloader工程独立的Keil工程编译产物是bootloader.bin烧录在0x08000000。App工程也是独立的Keil工程编译产物是app.bin通过OTA写入A/B槽位。上位机脚本Python写的串口升级工具。不要试图在同一个工程里用宏开关区分Bootloader和App分开工程管理最干净否则链接脚本和启动文件的改动会相互干扰后期维护很容易出乱子。标准库工程建好后先把串口调通打印“Hello Bootloader”或者“Hello App”确认两个独立的工程都能独立跑起来再开始做分区和跳转。2.3 Flash容量规划与分区表这是整个项目最关键的一步分区没规划好后面全乱。我的分区原则是Bootloader尽量留足余量App分区按实际固件大小加30%左右余量标志位区域单独划出几页避免和App数据混在一起被误擦。以512KB的STM32F103ZET6为例我使用的分区表如下区段起始地址大小作用Bootloader0x0800000064KBIAP引导、升级协议、Boot信息管理App A0x08010000128KB运行槽位AApp B0x08030000128KB运行槽位BBootFlag0x0805000064KB启动标志、升级计数、版本信息Reserved0x08060000剩余空间预留可用于参数存储、日志BootFlag独立占一块区域主要是因为Flash的擦除是以页为单位的如果标志位和App区域放在同一页里升级时很容易误伤。独立分页后任何情况下擦写App都不会影响启动标志。如果你用的是C8T6这种64KB Flash建议这样分区段起始地址大小Bootloader0x0800000016KBApp A0x0800400024KBApp B0x0800A00024KB后面我统一以512KB芯片为例来写代码但思路完全一致。3. Bootloader核心实现3.1 启动标志与Boot信息管理Bootloader的第一个任务是维护一份可靠的“系统启动状态”记录。我先定义一个Boot信息结构体存储在BootFlag区域的首个Flash页里#define BOOT_FLAG_BASE 0x08050000 typedef struct { uint32_t magic; // 0xA5A5A5A5判断数据是否有效 uint32_t active_slot; // 0 表示当前使用槽位A1 表示当前使用槽位B uint32_t boot_count; // 连续启动尝试次数用于失败回滚 uint32_t update_slot; // 本次固件升级写入的槽位1表示有新固件在B0表示新固件在A uint32_t new_version; // 新固件版本号 uint32_t boot_success; // App启动成功后写入的确认标志 uint32_t reserved[2]; } boot_info_t;Boot信息在系统里承担的角色类似于电脑上的“引导项记录”。Bootloader每次上电都先读取这块数据判断要不要切换到新的固件槽位App启动成功后再把“启动成功”这个结果写回去。这套交互机制是AB方案能够自动回滚的基石。这里有个必须注意的细节写Boot信息要防止写一半断电导致数据半新半旧。我的做法是写入前先把整页擦除然后一次性写入结构体最后再读回校验一遍。如果读回数据和写入数据不一致就判定本次记录失败等下次重启再处理。3.2 固件有效性判定CRC32Bootloader在跳转到某个槽位之前必须确认这份固件是完整的、可运行的。简单做法是校验固件头部的CRC32字段。App在打包时计算整个固件bin的CRC32填入固件头部Bootloader在跳转前读固件头部拿到CRC值后对固件真实长度做一遍CRC计算两个值相等才允许跳转。CRC32计算函数用最常见的查表法实现static uint32_t crc_table[256]; void crc32_init(void) { for (uint32_t i 0; i 256; i) { uint32_t c i; for (int k 0; k 8; k) { c (c 1) ? (0xEDB88320 ^ (c 1)) : (c 1); } crc_table[i] c; } } uint32_t crc32_calc(uint32_t crc, uint8_t *buf, uint32_t len) { crc ~crc; for (uint32_t i 0; i len; i) { crc crc_table[(crc ^ buf[i]) 0xFF] ^ (crc 8); } return ~crc; }在Bootloader里我们通常只读Flash不做大量运算所以CRC计算速度无所谓但在App侧计算发送固件的CRC时效率就值得关心。实测下来在72MHz主频下用查表法算32KB固件耗时在毫秒级完全够用。固件头部结构定义如下typedef struct { uint32_t magic; // 0x4F544146 OTAF固件魔数 uint32_t length; // 有效固件长度 uint32_t version; // 固件版本号 uint32_t crc32; // 固件内容CRC32 uint32_t reserved[4]; } fw_header_t;3.3 从Bootloader跳转到App跳转功能是整个Bootloader的核心动作。思路是这样取App区域首地址的前4字节作为栈顶指针MSP再取紧接着4字节作为复位入口地址然后把中断向量表重映射到App区最后把MSP设置成App自己的栈顶直接跳转到App的复位函数。Cortex-M3上SCB-VTOR位于0xE000ED08标准库的core_cm3.h里已经定义好了SCB结构体直接用就行。关键代码#define APP_A_ADDR 0x08010000 #define APP_B_ADDR 0x08030000 typedef void (*pFunction)(void); static uint8_t jump_to_app(uint32_t app_addr) { uint32_t msp_value *(volatile uint32_t *)app_addr; uint32_t reset_addr *(volatile uint32_t *)(app_addr 4); pFunction jump; /* 栈顶要落在RAM范围内否则固件无效 */ if ((msp_value 0xFFF00000) ! 0x20000000) { return 0; } if ((reset_addr 0x08000000) ! 0x08000000) { return 0; } jump (pFunction)reset_addr; __disable_irq(); SCB-VTOR app_addr; __set_MSP(msp_value); jump(); return 1; }跳转前一定要把所有全局中断关掉否则跳进App的瞬间如果中断向量表还没完全准备好任何一个外部中断进来都可能直接跑飞。App的main函数里第一件事要重新初始化中断并使能全局中断。另外一个容易忽略的问题跳转前Bootloader用到的外设最好复位到初始状态尤其是串口、DMA、定时器这些。否则App启动时这些外设还处于Bootloader配置的模式极有可能引发初始化异常。我习惯在跳转前调用一个deinit函数把用到的外设全部关掉。3.4 利用Boot Counter实现失败回滚仅仅做CRC校验还不够CRC只能保证固件数据是完整的不能保证固件能真正跑起来。新固件可能在main函数之前就因为初始化某个外设失败而死机这个时候CRC是算不对的——实际上CRC算得对但固件起不来。所以我们需要“启动计数”机制。Bootloader的启动决策逻辑是这样的读取Boot信息如果magic无效按默认值初始化active_slot0boot_count0。检查update_slot字段。如果update_slot等于0说明当前没有待确认的新升级优先启动active_slot指向的槽位。如果update_slot有效比如等于1即新固件写到了B槽Bootloader尝试从B槽启动同时把boot_count加1并写回Boot信息。App启动后运行正常在进入业务逻辑前调用“确认启动成功”接口把boot_success置1并清零boot_count同时把active_slot指向当前槽位、update_slot清0。如果App运行几秒后没有确认Bootloader在下一次启动时发现boot_count超过了阈值比如3次就认为这个新固件起不来把update_slot清0、active_slot切回原来的槽位从老固件启动。这套逻辑看起来简单但写起来要有耐心因为要处理各种边界情况比如升级过程中途断电、确认写了一半断电等。我的建议是画一张状态流图贴在你的工作台旁边写代码的时候对照着来。4. App侧改造要点4.1 链接脚本与向量表偏移App侧第一件大事是把编译出来的固件地址放到正确的位置。在Keil里打开Options for Target找到Target选项卡把IROM1的Start改成App分区的起始地址Size改成分区大小。以512KB芯片、App A槽位为例IROM1 Start: 0x08010000IROM1 Size: 0x20000128KBIRAM1 Start: 0x20000000IRAM1 Size: 0x1000064KB第二件大事是中断向量表的重映射。对于标准库SystemInit最后会读取VECT_TAB_OFFSET宏来设置SCB-VTOR。我们需要在App工程里全局定义一个宏或者更直接一点在main函数最开头手动设置int main(void) { SCB-VTOR 0x08010000; // 必须放在任何外设初始化之前 HAL_Init(); // 如果用的是HAL库 SystemClock_Config(); /* 其他初始化 */ }这句SCB-VTOR赋值是App能不能正常响应中断的分水岭。我见过太多人忘记写这句导致App能跑主循环但一进中断就HardFault。原因就是中断发生后CPU还去0x08000000找中断向量表而那里是Bootloader的代码解释不通就崩了。4.2 App中的升级模块App侧要有一个升级接收模块和应用业务解耦。我一般把它做成一个独立的小模块fw_update.c/h对外只暴露三个接口void fw_update_init(void); void fw_update_process_byte(uint8_t byte); // 串口收到字节后喂进来 void fw_update_confirm_success(void); // 启动成功后调用串口收到数据后在中断回调或轮询循环里把字节喂给fw_update模块。模块内部维护一个状态机等待帧头、解析命令、接收数据、写入Flash、校验、重启。状态机的好处是升级流程可以被切成很多小步骤配合串口中断不会卡死。这里不要用阻塞式接收否则升级过程中串口偶尔丢一个字节整个流程就要重新开始非常折磨人。当App接收到“确认启动成功”时机时调用fw_update_confirm_success内部去BootFlag区写入boot_success标志。这一步要注意写入Flash之前要关闭中断写完之后再打开防止写入期间被中断打断。还有一个设计取舍要讲清楚App收到完整固件包后到底是App自己直接写另一个槽位然后自己在内存里改Boot信息、自己软复位还是切到Bootloader让Bootloader来写两种方案都有人用。我采用的是前者App用空闲槽位接收固件并写入Flash写好之后改Boot信息然后软复位到Bootloader由Bootloader做最终的启动决策。这样Bootloader的代码可以保持极简只负责“验证跳转回滚”把复杂的通信和存储逻辑留给App。对有操作系统的产品App进程上下文里做这件事也更灵活。4.3 升级成功确认机制升级成功确认的时机很讲究。如果App一进main就确认成功那很多初始化错误还没暴露出来容易误判如果等太久又会影响用户感受。我的折中方案是App启动后先完成关键外设初始化、自检硬件然后启动一个5秒的定时器5秒内没有出现致命错误就调用fw_update_confirm_success确认成功。对于实时性要求高的产品可以把“确认成功”滞后到第一个业务周期完成之后。我遇到过一个项目App初始化后需要和上位机握手握手成功才算真正的“能工作”于是我们把确认成功的时机放在握手成功之后这样回滚判断更准确。还要配合IWDG独立看门狗使用。App启动时必须把IWDG喂起来否则Bootloader启动App后如果App卡死在某个循环里看门狗会在几百毫秒内把系统复位Bootloader就能及时感知到“这个App不行”并回滚。看门狗超时时间要设计合理至少要覆盖App最长启动时间又不能太长让用户等太久。5. 串口升级协议与上位机脚本5.1 帧格式设计Bootloader或者App接收固件都要有明确的帧格式。我用的协议很简单帧头命令长度载荷校验字段长度说明帧头2字节固定0xAA 0x55命令1字节见下方命令表长度2字节载荷长度小端载荷N字节具体数据CRC162字节从命令到载荷的CRC16校验命令表命令代码说明CMD_ERASE0x01擦除指定槽位的固件区域CMD_WRITE0x02写入一段固件数据含偏移地址CMD_VERIFY0x03校验固件CRC等待确认CMD_REBOOT0x04重启进入Bootloader或AppCMD_VERSION0x10查询当前固件版本我把擦除和写入分开是为了避免边擦边写时把总线占用时间拉得过长。实际传输时上位机先发一条CMD_ERASE等待设备返回成功标记再分片发送CMD_WRITE每片1KB左右正好等于F103一页Flash大小。这样处理的好处是擦除一次、写入一页单次Flash操作时间短不会因为长时间占用Flash总线而丢失串口数据。5.2 Flash写入流程细节App端收到CMD_WRITE后要先把数据缓存到RAM凑满一页再擦除写入。为什么不边收边写因为F103的Flash写入必须以16位半字为单位串口数据是按字节流进来的如果不对齐直接写会写乱。写入一页的流程uint8_t page_buf[2048]; uint32_t page_count 0; void fw_write_page_to_flash(uint32_t app_base, uint32_t page_index) { uint32_t page_addr app_base page_index * FLASH_PAGE_SIZE; uint16_t *p (uint16_t *)page_buf; FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); FLASH_ErasePage(page_addr); if (FLASH_WaitForLastOperation(FLASH_WAIT_TIMEOUT) ! FLASH_COMPLETE) { FLASH_Lock(); return; } for (uint32_t i 0; i FLASH_PAGE_SIZE; i 2) { FLASH_ProgramHalfWord(page_addr i, *p); if (FLASH_WaitForLastOperation(FLASH_WAIT_TIMEOUT) ! FLASH_COMPLETE) { FLASH_Lock(); return; } } FLASH_Lock(); }这里有几个关键点调用FLASH_Unlock之后如果之前有编程错误标志不清理的话后续操作会失败。所以我习惯每次先ClearFlag再操作。STM32F1的页大小中容量和高容量芯片不一样。中容量是1KB高容量是2KB代码里FLASH_PAGE_SIZE要按芯片类型调整。擦除一页在F103上大约耗时20毫秒到40毫秒写一页的时间也差不多。这段时间不要被长时间占用否则串口DMA缓冲区可能会溢出。说到串口我强烈建议接收侧用DMA空闲中断加环形缓冲区。升级时数据量比较大每包1KB如果只用普通中断在72MHz下接收不是不行但容易在Flash操作期间漏掉数据。用DMA在后台搬运CPU在Flash操作完后统一处理缓冲区里的数据稳定很多。5.3 上位机发送脚本上位机我用Python写依赖pyserial几百行代码搞定。核心流程import serial import struct import zlib def send_packet(ser, cmd, payloadb): length len(payload) header struct.pack(BBH, 0xAA, cmd, length) crc crc16(header[1:] payload) ser.write(header payload struct.pack(H, crc)) def send_firmware(ser, fw_path, app_base): fw open(fw_path, rb).read() crc32_val zlib.crc32(fw) 0xFFFFFFFF fw_header build_fw_header(crc32_val, len(fw), version) send_packet(ser, CMD_ERASE, struct.pack(I, app_base) struct.pack(I, len(fw))) wait_ack(ser) for offset in range(0, len(fw), 1024): chunk fw[offset:offset1024] payload struct.pack(I, offset) chunk send_packet(ser, CMD_WRITE, payload) wait_ack(ser) send_packet(ser, CMD_VERIFY, struct.pack(I, crc32_val)) wait_ack(ser) send_packet(ser, CMD_REBOOT)上位机脚本里有个小坑串口发送太快设备来不及处理所以每个包发送后必须等设备回ACK再发下一包。ACK超时则重发当前包连续重发3次失败则终止升级。6. 常见问题与排查技巧实录6.1 高频问题速查表复现过程中遇到的问题我整理成一张表周围几个同事做类似项目时也直接拿来参考现象可能原因排查方法跳转到App后立刻HardFault向量表没重映射、栈顶指针无效检查跳到App前SCB-VTOR是否赋值检查IROM1起始地址是否和分区一致App能跑主循环但一进中断就死中断向量表还指在0x08000000在main最开头设置SCB-VTORApp基址升级写入Flash后校验失败页擦除没做、半字没对齐、跨页边界出错确认写入前先擦除整页代码里每2字节写一次串口升级到一半卡死擦除期间串口数据丢失、DMA溢出用DMA环形缓冲写入期间不要长时间关闭全局中断新固件启动后频繁回滚boot_success确认时机太早/太晚把确认放在关键初始化完成后再启动一个稳定运行定时器Boot信息被升级过程擦掉BootFlag区域和App区域重叠或同页单独划出BootFlag分页App擦写范围严格限制在自己的槽位内App A启动后往B写入却把A写坏了写Flash时地址计算错误在写入接口里加边界检查超过槽位范围直接返回失败6.2 我踩过的坑和避坑建议第一个坑是向量表偏移宏没生效。标准库的system_stm32f10x.c文件里有一个VECT_TAB_OFFSET宏我一开始在App工程里定义了它但后来发现编译器没把它传给SystemInit导致SCB-VTOR根本没被设置。排查了很久最后改成在main函数里手动赋值彻底绕开这个宏。建议你也这么做别依赖宏定义。第二个坑是跳转之前的串口状态没清理。Bootloader里用串口和上位机交互跳转前如果没有把串口中断关掉App初始化串口时会发现接收中断一直触发还收到一堆乱码。后来我写了专门的deinit函数把串口、DMA、定时器的中断全部禁用、外设全部复位问题就没了。第三个坑是BootFlag写入次数太多把Flash磨损了。调试过程中频繁擦写BootFlag区域F103的Flash擦写寿命是1万次左右如果不控制写入频率长期OTA之后这块区域的寿命会成为短板。我后来加了写入保护逻辑只有在boot_count或者active_slot真正变化时才写入不要每次启动都写一遍。第四个坑也是很多人忽略的Keil编译App时如果不勾选“Create Batch File”或正确设置fromelf生成不了纯二进制bin文件。HEX格式带地址信息上位机解析起来麻烦所以一定要转成bin。在Keil的User选项里After Build/Rebuild里加一行fromelf --bin -o ./Objects/app.bin ./Objects/app.axf这样才能在编译后自动生成app.bin上位机直接拿这个bin文件发就可以。7. 进阶方向与个人体会7.1 固件签名与加密CRC32适合工程验证但不适合产品安全。任何能接触串口的人抓一下升级时的数据包就能拿到完整的固件内容逆向你的业务逻辑。如果产品有防抄板需求至少要加一层AES加密固件包在传输过程中是密文设备端解密后再写入Flash。对于防篡改更严格的做法是加签名。固件头部多放一个RSA或者ECDSA签名Bootloader或App写入后、启动前验签。签名的计算在设备端可以做F103验签一个RSA-1024签名一般在几百毫秒以内可以接受。但要注意签名验证意味着每个固件发布流程都要有私钥管理流程上要规范私钥泄露比固件泄露严重得多。7.2 移植到HAL库和其他传输通道这篇文章的代码基于标准库但思路可以直接搬到HAL库工程。HAL库的Flash操作函数是HAL_FLASH_Program、HAL_FLASH_Unlock、HAL_FLASHEx_Erase本质和标准库一样。唯一需要注意的是HAL库初始化Flash前要先调用HAL_FLASH_Unlock这个坑在HAL库工程里十分常见。传输通道也可以扩展。串口只是最普通的载体蓝牙、Wi-Fi、LoRa、CAN总线的升级协议框架完全一样只是把“串口收到字节”换成对应外设的收包回调。我后来在一个产品上把串口升级直接平移到了BLE透传状态机和Flash写入逻辑一行没改只是数据入口变了。这就是把协议和传输解耦带来的好处。7.3 最后的几点经验用STM32F103做AB OTA这个项目我自己最大的体会是方案本身不复杂难的是把每个细节都想周全。Flash分区要提前规划不要临时改Boot决策逻辑要写成状态机不要用一坨if else拼中断向量表偏移要在main最前面做这个顺序错了后面全白搭每个写Flash的动作都要考虑断电要能恢复。如果你打算在产品里用AB OTA我建议先在一个最小实验板上把整个链路跑通再往业务工程里移植。移植的时候不要急着搬代码先把Boot信息结构、分区定义、启动决策流程这些核心设计抄清楚再动手写。我见过太多人一上来就复制别人的Bootloader源码结果芯片型号、Flash大小、分区地址对不上出了问题完全没法查。AB OTA带给产品的价值不仅仅是“能升级”而已它意味着设备出问题后有了一条自动恢复的后路。这种安全感等你真正在远程设备上跑通一次自动回滚就能体会到了。
返回列表