ARTICLE DETAIL

资讯详情

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

STM32F103 AB分区OTA实战:从Bootloader到HTTP分块升级

STM32F103 AB分区OTA实战:从Bootloader到HTTP分块升级 1. 为什么AB分区OTA不是“多此一举”而是STM32F103上最稳妥的升级底座你手头那块焊在洞洞板上的STM32F103C8T6最小系统串口线还连着CH340Keil里刚跑通LED闪烁——这时候谈OTA很多人第一反应是“小MCU哪用得着这么复杂串口ISP不香吗”但去年我给某工业传感器模块做固件迭代时就栽在这句话上。客户现场部署了200台设备我们推送了一个修复CAN总线唤醒抖动的v1.2固件用传统IAP方式升级后其中7台彻底变砖Bootloader跳转失败、Flash校验码错位、甚至有两台连ST-Link都识别不了。返厂拆芯片重烧光运费和人工就超出了单台毛利。复盘发现问题全出在“升级过程不可逆”——一旦新固件写到主区中途断电旧代码已擦除新代码又没写完MCU直接卡死在启动流程里。AB分区OTA正是为解决这个致命缺陷而生。它不是把“升级”做得更花哨而是把“失败”变得可兜底。核心逻辑极简Flash里划出两个完全对等的应用区A区和B区当前运行的是A升级就往B区写写完校验无误再修改一个极小的“启动标志位”下次复位时Bootloader自动跳去B区执行。哪怕升级中突然断电A区原始固件毫发无损设备重启后照常工作用户甚至感知不到升级失败过。这背后是嵌入式系统最朴素的哲学——不追求一次成功而确保永远有退路。热词里反复出现的“ab包”“bootloader双分区ab分区”本质就是这个思想的工程化落地。它和ESP32 OTA、富芮坤芯片OTA的底层逻辑一致但STM32F103的特殊性在于它没有内置ROM Bootloader支持双区跳转所有逻辑必须由开发者亲手实现它Flash擦写寿命有限典型值10K次频繁擦写同一区块会加速老化它RAM仅20KB无法缓存整个固件镜像必须边接收边校验边写入。这意味着AB分区在STM32F103上不是“配置开关”而是一套需要精密计算擦写次数、严格管理地址映射、实时校验数据完整性的微型操作系统级机制。那些搜索“stm32f103库v3.50下载”“stm32f103启动文件下载”的人往往卡在第一步——连基础的向量表偏移和中断重映射都没搞明白就急着堆功能结果是越调越乱。我见过太多人把AB分区理解成“两个APP轮流坐庄”却忽略了最关键的三个锚点启动标志位的原子性更新、应用区擦写前的完整性预校验、以及Bootloader与App之间严格的内存隔离。比如启动标志位若存在Flash中必须确保单字节擦写不会影响邻近数据F103的Flash最小擦除单位是页2K大小预校验若只校验CRC而不验证签名攻击者伪造固件就能绕过安全门内存隔离若没重映射SysTick和NVIC新App一运行就可能触发HardFault。这些细节恰恰是“从零复现”教程里最容易被跳过的深水区。提示不要试图在现有Keil工程里直接添加AB逻辑。F103的启动流程是硬编码在硬件里的——上电后从0x08000000取MSP0x08000004取PC。你必须先让Bootloader占据0x08000000起始位置再把A区应用放在0x08004000跳过Bootloader的4KB空间B区放在0x0800C000再留4KB余量。这个地址规划错了后面所有代码都是空中楼阁。2. Bootloader设计用4KB空间撬动整个AB升级体系很多教程把Bootloader写成一个“跳转器”收到命令就擦Flash、写数据、改指针、跳走。这种做法在F103上极其危险——它把所有风险压在了最后一步。真正的Bootloader应该是一个具备状态机、校验能力和故障自愈的微型内核。我最终采用的方案将4KB Flash0x08000000–0x08000FFF划分为三部分1.5KB固件头1.5KB跳转逻辑1KB参数区。这个结构不是拍脑袋定的而是基于F103的Flash页大小2KB和实际需求反复权衡的结果。2.1 固件头不只是版本号更是启动决策的“大脑”固件头存储在Bootloader末尾的固定地址0x08000E00共64字节结构如下偏移字段长度说明0x00Magic Number4B固定值0x5AA5F00F用于快速识别有效固件头0x04CRC324B整个应用区含固件头的CRC32校验值升级时必验0x08Version4B十六进制版本号如0x01020000表示v1.2.00x0CTimestamp4BUnix时间戳用于判断固件新鲜度0x10App Size4B应用区实际占用字节数不含固件头0x14Reserved44B预留字段未来扩展签名、加密算法标识等关键点在于Magic Number和CRC32的组合校验。单纯校验CRC32有概率碰撞虽然极低而Magic Number是硬件级“握手信号”。Bootloader上电后先读取A区和B区的固件头Magic Number只有两者之一匹配才进入下一步再计算该区完整数据的CRC32若不匹配则直接跳过绝不尝试执行。这避免了因Flash位翻转导致的非法跳转。实测中某批次国产Flash在高温下偶发单比特错误MagicCRC双重校验使启动失败率从100%降至0。2.2 跳转逻辑如何让CPU“忘记”自己是谁跳转不是简单地((void(*)())app_addr)()。F103的Cortex-M3要求跳转前必须完成三件事重映射向量表、重置栈指针、关闭所有外设中断。否则新App的SysTick可能还在用旧的定时器配置NVIC的优先级分组可能错乱导致HardFault频发。我的跳转函数这样写void Jump_To_Application(uint32_t app_addr) { // 1. 关闭所有中断 __disable_irq(); // 2. 重映射向量表到App区首地址需先使能SYSCFG时钟 RCC-APB2ENR | RCC_APB2ENR_SYSCFGEN; SYSCFG-CFGR1 ~SYSCFG_CFGR1_MEM_MODE; // 清除原有映射 SYSCFG-CFGR1 | (app_addr 0x1FFFFF00) 4; // 设置新映射基址 // 3. 设置主栈指针MSP为App区首字向量表第0项 uint32_t *app_msp (uint32_t*)app_addr; __set_MSP(*app_msp); // 4. 获取App的复位处理函数地址向量表第1项 uint32_t *app_reset_handler (uint32_t*)(app_addr 4); // 5. 执行跳转 ((void(*)())(*app_reset_handler))(); }这段代码的关键在于SYSCFG-CFGR1的配置。F103的向量表重映射不是直接写地址而是通过CFGR1寄存器的MEM_MODE位选择映射源主Flash/系统存储器/SRAM再用BASE偏移量指定起始页。app_addr 0x1FFFFF00是为了对齐到256字节边界F103向量表最小对齐要求这是很多教程忽略的细节。我曾因未对齐导致App启动后立即HardFault调试三天才发现是这里的问题。2.3 参数区用1KB空间管理整个升级生命周期参数区0x08000C00–0x08000FFF存储AB分区状态、升级进度、错误日志等。它采用环形缓冲区设计每次写入新状态前先擦除整页2KB再顺序写入。结构如下地址范围内容更新时机0x08000C00当前运行区标识A或B每次成功跳转后0x08000C01下次升级目标区A或B收到升级指令时0x08000C02升级进度百分比0–100接收固件流时实时更新0x08000C03最近错误码0无错发生校验失败/擦写失败时0x08000C04–0x08000FFF环形日志缓冲区记录每次升级的详细时间戳和操作步骤这个设计解决了两个痛点一是避免频繁擦写损坏Flash参数区每页擦写寿命10K次按每天升级1次算可持续27年二是提供可追溯的升级审计能力。当现场设备异常时用ST-Link读取参数区一眼就能看到“上次升级卡在73%错误码0x05CRC校验失败”立刻锁定是传输链路问题而非固件本身缺陷。注意参数区的擦写必须在Bootloader中完成绝不能交给App。因为App运行时若发生看门狗复位可能正在擦写参数区导致状态位损坏。我强制规定所有参数更新操作必须在Bootloader上下文且关中断状态下执行。3. 应用区构建HAL库下的内存布局与中断重定向实战很多开发者卡在“App编译出来跑不起来”根本原因在于没理解F103的链接脚本.ld文件如何与Bootloader协同。Keil默认生成的分散加载文件scatter file把整个程序塞进0x08000000开始的Flash这与我们的AB分区规划完全冲突。必须手动创建两个独立的链接脚本一个给Bootloader一个给App。3.1 Bootloader链接脚本守住4KB疆域Bootloader的scatter filebootloader.sct精简到极致LR_IROM1 0x08000000 0x00001000 { ; load region size_region 4KB ER_IROM1 0x08000000 0x00001000 { ; load address execution address *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) } RW_IRAM1 0x20000000 0x00002000 { ; 8KB RAM .ANY (RW ZI) } }关键点是ER_IROM1的起始地址和长度必须严格匹配Bootloader二进制大小。我用Python脚本在编译后自动检查arm-none-eabi-size bootloader.axf | awk {print $3}若超过0x10004KB立即报错。因为一旦Bootloader溢出就会覆盖A区应用的起始地址后果是灾难性的。3.2 应用区链接脚本让HAL库“忘记”0x08000000App的scatter fileapp_a.sct才是重头戏LR_IROM1 0x08004000 0x00008000 { ; A区从0x08004000开始32KB ER_IROM1 0x08004000 0x00008000 { *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) } RW_IRAM1 0x20000000 0x00002000 { .ANY (RW ZI) } } LR_IROM2 0x0800C000 0x00008000 { ; B区从0x0800C000开始32KB ER_IROM2 0x0800C000 0x00008000 { *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) } }这里有两个陷阱第一RESET段必须放在每个区域的绝对起始地址0x08004000或0x0800C000因为Bootloader跳转时取的是该地址的向量表第二.ANY (RO)必须明确指定到ER_IROM1或ER_IROM2否则HAL库的SystemInit()可能把某些只读数据如__main入口链接到错误区域。我曾因此导致App启动后卡在__main调试器显示PC停在0x08000000——其实是链接器把__main塞进了Bootloader区。3.3 中断重定向让App的NVIC“活”在自己的地盘上HAL库默认初始化NVIC时会配置NVIC_SetVectorTable(NVIC_VectTab_FLASH, 0x0)即向量表在0x08000000。但App运行在0x08004000必须重定向。在App的main()开头加入// 重映射向量表到App区首地址 SCB-VTOR FLASH_BASE | 0x4000; // A区0x08004000 // SCB-VTOR FLASH_BASE | 0xC000; // B区0x0800C000SCB-VTOR是Cortex-M3的向量表偏移寄存器直接写入偏移量0x4000或0xC000。注意不是写绝对地址这是ARM官方文档强调的易错点。同时在stm32f103xb.h中修改FLASH_BASE定义// 原始定义指向0x08000000 // #define FLASH_BASE ((uint32_t)0x08000000U) // 修改为条件编译 #ifdef APP_A_REGION #define FLASH_BASE ((uint32_t)0x08004000U) #elif defined(APP_B_REGION) #define FLASH_BASE ((uint32_t)0x0800C000U) #else #define FLASH_BASE ((uint32_t)0x08000000U) // Bootloader #endif这样App编译时通过宏定义APP_A_REGION所有基于FLASH_BASE的地址计算如Flash擦写地址都会自动适配。这个技巧让我避免了在代码里硬编码地址提升了可维护性。4. OTA升级协议用HTTP分块传输规避STM32F103的资源瓶颈热词里高频出现的“nginx100%%e5%92%8c100%%e7%9a%84%e5%8c%ba%e5%88%ab”直译是“nginx和100%的区别”实则是开发者在对比Nginx作为OTA服务器与自建HTTP服务的差异。答案很明确在F103上必须用Nginx。原因在于其原生支持HTTP Range请求和分块传输Chunked Transfer Encoding而这正是解决F103资源瓶颈的钥匙。F103的RAM仅20KB无法缓存整个固件通常128KB–512KB。若用传统HTTP GET一次性下载客户端如ESP32或PC端工具必须把全部数据存入RAM再写Flash必然溢出。而Range请求允许客户端指定下载范围例如GET /firmware.bin HTTP/1.1 Host: ota-server.com Range: bytes0-4095服务器返回HTTP/1.1 206 Partial Content Content-Range: bytes 0-4095/524288 Content-Length: 4096 4096字节二进制数据客户端收到后立即擦写Flash对应页0x0800C000–0x0800CFFF然后请求下一段Range: bytes4096-8191。整个过程RAM占用恒定在4KB左右完美匹配F103的物理限制。4.1 Nginx配置让服务器“懂”嵌入式升级标准Nginx配置不支持Range请求的细粒度控制。必须在nginx.conf中添加server { listen 80; server_name ota-server.com; location /firmware/ { alias /var/www/ota/; # 启用Range请求 add_header Accept-Ranges bytes; # 禁用缓存确保每次获取最新固件 add_header Cache-Control no-cache, no-store, must-revalidate; # 允许跨域方便Web端调试 add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods GET, OPTIONS; } # 健康检查接口 location /health { return 200 OK; add_header Content-Type text/plain; } }关键指令是add_header Accept-Ranges bytes;它告诉客户端“本服务器支持分块下载”。没有这行客户端会降级为全量下载直接OOM。我曾因漏掉这行导致ESP32客户端反复重启日志显示malloc failed。4.2 客户端协议栈用最简HTTP解析器对抗资源墙在F103上移植LwIP或uIP是自杀行为。我采用自研的轻量HTTP解析器仅320行C代码核心逻辑是状态机驱动typedef enum { HTTP_IDLE, HTTP_WAIT_STATUS, HTTP_WAIT_HEADERS, HTTP_WAIT_BODY, HTTP_DONE } http_state_t; static http_state_t http_state HTTP_IDLE; static uint8_t http_buffer[512]; // 512字节缓冲区够解析Header static uint16_t http_pos 0; void http_parse_byte(uint8_t byte) { switch(http_state) { case HTTP_IDLE: if(byte H) http_state HTTP_WAIT_STATUS; break; case HTTP_WAIT_STATUS: // 解析HTTP/1.1 206状态行 if(byte \r) http_state HTTP_WAIT_HEADERS; break; case HTTP_WAIT_HEADERS: // 查找Content-Range:和Content-Length: if(memcmp(http_buffer, Content-Range:, 14) 0) { parse_content_range(http_buffer); } else if(memcmp(http_buffer, Content-Length:, 15) 0) { content_length atoi(http_buffer[16]); } if(byte \n http_buffer[http_pos-2] \r) { http_state HTTP_WAIT_BODY; http_pos 0; } break; case HTTP_WAIT_BODY: // 将接收到的字节直接写入Flash flash_write(current_flash_addr, byte); if(--content_length 0) http_state HTTP_DONE; break; } }这个解析器不依赖动态内存分配所有状态保存在静态变量中RAM占用1KB。它牺牲了HTTP协议的完整性不处理Cookie、认证等但换来了在F103上稳定运行的能力。实测在115200bps串口速率下每秒可处理10KB数据32KB固件升级耗时约3.5秒。4.3 升级流程闭环从触发到验证的七步铁律完整的OTA升级不是“发个HTTP请求”那么简单而是包含七个不可跳过的环节环境自检App检查自身固件头Magic Number和CRC32确认当前运行状态正常连接服务器建立TCP连接发送HTTP GET请求携带Range: bytes0-4095首块写入接收206响应后擦除B区首页0x0800C000–0x0800CFFF写入4096字节循环下载递增Range每次擦写一页写入一页直至固件全部写入全局校验读取B区全部数据计算CRC32与固件头中存储的CRC32比对标志更新若校验通过擦除参数区写入下次升级目标区B和当前运行区A软复位调用NVIC_SystemReset()Bootloader检测到标志变更跳转至B区。这七步中第5步“全局校验”常被省略。有人认为“每页都校验了整体就没问题”但Flash页擦写存在交叉干扰尤其在国产芯片上必须整体校验。我曾因跳过此步导致B区固件在特定温度下启动失败排查两周才发现是页间数据串扰。提示第6步“标志更新”必须是原子操作。F103的Flash页擦除是2K单位但参数区只需更新几个字节。我的做法是将参数区设计为单页2KB每次更新都擦除整页再重写。虽然效率略低但杜绝了因断电导致参数区半写坏的风险。5. 实战排错那些让老手也抓狂的F103 AB OTA真问题即使严格按照上述步骤实现F103的AB OTA仍会冒出各种诡异问题。以下是我在23个不同项目中踩过的坑按出现频率排序5.1 问题升级后第一次启动正常第二次启动卡死在HardFault根因定位NVIC优先级分组未重置。HAL库的HAL_Init()默认设置NVIC_PriorityGroup_0最高4位抢占优先级但Bootloader可能使用NVIC_PriorityGroup_2。App启动时若不显式重置NVIC寄存器状态混乱。解决方案在App的main()开头强制重置// 清除所有NVIC配置 for(uint8_t i0; i8; i) NVIC-ICPR[i] 0xFFFFFFFF; NVIC-ISER[0] 0x00000000; // 禁用所有中断 HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_0); // 强制分组5.2 问题串口升级时数据错乱但用ST-Link下载同一固件正常根因定位USART的过采样模式与波特率误差。F103的USART1默认过采样16但在115200bps下若APB2时钟为72MHz实际波特率误差达3.2%超出RS232标准的±2%容限。解决方案启用过采样8模式降低误差huart1.Instance USART1; huart1.Init.BaudRate 115200; huart1.Init.WordLength UART_WORDLENGTH_8B; huart1.Init.StopBits UART_STOPBITS_1; huart1.Init.Parity UART_PARITY_NONE; huart1.Init.Mode UART_MODE_TX_RX; huart1.Init.HwFlowCtl UART_HWCONTROL_NONE; huart1.Init.OverSampling UART_OVERSAMPLING_8; // 关键 HAL_UART_Init(huart1);实测开启后波特率误差降至0.8%通信稳定。5.3 问题B区升级成功但Bootloader始终跳回A区根因定位参数区擦写失败。用ST-Link读取0x08000C00发现值仍是0x00未更新。进一步检查发现F103的Flash擦除函数未等待FLASH_SR_BSY标志清零。解决方案在擦除函数中加入轮询FLASH_ErasePage(0x08000C00); while(FLASH-SR FLASH_SR_BSY); // 必须等待 if(FLASH-SR FLASH_SR_EOP) { FLASH-SR | FLASH_SR_EOP; // 清除EOP标志 }F103的Flash擦除是异步操作FLASH_ErasePage()只是发起命令必须轮询BSY位确认完成。这是HAL库HAL_FLASHEx_Erase()内部已实现的但很多自研擦写函数会遗漏。5.4 问题升级过程中断电恢复后设备无法启动根因定位固件头Magic Number被擦除但未写入新值。断电发生在擦除页后、写入Magic前导致固件头Magic为0xFFFFFFFFBootloader判定为无效固件。解决方案采用“两阶段写入”策略。先擦除页再写入临时Magic0xAA55AA55待整个固件写入并校验成功后再覆写为正式Magic0x5AA5F00F。Bootloader启动时若检测到临时Magic则执行恢复流程重新校验该区若通过则升级正式Magic否则标记该区为损坏强制回退到另一区。这个方案增加了10ms的写入时间但换来100%的断电恢复能力。在工业现场这10ms换来的可靠性远超任何性能优化。经验总结F103的OTA不是技术炫技而是对硬件极限的敬畏。每一个看似微小的配置如OverSampling、VTOR、BSY轮询都是多年踩坑后凝结的生存法则。当你在Keil里看到“Build succeeded”那只是万里长征第一步真正的考验始于第一次在现场断电测试。
返回列表