news 2026/9/11 15:59:41

STM32F103 AB分区OTA实战:设计原理、代码实现与踩坑记录

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F103 AB分区OTA实战:设计原理、代码实现与踩坑记录

STM32F103跑AB分区OTA,说实话第一次听到这个组合,我还愣了一下。F103是Cortex-M3的老古董了,Flash只有几十到两百多KB,一般大家做OTA都是单分区"下载完直接覆盖",用AB分区的确实少。但仔细一想,这个需求在工业设备、物联网节点、甚至一些量产消费产品里非常真实——升级到一半掉电,单分区方案直接变砖,返修成本高到想哭;而AB分区是"升级失败还能回滚",用户体验和售后压力完全是两回事。这篇文章我就把我从零复现STM32F103 AB OTA的整套思路、代码、踩坑记录全部摊开讲,适合已经有F103开发基础、想给自己的产品加可靠升级机制的朋友,也适合刚接触OTA、想搞明白AB分区到底是怎么一回事的入门者。

1. AB OTA的整体设计思路

1.1 为什么F103这种小资源芯片也要做AB分区

很多人的第一反应是:F103这么点Flash,分两个区放固件,不是浪费吗?没错,如果只做一个简单的远程升级,单分区确实省事:APP接收固件写入另一个临时区域,校验完擦掉自己、把新固件搬过来,再复位。但这里有个致命问题——如果"擦掉自己"之后、新固件还没写完,突然掉电,或者新固件本身有Bug启动不了,设备就永远停在半砖状态,只能开盖用仿真器救。

AB分区的本质是"永远有一份已知能跑的固件兜底"。系统里有A、B两个完全独立的APP区,Bootloader选择其中一个启动。升级时往不用的那个分区写,写完校验通过、标记一下,再重启切换过去。万一新的起不来,Bootloader发现启动失败,自动回滚到旧的。整个过程,可用的那个分区始终没有被破坏,这就是AB方案最核心的价值。

F103做这件事完全可行,关键在于Flash容量。我用的STM32F103RCT6,256KB Flash、48KB RAM,算是F103里比较舒服的配置。分完两个96KB的APP区,还剩48KB给Bootloader和参数区,够用。如果只有64KB的C8T6,两个APP区各分24KB,Bootloader 8KB,勉强也能做,只是功能受限,后面会详细讲。

1.2 升级流程里的三个角色

整个AB OTA系统至少包含三部分:Bootloader(引导程序)、APP(业务固件)、升级服务器/上位机(负责发固件)。

  • Bootloader:上电最先运行,检查启动标志、校验APP有效性、决定跳A还是跳B,同时也是唯一的Flash搬移执行者。
  • APP:业务逻辑,本身具备从服务器下载固件的能力(网口、Wi-Fi模块、4G模块都行),但APP不擦自己,它只把新固件写入另一个APP分区,然后设置标志、复位。
  • 升级服务器:提供固件文件,核心是一个增量/全量包 + 版本信息。全量包最简单,我用的就是全量包。

这里面有一条很容易踩的规则:APP不碰自己的分区,也无法直接跳转到另一个APP(Bootloader没被绕过)。所有切换动作,只能通过"写标志 + 复位",由Bootloader来完成。这样保证哪怕APP把自己跑崩了,复位后Bootloader依然能做出正确选择。

1.3 分区规划与状态标志

F103的Flash是0x08000000开始,按扇区组织。注意F103的扇区不是均匀的,前4个扇区每个16KB,后面才是64KB一个。我按这个特性做了如下规划:

区域地址范围大小用途
Bootloader0x08000000 - 0x08003FFF16KB引导程序,上电入口
APP_A0x08004000 - 0x0801BFFF96KB运行区A
APP_B0x0801C000 - 0x08033FFF96KB运行区B
参数区0x08034000 - 0x0803FFFF48KB启动标志、确认标志、升级计数

参数区我单独划出来,是因为F103擦Flash必须按扇区来,如果标志和APP挤在同一个扇区,擦写APP时会连标志一起擦掉,逻辑就乱了。参数区分两个8KB区域交替写,避免频繁擦写把Flash寿命耗尽——F103的Flash擦写寿命标称1万次,虽然实际远超,但设计上要养成好习惯。

