
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里的RDPReadout 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-0x08008FFF12KB作隔离带这个布局不是拍脑袋定的。关键决策点在于为什么Metadata要单独占一个扇区为什么A/B区之间要留12KB空隙先说Metadata区。很多人图省事把标志位如active_flag A直接存在A区开头。但问题来了当新固件正在写入B区时如果突然断电B区数据不完整此时若Metadata还和A区绑在一起Bootloader读取时可能因A区扇区擦除未完成而读到乱码导致误判启动区。我实测过将Metadata放在独立扇区后即使B区写到一半断电Bootloader仍能准确识别A区完整强制回退——因为Metadata扇区本身是原子操作要么全擦成功要么全擦失败不会出现“半擦”状态。再看那12KB的隔离带。这是对抗Flash擦除粒度的无奈之举。F103的Sector 2210x08006000-0x08007FFF大小是2KBSector 2220x08008000-0x08009FFF也是2KB。如果A区结束于0x08008FFFB区起始于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防止读取FlashUSER选项中禁用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的USART1PA9/PA10理论波特率最高可达4.5Mbps但实际在工业现场9600bps才是稳定底线。按9600bps计算传输28KB固件需要约24秒——这期间任何噪声干扰、线缆松动、PC端程序崩溃都会导致升级失败。我见过太多项目OTA功能开发完成一到客户现场就频繁失败根源不在代码而在协议层的设计哲学。市面上常见的做法是“裸发Bin文件简单校验”比如每包512字节发完一包回ACK超时重传。这在实验室OK但在电磁干扰强的产线ACK信号可能被淹没发送端无限重试直到超时最终整包重发——24秒的传输窗口里三次重传就耗尽时间板子直接挂起。我的解决方案是借鉴HTTP/2的流控思想设计了一个三层握手协议命名为STM32-OTA-Stream。它不追求速度而追求“可中断、可恢复、可验证”第一层会话建立Session HandshakePC端先发0x55 0xAA 0x01 [VERSION] [CRC8]Bootloader回复0x55 0xAA 0x02 [STATUS] [CRC8]。STATUS包含当前A/B区状态、剩余空间、支持的最大包长。这一步确认双方在线且协议兼容耗时100ms。第二层分片协商Chunk NegotiationPC端根据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片。第三层数据块传输Data Chunk每片格式[HEADER:4B][PAYLOAD:1024B][TRAILER:4B]。HEADER含chunk_index、total_chunks、payload_lenTRAILER是该片Payload的CRC32。Bootloader收到后不立即写Flash而是先存入RAM缓冲区我用1.5KB SRAM做双缓冲校验CRC32无误再写入B区对应扇区。写入前先擦除目标扇区——这是最耗时的操作约40ms但因在RAM校验完成后再执行避免了“边写边校验”的风险。这个设计带来三个实战优势断电续传Metadata区记录last_success_chunk重启后Bootloader自动从该索引继续。我故意在第22片时拔电源恢复后精准续传无一字节错误。抗干扰每片独立校验单片错误不影响其他片。某次产线测试3号片因电机启停干扰出错Bootloader丢弃该片并请求重发其余27片完好。资源友好1024B包长是平衡点——太小则协议开销占比高每片4B HEADER4B TRAILER8B占比0.8%太大则RAM缓冲不足F103只有20KB SRAM需预留10KB给App运行。PC端工具我用Python写了一个轻量级Uploader核心逻辑是def upload_firmware(port, firmware_path): ser serial.Serial(port, 9600, timeout5) # 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, timeout2) if ack SUCCESS: break time.sleep(0.1) else: raise RuntimeError(fChunk {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 ModeLED慢闪串口输出ERR: META CORRUPT等待人工干预。Active分区可用性验证读取Active分区A或B的前16字节检查是否为有效ARM Thumb指令如0x00000000或0xFFFFFFFF表示空白扇区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 WatchdogApp启动后每5秒向Bootloader的共享内存区0x20004000写入一个递增计数器。Bootloader在每次启动时读取该计数器若值为0或停滞超过10秒判定App已死锁强制进入Safe Mode。OTA状态上报Status ReportingApp运行时通过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 CommanderST提供 OpenOCD开源流水线核心脚本build_and_test.ymlname: STM32F103 AB-OTA CI/CD on: push: branches: [main] paths: - src/** - CMakeLists.txt jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkoutv3 - 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-artifactv3 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 从“裸机砖块”变成“可远程治愈的智能终端”那种掌控感远胜于任何云服务的虚幻承诺。