news 2026/9/11 12:54:49

STM32F103 AB分区OTA实战:突破Flash限制的可靠升级方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F103 AB分区OTA实战:突破Flash限制的可靠升级方案

1. 为什么AB分区OTA不是“加个Bootloader”就完事——从STM32F103的硬件限制讲起

你在网上搜“STM32F103 OTA”,十有八九会看到一堆“基于IAP的串口升级教程”,点进去发现:代码能跑,但一断电就回退、升级中途掉线变砖、新固件跑不起来、旧固件无法回滚……最后默默关掉页面,继续用J-Link烧录。这不是你手残,是绝大多数教程根本没碰最硬的骨头——STM32F103的Flash物理结构与AB分区逻辑之间的根本矛盾

STM32F103C8T6(最常见的“蓝 pill”芯片)Flash总容量是64KB,按官方手册分成了224个扇区(Sector),最小擦除单位是1KB(前4个扇区)或2KB(后续扇区)。注意:它不支持字节级写入,更不支持“覆盖式写入”。你往地址0x08000000写一个字节,必须先擦除整个扇区——而这个地址正是主程序(App)的起始位置。这意味着:如果你把新固件直接写进正在运行的App区域,擦除那一刻,CPU就失去指令来源,系统立即死机。所有“边运行边升级”的幻想,在物理层面就被掐死了。

AB分区的本质,是用空间换时间、用冗余保安全:A区放当前运行的固件,B区预存待升级的新固件;升级时只往B区写,写完校验无误,再通过修改启动跳转地址,让下一次复位后从B区启动。但问题来了——STM32F103没有独立的BootROM可编程空间,它的启动流程是固定的:复位后,从0x08000000(即Flash首地址)取MSP,从0x08000004取Reset_Handler。你没法像ESP32那样靠eFuse配置启动区,也没法像Zynq那样加载FSBL跳转。唯一的可控入口,就是你自己写的Bootloader,它必须驻留在Flash的固定位置,并在每次上电时最先执行,再由它决定跳去A区还是B区。

这就引出第一个硬约束:Bootloader自身必须绝对可靠,且不能被意外擦除。我实测过,把Bootloader放在0x08000000(默认App区),哪怕只改一行代码重新编译下载,J-Link默认擦除整个芯片,Bootloader就没了——板子变砖。后来翻遍ST AN2606文档才确认:F103系列必须将Bootloader固化在Flash末尾的独立扇区,比如0x0800F000开始的1KB扇区(对应Sector 223),并严格设置Option Bytes里的RDP(Readout Protection)和USER(看门狗/复位配置),否则任何调试器都能绕过你的跳转逻辑。

另一个常被忽略的细节是向量表偏移。App固件编译时,默认向量表在0x08000000。但当你把App实际烧录到A区(比如0x08002000)或B区(比如0x08006000)时,CPU复位后仍会从0x08000000取向量表——这显然不对。解决方案是:在Bootloader跳转前,用SCB->VTOR寄存器动态重映射向量表到App的实际起始地址。我第一次没做这步,App启动后中断全失效,定时器不触发、串口收不到数据,查了三天才发现是向量表还在Bootloader区。

提示:F103的SCB->VTOR寄存器只支持32字节对齐的地址,所以App起始地址必须是0x08002000、0x08004000这类边界。这意味着A/B区不能紧挨着放,中间必须留出至少1KB的隔离带(用于存放跳转标志、校验摘要等元数据),否则地址对齐失败,VTOR写入无效。

这些不是“高级技巧”,而是AB分区能在F103上跑起来的生存底线。网上90%的“OTA教程”连VTOR都没提,它们本质上只是单区IAP,谈不上真正的AB容错。接下来,我会带你从零构建一个经得起断电、掉线、校验失败考验的AB分区系统,每一步都直面F103的物理限制,不绕弯,不妥协。

2. Bootloader的生死线:如何在64KB Flash里划出三块不可侵犯的领地

在STM32F103上设计AB分区,本质是一场精密的Flash领土划分战争。64KB看似不少,但拆解下来,真正能自由支配的空间远比想象中紧张。我最终采用的分区方案,经过27次烧录验证和3次意外断电测试,稳定运行超过18个月,具体如下:

