STM32F103 AB分区OTA升级方案:Modbus RTU远程固件更新与自动回滚实现 手头有块STM32F103开发板想给它做一个真正能用于生产环境的升级机制——不是JTAG插上去下载程序那种小打小闹而是通过串口远程把新固件发过去、断电能自动恢复、刷挂了还能自动回滚的完整方案。这篇教程就是围绕这个目标来的从零复现一套基于STM32F103的AB双分区OTA升级系统传输层用标准库v3.5 FreeModbus v1.6移植Modbus RTUBootloader负责引导和刷写App负责业务逻辑与升级确认。文章会覆盖你可能关心的所有细节Flash分区怎么规划、向量表偏移怎么改、Bootloader怎么跳转App、FreeModbus移植要注意什么、AB分区状态机怎么设计、断点续传和自动回滚怎么实现、上位机脚本怎么写。适合两类读者一是已经跑通过STM32点灯、UART收发想进阶IAP和OTA的开发者二是正在做工业设备远程维护方案需要一套稳定可靠、可上线的升级架构的嵌入式工程师。1. 项目整体拆解AB OTA到底在解决什么问题1.1 一个设备升级事故引出的双分区方案先说一个我早年踩过的坑。那时候给一批现场设备做升级所有设备从同一个起始地址烧固件升级过程依赖串口一次性把整个bin写进Flash。结果某次现场网络抖动升级包传了一半断掉Flash里的程序被擦了一半设备变砖最后只能派人扛着编程器去现场拆机恢复。后来我意识到传统IAP方案最大的问题就是单分区当前运行的程序和即将写入的程序共用同一块Flash区域升级过程中一旦断电、误码或者协议异常唯一可运行的程序也损坏了设备自然起不来。AB双分区方案就是为了解决这个问题。简单说把存储区拆成两个应用分区A分区和B分区。任意时刻Bootloader只启动其中一个比如默认启动A。要升级时把新固件写入当前不运行的B分区写完后置一个待切换标志重启后Bootloader从B启动。如果B运行失败Bootloader再回滚到A。A始终保留着上一个可用版本任何时刻都有一份能跑的固件。这跟手机的双系统分区一个逻辑系统升级失败大不了回退旧版本设备始终能用。对嵌入式设备来说这几乎是远程升级方案里最稳妥的做法。你要知道工业现场一台设备动辄几万块派人去现场刷机不仅成本高还可能耽误产线运行AB分区方案能把这些风险压到最低。1.2 为什么选择Modbus RTU作为升级传输通道很多做OTA的教程默认用自定义串口协议或者Ymodem这些都用过也都能跑但我在实际项目里最终选择了FreeModbus v1.6移植的Modbus RTU作为设备端通信协议原因很简单很多工业设备本来就在跑Modbus总线。Modbus RTU是工业现场最常见的总线协议之一。如果你的设备已经在用Modbus RTU和上位机/PLC通信那直接用同一条链路做OTA不需要额外引入新协议也不需要动总线拓扑。上位机端打开一个串口既能查寄存器、控制参数也能触发升级整个心智负担小很多。另外Modbus RTU自带CRC16校验帧格式完整协议栈成熟。FreeModbus v1.6是一个开源Modbus从站协议栈移植到STM32F103并不复杂只要处理好串口底层和定时器中断就能跑。相比之下Ymodem虽然传输效率更高但在Modbus网络里不兼容也不方便做进度查询、分区切换、版本管理这些定制逻辑。我在这个项目里也重新写了一个上位机Python脚本直接通过Modbus RTU功能码与Bootloader交互。这样不仅本教程可以用你以后接到Modbus设备OTA需求时思路基本可以原样搬过去。1.3 Bootloader与App的职责划分AB OTA方案里Flash里跑的程序被分成两部分Bootloader引导程序上电先运行读参数区标志决定启动A还是B或者进入OTA升级模式。它内部集成了一套OTA协议处理逻辑能接收上位机发来的固件写入指定分区做CRC校验管理升级状态机。App应用程序实际业务程序比如电机控制、数据采集、显示界面等等。它通过一个寄存器写入命令触发软复位把控制权交回BootloaderApp启动后还需要执行提交更新动作告诉Bootloader自己运行正常。把OTA协议栈放在Bootloader里是我在实际项目中反复推敲后选定的架构。有人喜欢让App自己触发固件写入Bootloader只做跳转。这么做Bootloader虽然轻量但App一旦跑飞、死机或者被错误代码干扰设备就完全失去远程恢复能力。而如果我让Bootloader具备完整的OTA能力即使App完全没有响应上位机也能强制进入Bootloader重新刷写相当于设备多了一道保险。Bootloader体积会因为集成OTA协议栈而变大但考虑到STM32F103的Flash普遍在64KB到512KB之间一个带Modbus协议的Bootloader控制在16KB以内完全可行代价是可以接受的。2. Flash布局与启动机制设计2.1 地址空间规划分区表怎么排才干得漂亮这套方案的第一步是给存储空间做分区。我以STM32F103RCT6256KB Flash为例给出一个实践中验证过的分配方案你可以根据自己芯片的Flash大小做比例缩放。区域起始地址大小说明Bootloader区0x0800000016KB (0x4000)引导程序 OTA协议栈App分区A0x08004000100KB (0x19000)默认启动的应用App分区B0x0801D000100KB (0x19000)备用应用用于承载新版本参数/标志区0x080360004KB (0x1000)分区状态、版本号、升级标志预留区0x0803700036KB日志、参数存储、字库等如果你手里的板子是C8T6这种64KB Flash的芯片可以采用紧凑布局Bootloader 12KBA和B各22KB尾部留4KB参数区。App体积和时间戳一定要控制好不然22KB很快用完。有几个关键点需要强调参数区必须单独划一小块Flash不能和程序区混在一起。它的作用是持久化保存当前从哪个分区启动是否有待切换标志启动失败计数等信息。程序运行时可以随时修改掉电不丢。分区起点要对齐Flash页大小。STM32F103中中容量芯片64KB/128KBFlash页大小为1KB大容量芯片256KB/512KB为2KB。分区边界按页大小对齐能避免跨页擦除的麻烦所以上面表格里所有分区起始地址都是整数倍对齐的。预留区不是可有可无。项目后期你可能需要存字库、记录运行日志、保存标定参数如果没有预留区到时候就得痛苦地迁移分区表。2.2 向量表偏移跳转后第一个大坑分区规划好之后马上会遇到一个让无数人翻车的问题向量表偏移。单片机上电后默认从0x08000000读取向量表也就是从Bootloader的起始地址找栈顶指针和复位处理函数。当Bootloader跳转到App时App的向量表并不在0x08000000而在A分区或B分区的起始地址。如果不告诉CPUApp的向量表换位置了那么任何中断发生比如串口收一个字节CPU都会从原向量表去找中断服务函数找错地方自然死机。处理方式就是在App启动代码里设置Cortex-M3的向量表偏移寄存器VTORSCB-VTOR APP_ADDRESS; // APP_ADDRESS 为当前分区的起始地址在STM32F103的标准库里也可以在system_stm32f10x.c里找到宏定义VECT_TAB_OFFSET直接改成对应偏移量。比如A分区从0x08004000开始偏移就是0x4000。但注意B分区的偏移是0x1D000和A分区不一样。如果App的工程是同一份代码分别编译成A/B两个版本不同版本要保证VECT_TAB_OFFSET不同。我在实践中更推荐的做法是不在App工程里写死偏移而是由Bootloader在跳转前设置好VTOR。跳转函数里把目标分区地址写入SCB-VTORApp里就不必再关心自己到底在哪个分区。这样做的好处是同一份App固件既能刷到A区也能刷到B区上位机只需要根据目标地址把它写到对应位置即可不用编译两个版本。2.3 启动状态机与自动回滚设计AB方案的核心灵魂是状态机。参数区里我定义一个结构体把这几个关键字段持久化字段值含义magic0xA5A55A5A参数区有效性标记boot_flag0xAA正常启动A分区0xBB正常启动B分区0xCC待切换到未运行分区升级完成待确认boot_count0~N启动尝试计数version_a / version_b版本号各分区固件版本信息上电后Bootloader的执行逻辑读取参数区检查magic。无效则初始化参数区默认boot_flag 0xAA启动A分区。如果boot_flag 0xCC说明上次升级后还没被确认。此时把boot_count加1写入参数区需要做Flash擦写再跳转目标分区。如果boot_flag 0xAA或0xBB直接启动对应分区。App启动后若boot_flag仍为0xCCApp会在初始化成功后把boot_flag改为正常启动标志boot_count清零然后正常继续运行。如果App启动后崩溃、死循环、看门狗复位Bootloader再次启动时发现boot_flag仍为0xCC且boot_count超过阈值比如3次就判定升级失败自动把boot_flag改成另一个分区对应的正常启动标志回滚到旧版本。这套机制解决了一个关键问题什么算升级成功不是固件写进Flash就算成功而是新固件能正常启动并运行一段时间才算成功。否则可能出现固件写进去了一运行就死机之后每次上电都死在半路的尴尬局面。我在这里用了独立看门狗IWDG配合。做法是Bootloader跳转到目标分区之前启动IWDG超时设为几秒App启动后立即喂狗。如果App卡死IWDG会复位MCUBootloader再次上电后就能感知到这次切换失败了。Bootloader自己的OTA流程不启用IWDG因为升级过程中可能要等上位机慢慢发包不能被打断。这是很容易忽略的细节很多人直接在主程序里统一开看门狗结果OTA等包时被反复复位。3. Bootloader工程从零搭建3.1 工程骨架标准库v3.5 FreeModbus v1.6Bootloader工程的搭建我建议从标准库v3.5开始稳定成熟网上资料也多。新建一个Keil工程芯片选STM32F103系列加入标准库的启动文件、系统初始化代码、GPIO/UART/Flash/Timer相关外设驱动以及FreeModbus v1.6的源码。FreeModbus v1.6需要移植的底层接口不多核心就几个串口初始化与字节收发xMBPortSerialInit、xMBPortSerialPutByte、xMBPortSerialGetByte串口中断上报收字节后调prvvUARTRxISR发完后调prvvUARTTxReadyISR定时器实现3.5字符超时初始化一个定时器产生周期性tick如50us调用vMBPortTimersEnable、vMBPortTimersDisable在定时器中断里调prvvTIMERExpiredISR数据回调eMBRegHoldingCB用于读写保持寄存器这是OTA协议的主要入口移植时一个最容易出问题的地方是3.5字符时间计算。Modbus RTU要求帧与帧之间至少有3.5个字符时间的间隔用于从站判断一帧数据的结束。115200波特率、8N1格式下一个字符需要10bit时间3.5字符时间约为3.5 * 10 / 115200 ≈ 0.304ms。所以定时器tick选50us或100us都能满足要求。波特率越低3.5字符时间越长比如9600波特率下大约是3.65ms此时换一个慢速定时器更合适。我用的定时器tick是100us这样的精度对115200足够了。串口中断优先级设置也需要注意我把它设成比主循环临界区保护更高的优先级避免丢字节。3.2 Flash驱动页擦除、半字编程与预擦除策略STM32F103的Flash操作规则和很多芯片不太一样编程以半字16位为最小单位擦除以页为最小单位。标准库提供三个关键函数FLASH_Unlock()解锁、FLASH_ErasePage()擦页、FLASH_ProgramHalfWord()写半字。写数据函数我封装成这样void flash_write_buf(uint32_t addr, uint8_t *buf, uint32_t len) { FLASH_Unlock(); for (uint32_t i 0; i len; i 2) { uint16_t half buf[i] | ((uint16_t)buf[i 1] 8); FLASH_ProgramHalfWord(addr i, half); } FLASH_Lock(); }这里有两个硬性要求写地址必须是2字节对齐长度必须是2的倍数。如果不满足Flash编程会报错或者写入数据错乱。我在实际中把上位机下发的数据包长度固定为偶数并在Buffer里做对齐拷贝后再调用写入函数问题迎刃而解。擦除函数也不复杂void flash_erase_range(uint32_t addr, uint32_t len) { FLASH_Unlock(); uint32_t page_size 1024; // 中容量芯片1KB/页大容量2KB/页 for (uint32_t a addr; a addr len; a page_size) { FLASH_ErasePage(a); } FLASH_Lock(); }一个我强烈建议的做法是开始升级时先把整个目标分区擦空而不是边收数据边擦页。原因有两个擦除Flash需要时间中容量F1擦一页通常要20~40ms。如果每一次数据包都需要临时擦页Modbus响应时间会变得不稳定上位机容易超时。预擦除后写入阶段只需要做半字编程每半字耗时微秒级整个240字节数据包写入不到1ms对Modbus协议栈几乎无感。代价是如果升级中途放弃目标分区全0xFF表面上像废了。但AB方案下这不影响运行中的分区你随时可以重新发起升级覆盖。3.3 OTA寄存器协议一条升级指令的完整拆解FreeModbus移植好、Flash驱动写好后就要设计OTA协议了。我的思路是让它兼容标准Modbus协议用保持寄存器Holding Register作为交互窗口。保持寄存器映射表如下寄存器地址读写功能0x0000写OTA控制命令0x0001写目标分区号0A1B0x0002-0x0003写固件总长度32位高16位在0x00030x0004-0x0005写当前数据包的Flash写入偏移32位0x0010-0x00D0写数据缓冲区224字节0x00E0读升级状态寄存器0x00E1-0x00E2读当前活动分区版本号0x00F0写软复位并进入Bootloader升级模式控制命令定义如下写入1开始升级。内部执行预擦除目标分区、清零状态。写入2写入当前缓冲区数据。从偏移地址开始将0x0010-0x00D0里的224字节数据编程进Flash。写入3结束升级。计算已写入数据的CRC32尝试切换分区。写入4查询状态。读0x00E0获取当前升级状态。一次完整的数据包写入流程是上位机先写目标偏移到0x0004-0x0005再写224字节数据到0x0010-0x00D0最后写控制命令2到0x0000。Bootloader在eMBRegHoldingCB回调里解析控制命令调用Flash编程函数完成写入。为什么缓冲区选224字节这是结合Modbus RTU帧长限制算出来的。Modbus RTU最大帧长是256字节减去从站地址1字节、功能码2字节0x10写多个寄存器功能码起始地址、寄存器数量2字节、字节数1字节和CRC2字节再减去我们需要的偏移寄存器、控制寄存器等开销224字节是安全且高效的选择。实测在115200波特率下一帧数据从发出到固件写入Flash完成耗时约25ms整包100KB的固件传输只要不到30秒。这里涉及到一个关键实现Flash编程操作直接在FreeModbus的回调里同步执行。因为预擦除已经做好每次编程耗时很短不会导致Modbus超时这样做最简单不需要异步状态机。但要注意回调里做Flash操作时必须先关闭全局中断否则擦写过程中被中断打断可能出问题。标准库的FLASH_ProgramHalfWord内部会关闭中断FLASH_ErasePage也是所以直接用标准库问题不大。如果是自己写Flash寄存器操作要留意这一点。4. App工程改造要点4.1 Keil工程地址修改与bin文件生成App工程的改造核心是让链接器知道我的程序不在0x08000000开局了。在Keil的Options for Target中打开Target标签页。修改IROM1起始地址和大小。比如A分区起始0x08004000大小0x19000。确保勾选Create HEX File。同时配置生成bin文件在User标签页的After Build/Rebuild里加一行fromelf --bin -o $LL.bin #L这样每次编译都会生成一个和工程同名的.bin文件这正是OTA需要烧写的文件。它的第一个字节就是从分区起始地址开始的可执行代码不需要额外偏移。一个常见的错误是只改了起始地址但是App里某个绝对指针还是指向0x08000000或者链接器把常量池放到了错误位置。最直接的验证方式就是看map文件。打开生成的.map文件搜索__initial_sp正常情况下它应该落在分区地址范围内比如0x08004000附近。再搜索Reset_Handler确认复位处理函数也在分区内。如果发现符号地址仍然显示为0x08000000开头说明链接脚本没生效App烧进去必死。4.2 上电提交与进入升级模式App启动后第一件事就是处理待确认标志。前面说过Bootloader在跳转前把boot_flag设成了0xCC待切换如果App不能正常工作就没有机会修改这个标志。所以App代码最好在main函数的很靠前位置执行提交逻辑void cli_ota_commit(void) { OtaParam_t param; flash_read_param(param); // 从参数区读到RAM if (param.magic 0xA5A55A5A param.boot_flag BOOT_FLAG_PENDING_SWITCH) { param.boot_flag BOOT_FLAG_NORMAL; param.boot_count 0; flash_write_param(param); // 整页擦写回参数区 } }这里有个细节值得说明参数区的读写不能直接映射到Flash地址再改内容因为写Flash前必须整页擦除擦除期间如果代码还在从Flash取指令CPU取指令会失败。正确做法是先把整个参数结构拷贝到RAM修改完后再一次性擦写回Flash。App运行期间所有状态操作都基于RAM副本只在关键时刻回写Flash。进入升级模式的触发方式也很简单。App里注册一个Modbus寄存器或者直接接受业务命令当上位机写入0x00F0寄存器为特定值时App保存必要业务状态然后写参数区boot_flag为进入Bootloader升级模式最后执行软件复位void cli_enter_bootloader(void) { flash_write_param_enter_upgrade(); NVIC_SystemReset(); }Bootloader上电发现这个标志后就不再启动App直接进入OTA等待状态上位机就能开始发固件了。如果上位机一直不出现Bootloader可以设置一个超时时间超过后强制启动App保证设备不会永久停在升级模式里。4.3 独立看门狗IWDG的正确配合方式我前面提到了IWDG这里展开讲讲为什么、怎么做。AB方案最怕一种情况新固件能启动但运行几秒后死机。如果没有任何恢复机制设备就会卡死在新固件里。IWDG就是用来兜底的。具体做法是Bootloader在判定需要切换分区时初始化IWDG预分频和重装值算好让超时时间为5秒左右。Bootloader跳转到目标分区。App启动main函数后立刻调用IWDG_ReloadCounter()喂狗。App在提交更新boot_flag改成正常标志之前必须持续喂狗。如果App崩溃IWDG超时复位Bootloader重新上电发现boot_flag仍然是0xCCboot_count已经超过阈值于是回滚到旧分区。关键App发现boot_flag是0xCC时要延迟提交。比如先运行3秒、确认外设初始化正常再调用cli_ota_commit。这3秒内IWDG正常工作可以精准捕获启动即崩溃和初始化阶段卡死这两类故障。IWDG配置代码void iwdg_start(void) { IWDG_WriteAccessCmd(IWDG_WriteAccess_Enable); IWDG_SetPrescaler(IWDG_Prescaler_64); // 40kHz / 64 625Hz IWDG_SetReload(3125); // 约5秒 IWDG_ReloadCounter(); IWDG_Enable(); }注意IWDG的时钟源是独立的LSI时钟大约40kHz不同芯片可能偏差。实测发现有的芯片LSI误差可以到±10%所以超时时间不要设计得太紧给足余量。我一般取5到8秒这个时间足够App初始化外设和喂狗了。5. 上位机脚本与整机联调复现5.1 Python上位机核心逻辑Modbus RTU通信可以用Python的pyserial库直接拼帧不需要额外依赖。核心函数是Modbus CRC16计算和帧组装import serial import struct import time def modbus_crc(data: bytes) - int: crc 0xFFFF for byte in data: crc ^ byte for _ in range(8): if crc 1: crc (crc 1) ^ 0xA001 else: crc 1 return crc def build_frame(slave: int, pdu: bytes) - bytes: frame bytes([slave]) pdu crc modbus_crc(frame) return frame struct.pack(H, crc) def write_regs(slave: int, addr: int, values: list, ser: serial.Serial): cnt len(values) pdu struct.pack(BHHB, 0x10, addr, cnt, cnt * 2) for v in values: pdu struct.pack(H, v 0xFFFF) ser.write(build_frame(slave, pdu)) resp ser.read(8) def write_one_reg(slave: int, addr: int, value: int, ser: serial.Serial): pdu struct.pack(BHH, 0x06, addr, value 0xFFFF) ser.write(build_frame(slave, pdu)) resp ser.read(8)上位机升级流程骨架def ota_upgrade(ser, bin_path, target_part): # 1. 发开始升级命令 write_one_reg(SLAVE, 0x0001, target_part, ser) write_one_reg(SLAVE, 0x0000, 1, ser) # 控制字1 开始 time.sleep(0.5) # 等待预擦除完成 data open(bin_path, rb).read() for offset in range(0, len(data), 224): chunk data[offset:offset 224] if len(chunk) 224: chunk chunk b\xff * (224 - len(chunk)) # 补齐 # 写目标偏移 write_one_reg(SLAVE, 0x0004, offset 0xFFFF, ser) write_one_reg(SLAVE, 0x0005, (offset 16) 0xFFFF, ser) # 写数据缓冲区 regs [int.from_bytes(chunk[i:i2], big) for i in range(0, 224, 2)] write_regs(SLAVE, 0x0010, regs, ser) # 触发写入 write_one_reg(SLAVE, 0x0000, 2, ser) # 查询状态确认写入成功 status read_reg(SLAVE, 0x00E0, 1, ser) if status ! SUCCESS: raise Exception(write chunk failed at offset %d % offset) print(progress: %d%% % (offset * 100 // len(data))) # 2. 结束升级触发切换 write_one_reg(SLAVE, 0x0000, 3, ser)注意补全数据时用0xFF而不是0x00因为Flash擦除后是0xFF未编程区域本来就是0xFF这样补的字节不会影响固件有效性。如果补齐成0x00这部分Flash实际上还是0xFFCRC校验就会失败。5.2 正常升级全流程演示正常升级链路是这样一个闭环设备当前运行A分区固件v1.0。上位机通过App的0x00F0寄存器触发进入Bootloader升级模式设备软复位。Bootloader启动识别到升级模式标志初始化FreeModbus和OTA服务向上位机返回设备在线状态。上位机连接串口读取新固件bin文件先发送开始升级命令目标分区选B。Bootloader擦除B分区全部页面。此时设备仍运行在Bootloader中A分区固件原封不动。上位机循环发送数据包每包224字节。Bootloader接收后写入B分区并实时更新进度状态。全部发送完成后上位机发送结束升级命令。Bootloader对B分区固件做全量CRC32校验校验通过后置boot_flag0xCC然后跳转到B分区。B分区固件启动IWDG开始计时。固件初始化完成3秒后执行cli_ota_commitboot_flag改为0xBBboot_count清零。下次复位时Bootloader看到boot_flag0xBB直接启动B分区。升级完成。这个流程我在实际测试中100KB固件在115200波特率下耗时约35秒包括擦除和校验时间。相比JTAG烧写确实慢一些但胜在可以远程操作而且全程不需要打开机箱。5.3 异常场景实测断电中断、固件损坏、启动失败回滚写升级系统不能只看正常流程异常场景才是考验方案设计水平的地方。我实测了三种典型故障结果都符合预期断电中断升级包传到一半时给设备断电。重新上电后Bootloader发现boot_flag不是0xCC仍然启动A分区旧固件设备正常工作。B分区里残留半个固件但无伤大雅下次升级时会被重新擦除覆盖。固件损坏用上位机人为修改bin文件中间几个字节让其CRC校验不通过。结束升级时Bootloader计算CRC32对比失败拒绝切换分区设备继续运行A分区固件。这里我把CRC32写在结束升级命令里Bootloader对整个目标分区数据进行遍历计算避免依赖单包校验的盲区。新固件启动失败故意让新固件在初始化时进入死循环。Bootloader切换后IWDG超时复位再次进入Bootloader发现boot_count1boot_flag仍为0xCC继续尝试。连续3次失败后Bootloader自动把boot_flag改回0xAA启动A分区旧固件设备恢复正常。需要提醒的是回滚后最好在参数区记录一个上次升级失败的状态上位机查询时能看到。否则运维人员会困惑明明升级成功了怎么版本还是旧的。6. 常见问题排查与避坑清单6.1 典型故障速查表我把复现过程中踩过的坑和同行反馈过的经典问题整理成一张速查表方便你遇到问题时一眼定位。现象直接原因解决办法Bootloader跳转后黑屏/死机向量表偏移未设置或设置错误在跳转函数和App里都设置SCB-VTOR确认偏移值正确串口收不到Modbus响应FreeModbus定时器T35配置不对重算3.5字符时间115200下tick小于0.3ms即可升级到一半设备复位Flash擦除/编程时间过长触发看门狗升级期间关闭IWDG或加强刷新机制数据包写入后校验不过Flash地址非2字节对齐或长度非偶数统一使用2字节对齐地址数据长度补齐偶数App跳转后进不了main函数代码链接地址与实际烧录地址不一致查看map文件确认Reset_Handler地址核对IROM设置升级次数多了参数区损坏参数区频繁擦写导致Flash寿命耗尽低频升级场景下问题不大避免高频写参数区新固件能启动但无法提交更新App提交逻辑被业务初始化阻塞把cli_ota_commit移到main函数最前面上位机发完包没有进入切换结束升级命令的CRC32不匹配统一用相同CRC32算法IEEE 802.3初始0xFFFFFFFF比对6.2 调试利器map文件与串口日志联合分析调试OTA这类涉及地址跳转的问题最有效的两件工具是map文件和串口日志。map文件前面提过编译后一定要打开看。我特别关注三个符号__initial_sp、Reset_Handler、main。在Keil里编译完成后双击.map文件搜索这些符号确认地址落在预期分区范围内。如果发现__initial_sp地址在0x20000000之前或者和链接起始地址对不上那App烧进去之后大概率直接硬件错误。串口日志则要讲究层级。Bootloader的日志和App的日志建议共用同一个UART但加上不同的前缀比如[BT]和[APP]。这样即使程序在跳转后崩溃你也能从串口输出判断代码执行到了哪个阶段。比如Bootloader打印[BT] jump to 0x0801D000如果这条日志之后没有任何[APP]输出说明App可能根本没跑起来。这时候再查向量表偏移和链接地址定位很快。如果条件允许在Bootloader里加一个LED指示状态也是个好习惯。上电亮一下表示Bootloader正常进入OTA模式闪烁跳转前熄灭。这在大批量设备现场排查时比接串口方便得多。6.3 这个方案还能怎么扩展AB OTA这套框架定型后扩展方向很多都是我在实际项目中接到过类似需求的固件加密签名目前协议里只有CRC32校验防误码不防篡改。如果设备暴露在不可信环境可以在固件包里加一段签名比如AES-GCM标签或RSA签名Bootloader在切换分区前验签防止恶意固件或非法固件被刷入。多分区AB两分区是基础版有些产品需要额外分区承载出厂固件、语音包、字库文件。这套思想可以扩展成ABC甚至更多分区只要状态机设计得当。从Modbus RTU换成其他链路有些设备走CANopen或者以太网Modbus TCP传输协议的替换不会影响AB分区的核心逻辑Bootloader和App之间还是同样的Flash状态机。上报升级进度到云端AB OTA的上位机如果对接云端每包数据上报进度就能实现生产环境大批量设备分批升级。结尾最后聊聊我个人的体会。做OTA方案很多人一上来就急着写代码、组帧、烧板子真正把分区和状态机想清楚的不多。我在反复迭代这套AB OTA架构之后最大的心得是通讯协议可以换波特率可以提高Flash驱动可以优化但分区规划和回滚状态机是一开始就必须想明白的地基。地基歪了后面再漂亮的应用代码也站不稳。如果你打算在自己的项目里落地这套方案建议先不要急着做上位机而是先手工模拟整个流程用串口助手手动发几条指令观察Bootloader怎么擦除、怎么接收、怎么跳转把每个环节都吃透再写自动化脚本。这个过程会帮你建立对系统行为最直观的感觉。还有一个小技巧调试时可以做一个强制进入Bootloader的按键逻辑上电时检测某个按键按下就跳过App启动直接进入OTA模式。这在开发调试阶段非常救命哪怕App被刷坏了你也能通过按键进入Bootloader重新烧写固件。等整套流程稳了再把按键逻辑换成纯粹的远程触发也不迟。