STM32F103 AB OTA升级方案详解:从Bootloader到固件回滚

发布时间:2026/9/9 3:06:08

STM32F103 AB OTA升级方案详解:从Bootloader到固件回滚 1. 从零复现STM32F103 AB OTA先搞清楚这套方案到底在解决什么问题做嵌入式固件开发的朋友应该都有过这种经历产品已经批量出货了结果发现固件有个小bug或者客户提了新需求这时候如果不能远程升级就只能让售后一台台拆机烧录。那个场面经历过的人都懂。OTAOver-The-Air升级就是为了解决这个问题而AB分区方案则是OTA升级里最稳妥、最不容易把自己玩死的一种策略。我最早接触STM32F103的OTA是在三年前当时项目要求做串口升级后来客户又提出要通过局域网远程升级而且强调升级过程中断电不能变砖。这一套需求催着我从零把AB OTA方案完整撸了一遍。做完了才发现网上关于这个主题的资料其实零零散散很多但要么只讲了Bootloader怎么做跳转要么只讲了App端怎么接收数据很少有人把整条链路——分区划分、Bootloader设计、App配合、固件生成、远程传输、失败回滚——掰开揉碎讲清楚。这篇文章我就从我的实际复现过程出发把STM32F103上AB OTA的完整方案拆开讲一遍。文章适合两类人一类是做产品开发、需要给设备加远程升级能力的嵌入式工程师另一类是正在学习STM32、想理解Bootloader和应用升级原理的学生。你不需要有很深的经验但至少要写过STM32的标准库或者HAL库基础程序知道Flash读写、中断向量表这些基本概念。我会把每一步的操作细节和踩过的坑都写出来你照着走基本能复现一套可用的AB OTA方案。先说清楚这套方案的整体面貌。AB OTA也叫双分区升级、A/B Slot方案核心思路非常朴素在Flash里放两份固件一份是当前正在运行的固件称为A分区另一份是待激活的固件称为B分区。升级的时候Bootloader把新固件写入B分区写入完成后做校验校验通过就切换启动标志下次启动直接从B分区加载如果中途断电、写入失败或者新固件跑不起来Bootloader会自动回退到A分区。你想想这有点像手机系统的无缝升级机制苹果、安卓实现系统更新用的也是类似原理只不过手机的实现更复杂但核心思路一脉相承。那为什么要在STM32F103这种Flash只有512KB大容量型号的MCU上做双分区因为很多物联网设备对可靠性要求很高表计类设备装在现场可能三年不派人维护工业控制器坏了停机就是损失。这时候AB分区方案的自动回滚特性就显得格外珍贵。相比单分区方案——就是Bootloader接收完数据直接覆盖App区的那种做法——AB分区最大的优势是升级失败不会导致设备变砖总能回到上一个能用的版本。接下来说一点我的个人经验。如果你只是做个学习Demo用串口把固件传给Bootloader烧进B分区然后跳转运行那整个链路其实不算复杂。真正麻烦的是生产环境的细节固件如何打包怎么在传输过程中保证数据完整B分区启动后怎么确认它真的正常运行如果B分区程序跑飞了怎么办这些细节如果不提前设计好后面会踩很多坑。2. 方案选型与分区规划为什么我最后选了这套配置2.1 三种主流OTA方案对比AB分区赢在哪里在做方案选型之前我花了些时间对比了目前MCU上常见的几类OTA实现思路。这里直接上对比表方便你一眼看清差异。方案类型实现复杂度升级安全性典型应用场景单分区覆盖低Bootloader接收完数据直接擦除App区写入低中途断电/写入错误直接变砖学习Demo、对可靠性要求极低的场合双Bank方案类似AB中需要两份固件区和切换逻辑高升级失败可回滚主流产品级OTA方案外部存储引导方案中高固件先存外部Flash/SDBootloader校验后搬运高但需要额外硬件Flash容量紧张的设备我最终选择了AB双分区方案最核心的考量就是安全。STM32F103系列虽然有512KB的FlashF103ZET6但说实话现在随便一个带网络协议的固件动辄就一两百KB再塞下两份完整固件确实有点紧张。不过对于大多数物联网终端来说固件控制在128KB以内是常态这个容量做AB分区完全可行。如果你的固件超过300KB就不太建议在F103上做AB分区了更合理的做法是结合外部串行Flash做压缩包升级那是另一个话题后面有机会再展开。另外还有一点促使我选择AB分区STM32F103的Flash支持在应用运行时进行读写操作不需要额外等待而且它有足够数量的扇区可以灵活配置。这让分区规划变得很容易加上标准库对Flash操作的封装很成熟整个方案的工程量是可控的。2.2 F103的Flash结构梳理512KB到底怎么分在划分分区之前必须先搞清楚STM32F103ZET6的Flash结构。别嫌这一步啰嗦我见过太多人上来就写代码结果Flash扇区没对齐写了几次就出怪问题。F103ZET6的主Flash空间从0x08000000开始一共512KB。其中前面4个扇区每个16KB然后是1个64KB的扇区最后是7个128KB的扇区总数是12个扇区。这个扇区大小不均匀的布局在做分区边界对齐的时候非常关键。Flash的最小擦除单位是扇区有的型号是页写入最小单位是半字16位。换句话说你没法单独擦除某个字节只能整个扇区擦除。这就意味着分区划分必须按扇区边界来而且App程序的大小最好也不要跨扇区边界规划否则会给自己找麻烦。基于这个结构我把整个512KB Flash做了如下划分分区名称起始地址大小作用Bootloader区0x0800000032KB2个16KB扇区存放Bootloader程序上电后首先运行App A区0x08008000192KB存放正常运行的应用固件AApp B区0x08039800192KB存放升级用应用固件B参数/标志区0x0806F8008KB存放升级标志、版本号、启动计数等我们来核算一下这个分区。Bootloader用32KB对于一段负责接收固件、写Flash、跳转的程序来说32KB绰绰有余。App A和App B各自192KB我的实际应用固件编译出来大概70KB左右留了充足的余量方便后续功能迭代。参数区8KB用于存放升级标志、运行状态、版本号等关键信息通过独立扇区避免和程序代码互相干扰。三块加起来正好512KB一分不多一分不少。这里有个经验分享一下分区规划时建议在你预估固件大小基础上再预留50%以上余量。因为在开发周期里功能会不断增加如果当初卡得刚刚好后面扩展代码的时候会因为分区满而痛苦不堪。2.3 版本管理的双区切换逻辑到底什么时候该切槽位分区划好了下一步就是理清AB槽位怎么切换。不夸张地说这里是整套方案的思想核心。常规思路其实很简单。Bootloader启动后第一步检查参数区里的标志和状态。如果标志显示当前槽位为ABootloader就跳转到A分区启动如果标志显示当前槽位为B就跳转到B分区启动。升级的时候流程又不同了假设当前在A分区运行Bootloader把新固件写入B分区写完以后不立刻切换标志而是先把B分区里的固件做一次完整性校验通过后再把参数区的当前槽位改为B然后软件复位重启。Bootloader检测到当前槽位已经变成B就启动B分区的固件。关键是B分区的固件跑起来之后并不是马上就万事大吉。这里需要一个确认机制应用启动后如果在规定时间内比如5秒没有系统级异常就主动往参数区写入一个应用运行正常的状态位。这个机制是为了防止新固件虽然校验通过了但跑起来就死机、看门狗频繁复位的情况。如果B固件启动后一直不写正常状态位Bootloader侧可以通过启动计数器判断连续N次启动都没有确认就自动把当前槽位切回A并标记B固件为不可用实现自动回滚。这套逻辑的本质是把写入成功和运行成功两件事分开来看。我只是把新固件写进了B区那不代表它能正常工作必须给它一个自证清白的机会。这个思路看着简单但很多单分区方案根本没法做到所以AB分区的价值才显得这么突出。3. 从零写Bootloader跳转、标志管理、固件接收3.1 Bootloader的整体代码框架和启动流程Bootloader是整套AB OTA的地基。它要做的事情不多但每一件都容不得差错。按照我的设计Bootloader的启动流程如下MCU上电 - 初始化时钟和串口 - 读取参数区的标志和状态 - 判断是否需要进入升级模式 - 如果需要升级则执行固件接收和烧写 - 如果不需要则直接跳转对应槽位。这里有个关键点要提醒大家Bootloader的代码中断向量表是在链接阶段就固定到0x08000000开头的MCU上电后会从这里取第一条指令。而App程序的代码是要跑在别的地址的所以App的链接地址也必须跟着变否则就算Bootloader成功跳转过去App跑起来也是一堆异常。具体的向量表偏移处理我后面会专门讲这里先心里有数。Bootloader的main函数逻辑大致如下代码可以跟着我的思路写核心是状态判断int main(void) { // 初始化时钟、串口、LED等 SystemInit(); UART_Init(115200); LED_Init(); // 检查是否有升级请求标志按键、串口命令、参数区标志均可触发 if (Check_Update_Request() UPDATE_REQUESTED) { // 进入升级模式接收新固件并写入另一个槽位 Perform_Update(); } // 选择要启动的槽位并跳转 uint8_t active_slot Get_Active_Slot(); if (active_slot SLOT_A) { Jump_To_App(APP_A_ADDR); } else if (active_slot SLOT_B) { Jump_To_App(APP_B_ADDR); } else { // 标志异常默认跳转A分区并清空异常状态 Set_Active_Slot(SLOT_A); Jump_To_App(APP_A_ADDR); } while (1); }我建议Bootloader的串口初始化用最朴素的轮询方式收发不要上中断也不要用RTOS。原因很简单Bootloader的生命周期非常短暂它只在两种场景下工作——上电后需要升级时以及正常启动准备跳转App时。给它引入复杂的中断机制和操作系统反而会带来不确定性。串口升级的时候数据是逐个字节到达的Bootloader接收完一包处理一包完全够用。3.2 向量表偏移与链接脚本设置App能跑起来的先决条件如果你的App程序编译出来是默认的0x08000000起始地址那你直接跳转到App区边界是跑不起来的。因为App的中断向量表会被编译到App的起始地址而系统中断发生后CPU仍然会从0x08000000去取中断向量拿到的其实是Bootloader的向量表。所以App工程必须做两件事第一在链接脚本MDK里的分散加载文件或者IAR的.icf文件里把只读区FLASH的起始地址改成App分区的实际地址我把App A放在0x08008000App B放在0x08039800第二在App的main函数最开始执行向量表重映射。对于STM32F103标准做法是通过设置VTOR寄存器来偏移向量表基地址。我在App工程里加了一段启动代码效果很好// App main函数最开头执行 #define APP_A_BASE_ADDR 0x08008000 #define APP_B_BASE_ADDR 0x08039800 // 根据当前运行地址决定向量表偏移量 void Vector_Table_Relocation(void) { uint32_t app_base 0; // 通过检查PC指针范围判断自己是在A还是B分区运行 uint32_t pc (uint32_t)Vector_Table_Relocation; if (pc APP_B_BASE_ADDR pc APP_B_BASE_ADDR 0x30000) { app_base APP_B_BASE_ADDR; } else { app_base APP_A_BASE_ADDR; } // 配置向量表偏移 SCB-VTOR app_base; }注意F103的向量表偏移量必须是0x200的整数倍。我们App分区起始地址0x08008000是0x200的整数倍所以没问题。另外函数里通过PC指针判断自己在哪个分区运行比在代码里硬编码更灵活这样同一份App固件烧到A区和B区都能正确偏移向量表。这一点对AB分区方案的工程化很有价值固件是完全相同的只是烧写的地址不同。3.3 固件接收协议设计包格式、帧校验、应答与超时重传Bootloader和上位机之间的固件传输必须有一个明确的通信协议。我最初在这块偷过懒直接造轮子做了套十分简单的一次性帧格式结果调试起来苦不堪言。后来我完全参照XModem/YModem的成熟思路重新设计了协议虽然不复杂但很健壮。我用的帧格式是这样的帧头0xAA 0x55 - 帧类型1字节0x00数据帧0x01结束帧- 包序号2字节- 数据长度2字节- 数据区最大256字节- CRC16校验2字节。上位机发一帧Bootloader校验CRC正确后回一个ACK0x06错误就回NAK0x15上位机收到NAK或者超时未收到ACK就重发当前帧。这里我强烈建议不管你是谁固件传输协议里一定要上CRC校验不要用简单的累加和。我在实际测试中遇到过非常隐蔽的串口丢字节问题累加和根本发现不了而CRC16基本能保证捕获所有单比特错误和绝大多数突发错误。STM32标准库里没有现成的CRC软件实现但网上有很多查表法的CRC16-MODBUS代码随手就能集成。另外传输的包序号必须做防重发处理。因为你不能保证每一帧都恰好收到一次可能出现上位机重发某个包、但Bootloader其实已经收到上一包的情况。最简单可靠的做法是Bootloader记录当前已写入的包的序号如果收到序号小于当前序号的包认为是重复包直接回ACK但不再写入Flash如果收到当前序号1的包正常写入如果收到跳跃的序号回NAK请求重发。还有一点要专门提出来Bootloader接收完一整包数据后先不要急着搬运到Flash。正确的顺序应该是先写到RAM里的一个临时缓冲区等缓冲区满了或者收到完整一帧后统一擦除目标扇区然后一次性写入。原因很简单——Flash的擦写次数虽然有上万次寿命但你每次升级都在同一块区域反复擦写如果中途出现问题导致擦写不完全就会留下半擦除状态下次擦除可能会失败。更优雅的做法是边收边写但要注意扇区边界对齐当一个扇区写满后再擦除下一个扇区。这个方法我用了很久稳定得很。3.4 Bootloader跳转App的细节与陷阱跳转是Bootloader最后一步也是最容易出现看起来死了但实际没死的诡异问题的地方。一个标准的跳转代码是这样写的Cortex-M3内核typedef void (*pFunction)(void); void Jump_To_App(uint32_t app_addr) { // 检查栈顶地址是否合法栈顶必须落在RAM范围内 uint32_t msp_value *(volatile uint32_t *)app_addr; if ((msp_value 0xFFF00000) ! 0x20000000) { return; // 栈顶不合法不执行跳转 } // 关闭全局中断防止跳转过程中断响应错乱 __disable_irq(); // 设置主栈指针 __set_MSP(msp_value); // 获取App的复位处理函数地址 pFunction jump_func (pFunction)(*(volatile uint32_t *)(app_addr 4)); // 跳转 jump_func(); // 正常情况下永远不会执行到这里 while(1); }这个函数里有几个值得特别注意的地方。第一个判断栈顶值是否合法这一点太多人忽略。App分区的前4个字节是栈顶指针理论上任何复位后从该地址取值的行为都应该以一个合理的局部变量起始值存在。如果不加判断直接跳转万一App区是空的——比如第一次烧录设备还没来得及烧App——Bootloader就会直接跳到0xFFFFFFFF然后硬件错误死循环表现成刷了Bootloader之后设备死了。加了栈顶检查后这种场景就可以安全地回退可以打印提示信息或者直接停在Bootloader里。第二个值得注意的点跳转前一定要关闭所有中断包括SysTick和PendSV。因为跳转后App的启动代码会重新初始化中断向量表和中断控制器如果在关闭之前还有中断在挂着跳转瞬间可能会产生异常。我还建议跳转前把所有外设复位一遍比如串口、DMA、定时器都调到默认状态避免App启动时某些外设寄存器处于一个奇怪的状态。用RCC_DeInit()就够了。第三个细节稍微冷门一点但非常重要——跳转前把SysTick中断关掉。很多人在Bootloader里用了延时函数延时函数依赖的是SysTick如果你跳转时不关闭SysTick它可能会在跳转过程中再次触发中断而此时SysTick的时钟源、优先级配置已经被App侧重新设置了两边一配合就乱套。实测表现就是跳转后偶尔复位一次偶尔不复位让人排查到怀疑人生。4. App端改造与升级触发让应用配合Bootloader工作4.1 App工程必须做的三处调整链接地址、向量表偏移、启动确认App工程不是一个普通的STM32工程它必须为OTA做三处特殊调整。第一处是链接地址这个前面已经说了必须把代码段起始地址改到App分区的基地址。以MDK工程为例在Options for Target - Target页里把IROM1的Start改为0x08008000Size改为0x30000192KB。这里有个小坑修改了FLASH起始地址后Debug下载设置里的烧录算法也要对应调整像J-Link、ST-Link的下载算法通常按整个Flash来配置但如果你的烧录算法文件只覆盖了Bootloader区间下载时会报错。最简单的方式是使用STM32F10x高密度Flash的烧录算法覆盖0x08000000到0x080FFFFF就不用担心分区问题了。第二处是向量表偏移。顺着前面讲的在main最开头调用向量表重映射函数。这里我还要补充一点如果你的启动文件是标准库自带的startup_stm32f10x_hd.s它会在进入main之前执行SystemInit而SystemInit里面如果没有做向量表偏移的话那你必须在main的第一行代码就完成重映射实际上SystemInit内部配置完时钟后加一行SCB-VTOR即可两种做法都可以推荐在SystemInit里加因为这样更早生效防止启动文件阶段产生中断异常。第三处是启动确认机制。这是AB OTA的灵魂所在。App在main函数初始化完外设后启动一个5秒的安全计时器在5秒内如果程序运行正常没有触发HardFault、看门狗没有复位就把参数区里的启动次数计数器清零并标记本次启动正常。如果程序在5秒内崩了计数器不会被清零Bootloader在下一次启动时发现计数器的值达到了预设阈值比如3次就认定新固件有问题执行回滚。代码大致是这样一个结构int main(void) { // 向量表偏移 SCB-VTOR Get_App_BaseAddr(); // 配置看门狗超时时间略大于5秒 IWDG_Init(IWDG_Prescaler_64, 1250); // 清零启动计数器 Firmware_Reset_StartupCounter(); // 主循环正常运行 while (1) { // 业务逻辑... // 每1秒喂狗一次 IWDG_ReloadCounter(); // 5秒后确认启动正常 if (g_startup_tick 5000) { Firmware_Confirm_App_Good(); g_startup_tick 0xFFFFFFFF; // 只确认一次 } } }4.2 升级触发方式我的实现是按键串口命令上位机指令三合一Bootloader需要一个要不要进入升级模式的判断依据。我在方案里实现了三种触发方式按触发时机和使用场景区分。第一种是启动时检查外部条件。我在Bootloader里检测两个GPIO引脚的状态如果上电时按键处于按下状态或者指定引脚被拉高就强制进入升级模式。这是最保险的兜底方式——即使系统已经没法正常工作了只要能上电就能通过按键强制进入Bootloader刷机。这一点千万别省我有一次把App写崩了Bootloader又不支持强制升级设备直接成砖只能拆壳子接SWD重新烧。那次之后我就把所有OTA方案的Bootloader都加上了按键触发逻辑。第二种是运行中通过命令行触发。我用的串口命令是加回车App收到后执行复位进入Bootloader后Bootloader检查参数区的升级请求标志发现被置位就进入升级模式。这种方式适合在开发调试阶段使用比较方便。但要注意App在复位前必须把标志写入参数区而且写入要确保Flash操作完成后再复位否则标志可能丢。第三种是上位机主动下发升级指令。App通过网口或串口收到特定协议包后保存新固件版本号等信息到参数区然后软件复位。这是量产模式下最常用的触发方式缺点是App代码里需要预留协议解析和处理接口。因为这次项目用的是串口链路做固件传输所以上位机指令和XModem传输协议是走同一个串口通道的App先把新固件相关的信息记录好然后复位进Bootloader等Bootloader收到开始传输固件的握手包后再开始接收数据。4.3 利用看门狗实现跑飞自动回滚看门狗是AB OTA方案里保证自动回滚真正能落实的关键装置。如果App跑飞了看门狗没人喂几秒钟后系统就会复位。这个复位动作本身不重要重要的是复位后发生的事Bootloader启动检查启动计数器发现没有确认运行正常然后决定是否回滚。我在前面提到参数区里有一个启动次数计数器。这里把它和看门狗配合起来的逻辑再详细说明一下。参数区里保存两个关键字段一个是连续启动次数另一个是上次启动槽位。每次Bootloader启动App之前会把连续启动次数加1然后擦写参数区。App如果在5秒内正常运行就把连续启动次数清零。如果App在5秒内崩溃这个次数就不会被清零。当Bootloader检测到连续启动次数为3时就判定当前槽位的固件有严重问题执行回滚。实际操作时你会发现这个过程要经过多次复位但基本上第二次、第三次就会果断回滚。我设置的阈值是3次是针对这个项目固件启动速度大约1秒内就能到达喂狗循环来设的。如果你的固件启动比较慢比如要初始化一堆外设、连服务器等阈值可能要设大一些。但千万别设太小否则慢启动设备会被误判为跑飞而反复回滚。另外补充一个关于看门狗的小知识点很多人在App运行起来之后才初始化看门狗其实正确做法是把看门狗放在Bootloader里就初始化好跳转App时不关闭它。这样即使App完全没有运行或者跳转失败卡死了看门狗也会在几秒后把系统拉回BootloaderBootloader再次判断是否需要回滚——形成一个死循环保护。从安全性角度讲看门狗永远不应该被关闭除非你确定整个系统已经进入了一个不需要保护的休眠状态。5. 固件生成与上位机工具链从编译产物到可升级的固件包5.1 为什么要做CRC和版本信息头怎么做Bootloader接收到的不能是裸的.hex或者.bin文件至少应该在固件前面加一个元信息头这样Bootloader才能知道这包数据是什么版本、多长、校验值是多少。我在实际项目中设计了一个32字节的固件信息头放在固件二进制文件的最前面typedef struct { uint8_t magic[4]; // 固定为 0x5A 0xA5 0xC3 0x3C uint8_t version[8]; // 版本号字符串如 1.2.0\0 uint32_t image_size; // 固件有效数据长度不含信息头 uint32_t image_crc32; // 固件原始数据的CRC32校验值 uint8_t reserved[12]; // 保留字段用于未来扩展 } firmware_header_t;为什么用CRC32而不是CRC16固件包的长度可能到100KB以上CRC16的检错能力在这种数据量下相对有限而CRC32能覆盖的数据范围更大碰撞概率极低。关于计算我直接用Python的zlib.crc32从固件原始bin文件的头算到尾生成校验值后打包进信息头。这样上位机在刷写之前可以先校验一遍整包数据Bootloader接收完毕后还要再用CRC32校验一遍整个信息头和固件数据两道校验能拦截绝大多数传输错误。关键点在于这个信息头不是给Bootloader用的运行代码它只是固件包的封装Bootloader在烧写时需要把信息头解析出来然后把信息头之后的有效数据写到App分区起始地址。而App分区起始位置的第一条指令仍然是栈指针——也就是Bootloader跳转时读到的值必须在偏移0处。这就要注意当你把带信息头的固件包烧写进Flash时信息头不能被写进去只有有效固件数据才能落到App分区。最简单是在上位机传输前把信息头去掉再传或者Bootloader接收时跳过信息头长度。我倾向于让上位机直接传输信息头有效数据的完整包Bootloader先解析信息头然后把后续数据一股脑写入Flash。这样上位机只负责发数据Bootloader更可控还能在烧写前先判断版本号要不要升、CRC对不对。那编译产物怎么生成带信息头的固件包呢用MDK的话在User标签页的After Build里加一行命令调用Python脚本python build_firmware.py $L L output.bin脚本做的事情调用arm-none-eabi-objcopy或MDK自带的fromelf工具把.axf转成.bin然后读取bin文件计算CRC32拼上信息头输出最终可升级的固件包。这一步做一次以后就不用管了每次成功编译后升级固件包会自己出现在工程目录下。5.2 上位机升级工具的实现思路Python版核心代码上位机工具我用Python写的很方便跨平台而且不用编译直接跑。整个工具的核心功能就是打开串口 - 发送握手包 - 读取Bootloader的应答 - 分包发送固件数据 - 等待全部ACK - 发送结束帧 - 告诉Bootloader可以校验并切换。分包发送的代码我简化一下给你看核心骨架import serial import struct import zlib import time def compute_crc16_modbus(data): 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 send_firmware(port, bin_path, baudrate115200): with open(bin_path, rb) as f: fw_data f.read() # 解析信息头 # magic、version、image_size、image_crc32... ser serial.Serial(port, baudrate, timeout0.5) # 发送握手指令等待Bootloader回复 ser.write(bHANDSHAKE) resp ser.read(2) if resp ! bOK: print(Bootloader未响应) return seq 1 CHUNK_SIZE 256 for offset in range(0, len(fw_data), CHUNK_SIZE): chunk fw_data[offset:offset CHUNK_SIZE] frame b\xAA\x55 struct.pack(BH, 0x00, seq) frame struct.pack(H, len(chunk)) chunk crc compute_crc16_modbus(frame) frame struct.pack(H, crc) for retry in range(5): ser.write(frame) ack ser.read(1) if ack b\x06: break else: print(f包序号{seq}重试失败) return seq 1 # 简单打印进度 if seq % 50 0: print(f已完成 {min(offset CHUNK_SIZE, len(fw_data))}/{len(fw_data)} 字节) # 发送结束帧 end_frame b\xAA\x55 struct.pack(BHH, 0x01, seq, 0) crc compute_crc16_modbus(end_frame) end_frame struct.pack(H, crc) ser.write(end_frame) resp ser.read(2) if resp bOK: print(升级成功正在重启设备...) else: print(升级失败) ser.close()Python工具的调试效率远高于自己写上位机界面尤其是串口分包发送这类逻辑。当然如果你需要图形化界面可以套一层PyQt或者Electron但核心流程不变。这里我还想强调一下上位机和Bootloader之间的握手包很重要。握手的作用是确认Bootloader还活着、串口参数是对的、Flash空间够不够、版本号符不符合要求这些都是在实际应用中容易踩坑的点。5.3 如何把固件包通过局域网推送NGINX静态服务和串口传输结合这个项目原始的远程升级链路是局域网环境。我的方案是把编译好的固件包放到一个HTTP服务器上这里用了NGINX配置一个静态目录就行设备端通过网口从服务器拉取固件包下载完成后通过Bootloader刷入B分区。但考虑到很多STM32设备并没有网络硬件比如F103最常用的就是串口和CAN实际落地我改成了一种混合思路PC做中继从NGINX下载固件包到本地然后通过串口刷给设备。这个方案的好处是显而易见的上位机工具既负责网络交互又负责串口传输是一段全自动化的流程。工程师在办公室里点一下升级按钮固件包自动从服务器下载然后通过串口或者通过RS485总线甚至通过一个无线模块刷给现场设备。这套链路对很多工厂设备、仪表类产品已经足够了。我还实测过另一个组合把ESP32作为上位机从NGINX拉取固件再通过串口/SPI转发给STM32F103烧写。这个方案成本很低而且ESP32本身有Wi-Fi能直接访问局域网服务器。如果你有一个设备矩阵每个节点都是F103完全可以用一个ESP32做烧录网关从服务器拉固件再通过RS485总线广播给多个从机并行升级。不过这套方案实现起来要处理的协议细节比较多我这次先不展开后面有机会单独写一篇。6. 实操排障实录与效果验证6.1 升级全流程实录与日志分析我自己在开发板上完整跑通流程之后记录了下面这条典型升级链路你可以对照着看自己系统的工作状态是否正常。当前设备运行A分区固件v1.0。上位机连接串口发送升级指令包含新固件版本v1.1和固件长度。App收到升级请求后将请求升级标志和版本号写入参数区软复位。Bootloader启动检测到升级请求标志后初始化串口并向上位机发送准备好了的握手包。上位机开始分包发送固件Bootloader逐包写入B分区的Flash。全部发送完成后Bootloader读取B分区固件计算CRC32信息头里的校验值和计算结果匹配。Bootloader将当前槽位标志从A切换到B软件复位。Bootloader再次启动检测到当前槽位为B跳转到B分区。B分区App运行成功初始化5秒内写运行正常状态。设备完成升级正常运行固件v1.1。如果升级过程出了问题日志会怎样呢假设在传输过程中有人拔掉串口线Bootloader会在重试超时后停在一个未完成的状态。不会因为没写完就直接跳转。这是我设计时特意加入的只有收到结束帧而且校验成功了才会切换槽位。传输中途结束完全不影响A分区运行下次上电还是会从A启动。这一点就是AB分区方案和单分区方案最根本的区别。6.2 实战中配置和调试失败的高频问题为了让你少走弯路我把实际复现过程中遇到过的、以及身边朋友问过最多的问题整理成了一张速查表。现象可能原因排查方法跳转App后死机向量表未偏移或偏移值不对栈顶指针非法在跳转前单步调试验证SCB-VTOR值检查App链接地址是否真的改到了分区地址App启动后串口输出乱码波特率配置不一致Bootloader里的外设时钟配置残留影响App统一Bootloader和App的RCC配置跳转前执行RCC_DeInit()升级传输中途失败重试也失败串口流控问题上位机发送速度太快Bootloader的Flash擦写跟不上在上位机每包之间加2-5ms延时确保波特率不超过115200升级完成后回不到新固件校验失败Bootloader拒绝切换槽位App启动后没有写确认标志检查是否用同一个CRC算法用ST-Link读Flash检查B分区数据是否正确反复复位始终起不了App看门狗没有在Bootloader中被喂App启动时间超过看门狗阈值延长看门狗超时时间或者把App确认时间提前到看门狗复位周期前断电后参数区标志丢失参数区的写操作未完成就断电Flash写时序被中断打断Flash写操作前关闭中断写入后立即读取校验考虑双备份参数区这里最让我印象深刻的是串口传输超时问题。最初我的上位机把分包发得非常快每包之间基本上没有间隔结果Bootloader经常来不及处理——它在收包的同时还要执行Flash擦写而F103的Flash擦写时间不短128KB扇区擦除需要大约1秒虽然我的代码是边收边写、写完当前扇区才擦下一个但上位机速度上来了还是会丢包。后来我在上位机每包之间加了3ms延时问题就消失了。如果你遇到传输超时优先考虑降低发送速率别急着改协议。6.3 一个罕见的BUG案例跳转前SysTick残留导致随机复位这个BUG排查过程让我记忆深刻值得拿出来单独说说。设备做完升级后运行完全正常但从Bootloader跳转到App的瞬间有时候会随机复位一次。不是每次都复现大概五分之一概率而且只在特定条件下出现——串口波特率是115200App里有大量中断的时候。查了很久最后发现问题出在SysTick上。我在Bootloader里用SysTick做延时跳转之前没有主动关闭SysTick中断和清空SysTick计数器。Bootloader的SysTick配置和App启动流程里的SysTick配置发生了冲突导致在跳转后的极短时间内SysTick中断触发了一次而此时向量表已经切换到了App的地址两个中断处理逻辑互相衔接出现问题系统就复位了。修复办法就一行SysTick-CTRL 0; // 关闭SysTick SysTick-VAL 0; // 清空计数器放在跳转代码里和关闭全局中断放在一起。从那以后这个随机复位问题就再没出现过。这种事情很典型如果是经验不够的开发者遇到很容易怀疑是硬件问题、电源问题、时序问题折腾很久。这让我更加坚定了一个习惯代码初始化要完整对称用什么外设就要清什么外设跳转前把所有资源恢复到复位状态这不是洁癖这是可靠性。7. 扩展与收尾这套方案的极限和它的演化方向AB OTA这套方案不是万能的它有自己的适用范围和极限。在STM32F103上最大的限制就是Flash容量。如果你的固件超过了200KB双分区方案就会显得捉襟见肘。这时候有几个思路可以参考第一个是精简固件把不需要上电启动的资源改成按需加载第二个是引入压缩升级包方案固件压缩后存入外部Flash升级时解压写入内部Flash缺点是Bootloader要负责解压代码复杂度和时间开销都会上升第三个是换用更大Flash的芯片比如STM32F407或者更高端的系列Flash动辄1MB以上做双分区很轻松。我在实际项目中还遇到过一种场景多台设备需要同时升级。一台一台刷太慢了我就在上位机工具里加了广播升级功能通过RS485总线或者CAN总线广播固件包接收方分别写入各自的B分区然后统一切换。这个功能用AB分区的好处体现得非常明显——广播升级的过程中每台设备写入B区不影响正在运行的A区写完B区后由上位机统一发激活指令设备才切换到新固件。如果某台设备写入失败它仍然停留在旧固件运行不会影响整个总线的工作。这种先缓存、后激活的升级方式对工业现场的维护体验提升是质的飞跃。回过来再说说对AB OTA本身的体会。每次和朋友聊到OTA我都会强调一个观点OTA不是一个加一个功能的事而是一个系统设计的事。从分区规划、Bootloader编写、App改造到上位机工具、传输协议、异常回滚任何一个环节做不好最终产品都会在某个关键时候出幺蛾子。你在开发阶段可能觉得AB方案麻烦——处理向量表偏移、处理双固件空间、处理启动确认逻辑——但等产品真的上线了需要通过远程升级来解决一个紧急bug的时候你就会发现当初这些麻烦完全是值得的。最后分享一个小技巧是我自己在维护OTA系统时养成的一个习惯。每次发布新固件包我都会同步记录一个发布清单内容包括版本号、变更内容、固件包CRC32、目标设备数量、发布窗口时间。这样万一升级后出了问题我可以快速判断哪些设备可能受影响并且通过CRC32核对每台设备实际烧写的是不是预期固件。这种运营级的细致程度很多时候比代码本身更能决定一个OTA系统靠不靠谱。
延伸阅读

更多相关文章

2026/9/9 3:06:08

MyBatis 数据库配置与 SQL 操作全解析:从动态 SQL 到二级缓存

说实话,在做 Java 后端这几年里,MyBatis 是我用得最多的持久层框架,没有之一。市面上的教程一抓一大把,但大部分都停留在“怎么配能跑起来”的程度,真正遇到问题的时候,比如 SQL 日志打不出来、动态 SQL 拼…

2026/9/9 3:06:08

Page Assist:让Ollama本地大模型更好用的浏览器插件

简介:Page Assist是一款面向ollama本地AI模型的Chrome浏览器插件,致力于让用户在不离开当前网页的情况下,通过侧边栏即可完成模型对话、参数配置等操作,解决切换窗口带来的效率损耗。这份资源包共含95个文件,以ttf/wof…

2026/9/9 3:01:08

Java Arrays工具类在集合中的核心应用与常见坑解析

先问个很现实的问题&#xff1a;一个int[]数组&#xff0c;想交给一个只接受List<Integer>的方法&#xff0c;你第一反应是不是Arrays.asList(arr)&#xff1f;很多人就是这么干的&#xff0c;结果拿到一个size为1的List&#xff0c;里面装的是一个长度为N的int[]。这个场…

2026/9/9 4:16:15

STM32F103 AB分区OTA远程固件升级实战:Bootloader到App完整方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/9/9 4:16:15

Infinite Slop:AI批量生成垃圾内容如何泛滥,创作者该如何自救

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/9/9 4:16:15

Cursor与IntelliJ IDEA:AI编程助手集成路线与真实使用指南

如果你最近在刷技术社区&#xff0c;大概率会发现一个现象&#xff1a;搜索框里挤满了“idea 内置 cursor”“idea 集成 AI 编程助手”之类的词。有人在问能不能把 Cursor 装进 IntelliJ IDEA&#xff0c;有人在问怎么把 Cursor 设置成中文&#xff0c;还有人在纠结 Cursor 免费…

2026/9/9 4:16:15

轻量开源版IDEA怎么选?社区版调优与开源替代方案全解析

最近社区里关于“轻量开源版 IDEA”的讨论一下子多了起来&#xff0c;不少人私信问我到底该怎么选、怎么配。说实话&#xff0c;IDEA 社区版本身就是开源免费的&#xff0c;只是很多人被“轻量”两个字带偏了&#xff0c;以为又是某个新出的仿制品。我倒是觉得&#xff0c;与其…

2026/9/9 4:16:15

WorkBuddy实战:用AI智能体自动生成周报,从3小时到15分钟

1. 先从每个周五下午都要消耗我半天的周报说起做了三年多项目交付&#xff0c;我最烦的其实不是开会&#xff0c;是写周报。每个周五下午&#xff0c;我都要从三四个不同系统里把数据捞出来&#xff1a;项目台账、工时记录、风险清单、客户反馈&#xff0c;再按固定格式拼成一份…

2026/9/9 4:11:14

大模型选型实战指南:Tokenizer、API错误与场景适配

1. 这不是“哪个模型更好”的玄学投票&#xff0c;而是工程师手里的工具箱选型指南你打开终端敲下第一行代码前&#xff0c;得先确认手边这把“螺丝刀”是不是真能拧开眼前这颗锈死的螺栓。DeepSeek、Claude、GPT——这三个名字最近像地铁报站一样高频刷屏&#xff0c;但它们从…

2026/9/8 7:15:10

超人会飞不算本事:系统稳定依赖清晰规则与边界设计

开头先不绕弯子。“#斯坦李吐槽dc 所以超人是无缘无故会飞的嘛哈哈哈哈哈哈哈锤哥真是技术人才啊&#xff01;#雷神 #复联”这类调侃式短标题&#xff0c;第一波冲击力在于它把两个宇宙的角色塞进同一个吐槽箱里&#xff0c;但细想一下就能发现&#xff0c;它真正碰到的根本不是…

2026/9/8 7:15:15

超人VS蜘蛛侠:拆解超级IP的影响力与传播方法论

把“蜘蛛侠 vs 超人”放在 CSDN 上聊&#xff0c;可能很多人第一反应是走错片场了。但如果把这两个角色看成“两个持续运营了 80 多年的文化产品”&#xff0c;你会发现&#xff0c;这场比较本质上是两个不同 IP 策略的长期结果对比&#xff1a;超人赢在定义了整个超级英雄题材…

2026/9/8 7:15:10

基于CNN的调制信号识别:MATLAB实现时频图分类实战

简介&#xff1a;本资源是一套面向通信工程与信号处理方向学习者、研究者的深度学习实践方案&#xff0c;聚焦调制信号自动检测与识别这一典型无线通信任务&#xff0c;解决传统方法依赖人工特征、低信噪比下性能下降等痛点。压缩包共12个文件&#xff08;10.73MB&#xff09;&…

2026/9/9 0:00:48

MHS模型硬件标准:让大模型像调用软件一样控制物理设备

让Claude真正看着显微镜说“这个细胞形态不太对”&#xff0c;或者让大模型自己调一版机械臂的运动轨迹&#xff0c;这事儿听上去已经很接近科幻片了。但你真上手试一次就会发现&#xff0c;模型不缺智商&#xff0c;缺的是一个能插进显微镜、机械臂、激光控制器里的“通用插座…

2026/9/9 0:00:48

AI五大核心方向详解:从机器学习到大模型,零基础转行选哪条?

会有人告诉我&#xff0c;他想转行学AI&#xff0c;但打开招聘网站一看直接傻眼&#xff1a;机器学习、深度学习、自然语言处理、计算机视觉、大模型应用……满屏都是这些词&#xff0c;好像每个都会一点&#xff0c;又好像每个都离自己很远。还有人上来就问“学Python还是学Ja…

2026/9/9 0:00:49

从50行最小循环到生产级AI引擎:工程化改造全解析

直接说干货。这一章我写的不是那种"hello world跑通某个模型"的教程&#xff0c;而是把AI引擎当做一个真正要上线、要被人调用、要扛流量的系统来聊。从最初只有50行的最小循环&#xff0c;到能够承载生产流量的AI引擎&#xff0c;中间差的不是代码量&#xff0c;而是…

2026/9/7 16:23:03

USB Type-C PCB布局分区设计:电源、高速信号与PD协议全攻略

做硬件这行&#xff0c;Type-C接口算是典型的“看着简单&#xff0c;做起来全坑”的东西。光引脚就24个&#xff0c;高低速信号、电源、控制线全部塞在一个小小的连接器里&#xff0c;如果PCB布局不做规划&#xff0c;打样回来基本就是“插上没反应”、“高速掉线”、“静电一打…

2026/9/7 22:46:00

系统编程学习原型如何补齐稳定性边界

系统编程学习原型如何补齐稳定性边界预算有限时&#xff0c;我先优化明显多余的复制&#xff0c;而不是猜测性地换容器。用借用传递只读数据通常就能减少分配&#xff1a; fn parse(line: &str) -> Result<Item, Error> { /* ... */ }用基准确认热点确实在分配&am…

2026/9/7 22:45:59

雨花区哪家财务公司代理记账比较好?

在雨花区&#xff0c;企业处理财税事务常常面临诸多挑战&#xff0c;选择一家靠谱的财务公司至关重要。湖南巨勤财务管理咨询有限公司就是本地正规实体财税服务机构&#xff0c;深耕本地工商财税行业多年&#xff0c;熟悉当地工商局、税务局最新政策与申报流程。主营公司注册、…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
咨询二维码