
1. 为什么选AB双槽位而不是一个App区加一个备份区1.1 单分区升级的断电噩梦做嵌入式的老哥应该都有过这种经历产品已经量产了几千台现场说软件要加需求于是拿上电脑、下载器、螺丝刀跑到客户现场拆壳、焊线、烧录、装机。运气好半小时搞定运气差遇到紧凑型外壳光是拆机就够喝一壶。这也是我最早接触OTA的动力先能把固件通过串口传下去哪怕慢一点至少不用拆壳。第一版方案我图省事做的是单App区备份区Flash里放一个Bootloader、一个App运行区、一个备份区。升级时先校验备份区再搬到App区。这方案看起来没毛病直到有一次我在copy过程中不小心拔了串口线。结果就是App区擦了一半备份区又是旧版本设备当场变砖。好在还能进Bootloader重新刷但如果Bootloader也恰好被波及就只能拆壳上烧录器了。单分区方案最大的问题在于升级过程中存在一个中间状态这个状态下设备既不能正常跑业务也没有一个完整的旧固件可依赖。AB双槽位正是为了解决这个问题出现的。1.2 AB方案的核心状态机与真回滚AB方案的本质是给Flash里准备两个完全独立的App槽位命名A和B当前只从其中一个启动另一个作为待升级的目标。整个生命周期里只存在三种稳定状态从A启动、从B启动、以及正在尝试切换但尚未确认。正常运行时设备在A槽收到新固件后写入B槽写完置一个尝试从B启动的标志然后复位。Bootloader看到这个标志引导B槽的固件跑起来。B固件运行正常后主动上报一次我活着Bootloader把状态改成确认从B启动于是B成为当前槽。如果B固件起不来、跑飞、看门狗复位Bootloader在连续几次尝试后会把状态回滚为继续从A启动设备回到旧版本继续工作。这套状态机比拷贝备份区可靠得多因为它不依赖运行时的数据搬运。擦写期间如果掉电最多是目标槽的固件不完整当前运行槽完全不受影响下次上电Bootloader一样能进入当前槽。真正做到了升级失败时有后悔药吃。1.3 硬件前提Flash容量要宽裕到什么程度AB方案最被人诟病的点就是Flash占用翻倍原来一个App区64KB现在要两个区。以本文用的STM32F103RCT6为例Flash总共256KB我的分法是Bootloader占32KB、标志区4KB、Slot A占108KB、Slot B占108KB最后预留4KB。如果你的App编译出来超过108KB要么优化代码把体积压下来要么换512KB的ZET6要么削减Bootloader到16KB换取更多App空间。但我个人强烈建议Bootloader至少留32KB因为后续你很可能想加签名验签、加各种传输协议Bootloader空间紧张会非常痛苦。这里有一个我对新人的忠告做分区规划之前先看一眼你当前App的bin文件到底多大然后再留出至少30%的余量。千万别等Bootloader做完才发现App放不下那时候返工成本太大。2. 地址分区和状态标志先于代码定死的两件事2.1 F103RCT6的256KB Flash怎么切我使用的开发板是STM32F103RCT6256KB Flash、48KB SRAM属于大容量产品Flash每页2KB。分区表如下区域起始地址结束地址大小说明Bootloader0x080000000x08007FFF32KB存放Bootloader代码Flag区0x080080000x08008FFF4KB存放OTA状态结构体Slot A0x080090000x08023FFF108KBApp槽位ASlot B0x080240000x0803EFFF108KBApp槽位B预留0x0803F0000x0803FFFF4KB以后放版本信息等地址全部对齐到2KB页边界这是Flash擦除的最小单位不然后面写驱动会很难受。如果你用的是512KB的F103ZE只需把Bootloader两个Slot按比例放大即可Flag区建议放在Bootloader后单独划分不要和App区混在一起。分区表定下来之后Bootloader和App两边都在代码里以宏定义方式写死不要到处出现裸数字。2.2 OTA状态结构体与CRC保护策略标志区我放了一个结构体专门记录OTA状态。这个结构体的定义是#define OTA_STATUS_BASE 0x08008000 typedef struct { uint32_t magic; /* 固定为 0x4F544131, 即 OTA1 */ uint32_t state; /* STATE_A_CONF / STATE_B_CONF / STATE_A_TRY / STATE_B_TRY */ uint32_t boot_count; /* 当前槽尝试启动次数, 正常运行时清零 */ uint32_t reserved[3]; uint32_t crc32; /* 前面所有字节的CRC32 */ } ota_status_t;state字段只有四个值状态宏值含义STATE_A_CONF0xA5A50001确认从A槽启动STATE_B_CONF0xA5A50002确认从B槽启动STATE_A_TRY0xC3C30001准备从A槽启动等待App确认STATE_B_TRY0xC3C30002准备从B槽启动等待App确认为什么需要一个magic和CRC32因为Flash内容在掉电写入时有概率变成乱值。Bootloader上电后第一件事就是读这个结构体magic不对直接认为无效CRC32不对也认为无效。判定无效后最安全的做法是强制走A槽同时把状态重写成A_CONF。虽然会造成一次升级记录丢失但至少设备不会变砖。读取逻辑我建议带一个双副本策略同一份结构体写两份一份在0x08008000一份在0x08008400。Bootloader先读主副本CRC校验失败再读备份两个都失败就回A槽。成本只多4KB但实际产线上遇到强电磁干扰时能救命。2.3 固件包格式24字节头都是干什么的上位机发给Bootloader的二进制文件不是单纯App编译出来的bin而是加了一个24字节的文件头。定义如下#pragma pack(push, 1) typedef struct { uint32_t magic; /* 固定为 0x534D5446, 即 SMTF */ uint32_t version; /* 版本号, 0x00000100 表示 v1.0.0 */ uint32_t length; /* 有效固件长度, 不含文件头 */ uint32_t crc32; /* 对有效固件区段的CRC32 */ uint32_t target; /* 0xAA 表示写入Slot A, 0xBB 表示写入Slot B */ uint32_t reserved; /* 预留 */ } fw_header_t; #pragma pack(pop)Bootloader收到这个文件头后先检查magic再检查target和version最后接收完整包并校验CRC32全部通过才允许更新OTA状态并复位。target字段非常关键它决定了这一包固件是写给A还是B避免在固件A/B镜像名称混淆时刷错槽位。实际使用时上位机脚本会把原始的app_a.bin或app_b.bin加上这个文件头生成ota_app.bin再通过串口发给Bootloader。2.4 为什么选CRC32做整包校验有人问STM32F103内部不是有硬件CRC外设吗直接用硬件CRC32不行吗这里有一个坑F103的硬件CRC外设计算结果是固定的32位多项式结果但它的输入输出没有做标准反射处理和ZIP/GZIP体系中常用的CRC32算法不一致。你要用它还得自己写位反转不如直接用软件查表法速度也完全够。对于108KB的固件软件查表CRC32在72MHz主频下大概几十毫秒算完Bootloader阶段多等这一下没有任何问题。我在Bootloader里实现了一个标准的查表CRC32多项式是0xEDB88320初始值和输出异或值都是0xFFFFFFFF。这样和Python的zlib.crc32、PC端的7-Zip算出来完全一致调试时方便比照。3. Bootloader从零实现YMODEM接收、Flash写入与安全跳转3.1 Bootloader的编译空间与工程骨架Bootloader我是单独建了一个Keil工程不带RTOS裸机状态机搞定。整体流程如下int main(void) { ota_status_t status; SystemInit(); UART1_Init(115200); if (OTA_ReadStatus(status) ! OTA_OK) { /* 状态无效, 保守回A槽 */ OTA_SetState(STATE_A_CONF); } if (OTA_CheckBootCount(status)) { /* 尝试次数超限, 执行回滚 */ OTA_Rollback(); } if (UART1_RxChar() OTA_ENTER_KEY || GPIO_ReadInputDataBit(BOOT_GPIO, BOOT_PIN) 0) { /* 进入YMODEM接收流程 */ if (YModem_Receive() OTA_OK) { OTA_ConfirmAndReboot(); } } OTA_JumpToApp(current_slot_addr); }需要注意Bootloader里尽量不要开中断。整个接收、擦写、跳转都放在主循环里用状态机跑中断除了UART接收缓存之外能不开就不开。我在第一版里开了UART接收中断结果跳转App前忘记关导致App启动立刻触发中断跑飞排查了整整一个下午。3.2 串口YMODEM接收端协议细节与状态机我选择YMODEM而不是XMODEM原因是YMODEM能在第一个包里携带文件名和文件大小Bootloader可以直接确认目标文件和长度不需要提前在PC端设置也不容易出现串口工具和Bootloader参数不一致的问题。YMODEM会话大体分三个阶段接收方发送字符C表示我准备好了用CRC模式发送发送方发送文件名块128字节接收方正确解析后回复ACK发送方连续发送数据块和结束块接收方逐块校验回复ACK出错回复NAK核心接收代码如下int YModem_Receive(uint32_t flash_addr, uint32_t *total_len) { uint8_t buf[1024]; uint8_t seq 1; uint8_t pkt_seq; uint32_t offset 0; int state 0; const uint8_t C_CHAR 0x43; UART_SendByte(C_CHAR); while (1) { int len UART_RecvPacket(buf, sizeof(buf), 1000); if (len 0) { UART_SendByte(NAK); continue; } switch (buf[0]) { case SOH: /* 128字节包 */ case STX: /* 1024字节包 */ pkt_seq buf[1]; if (pkt_seq ! seq) { UART_SendByte(NAK); continue; } if (CRC16(buf 3, len - 5) ! (buf[len-1] 8 | buf[len])) { UART_SendByte(NAK); continue; } if (state 0) { /* 文件名块: 把文件名和大小解析出来 */ ParseFileName((char *)(buf 3), total_len); if (*total_len 0) { UART_SendByte(ACK); state 2; } else { UART_SendByte(ACK); UART_SendByte(C_CHAR); state 1; } } else if (state 1) { /* 数据块: 写入Flash */ Flash_WritePage(flash_addr offset, buf 3, PacketDataLen(buf[0])); offset PacketDataLen(buf[0]); UART_SendByte(ACK); } seq; break; case EOT: /* 结束 */ UART_SendByte(NAK); if (UART_WaitByte(1000) EOT) { UART_SendByte(ACK); UART_SendByte(C_CHAR); return OTA_OK; } break; case CAN: return OTA_ERR; default: UART_SendByte(NAK); break; } } }有几个细节值得展开YMODEM的文件名块内容是文件名 空格 文件大小比如app_a.bin 106496。Bootloader从第3字节开始按空格分割就能拿到文件大小收到一个数据包后不能立刻擦写同一页的下一个地址因为Flash出于性能考虑是攒够一页2KB再擦写否则擦写次数会爆炸YMODEM标准里接收方收到EOT后要回NAK发送方再发一次EOT接收方回ACK这是协议规定的握手顺序不能省3.3 Flash擦写封装2KB页对齐与掉电保护STM32F103大容量芯片的Flash每页2KB擦除操作按页进行编程可以按16位半字或32位字进行。我的Flash写入驱动分两层void Flash_ErasePage(uint32_t addr) { FLASH_Unlock(); while (FLASH_ErasePage(addr) ! FLASH_COMPLETE) {} FLASH_Lock(); } void Flash_WritePage(uint32_t flash_addr, uint8_t *buf, uint32_t len) { static uint8_t page_buf[2048]; static uint32_t page_offset 0; static uint32_t page_base 0; if (page_base 0 || flash_addr ! page_base page_offset) { if (page_base ! 0) { Flash_EraseAndWrite(page_base, page_buf, page_offset); } page_base flash_addr ~0x7FF; page_offset 0; memset(page_buf, 0xFF, sizeof(page_buf)); } memcpy(page_buf page_offset, buf, len); page_offset len; if (page_offset 2048) { Flash_EraseAndWrite(page_base, page_buf, page_offset); page_base 0; page_offset 0; } }这样每收满2KB擦写一次页整体效率比每128字节擦一次高很多同时也能避免反复擦写同一页导致Flash寿命过快损耗。F103的Flash擦写寿命标称1万次正常OTA频率下根本用不完但如果你每128字节擦一次一次升级可能擦几百次长期看还是有隐患。掉电保护方面AB方案已经足够宽容写入过程中掉电最多目标槽固件不完整Bootloader下次启动时检查目标槽的固件头合法性不合法就继续用当前槽不影响启动。真正需要小心的是写状态结构体时掉电。我的办法是在状态结构体里带CRC32一旦CRC不对就回滚到A槽不回滚到B。3.4 启动判定与跳转跳之前一定要关中断Bootloader决定跳转前先要确认目标槽里确实有一份可用的固件。我采取了两层检查int OTA_CheckSlotValid(uint32_t slot_addr) { fw_header_t *hdr (fw_header_t *)slot_addr; uint32_t sp, pc; if (hdr-magic ! 0x534D5446) return 0; if (hdr-crc32 ! CRC32(slot_addr sizeof(fw_header_t), hdr-length)) return 0; sp *(volatile uint32_t *)slot_addr; pc *(volatile uint32_t *)(slot_addr 4); if ((sp 0xFFF00000) ! 0x20000000) return 0; if ((pc 0xFFF00000) ! 0x08000000) return 0; return 1; }第二层检查是看向量表的前两个值这是Cortex-M的栈顶指针和复位向量。栈顶指针必须落在RAM区0x20000000复位向量必须落在Flash区0x08000000否则说明槽位里的数据根本不是有效的固件镜像盲目跳转会直接hardfault。跳转代码是Bootloader里最不能出错的部分void OTA_JumpToApp(uint32_t app_addr) { uint32_t sp *(volatile uint32_t *)app_addr; uint32_t pc *(volatile uint32_t *)(app_addr 4); void (*app_entry)(void) (void (*)(void))pc; __disable_irq(); SysTick-CTRL 0; SysTick-VAL 0; UART1_DeInit(); FLASH_Lock(); SCB-VTOR app_addr; __set_MSP(sp); app_entry(); while (1) {} }这里有几个动作一个都不能省__disable_irq()关闭全局中断防止跳转瞬间触发外设中断关闭SysTick清空其计数值否则App里的延时函数会被残留的SysTick状态干扰串口外设反初始化把发送中断、接收标志全部清掉不然App初始化串口时可能收到一堆残留字节设置SCB-VTOR让中断向量表指向新App的地址4. 业务App适配同一套代码编出A/B两个镜像4.1 Keil双Target创建和分散加载配置App工程需要在Keil里建两个TargetAPP_A和APP_B。它们的源码完全一致但分散加载文件里配置的IROM起始地址和大小不同。APP_A的配置为IROM1起始地址0x08009000IROM1大小0x1B000108KBAPP_B的配置为IROM1起始地址0x08024000IROM1大小0x1B000108KB这样编译出来的bin中断向量表、函数入口、字符串常量全都在各自槽位的地址范围内。需要注意不要尝试用同一个bin同时跑A和B两个槽位因为代码里的绝对地址引用无法自动适配两个不同的基地址。两个Target编译产物分别命名为app_a.bin和app_b.bin。Keil里可以在Target设置中指定不同的输出文件名或者用FromELF命令行工具统一导出。我在工程里加了一个自定义User命令fromelf --bin --output./output/app_a.bin ./output/APP_A.axf每次编译后自动生成bin省得手动导出。4.2 中断向量表重映射F103的VTOR到底能不能用网上关于STM32F1能不能用SCB-VTOR重映射中断向量表一直有争议。我在F103RCT6上实际测试直接写SCB-VTOR 0x08009000是能正常工作的但有一个前提必须在App复位后非常早的时候设置最好在SystemInit里完成而不是等到main里才做否则启动阶段一旦来一个中断向量表还是指向旧的0x08000000程序直接跑飞。#define SCB_VTOR (*(volatile uint32_t *)0xE000ED08) void SystemInit(void) { /* 其他初始化 */ SCB_VTOR APP_SLOT_BASE; }APP_SLOT_BASE怎么获得这里有一个小技巧不要直接写死宏而是通过取函数当前地址动态判断#define SLOT_A_BASE 0x08009000 #define SLOT_B_BASE 0x08024000 uint32_t GetCurrentSlotBase(void) { uint32_t pc (uint32_t)GetCurrentSlotBase; if (pc SLOT_A_BASE pc SLOT_B_BASE) { return SLOT_A_BASE; } return SLOT_B_BASE; }因为这是一个函数指针的地址链接器会把该函数放在当前槽位对应的地址区间。在SystemInit里调用它返回的自然就是当前运行槽的起始地址不需要手动判断版本。如果你在F103某个型号上发现设置VTOR后中断依然异常退而求其次的办法是把向量表整体搬运到SRAM里再把VTOR指向SRAM地址。原理是Cortex-M3的向量表不必在Flash里SRAM里放一份中断就可以命中。这个方案在F1上兼容性最好代价是多占用一份SRAM。具体做法也很成熟就是把Flash地址0x08009000开头的一段数据拷贝到SRAM然后设置VTOR。我建议先用VTOR直接设置法不行再考虑SRAM搬运法不用一开始就上重方案。4.3 存活上报与看门狗联动回滚机制闭环App的存活上报是回滚机制的核心。我在App里启动了一个独立看门狗IWDG一旦App跑飞或卡死看门狗会复位系统。同时App在业务稳定运行后调用一个确认函数把Bootloader里的状态从TRY改成CONF。void OTA_ReportAlive(void) { ota_status_t status; uint32_t base GetCurrentSlotBase(); OTA_ReadStatus(status); if (status.state STATE_A_TRY base SLOT_A_BASE) { status.state STATE_A_CONF; status.boot_count 0; OTA_WriteStatus(status); } else if (status.state STATE_B_TRY base SLOT_B_BASE) { status.state STATE_B_CONF; status.boot_count 0; OTA_WriteStatus(status); } }这个函数放在App主循环的一个固定位置比如按键检测、传感器上报、通信心跳等业务动作跑起来之后调用。我建议放在至少3秒稳定运行后再上报太早的话后续业务初始化的故障还没来得及暴露回滚机制就失效了。看门狗的配合方式是App启动时就初始化IWDG并开始喂狗如果App在某个初始化环节卡死看门狗超时复位Bootloader会再次把状态置为TRY并递增boot_count。当boot_count超过3Bootloader判定当前槽固件不可用自动回滚到另一个已确认槽。这里有一个细节容易忽略App上报成功前boot_count已经累计的值不应该立即清零而是等到确认成功后清零。否则中途看门狗复位boot_count重新计数等于给了坏固件无限次机会。5. PC端推送工具一个Python脚本走完YMODEM发送5.1 为什么不用SecureCRT很多教程让读者用SecureCRT的YMODEM发送功能来推送固件。但SecureCRT是商业软件破解版又经常会遇到协议处理不标准、上传大文件卡死的问题。而且Bootloader调试阶段需要频繁修改固件头、指定目标槽位手工操作很容易点错。我写了一个简单的Python工具基于pyserial实现YMODEM发送端。整个工具就两个命令一个是给原始bin加文件头一个是发送到串口python add_header.py app_a.bin ota_app.bin --target A --version 1.0.0 python ymodem_send.py /dev/ttyUSB0 ota_app.bin --baud 1152005.2 固件头封装与发送脚本核心逻辑固件头封装脚本的逻辑很简单就是计算原始bin的长度和CRC32然后写入固定24字节头部import struct, zlib, sys def add_header(src, dst, version, target): data open(src, rb).read() magic 0x534D5446 ver (int(version.split(.)[0]) 16) | (int(version.split(.)[1]) 8) | int(version.split(.)[2]) crc zlib.crc32(data) 0xFFFFFFFF tgt 0xAA if target.upper() A else 0xBB header struct.pack(IIIII I, magic, ver, len(data), crc, tgt, 0) open(dst, wb).write(header data)注意一点整个固件包头部数据在YMODEM传输中由协议自身的CRC16保证传输无差错而头部里的CRC32是为了防止文件写进Flash后被杀毒软件改写或传输过程异常两者不冲突。YMODEM发送端的关键流程是先发C字符等接收方应答然后发送文件名块文件名内容写ota_app.bin 文件大小随后逐块读取文件内容并发送。每收到一个ACK再发下一块收不到ACK则重发。发送过程中如果连续收到3次NAK脚本直接退出并提示用户检查连接。5.3 发送失败排查清单我把自己在调试中遇到过的发送失败情况整理成一个清单现象可能原因排查方向一直停在没有发送方响应Bootloader没进入YMODEM模式按住BOOT按键再上电或发OTA命令字符串收到文件名后立即结束YMODEM发送端没有等到接收方的C就发下一包检查YMODEM实现中ACK后是否再发C传完CRC校验失败串口波特率漂移或USB转TTL掉字节降波特率到57600或9600测试传到一半卡死Flash擦写时间过长导致YMODEM超时检查Flash写入端是否有阻塞等待优化页写入策略App启动后没有任何反应向量表重映射失败检查SystemInit里的VTOR设置是否在App入口早期执行6. 全流程实测正常升级、掉电中断、坏固件回滚6.1 第一次烧录与从A启动第一次烧录分三步把Bootloader工程通过ST-Link烧到0x08000000把APP_A工程编译出的app_a.bin烧到0x08009000手动往Flag区0x08008000写一份state为STATE_A_CONF的状态结构体如果你不想手写状态结构体也可以在Bootloader里加一个初始化逻辑检测到Flag区magic无效时自动写A_CONF。这样上电就直接进A槽。我第一次调试时就是靠这个逻辑省去烧Flash原始数据。烧录完成后上电串口应该先看到Bootloader的启动日志再看到App的启动日志说明Bootloader成功跳转到了A槽。6.2 从A升级到B的完整过程假设当前A槽运行的是v1.0.0现在要把v1.1.0刷到B槽。流程是用app_b.bin生成ota_app.bintarget指定B设备运行时通过串口发一个进入OTA的指令我定义的是ATOTA收到后App延迟1秒复位进入BootloaderBootloader检测到标志位或按键进入YMODEM接收模式PC端执行python ymodem_send.py COM3 ota_app.bin开始传输传输完成Bootloader校验CRC状态改为STATE_B_TRY复位B槽App启动运行3秒后上报存活成功状态改为STATE_B_CONF整个过程在115200波特率下传108KB大约需要20秒可以接受。如果想提速可以启用YMODEM的1024字节包模式我的代码里已经支持STX包所以设备端会自动接收更大的包速度能提升不少。6.3 故意断电后的表现为了测掉电保护我在传输到80%左右时直接拔掉串口线或者关电源。重新上电后观察现象Bootloader正常启动检查到目标槽固件头部magic不对直接忽略该槽继续启动当前已确认的A槽。App正常运行OTA状态结构体里state依然是A_CONF没有被污染。这里体现的就是AB方案的价值目标槽写坏了当前槽还能跑即使是正在写目标槽的过程中断电也不会影响已经确认的当前槽。6.4 写一个死循环固件验证自动回滚验证自动回滚需要准备一个坏固件我写了一个特殊的v1.2.0main函数进入一个死循环并且不喂狗int main(void) { SystemInit(); while (1) { /* 死循环, 不初始化看门狗, 不上报存活 */ } }把这个固件刷到B槽状态置为B_TRY然后复位。Bootloader引导B槽启动但B槽固件卡死在main里不喂狗。IWDG超时后芯片复位Bootloader再次读到B_TRY此时boot_count递增。第三次复位后boot_count超过阈值Bootloader直接把状态改回A_CONF并引导A槽启动设备回到旧版本继续工作。实测这个回滚过程在3次尝试后完成耗时大约15秒。用户只会感觉设备多重启了几次不会因此变成砖头。7. 进阶扩展加签验签、网络下发和CAN通道的思路7.1 加签验签怎么做公钥放哪、验签开销多大如果你的产品要面对工业环境甚至车载场景固件包需要签名防篡改。做法是在固件头后面追加一个签名区。Bootloader收到固件后先用内置公钥对头部数据做签名校验通过后才会写入Flash。STM32F103没有硬件RSA/ECC加速器只能纯软件算。实测经验RSA2048验签公钥指数65537在72MHz主频下大约耗时200ms到400ms可接受ECDSA P-256验签大约几十到一百多毫秒密钥更短、更省空间。如果Bootloader要支持验签建议用ECDSA同时把签名算法做成可选编译项调试时先关掉签名验证量产版本再打开。公钥存放在Bootloader区最末尾的预留空间里不要在App里放私钥或公钥。私钥只保存在PC端签名工具所在的安全环境里。签名流程是PC端用私钥对固件包计算签名追加到固件末尾Bootloader用内置公钥验签。验签通过后再把固件搬运到目标槽。这块代码不复杂但密钥管理和颁发流程在生产环境里比较繁琐我后续会单独写一篇。7.2 借道ESP8266/以太网从nginx拉固件串口YMODEM适合开发调试和产线但量产产品的OTA一般走网络。常见做法是让App通过串口或SPI挂一个ESP8266模块App运行期间从HTTP服务器下载固件到内存缓冲区再调用Flash驱动写入非当前槽位。服务器的静态文件可以直接用nginx托管配置文件里加一个location指定bin文件目录即可。注意nginx默认会带Content-Length头App端用这个长度判断固件包是否完整。下载过程建议按块拉取每块4KB到8KB收到就写Flash不要一次性把整个固件载入RAMF103只有48KB SRAM大固件放不下。这种架构下Bootloader不需要实现网络协议它的职责不变接收完整固件包、校验、跳转。网络下载全在App里完成逻辑更清晰。7.3 换大容量芯片和F4系列时改哪里如果你的产品换成了512KB的F103ZET6分区表直接翻倍Bootloader 32KB、Flag区4KB、Slot A 238KB、Slot B 238KB。代码基本不用改只需要把SLOT_A_BASE和SLOT_B_BASE对应的宏改掉即可。换到F4系列要注意三点F4的Flash每页大小通常是16KB甚至更大Flash_WritePage里的页缓冲区要相应扩容F4支持真正的硬件双Bank启动机制某些型号可以做到真正的无缝切换但代码复杂度和F103完全不在一个量级F4的内核时钟和Flash等待周期配置不同Bootloader里SystemInit部分要按对应型号的启动文件处理从F103切到F4时我踩过最深的坑是Flash擦写时序。F4的Flash擦除页大小和F1完全不同直接用F1的擦写代码非常容易触发Flash操作错误标志。移植时务必仔细阅读对应型号的参考手册中Flash编程章节。做完整套AB OTA之后再回头看最值得花时间的其实不是YMODEM协议也不是Flash驱动而是分区规划和状态机的边界情况处理。把什么时候该回滚、什么时候该确认、掉电了怎么办这些问题想清楚后面所有代码都顺理成章。如果你也想在自己的F103板子上复现这套方案建议先从最小系统板加USB转TTL开始按文中分区表把Bootloader和App跑通再考虑加签名和网络下载。整个流程大概需要两到三个周末但跑通那一刻你会觉得之前拆壳刷固件的日子再也不想回去了。