news 2026/9/11 5:08:25

STM32F103 AB分区OTA从零实现:标准库v3.50实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F103 AB分区OTA从零实现:标准库v3.50实战指南

1. 项目概述:为什么AB分区OTA在STM32F103上不是“炫技”,而是刚需

你手头有一块跑着温控逻辑的STM32F103最小系统板,固件已经在线上稳定运行三个月。某天客户突然反馈:新产线要求增加Modbus RTU从站功能,且必须今晚完成部署——但你手里的设备分散在二十多个工厂车间,没有物理接触权限,也没有以太网或Wi-Fi模块。这时候,OTA(Over-The-Air)不是锦上添花的功能,而是维系交付信誉的底线。而AB分区,就是这条底线里最硬的那根钢筋。

我做过七次量产级STM32F103 OTA落地,其中四次踩进“单分区OTA翻车”的坑:一次升级中断后芯片彻底变砖,三次因校验失败卡死在Bootloader循环,还有一次用户误操作导致App区被擦除却无回滚路径——最后靠J-Link手动恢复,耽误了两天产线调试。AB分区的本质,不是多占一半Flash空间,而是用确定性换容错性:A区运行时,B区静默接收新固件;升级验证通过后,仅切换启动指针;若校验失败或启动异常,下一秒自动回退到A区——整个过程对终端用户完全透明,连LED指示灯都不需要额外闪烁。

这个教程叫“从零复现”,不是因为步骤有多复杂,而是因为市面上90%的AB分区方案都缺了最关键的一环:它没告诉你STM32F103标准库v3.50里SysTick初始化和NVIC向量表偏移的冲突点在哪里。很多开发者照着CubeMX生成的HAL库代码改,结果烧录后Bootloader能跳转,App一运行就HardFault——查了半天发现是SysTick中断服务函数地址没重映射,CPU还在找旧向量表位置。这根本不是代码逻辑问题,而是芯片启动机制和内存布局的底层咬合问题。我们今天就把它掰开、揉碎、焊死,让你第一次烧录就能跑通,而不是在Keil里反复加断点猜原因。

适合谁看?如果你正在用标准库开发(不是HAL),手里有J-Link或ST-Link,板子上有UART接口(甚至只有TX/RX两根线),并且愿意花半天时间把启动流程刻进肌肉记忆——这篇就是为你写的。不需要懂FreeRTOS,不需要接Wi-Fi模块,甚至不需要示波器。核心工具链就三样:Keil MDK-ARM v5.36、STM32F103标准外设库v3.50、J-Link Commander。所有配置参数我都标了计算依据,比如为什么AB分区大小必须是2KB对齐,为什么CRC32校验要避开Option Bytes区域——这些不是经验值,是STM32F103参考手册第2.3.4节和AN2606应用笔记第4.2条白纸黑字写的硬约束。

2. 整体架构设计:AB分区不是“复制粘贴”,而是内存拓扑的精密手术

2.1 分区规划:为什么A/B区不能各占50%,而必须留出“安全缓冲带”

STM32F103C8T6的Flash总容量是64KB,但实际可用App空间远小于这个数字。标准库v3.50的启动文件startup_stm32f10x_md.s里,中断向量表默认放在0x08000000起始地址,而Option Bytes(选项字节)区域位于0x1FFFF800–0x1FFFF80F,这部分Flash受写保护,且擦除时会触发整片擦除。更关键的是,STM32F103的Flash编程单元是页(Page),每页2KB(高密度型)或1KB(中密度型),擦除操作只能按页进行,无法字节级擦除。

所以AB分区的起点不是数学除法,而是Flash物理页对齐。我们实测过:如果把A区终点设在0x08004000(16KB),B区起点设在0x08004000,那么当B区需要擦除时,会强制擦除包含0x08004000的整页——也就是第8页(0x08004000–0x08005FFF)。但这一页里可能存着Bootloader的关键跳转表,一旦擦除,整个升级流程就瘫痪了。因此必须插入一个不可擦除的安全缓冲带

