☰
STM32F103 AB分区OTA升级方案详解与实战指南
2026/11/2 10:33:10 网站建设 项目流程

直接说结论:STM32F103做AB分区OTA,完全可行,而且没那么玄乎。

我最早接触OTA是在ESP32上,后面因为一个量产项目必须在国内用F103C8T6这种老片子,才被迫在Cortex-M3上啃下了完整的一套AB双备份升级方案。整个过程从懵到跑通中间踩了不少坑,后来把这套东西整理成了工程模板,陆续也有同行拿着它去改到G030、F401这些型号上。这篇文章就把我复现这套方案时的完整思路、代码结构、分区规划、跳转细节原原本本写出来,适合已经在用标准库写STM32、正准备接触Bootloader和OTA的开发者,也适合被单分区升级搞得头皮发麻、想换成AB方案的老手。

1. 整体方案设计:为什么非得在F103上搞AB分区

先说一个现实问题:STM32F103这片子放在2025年看,性能确实不够看,Cortex-M3内核、72MHz主频、片上Flash最多512KB(C8T6只有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双分区OTA
Flash占用只需要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留12KB,App A占32KB,App B占20KB,刚好塞下。如果你的App超过30KB,C8T6就有点挤了,我会建议直接换F103RCT6(256KB)或者F103ZET6(512KB),同样是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是64KB(0x08000000到0x0800FFFF),RCT6是256KB(0x08000000到0x0803FFFF),ZET6是512KB(0x08000000到0x0807FFFF)。分区之前先查手册确认你的具体型号容量,别凭印象。

2.1 C8T6与RCT6的分区参数计算过程

以我项目里用过的F103C8T6和F103RCT6为例,分别给出完整的分区计算过程。

C8T6(64KB Flash)的分区方案:

  • Bootloader:从0x08000000开始,大小12KB(0x3000字节),结束地址0x08002FFF。
  • App A:从0x08003000开始,大小32KB(0x8000字节),结束地址0x0800AFFF。
  • App B:从0x0800B000开始,大小20KB(0x5000字节),结束地址0x0800FFFF。
  • 状态区:0x0800F000到0x0800FFFF,1页(F103每页1KB)。
  • 校验:0x08003000 + 0x8000 = 0x0800B000,正好是App B的起始地址;0x0800B000 + 0x5000 = 0x08010000,溢出边界,但由于总大小64KB,0x0800FFFF是最后一个地址,所以刚好卡满。

RCT6(256KB Flash)的分区方案:

  • Bootloader:从0x08000000开始,大小16KB(0x4000字节),留出更大余量给未来Bootloader增加功能。
  • App A:从0x08004000开始,大小112KB(0x1C000字节)。
  • App B:从0x08020000开始,大小112KB(0x1C000字节)。
  • 状态区:0x0803F000到0x0803FFFF,1页。
  • 剩余空间: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; // 当前活动分区:0=App A,1=App B uint8_t pending_bank; // 待切换分区:0=无,1=切到A,2=切到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协议的帧格式如下:

  • SOH(0x01)表示128字节数据块
  • STX(0x02)表示1024字节数据块
  • 块序号(1字节,从0开始,序号到255后回绕)
  • 块序号取反(1字节)
  • 数据(128字节或1024字节)
  • CRC16高字节
  • CRC16低字节

接收端在准备就绪后先发送字符C(CRC模式),发送端收到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比较。两个都通过才向发送端回复ACK(0x06),否则回复NAK(0x15)请求重发。这套逻辑虽然简单,但一定要保证串口中断里不能做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 A:IROM1起始地址0x08003000,大小0x8000(C8T6)或0x1C000(RCT6)。
  • App B:IROM1起始地址0x0800B000,大小0x5000(C8T6)或0x20000(RCT6)。

对应的,IRAM1保持默认即可(0x20000000,大小0x5000对应20KB,C8T6的实际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函数末尾强制重新设置VTOR:

void 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负责“业务逻辑和触发条件判断”,两者职责分离。

具体执行流程是:

  1. App运行中收到上位机的升级命令(比如串口协议里的0xAA 0x55命令)。
  2. App解析命令,把版本号、固件长度等信息放入状态区,设置pending_bank为另一个分区,然后软复位(NVIC_SystemReset)。
  3. Bootloader启动后看到pending_bank不为0,进入YMODEM接收模式。
  4. 接收完成后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是最快的,不需要写一行上位机代码。步骤是:

  1. 打开Tera Term,选择串口,波特率115200。
  2. 连接后先发送一个字符U(让Bootloader进入接收模式)。
  3. 菜单栏选择File -> Transfer -> YMODEM -> Send。
  4. 选择App A或App B对应的bin文件,点击打开。
  5. 观察传输进度,结束后查看串口输出日志。

我在开发初期就是这么验证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_size=128): # 构造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字段解决。

流程是:

  1. Bootloader跳转到新固件前,先把boot_count加1并写入状态区。
  2. App正常运行后,在main函数早期调用一个接口将boot_count清零。
  3. 如果App在启动初期崩溃,boot_count就不会被清零。每次复位后Bootloader都发现boot_count递增,当boot_count超过阈值(比如3),Bootloader认为新固件无法启动,自动回滚到另一个分区。

这个逻辑的精髓在于:新固件是否“正常启动”不是靠人为判断,而是靠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,配置好串口1(PA9/PA10)和必要的GPIO。把第三章的代码整理进工程,确保编译通过,烧录进芯片。

第二步:在Keil里把Bootloader工程的IROM1起始地址设为0x08000000,大小0x3000。这里要确认Bootloader的代码不能超过12KB,否则会越界覆盖App A。

第三步:新建App A工程。复制Bootloader工程,改名为App_A,IROM1起始地址改为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初值、多项式、字节序,用标准CCITT
YMODEM传输到一半卡死接收过程中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方案,基本一眼就能看懂它的分区表和工作流程。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询