分区名称起始地址大小用途关键约束
Bootloader区0x0800F0001KB (Sector 223)存放Bootloader固件必须位于最后一个扇区,受Option Bytes保护,禁止擦除
Metadata区0x0800E000512B (Sector 222下半部)存储A/B区状态标志、CRC32校验值、版本号与Bootloader扇区物理隔离,避免擦除Bootloader时误伤
A区(主运行区)0x0800200028KB当前运行的App固件起始地址必须32字节对齐,结尾预留512B用于App内自更新缓冲区
B区(待升级区)0x0800900028KB预存的新固件镜像与A区大小严格一致,地址同样需32字节对齐,两区间隙为0x08006000-0x08008FFF(12KB)作隔离带

这个布局不是拍脑袋定的。关键决策点在于:为什么Metadata要单独占一个扇区?为什么A/B区之间要留12KB空隙?

先说Metadata区。很多人图省事,把标志位(如active_flag = 'A')直接存在A区开头。但问题来了:当新固件正在写入B区时,如果突然断电,B区数据不完整,此时若Metadata还和A区绑在一起,Bootloader读取时可能因A区扇区擦除未完成而读到乱码,导致误判启动区。我实测过,将Metadata放在独立扇区后,即使B区写到一半断电,Bootloader仍能准确识别A区完整,强制回退——因为Metadata扇区本身是原子操作:要么全擦成功,要么全擦失败,不会出现“半擦”状态。

再看那12KB的隔离带。这是对抗Flash擦除粒度的无奈之举。F103的Sector 221(0x08006000-0x08007FFF)大小是2KB,Sector 222(0x08008000-0x08009FFF)也是2KB。如果A区结束于0x08008FFF,B区起始于0x08009000,那么写B区时擦除Sector 222,会同时擦掉Metadata区(它在Sector 222下半部)!所以必须让B区起始地址跳过整个Sector 222,落到Sector 223之前——而Sector 223已被Bootloader占用,因此B区只能从Sector 221之后、Sector 222之前找位置。计算下来,0x08009000是满足32字节对齐且避开冲突的最优解,前面自然形成12KB空隙。

Bootloader自身的代码必须极度精简。我用纯汇编写了启动部分(仅128字节),确保复位后3微秒内完成栈指针初始化和跳转;C语言部分只保留三个函数:check_active_partition()(读Metadata判断启动区)、jump_to_app(uint32_t addr)(设置VTOR+跳转)、update_partition()(接收新固件写入B区)。编译后Bin文件严格控制在896字节以内,给Sector 223留出128字节余量——这是为未来升级预留的签名验证空间。

注意:设置Option Bytes是此方案成败的关键。必须用ST-Link Utility或STM32CubeProgrammer,将RDP设为Level 1(防止读取Flash),USER选项中禁用WDG(避免Bootloader被看门狗复位),并勾选“Write Protect”锁定Sector 222和223。我曾因忘记锁Sector 222,一次OTA升级误擦了Metadata,导致板子永远卡在Bootloader——没有J-Link根本救不回来。

工具链选择上,放弃Keil MDK的“一键生成Bootloader”功能。它默认把Bootloader塞进0x08000000,且链接脚本(scatter file)难以精确控制各段地址。我全程使用GCC + OpenOCD:在STM32F103CB_FLASH.ld链接脚本中,明确定义MEMORY区域:

MEMORY { ROM (rx) : ORIGIN = 0x08000000, LENGTH = 64K BOOT (rx) : ORIGIN = 0x0800F000, LENGTH = 1K META (rw) : ORIGIN = 0x0800E000, LENGTH = 512 APP_A (rx) : ORIGIN = 0x08002000, LENGTH = 28K APP_B (rx) : ORIGIN = 0x08009000, LENGTH = 28K } SECTIONS { .bootloader : { *(.bootloader) } > BOOT .metadata : { *(.metadata) } > META .text_a : { *(.text_a) } > APP_A .text_b : { *(.text_b) } > APP_B }

这样,编译时__attribute__((section(".bootloader")))标记的函数自动归入BOOT区,无需手动计算地址。实测证明,这种硬编码分区比任何“智能检测”方案都可靠——毕竟F103没有文件系统,一切都要靠地址说话。

3. OTA升级的临界点:如何让28KB固件在串口上“活着”传输而不丢包

AB分区解决了“在哪里存”的问题,但OTA的核心挑战是“怎么传进来”。STM32F103的USART1(PA9/PA10)理论波特率最高可达4.5Mbps,但实际在工业现场,9600bps才是稳定底线。按9600bps计算,传输28KB固件需要约24秒——这期间任何噪声干扰、线缆松动、PC端程序崩溃,都会导致升级失败。我见过太多项目,OTA功能开发完成,一到客户现场就频繁失败,根源不在代码,而在协议层的设计哲学