Bootloader里维护一个4字节的启动控制结构体,存放在参数区固定地址上:

#define PARAM_BASE 0x08034000 typedef struct { uint32_t magic; // 0x5A5AA5A5 表示结构有效 uint32_t boot_target; // 下一次启动的目标分区:0=A,1=B uint32_t boot_tries; // 当前分区连续启动尝试次数 uint32_t confirmed; // 当前分区是否已被APP确认可用 } boot_param_t;

每次Bootloader启动时读这个结构体,根据里面的boot_triesconfirmed决定跳哪个区。逻辑上要解决的事情是:升级后第一次启动是"试验启动",APP跑起来后主动确认自己没问题,才把confirmed置1;如果一直没确认、复位了好几次,Bootloader就不能再往这个分区跳了,要回滚到另一个。

2. 硬件与环境准备

2.1 最小系统清单

虽然ST官方有完整的开发板,但真正做产品、自己画板的人用最小系统就够。我复现时用的是自己焊的板子,核心就这几样:

  • STM32F103RCT6主控,LQFP64封装
  • 8MHz无源晶振 + 两个20pF负载电容
  • 复位电路:10k电阻上拉 + 0.1uF电容到地
  • 3.3V电源,AMS1117-3.3或者MP2315都行,注意F103的VDDA引脚一定单独滤波
  • BOOT0/BOOT1跳线帽,调试下载用
  • 一个按键接PA0做APP里的"触发升级"按钮(方便演示)
  • 串口1(PA9/PA10)接USB转串口,用于日志输出

千万别省掉串口调试口。做OTA没有串口日志,出了问题就像蒙眼开车,光靠LED指示灯猜,效率低到怀疑人生。

2.2 开发环境与固件库

F103的开发环境,我推荐再老练一点的说法:不要一上来就用CubeMX + HAL库做Bootloader。不是说HAL不好,而是Bootloader代码量小、逻辑死板,用标准库或寄存器更直观,也更容易控制Flash操作的时序。等APP层再用HAL/CubeMX加速开发,两边各取所长。

我用的组合是:

  • Keil MDK 5.37(AC5,AC6也试过,F103上兼容性不如AC5稳定)
  • STM32F10x标准外设库V3.5(ST官方最后的经典版本,网上随处可下)
  • ST-Link V2下载器(便宜好用)
  • 串口助手 + Xshell(看日志)

标准库V3.5对F103来说堪称"老而弥坚",该有的外设驱动全都有,Flash编程用FLASH_ProgramWordFLASH_ErasePage,逻辑非常直白。不过要注意,标准库默认的FLASH_ProgramWord是每次写一个字,在Bootloader里用没问题;如果要追求速度可以改成整字写入,但Bootloader不追求速度,稳定第一。

2.3 Keil工程的三个关键点

Bootloader工程和普通APP工程有几个不同的地方:

第一,IROM1起始地址和大小必须设置成Bootloader自己的区域。Keil里Options for Target -> Target -> IROM1:Start=0x8000000,Size=0x4000。如果不改,链接器会把代码链接到0x08000000之后的连续地址,超出Bootloader区域,运行时就会和APP冲突。

第二,使用微库(Target一页勾选Use MicroLIB)。Bootloader里我用了printf打印日志,MicroLIB的printf体积小,不占太多Flash。

第三,启动文件不能丢。F103的startup_stm32f10x_hd.s放在工程里,它负责初始化堆栈、调用SystemInitmain。有些精简Bootloader会自己写启动代码,但F103这种成熟芯片完全没必要,直接用ST官方的启动文件最省心。

我这里补一句,Bootloader编译时不要用--split_section之类的优化裁剪选项,因为Bootloader需要精确控制代码位置,链接脚本越简单越不容易出诡异问题。默认工程配置就够了。

3. Bootloader的具体实现

3.1 上电主流程:三态判断

Bootloader的核心就是一个有限状态机。我把它简化成三个分支:

  1. 参数区解析失败或magic不对 -> 进入出厂模式:尝试校验A,A有效跳A,A无效跳B,B也无效就停在Bootloader,等待串口下载。
  2. 正常启动模式(confirmed == 1)-> 直接跳当前boot_target指定的分区。
  3. 试验启动模式(confirmed == 0boot_tries大于0)-> 说明上次升级完还没被确认,减少boot_tries,如果减到0则回滚到另一个分区,否则跳转。

