ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32F103裸机OTA实践:基于Modbus RTU的AB双分区固件升级

2026/9/6 10:07:48 拓冰建站 浏览量
STM32F103裸机OTA实践:基于Modbus RTU的AB双分区固件升级 做嵌入式的小伙伴应该都有过这种经历产品已经量产现场说要改功能你只能夹着下载器出差。如果设备装在机柜里、封在电池盒里拆机升级的痛苦谁做谁知道。OTA就是解决这个问题的设备原地不动固件通过通信接口自己更新自己。我这次在STM32F103上把基于Modbus RTU的A/B双分区OTA完整做了一遍从Flash分区规划、Bootloader跳转、freemodbus v1.6移植到掉电回滚和断点续传全部跑通。这篇文章就把整个过程从零拆开献给想在裸机MCU上做可靠升级但还没找到完整参考的朋友。这套方案跑在STM32F103标准库v3.5环境下开发工具是Keil MDK下位机用RS232串口接MAX3232连到PC通信协议走Modbus RTU。它能做的事情很具体设备正常运行时跑App A新固件通过串口下载到App B区下载完成后重启进BB跑不起来就自动回滚到A整个过程中设备不会变砖。适合刚接触IAP的工程师、想给现有产品加升级能力的技术人员或者说在实验室折腾F103最小系统板的同学。下面按我实际复现的顺序把整条链路拆开讲。1. 项目概述与整体方案1.1 为什么要做AB双分区而不是单区覆盖单区升级的思路是擦除旧固件直接写入新固件。这个方案实现很简单但风险非常大。新固件下载到一半断电、串口传输过程中校验出错、或者固件本身有逻辑问题跑不起来设备基本就变成砖头了只能开壳用SWD重新烧录。对消费类或工业设备来说这是不可接受的。A/B双分区方案的核心思路是保留一个“已知能用的固件”在A区把新固件写入另一个B区。确认B区固件正常工作之后再切换启动区。如果B区固件有问题系统还能自动回到A区继续运行。这个思想和手机系统升级一模一样系统一更新失败手机上会提示“无法验证更新请重新下载”但手机本身还能用因为系统把可用的旧分区留着。在STM32F103这类片内Flash不太大的裸机MCU上AB双分区并不复杂核心就是规划好地址、写好Bootloader和App的跳转逻辑。和生产成本敏感、不想增加外部Flash的产品相比直接在片内Flash上划分两个App区是最务实的做法。F103VET6有512KB Flash分一个32KB的Bootloader加两个128KB的App区完全够用对大多数中小固件来说绰绰有余。1.2 方案选型背后的取舍通信协议我选了Modbus RTU而不是自己发明一套协议。原因有三个第一Modbus RTU在工业环境里遍地都是帧结构简单、CRC16校验非常成熟第二有freemodbus这种现成协议栈移植到STM32标准库环境下只需要写几个Port层文件第三调试时有Modbus Poll这类现成上位机工具不用从零写上位机。缺点是Modbus寄存器是16位的传大块固件数据要拆成16位一帧但这个限制反而让“断点续传”和“错误重发”更容易设计。固件传输方式选了最直接的bin文件不走压缩包和外部Flash方案。压缩包升级效率高但MCU端要处理解压和文件系统工作量翻倍外部Flash方案成本高可靠性也要重新测试。裸机场景下bin文件配合AB双区和回滚机制已经足够而且Bootloader只需要做一件事——往Flash里写数据不碰其他复杂逻辑。freemodbus和手写解析的取舍也提一下。如果只用03、06、10三个功能码手写一个精简的Modbus RTU解析并不困难。但如果后续要扩展协议、增加加密处理、批量读写参数freemodbus能省很多事。我这次直接移植了freemodbus v1.6刚开始觉得Port层文件有点绕跑通之后感觉还是值的。1.3 硬件平台与软件工具准备主控使用STM32F103VET6512KB Flash这是很典型的“大容量F103”。如果你手里是F103C8T6这种64KB的小容量型号方案也能跑只需要按2.1节改成更紧凑的分区。串口使用USART1PA9/PA10外接MAX3232转成RS232电平连接PC的串口线或USB转RS232设备。波特率1152008N1。为什么用RS232而不是TTL因为RS232电平抗干扰能力更好也更符合工业设备的常规接法其实原理不依赖具体电平方式用USB转TTL一样能调。软件开发环境是Keil MDK 5.3x固件库使用STM32标准外设库v3.5也就是很多人电脑里已经躺了好几年的那个StdPeriph_Lib协议栈是freemodbus v1.6。调试工具我准备了三样JLINK/SWD调试器用于回退现场逻辑分析仪用于抓串口波形Modbus Poll用于单帧调试。最后还需要一个Python环境我后面用minimalmodbus库写了一个自动化升级脚本这个在5.4节单独说。2. Flash分区规划与内存布局分区规划是整个方案的“地基”必须最先定死。因为分区地址会影响链接脚本、App里的绝对地址、Bootloader的跳转目标后期改动成本很高。这一章我直接给出我调试通过的配置。2.1 STM32F103 Flash地址空间分配以512KB型号的F103VET6为例我的分区如下区域起始地址大小说明Bootloader0x0800000032KB上电首先执行负责启动选择和固件下载App A0x08008000128KB出厂固件、回滚时使用的正式运行区App B0x08028000128KBOTA新固件的下载目标区标志区0x0807F8002KB1页保存启动状态、版本号、尝试计数这里最需要注意的是页对齐。F103VET6属于大容量产品Flash擦除单位是一页2KB所以所有分区起始地址必须按2KB对齐。上面表格里的地址都是页对齐的否则擦除函数会把相邻区域的代码一起擦掉这是最经典的事故原因。对64KB的F103C8T6可以把分区压缩为Bootloader 16KB、App A 24KB、App B 24KB起始地址分别是0x08000000、0x08004000、0x0800A000。原则不变页对齐 标志区单独占一页。如果你用的固件本身超过100KB那就按比例扩大App区只要两个App区的结束地址不越过标志区就行。2.2 运行状态标志Flash vs 备份寄存器状态标志是整个方案的“方向盘”Bootloader靠它决定启动A还是B。我一开始用Flash里固定地址的4个字节保存状态发现一个问题每次升级、回滚都要写Flash一个2KB页的擦写寿命虽然标称有几万次但实验室里反复测试升级流程时这个数字消耗得很快。后来我改成混合方案。状态机的大状态IDLE表示正常运行、TO_A表示请求切换到A、TO_B表示请求切换到B、RECOVERY表示回滚恢复仍然存在Flash里因为需要掉电保持。但启动尝试计数放在备份寄存器BKP里这个寄存器没有擦写寿命限制软件复位也不会清空非常适合做“最近几次启动是否成功”的统计。用备份寄存器需要注意一个细节F103的BKP电路默认是锁住的使用前要打开PWR和BKP外设时钟然后执行PWR_BackupAccessCmd(ENABLE)解锁才能调用BKP_WriteBackupRegister()和BKP_ReadBackupRegister()。我见过不少人在这一步没解锁就写结果寄存器值一直是0回滚逻辑死活不触发。2.3 链接脚本与map文件配合App工程的起始地址改到0x08008000之后Keil里需要修改Target页面的IROM1起始地址和大小。如果这一步漏改编译出来的App链接地址还是0x08000000和Bootloader重叠下载到B区后跳转进去立刻HardFault。改完地址后一定要看一次map文件。重点确认两个符号__initial_sp应该是0x2000xxxx开头的RAM地址Reset_Handler应该在0x0800xxxx范围内如果这两个值正常说明链接脚本设置正确。我调试时还发现一个非常实用的技巧用十六进制编辑器打开编译生成的bin文件前4个字节是栈顶地址紧接着4个字节是复位向量地址。这两个值能对上IAP跳转就成功了一半。3. Modbus RTU下载协议设计协议设计的好坏直接决定升级稳不稳。因为Modbus RTU的帧格式本身带CRC16校验链路误码已经能拦住所以我们真正要设计的是“上位机怎么告诉下位机该干什么”以及“下位机怎么反馈状态”。3.1 寄存器映射表与功能码我的协议里用了三个功能码0x03用于上位机读取状态0x06用于写单个控制寄存器0x10用于批量写固件数据。这三个功能码都是freemodbus默认支持的协议栈核心代码不用改。寄存器地址类型名称说明0x0000只读FW_STATUSBit0当前运行区(0A,1B)Bit1下载状态Bit2回滚计数溢出0x0001写CMD1进入下载模式2擦除目标区3复位到新固件0x0002写ADDR_H目标Flash地址高16位0x0003写ADDR_L目标Flash地址低16位0x0004写SEQ帧序号用于断点续传记录0x0005写CRC32整包CRC32校验值0x0006只读CUR_ADDR_H当前已写入Flash地址高16位0x0007只读CUR_ADDR_L当前已写入Flash地址低16位0x0010~0x001F写DATA_BUF固件数据缓冲区一次最多16个寄存器32字节寄存器地址0x0010到0x001F一共16个寄存器也就是32字节。ST官方Flash编程的最小单位可以在半字级别操作但用32字节一块来写入传输效率和擦写管理更方便。每个寄存器都是16位先传数据的高字节还是低字节取决于Modbus协议的大端格式freemodbus内部已经处理好了上位机端用minimalmodbus这类库也默认匹配。3.2 升级流程状态机与帧格式整个升级流程分6步上位机读FW_STATUS确认设备在线且当前版本合法。写CMD1Bootloader进入下载模式把目标地址重置为B区起始地址0x08028000。写CMD2Bootloader对整个B区按页擦除所有页擦除完成后才允许后续写数据操作。上位机循环发送数据帧。每个数据帧先写ADDR_H、ADDR_L再写DATA_BUF的16个寄存器Bootloader收到完整一帧后写入Flash对应地址。所有数据发送完毕上位机写CRC32寄存器并触发校验命令这个命令我复用了CMD3也可以单独加寄存器Bootloader对B区计算CRC32并比对。校验通过后写CMD4Bootloader把启动状态置为TO_B执行软件复位。复位后Bootloader检测到TO_B状态跳转进B区。举个例子上位机要把32字节数据写到0x08028000Modbus寄存器操作是这样写寄存器0x0002 0x0802高16位寄存器0x0003 0x8000低16位。把32字节数据按16位一组拆成16个寄存器用功能码0x10一次性写入0x0010到0x001F。写Flash的时序也要注意。每次写入前目标页必须处于已擦除状态。我的做法是在步骤3里一次性把所有需要写的页擦干净之后的写操作只管写数据不用再检查页状态。如果设备中途掉电B区可能处在“部分页已擦除但未写入”的状态但因为AB方案本身不碰A区重启后直接进A区下次升级再重新擦B区就好不会出问题。3.3 断点续传与进度计算断点续传的实现思路很简单Bootloader每写完一块数据就把“当前写入地址”更新到CUR_ADDR_H和CUR_ADDR_L寄存器里。上位机重新连接后先读这两个寄存器就能知道上次传到了哪里然后从下一个未写地址继续发。不过要注意一个问题如果断点落在某个页的中间Bootloader的“按页擦除”策略会导致后续帧写入时碰到未擦除的页。最简单的处理方式是上位机检测到断点后先发送CMD2重新擦除整个B区再从头开始传。AB双区的优势就在这里体现就算从头重传也只是浪费一点带宽A区始终保底设备不会变砖。进度百分比也有一个容易踩的坑。上位机计算进度时不要用(cur - start) / total * 100这种顺序因为整数除法会先截断进度会一直停在0%或者跳到100%。正确做法是先乘后除((cur - start) * 100) / total。128KB的传输量乘100之后不会溢出这个公式安全。4. Bootloader与App配合的关键实现代码层面的核心就三件事Bootloader怎么跳、App怎么改、回滚怎么兜底。下面给出关键代码和避坑说明。4.1 Bootloader启动流程与校验策略Bootloader上电后的逻辑是这样的void bootloader_main(void) { uint8_t state read_start_state(); if (state TO_B) { if (check_app_header(APP_B_ADDR)) { inc_try_count(); jump_to_app(APP_B_ADDR); } else { inc_try_count(); if (bkp_read(SYS_RETRY_CNT) MAX_RETRY) { write_start_state(IDLE); jump_to_app(APP_A_ADDR); } else { jump_to_app(APP_A_ADDR); } } } else { jump_to_app(APP_A_ADDR); } }check_app_header不是对整个App区做CRC32而是只检查开头8个字节栈顶地址是否在0x20000000开头的RAM范围复位向量地址是否在0x08000000开头的Flash范围。这两个值能对上说明这个分区里至少有个合法的固件头。启动时不做全量CRC32是因为128KB的CRC计算在72MHz主频下也要几百毫秒每次开机都等这段时间对启动速度不友好。更合理的做法是在固件升级完成时对整包做一次CRC32校验启动时检查头部合法性就够用。头部合法、App能跑起来、配合回滚机制已经能兜住绝大多数故障场景。如果A区和B区都不合法Bootloader不能跳转应该保持在下载模式等待上位机重新写固件。我实测过把A区和B区都破坏后设备在Bootloader里待机上位机重发固件后能恢复正常。4.2 IAP跳转函数的正确写法这是整个项目最值得反复看的一段代码typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t app_msp *(volatile uint32_t *)app_addr; uint32_t app_reset *(volatile uint32_t *)(app_addr 4); __disable_irq(); if (((app_msp 0xFFF00000u) 0x20000000u) ((app_reset 0xFFF00000u) 0x08000000u)) { SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; SCB-VTOR app_addr; __set_MSP(app_msp); pFunction jump (pFunction)(app_reset); jump(); } else { ota_set_state(IDLE); jump_to_app(APP_A_ADDR); } }为什么先读app_msp和app_reset因为每个App的bin文件前4字节是栈顶地址紧接着4字节是复位向量地址这两个值由链接器生成。跳转前把MSP改成App的栈顶然后直接执行App的Reset_Handler等价于App刚上电。为什么跳转前要__disable_irq()因为Bootloader里可能开着定时器、串口、DMA的中断。如果带中断跳进AppApp在初始化外设前先被中断打断执行的处理函数还是Bootloader的会造成各种诡异问题。所以跳转前先关全局中断再清SysTick把VTOR指向新App的起始地址最后跳转。App的启动代码会在Reset_Handler里重新初始化时钟、中断、外设最终会把全局中断重新打开。还有一个容易忽略的细节跳转前要把Bootloader用过的DMA通道禁用。DMA如果不关跳转后它还在搬运数据可能往随机地址写数据。我在代码里用外设复位函数把所有用过的外设复位了一遍这样跳转后外设不会给App留“遗产”。4.3 App侧中断向量表偏移与稳定运行上报App的链接地址改到0x08008000之后中断向量表默认还在0x08000000必须让硬件知道向量表挪了位置。F103的Cortex-M3内核有VTOR寄存器App在main函数最前面设置一下static void relocate_vector_table(void) { SCB-VTOR APP_START_ADDR (uint32_t)0x1FFFFF80; }为什么和0x1FFFFF80做与运算因为F103的向量表要求128字节对齐加掩码可以防止拼错地址也确保地址落在有效空间里。App跑起来之后还要告诉Bootloader“我这里稳定了”。时序很关键如果在跳转前就把状态置为IDLE回滚机制就形同虚设因为Bootloader永远以为新固件启动成功了。我的做法是Bootloader置TO_B并跳转App在main里运行一段时间比如3秒正常执行任务、正常喂狗3秒后调用ota_mark_success()往Flash标志区写IDLE同时把备份寄存器里的尝试计数清零。void ota_mark_success(void) { if (BKP_ReadBackupRegister(BKP_DR1) 0x5AA5) { FLASH_Unlock(); FLASH_ErasePage(FLAG_ADDR); FLASH_ProgramWord(FLAG_ADDR, STATE_IDLE); FLASH_Lock(); BKP_WriteBackupRegister(BKP_DR1, 0); BKP_WriteBackupRegister(BKP_DR2, 0); } }这种“延迟确认”机制非常有用。如果新固件起来后3秒内就崩溃ota_mark_success不会执行尝试计数不会清零系统复位后Bootloader就知道这次启动失败了。4.4 看门狗回滚机制实现回滚机制使用的是IWDG独立看门狗因为它独立于主时钟运行不受主程序卡死影响。Bootloader跳转App之前启动看门狗IWDG_WriteAccessCmd(IWDG_WriteAccess_Enable); IWDG_SetPrescaler(IWDG_Prescaler_64); IWDG_SetReload(3750); IWDG_ReloadCounter(); IWDG_Enable();这里的3750是重载值对应超时时间约6秒。计算方式F103内部LSI时钟典型值40kHz经过64分频后约625Hz重载3750除以625等于6秒。注意LSI的实际频率在30kHz到60kHz之间所以6秒只是一个大致数时间误差不影响功能只要App能在几秒内把狗喂起来就行。App侧要在主循环里周期性执行IWDG_ReloadCounter()一旦App因为死锁、跑飞、异常导致停止喂狗IWDG就会复位芯片再次进入Bootloader。Bootloader发现“上次要启动B但B没有上报成功”尝试计数就加1超过阈值后自动切回A区。我在实测时故意让B固件在初始化外设时进入死循环大约经过3次复位、约18秒后设备回到了A区并正常运行。这个结果说明回滚逻辑是有效的。对于安全要求更高的产品后续还可以加上固件签名和验签CRC32只能防误码不能防恶意固件注入但作为裸机MCU的OTA基础方案这套回滚机制已经够扎实。5. 常见问题与调试心得从零复现这个项目必然会遇到几个典型故障。我把调试过程中的排查记录整理成速查表方便对号入座。5.1 下载完固件跳转后直接HardFault这个问题出现的频率最高原因通常有四个App工程IROM1地址没改App链接地址还是0x08000000和Bootloader重叠。查工程设置和map文件。中断向量表没有重定位App在0x08008000执行但中断向量仍指向0x08000000一旦触发中断就飞了。在App main最前面加SCB-VTOR设置。跳转前没有关闭中断或外设带着定时器和串口中断进去被打断崩溃。把跳转函数里的关中断和外设复位逻辑加上。App链接地址改了但代码里用了绝对地址访问比如调试用的printf重定向、DMA描述符地址这些要统一检查。排查顺序建议是先看map文件确认__initial_sp和Reset_Handler再看跳转前读到的app_msp和app_reset最后用SWD连接目标板看PC在崩溃时跑到了哪个地址。5.2 Modbus通信偶发错误帧与freemodbus移植的坑我这套方案用的是RS232PC和板子必须共地。第一次调试时我没注意串口偶尔出现0x00或者0xFF乱码Modbus CRC16校验直接失败。把GND接好后这个问题基本消失。另一个高频坑是字节间隔。Modbus RTU要求帧内字节间隔小于3.5个字符时间115200波特率下大约0.334毫秒。如果上位机用串口助手的“按字符发送”很容易超时下位机就会把一帧拆成多帧处理。解决办法是上位机整包发送包间延迟建议大于20毫秒别用“定时发送”功能做升级帧。freemodbus v1.6移植时的几个关键点mbconfig.h里打开MB_RTU_ENABLED关掉MB_ASCII_ENABLEDportserial.c要正确对接串口中断接收中断里调用pxMBPortCBRXByte()发送完成中断里调用pxMBPortCBTXByte()porttimer.c必须提供一个定时器定时周期约0.5毫秒中断里调用pxMBPortCBTimerExpired()用于判断帧接收是否结束这三点按官方demo模板接就行但很多人会漏掉vMBPortSerialEnable的前后状态切换。尤其是在响应功能码0x10这种大帧时如果发送使能和接收使能切换不及时上位机会读到空响应或者超时。5.3 升级过程中掉电设备会变砖吗不会。AB方案的核心价值就在这里。如果掉电发生在下载B区的过程中A区完全没有被动过重启后Bootloader发现状态是IDLE直接进A区。如果掉电刚好发生在写完TO_B标志、准备跳转的窗口Bootloader在跳转前会校验B区的固件头校验失败就自动回A区。唯一需要注意是写Flash标志区时不要被打断。我的做法是写标志时关闭全局中断保证一个32位数据原子写入__disable_irq(); FLASH_Unlock(); FLASH_ProgramWord(FLAG_ADDR, STATE_TO_B); FLASH_Lock(); __enable_irq();这样能避免“写了一半的Word”被读出来当成一个非法状态值。另外所有状态标记建议做“两段式”先写一个预备值再写生效值最坏情况下也能通过剩余的标志判断出上次想干什么。5.4 工具链建议与自动化测试Modbus Poll适合查看单个寄存器的值升级前读FW_STATUS升级中观察CUR_ADDR的变化升级后读状态确认版本。但是全流程手动操作太累我用Python的minimalmodbus库写了一个自动化升级脚本按顺序执行读状态、进入下载模式、擦除、逐帧写寄存器、写CRC、比对、复位。整个脚本不到200行强烈建议做一版。用minimalmodbus时要注意超时设置。每次write_register调用的默认超时可能不够因为Bootloader擦除整个B区时要等几秒。我在代码里把instrument.timeout设成1秒以上擦除命令的等待时间单独设到5秒不然上位机会在下位机擦Flash期间误报超时。擦除整个B区128KB大概需要多久实测F103大容量产品擦一页2KB大约20到40毫秒64页总共2到3秒。这个时间做进度提示时要有心理准备用户看到进度条卡住不要以为死机了。最后分享一个调试技巧把Bootloader里所有关键节点进入下载模式、擦除完成、开始写帧、CRC校验通过、准备跳转通过Debug串口打印出来。配合逻辑分析仪抓串口波形上位机脚本在哪一步断开一眼就能定位问题。我后来把所有打印规范成“T时间戳事件码”格式排查问题的效率提升了一倍。这个项目做下来我最大的体会是AB OTA本身不复杂难在把“万一”“如果”这些边界情况都想清楚。写Flash时掉电怎么办、新固件起不来怎么办、传输中间断了怎么办每解决一个问题设备就离“永远不会变砖”更近一步。如果你也要在STM32F103上做OTA建议先从最简单的单区IAP跑通再改成AB双区最后一步一步把回滚、断点续传加进去。别想着一口气做一个能应对所有场景的Bootloader先把主流程做扎实坑是一个一个踩过去的。