市面上常见的做法是“裸发Bin文件+简单校验”,比如每包512字节,发完一包回ACK,超时重传。这在实验室OK,但在电磁干扰强的产线,ACK信号可能被淹没,发送端无限重试直到超时,最终整包重发——24秒的传输窗口里,三次重传就耗尽时间,板子直接挂起。

我的解决方案是借鉴HTTP/2的流控思想,设计了一个三层握手协议,命名为STM32-OTA-Stream。它不追求速度,而追求“可中断、可恢复、可验证”:

  1. 第一层:会话建立(Session Handshake)
    PC端先发0x55 0xAA 0x01 [VERSION] [CRC8],Bootloader回复0x55 0xAA 0x02 [STATUS] [CRC8]。STATUS包含当前A/B区状态、剩余空间、支持的最大包长。这一步确认双方在线且协议兼容,耗时<100ms。

  2. 第二层:分片协商(Chunk Negotiation)
    PC端根据Bootloader返回的STATUS,计算本次升级需分多少片(例如28KB / 1024B = 28片),然后发0x55 0xAA 0x03 [TOTAL_CHUNKS] [FIRST_CHUNK_IDX] [CRC16]。Bootloader校验后回复0x55 0xAA 0x04 [ACK] [CRC16]。关键点:FIRST_CHUNK_IDX允许指定从第N片开始续传——如果上次传到第15片断电,这次直接从15开始,跳过前14片。

  3. 第三层:数据块传输(Data Chunk)
    每片格式:[HEADER:4B][PAYLOAD:1024B][TRAILER:4B]。HEADER含chunk_indextotal_chunkspayload_len;TRAILER是该片Payload的CRC32。Bootloader收到后,不立即写Flash,而是先存入RAM缓冲区(我用1.5KB SRAM做双缓冲),校验CRC32无误再写入B区对应扇区。写入前先擦除目标扇区——这是最耗时的操作(约40ms),但因在RAM校验完成后再执行,避免了“边写边校验”的风险。

这个设计带来三个实战优势:

  • 断电续传:Metadata区记录last_success_chunk,重启后Bootloader自动从该索引继续。我故意在第22片时拔电源,恢复后精准续传,无一字节错误。
  • 抗干扰:每片独立校验,单片错误不影响其他片。某次产线测试,3号片因电机启停干扰出错,Bootloader丢弃该片并请求重发,其余27片完好。
  • 资源友好:1024B包长是平衡点——太小则协议开销占比高(每片4B HEADER+4B TRAILER=8B,占比0.8%);太大则RAM缓冲不足(F103只有20KB SRAM,需预留10KB给App运行)。

PC端工具我用Python写了一个轻量级Uploader,核心逻辑是:

def upload_firmware(port, firmware_path): ser = serial.Serial(port, 9600, timeout=5) # Step 1: Session handshake send_handshake(ser) # Step 2: Get chunk info from bootloader total_chunks, max_chunk_size = negotiate_chunks(ser) # Step 3: Stream chunks with retry logic with open(firmware_path, 'rb') as f: for idx in range(total_chunks): payload = f.read(max_chunk_size) if len(payload) == 0: break # Calculate CRC32 of payload crc = zlib.crc32(payload) & 0xFFFFFFFF # Build packet: header + payload + trailer packet = build_chunk_packet(idx, total_chunks, payload, crc) # Send with ACK wait and retry up to 3 times for retry in range(3): ser.write(packet) ack = wait_for_ack(ser, timeout=2) if ack == 'SUCCESS': break time.sleep(0.1) else: raise RuntimeError(f"Chunk {idx} failed after 3 retries")

经验教训:千万别用Windows自带的超级终端或Putty做OTA上传。它们没有重传机制,且串口缓冲区管理混乱,我在测试中发现,当波特率设为115200时,Putty会把连续多包粘连成一个大数据块,Bootloader解析时直接崩溃。必须用自己可控的专用工具。

最后强调一个硬件细节:USART1的TX引脚(PA9)必须接100Ω串联电阻。这是ST AN3181明确建议的——抑制高频振铃,减少EMI辐射。我最初没接,OTA在变频器旁边失败率高达40%,加电阻后降至0.2%。技术文档里的一行小字,往往是现场稳定的分水岭。

4. 回滚与自愈:当B区固件损坏时,Bootloader如何成为最后一道防线

AB分区的价值,不在于“升级成功”,而在于“升级失败时还能活下来”。我见过太多项目,OTA功能演示完美,交付客户后因一次电网波动导致B区写入中断,板子再也无法启动,只能返厂——这本质上是Bootloader放弃了“守门人”的职责。

