ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32F103 AB双分区串口OTA方案全记录:从Bootloader到上位机

STM32F103 AB双分区串口OTA方案全记录:从Bootloader到上位机 STM32F103的AB双分区OTA从零复现全记录做过单片机量产项目的朋友应该都有过这种经历产品已经铺出去了结果现场发现一个bug或者客户提了个新需求这时候要么派人带着烧录器跑现场要么让用户寄回来返工又慢又费钱。如果产品本身有联网能力还好说但很多工业设备、仪器仪表走的还是RS232/RS485这种串口总线压根没有以太网口。这种情况下一套可靠稳定的串口OTA方案就显得特别关键。这篇文章我打算完整复盘一个基于STM32F103的AB双分区OTA方案从Bootloader设计、App端改造、Modbus RTU分包协议到上位机升级工具的整个链路全部走一遍。整个方案我实测跑通了用的是标准外设库StdPeriph Lib V3.5 FreeModbus V1.6传输层走RS232串口协议层是Modbus RTU固件包做了A/B双分区冗余设计升级过程中即使断电或者传输错误设备也还能回滚到旧版本继续跑不会变砖。这篇文章适合正在做单片机Bootloader、或者准备给自己的串口设备加OTA能力的朋友参考内容偏实战我会把关键代码思路、Flash分区规划、协议帧格式、校验策略这些都说清楚。1. 整体方案设计与分区规划1.1 为什么选AB双分区而不是单分区升级先聊一个基础问题OTA升级最怕什么最怕设备在擦写Flash的过程中断电、串口断线、或者写入的数据有问题导致App区被写坏设备起不来。单分区方案下Bootloader直接擦除原App区域然后写入新固件整个过程非常脆弱——任何一步出错设备就变成一块砖头只能靠烧录器救援。AB双分区方案的核心思路是Flash里同时保留两个完整的App区域分别称为A区和B区。当前运行的是A区固件升级时把新固件写入B区写入完成并校验通过后再切换启动标志让Bootloader下次启动时引导B区。如果新固件运行异常可以再次切换回A区。说得直白点AB方案相当于给固件升级上了一道保险你永远有一个已知能跑的旧版本垫底新版本不行就回滚。代价是Flash占用翻倍但STM32F103ZET6有512KB Flash一个常规的App固件通常只有几十KB翻倍之后也完全够用。1.2 三个核心组件划分整个系统拆成三个独立的固件工程Bootloader位于Flash起始地址负责上电时的启动判断决定跑A区还是B区接收升级指令和固件数据包写入目标分区以及最后的校验和启动切换。Bootloader本身要尽量精简功能越少出问题的概率越低。App A位于分区A设备的正式应用程序包含业务逻辑、通信协议等。启动时向Bootloader上报自身版本和运行状态用于回滚判断。App B位于分区B与新固件相同结构的应用副本平时不运行只在Bootloader的引导下作为新版本启动。注意一点App A和App B的代码逻辑完全一样只是编译时的链接地址不同。所以工程里需要一个宏来控制链接脚本的Flash起始地址和中断向量偏移。1.3 Flash分区地址规划以STM32F103ZET6512KB Flash为例这是我的实际分区分区起始地址大小说明Bootloader0x0800000032KB启动引导 升级接收端App A0x08008000224KB运行中的主程序App B0x080040000224KB待升级/备用的主程序参数区0x0807F8002KB启动标志、版本号、升级状态分区大小不是随便定的。Bootloader其实用不了32KB我预留这么大是为了后续扩展方便App区224KB对于绝大多数应用来说绰绰有余参数区用2KB是因为我按Flash页大小2KB对齐。提示分区起始地址必须是Flash页大小的整数倍这是擦除操作的基本要求。STM32F103的Flash页大小是2KB中高容量型号规划地址时务必对齐。还有一些隐藏细节容易踩坑中断向量表的位置、IAP跳转前的栈顶指针设置、编译链接脚本的修改这些后面细讲。2. 升级协议设计Modbus RTU如何承载固件数据2.1 为什么选Modbus RTU作为传输协议有人可能觉得OTA传输协议自己随便定义一套就行何必套Modbus我选Modbus RTU有四个原因第一FreeModbus是开源的移植成本极低在STM32F103标准库环境下基本是现成的第二Modbus RTU自带CRC16校验可以复用它的校验帧减少自己造轮子出bug的概率第三很多工业设备本身就跑了Modbus协议栈在原有基础上扩展升级功能不用改底层通信逻辑第四Modbus RTU的报文结构清晰功能码可以灵活扩展天然适合做分包传输。2.2 自定义功能码设计FreeModbus默认支持的功能码有限好在它提供了钩子函数eMBFuncCallBack可以拦截未定义的功能码做自定义处理。我规划了三个自定义功能码功能码寄存器/数据区方向含义0x50子功能码 0x01上位机 → 设备查询升级状态和当前版本0x51子功能码 0x02上位机 → 设备开始升级携带固件长度、CRC32、目标分区0x52子功能码 0x03上位机 → 设备传输固件数据块携带偏移地址和内容0x53子功能码 0x04上位机 → 设备结束升级并触发校验启动每个请求帧都严格遵循Modbus RTU的帧格式从机地址 功能码 子功能码 数据区 CRC16。FreeModbus在收到完整且CRC校验通过的帧后会自动调用对应的回调函数这个机制帮我省了不少事。2.3 单包大小与效率的权衡固件包通常有几十KB而RS232串口一帧数据如果太长出错概率会上升接收缓冲区压力也大。我最终把单包数据区大小定为240字节加上帧头、地址、功能码、CRC等额外字段一帧总长度在255字节以内刚好落在Modbus RTU常见的256字节缓冲区能力范围内。为什么不是512或者1024一方面Bootloader里的RAM有限我给它分配的接收缓冲区只有1KB太大的包容易溢出另一方面串口波特率决定了传输时间240字节一包在115200波特率下耗时约20多毫秒配合等待应答机制升级100KB固件大约需要50秒左右这个时间完全可接受。传输流程是这样的上位机发送查询帧0x50确认设备在线且处于可升级状态上位机发送开始升级帧0x51携带固件总长度和CRC32校验值Bootloader收到后擦除目标分区擦除Flash比较耗时必须先做上位机循环发送数据帧0x52每帧240字节设备收到后写入Flash并回执当前帧号所有帧发送完上位机发送结束帧0x53Bootloader对整包数据进行 CRC32 重校验通过后写启动标志并软复位2.4 帧序号与重传机制某帧数据丢失或者CRC错误怎么办我采用了“连续重传超时重置”的简单策略每帧数据里带一个2字节的帧序号0开始递增设备收到一帧数据后先比较帧序号是否等于“当前期望的序号”相等则写入Flash并回执ACK期望序号1不相等则回执NACK并带上自己的当前期望序号上位机收到NACK后从期望序号处重新发送不需要整体重传如果超过3秒没有收到任何回执上位机判定连接异常并中止升级这东西比TCP简单粗暴但胜在逻辑简单清晰设备端实现起来不容易出错。3. Bootloader实现细节从Flash分区到跳转3.1 Bootloader的启动流程Bootloader的main函数其实非常短逻辑核心就是一个状态机int main(void) { // 初始化时钟、串口、Flash、Modbus SystemInit(); UART_Init(); Flash_Init(); eMBInit(MB_RTU, 0x01, 1, 115200, MB_PAR_NONE); eMBEnable(); // 读取启动标志和版本状态 stBootParam_t bootParam Flash_ReadBootParam(); uint8_t appStatus bootParam.lastAppStatus; // 判断是否需要进入升级模式 if (bootParam.upgradeFlag UPGRADE_FLAG_START) { // 进入升级模式等待上位机下发固件 EnterUpgradeMode(); } else { // 正常启动根据目标分区选择跳转地址 uint32_t appAddr (bootParam.bootTarget BOOT_FROM_A) ? APP_A_ADDR : APP_B_ADDR; JumpToApp(appAddr); } }启动标志默认是“从A区启动”。当一次升级流程成功完成后Bootloader会把启动标志切换成新写入的那个分区。如果新应用运行后检测到异常比如看门狗复位会在参数区把lastAppStatus写成“运行失败”下次启动时Bootloader就会自动切回上一个分区。3.2 跳转App前的关键动作JumpToApp这个函数看起来简单但里面有几个坑void JumpToApp(uint32_t appAddr) { // 检查栈顶地址是否合法RAM范围内 uint32_t appStackTop *(volatile uint32_t *)appAddr; if ((appStackTop 0xFFF00000) ! 0x20000000) { Error_Handler(); // 栈顶不合法说明App区没有有效固件 return; } // 关闭全局中断防止跳转过程中断 __disable_irq(); // 关闭所有外设时钟和中断 RCC_DeInit(); SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; // 设置主栈指针 __set_MSP(appStackTop); // 跳转到App的复位向量 typedef void (*pFunction)(void); pFunction jumpToApp (pFunction)(*(volatile uint32_t *)(appAddr 4)); jumpToApp(); }跳转前必须要做两件事关全局中断和反初始化外设。我一开始只关了中断没做RCC_DeInit结果App启动后串口数据错乱查了半天才发现是外设时钟状态没有复位干净导致外设寄存器状态混乱。3.3 Bootloader中Modbus栈的移植FreeModbus在STM32F103标准库上的移植网上教程很多我简单说一下关键点需要实现串口收发函数xMBPortSerialInit、xMBPortSerialPutByte、xMBPortSerialGetByte需要实现定时器函数xMBPortTimersInit、vMBPortTimersEnable、vMBPortTimersDisable用于产生3.5字符时间的帧间隔中断串口接收中断里每收到一个字节要调用pxMBFrameCBByteReceived()发送完成的回调里要调用pxMBFrameCBTransmitterEmpty()定时器溢出中断里要调用pxMBPortCBTimerExpired()FreeModbus的V1.6版本对应标准库V3.5是比较顺的因为两者都是老的、稳定的代码兼容性没问题。如果你的工程用的HAL库移植逻辑也差不多只是串口和定时器的初始化代码不同。我在Bootloader里没有开操作系统所有逻辑都是裸机跑Modbus的事件轮询通过eMBPoll()放在while循环里。升级过程中串口接收中断会源源不断地把数据写入Modbus缓冲区主循环里的eMBPoll会解析出完整帧并调用回调。这里有个性能注意点在回调里写Flash是阻塞操作擦写一页2KB数据大约需要20~40ms这个时间内不能响应下一个Modbus请求所以上位机那边对超时时间要留足够余量。3.4 Flash写入的细节处理Bootloader最核心的硬件操作就是写Flash。STM32F103的Flash写入有几个限制写入前必须先擦除整页2KB粒度写入是16位半字对齐的不能按字节写擦写过程中不能读Flash会卡总线擦写操作会自动暂停CPU所以不需要特殊处理并发问题我封装了三个基础函数void Flash_ErasePage(uint32_t addr); void Flash_WriteHalfWord(uint32_t addr, uint16_t data); void Flash_ReadData(uint32_t addr, uint8_t *buf, uint32_t len);写入固件数据时上位机下发的240字节数据我先放入RAM缓冲再以半字为单位循环写入目标Flash地址。这里要特别注意对齐每次写入的起始地址必须是2的倍数我约定所有数据帧的长度都是偶数240正好是偶数避免出现半字不对齐的情况。还有一个关键点在Bootloader里操作Flash时需要先解锁Flash擦写完成后重新上锁。用标准库就是FLASH_Unlock()和FLASH_Lock()这个不要省。4. App端改造中断向量偏移与应用状态上报4.1 中断向量表偏移App工程不能直接拿原来的程序改个地址就用。STM32F103的中断向量表默认放在Flash起始地址0x08000000如果App链接到0x08008000那么芯片一旦产生中断去0x08000000取中断服务函数地址取到的是Bootloader的向量表这就出大事了。解决方法是设置向量表偏移寄存器VTOR。STM32F103没有VTOR寄存器这是Cortex-M4才有的需要采用另一种做法在跳转之前把App的中断向量表复制到RAM然后把RAM的起始地址映射到0x00000000。但更简单的做法是利用NVIC_SetVectorTable标准库里其实也支持一定程度的偏移NVIC_SetVectorTable(NVIC_VectTab_RAM, APP_A_ADDR);这个宏会配置SCB-VTOR寄存器。等等我确认一下——实际上STM32F103的SCB-VTOR是存在的在Cortex-M3内核上VTOR是可选实现STM32F103是支持VTOR的所以可以直接写SCB-VTOR APP_A_ADDR;App编译时链接脚本需要把Flash起始地址改为APP_A_ADDR0x08008000RAM起始地址保持不变0x20000000。在Keil里就是修改Target选项卡的IROM1起始地址和大小。4.2 编译级宏区分AB区我用了两种不同的宏定义来编译A区和B区固件#if defined(BUILD_FOR_APP_A) #define APP_FLASH_ADDR 0x08008000 #elif defined(BUILD_FOR_APP_B) #define APP_FLASH_ADDR 0x08040000 #else #error 请定义BUILD_FOR_APP_A或BUILD_FOR_APP_B #endif #define APP_VERSION_MAJOR 1 #define APP_VERSION_MINOR 0 #define APP_VERSION_PATCH 1同时Keil的IROM1地址也需要跟着宏切换。实际操作中我维护了两个Keil工程一个编A区版本一个编B区版本避免来回改工程配置导致地址出错。也可以用命令行编译脚本但维护两个工程最直观不容易犯低级错误。4.3 App如何上报状态并实现自动回滚App启动后第一件事不是跑业务逻辑而是先做“健康自检”并上报给Bootloaderint main(void) { SCB-VTOR APP_FLASH_ADDR; // 延时等待电源稳定 Delay_Init(); Delay_Ms(50); // 初始化看门狗 IWDG_Init(4000); // 4秒超时 // 告诉Bootloader我正在启动 BootParam_WriteLastAppStatus(APP_STATUS_BOOTING); // 初始化外设 UART_Init(); eMBInit(MB_RTU, 0x01, 1, 115200, MB_PAR_NONE); eMBEnable(); // 业务初始化... if (Business_Init() HAL_OK) { // 运行状态告诉Bootloader当前App运行正常 BootParam_WriteLastAppStatus(APP_STATUS_RUNNING); MainLoop(); } else { // 业务初始化失败触发看门狗复位 BootParam_WriteLastAppStatus(APP_STATUS_FAULT); while(1); } }看门狗的作用在这里非常关键。App启动后如果跑飞或者卡死看门狗超时后自动复位Bootloader检查lastAppStatus如果是“启动中”或“运行失败”就会自动切换到另一个分区并且把启动标志改回旧分区。这里有个trade-off状态写入不能太频繁Flash有擦写寿命STM32F103一般是1万次频繁写参数区会缩短寿命。我的策略是只在启动时写一次“启动中”运行正常后写一次“运行中”平时运行过程中不写。这样即使每天重启10次一年也就写3650次还在寿命范围内。4.4 版本号与固件头为了让Bootloader和上位机都能识别固件信息我在App固件里加入了一个自定义固件头放在App区的起始位置typedef struct { uint32_t magic; // 固定魔数 0xA5A5A5A5 uint32_t appVersion; // 版本号如 0x00010001 uint32_t appLength; // 固件实际长度 uint32_t crc32; // 整个固件的CRC32 uint8_t reserved[16]; } firmware_header_t;编译时可以通过某种方式自动填充这些字段但比较省事的方法是用一个上位机工具在生成bin文件后自动附加、修改头部信息。我写了个Python小脚本读取编译输出的bin文件计算出CRC32后在bin文件头部填充固件头然后输出最终可升级的固件包。Bootloader在开始升级前会先解析新固件的头部检查魔数、版本号是否合法。版本号大于当前运行版本才允许升级低于或等于当前版本的直接拒绝。5. 上位机“OTA提取器”与升级流程工具链5.1 为什么需要单独的OTA提取器工具有些人可能觉得上位机直接读bin文件发送不就行了哪有那么麻烦。但实际生产中bin文件往往是某个完整项目编译出来的它的内容可能比你想象中复杂包含多个段、填充数据、甚至可能带有调试信息。如果直接发送原始bin文件Bootloader侧没法判断固件是否完整、是否被篡改。所以我做了一个独立的“OTA提取器”小工具它的职责是把编译好的bin文件预处理成适合传输的、带校验信息的固件包。这个工具本质上就是给bin文件加上固件头计算CRC32再按240字节一帧拆成帧序列方便上位机发送端直接遍历发送。提取器我顺手实现了命令行版用Python写的逻辑很直接import struct import zlib import sys import os def build_firmware_package(bin_file, out_file, version): with open(bin_file, rb) as f: data f.read() crc32 zlib.crc32(data) 0xFFFFFFFF length len(data) magic 0xA5A5A5A5 header struct.pack(IIII, magic, version, length, crc32) header b\x00 * 16 with open(out_file, wb) as f: f.write(header) f.write(data) print(f固件包生成完成: {out_file}) print(f固件大小: {length} bytes) print(fCRC32: 0x{crc32:08X})这里有个细节CRC32计算的是原始bin文件的数据不包括固件头。这样设计的好处是Bootloader写完固件后做CRC32校验时直接从App起始地址头部大小处开始算简单明了。5.2 Python上位机发送端实现发送端我用Python写了一个串口工具配合pyserial库。核心逻辑是一个逐帧发送状态机import serial import struct import time import zlib class OTAUpdater: def __init__(self, port, baud115200, slave_addr0x01): self.ser serial.Serial(port, baud, timeout1) self.slave_addr slave_addr def modbus_frame(self, func_code, sub_func, data): frame bytearray() frame.append(self.slave_addr) frame.append(func_code) frame.append(sub_func) frame.extend(data) crc self.calc_crc16(frame) frame.extend(crc) return bytes(frame) def calc_crc16(self, data): crc 0xFFFF for byte in data: crc ^ byte for _ in range(8): if crc 0x0001: crc (crc 1) ^ 0xA001 else: crc 1 return struct.pack(H, crc) def start_upgrade(self, fw_file, target_partB): # 读取固件包 with open(fw_file, rb) as f: fw_data f.read() # 解析固件头 magic, version, length, crc32 struct.unpack_from(IIII, fw_data, 0) # 发送开始升级请求 target 0x01 if target_part A else 0x02 payload struct.pack(HHI, target, length, crc32) frame self.modbus_frame(0x51, 0x02, payload) self.ser.write(frame) resp self.ser.read(8) if resp[2] ! 0x06: raise Exception(设备拒绝开始升级) # 分包发送固件数据 app_data fw_data[32:] # 跳过固件头 seq 0 offset 0 chunk_size 240 while offset len(app_data): chunk app_data[offset:offsetchunk_size] if len(chunk) % 2 ! 0: chunk b\x00 # 半字对齐 payload struct.pack(H, seq) struct.pack(H, len(chunk)) chunk frame self.modbus_frame(0x52, 0x03, payload) self.ser.write(frame) # 等待ACK ack self.ser.read(8) # 解析ACK如果NACK则重发 offset chunk_size seq 1 # 发送结束帧 frame self.modbus_frame(0x53, 0x04, b) self.ser.write(frame) resp self.ser.read(8) # 解析最终结果...发送端还有个隐藏问题要处理写完一帧Flash需要时间如果上一帧的擦写还没完成下一帧就到了缓冲区一满就丢数据。所以我每帧发完后等ACK再发下一帧ACK里带的就是当前已经成功写入的帧序号。这个机制可能牺牲了一点速度但对可靠性帮助巨大。5.3 Nginx在OTA场景里的角色你可能会问Nginx跟STM32的串口OTA有什么关系其实这是我做的一个扩展功能当设备数量多、需要批量升级时不会一个个人工点发送而是把固件包放到内网或者云端的Nginx服务器上上位机工具先从Nginx拉取固件包再逐个设备下发。Nginx在这里扮演的是“固件仓库”的角色配置超级简单就是开放一个静态目录server { listen 8888; server_name _; root /var/www/firmware; autoindex on; location /fw/ { alias /var/www/firmware/; types { application/octet-stream bin; } } }配合Python的requests库上位机先GET http://nginx-server:8888/fw/app_v1.0.1.bin拉取固件再通过串口发送给设备。这个链路做完批量升级的工作量就从“一台台手动烧录”变成了“一键批量推送”。如果设备本身有网络接口甚至可以直接让设备端内置HTTP Client去Nginx拉固件——但对纯串口设备上位机中转还是最稳妥的方案。5.4 100%和100的区别有个小细节我在搜索资料的时候看到很多人问“100%%和100%的区别”这个在OTA场景里其实也有对应情况。比如升级进度显示如果你在格式化字符串里写进度%d%%传参100显示出来是“进度100%”但如果你写进度%d%%%传参100就会显示“进度100%%”因为最后一个%被当成了转义符。这种进度显示bug在OTA工具里很容易出现排查起来还挺隐蔽。还有一个更现实的坑在Modbus RTU报文里如果数据区恰好包含了0x3A、0x0D、0x0A这些在ASCII模式下具有特殊含义的字符在ASCII传输模式下会被错误解析。我一开始用ASCII模式踩过这个坑后来改成RTU模式彻底解决。所以这里顺便提醒一下串口OTA的传输模式坚决用RTU不要用ASCII省得数据里某个字节恰好撞上控制字符导致分包错乱。6. 升级流程演示与实测数据6.1 一次完整升级的操作流程我把整个流程跑了一遍记录一下实际操作步骤编译App工程BUILD_FOR_APP_B宏定义生成bin文件运行OTA提取器生成带固件头的升级包app_v1.0.1.bin打开串口工具连接设备RS232接口波特率115200点击“查询升级状态”确认设备返回版本信息App A运行中版本1.0.0点击“开始升级”选择分区B选择固件包app_v1.0.1.bin设备返回确认后自动开始分包发送进度条逐帧增加全部发送完成设备执行CRC32校验写入启动标志软复位Bootloader引导从B区启动App上报版本1.0.1查询状态确认升级成功整个流程大约耗时60秒100KB固件中途我故意拔掉串口线一次重新连接后升级工具自动从断点帧序号继续发送这个容错机制实测有效。6.2 升级过程数据记录我实测记录了一组关键数据项目数值固件大小96,512 字节单帧数据区240 字节总帧数403 帧单帧发送耗时约 22ms 115200bps写Flash耗时每帧约 10~15ms整包升级耗时约 55秒中途断电恢复时间重新上电后 5秒内自动进入升级模式这个速度在RS232场景下非常可以了。如果换成RS485或者更高波特率比如460800还能快不少。6.3 回滚机制实测回滚是AB分区方案的灵魂我专门测试了三种异常场景场景一升级过程中串口拔线再插回升级工具从断点续传最终升级成功。场景二升级过程中直接断电重新上电后Bootloader检测到B区固件数据不完整CRC32校验不通过自动回滚到A区继续运行用户无感知。场景三B区固件CRC32校验通过但App启动后业务初始化失败看门狗复位两次后Bootloader判定App运行异常自动切回A区。这三个场景全部测试通过。最让我放心的是场景二即便升级过程中断电设备下次上电也不会变砖顶多是升级失败旧版本照常跑。6.4 一个容易忽略的细节Bootloader与App的栈指针跳转之前那个栈顶地址检查非常关键。如果不做检查一旦App区是空的或者数据损坏读取到的“栈顶地址”可能是一个乱七八糟的值执行__set_MSP后系统直接HardFault连错误处理的机会都没有。加一行检查成本极低if ((appStackTop 0xFFF00000) ! 0x20000000) { // 栈顶不在RAM范围内跳到错误处理 Error_Handler(); }这个检查在AB分区方案里尤其重要因为AB模式下App区未必始终有固件比如刚出厂时B区可能没烧录Bootloader必须能优雅处理“分区空”的情况。7. 常见问题与排查技巧实录7.1 升级后设备完全没反应最典型的现象就是升级过程中一切都正常但重启后设备像死了一样串口无任何输出。排查思路第一条先检查是不是中断向量表没设置。App里一定要在main函数最前面加SCB-VTOR APP_FLASH_ADDR;而且要确保这一行在任何中断使能之前执行。第二条检查App工程的链接地址是否和Bootloader分区地址一致。如果链接地址是0x08000000而Bootloader把运行入口指向0x08008000代码跑起来必定炸。这个错误我犯过一次编译时没问题跑起来找不到任何逻辑错误最后用map文件对比才定位到。第三条确认跳转前是否完整关闭了外设中断。如果跳转时某个外设中断还在挂着进入App后中断一来向量表切换还没来得及完成系统就乱了。7.2 升级过程中频繁丢帧丢帧在串口OTA里很常见可能的原因包括波特率太高线材质量差信号边缘太差。长距离或非屏蔽线缆下115200以上波特率容易出问题Bootloader里写Flash时如果没关串口中断写入Flash期间串口照常接收缓冲区可能会满。我一开始没注意这个后来把接收中断优先级调高并且用DMA接收丢帧问题就少了很多上位机发送节奏太快设备端来不及处理。每帧发完等ACK再发下一帧这个小改动比任何优化都有效7.3 CRC校验总是失败如果整包升级完成后CRC32校验一直失败先确认提取器和设备端使用的CRC算法是否一致。CRC32有好多变体IEEE、MPEG-2、C等两边的初始值、多项式、输入输出反转必须完全一致。我踩过一个坑Python zlib库的CRC32是标准CRC32多项式0x04C11DB7初始值0xFFFFFFFF结果取反而设备端如果用的软件CRC32是另外一套参数两边算出来的值对不上。后来我统一用zlib的参数在C代码里实现了CRC32才彻底解决。7.4 升级完成后A/B版本号不变这个问题的原因通常是App里上报的版本号写死了没有放在Flash的固定偏移位置。理想做法是App启动后从自身的固件头分区起始地址偏移读取版本号而不是用编译期的宏。否则即使App代码更新了版本号还是编译时写死的旧值。7.5 设备在升级模式下看门狗复位这个问题比较隐蔽。Bootloader如果在升级过程中开了看门狗而升级流程耗时较长例如60秒看门狗在升级中途超时复位导致升级中断。解决办法是进入升级模式后立刻关闭看门狗或者在看门狗超时前周期性喂狗。我的做法是Bootloader里默认不开看门狗只有在正常启动App时才初始化看门狗。升级模式的超时保护用一个软件定时器实现超过120秒没有收到新帧就自动复位退出升级模式。7.6 常见问题速查表问题现象可能原因解决方案跳转后死机中断向量表未偏移App启动时设置SCB-VTOR跳转后死机链接地址与分区不匹配检查Keil IROM1地址串口收发异常外设未反初始化跳转前执行RCC_DeInit升级中途丢帧写Flash期间缓冲区溢出使用DMA接收 减少发送速率CRC校验失败CRC算法参数不一致统一算法参数版本号不变版本号写死未从固件头读取从固件头动态读取版本升级中被看门狗复位看门狗未关闭升级模式关闭/周期喂狗B区升级失败后无法启动B区无有效固件Bootloader增加栈顶合法性检查8. 关于整个方案的一些体会整套系统从零开始搭前后花了我大概三周业余时间复现难度最大的其实不是Flash操作、不是Modbus移植而是把各个模块之间的时序和状态机理清楚。Bootloader的时序是串行依赖的查询状态、开始升级、擦除Flash、接收数据、写入Flash、校验切换每一步都可能出错每一步都要有对应的处理分支逻辑看似简单但一旦某个分支没写全实际运行时很容易出现“卡住不动”或者“莫名其妙重启”的情况。我个人做完这个项目后的最大体会是AB双分区虽然多占了一倍Flash但它带来的安全感是单分区方案给不了的。产品在用户手里升级你不可能控制电网的稳定性、不知道用户什么时候会拔电AB方案确保任何时刻都有可回退的版本这种设计层面的冗余比任何代码层面的健壮性都更有价值。还有一个想分享的小技巧如果你手头暂时没有现成的串口升级工具可以直接用Modbus Poll这种通用调试工具配合脚本做测试但正式批量升级还是建议自己写一个带进度显示、断点续传和日志记录的小工具因为调试工具没有异常处理逻辑一旦断线只能重头再来。这套方案的基础是STM32F103 标准库 FreeModbus但设计思路完全可以平移到其他MCU平台比如GD32、AT32、或者STM32L4系列甚至换成RS485总线也一样能用Modbus RTU本身就是为RS485而生的。希望这篇记录对你自己的OTA方案设计有点启发。
返回列表