1. 为什么AB分区OTA在STM32F103上不是“开箱即用”,而是必须亲手拧紧每一颗螺丝?
你手头那块印着“STM32F103C8T6”的蓝色最小系统板,刷完官方标准库v3.50的LED闪烁例程后一切正常——但当你搜索“stm32f103 ota升级”时,满屏跳出的却是“esp32 ota”“arduino ota”“腾讯连连 arduino ota”,再往下翻,才零星看到几篇标题带“bootloader”“iap”的文章,点进去一看,要么是HAL库+CubeMX生成的半成品框架,要么是直接贴一段汇编启动代码加一句“自行实现跳转逻辑”,最后配个模糊的接线图。我第一次做这个项目时,就在J-Link烧录器绿灯亮起、串口助手却收不到任何响应的深夜里,盯着示波器上UART引脚那根平直的电平线发了二十分钟呆:不是代码没跑,是它根本没从Bootloader开始跑。
STM32F103的OTA之所以不能像ESP32那样敲几行ArduinoOTA.begin()就搞定,根源在于它的硬件架构和出厂固件设计逻辑完全不同。F103没有内置ROM级的OTA引导程序,它的启动流程完全由用户自己定义:复位后,芯片只认三件事——BOOT0/BOOT1引脚电平、主闪存(Flash)起始地址0x08000000处存放的向量表、以及向量表里第一个字(SP初始值)和第二个字(复位中断入口地址)。这意味着,你写的Bootloader不是“附加功能”,而是整个系统的第一个且唯一的操作系统内核。它必须亲自完成:校验Flash分区状态、解析新固件包格式、擦除目标扇区、写入数据、验证CRC、切换运行分区、最后跳转到App入口——每一步都踩在Flash擦写寿命、中断向量重映射、栈空间管理这些硬边界上。网上那些“stm32f103 bootloader”关键词下混杂的“jlink正版 bootloader sn”“随身wifi解锁bootloader”之类信息,恰恰暴露了行业现状:太多人把Bootloader当成黑盒工具链的一部分,却忘了它本质是一段必须与硬件寄存器搏斗的裸机程序。
AB分区机制在这里不是锦上添花,而是生存必需。F103的Flash扇区擦除是“整扇区抹除”,最小擦除单位是1KB或2KB(取决于具体型号,C8T6为1KB)。如果只用单一分区升级,一旦新固件传输中断或校验失败,旧固件已被擦除,设备直接变砖。AB分区通过将Flash划分为两个等大区域(A区0x08000000起,B区0x08004000起,各32KB),让Bootloader始终保有至少一个可运行的固件副本。但问题来了:F103没有独立的“分区元数据存储区”,你得在每个分区头部硬编码一个标志位(比如第0x100字节存一个uint32_t的版本号+状态码),还要确保Bootloader能准确识别当前应从哪个分区启动——这要求你在每次升级前,必须先擦除目标分区的整个扇区,再写入新固件,最后更新另一个分区的启动标志。这个过程里,UART IAP(In-Application Programming)的可靠性就成了生死线:波特率设错1位,接收缓冲区溢出1字节,CRC校验偏移2字节,都会导致Bootloader误判分区状态,进而跳转到无效地址触发HardFault。
所以,“从零复现”这四个字,不是谦辞,而是铁律。它意味着你要亲手配置SysTick作为超时计时器,手动计算每个Flash扇区的起始地址(查RM0008手册Table 7,C8T6的Sector 0是0x08000000~0x080003FF),用FLASH_Unlock()和FLASH_Lock()包裹每一次写操作,甚至要重写memcpy函数来规避ARM Cortex-M3的未对齐访问陷阱。那些热词里反复出现的“stm32f103(标准库std v3.5)通过rs232串口基于freemodbus v1.6移植”,本质上是在提醒你:当协议栈(Modbus RTU)和升级逻辑(IAP)共用同一UART外设时,中断优先级配置稍有不慎,Modbus从机响应就会被IAP接收中断抢占,导致485总线通信紊乱——这正是我在调试CAN STM32F103 SJW同步跳跃宽度时连带发现的底层耦合问题。
提示:别信“一键生成Bootloader”的宣传。F103的Flash擦写时间(典型值10ms/扇区)和UART接收超时阈值(需大于最大帧间隔)必须根据你的实际电路参数实测标定。我曾因照搬某教程的50ms超时值,在使用CH340 USB转串口芯片时遭遇连续丢包——实测该芯片在Windows驱动下实际帧间隔达62ms。
2. AB分区物理布局与Bootloader内存映射:如何让两套固件在同一个Flash里和平共处
在STM32F103上实现AB分区,第一步不是写代码,而是用尺子(思维上的)精确丈量Flash空间。很多人栽在第一步:以为只要把Flash分成两半就行,结果发现Bootloader自身也占空间,App固件还有中断向量表重映射需求,最终导致分区错位。我们以最常见的STM32F103C8T6(64KB Flash)为例,按工业级可靠方案规划:
| 分区 | 起始地址 | 大小 | 用途 | 关键约束 |
|---|---|---|---|---|
| Bootloader | 0x08000000 | 16KB | 引导程序、IAP逻辑、分区管理 | 必须包含向量表,首地址必须为0x08000000 |
| A区(App1) | 0x08004000 | 24KB | 当前运行固件 | 向量表需重映射至0x08004000 |
| B区(App2) | 0x0800A000 | 24KB | 待升级固件 | 向量表需重映射至0x0800A000 |
这个布局背后有三重硬性逻辑:第一,Bootloader必须驻留在0x08000000,因为芯片复位后只从此地址读取SP和PC;第二,A/B区大小必须相等且为Flash扇区整数倍(C8T6扇区为1KB,故24KB=24扇区);第三,Bootloader自身需预留足够空间——16KB看似充裕,但加入RSA256签名验证、AES128解密、双缓冲UART接收后,实际可用仅约12KB。我最初按12KB规划,结果在加入CRC32校验表后触发链接器报错region FLASH overflowed,被迫重新精简中断服务程序。
真正的难点在于向量表重映射。F103的NVIC默认从0x08000000读取向量表,但App固件编译时链接脚本指定其向量表位于自身基址(如A区0x08004000)。若不重映射,App运行时触发中断会跳转到Bootloader的向量表,必然崩溃。解决方案是App启动时执行:
// App main()开头必须添加 SCB->VTOR = FLASH_BASE + 0x4000; // A区向量表基址 __DSB(); __ISB(); // 数据/指令缓存同步而Bootloader跳转到App前,必须先禁用所有中断(__disable_irq()),清除待处理中断(NVIC_ICPR寄存器),再设置SCB->VTOR,最后用__set_MSP()加载App的栈顶指针。这里有个致命细节:App的startup_stm32f10x_md.s中Reset_Handler末尾的__main调用,会初始化C库全局变量,但Bootloader跳转时并未执行该初始化流程。因此,App中所有全局变量(尤其是static uint8_t buffer[256]这类)必须显式初始化为0,否则可能残留Bootloader运行时的垃圾值。我在测试中发现LED闪烁频率异常,追踪发现是App的定时器计数器变量未初始化,被Bootloader遗留的随机值覆盖。
AB分区的状态管理则更微妙。不能简单用“0xAA55”标记有效分区,因为Flash擦除后全为0xFF,若状态字节恰好被擦除为0xFF,Bootloader会误判分区为空。我的方案是采用三态标记:
0x00000001:A区有效,B区待升级0x00000002:B区有效,A区待升级0x00000000:双区均无效(强制进入Bootloader)
状态字存储于每个分区的固定偏移(如A区0x08004100,B区0x0800A100),写入前先擦除所在扇区——注意,状态字所在扇区可能与固件代码扇区重叠,必须单独规划。例如A区固件占用0x08004000~0x08009FFF(24KB),状态字放在0x0800A000(B区起始),这样擦写状态字不会影响A区代码。这种设计让状态更新成为原子操作:擦除B区状态扇区→写入新状态→擦除A区代码扇区→写入新固件,任一环节失败,Bootloader都能根据残留状态字恢复。
注意:不要在Bootloader中使用
printf调试!标准库printf依赖malloc和文件系统,而Bootloader运行在无OS环境,调用它会导致HardFault。我用过最可靠的调试方式是GPIO翻转+示波器测脉宽:在关键分支(如“检测到B区有效”)前翻转PA0,用示波器看脉冲是否存在,比串口打印快10倍且不干扰UART IAP。
3. UART IAP协议栈深度定制:从裸机接收中断到固件包解析的全链路控制
当Bootloader决定从UART接收新固件时,“串口通信”立刻从外围设备变成系统心脏。网上教程常教你配置USART1为115200波特率、8N1,然后贴一段while(USART_GetFlagStatus(USART1, USART_FLAG_RXNE) == RESET);轮询代码——这在F103上是灾难性设计。原因有三:第一,轮询模式下CPU全程忙等,无法响应其他中断(如定时器超时);第二,UART接收缓冲区仅1字节,高速传输时极易溢出;第三,固件包通常为二进制流,需边接收边校验,轮询无法实现流水线处理。
我的方案是构建三级缓冲架构:
- 硬件FIFO层:启用USART1的DMA接收(通道5),配置DMA循环缓冲区(2KB),DMA满触发中断;
- 协议解析层:DMA中断中将数据搬入环形缓冲区(Ring Buffer),长度16KB,支持多生产者/单消费者;
- 应用逻辑层:主循环中从环形缓冲区提取完整数据包,执行协议解析。
关键代码片段:
// DMA接收完成中断 void DMA1_Channel5_IRQHandler(void) { if(DMA_GetITStatus(DMA1_FLAG_TC5)) { DMA_ClearITPendingBit(DMA1_IT_TC5); // 将DMA缓冲区数据拷贝到环形缓冲区 ring_buffer_write(&rx_ring, dma_buffer, RX_BUFFER_SIZE); DMA_SetCurrDataCounter(DMA1_Channel5, RX_BUFFER_SIZE); // 重置计数器 } } // 主循环中的协议解析 while(ring_buffer_available(&rx_ring) >= HEADER_SIZE) { if(parse_header(&rx_ring, &packet_info)) { // 解析包头(含长度、CRC) if(ring_buffer_available(&rx_ring) >= packet_info.length) { if(validate_packet(&rx_ring, &packet_info)) { // 校验CRC handle_packet(&packet_info); // 写Flash或更新状态 } } } }这个架构解决了核心痛点:DMA解放CPU,环形缓冲区吸收突发流量,协议解析与硬件解耦。但真正考验功力的是协议设计。我摒弃了Modbus RTU(因其帧结构复杂,需处理地址/功能码/校验,增加Bootloader体积),自定义轻量协议:
[SOH:0x01][LEN:2B][CMD:1B][PAYLOAD:LEN B][CRC16:2B][ETX:0x04]其中CMD=0x01为固件数据包,0x02为状态查询,0x03为重启指令。LEN为PAYLOAD长度(不含SOH/ETX),CRC16采用CCITT-FALSE算法(初始值0xFFFF,多项式0x1021)。选择CCITT-FALSE而非标准CRC16,是因为其在嵌入式平台有成熟查表法实现,代码体积仅128字节,而标准CRC16需256字节。
固件包格式同样精心设计。不是直接发送原始.bin文件,而是封装为:
[HEADER:16B][APP_CODE:...][PAD_TO_1KB_ALIGN][CRC32:4B]HEADER包含:Magic Number(0x55AA55AA)、App版本号(uint32_t)、目标分区(A/B)、总长度(uint32_t)。PAD填充至1KB边界,确保Flash写入时对齐扇区——F103的Flash编程要求地址和数据长度均为半字(2字节)对齐,未对齐写入会触发BUS_FAULT。我在早期版本中忽略此点,导致写入后读取数据错位,耗时两天排查才发现是PAD缺失。
最隐蔽的坑在波特率容错。不同USB转串口芯片(CH340/CP2102/FT232)在Windows/Linux下实际波特率偏差可达±2%,而F103的UART采样容忍度仅±3%。解决方案是Bootloader启动时发送AT指令AT+BAUD?,要求上位机返回当前波特率,然后动态调整USARTDIV寄存器值。实测CH340在Win10下115200实际为113248,需将DIV设为(72000000/(113248*16))=39.6≈40,对应理论波特率112500,误差仅0.66%,远低于容错阈值。
提示:固件包传输必须带超时机制。我在环形缓冲区设计中加入时间戳字段,每次写入记录
SysTick_GetValue()。主循环检查:若最新数据包等待超时(如5秒),则清空缓冲区并返回错误。这避免了因上位机异常断连导致Bootloader无限等待。
4. 从Bootloader跳转到App的临界点控制:栈指针、向量表、时钟的三位一体校准
当Bootloader确认A区固件有效,准备跳转时,这不是简单的函数调用,而是一次精密的“系统移交仪式”。网上流传的((void (*)(void))app_addr)();跳转方式,在F103上大概率失败,因为它忽略了三个致命维度:栈空间、中断向量、系统时钟。
首先,栈指针(MSP)必须精准加载。App的startup代码中,向量表第一个字是初始MSP值,该值由链接脚本*.ld中的_estack符号定义。但Bootloader跳转时,若直接读取App向量表首字,会得到一个绝对地址(如0x20005000),而F103的SRAM起始地址为0x20000000,大小20KB。问题在于:App编译时假设其栈顶在SRAM末尾,但Bootloader运行时已占用部分SRAM(如DMA缓冲区、环形缓冲区),若不重置MSP,App的局部变量会覆盖Bootloader数据。正确做法是:
uint32_t *app_vector = (uint32_t*)APP_A_BASE; __set_MSP(app_vector[0]); // 加载App的初始MSP __DSB(); __ISB();其次,向量表重映射必须在跳转前完成。如前所述,SCB->VTOR = APP_A_BASE;,但要注意:VTOR寄存器写入后需执行__DSB()(数据同步屏障)和__ISB()(指令同步屏障),否则后续指令仍从旧向量表取址。我在测试中发现跳转后首次中断(如SysTick)仍指向Bootloader的Handler,就是因为漏了__ISB()。
最易被忽视的是系统时钟校准。Bootloader通常配置为72MHz(PLL倍频),但App可能使用不同时钟源(如HSI 8MHz)。若跳转后App未重新初始化RCC,其SysTick定时器会按72MHz计时,导致HAL_Delay(1000)实际延时仅13.9ms。解决方案是App的SystemInit()函数必须完整执行RCC初始化,而非依赖Bootloader的时钟状态。为此,我在Bootloader跳转前强制复位RCC:
RCC_DeInit(); // 将RCC寄存器恢复为复位值 FLASH_SetLatency(FLASH_LATENCY_2); // 根据App需求设置Flash等待周期这样App的SystemInit()就能安全地重新配置时钟。
最后是跳转指令本身。((void (*)(void))app_entry)();存在风险:若App入口地址非法(如0xFFFFFFFF),CPU会执行未定义指令触发HardFault。工业级方案应加入地址校验:
if((app_entry & 0xFFFFFFFE) == 0 && app_entry > 0x08004000 && app_entry < 0x08010000) { ((void (*)(void))app_entry)(); } else { // 进入安全模式,LED慢闪 }校验条件包括:地址为偶数(ARM Thumb指令要求)、在A区有效范围内、非全1地址。这个检查让我避开了因Flash写入错误导致的地址高位全1故障。
经验:跳转后务必禁用Bootloader使用的外设。我在UART IAP中启用USART1的DMA和中断,跳转前必须执行:
DMA_Cmd(DMA1_Channel5, DISABLE); USART_ITConfig(USART1, USART_IT_RXNE, DISABLE); USART_Cmd(USART1, DISABLE);否则App运行时,USART1的RXNE中断可能被触发,而App未注册该中断Handler,导致HardFault。
5. 实战排错链路:从J-Link烧录失败到AB分区状态错乱的完整溯源
在复现AB分区OTA过程中,我遭遇过七类典型故障,每类都耗费数小时定位。以下按真实排查顺序还原,附带根本原因和修复方案:
故障1:J-Link烧录Bootloader后,复位不进入Bootloader,直接运行旧App
- 现象:J-Link指示灯绿,但串口无任何输出,LED按旧固件逻辑闪烁
- 排查链路:
- 用万用表测BOOT0引脚电压——发现开发板BOOT0通过10K电阻上拉,但J-Link烧录时BOOT0被拉低(J-Link默认控制BOOT引脚)
- 查J-Link配置:在J-Flash中勾选"Use reset pin for target reset",取消"Connect under reset"
- 硬件修正:在BOOT0与VCC间加100nF电容,抑制J-Link干扰
- 根本原因:J-Link烧录协议与F103启动模式冲突,BOOT0电平被临时篡改
故障2:UART接收数据全为0x00
- 现象:上位机发送0x01 0x02 0x03,串口助手收到0x00 0x00 0x00
- 排查链路:
- 示波器测USART1_TX引脚——无波形,确认发送失效
- 检查RCC配置:
RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_USART1, ENABLE);漏写 - 检查GPIO:
GPIO_PinRemapConfig(GPIO_PartialRemap_USART1, ENABLE);未调用(因使用PA9/PA10)
- 根本原因:外设时钟和引脚重映射未使能,USART1硬件未激活
故障3:AB分区状态字写入后读取为0xFF
- 现象:调用
FLASH_ProgramWord(0x0800A100, 0x00000001)后,读取该地址仍为0xFFFFFFFF - 排查链路:
- 检查Flash解锁:
FLASH_Unlock()后未调用FLASH_ClearFlag(FLASH_FLAG_BSY | FLASH_FLAG_EOP | FLASH_FLAG_PGERR); - 检查地址对齐:0x0800A100非半字对齐(0x0800A100 % 2 == 0,但Flash编程要求地址为偶数且数据为uint32_t)
- 正确写法:
FLASH_ProgramWord(0x0800A100, 0x00000001);需确保0x0800A100是32位字地址
- 检查Flash解锁:
- 根本原因:Flash编程前未清除状态标志,且地址虽对齐但未确认是否在可编程扇区内(0x0800A100属Sector 3,需先擦除)
故障4:跳转到App后HardFault
- 现象:LED熄灭,无任何响应
- 排查链路:
- 配置Keil μVision的HardFault Handler断点
- 查看R0-R3寄存器——发现R0=0x20000000(SRAM起始),但App期望栈顶在0x20005000
- 检查
__set_MSP()调用位置——发现放在跳转指令后,未生效
- 根本原因:跳转前未正确加载MSP,且HardFault Handler未重映射
故障5:固件升级后App中断不触发
- 现象:SysTick中断Handler未执行,
HAL_Delay()卡死 - 排查链路:
- 检查
SCB->VTOR值——为0x08000000(仍指向Bootloader) - 发现App中
SystemInit()未调用HAL_RCC_OscConfig(),时钟未重置 - 在App的
main()开头强制添加HAL_Init(); SystemClock_Config();
- 检查
- 根本原因:App未重新初始化NVIC和RCC,VTOR未更新
故障6:B区升级成功,但下次启动仍运行A区
- 现象:烧录新固件到B区,复位后仍执行A区旧代码
- 排查链路:
- 用ST-Link Utility读取0x08004100(A区状态字)——值为0x00000001
- 读取0x0800A100(B区状态字)——值为0x00000000(应为0x00000002)
- 检查状态更新代码:
FLASH_ProgramWord(0x0800A100, 0x00000002);前未擦除扇区
- 根本原因:Flash写入前未擦除目标扇区,0x00000002被0xFF掩码覆盖
故障7:OTA升级中途断电,设备无法启动
- 现象:断电后复位,Bootloader检测到双区状态字均为0x00000000,进入无限等待
- 修复方案:
- Bootloader增加“安全模式”:若双区均无效,尝试从0x08000000读取Bootloader自身向量表,验证其有效性
- 若Bootloader有效,则进入UART命令行,允许手动擦除分区或重传固件
- 添加LED三闪提示“安全模式激活”
这些故障共同指向一个事实:AB分区OTA不是功能模块,而是系统级工程。每一个看似简单的步骤,背后都交织着硬件特性、编译器行为、时序约束的多重博弈。我最终整理出一份《F103 OTA排错速查表》,按现象分类,每项标注“硬件层/驱动层/协议层/应用层”归属,成为团队新人的必备手册。
6. 工程化落地要点:量产固件的签名验证、功耗优化与产线烧录适配
当AB分区OTA在实验室跑通后,真正的挑战才开始:如何让它在量产环境中稳定运行?我参与过三个量产项目,总结出三大工程化要点,它们决定了OTA是锦上添花还是埋雷隐患。
第一,固件签名验证不可省略。很多团队为节省Bootloader空间,取消签名验证,仅靠CRC32防误传。但在产线环境中,这是重大风险。曾有客户反馈:产线工人误将测试版固件(含调试日志)烧录到正式产品,导致设备上报大量无效数据。引入RSA2048签名后,Bootloader在写入Flash前,先用公钥验证固件包签名,验证失败则拒绝写入。关键优化在于:
- 公钥硬编码在Bootloader中(非Flash存储),防止被篡改
- 签名验证使用汇编优化的模幂运算,耗时从120ms降至38ms
- 签名区域与固件分离:固件包末尾4KB专用于签名,不影响App代码布局
第二,功耗敏感场景下的OTA策略。某电池供电项目要求OTA期间整机功耗<1mA。标准UART IAP在115200波特率下,USART1+DMA+CPU活跃功耗达8mA。解决方案:
- 动态降频:OTA开始时,将系统时钟从72MHz降至8MHz(HSI),功耗降至1.2mA
- 深度睡眠唤醒:接收每1KB数据后,进入STOP模式,由USART1的IDLE中断唤醒
- 电源门控:关闭未使用外设时钟(如ADC、SPI)
实测整机平均功耗0.85mA,满足要求。
第三,产线烧录流程适配。工厂使用通用烧录器(如Xeltek SuperPRO),不支持自定义协议。我们设计“双阶段烧录”:
- 阶段1:烧录Bootloader(固定地址0x08000000)
- 阶段2:烧录预置App(A区0x08004000),并在A区状态字写入0x00000001
这样出厂设备首次上电即运行App,无需人工干预。OTA升级时,Bootloader自动将新固件写入B区,更新状态字为0x00000002,下次启动即切换。
最后分享一个血泪教训:某项目为压缩Bootloader体积,将CRC32校验表(1KB)从Flash移到RAM,启动时动态生成。结果在高温环境(70℃)下,RAM数据偶发翻转,导致校验失败。解决方案是放弃查表法,改用位运算CRC32(代码体积320字节),牺牲速度换取可靠性。这印证了一个原则:在嵌入式系统中,确定性永远优于性能。
小技巧:量产前必做“断电测试”。用继电器模拟OTA传输中随机断电,重复1000次,验证AB分区状态一致性。我设计的测试脚本会自动统计“双区无效”发生率,>0.1%即判定为不合格。