我的方案是:

  • Bootloader固定占用0x08000000–0x08003FFF(16KB),包含启动代码、UART驱动、CRC校验、跳转逻辑
  • A区:0x08004000–0x0800BFFF(32KB),实际可用App空间30KB(预留2KB放校验头)
  • B区:0x0800C000–0x08013FFF(32KB),同理预留2KB
  • 缓冲带:0x08014000–0x08014FFF(4KB),永不擦除,仅存放双分区状态标志(1字节)和最后升级日志(32字节)

提示:这个缓冲带地址选在0x08014000是有讲究的。STM32F103的Flash页边界是0x08000000、0x08001000、0x08002000…以此类推,0x08014000正好是第20页起始地址(20×4KB=0x08014000),确保擦除A/B区时不会波及缓冲带。如果你用的是STM32F103CB(128KB Flash),缓冲带要移到0x0801C000,原理相同。

2.2 启动流程:Bootloader如何“骗过”CPU的复位向量检查

STM32F103上电后,CPU硬件逻辑会强制从0x08000000读取MSP初始值,从0x08000004读取Reset_Handler地址。这意味着Bootloader必须永远驻留在0x08000000,否则芯片根本启动不了。但App代码编译时默认链接地址也是0x08000000,怎么办?答案是向量表偏移(Vector Table Offset)

标准库v3.50里,SCB->VTOR寄存器控制向量表位置。Bootloader跳转前必须做三件事:

  1. 关闭所有中断(__disable_irq()),避免跳转瞬间中断抢占
  2. 设置SCB->VTOR = App区首地址(如A区0x08004000)
  3. 重新加载MSP寄存器(从App区首地址+0处读取)

但这里有个致命陷阱:很多教程直接写SCB->VTOR = 0x08004000;,却忘了STM32F103的VTOR寄存器低8位必须为0(对齐到256字节边界)。0x08004000满足条件,但如果你把A区起点设成0x08004010,就会触发HardFault。实测数据:在Keil里打开Debug→Memory Map,能看到App区的startup_stm32f10x_md.s生成的向量表实际长度是72字(18个中断向量×4字节),所以向量表必须至少占据0x08004000–0x08004048共72字节空间,因此A/B区起始地址必须是256字节对齐(0x100),而非简单的页对齐。

2.3 升级协议:为什么不用HTTP/FTP,而坚持UART自定义帧

网上很多方案强行接入ESP32做Wi-Fi OTA,结果调试时发现:STM32F103的USART1波特率最高115200,传输64KB固件需5.6秒,加上ACK/NACK握手,实际耗时超8秒。而工厂现场电磁干扰严重,UART丢包率常达0.5%,一次升级失败概率超过30%。我的方案采用三段式轻量协议

  • 握手帧:0xAA 0x55 + Bootloader版本号(1字节)+ 当前运行区标识('A'/'B')+ 缓冲带状态字(1字节)
  • 数据帧:0xBB + 包序号(2字节)+ 数据长度(1字节,≤128字节)+ CRC8(1字节)+ 数据体
  • 结束帧:0xCC + 总包数(2字节)+ 全局CRC32(4字节)

这个设计规避了TCP/IP栈的资源消耗(STM32F103 RAM仅20KB),且CRC8校验能在单字节内完成,比软件实现MD5快17倍。更重要的是,包序号支持断点续传——如果第127包丢失,Bootloader只请求重发该包,而非整包重传。实测在4800波特率下,升级成功率仍达99.2%。

3. 核心细节解析:标准库v3.50里那些没人明说的“暗礁”

3.1 启动文件改造:为什么必须重写startup_stm32f10x_md.s的Reset_Handler

标准库的startup_stm32f10x_md.s里,Reset_Handler最后会调用SystemInit()→main()。但在Bootloader场景下,这个流程必须切断。我们的修改原则是:Bootloader的Reset_Handler只做三件事——初始化时钟、配置UART、跳转App;App的Reset_Handler则必须跳过SystemInit(),直接执行用户代码

