资讯动态

STM32F103 AB分区OTA升级方案详解与实战指南

发布时间:2026/9/8 21:16:50 来源:尧图企业网站定制
直接说结论STM32F103做AB分区OTA完全可行而且没那么玄乎。我最早接触OTA是在ESP32上后面因为一个量产项目必须在国内用F103C8T6这种老片子才被迫在Cortex-M3上啃下了完整的一套AB双备份升级方案。整个过程从懵到跑通中间踩了不少坑后来把这套东西整理成了工程模板陆续也有同行拿着它去改到G030、F401这些型号上。这篇文章就把我复现这套方案时的完整思路、代码结构、分区规划、跳转细节原原本本写出来适合已经在用标准库写STM32、正准备接触Bootloader和OTA的开发者也适合被单分区升级搞得头皮发麻、想换成AB方案的老手。1. 整体方案设计为什么非得在F103上搞AB分区先说一个现实问题STM32F103这片子放在2025年看性能确实不够看Cortex-M3内核、72MHz主频、片上Flash最多512KBC8T6只有64KB。但它在工控、车载后装、低成本消费电子里的存量极其恐怖很多产品到现在还在用它做控制核心。这就带来一个很直接的需求——产品出厂之后固件有bug或者要加功能总不能把设备拆回来烧录于是OTA势在必行。OTA本身不稀奇单分区也能做就是Bootloader接收固件后直接擦掉App区写新的。问题是如果写入过程中断电、传输中断、固件本身有bug设备就变成砖头了。很多开发者在F103上不敢做OTA怕的就是这个“升级变砖”。AB分区的意义恰好在这里——A区和B区互为备份升级过程中即使出了任何问题Bootloader都能检测到并回滚到另一个正常的分区设备永远有一个能跑的固件这是AB方案最核心、最不可替代的价值。我选择在F103上做AB方案还有一个非常实际的理由价格和国产替代。F103的全系列引脚和内存基本兼容意味着这套Bootloader代码可以在不同容量的型号之间平移只要改分区表就行。你花几天时间把AB OTA模板调通后面出9块钱的C8T6方案或者30块钱的ZET6方案都是同一套代码省下来的时间足够让你在项目排期里喘口气。1.1 双分区策略 vs 单分区方案对比先摆一个对比表格把两种方案的核心差异列明白了再说理由。对比项单分区OTAAB双分区OTAFlash占用只需要1份App空间需要2份App空间升级失败安全性无保护极易变砖Bootloader自动回滚安全断电/断流处理只能靠超时重传仍然可能变砖断电后仍可回滚到旧固件实现复杂度Bootloader逻辑简单Bootloader逻辑稍复杂但可控代码量Bootloader约300-500行约500-800行适用场景开发板、个人DIY、可接受返修量产设备、远程维护、户外设备看到这里可能会有疑问F103的Flash本来就不大C8T6只有64KB扣掉Bootloader以后要放两份App够用吗这是一个非常实际的问题答案取决于你的App实际有多大。拿我的项目举例App用标准库逻辑包括Modbus通信、ADC采集、继电器控制、OLED显示整个工程编译出来约27KB。在64KB的C8T6上Bootloader留12KBApp A占32KBApp B占20KB刚好塞下。如果你的App超过30KBC8T6就有点挤了我会建议直接换F103RCT6256KB或者F103ZET6512KB同样是F103系列代码完全不用重写只修改链接脚本里的Flash起始地址和大小即可。另外一个看似不显眼、实际影响很大的优点是AB分区天然适合服务器端灰度升级。你先在服务器上发布新固件只有小批量设备会拉取到B分区并切换到B分区运行如果这批设备运行稳定再把灰度比例调大如果这批设备出了问题服务器直接禁止继续发布已经升级的设备也可以被强制回滚到A分区。这种发布策略在单分区方案里想都不敢想。1.2 Bootloader App App的结构职责划分AB方案在F103上的物理结构非常清晰从Flash地址0x08000000开始依次排列Bootloader、App A、App B外加一个专门存放固件状态信息的小区域。这里的核心设计思想是Bootloader永远负责“管家”的角色它不参与业务功能只负责启动引导、校验固件、跳转执行App A和App B则是实际干活的人它们根据Bootloader写入的标志位决定自己的行为。三个区域的职责再具体一点Bootloader区上电最先执行读取状态区里的固件信息确认是否需要升级、当前哪个分区有效、哪个分区需要回滚然后跳转到有效的App。App区A/B业务代码运行的地方正常运行期间收到升级指令串口、网络等把固件数据传给Bootloader由Bootloader写入非活动分区写入完成后设置“待切换”标志然后软复位。状态区如0x0800F000开始的一页保存固件版本号、CRC校验值、启动计数、分区切换标志。这个区域必须独立存在绝不能和App代码区共用因为App升级时擦写的是整个App区不能把状态信息一并擦掉。一开始做这套结构时最容易犯的错是顺手把状态标志定义在App的某个全局变量里结果升级完成后Bootloader根本读不到这个变量。后面我统一改成了固定的Flash页地址并且定义成一个结构体用两个32位字做魔术字来判别数据是否有效才彻底解决了这个问题。具体实现细节在第三部分会给出完整代码。1.3 为什么选择YMODEM协议而不是串口裸传或者XMODEM固件传输协议是OTA方案里一个绕不开的环节常见选择有YMODEM、XMODEM、以及裸串口自定义协议。我最终选的是YMODEM原因有三点。一是YMODEM自带CRC16校验和包序号机制一个包128字节也有1024字节模式包含块序号、数据、校验码接收方能明确知道每个包是否传输成功。相比之下裸串口自定义协议需要自己实现分包、应答、校验代码量并不比YMODEM少而且自己造的轮子往往漏洞更多。二是YMODEM支持同时传输文件名和文件大小。Bootloader接收到文件名后可以直接获得固件大小用来判断固件是否超出App分区容量拿到文件大小后也能知道整个接收过程何时结束不需要依赖超时判断收尾逻辑简单得多。三是YMODEM是Tera Term、SecureCRT等串口工具原生支持的协议开发阶段不需要自己写上位机就能测试Bootloader的接收逻辑。后面要做成产品级工具再用Python或Qt实现一个带进度条、带版本校验的上位机也不难。这里顺便说一句很多人纠结要不要用更高大上的HTTP或者MQTT传输固件。我的观点是如果设备本身没有网络模块串口YMODEM就是最优解如果设备上有ESP8266或者W5500那可以用自定义协议在应用层封装但底层Flash写入和分区切换逻辑完全一样。先学串口YMODEM触类旁通后面再扩展网络通道只是时间问题。2. Flash分区规划给地址之前先把数学算明白在F103上做AB分区最致命的问题不是代码写不出来而是Flash地址规划出错。地址一旦定死后面所有代码都基于这个地址展开改起来极其痛苦。所以这一章我把自己的分区计算过程完整写出来并解释每一步为什么要这么做。F103的Flash起始地址固定在0x08000000不同的芯片型号容量不同C8T6是64KB0x08000000到0x0800FFFFRCT6是256KB0x08000000到0x0803FFFFZET6是512KB0x08000000到0x0807FFFF。分区之前先查手册确认你的具体型号容量别凭印象。2.1 C8T6与RCT6的分区参数计算过程以我项目里用过的F103C8T6和F103RCT6为例分别给出完整的分区计算过程。C8T664KB Flash的分区方案Bootloader从0x08000000开始大小12KB0x3000字节结束地址0x08002FFF。App A从0x08003000开始大小32KB0x8000字节结束地址0x0800AFFF。App B从0x0800B000开始大小20KB0x5000字节结束地址0x0800FFFF。状态区0x0800F000到0x0800FFFF1页F103每页1KB。校验0x08003000 0x8000 0x0800B000正好是App B的起始地址0x0800B000 0x5000 0x08010000溢出边界但由于总大小64KB0x0800FFFF是最后一个地址所以刚好卡满。RCT6256KB Flash的分区方案Bootloader从0x08000000开始大小16KB0x4000字节留出更大余量给未来Bootloader增加功能。App A从0x08004000开始大小112KB0x1C000字节。App B从0x08020000开始大小112KB0x1C000字节。状态区0x0803F000到0x0803FFFF1页。剩余空间0x0803C000到0x0803EFFF这段12KB空间可以留作参数存储区或日志区。分区计算的前提是先确定App A的大小而App A大小又取决于你的固件在-0s优化下编译出来的实际大小。我的经验做法是先将App区大小设成一个足够大的值比如64KB编译完看.map文件里_erw或者__initial_sp的实际值再反推真实占用最后在真实占用上留出40%余量作为正式分区大小。2.2 为什么Bootloader占12KB而不是更少Bootloader代码量其实可以压到6KB以内因为它的功能很单纯串口驱动、Flash擦写、YMODEM解析、CRC计算、跳转。但我不建议刻意压缩Bootloader的空间。一个很现实的原因Bootloader本身也要迭代。项目后期如果要增加固件加密、增加双Bank切换或者增加通信超时保护Bootloader代码必然膨胀。把Bootloader从8KB改成12KB留出足够余量可以避免后期为了塞下新功能去推倒重来。另一个原因是F103的擦除粒度是页1KB如果分区边界不是1KB的整数倍Flash操作会变得非常麻烦。12KB、16KB这些数字天然就是1KB的整数倍也不用担心跨页问题。顺带提一下F103的页大小在不同型号上有差异。中容量C8/CB的页是1KB大容量RC/RE/ZD/ZG的页是2KB。分区地址和擦除代码必须按目标型号的页大小来写直接复制网上代码而不检查页大小是踩坑高发区。我在RCT6上就吃过这个亏——用C8T6的1KB页写擦除函数跑在RCT6上把地址全擦错了。2.3 状态区与固件标志位的存储设计状态区的设计是整个AB OTA能够实现回滚的关键。它不保存固件数据只保存四个关键字段魔术字、当前活动分区编号、待切换标志、升级尝试次数。四个字段各占4字节整个结构体16字节存一页绰绰有余。我用一个结构体来管理这些字段typedef struct { uint32_t magic; // 魔术字固定为0xA5A5A5A5 uint8_t active_bank; // 当前活动分区0App A1App B uint8_t pending_bank; // 待切换分区0无1切到A2切到B uint8_t boot_count; // 启动计数正常启动后App清零 uint8_t reserved; uint32_t fw_version; // 固件版本高16位主版本低16位次版本 uint32_t fw_crc; // 当前活动分区的CRC32校验值 } ota_status_t;魔术字的作用是判断这页Flash是否被写过有效数据。F103的Flash擦除后所有字节都是0xFF如果读出来的magic不等于0xA5A5A5A5说明状态区是空的Bootloader会走默认流程在App A和App B里按CRC判断哪个能跑。boot_count则是配合回滚机制用的——如果Bootloader跳转到新固件后App里的看门狗超时或者系统崩溃App会在复位前把boot_count加到一个阈值Bootloader发现boot_count超过阈值比如3次就认为新固件起不来自动回滚到旧分区。这一套设计全部围绕一个原则Flash上必须有一块“无论App怎么折腾都不会被擦掉”的区域。单独分一页、用结构体读写、每次写入前先擦除这个区域就永远不会被App升级过程污染。3. Bootloader侧核心逻辑与代码实现Bootloader是整套AB OTA方案的灵魂。它不复杂但每一步都不能错。我会按启动流程的顺序把代码和设计思路拆开讲从向量表跳转到YMODEM接收再到最后的Boot跳转尽量覆盖你在自己写的时候会遇到的每一个细节。3.1 启动流程从复位到App跳转的状态机Bootloader的主逻辑是一个状态机比裸写if-else清晰得多。复位进入main后依次做四件事初始化时钟和串口、读取状态区、检查是否需要进入升级模式、按条件跳转或进入固件接收流程。我实现的简化版状态机如下typedef enum { OTA_IDLE 0, OTA_CHECK_STATUS, OTA_ERASE_BANK, OTA_RECEIVING, OTA_WRITING_FLASH, OTA_VERIFY, OTA_JUMP_TO_APP } ota_state_t; int main(void) { ota_state_t state OTA_CHECK_STATUS; uint8_t ch; SystemInit(); uart1_init(115200); led_init(); while (1) { switch (state) { case OTA_CHECK_STATUS: read_ota_status(g_status); if (g_status.pending_bank ! 0) { // 有待切换标志先验证目标分区CRC // CRC通过则切换失败则回滚到另一个分区 if (verify_bank(g_status.pending_bank) 0) { g_status.active_bank g_status.pending_bank; g_status.pending_bank 0; g_status.boot_count 0; write_ota_status(g_status); state OTA_JUMP_TO_APP; } else { // 目标分区损坏回滚到当前活动分区 g_status.pending_bank 0; write_ota_status(g_status); state OTA_JUMP_TO_APP; } } else { state OTA_JUMP_TO_APP; } break; case OTA_JUMP_TO_APP: // 确认活动分区CRC有效就跳转 if (verify_bank(g_status.active_bank) 0) { jump_to_app(g_status.active_bank); } else { // 活动分区也坏了只能等待用户重新升级 state OTA_RECEIVING; } break; case OTA_RECEIVING: // 检查串口是否有升级请求等待YMODEM数据帧 if (uart1_rx_available()) { ch uart1_read(); if (ymodem_receive_byte(ch) YMODEM_DONE) { state OTA_VERIFY; } } break; } } }进入升级模式的判断逻辑也值得说一下。我的做法是上电后Bootloader等待500ms如果串口收到字符U则进入升级模式否则直接跳转App。这个方案在开发调试时最方便——每次升级只需要在Tera Term里按住某个键或先发一个U然后发送YMODEM文件即可。量产时也可以把“进入升级”改成某种硬件引脚组合触发逻辑一样。3.2 串口接收YMODEM帧CRC16校验与分包重组YMODEM协议的帧格式如下SOH0x01表示128字节数据块STX0x02表示1024字节数据块块序号1字节从0开始序号到255后回绕块序号取反1字节数据128字节或1024字节CRC16高字节CRC16低字节接收端在准备就绪后先发送字符CCRC模式发送端收到C后开始传输。第一个包是文件名包包含文件名、文件大小和时间信息后续包是数据包传输完成后发送EOT结束。接收的关键点是CRC16校验函数必须和发送端一致。YMODEM协议标准使用CRC-16/CCITT多项式0x1021初值0x0000。网上能找到各种实现版本但很多初值或字节顺序不对导致校验不通过。我用的实现是查表法实测稳定static uint16_t crc16_ccitt(uint8_t *data, uint32_t len) { uint16_t crc 0x0000; while (len--) { crc ^ (*data 8); for (int i 0; i 8; i) { if (crc 0x8000) { crc (crc 1) ^ 0x1021; } else { crc 1; } } } return crc; }接收数据包后先比较块序号是否符合预期第一包必须是0再计算CRC16和收到的CRC比较。两个都通过才向发送端回复ACK0x06否则回复NAK0x15请求重发。这套逻辑虽然简单但一定要保证串口中断里不能做Flash擦除这类耗时操作否则会丢字节导致反复NAK。3.3 Flash擦写操作按页擦除、按半字写入的关键细节F103的Flash操作有几个硬性规则违反了直接HardFault写Flash前必须先擦除所在页而且Flash只能把1写成0不能把0写成1。写入必须按16位半字half-word进行不能按字节写。Flash操作期间CPU不能从Flash取指和执行因此擦写函数必须放在RAM里执行或者关闭中断并用代码复制到RAM的方式运行。标准库的FLASH_ProgramHalfWord函数内部已经处理了这些问题但如果自己实现必须注意。我用的Flash写函数基于标准库核心逻辑如下void flash_write_buf(uint32_t addr, uint8_t *buf, uint32_t len) { uint32_t i; uint16_t tmp; FLASH_Unlock(); for (i 0; i len; i 2) { tmp buf[i] | (buf[i 1] 8); FLASH_ProgramHalfWord(addr i, tmp); // 等待操作完成标准库内部已处理忙等待 } FLASH_Lock(); }写入前一定要确保addr是2字节对齐的长度也最好是2的倍数否则最后一个字节会越界访问buf。YMODEM接收到的数据是连续的所以写入时可以直接把一帧数据丢进Flash不用额外拼包。擦除则用标准库的FLASH_ErasePage传入页起始地址即可。F103的页大小要查对应型号的手册确认中容量1KB大容量2KB。擦除一个页大约耗时20-40ms如果擦除整个App分区32KB在C8T6上需要擦32个页总共耗时大约1秒左右这个耗时足够让看门狗超时所以Bootloader里要么不开启看门狗要么在擦除期间喂狗。3.4 状态区读写与固件校验函数实现状态区读写是整个AB方案正确工作的基石。读比较简单直接把地址强制转换成结构体指针void read_ota_status(ota_status_t *st) { // 读取前先确保magic有效如果无效则返回默认值 ota_status_t *p (ota_status_t *)OTA_STATUS_ADDR; if (p-magic OTA_MAGIC) { *st *p; } else { memset(st, 0, sizeof(ota_status_t)); st-magic OTA_MAGIC; st-active_bank 0; // 默认启动App A } }写入则要先擦除再写因为Flash不能原位更新void write_ota_status(ota_status_t *st) { FLASH_Unlock(); FLASH_ErasePage(OTA_STATUS_ADDR); FLASH_ProgramHalfWord(OTA_STATUS_ADDR, st-magic 0xFFFF); FLASH_ProgramHalfWord(OTA_STATUS_ADDR 2, st-magic 16); // 依次写入其他字段 // ... FLASH_Lock(); }这里有个小坑结构体在内存里可能因为对齐产生padding直接memcpy结构体到Flash再读回来字段可能错位。我的做法是把结构体定义成__packed在Keil里用#pragma pack(1)在GCC里用__attribute__((packed))确保字段连续紧凑然后在写入时逐字段写入不要用整个结构体memcpy。固件校验我用的是CRC32算法参数采用标准ZIP格式多项式0x04C11DB7初值0xFFFFFFFF输出异或0xFFFFFFFF。App在编译完成后用上位机或脚本计算整个bin文件的CRC32并把这个值附加到固件末尾或者写在状态区里。Bootloader在跳转前对整个分区逐字节计算CRC32如果结果和状态区保存的一致说明固件完整可用。CRC32覆盖整个分区包括所有代码和数据但要注意把分区里末尾的0xFF填充字节也纳入计算。换句话说App侧编译出来的bin可能只有27KB但分区有32KB计算CRC时要把这32KB全部算进去否则Bootloader验证时会失败。最简单的做法是App构建后固件填充工具把bin补齐到分区大小再计算CRC。3.5 Boot跳转App向量表、栈指针、以及那个经典的SysTick坑Bootloader跳转App是整个流程里最“惊险”的一步。跳转代码不多但每个细节都是坑。完整实现如下typedef void (*app_reset_handler_t)(void); void jump_to_app(uint8_t bank) { uint32_t app_addr (bank 0) ? APP_A_ADDR : APP_B_ADDR; uint32_t app_sp *(volatile uint32_t *)app_addr; app_reset_handler_t app_reset (app_reset_handler_t)(*(volatile uint32_t *)(app_addr 4)); // 检查栈指针是否在RAM范围内防止跳转到非法地址 if ((app_sp 0xFFF00000) ! 0x20000000) { // 栈指针异常不能跳转 return; } // 关闭全局中断 __disable_irq(); // 复位SysTick防止App初始化时的定时器状态异常 SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; // 关闭所有已启用的外设时钟这里可以用RCC_DeInit() RCC_DeInit(); // 将中断向量表重定向到App的起始地址 SCB-VTOR app_addr; // 设置主栈指针并跳转 __set_MSP(app_sp); app_reset(); }这里有几个不得不提的细节第一检查栈指针是否落在RAM区。0x20000000是F103 SRAM的起始地址如果App固件的首4字节不是合法的栈指针比如Flash被擦除后的0xFFFFFFFF跳转就会直接HardFault。这个检查能有效防止跳转到空分区。第二跳转前一定要关闭全局中断。App初始化时会重新配置所有外设和中断如果Bootloader的某个中断在跳转过程中触发而App还没来得及重定向向量表就会跑飞。虽然App的SystemInit和main开头会重设中断但跳转瞬间是真空期必须先关中断。第三SysTick必须复位。很多人的Bootloader本身用SysTick做延时跳转时SysTick计数器里还残留着Bootloader的值。App如果直接操作SysTick而不重新初始化第一次延时就会出现一个很长的空档表现就是App“卡死”一两秒才开始跑。第四也是最重要的App侧的启动文件里SystemInit函数会重设中断向量表到当前地址或者你在App的main函数里自己调用SCB-VTOR FLASH_BASE | APP_OFFSET二选一即可。如果App不重定位向量表所有中断串口、定时器、外部中断都会跳到Bootloader的向量表灾难性后果不用我多说。这部分在第四章专门讲App侧的适配。4. App侧适配链接脚本、向量表偏移与应用层升级逻辑App侧的改动看似没有Bootloader多但每一处都是必须的。少改一个整个OTA方案就废了。这里按步骤拆开讲。4.1 修改链接脚本给App指定运行地址以Keil MDK 标准库为例修改工程设置里Target选项卡的IROM1即可不需要手动改sct文件除非你的工程用了自定义分散加载文件。具体参数如下App AIROM1起始地址0x08003000大小0x8000C8T6或0x1C000RCT6。App BIROM1起始地址0x0800B000大小0x5000C8T6或0x20000RCT6。对应的IRAM1保持默认即可0x20000000大小0x5000对应20KBC8T6的实际SRAM是20KB。如果你用GCC工具链则修改链接脚本里的FLASH区域MEMORY { FLASH (rx) : ORIGIN 0x08003000, LENGTH 32K RAM (xrw) : ORIGIN 0x20000000, LENGTH 20K }链接脚本修改完成后编译生成的bin文件起始地址就是App A的偏移地址。注意App B的固件编译时要把ORIGIN改成0x0800B000重新编一份或者用同一个bin文件但偏移地址不同这里取决于你的编译流程。我一般维护两套工程的链接配置一套编App A一套编App B只是链接地址不同代码完全一样。4.2 中断向量表重映射SCB-VTOR与启动文件的关系不管哪种工具链App在运行前都必须把中断向量表指向自己的起始地址。标准库在system_stm32f10x.c里默认配置VTOR为0x08000000但如果你把App链接到0x08003000就必须覆盖这个默认值。最简单的做法是在SystemInit函数末尾强制重新设置VTORvoid SystemInit(void) { // ...原有代码... #ifdef VECT_TAB_OFFSET SCB-VTOR FLASH_BASE | VECT_TAB_OFFSET; #endif }在system_stm32f10x.c头部定义VECT_TAB_OFFSET的值为App的偏移地址比如App A就定义成0x3000。注意这里用的是十六进制不要写成十进制的12288容易看错。很多教程说要把VECT_TAB_SRAM宏打开这是把向量表放到RAM里属于特殊场景比如需要频繁修改向量表普通OTA场景不需要。直接用Flash里的向量表配合VTOR设置就行。4.3 App内触发OTA升级把接收逻辑放在Bootloader还是App这是一个很多人纠结的架构问题。我的建议非常明确Bootloader只负责“接收固件、写Flash、跳转”App负责“业务逻辑和触发条件判断”两者职责分离。具体执行流程是App运行中收到上位机的升级命令比如串口协议里的0xAA 0x55命令。App解析命令把版本号、固件长度等信息放入状态区设置pending_bank为另一个分区然后软复位NVIC_SystemReset。Bootloader启动后看到pending_bank不为0进入YMODEM接收模式。接收完成后Bootloader写入非活动分区校验CRC切换active_bank跳转新App。这样设计的好处是App不需要包含YMODEM解析和Flash擦写代码因此App的代码体积不会因为OTA功能膨胀太多而Bootloader在接收固件时也没有业务逻辑干扰出错概率更低。另外Bootloader可以独立于App升级和迭代将来Bootloader本身要升级只需在App里加一个类似“Bootloader升级模式”的命令即可。App侧触发升级的简化代码void enter_ota_mode(void) { ota_status_t st; read_ota_status(st); // 计算目标分区当前活动分区是A就切B是B就切A st.pending_bank (st.active_bank 0) ? 1 : 0; st.boot_count 0; write_ota_status(st); NVIC_SystemReset(); }4.4 编译固件与进行自校验给bin尾部附加CRC为了能在Bootloader里验证固件完整性App侧每次编译后都要计算整个bin文件的CRC32并把CRC值写进状态区。这个操作我一般放在Makefile或Keil的After Build步骤里用一个小工具自动完成。工具逻辑很简单import zlib import struct import sys def main(): bin_file sys.argv[1] with open(bin_file, rb) as f: data f.read() # 补齐到分区大小例如32KB padded data b\xFF * (0x8000 - len(data)) crc zlib.crc32(padded) 0xFFFFFFFF print(CRC32: 0x%08X % crc) # 可以把CRC写入一个文本文件后续由上位机写入状态区 if __name__ __main__: main()注意这个CRC必须用“补齐后的整个分区”计算不是原始bin文件。因为Bootloader校验时读的是整个分区包括填充字节。如果上下两边的计算范围不一致校验永远失败。5. 上位机传输与升级策略Tera Term裸传和Python工具开发阶段用Tera Term手工升级产品阶段用Python上位机自动化升级两条路线我都走过。这里把两种方式都说清楚。5.1 开发阶段Tera Term YMODEM快速验证调试Bootloader时用Tera Term是最快的不需要写一行上位机代码。步骤是打开Tera Term选择串口波特率115200。连接后先发送一个字符U让Bootloader进入接收模式。菜单栏选择File - Transfer - YMODEM - Send。选择App A或App B对应的bin文件点击打开。观察传输进度结束后查看串口输出日志。我在开发初期就是这么验证Bootloader的。第一次跑通YMODEM接收时的感觉是协议调试确实是OTA开发里最费时间的一环因为串口字节的时序问题、CRC不一致、块序号回绕这些Bug都要靠打印日志定位一次传输下来日志就有几百行。一个小技巧在Bootloader的每个关键节点加调试打印比如收到文件头、开始擦除、写入完成、CRC校验通过、准备跳转每一行都带上时间戳用SysTick计数这样就能精确定位卡在哪一步。Percepio Tracealyzer这类工具对F103来说过于重量级用不上。5.2 开发者工具用Python实现带进度条的上位机等Bootloader稳定后我写了一个基于Python的升级小工具用pyserial发送YMODEM。YMODEM协议本身并不复杂核心是发送文件头、数据块、处理ACK/NAK、收尾150行左右能搞定。这里给出关键函数import serial import struct import time import zlib CRC16_TABLE [] def crc16_ccitt(data): crc 0x0000 for byte in data: crc ^ byte 8 for _ in range(8): if crc 0x8000: crc ((crc 1) ^ 0x1021) 0xFFFF else: crc (crc 1) 0xFFFF return crc class YModemSender: def __init__(self, ser): self.ser ser self.seq 0 def send_packet(self, data, block_size128): # 构造YMODEM数据包 seq self.seq 0xFF header block_size 1024 and 0x02 or 0x01 packet bytes([header, seq, ~seq 0xFF]) if block_size 1024: packet data b\x00 * (1024 - len(data)) else: packet data b\x00 * (128 - len(data)) crc crc16_ccitt(packet[3:]) packet struct.pack(H, crc) self.ser.write(packet) self.seq 1 def send_file(self, filepath): # 发送文件名包 filename filepath.split(/)[-1].encode(utf-8) with open(filepath, rb) as f: filedata f.read() filesize len(filedata).to_bytes(8, big) header filename b\x00 filesize self.send_packet(header) # 等待ACK然后发送数据包 # 注意此时需要处理接收端的C/EOT状态 # 这里省略完整流程细节核心结构如上实际使用中我的工具还加了进度条显示、升级失败的自动重试、升级前后版本号校验。因为YMODEM本身有ACK/NAK机制传输过程还算可靠真正容易出问题的是上位机与Bootloader的握手时序——Bootloader必须在发出C字符前完成初始化否则上位机提前发送文件头会导致数据丢失。5.3 版本管理与灰度发布思路版本管理是OTA里最容易被忽视、最容易出乱子的环节。我在状态区里设计了fw_version字段上位机和Bootloader在升级前都会校验版本号。原则很简单新固件版本号必须大于当前活动分区的版本号否则拒绝升级。boot_count字段用于灰度发布时的回滚判断——如果某个版本的固件在多次启动后都无法稳定运行boot_count超过阈值Bootloader自动回滚到上一个版本。这套机制在真机上验证过确实能在新固件崩溃时自动恢复减少返修率。灰度发布的服务器策略我没有在F103上实现但如果你的设备有网络模块思路是服务器端维护一个发布比例设备启动时上报当前固件版本服务器决定是否下发新版本。AB分区让这个过程更从容——新版本先写到B分区跑一段时间没问题再切换为主要版本如果B分区崩了直接回滚A分区服务不中断。这种体验和手机系统的AB无缝升级是一个逻辑。6. 升级异常与回滚机制真正理解AB分区为什么能防砖AB分区能防砖不是因为它能让升级过程100%不发生错误而是因为它在错误发生时提供了恢复路径。这一章把回滚机制和掉电保护的具体实现拆开讲。6.1 断电保护和Flash擦写中断恢复设备在OTA过程中断电是最常见的异常场景。AB方案的处理方式是Bootloader启动后检查状态区发现上次升级没有正常完成比如pending_bank未清零就先去验证目标分区的CRC。如果目标分区CRC校验失败说明固件写入不完整Bootloader直接把pending_bank清零并跳转到当前活动分区也就是旧的、仍然完好的固件。整个过程中设备一直有一个能跑的固件用户感觉不到异常。有一种极端情况是掉电恰好发生在Bootloader正在擦除旧固件所在分区的时候。此时两个分区可能都不可用这种情况AB方案也无法完全避免但概率极低而且可以通过升级前对目标分区的CRC、长度进行预校验来减少。更进一步如果芯片支持双Bank比如F103的大容量型号还可以利用硬件双Bank启动的特性但这超出了F103标准库的范畴需要借助芯片特有的Flash控制器配置这里不展开。6.2 新固件启动失败后的自动回滚逻辑新固件写入成功、CRC也通过了但App跑起来就崩溃——这是另一个常见场景。AB方案通过boot_count字段解决。流程是Bootloader跳转到新固件前先把boot_count加1并写入状态区。App正常运行后在main函数早期调用一个接口将boot_count清零。如果App在启动初期崩溃boot_count就不会被清零。每次复位后Bootloader都发现boot_count递增当boot_count超过阈值比如3Bootloader认为新固件无法启动自动回滚到另一个分区。这个逻辑的精髓在于新固件是否“正常启动”不是靠人为判断而是靠App自己向状态区写“我活了”这个信号。App的main函数第一件事就是清boot_count这条代码必须在初始化外设之前执行因为如果初始化外设时崩溃boot_count就不会被清Bootloader才有机会介入回滚。6.3 固件加签验签不是必须但强烈建议如果产品是公开渠道销售的OTA固件有被篡改的风险。一个最简单的做法是编译完成后用私钥对bin文件签名Bootloader里内置公钥接收完整固件后先验签再写入Flash。签名算法用RSA2048或ECDSA计算量在72MHz的F103上大约需要几百毫秒到几秒完全可以接受。具体实现我推荐用mbedTLS现在叫Mbed TLS的RSA验签接口代码量不算大但能把“任意固件都能刷”升级成“只有我签过名的固件才能刷”。这个对工业设备尤为重要——总不能让人随便拿串口线连上去刷一个恶意固件。如果只是个人DIY、开发板练习跳过验签也问题不大但至少要保留CRC校验。7. 完整复现清单从零跑通这套方案的具体步骤其实到第六章为止这套AB OTA方案的原理和核心代码已经讲完了。但我知道很多人拿到代码还是不知道从哪里开始所以这里给出一份我实际跑通时的操作顺序按这个顺序走可以少踩很多坑。7.1 复现所需的软硬件清单硬件方面STM32F103C8T6最小系统板或者你自己的F103核心板注意确认Flash容量。USB转串口模块CH340或CP2102均可用于和电脑通信。如果板上没有板载串口芯片需要自行连接TXD/RXD/GND三根线。ST-Link V2或J-Link用于下载Bootloader和App程序。供电用USB线即可整个调试过程不需要外部电源。软件方面Keil MDK 5我用的版本是5.36配ARMCC 5.06编译器。STM32F103标准外设库V3.5。Tera Term串口终端支持YMODEM。Python 3加上pyserial用于后续自动化升级工具。7.2 从创建工程到首次跳转成功的详细步骤第一步先把Bootloader工程建起来。新建一个标准库工程选择STM32F103C8配置好串口1PA9/PA10和必要的GPIO。把第三章的代码整理进工程确保编译通过烧录进芯片。第二步在Keil里把Bootloader工程的IROM1起始地址设为0x08000000大小0x3000。这里要确认Bootloader的代码不能超过12KB否则会越界覆盖App A。第三步新建App A工程。复制Bootloader工程改名为App_AIROM1起始地址改为0x08003000大小设为0x8000。在system_stm32f10x.c里定义VECT_TAB_OFFSET为0x3000。写一个简单的串口回显程序编译后烧录到0x08003000地址。第四步直接用ST-Link把App A固件烧到0x08003000地址然后复位观察串口是否能收到App A的输出。如果程序卡死大概率是向量表偏移没配置好回到4.2节检查。第五步复位后按Bootloader流程先让芯片运行Bootloader然后用Tera Term发送字符U进入升级模式选择App B的bin文件编译时链接地址为0x0800B000。如果Bootloader能正确接收YMODEM并写入App B再复位后设备就应该运行App B了。第六步在App B里把串口输出改成App B Running再次跳转验证AB切换正确。第七步测试断电恢复。在接收YMODEM过程中直接断电重新上电后观察是否还能运行App A。如果一切正常说明回滚逻辑生效。7.3 环境搭建中的常见坑C8T6的64KB识别问题C8T6有一个知名的Flash容量识别问题很多C8T6芯片实际内部Flash是128KB但ST原厂出厂时只保证前64KB可用超出的部分虽然物理存在但不保证可靠性。有些盗版C8T6甚至直接用128KB型号打磨而成。如果你发现芯片Flash容量能读到128KB也不要贪图空间去用后64KB量产时可能出问题。另外一个坑是标准库的FLASH_GetFlashSize函数返回的是K字节单位C8T6正常返回64。如果返回128说明你的芯片可能是CB或者打磨片这时候分区表按C8T6规划仍然安全但不要试图用满128KB留出测试余量。8. 实坑速查表我从这套方案里踩过的每一个雷最后把我在F103 AB OTA开发中遇到过的、也是最容易复发的坑整理成一张速查表方便你对照排查。现象根因解决方案跳转到App后程序跑飞HardFault向量表未重映射在App的SystemInit或main开头设置SCB-VTOR为App起始地址跳转到App后串口输出乱码波特率不匹配或Bootloader和App的时钟配置不同确认两边系统时钟都为72MHz波特率一致YMODEM传输一直NAKCRC16算法和Tera Term的不一致检查CRC16初值、多项式、字节序用标准CCITTYMODEM传输到一半卡死接收过程中Flash擦写耗时太长串口缓冲区溢出优化串口中断接收用DMA或环形缓冲区App A升级到App B后App A被擦掉分区规划错误App A的固件大小超出了分区大小检查.map文件确认固件实际大小调整分区规划掉电后Bootloader无法恢复状态区在App分区中被擦除或状态区地址不在独立页上给状态区单独分配一页Flash与App分区完全隔离新固件启动崩溃但未回滚boot_count没在App里清零在App main函数开头调用clear_boot_count且先于外设初始化串口传输结束后App运行正常但重启后回到旧版本Bootloader每次启动都验证CRC失败确认CRC的计算范围是“补齐后的整个分区”不只是bin文件用ST-Link烧录App后芯片无反应App的IROM1地址设置错误确认App的链接地址和实际烧录地址一致下面挑几个最典型的坑详细说一下。YMODEM“一直NAK”这个坑90%的情况是CRC算法不一致。Tera Term的YMODEM实现是标准的CCITT多项式0x1021初值0x0000。很多网上抄来的代码初值是0xFFFF或者字节序高低反过来导致每次校验都失败。我的经验是先在PC上用Python把一帧数据的CRC16算出来和单片机的实现对比两边能对上再跑真机。这种离线对比比在嵌入式侧加打印快得多。“跳转后串口乱码”这个坑更阴险。Bootloader和App各自执行SystemInit如果两边的时钟配置一样不乱码但如果Bootloader里开启了PLL而App的SystemInit又初始化一遍PLL在跳转时部分外设寄存器状态残留可能导致App的串口波特率计算错误。解决方法是在Bootloader跳转前调用RCC_DeInit把外设时钟恢复默认再让App自己初始化。第四章的跳转代码里已经包含了这一步。分区地址算错的问题我见过最典型的一种情况是把C8T6的页大小当成1KB去擦除RCT6的Flash结果RCT6的页大小是2KB导致擦除地址错位。这种错误不会立即报错但会在升级几次后出现诡异的“A区内容损坏”问题。排查方式是打印每次擦除的起始地址确认和手册上的页边界一致。还有一个小坑容易被人忽略App工程里如果开了看门狗Bootloader跳转过去后看门狗还在跑。如果App初始化时间超过看门狗超时时间App会被反复复位。解决办法是跳转前关闭看门狗或者在App早期重新初始化看门狗并喂狗。9. 验证标准怎样算真正跑通了AB OTA按照下面清单逐项验证全过了才算真的跑通升级完整性验证从App A通过YMODEM升级到App B升级完成后设备运行新固件且版本号正确。回滚有效性验证故意让App B崩溃比如在App B里加一个死循环等待boot_count超过阈值观察设备是否自动回滚到App A。断电恢复验证在YMODEM传输过程中随机断电三次每次重新上电后设备都能正常启动到旧固件。边界容量验证编译一个接近分区上限的App升级到对应分区确认不越界擦除相邻区域。重复升级验证A到B、B到A、A到B循环升级50次每次升级完成后复位再升级确认没有累积性错误比如状态区脏数据、Flash磨损导致的问题。这套方案在F103上跑通之后你会对“嵌入式设备如何安全地自我更新”有一个完整的认知框架。后面换到ESP32、GD32、或者更复杂的带网络模块的MCU核心逻辑都是同一个一个可靠的引导程序加一个具备回滚能力的双备份存储布局。理解了这套东西你再去看任何一款芯片的OTA方案基本一眼就能看懂它的分区表和工作流程。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价