main函数的主流程大概长这样:

int main(void) { // 初始化时钟、串口、Flash接口 SystemInit(); UART1_Init(115200); boot_param_t param; read_boot_param(&param); if (param.magic != BOOT_PARAM_MAGIC) { // 出厂状态:默认尝试A,回退B param.boot_target = 0; param.boot_tries = 0; param.confirmed = 1; save_boot_param(&param); } if (param.confirmed == 0) { // 试验启动,次数减一 if (param.boot_tries > 0) { param.boot_tries--; save_boot_param(&param); } if (param.boot_tries == 0) { // 试验启动耗尽,回滚到另一个分区 param.boot_target = 1 - param.boot_target; param.confirmed = 1; param.boot_tries = MAX_BOOT_TRIES; save_boot_param(&param); } } // 根据 boot_target 校验并跳转 uint32_t app_addr = (param.boot_target == 0) ? APP_A_ADDR : APP_B_ADDR; if (check_app_valid(app_addr)) { printf("boot to %s\r\n", param.boot_target == 0 ? "A" : "B"); jump_to_app(app_addr); } else { // 当前分区无效,尝试另一个 uint32_t backup_addr = (param.boot_target == 0) ? APP_B_ADDR : APP_A_ADDR; printf("primary invalid, try backup\r\n"); if (check_app_valid(backup_addr)) { param.boot_target = 1 - param.boot_target; save_boot_param(&param); jump_to_app(backup_addr); } } // 两个分区都无效,停留在Bootloader,开串口等待恢复 printf("no valid app, stay in bootloader\r\n"); while(1); }

这里有个细节要注意:MAX_BOOT_TRIES我设的是3。也就是升级后,APP最多有3次"冷启动机会"去自我确认,超过3次还没确认,Bootloader就认为这个固件起不来,强制回滚。3次听起来少,但实际非常合理——正常固件启动后不到1秒就能跑完初始化、确认标志,如果连3次都撑不过去,那确实是有严重问题,没必要继续尝试。

3.2 固件包头与校验

Bootloader判断"一个分区里的固件是否有效",靠的是固件包头和CRC32。在讲我之前先强调:固件校验一定要在跳转前做,而且要做得足够可靠。很多人图省事只校验一个magic,结果Flash里其实是一段残缺数据,跳转进去就hardfault,这类事故我见过太多。

我在每个APP分区的起始地址放了48字节的包头,结构如下:

#define HEADER_SIZE 48 #define FW_MAGIC 0x46415731 // "FAW1" typedef struct { uint32_t magic; // FW_MAGIC uint32_t version; // 固件版本号,比如 0x00010002 表示 1.0.2 uint32_t length; // 固件有效代码长度(不含包头) uint32_t crc32; // 固件数据的CRC32 uint32_t chip_id; // 目标芯片ID,防止跨型号烧录 uint32_t reserved[5]; } fw_header_t;

Bootloader的check_app_valid函数,就是读取这个包头,检查magic、chip_id,然后对从app_addr + HEADER_SIZE开始、长度为length的数据做CRC32计算,与包头里的crc32比对。全部通过才允许跳转。

CRC32算法我用的是标准ZIP格式查表法,速度在F103上完全没问题。96KB的数据,用查表法算一遍,大约几十毫秒,Bootloader启动多这几十毫秒完全可接受。这里贴出Bootloader里的CRC32轮子代码:

static uint32_t crc32_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); } crc32_table[i] = c; } } uint32_t crc32_bytes(const uint8_t *buf, uint32_t len) { uint32_t crc = 0xFFFFFFFF; for (uint32_t i = 0; i < len; i++) { crc = (crc >> 8) ^ crc32_table[(crc ^ buf[i]) & 0xFF]; } return crc ^ 0xFFFFFFFF; }