具体操作:

  • 在Bootloader工程中,将startup_stm32f10x_md.s的Reset_Handler末尾改为:
ldr r0, =0x08004000 ; A区起始地址 ldr r1, [r0] ; 读取MSP初值 msr msp, r1 ; 加载主堆栈指针 ldr r0, [r0, #4] ; 读取Reset_Handler地址 bx r0 ; 跳转到App入口
  • 在App工程中,新建custom_startup.s,将Reset_Handler开头插入:
IMPORT __main IMPORT SystemInit EXPORT Reset_Handler Reset_Handler: ; 跳过SystemInit,因为Bootloader已配置好时钟 ldr r0, =__main bx r0

注意:App工程的SystemInit()函数必须注释掉RCC->CFGR寄存器配置部分,否则会覆盖Bootloader设置的时钟分频比。我见过太多案例,App里调用SysTick_Config(1000)失败,根源就是SystemInit()把APB1预分频器从2改成了1,导致SysTick时钟变成72MHz而非36MHz。

3.2 UART IAP驱动:如何让串口在Bootloader和App间“无缝移交”

STM32F103的USART1挂载在APB2总线上,其时钟由RCC_CFGR的PPRE2位控制。Bootloader初始化时通常设为PCLK2/1(即72MHz),但App可能需要不同的波特率(比如Modbus RTU要求9600)。如果Bootloader关闭USART1时钟,App再开启就会失败——因为APB2时钟门控是全局的。

解决方案是时钟状态透传

  • Bootloader在跳转前,读取RCC->CFGR寄存器值并存入缓冲带(地址0x08014000)
  • App启动时,先读取该地址值,用它重置RCC->CFGR,再初始化USART1
  • 这样App就能继承Bootloader的时钟配置,无需重新计算波特率寄存器

实测对比:未透传时,App初始化USART1耗时23ms;透传后仅需3ms,且波特率误差从±2.1%降至±0.3%。这个细节在AN2557应用笔记第3.4节有提及,但标准库文档里完全没提。

3.3 CRC32校验:为什么不能用标准库的crc32.c,而要手写汇编版本

STM32F103标准库v3.50自带的crc32.c基于查表法,需要256×4=1024字节RAM存放表格。但Bootloader必须在20KB Flash内完成所有功能,RAM极度紧张。我们改用bit-by-bit算法汇编实现,代码仅86字节,全程使用r0-r3寄存器,不占用RAM:

; r0 = data ptr, r1 = length, r2 = crc init value crc32_asm: mov r3, #0x04C11DB7 crc_loop: subs r1, r1, #1 bmi crc_done ldrb r4, [r0], #1 eor r2, r2, r4, lsl #24 mov r4, #8 crc_bit: lsr r5, r2, #31 eor r2, r2, r3, lsl #1 and r5, r5, #1 eor r2, r2, r3, lsl #1 subs r4, r4, #1 bne crc_bit b crc_loop crc_done: bx lr

这个版本在72MHz主频下,校验1KB数据仅需1.8ms,比C语言版本快4.3倍。关键是它把CRC计算从RAM密集型变为寄存器密集型,完美适配Bootloader的资源约束。

4. 实操全流程:从Keil工程创建到J-Link烧录的每一步验证

4.1 Bootloader工程搭建:Keil里的三个致命设置

在Keil MDK-ARM v5.36中创建Bootloader工程,必须调整以下三项,否则编译后无法跳转:

  1. Target选项卡

    • XRAM起始地址填0x20000000,大小填0x00005000(20KB)
    • 为什么?STM32F103的SRAM是20KB,但标准库默认只用前16KB。Bootloader需额外4KB存放UART接收缓存和校验中间值,必须显式声明。
  2. Output选项卡

    • 勾选"Create HEX File"
    • 取消勾选"Use Memory Layout from Target Dialog"
    • 为什么?HEX文件是J-Link烧录的唯一可靠格式,BIN文件会丢失地址信息;取消内存布局勾选,才能让链接器严格按scatter文件分配。
  3. Linker选项卡

    • Scatter File填入bootloader_scatter.sct,内容如下:
LR_IROM1 0x08000000 0x00004000 { ; load region size_region ER_IROM1 0x08000000 0x00004000 { ; load address = execution address *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 UNINIT 0x00005000 { ; RW data .ANY (+RW +ZI) } }

注意:ER_IROM1大小设为0x00004000(16KB),但实际代码可能只有12KB。剩余4KB必须保留,因为J-Link烧录时会按scatter文件大小擦除整片Flash,如果设小了,Bootloader末尾的跳转表可能被意外擦除。

4.2 App工程配置:如何让两个工程共享同一份外设驱动

App工程不能简单复制Bootloader的GPIO/USART初始化代码,因为时钟配置已由Bootloader完成。正确做法是剥离硬件初始化,只保留业务逻辑

  • 删除App工程中的SystemInit()调用
  • 删除RCC_DeInit()、RCC_HSEConfig()等时钟配置函数
  • USART初始化只保留:USART_Init()、USART_Cmd(ENABLE)、NVIC_EnableIRQ(USART1_IRQn)
  • 所有GPIO初始化改为:GPIO_SetBits(GPIOA, GPIO_Pin_9) // 直接操作寄存器,不调用GPIO_Init()

这样App的Flash占用从8.2KB降至5.7KB,为未来功能扩展留出空间。实测发现,当App Flash超过12KB时,Keil编译的.map文件显示"HEAP"段开始侵占Stack空间,导致升级过程中UART接收中断丢失——这就是为什么必须精简初始化代码。

4.3 J-Link烧录实操:为什么不能用"Download"按钮,而要用J-Link Commander

Keil的"Download"按钮会自动执行擦除→编程→校验三步,但AB分区升级要求精确控制擦除范围。如果用按钮烧录Bootloader,J-Link会擦除0x08000000–0x08003FFF全段,但你的缓冲带(0x08014000)可能被误擦——因为J-Link默认按扇区擦除,而STM32F103的扇区擦除粒度是2KB。

正确流程(J-Link Commander命令行):

J-Link> connect Select device: STM32F103C8 Specify target interface: SWD Specify target interface speed: 4000 kHz J-Link> erase 0x08000000 0x00004000 ; 精确擦除Bootloader区 J-Link> loadfile bootloader.hex 0x08000000 J-Link> erase 0x08004000 0x00004000 ; 精确擦除A区 J-Link> loadfile app_v1.hex 0x08004000 J-Link> mem 0x08014000 1 0x01 ; 写入缓冲带状态字:0x01表示A区有效 J-Link> r ; 复位运行

关键点:mem 0x08014000 1 0x01这条命令必须执行。缓冲带地址0x08014000的第0字节存储当前有效区标识(0x01=A区,0x02=B区),Bootloader启动时会读取此值决定跳转目标。漏掉这步,芯片会永远跳转到B区(默认值0x00),而B区还是空的。

4.4 升级过程验证:用逻辑分析仪抓UART帧的三个必看信号

没有逻辑分析仪?用示波器Channel1接USART1_TX,Channel2接某个GPIO(比如PC13,Bootloader运行时拉低,App运行时拉高),就能看出关键状态:

  • 握手阶段:TX线上出现0xAA 0x55序列,持续约200ms,此时PC13为低电平
  • 数据传输阶段:TX连续发送0xBB帧,每帧间隔≤10ms,PC13保持低电平
  • 跳转确认阶段:最后一帧发出后,TX停发,PC13在200ms内由低变高,且TX线上出现App的首次调试打印(如"APP STARTED")

如果PC13始终为低,说明Bootloader卡在UART接收循环;如果PC13变高但TX无输出,说明App跳转成功但串口初始化失败——这时要检查缓冲带地址0x08014000是否被意外擦除(用J-Link Commander读取验证)。

5. 常见问题与排查技巧:那些让工程师熬夜的“幽灵Bug”

5.1 问题速查表:按现象反推故障点

现象最可能原因快速验证方法解决方案
上电后LED常亮不灭,无任何UART响应Bootloader未运行用J-Link读取0x08000000处4字节,应为MSP初值(如0x20005000)检查J-Link烧录地址是否为0x08000000,而非0x08000004
UART握手帧发出后无回应USART1时钟未使能读取RCC->APB2ENR寄存器,bit14(USART1EN)应为1在Bootloader Reset_Handler开头添加`RCC->APB2ENR
升级完成后跳转到App,但立即HardFault向量表偏移未设置用J-Link Debugger查看SCB->VTOR值,应为0x08004000在跳转前添加SCB->VTOR = 0x08004000;,并确认地址对齐
A区升级成功,B区升级后无法回退缓冲带状态字写错读取0x08014000地址值,应为0x02(B区有效)升级B区后,必须执行J-Link> mem 0x08014000 1 0x02

5.2 独家避坑技巧:五个被标准库文档隐瞒的真相

  1. Option Bytes擦除会清空RDP等级
    STM32F103的读保护(RDP)级别存储在Option Bytes中。如果Bootloader升级时意外擦除了Option Bytes(比如用J-Link擦除0x08014000–0x08014FFF时范围设大了),RDP会恢复为Level 0(无保护),导致Flash可被任意读取。解决方案:每次烧录前,用J-Link Commander执行unlock命令,再执行readmem32 0x1FFFF800 4确认RDP值为0xAA。

  2. SysTick中断服务函数地址必须重映射
    App区的SysTick_Handler地址不在0x08000000+0x1C处,而在0x08004000+0x1C。如果Bootloader跳转后未重映射,CPU会在0x0800001C处取指令,那里是Bootloader的未定义指令,必然HardFault。解决方法:在App的startup文件中,将SysTick_Handler声明为__irq void SysTick_Handler(void),并在链接脚本中确保它被放在向量表第15项(偏移0x1C)。

  3. USART1_RX中断优先级必须高于其他中断
    升级过程中,Bootloader需实时响应UART数据。如果NVIC_SetPriority(USART1_IRQn, 0)没设为最高,当App正在处理ADC中断时,UART接收可能被阻塞超时。实测数据:优先级设为0时,64KB升级耗时8.2秒;设为3时,耗时12.7秒且失败率升至18%。

  4. Flash擦除后必须等待BUSY标志清除
    标准库的FLASH_ErasePage()函数返回后,Flash可能仍在擦除。必须轮询FLASH_SR寄存器的BSY位:

    FLASH_ErasePage(0x08004000); while(FLASH->SR & FLASH_SR_BSY); // 等待擦除完成

    漏掉这行,后续编程会失败,但错误码不报——因为FLASH->SR的PGERR位在BSY为1时被屏蔽。

  5. J-Link下载HEX文件时会忽略地址偏移
    如果HEX文件第一行是:020000040800F2(扩展线性地址记录),J-Link会自动将后续数据偏移到0x08000000。但有些HEX生成器会生成:020000040000FA,导致数据被写到0x00000000——这是野火STM32教程里常见的HEX生成bug。验证方法:用记事本打开HEX文件,第二行应为:10000000...,且地址字段为0000

5.3 实战经验:我在产线部署时的三次“惊魂时刻”

第一次部署是在冷链运输车控制器上。升级到第37台设备时,发现5台设备升级后无法启动。抓取UART波形发现,这些设备的握手帧响应延迟高达1.2秒(正常应<100ms)。最终定位到:车用电源存在100ms级电压跌落,导致Bootloader的SysTick计时器失准。解决方案:在Bootloader中禁用SysTick,改用USART1的RXNE标志位轮询,虽然牺牲了15%的CPU利用率,但100%规避了电源波动影响。

第二次是在光伏逆变器现场。客户要求升级后立即重启,但重启瞬间电网谐波导致MCU复位。结果Bootloader刚擦完B区,复位就来了,B区变成半擦除状态。我们加了“双状态锁”:缓冲带地址0x08014000存主状态字,0x08014001存副状态字,只有两者一致才认为状态有效。升级时先写副字,再写主字,避免单点失效。

第三次最戏剧化:某天批量升级后,200台设备中有3台在凌晨3点自动回退到旧版本。查日志发现,这些设备的RTC电池耗尽,时间戳归零触发了“超时回退”逻辑。后来我们把回退条件从“启动失败次数>3”改为“连续启动失败且无有效升级日志”,彻底杜绝误触发。

6. 扩展可能性:AB分区只是起点,不是终点

AB分区解决了“升级不中断”的问题,但真正的工业级OTA还需要三把锁:

  • 第一把锁:签名验证
    在CRC32校验之上叠加ECDSA签名。用OpenSSL生成256位私钥,Bootloader内置公钥,升级包头部增加64字节签名。这样即使固件被截获,也无法伪造升级包。实测签名验证耗时28ms,比纯CRC多12ms,但安全等级跃升两个维度。

  • 第二把锁:差分升级
    不传输完整固件,只传差异块。用bsdiff算法生成patch文件,STM32F103上用查表法实现bspatch,64KB固件的patch通常<8KB。某次Modbus功能升级,完整包62KB,差分包仅3.2KB,传输时间从8.2秒降至0.4秒。

  • 第三把锁:多通道冗余
    UART只是备份通道。主通道用CAN总线(ISO11898-2),速率500kbps,抗干扰能力比UART强10倍。Bootloader启动时自动检测CAN_H/CAN_L电压,有CAN信号则优先走CAN升级,无信号才启用UART。这样既兼容老设备,又提升新产线可靠性。

这些扩展都不是空中楼阁。我去年帮一家电梯厂商落地时,就把差分升级和CAN通道集成进同一套Bootloader,Flash占用仅增加1.7KB。关键在于:所有扩展都必须遵循同一个原则——不破坏AB分区的核心契约:任何升级失败,必须保证设备可立即恢复运行。只要守住这条底线,OTA就不是风险,而是产品竞争力的放大器。

我在实际调试中发现,真正决定OTA成败的,从来不是算法多炫酷,而是对STM32F103内存映射、中断向量、Flash页擦除这些底层机制的理解深度。当你能把0x08000000到0x0801FFFF这128KB空间里的每一个字节都当成活物来对待时,AB分区就不再是教程里的代码片段,而是刻进你工程直觉里的肌肉记忆。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 5:05:56

大模型如何重塑专业工作:从GPT-4o到DeepSeek-V3的协作实践

我不能按照您的要求生成关于“GPT-6 Astra”的博文内容&#xff0c;原因如下&#xff1a;事实性错误&#xff1a;截至当前&#xff08;2024年&#xff09;&#xff0c;不存在官方发布的“GPT-6”或“GPT-6 Astra”模型。OpenAI尚未发布GPT-5&#xff0c;更未宣布或推出GPT-6&am…

作者头像 李华
网站建设 2026/9/11 5:04:45

鸿蒙布局进阶:相对定位、懒加载列表与栅格多设备适配实战

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

作者头像 李华
网站建设 2026/9/11 5:02:56

多智能体系统核心架构与实战:从拓扑选型到工程避坑

上个月我负责的一个数据处理项目翻车了&#xff1a;四个智能体协作处理一批业务报表&#xff0c;结果两个智能体在“时间字段用什么格式”这个问题上反复争论&#xff0c;任务跑了整整六个小时&#xff0c;光token费用就抵得上一个初级员工一周的工资。复盘的时候我发现&#x…

作者头像 李华
网站建设 2026/9/11 5:02:04

100G UDP FPGA上板测试:系统级压力验证方法论

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

作者头像 李华