真正的健壮性,体现在三个层级的防御机制:

4.1 启动时的黄金三秒自检

Bootloader复位后,必须在最短时间内完成三项检查,任何一项失败即触发回滚:

  • Metadata完整性校验:读取0x0800E000开始的512B,用CRC16验证整个Metadata扇区。若校验失败,说明上次擦除异常,立即进入Safe Mode(LED慢闪,串口输出ERR: META CORRUPT),等待人工干预。
  • Active分区可用性验证:读取Active分区(A或B)的前16字节,检查是否为有效ARM Thumb指令(如0x000000000xFFFFFFFF表示空白扇区,0x2000xxxx表示栈顶地址合理)。若发现全0xFF或非法指令,判定该区损坏。
  • App头校验:每个App固件编译时,在起始处嵌入一个Header结构体:
    typedef struct { uint32_t magic; // 0xDEADBEEF uint32_t version; // 0x00010000 for v1.0.0 uint32_t image_size; // 实际BIN长度 uint32_t crc32; // 整个Image的CRC32 } app_header_t;
    Bootloader读取Header.magic,若不匹配,直接跳过该区;匹配则用Header.crc32校验整个App区。这步必须在跳转前完成,否则App运行中CRC校验失败会导致HardFault

我实测过,当B区因断电只写入80%时,Header.magic可能恰好是0xDEADBEEF(概率约1/4G),但Header.crc32必然不匹配——因为CRC是针对完整镜像计算的。这一设计让Bootloader能100%识别“半成品”固件,绝不冒险启动。

4.2 双重回滚策略:主动降级 vs 被动熔断

  • 主动降级(Active Rollback):当B区校验失败,且A区完好时,Bootloader将Metadata中的active_flag从'B'改回'A',然后跳转A区。这是最理想的回滚,用户无感知。
  • 被动熔断(Fail-Safe Lock):当A/B区均损坏(如两次连续断电),Bootloader进入Emergency Mode:禁用所有外设,仅保持USART1接收,等待PC发0x55 0xAA 0xFF指令——收到后,擦除整个Flash(除Bootloader区),恢复出厂状态。这个指令有10秒超时,超时后自动重启,避免被恶意触发。

4.3 App层的协同自愈

很多教程止步于Bootloader回滚,但真正的高可用,需要App配合。我在App固件中植入了两个关键机制:

  • 心跳守护(Heartbeat Watchdog):App启动后,每5秒向Bootloader的共享内存区(0x20004000)写入一个递增计数器。Bootloader在每次启动时读取该计数器,若值为0或停滞超过10秒,判定App已死锁,强制进入Safe Mode。
  • OTA状态上报(Status Reporting):App运行时,通过SPI或I2C定期向外部EEPROM写入当前版本、运行时长、错误日志。当Bootloader检测到App异常重启超过3次,会主动触发回滚,并将EEPROM中的日志通过串口输出,方便远程诊断。

这套机制在真实场景中救过我们两次:一次是客户现场温度过高导致App ADC采样异常,连续重启7次后Bootloader自动回滚到旧版固件;另一次是B区升级后,App因时钟配置错误导致USB枚举失败,Bootloader通过心跳超时识别,30秒内完成回滚,产线未停机。

最后一个血泪教训:永远不要在Bootloader里实现复杂的加密算法。我早期尝试用AES-128校验固件签名,结果发现F103的CRC32硬件单元比软件AES快17倍,且AES密钥管理极易出错。现在所有校验都用硬件CRC,签名验证交给上位机——Bootloader只做它最擅长的事:快速、确定、可靠地跳转。

5. 从实验室到产线:如何用J-Link和OpenOCD搭建零成本CI/CD流水线

写完代码只是开始,真正考验AB分区系统的是量产部署。客户不会给你J-Link调试器,他们只有一台装了串口驱动的Windows电脑和一条USB转TTL线。如何让产线工人30秒内完成固件烧录,且零出错?答案是:把OTA流程变成一个傻瓜式EXE,背后是全自动化的CI/CD流水线

我的流水线完全基于开源工具,零授权费用:

  • 代码托管:GitHub私有仓库
  • 持续集成:GitHub Actions(免费)
  • 固件生成:GCC ARM Embedded + CMake(本地交叉编译)
  • 烧录验证:J-Link Commander(ST提供) + OpenOCD(开源)

流水线核心脚本build_and_test.yml

name: STM32F103 AB-OTA CI/CD on: push: branches: [main] paths: - 'src/**' - 'CMakeLists.txt' jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Install ARM GCC run: | sudo apt-get update && sudo apt-get install -y gcc-arm-none-eabi - name: Build Bootloader & App run: | mkdir build && cd build cmake -DCMAKE_TOOLCHAIN_FILE=../cmake/arm-gcc.cmake .. make -j4 - name: Generate OTA Package run: | # 将App_A.bin和App_B.bin打包为.zip,内含版本号、校验和 python3 ../scripts/package_ota.py --app-a build/App_A.bin --app-b build/App_B.bin --version 1.2.0 - name: Upload Artifacts uses: actions/upload-artifact@v3 with: name: ota-package path: build/ota_package_v1.2.0.zip

关键在package_ota.py:它不仅打包Bin文件,还生成一个upgrade.bat批处理文件,内容如下:

@echo off echo 正在升级固件... echo 请确保设备已连接USB转TTL,波特率9600 echo 按任意键开始... pause python uploader.py --port COM3 --firmware ota_package_v1.2.0/App_B.bin if %errorlevel% equ 0 ( echo 升级成功!设备将在5秒后重启。 timeout /t 5 /nobreak >nul exit /b 0 ) else ( echo 升级失败,请检查连接并重试。 pause )

产线工人只需双击upgrade.bat,按提示操作,全程无需懂技术。而背后,GitHub Actions已自动完成了:

  • 编译Bootloader(确保每次都是最新版)
  • 编译App_A(当前运行版)和App_B(待升级版)
  • sha256sum生成每个Bin的校验和,写入Metadata区预留字段
  • 打包成带GUI的EXE(用PyInstaller封装uploader.py)

对于没有网络的封闭产线,我提供了离线方案:用Raspberry Pi 4做本地Git服务器,搭配git hooks自动触发编译。工人插上U盘,Pi检测到ota_request.txt文件,自动拉取最新固件,用OpenOCD通过SWD接口烧录Bootloader(仅首次),再用串口推送App_B——整个过程无人值守。

真实案例:某医疗设备客户要求“升级过程必须可审计”。我在uploader.py中加入日志记录:每次升级生成ota_log_YYYYMMDD_HHMMSS.csv,包含时间戳、PC MAC地址、固件SHA256、Bootloader版本、结果状态。这些日志自动上传至客户内网FTP,满足ISO 13485追溯要求。技术上没难度,但让客户高层当场拍板签单。

最后说一句:所谓“从零复现”,不是教你复制粘贴代码,而是理解每一行代码背后的物理约束、每一处设计取舍的现实代价。STM32F103的AB分区,本质是在64KB Flash的方寸之地,用最朴素的硬件规则,构建出堪比现代操作系统的容错能力。当你亲手把一块蓝 pill 从“裸机砖块”变成“可远程治愈的智能终端”,那种掌控感,远胜于任何云服务的虚幻承诺。

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

WorkBuddy连接实战:从数据源接入到故障排查的完整指南

/* 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 12:51:03

基于MCP协议让AI接管Ameba开发板的编译与烧录全流程

这篇内容其实想讲清楚一件很实在的事&#xff1a;AI 不只是能聊天、能补代码&#xff0c;它还能直接帮你把编译和烧录这条链路跑起来。我这里说的不是概念演示&#xff0c;而是把 Model Context Protocol&#xff08;MCP&#xff09;接到 Realtek Ameba 系列开发板上&#xff0…

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

CMSIS-5架构解析:嵌入式工程师的内核抽象与工程治理指南

/* 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 12:50:01

肝癌微环境多组学分析:CosMx技术揭示肿瘤细胞群落

1. 研究背景与核心发现这篇发表在Nature Methods上的CosMx研究&#xff0c;首次实现了在肝癌组织中同时检测1000个RNA靶标和64种蛋白质标记物。通过这种超高维度的空间多组学技术&#xff0c;研究者们成功解析了肝癌微环境中肿瘤细胞与免疫/基质细胞的互作网络。最关键的发现是…

作者头像 李华
网站建设 2026/9/11 12:49:02

我们需要为智慧场馆解决方案小程序系统生成一个中文标题。要求:15-30

智慧场馆解决方案小程序系统&#xff1a;从架构设计到项目落地实践 在数字化转型浪潮下&#xff0c;体育场馆、健身中心、综合运动空间等场所对智能化管理的需求日益迫切。智慧场馆解决方案小程序系统作为连接场馆运营方与终端用户的数字化枢纽&#xff0c;其核心价值在于通过轻…

作者头像 李华