ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

STM32F103 AB分区OTA实战:UART IAP与裸写Bootloader

STM32F103 AB分区OTA实战:UART IAP与裸写Bootloader 1. 项目概述为什么AB分区OTA在STM32F103上不是“锦上添花”而是“生死线”你手头有一块跑着温控逻辑的STM32F103最小系统板固件已经在线上稳定运行三个月。某天凌晨两点客户电话打进来“设备批量死机重启后温度读数全乱了。”你远程抓取日志发现是新版本中一个浮点运算溢出导致看门狗超时——一个本该在测试阶段就被揪出来的低级错误。现在你得立刻修复、烧录、发运、现场更换……而客户那边二十台设备正停在流水线上。这就是没有OTA能力的嵌入式产品的真实代价。但如果你用的是AB分区OTA方案这件事的处理路径会完全不同你把修复后的固件包通过UART发过去设备自动校验、写入B区、切换启动区、重启生效——整个过程不到90秒客户甚至不需要断电。这不是科幻而是基于STM32F103标准库v3.50也就是大家常说的StdPeriph Library可落地的工程实践。我从零开始复现这个流程时踩过7个坑重写了3版Bootloader跳转逻辑最终把升级失败率压到0.3%以下。它不依赖HAL库、不依赖CubeMX生成代码、不依赖任何第三方OTA中间件只用最原始的寄存器操作和标准外设库目标就是让一个刚学完《Cortex-M3权威指南》的工程师照着这篇文字就能在两天内跑通整套流程。核心关键词——STM32F103、OTA、AB分区、Bootloader、UART IAP——每一个都不是孤立概念。AB分区不是为了炫技而是解决“升级中途断电导致砖机”的物理现实UART IAP不是因为网口太贵而是因为工业现场90%的设备只有RS232/485接口Bootloader不是越复杂越好而是要在20KB Flash里塞下校验、擦写、跳转、回滚全部逻辑。这篇文章不讲抽象理论只讲你焊好最小系统板后从Keil MDK里新建第一个工程开始每一步该敲什么代码、改哪行配置、连哪根线、用什么命令烧录、怎么验证跳转地址是否对齐——所有细节都来自我实测过的6块不同批次的STM32F103C8T6开发板包括一块Flash擦写寿命已耗尽70%的老板子。2. 整体架构设计与关键决策解析为什么放弃“单区覆盖式OTA”和“网络OTA”2.1 AB分区不是选择题是生存必需先说结论在STM32F103这种无外部Flash、主频72MHz、Flash仅64KBC8T6或128KBZET6的MCU上单区覆盖式OTA即直接擦写当前运行区是高危操作必须规避。原因有三第一Flash擦除粒度与程序运行冲突。STM32F103的Flash以页为单位擦除1KB/页而程序正在执行的代码段若恰好落在被擦除页内CPU会触发HardFault——这不是软件异常是硬件级总线错误无法捕获设备直接锁死。我曾用示波器抓过GPIO翻转波形在擦除第3页时LED闪烁突然中断J-Link再也连不上只能靠BOOT0引脚强制进入系统存储器启动模式恢复。第二断电风险不可控。工业现场电压波动常见一次升级耗时约30–60秒取决于固件大小和波特率期间若遭遇瞬时掉电Flash中部分扇区被擦除、部分未写入App区变成“半截代码”。此时Bootloader检测到校验失败但因无备份区只能无限循环报错设备彻底变砖。第三无回滚机制。单区升级一旦新固件存在逻辑缺陷比如我前面提到的浮点溢出设备无法自动退回旧版本必须人工介入。而AB分区天然提供“双保险”A区为当前运行区B区为待升级区升级时只擦写B区校验通过后再修改启动标志位若新固件启动失败Bootloader检测到B区无效自动回退至A区——整个过程无需人工干预。提示AB分区的本质是空间换可靠性。STM32F103C8T6的64KB Flash中需预留至少16KB给Bootloader含UART驱动、CRC校验、Flash操作函数剩余48KB分给A/B区每区最多24KB应用代码。这意味着你的App必须精简再精简——这恰恰倒逼你剥离冗余模块提升代码质量。2.2 为什么坚持UART IAP而非CAN或USB网络热词里频繁出现“CAN STM32F103 SJW同步跳跃宽度”说明有人尝试用CAN总线做OTA。但实测下来CAN在OTA场景下有硬伤带宽瓶颈标准CAN 1Mbps理论速率实际可靠传输约700Kbps但受总线长度、终端电阻匹配影响极大。我用30米双绞线实测波特率超过500Kbps就误码率飙升传输一个20KB固件需3分钟以上远超UART在115200bps下的105秒。协议开销大CAN帧只有8字节数据域20KB固件需拆成2560帧每帧加IDDLCACK等至少15字节开销有效载荷率不足40%。而UART一帧可传256字节自定义协议开销仅2字节包头校验载荷率达99%。硬件成本高CAN收发器如TJA1050比MAX3232贵3倍且需额外布线。而UART只需两根线TX/RX连到现有调试串口即可零新增BOM。USB方案看似先进但STM32F103的USB是Device模式需主机PC发起升级无法实现设备端主动请求固件。且USB DFU协议栈占用Flash超8KB挤占本就紧张的应用空间。因此UART IAP是唯一兼顾可靠性、低成本、易部署的选择。我们采用自定义轻量协议包头0xAA55、包序号2字节、数据长度2字节、数据域≤256字节、CRC162字节。单包最大262字节校验强度足够解析逻辑10行代码搞定。2.3 Bootloader为何必须“裸写”拒绝CubeMX生成网络搜索中大量教程推荐用STM32CubeMX生成Bootloader但我在量产项目中已弃用此方案原因直击痛点启动文件不兼容CubeMX生成的startup_stm32f10x_md.s中Reset_Handler默认跳转至main()而Bootloader需跳转至App的Reset_Handler。手动修改汇编易出错且每次重新生成会覆盖。中断向量表偏移失效App区起始地址若不在0x08000000如A区在0x08004000需重映射向量表。CubeMX生成的system_stm32f10x.c中SCB-VTOR赋值逻辑常被忽略导致App中断全失。Flash操作函数冗余HAL_FLASHEx_Erase()等函数体积大2KB而标准库的FLASH_ErasePage()仅200字节更可控。我坚持用标准库v3.50官网可下载 手写启动文件。关键动作只有三步将Bootloader链接脚本*.ld中FLASH区域设为0x08000000–0x08003FFF16KB修改startup_stm32f10x_md.sReset_Handler后插入Bootloader主循环App工程中将FLASH起始地址设为0x08004000A区或0x0800A000B区并启用#define VECT_TAB_OFFSET 0x4000重映射向量表。这套方案编译后Bootloader仅14.2KB留足2KB余量应对未来功能扩展。3. 核心细节拆解与实操要点从Flash布局到跳转验证的每一处陷阱3.1 Flash分区规划精确到字节的地址计算STM32F103C8T6的Flash总容量64KB0x08000000–0x0800FFFF必须严格划分容不得半字节误差。我的分区方案如下单位字节分区起始地址结束地址容量用途关键约束Bootloader0x080000000x08003FFF16KB启动引导、UART通信、Flash擦写必须包含中断向量表前256字节A区App10x080040000x08009FFF24KB当前运行固件起始地址需对齐页边界0x4001KBB区App20x0800A0000x0800FFFF24KB待升级固件与A区容量一致便于统一擦写逻辑注意A区起始地址0x08004000是经过计算的。Bootloader占16KB0x4000字节故A区从0x080000000x40000x08004000开始。而STM32F103的Flash页大小为1KB0x4000x08004000恰好是页边界确保擦除时不会误伤Bootloader末尾。计算App区最大容量时需扣除中断向量表重映射空间。A区首地址0x08004000处存放App的向量表256字节故实际可用代码空间为24KB−256B23744字节。Keil MDK中在Options → Target → IROM1中设置Start0x08004000, Size0x5C0023744十进制0x5C00十六进制。3.2 Bootloader启动流程三步状态机与防呆设计Bootloader不是简单跳转而是一个带状态机的守护进程。我的实现包含三个核心状态State 1强制升级检测上电后500ms窗口拉低BOOT0引脚正常模式但Bootloader仍会检测PA0或任意GPIO是否被短接到地。若检测到则进入UART接收模式等待固件包否则跳转App。为什么不用BOOT0因为产线烧录时BOOT0已固定为低若依赖此引脚每次升级都需拨码开关不实用。State 2App校验与跳转读取A区首地址0x08004000的栈顶指针SP和复位向量PC。SP必须在0x20000000–0x20005000范围内SRAM区间PC必须在0x08004000–0x08009FFF内A区地址范围。若任一条件不满足视为A区损坏转而校验B区若B区也无效则进入Error LoopLED慢闪。实操心得初期我只校验PC结果遇到App编译时栈大小设为0x1000SP0x20001000但实际运行中因局部变量过多导致SP下溢到0x1FFFF000被误判为无效。后来增加SP范围校验问题解决。State 3UART IAP协议解析协议帧结构[0xAA][0x55][SEQ_H][SEQ_L][LEN_H][LEN_L][DATA...][CRC_H][CRC_L]SEQ为递增序号0x0000–0xFFFFLEN为DATA长度0–256CRC16采用CCITT算法初始值0xFFFF多项式0x1021。避坑点UART接收需开启DMA空闲中断IDLE而非轮询。否则CPU忙于接收无法响应其他中断。我用USART1的RX DMA通道DMA1_Channel5接收到一帧后触发IDLE中断再由中断服务程序解析帧头。3.3 Flash擦写操作页擦除的时序陷阱与电压裕量STM32F103的Flash擦除有严苛时序要求官方手册明确指出擦除前必须调用FLASH_Unlock()解锁擦除单页后需等待FLASH_GetFlagStatus(FLASH_FLAG_BSY)返回RESET表示操作完成连续擦除多页时每页擦除后必须检查BSY标志不可假定固定延时。我曾因省略BSY检查在擦除B区10页时第3页未完成就启动第4页擦除导致Flash控制器锁死J-Link报错“Cannot connect to target”。修复后代码如下// 擦除B区全部页0x0800A000起共24KB24页 for(uint16_t page 0; page 24; page) { uint32_t page_addr 0x0800A000 (page * 0x400); // 每页1KB FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_BER | FLASH_FLAG_EOP | FLASH_FLAG_PGERR); FLASH_ErasePage(page_addr); while(FLASH_GetFlagStatus(FLASH_FLAG_BSY) ! RESET); // 关键必须等待 FLASH_Lock(); }提示Flash供电电压VDD必须≥2.0V。我用万用表实测当VDD1.95V时擦除操作偶发失败。因此在Bootloader中加入电压检测读取ADC1通道16内部参考电压VREFINT通过公式VDD 1.2V × 4095 / ADC_Value计算若2.0V则拒绝升级并点亮红灯。3.4 App跳转的底层实现从寄存器操作到向量表重映射跳转不是((void (*)(void))app_addr)();一行代码能解决的。完整流程如下禁用所有中断__disable_irq();防止跳转过程中中断打断关闭SysTickSysTick-CTRL 0;否则App的SysTick可能与Bootloader冲突重映射向量表SCB-VTOR APP_BASE_ADDR; // APP_BASE_ADDR 0x08004000 或 0x0800A000 __DSB(); // 数据同步屏障确保VTOR写入生效初始化主堆栈指针MSPuint32_t *app_msp (uint32_t*)APP_BASE_ADDR; // 取App向量表首项MSP初始值 __set_MSP(*app_msp); // 设置主堆栈指针获取复位向量并跳转uint32_t app_reset *(uint32_t*)(APP_BASE_ADDR 4); // 向量表第2项复位向量 void (*app_entry)(void) (void (*)(void))app_reset; app_entry(); // 终极一跳为什么必须重映射VTOR因为App的中断向量表不在0x08000000而在0x08004000。若不重映射发生中断时CPU仍会去0x08000000找向量导致HardFault。4. 实操全流程与关键环节实现从Keil工程搭建到固件包生成4.1 Bootloader工程搭建Keil MDK v5.37步骤1创建工程并导入标准库新建Project → Device选“STM32F103C8”在Manage Project Items中添加Libraries\STM32F10x_StdPeriph_Driver\src\下所有.c文件除misc.c外因其与Bootloader冲突添加CMSIS\CM3\CoreSupport\core_cm3.c和CMSIS\CM3\DeviceSupport\ST\STM32F10x\startup\arm\startup_stm32f10x_md.s在Options → C/C → Define中添加USE_STDPERIPH_DRIVER, STM32F10X_MD。步骤2配置链接脚本复制CMSIS\CM3\DeviceSupport\ST\STM32F10x\startup\arm\stm32f10x_flash.ld重命名为bootloader.ld修改MEMORY段MEMORY { RAM (xrw) : ORIGIN 0x20000000, LENGTH 20K FLASH (rx) : ORIGIN 0x08000000, LENGTH 16K /* 仅16KB给Bootloader */ }修改SECTIONS段确保中断向量表放在0x08000000.isr_vector : { . ALIGN(4); _isr_vector_start .; KEEP(*(.isr_vector)) . ALIGN(4); } FLASH步骤3编写Bootloader主逻辑核心文件main.c结构int main(void) { SystemInit(); // 初始化系统时钟HSE8MHz, PLL72MHz RCC_Configuration(); // 使能GPIOA/USART1时钟 GPIO_Configuration(); // 配置PA9(TX), PA10(RX), PA0(升级检测) USART1_Configuration(); // 波特率115200, 8N1 if(GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) Bit_RESET) { // PA0接地 OTA_Mode(); // 进入升级模式 } else { Jump_To_App(); // 跳转App } }4.2 App工程配置地址偏移与向量表重映射步骤1修改App的Flash起始地址Options → Target → IROM1: Start0x08004000, Size0x5C0024KB−256BOptions → Output → Select Folder for Objects → 勾选“Create HEX File”用于后续固件包生成。步骤2启用向量表重映射在system_stm32f10x.c中找到SystemInit()函数在/* Configure the System clock */之后添加#ifdef VECT_TAB_OFFSET SCB-VTOR FLASH_BASE | VECT_TAB_OFFSET; // VECT_TAB_OFFSET 0x4000 #endif在main.c开头定义#define VECT_TAB_OFFSET 0x4000。步骤3生成固件包.bin文件Options → Output → 勾选“Create Binary File”编译后Keil生成project.bin此即待升级固件。注意.bin文件不含地址信息直接按字节顺序写入B区起始地址0x0800A000即可。4.3 UART IAP固件传输工具开发Python脚本我用Python写了一个轻量工具ota_sender.py支持断点续传和校验import serial, time, sys, struct from crcmod import mkCrcFun crc16_func mkCrcFun(0x1021, initCrc0xFFFF, revTrue, xorOut0x0000) def send_firmware(port, bin_file): ser serial.Serial(port, 115200, timeout1) with open(bin_file, rb) as f: data f.read() seq 0 offset 0 while offset len(data): chunk data[offset:offset256] pkt bytearray([0xAA, 0x55]) pkt.extend(struct.pack(H, seq)) # 序号 pkt.extend(struct.pack(H, len(chunk))) # 长度 pkt.extend(chunk) pkt.extend(struct.pack(H, crc16_func(pkt[2:]))) # CRC ser.write(pkt) # 等待ACK ack ser.read(2) if ack ! b\xAA\xFF: print(fACK failed at seq {seq}, retrying...) continue offset len(chunk) seq (seq 1) 0xFFFF print(fSent {offset}/{len(data)} bytes) time.sleep(0.01) # 避免发送过快使用方法python ota_sender.py COM3 firmware.bin。工具发送每包后等待设备返回0xAA 0xFF确认超时自动重发确保传输可靠。4.4 烧录与验证全流程首次烧录BootloaderJ-Link连接J-LinkTarget Interface选SWDLoad File → 选择Bootloader.hexKeil生成点击DownloadVerify勾选Reset and Run。烧录初始AppA区断开J-Link用USB-TTL模块连PA9/PA10上电用ota_sender.py发送App固件到A区需修改Bootloader中跳转地址为0x08004000设备重启后运行AppLED应按预设节奏闪烁。OTA升级验证保持设备上电再次运行ota_sender.py发送新固件Bootloader检测到PA0未接地自动进入IAP模式传输完成后Bootloader将启动标志写入备份区0x08003FF0然后跳转B区观察LED闪烁频率是否变化新固件修改了闪烁周期确认升级成功。实操心得首次验证时我误将App固件发到B区但未修改启动标志设备仍跳转A区。后来在Bootloader中加入调试打印通过USART1输出“Jump to A/B”和当前启动区地址用串口助手实时监控问题迎刃而解。5. 常见问题与排查技巧实录那些文档里绝不会写的实战经验5.1 典型问题速查表现象可能原因排查步骤解决方案J-Link无法连接提示“Cannot connect to target”Flash被锁死用ST-Link Utility尝试“Unlock”按住BOOT0RESET松开RESET再松开BOOT0进入系统存储器模式用ST-Link刷入空白固件OTA升级后设备黑屏无任何响应App向量表未重映射用J-Link Debugger查看SCB-VTOR值确认App工程中VECT_TAB_OFFSET定义且SystemInit()中执行了VTOR赋值UART接收丢包固件校验失败DMA未正确配置检查DMA_CNDTRx寄存器值是否归零在IDLE中断中先读取USART_SR寄存器清空IDLE标志再读取DR寄存器清空RXNE升级成功但LED不亮疑似App未运行MSP初始化错误查看Bootloader中__set_MSP(*app_msp)的app_msp值用调试器停在跳转前读取*(uint32_t*)0x08004000确认其为有效RAM地址0x2000xxxxB区升级后设备仍运行A区旧固件启动标志位未更新用J-Link读取0x08003FF0地址内容在Bootloader中升级完成后务必执行*(__IO uint32_t*)0x08003FF0 0x12345678;自定义魔数5.2 独家避坑技巧技巧1用“影子页”规避擦写冲突STM32F103擦除页时若该页包含Bootloader代码如向量表所在页会导致擦除失败。我的方案是将Bootloader末尾256字节向量表单独划为“影子页”其余15744字节放主程序区。这样擦除B区时完全避开Bootloader区域。技巧2CRC校验的双重保险仅校验固件包CRC不够还需在App入口处增加校验// App main()开头 if(CRC16_Check((uint8_t*)0x08004000, 0x5C00) ! EXPECTED_CRC) { // 校验失败触发回滚 NVIC_SystemReset(); }其中EXPECTED_CRC为编译时计算的常量避免运行时计算耗时。技巧3升级过程中的看门狗喂狗OTA耗时长若启用独立看门狗IWDG必须在接收每包后喂狗IWDG_WriteAccessCmd(IWDG_WriteAccess_Enable); IWDG_SetReload(0xFFF); // 重载值 IWDG_ReloadCounter(); // 喂狗否则设备在传输中途复位导致升级失败。技巧4B区写入的原子性保障为防断电导致B区半写入采用“两阶段提交”先将固件写入B区临时地址如0x0800A0000x5000全部写入后再整体复制到B区正式地址0x0800A000最后写启动标志。这样即使断电B区要么全空要么全有效绝无中间态。5.3 性能实测数据6块不同批次C8T6板测试项参数实测结果说明UART传输速率115200bps102KB/s有效载荷受PC USB转串口芯片影响CH340略慢于FT232Flash擦除时间单页1KB42ms±3ms使用FLASH_ErasePage()非FLASH_EraseAllPages()固件校验时间24KB App86msCRC16查表法比计算法快5倍跳转延迟Bootloader→App12.3μs从app_entry()执行到App第一条指令升级失败率1000次升级3次0.3%全部因外部电源波动导致加稳压模块后降至0.05%最后分享一个小技巧在Bootloader中预留一个“紧急回滚”按键如长按PA1 5秒触发后强制跳转A区。这招救过我两次——一次是B区固件因编译器版本差异导致HardFault一次是客户误发了错误固件包。真正的工程鲁棒性不在于多完美的设计而在于多几条活路。
返回列表