生成包头、写包头这个动作,放在打包固件时由上位机完成,Bootloader只负责校验。顺便提一句,很多产品会省略chip_id,但Debug时一旦拿错MCU的固件刷进去,跳转后外设初始化全乱,排查起来特别痛苦。留着这个字段,Bootloader里几行代码就能挡住绝大多数误刷。

3.3 Flash擦写函数与跳转

Bootloader里最关键的两个操作,一个是读,一个是跳转。读是校验用的,跳转是执行用的。

跳转函数算是AB OTA的灵魂所在,网上各种"跳转跑飞"的帖子一大半都是因为这个函数写错了。正确的做法是:

typedef void (*p_function)(void); void jump_to_app(uint32_t app_addr) { uint32_t sp_value = *(volatile uint32_t *)app_addr; p_function app_entry = (p_function)(*(volatile uint32_t *)(app_addr + 4)); // 检查栈顶地址是否在RAM范围内 if ((sp_value & 0xFFF00000) != 0x20000000) { printf("invalid SP 0x%08X\r\n", sp_value); return; } __disable_irq(); // 把外设恢复到复位状态 RCC_DeInit(); SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; // 关掉所有中断 for (int i = 0; i < 8; i++) { NVIC->ICER[i] = 0xFFFFFFFF; NVIC->ICPR[i] = 0xFFFFFFFF; } __enable_irq(); // 把MSP指向APP的栈顶 __set_MSP(sp_value); // 跳转 app_entry(); }

这里有几个非常关键的点:

第一,跳转前要做RCC_DeInit()和中断清理。因为Bootloader初始化过串口、可能开了定时器,如果不清理,APP启动时会发现一串"来路不明"的中断挂在那,一旦触发就进HardFault。RCC_DeInit()把所有时钟复位到默认状态,是最省事的清场方式。

第二,检查栈顶地址是否在RAM范围。这个检查能拦住一批低级错误,比如Flash里根本没有有效数据,读出来的"栈顶"是0xFFFFFFFF或者别的乱值,硬跳过去必死。我见过好多案例就是跳转前没做这个检查,结果死在跳转函数里还查不出来的。

第三,跳转后APP要自己重新设置向量表。Bootloader跳转前不会也不能替APP设置VTOR,因为设置成哪个地址是APP自己的事。这就是接下来APP端要做的工作。

Flash擦写方面,Bootloader不直接参与OTA下载流程时其实用不到擦写功能(擦写发生在APP侧),但Bootloader作为兜底恢复工具,最好也保留一个通过串口接收固件并写入另一个分区的功能。我用标准库的Flash接口写了一段,注意F103写Flash之前必须先擦扇区,而且擦除期间CPU会暂停执行Flash上的代码——这会导致擦除时中断全部挂起,所以擦除时一定不能有中断依赖逻辑在跑。

void flash_write_buffer(uint32_t addr, uint8_t *buf, uint32_t len) { // 擦除目标扇区(F103前4个扇区16KB,后面每个64KB) FLASH_Unlock(); for (uint32_t i = 0; i < len; i += 4) { if (FLASH_ProgramWord(addr + i, *(uint32_t *)(buf + i)) != FLASH_COMPLETE) { printf("flash write failed at 0x%08X\r\n", addr + i); break; } } FLASH_Lock(); }

扇区地址的计算要留意:F103的扇区起始地址不是均匀分布的,前4个扇区是16KB间隔,到第4个扇区之后变成64KB间隔。我写了一个小的扇区映射函数,避免擦错地方:

uint32_t flash_get_sector_addr(uint32_t addr) { // 0x08000000 + 前4个扇区16KB + 后面64KB扇区 if (addr < 0x08010000) { return addr & ~0x3FFFu; // 16KB对齐 } return addr & ~0xFFFFu; // 64KB对齐 }

注意,这个只适用于中等容量F103(64KB Flash是类似划分)和高密度(256KB)的前半段,如果你的芯片是F103C8T6这种64KB的,只有前4个16KB扇区,逻辑简单很多。

3.4 参数区的写入与磨损均衡

参数区如果设计得太简单,比如每次启动都往同一个地址写,几天就写穿了。我做了一个最简单的双备份+Ping-Pong机制:

  • 参数区起始地址0x08034000分成两块:0x080340000x08038000(注意0x08034000之后第三个扇区刚好是64KB边界,但参数区整体在最后48KB,跨了两个64KB扇区,所以我把参数区设计成两个独立64KB扇区,只使用每个扇区的前256字节)。
  • 写入时先写块A,写完把magic写好;下次再写就写块B,交替使用。
  • 读的时候先看A的magic对不对,不对就看B,两个都不对就返回空。

这样写寿命翻倍,虽然算不上真正意义的磨损均衡,但配合"只在启动时写一次、APP确认时写一次"的频率,足够了。这个设计很简单,但确实是我在项目里踩过Flash写穿坑之后学乖的。

4. APP端适配

4.1 APP编译链接与起始地址

APP要把自己链接到0x080040000x0801C000,这一步做错,Bootloader跳过去必死。Keil里设置APP工程:

  • Options for Target -> Target -> IROM1:Start=0x8004000,Size=0x18000(对应96KB的APP_A)
  • 如果做APP_B的工程,就设置Start=0x801C000

同时,把IRAM1保持默认0x20000000,大小按芯片实际RAM设,RCT6是48KB。

要强调的一点是:A和B两个APP在编译时其实是一样的,除非你要在两个分区里区分版本号。也就是说,你可以用同一个工程,编译出两份,分别刷到A和B,只是烧录地址不同。但我们做AB OTA时,老固件在A,新固件下载到B,所以新固件的编译地址要匹配Bootloader选中的那个空闲分区。我这边的做法是:APP代码里通过宏APP_SLOT_AAPP_SLOT_B控制链接地址,编译时传不同的宏,生成两个固件。

4.2 中断向量表重定位

这是新手最容易翻车的地方。F103的默认向量表在0x08000000,也就是Bootloader的位置。APP跳转过来后,如果不在启动代码里把向量表搬到自己的地址,任何中断一来,CPU去0x08000000查向量表,拿到的就是Bootloader的中断处理函数地址,后果不堪设想(通常是进HardFault或者直接卡死)。

对F103来说,有两种做法:

一种是在SystemInit()后面、进main前,调用SCB->VTOR = APP_A_ADDR;,把向量表指向APP所在地址。用标准库时可以在main()第一行就干这事:

int main(void) { SCB->VTOR = APP_ADDR; // 关键,把这行放在外设初始化之前 SystemInit(); // ... 其余初始化 }

另一种是修改启动文件startup_stm32f10x_hd.s里的复位向量处理,在__main之前设置VTOR。我推荐在main里做,简单直白,逻辑外露。

注意,VTOR是在Cortex-M3上才有的寄存器,F103支持得很好。如果你用的芯片是更老的M0(比如F0系列),它没有VTOR,就得用"复制向量表到RAM+改起始地址"的骚操作,那是另一个话题。

4.3 APP里的OTA升级逻辑

APP这边主动发起升级,通常长这样:

  1. 通过HTTP从服务器下载新固件到接收缓冲区(用SD卡或者外部Flash做中转更好,F103 RAM不够直接接收大固件)。
  2. 把下载来的固件(带包头)写入"另一个"APP分区。怎么知道哪个是"另一个"?要么在编译时写死,要么从Bootloader的参数区读。我推荐从参数区读,这样固件可以同时支持A、B两个位置。
  3. 写入完成后,对数据重新算一遍CRC32,与包头里的CRC32比对。不一致说明传输或写入有问题,直接放弃,绝不能置标志位
  4. 校验通过后,写Bootloader参数:boot_target指向新分区,confirmed清零(表示待确认),boot_tries = 3
  5. 执行NVIC_SystemReset()复位。

APP侧的Flash写入代码和Bootloader的类似,但APP是在运行状态下写Flash,写的是另一个分区,不会影响自己当前运行的代码。不过要注意,如果APP自己也在Flash里执行,擦除Flash期间CPU取指会暂停,可能导致看门狗超时。所以擦写另一个分区时,要么关了看门狗,要么把擦写函数放到RAM里执行。

把函数放到RAM执行的写法不复杂,Keil里用__ramfunc修饰函数即可:

__ramfunc void flash_write_slot(uint32_t dst, uint8_t *src, uint32_t len) { // 在RAM里执行擦写代码 }

这个坑我是真实踩过的:第一次做AB OTA时,APP一边跑一边擦除B分区,擦除的那一瞬间IWDG触发复位,导致升级永远失败,查了半天才发现是Flash擦除期间代码取指暂停太久的锅。

APP在下载固件时还有一个规划问题:整包96KB固件不可能一次性塞进F103的48KB RAM。我的做法是分包接收,每包1KB,收到一包就写一包Flash。Flash写入的最小单位是16位半字,1KB对齐完全没问题。传输协议用HTTP分块或者自定义分帧都行,只要保证数据完整性和顺序即可。

4.4 升级确认机制

新固件启动后,如果不主动"确认",Bootloader会把这次启动当作"试验启动",最多3次机会。所以APP必须在初始化完成后、确认自己一切正常时,调用一个"上报/确认"函数,把参数区的confirmed置1。

确认的时机很重要:不是main函数一进来就确认,而要等关键外设都初始化完、传感器自检通过、业务逻辑能正常跑起来之后再确认。否则,假设你因为某个外设初始化失败导致系统跑飞,复位后Bootloader还傻傻地认为新固件已经确认成功,就不会回滚了。

我一般把确认动作放在"已经成功进入主循环、并且完成第一轮业务协程调度"之后:

void app_init_done(void) { boot_param_t param; read_boot_param(&param); if (param.confirmed == 0) { param.confirmed = 1; param.boot_tries = 0; save_boot_param(&param); } }

这个函数在初始化流程尾部调用。这样,即使新固件跑起来后没过几秒才崩,也已经算"确认完成",Bootloader不会再回滚。是否要更严格(比如运行5分钟无异常才确认),取决于你的产品对稳定性的要求。工业场景建议加一个看门狗 + 延迟确认,比如运行120秒无重启再确认。

5. 上位机打包与服务器部署

5.1 固件打包工具

Bootloader要求APP分区起始地址放一个48字节的包头,这个包头不可能靠编译器自动生成,需要一个小的打包脚本在编译完成后处理*.bin文件。我用Python写了一个pack_fw.py,流程如下:

  1. 读入app.bin,计算长度、CRC32。
  2. 生成48字节包头,填入magic、version、length、crc32、chip_id等。
  3. 将包头和固件数据拼接,输出app_v1.0.2.bin,这个文件就是最终要烧录到另一个分区的固件包。
import struct import zlib def pack_fw(src_bin, out_bin, version, chip_id): with open(src_bin, 'rb') as f: data = f.read() length = len(data) crc32 = zlib.crc32(data) & 0xFFFFFFFF magic = 0x46415731 # FW_MAGIC header = struct.pack( '<5I4I', magic, version, length, crc32, chip_id, 0, 0, 0, 0 # reserved[0..3] ) # 补足48字节 header = header.ljust(48, b'\x00') with open(out_bin, 'wb') as f: f.write(header) f.write(data) print(f"pack ok: {out_bin}, len={length}, crc={crc32:08X}")

version我建议用(major << 16) | (minor << 8) | patch的整型编码,简单且便于比较。比如0x00010002表示1.0.2。这样Bootloader里如果要判断"新固件版本必须高于当前版本",直接比较整型就行。

注意,Python的zlib.crc32算出来的是标准CRC32,和我在Bootloader里用查表法实现的CRC32(ZIP多项式)完全一致。这里有一个常见的坑:CRC实现有几十种变体,参数稍微不一样(初始值、反射、异或输出)结果就完全不同,打包脚本和Bootloader必须用同一套参数。我在Bootloader里专门写了单元测试,用一组已知字节和标准CRC结果比对,确保两边一致。

5.2 用nginx做简易升级文件服务器

APP的固件下载功能,最简单的方式就是HTTP GET拉取一个文件。调试阶段完全不用写复杂的服务器代码,用nginx做一个静态文件服务器就够了。

nginx配置里加一个location,把固件目录暴露出来:

server { listen 8080; server_name _; root /var/www/ota; autoindex on; location /fw/ { alias /var/www/ota/fw/; default_type application/octet-stream; } }

然后在/var/www/ota/fw/目录下放打包好的固件文件,命名尽量带版本号,比如app_v1.0.2.bin。APP请求的URL就是http://服务器IP:8080/fw/app_v1.0.2.bin

这里有个生产环境要注意的点:不能拿文件大小当版本判断依据。一定要在请求URL或响应里带版本信息。最简单的做法是,服务器上放一个latest.json

{ "version": 262146, "url": "/fw/app_v1.0.2.bin", "size": 98288, "crc32": "DEADBEEF" }

APP先请求latest.json,解析出最新版本号,和自己当前版本比较,需要升级再去拉url指定的文件。这样版本的维护方统一在服务器,APP只需要一个解析JSON的小库(F103 RAM小,建议手动撸一个极简JSON解析器,只提取自己关心的几个字段)。

nginx做静态服务器的好处是零业务代码、性能稳定、支持断点续传(HTTP Range)和ETag缓存,这些都免费获得。如果不想搭服务器,局域网调试阶段也可以用Python的http.server临时顶一下,但正式产品里还是nginx更靠谱。

5.3 一次完整的AB升级实验

讲完各部分实现,我把整个升级链路串一遍,方便你照着做实验:

  1. 先用烧录器把Bootloader烧到0x08000000,把旧固件包(带包头)烧到APP_A。
  2. 上电,Bootloader校验A区有效,跳转到A,APP正常运行,打印slot A version 1.0.1
  3. 我改了代码里一个功能,把version改成1.0.2,编译、用打包工具生成新固件包,放到nginx目录。
  4. APP侧按下PA0按键,触发升级流程:请求latest.json,发现版本有新,于是下载新固件包,按每包1KB写入APP_B。
  5. 全部写完,APP计算CRC32,比对通过,写Bootloader参数:boot_target=1confirmed=0boot_tries=3,然后复位。
  6. Bootloader重启,读到boot_target=1confirmed=0,进入试验启动逻辑,boot_tries-1=2,跳转到B区。
  7. APP_B运行,打印slot B version 1.0.2,初始化完调用app_init_done(),把confirmed置1。
  8. 升级完成。如果第7步故意不确认(比如我在B区代码里加了死循环),那么复位3次之后,Bootloader会把目标切回A,实现回滚。

整个流程跑通,AB OTA的核心机制就完全掌握了。一套下来大约半天时间,建议你也亲手走一遍。

6. 常见问题与排查思路

我把复现过程中最容易遇到的问题整理成一张速查表,基本覆盖99%的翻车场景。

问题现象根因解决方案
跳转后HardFault复位后LED不闪、串口无日志跳转前没清RCC/中断;或SP检查没做跳转函数里加RCC_DeInit、NVIC清理,检查SP地址
中断不生效APP能跑主循环,但按键中断没反应VTOR没设置或设置地址错误main最前面加SCB->VTOR = APP_ADDR,确认宏地址正确
CRC校验失败升级时提示校验失败、不置标志打包脚本和Bootloader的CRC32参数不一致统一使用标准ZIP CRC32,用已知数据测试两端算法
Flash擦除导致看门狗复位擦写另一个分区时系统重启擦除期间CPU暂停取指,IWDG超时把擦写函数放RAM执行或临时喂狗
升级后反复重启计数3次后回滚到旧版本APP没调用确认函数或确认时机太晚初始化完成且自检通过后尽早确认,必要时延迟确认
两个分区都无效Bootloader卡在串口等待烧录时没带包头或烧录地址错用打包工具生成带包头固件,烧录地址核对分区表
串口打印乱码Bootloader日志正常,APP日志乱码两边波特率或时钟配置不一致检查系统时钟配置,确认SYSCLK=72MHz,波特率一致

6.1 跳转后跑飞的正确排查顺序

很多新手遇到"跳转后跑飞"就开始怀疑Bootloader代码,其实90%是APP侧的问题。我总结一个排查顺序:

第一步,确认APP能单独运行。把APP烧到自己的分区地址,然后用调试器直接从0x08004000运行,看能不能跑起来。如果单独运行就死,说明APP工程本身有问题,别怪Bootloader。

第二步,确认Bootloader校验通过、确实执行了跳转。在跳转前打印一条日志,比如jump to 0x08004000,看串口有没有这条。没有就是校验没过,有但卡死,问题多半在清外设/向量表。

第三步,确认APP的VTOR设置。单独运行APP时,F103默认向量表在0x08000000,因为调试器可能加载的是整个芯片的烧录算法,掩盖了问题。但在真实跳转场景,没有VTOR设置就是死路一条。所以单独运行能起不代表跳转后能起,别被这个假象迷惑。

第四步,用调试器配合仿真看现场。在跳转函数里设断点,查看sp_valueapp_entry的值是否正确。app_entry一般是某个地址,反汇编检查是不是Reset_Handler的入口。

6.2 容量规划和扩展思路

如果你用的是C8T6(64KB Flash),分区就得精打细算:

区域地址范围大小说明
Bootloader0x08000000 - 0x08001FFF8KB尽量精简,去掉不必要的库函数
APP_A0x08002000 - 0x08007FFF24KB编译选项开-Os压缩代码
APP_B0x08008000 - 0x0800DFFF24KB同上
参数区0x0800E000 - 0x0800FFFF8KB双备份标志

C8T6这样的容量,APP功能得控制得非常克制,否则24KB根本不够塞。实际产品里如果Flash吃紧,建议上外部SPI Flash存固件包,或者改用压缩固件(比如用LZMA压缩,Bootloader解压),但这些都是进阶话题,F103本身算力一般,压缩解压要考虑时间成本。

后续如果要扩展,可以考虑这几个方向:

  1. 在Bootloader里集成串口/YModem升级通道,作为设备变砖后的兜底恢复手段。
  2. 加入固件签名验证(比如HMAC-SHA256),防止恶意固件被刷入,对在公网上的IoT设备这步不能省。
  3. 把OTA协议从HTTP升级到HTTPS/MQTT,或者加服务器端设备认证,保证升级链路安全。
  4. 引入增量升级,只传输差异部分,节省流量和Flash写入时间。

我自己做完这套AB OTA后最大的体会是:分区规划决定架构质感,参数区位置、Bootloader大小、APP地址、确认机制,这些在最开始花一小时想清楚,后面能少熬三个通宵。做OTA千万不要等产品快量产了才想起来加,那时候Flash布局早就定死了,硬塞进去只会处处掣肘。趁项目早期把AB机制铺好,后面每次迭代升级都有退路,心里踏实得多。

最后分享一个小习惯:每次做OTA实验前,先把当前能跑的双分区固件完整备份到一个known_good文件夹。升级测试翻车时,烧回去只要10秒,能省下大量"救砖"的时间。这个习惯我用了好几年,从来没因为OTA实验把开发板彻底搞废过。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 15:59:27

Codex工作流实战:构建可审计、可降耗、可兜底的本地AI编码系统

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 15:58:52

降AI率工具怎么选?Passbug理工科表现最亮眼

passbug官网直达入口&#xff1a;https://passbug.cn/ 高校对论文AI生成内容的检测日趋严格&#xff0c;降AI率从可选操作变成了必经环节。市面上的工具虽多&#xff0c;真正能打的却少&#xff0c;多数要么效果差、要么操作繁琐。这篇盘点围绕降AI率这一核心需求&#xff0c;…

作者头像 李华
网站建设 2026/9/11 15:58:50

JetPack 5.1.2下Orin开发环境深度部署指南

1. 项目概述&#xff1a;为什么在Orin上部署开发环境不是“装个系统”那么简单Jetson Orin系列——无论是Orin Nano、Orin NX还是AGX Orin——早已不是实验室里的玩具&#xff0c;而是工业质检、边缘AI推理、机器人实时导航、车载视觉感知等真实产线场景的主力计算平台。但很多…

作者头像 李华
网站建设 2026/9/11 15:58:42

华为官网前端实战:响应式架构与性能优化深度解析

简介&#xff1a;这是一份面向前端初学者的华为官网仿写实战项目&#xff0c;聚焦HTML5结构搭建、原生CSS响应式布局与JavaScript交互功能实现&#xff0c;帮助学习者系统掌握网页开发全流程核心技能。资源包共200个文件&#xff0c;包含4个HTML页面骨架、4个JS交互脚本、29个C…

作者头像 李华