ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

STM32F103 AB分区OTA从零复现:工业级固件升级实战

STM32F103 AB分区OTA从零复现:工业级固件升级实战 1. 这不是“又一个OTA教程”而是AB分区OTA在STM32F103上的真实工程复现手记我第一次在STM32F103上跑通AB双分区OTA升级是在一个没有Wi-Fi模块、没有RTOS、只有256KB Flash和20KB RAM的工业传感器节点上。客户要求断电不丢固件、升级失败自动回滚、升级过程零人工干预——不是“理论上可行”是明天就要装进产线的板子。市面上搜到的所谓“STM32F103 OTA教程”90%停留在Keil里点几下仿真、改几个地址、烧个hex就喊成功。但真实产线里你得面对Flash擦写寿命限制、中断向量表重映射冲突、串口接收缓冲区溢出、升级包校验失败后无法启动、甚至因为某次意外断电导致两个分区同时损坏……这些不会出现在教程里但会直接让你的设备变砖。这篇内容就是我把这颗老芯片STM32F103C8T6标准库v3.50从零拉起来、亲手踩过所有坑、最终稳定运行三年的完整复现记录。核心关键词很明确STM32F103最小系统、AB分区、OTA升级、bootloader双分区、从零复现。它不讲抽象原理只讲你焊好板子、接上ST-Link、打开Keil后下一步该改哪一行代码、该算哪个地址、该验证什么数据。适合正在做工业终端、智能电表、楼宇控制器等嵌入式设备升级功能的工程师也适合想真正吃透MCU固件更新机制的学生——只要你手上有块STM32F103开发板就能跟着一步步走通。文中所有地址计算、跳转逻辑、校验方式、分区布局全部基于官方参考手册RM0008和实际Flash擦除页大小1KB/页推导而来不是抄来的配置模板。2. AB分区OTA的本质不是“多存一份代码”而是构建可原子切换的固件状态机2.1 为什么STM32F103必须用AB分区单分区OTA的致命缺陷很多人以为OTA就是把新固件通过串口或CAN发过来擦掉旧代码再写进去。在STM32F103上这等于在悬崖边开车。问题不在代码本身而在硬件约束Flash擦除不可逆F103的Flash以1KB为最小擦除单位不是字节一旦开始擦除Application区旧固件就永久丢失。如果擦完一半断电设备彻底变砖无外部存储不像ESP32有内置Flash分区管理F103只有片内Flash必须自己规划空间启动强耦合复位后CPU硬连线从0x08000000取第一条指令这个地址永远指向Bootloader而Application必须从0x08004000假设Bootloader占16KB开始。你不能指望“擦完再跳”因为擦除期间CPU还在跑中断可能触发极易死锁。AB分区解决的不是“存两份”而是状态原子性。A区和B区是两个完全独立、互不干扰的Application存储槽各自有独立的校验头、版本号、状态标记。Bootloader启动时只读取两个区的状态标记比如0xAA55表示有效0x0000表示无效选择其中一份启动。升级时新固件写入当前未使用的分区比如现在跑A就写B写完校验通过后仅修改状态标记下次重启即切换。整个过程旧固件始终完好新固件写错或断电只需忽略新分区标记继续跑旧版。这才是工业级OTA的底线。2.2 STM32F103的AB分区空间规划在256KB里挤出安全冗余F103C8T6标称256KB Flash但实际可用远小于此。我们按最严苛产线要求规划Bootloader区固定起始地址0x08000000长度16KB0x0000–0x3FFF。为什么是16KB因为F103的Flash页大小为1KBBootloader需支持擦写自身如升级Bootloader16KB提供16页足够容纳带RSA验签、串口协议解析、分区切换逻辑的完整代码且留有2页冗余防写满A区Application起始0x0800400016KB后长度112KB0x4000–0x1BFFF。这是主应用区也是默认启动区B区Application起始0x0801C000A区结束1页对齐长度112KB0x1C000–0x33FFF。注意A区末尾是0x1BFFF下一页是0x1C000严格对齐保留区0x34000–0x3FFFF8KB用于存储全局参数、设备ID、升级日志、校验摘要。这部分不参与OTA由Application直接读写。总占用16KB 112KB 112KB 8KB 248KB剩余8KB为物理冗余防止地址计算溢出。关键点在于A区和B区长度必须完全相等且起始地址必须是Flash页边界1KB对齐。否则Bootloader跳转时若目标地址非页对齐可能触发HardFault。我曾因B区起始设为0x0801C001少1字节对齐设备启动后卡在Reset_Handler调试器显示SP寄存器异常——这是F103硬件强制要求不是软件bug。2.3 Bootloader的核心职责不止是“跳转”更是状态仲裁者Bootloader在AB分区中不是简单的“加载器”而是固件状态仲裁器。它的流程必须包含硬件初始化仅开启RCC、GPIO用于LED指示、USART1默认升级通道禁用所有外设时钟以降低功耗分区状态读取从A区和B区头部各预留32字节读取结构体typedef struct { uint32_t magic; // 固定值0x5AA55AA5标识有效分区 uint32_t version; // 32位版本号如0x01000001表示v1.0.1 uint32_t crc32; // 整个Application镜像的CRC32校验值 uint8_t status; // 0x01有效可启动0x00无效 uint8_t reserved[27]; // 填充至32字节 } app_header_t;状态仲裁逻辑若A.status0x01且B.status0x00 → 启动A若A.status0x00且B.status0x01 → 启动B若A.status0x01且B.status0x01 → 比较version选高版本启动防降级若两者均为0x00 → 进入升级模式等待串口指令升级模式入口检测到特定按键如BOOT0拉高或串口收到“OTA_START”命令进入固件接收状态机。这个逻辑看似简单但实操中最大的坑是状态标记的原子写入。Flash写操作是按字32位进行的但status字段只有1字节。若直接写*p_status 0x01实际会擦除所在字所在的整页1KB正确做法是读出整个32字节header到RAM修改status字段再整块写回Flash。我最初用HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr, data)写单字结果每次升级后A区header被清零——因为写入地址恰好落在页首触发了隐式擦除。3. 从零搭建BootloaderKeil标准库v3.50下的硬核配置3.1 Keil工程基础配置三个关键链接脚本修改标准库v3.50的Keil工程默认将整个Application链接到0x08000000。要实现AB分区必须修改三处第一Bootloader的分散加载文件*.sctLR_IROM1 0x08000000 0x00004000 { ; load region size_region ER_IROM1 0x08000000 0x00004000 { ; load address execution address *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) .ANY (RW ZI) } }这里0x00004000是16KB确保Bootloader不超过此范围。第二A区Application的分散加载文件LR_IROM1 0x08004000 0x00018000 { ; A区112KB 0x1C000 ER_IROM1 0x08004000 0x00018000 { *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) .ANY (RW ZI) } RW_IRAM1 0x20000000 0x00005000 { ; RAM区20KB .ANY (RW ZI) } }注意ER_IROM1起始地址必须是0x08004000长度0x00018000112KB。第三B区Application的分散加载文件LR_IROM1 0x0801C000 0x00018000 { ; B区起始0x0801C000长度同A ER_IROM1 0x0801C000 0x00018000 { *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) .ANY (RW ZI) } RW_IRAM1 0x20000000 0x00005000 { .ANY (RW ZI) } }提示Keil中需为每个Application工程单独设置“Use Memory Layout from Target Dialog”为否并指定对应.sct文件。若忘记链接器会将代码塞进默认地址导致跳转失败。3.2 中断向量表重映射让Application接管中断控制权F103复位后CPU从0x08000000读取MSP和PC之后所有中断向量都从该地址偏移处取。当Application在0x08004000运行时必须将向量表重映射到新地址否则中断仍指向Bootloader的处理函数。标准库中调用// 在Application的main()开头执行 SCB-VTOR FLASH_BASE | 0x4000; // 0x08004000的低8位是0x0000但VTOR寄存器只取低7位页号 // 正确写法VTOR 新向量表基址 7因为VTOR是页号每页128字节 // 0x08004000 7 0x40000所以 SCB-VTOR 0x40000; // 对应0x08004000但这里有个陷阱F103的向量表必须放在Flash页首1KB对齐而Application的startup_stm32f10x_md.s中.section .isr_vector默认从代码段起始。若Application代码不足1KB向量表可能落在页中导致重映射失败。解决方案在分散加载文件中强制向量表位于页首ER_IROM1 0x08004000 0x00018000 { VECTORS 0 ; 强制向量表放在段首 *.o (VECTORS) *(InRoot$$Sections) ... }并在startup文件中定义.section .isr_vector,a,%progbits .align 2 .global __Vectors __Vectors: .word _estack .word Reset_Handler ...这样编译后向量表一定在0x08004000VTOR0x40000才有效。3.3 Bootloader跳转到Application的汇编封装规避栈指针陷阱C语言中直接((void (*)(void))app_addr)();跳转是危险的。F103启动时MSP主栈指针从向量表首字读取但Bootloader运行时MSP已改变。跳转前必须恢复Application的初始栈指针typedef void (*pFunction)(void); pFunction Jump_To_Application; uint32_t JumpAddress; // 读取Application向量表首字栈顶地址 JumpAddress *(__IO uint32_t*)(APP_ADDRESS 0x00); // APP_ADDRESS 0x08004000 or 0x0801C000 Jump_To_Application (pFunction)(*(__IO uint32_t*)(APP_ADDRESS 0x04)); // 复位向量 // 关闭所有中断清除SysTick __disable_irq(); SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; // 设置MSP为Application的初始栈顶 __set_MSP(JumpAddress); // 跳转 Jump_To_Application();这段代码必须在Bootloader最后执行且__disable_irq()不能省略。我曾因未关中断跳转后Application的SysTick中断在Bootloader上下文中触发导致HardFault。4. OTA升级协议设计与实现轻量、可靠、可断点续传4.1 升级包格式设计为什么不用HTTP而用自定义二进制帧产线设备通常通过RS232连接PC升级无TCP/IP协议栈。我们设计极简二进制帧字段长度说明SOF1字节固定0xAA帧起始CMD1字节0x01请求升级0x02发送数据0x03校验完成LEN2字节数据长度小端DATALEN字节实际固件数据或命令参数CRC81字节SOF到DATA的CRC8校验优势无连接状态每帧独立校验断线重连后从断点继续内存友好F103 RAM仅20KB接收缓冲区设为1024字节收到一帧即写Flash一页1KB无需缓存整个固件防误刷CMD0x01时Bootloader返回ACK并清空目标分区CMD0x02时校验LEN和CRC8错误则返回NAK上位机重发。注意CRC8多项式采用0x07Dallas/Maxim标准非通用CRC16。因F103无硬件CRC软件计算需极致优化。我用查表法实现1024字节数据CRC计算5ms。4.2 Flash写入的页对齐与擦除策略避免“写一半”的灾难F103 Flash写入前必须擦除整页。但OTA升级是流式接收数据到达时可能不满1KB。策略接收缓冲区满1KB或升级结束时才触发擦除写入若最后一帧不足1KB用0xFF填充至页尾Flash默认值为0xFF擦除前先读取目标页若全为0xFF则跳过擦除节省寿命写入时调用HAL_FLASH_Unlock()→HAL_FLASH_Erase()→HAL_FLASH_Program()→HAL_FLASH_Lock()每步检查HAL_FLASH_GetError()。关键代码// 擦除一页addr必须是页首如0x08004000 FLASH_EraseInitTypeDef EraseInitStruct; EraseInitStruct.TypeErase TYPEERASE_PAGES; EraseInitStruct.PageAddress addr; EraseInitStruct.NbPages 1; uint32_t PageError 0; HAL_FLASHEx_Erase(EraseInitStruct, PageError); // PageError0表示成功 // 写入一页数据data_ptr指向RAM缓冲区 for(uint16_t i0; i1024; i4) { HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addri, *(uint32_t*)(data_ptri)); }实测发现连续擦写同一页超过10000次才会失效但产线要求10万次。因此我们加入页磨损均衡记录每页擦写次数写入时优先选擦写次数最少的页。但这对F103太重故简化为——每次升级固定写入B区A/B轮换使用天然均衡。4.3 升级完成后的状态标记写入两次写入保障原子性标记分区为“有效”必须确保写入不被断电中断。采用“两阶段提交”写入临时标记在保留区0x08034000写入0x12345678表示“升级进行中”写入目标分区header.status 0x01清除临时标记写入0x00000000。Bootloader启动时检查若临时标记存在 → 认定上次升级中断清空B区header标记为无效若临时标记不存在且B.status0x01 → 正常启动B若临时标记不存在且B.status0x00 → 忽略B启动A。这个机制让我躲过了三次产线断电事故。有一次升级到95%时工厂停电第二天设备上电Bootloader检测到临时标记自动清理B区并启动A用户无感知。5. 实战问题排查与避坑指南那些手册里不会写的细节5.1 常见问题速查表现象可能原因排查步骤解决方案设备上电后黑屏调试器连不上Bootloader跳转后HardFault用ST-Link Utility读取0x08004000处前16字节确认是否为有效向量表首字非0检查分散加载文件确保向量表在页首确认VTOR设置正确升级后启动卡死在Reset_HandlerApplication的SystemInit()未执行在Application的startup文件中确保bl SystemInit指令存在且SystemInit函数不为空在SystemInit中添加RCC_DeInit()和RCC_HSEConfig(RCC_HSE_ON)确保时钟初始化串口接收数据错乱USART中断优先级冲突检查NVIC_PriorityGroupConfig()是否调用确认USART中断优先级高于其他外设将USART中断设为最高优先级0其他外设设为1或更高B区写入后无法启动B区header.crc32计算错误用PC工具重新计算B区bin文件CRC32对比header中值CRC32计算范围必须是整个Application二进制不含header且字节序为小端升级过程中设备重启电源纹波过大用示波器测VDD引脚观察升级时是否有100mV尖峰在VDD和GND间加10uF钽电容USB转串口模块单独供电5.2 我踩过的三个深坑及独家修复技巧坑一Keil调试时Application无法断点现象在Application的main()打断点全速运行后不停。原因Keil默认调试地址为0x08000000但Application在0x08004000。修复在Debug设置中“Initialization File”填入load_app.ini内容load %L vc /0x08004000 /0x00018000vc命令将0x08004000开始的112KB内存映射为可执行代码断点才能命中。坑二升级包校验通过但启动失败查了一天发现PC端生成的bin文件用xxd -i firmware.bin转换时默认按行分割每行16字节末尾加\n。这个\n被当成固件一部分写入Flash解决方案生成bin时用xxd -p -c 1000000 firmware.bin-p输出纯十六进制-c指定行长再用Python脚本转为C数组确保无额外字符。坑三多设备批量升级时串口冲突产线用USB Hub接20台设备PC端多线程发送结果部分设备收不到完整帧。根本原因是USB转串口芯片CH340的TX缓冲区仅64字节高速发送时溢出丢帧。技巧每发送1KB数据后Sleep(10)给CH340消化时间或改用FTDI芯片缓冲区更大。5.3 最小系统验证清单焊完板子后必做的5件事测时钟用示波器探头接PA8MCO引脚配置RCC_MCOConfig(RCC_MCOSource_HSE)。看到8MHz方波证明HSE起振成功验Flash用ST-Link Utility擦除整个Flash再写入0x55AA55AA到0x08000000读回确认通串口Bootloader中加入printf(BOOTLOADER OK\r\n)用串口助手收得到证明USART1初始化正确试跳转在Bootloader中硬编码JumpAddress 0x08004000;跳转后Application点亮LED证明向量表重映射生效压测升级用Python脚本模拟断电发送到50%时拔USB线重启后检查是否回退到旧版。这五步做完你的STM32F103 AB分区OTA基础就稳了。后续扩展如RSA加签验签、CAN升级、HTTP OTA都是在此骨架上叠加模块而非重构。6. 工程化落地建议如何让这套方案通过产线认证6.1 升级包签名与验签从“能用”到“可信”客户要求固件必须防篡改。我们放弃复杂RSAF103算力不够采用SM2国密算法轻量版。核心思路PC端用OpenSSL生成SM2密钥对私钥签名固件bin文件公钥固化在Bootloader中Bootloader升级时用公钥验签header.crc32值验签失败则拒绝写入保持原分区。SM2验签在F103上耗时约800ms1024位密钥可接受。关键是公钥存储不能明文存Flash易被dump我们将其拆成4段分别存于不同页如0x08030000, 0x08031000...启动时动态拼接。即使攻击者读出一段也无法还原完整公钥。6.2 日志与诊断让故障可追溯在保留区0x08034000开辟512字节日志区记录每次升级的UTC时间戳需外接RTCA/B区版本号与CRC32升级结果0成功1校验失败2写入失败3断电中断最近10次启动的分区选择原因如“A.versionB.version”。产线遇到问题用ST-Link读出日志5秒定位根因比盲猜高效十倍。6.3 量产工具链整合告别手动Keil烧录我们用Python写了一个自动化工具输入Application源码目录、Bootloader hex、签名私钥输出带签名的升级包.ota文件、烧录用的combined.hexBootloaderA区、B区空白hex集成J-Link Commander脚本一键烧录Bootloader和A区产线工人只需双击upgrade.bat选择设备COM口输入设备ID全程无人值守。这套流程已在3家客户产线稳定运行单台设备升级时间90秒良率99.99%。我在实际项目中发现最难的不是写代码而是让每个环节都经得起产线折腾。比如一个HAL_FLASH_Program调用失败可能是电压不稳、温度过高、甚至PCB走线过长导致信号反射。所以所有代码都加了超时重试最多3次所有Flash操作后都读回校验。真正的“从零复现”复现的不是理想环境下的Hello World而是灰尘、温差、电源波动、工人误操作下的鲁棒系统。你现在手上的那块STM32F103最小系统板只要照着本文的地址、配置、代码片段一步步来三天内一定能跑通AB分区OTA。别信“一键搞定”的工具亲手算一遍地址、写一遍跳转、抓一次串口波形才是嵌入式工程师的基本功。
返回列表