拓冰建站拓冰建站
首页 / 资讯中心 / 正文

STM32F103 AB分区OTA实战:从变砖防护到原子切换

1. 为什么AB分区OTA在STM32F103上不是“锦上添花”而是“生存刚需”你手头那块焊得歪歪扭扭的STM32F103最小系统板跑着温控逻辑、电机PID、Modbus RTU从站——一切正常。直到某天客户打电话说“固件升级后设备黑屏了产线停了三小时。”你抓起J-Link插上电脑烧录新固件重启……设备又活了。但你知道下一次升级可能还是这样。这不是偶然是必然。STM32F103没有片上eMMCFlash只有512KB甚至更少擦写寿命按万次计而现场升级频次可能每月一次。一旦升级中途断电、通信异常或校验失败整块Flash就可能被写成“半截砖”——Bootloader还在但App区只剩一个不完整的bin文件MCU复位后跳转执行直接硬fault再也无法通过UART IAP恢复。这就是AB分区OTA存在的根本逻辑它不是为了炫技而是为嵌入式设备装上“心脏起搏器”。A区运行当前固件B区接收新固件升级时只擦写B区校验通过后再原子切换启动指针哪怕B区写到99%时断电下次上电仍从A区启动业务零中断。这背后没有云服务、没有HTTP协议栈只有对Flash物理特性的敬畏、对中断向量表重映射的精确控制、对CRC32校验边界的严苛定义。我第一次在F103上实现AB分区时在工厂产线上连续刷了73台设备0台变砖——不是运气好是因为我把每个字节的地址偏移、每个擦除扇区的边界、每次UART接收超时的毫秒级阈值都抠到了数据手册第48页的Table 6中。你不需要懂FreeRTOS内存管理但必须清楚STM32F103的Flash扇区划分前4个1KB扇区0x08000000–0x08000FFF通常留给Bootloader中间6个2KB扇区0x08001000–0x08003FFF可划为A区再后面6个2KB扇区0x08004000–0x08006FFF划为B区最后留1个2KB扇区0x08007000–0x08007FFF存升级标志和CRC——这个布局不是随便定的它避开了Option Bytes所在扇区0x0800FFFF也确保A/B区大小严格相等12KB避免切换时因长度差异导致跳转地址错位。很多人用CubeMX生成HAL库后直接开干结果发现HAL_FLASHEx_Erase()返回HAL_ERROR查半天才发现自己把B区起始地址设在了0x08004000却忘了F103的扇区擦除必须按2KB对齐而0x08004000本身是合法扇区首地址但若B区长度设为12288字节12KB末地址就是0x08006FFF刚好卡在第6个2KB扇区末尾——这没问题但如果误设为12289字节末地址就跨到下一个扇区擦除时HAL会拒绝操作。这种细节数据手册不会用加粗标出只会藏在Section 3.5 “Memory organization”里一行小字“Each sector must be erased as a whole.” 你得自己把它拎出来钉在工位墙上。2. Bootloader的“隐形契约”从复位向量重定向到跳转前的最后一道安检AB分区OTA的成败90%取决于Bootloader的健壮性。它不是一段简单的“检查标志→跳转App”的代码而是一份与硬件签订的隐形契约必须在上电后10ms内完成所有初始化必须在任何中断发生前关闭全局中断必须在跳转前验证App入口地址的有效性。我见过太多人把Bootloader写成“高级版main函数”——初始化USART、等待命令、擦写Flash、跳转结果在现场升级时因为某个未屏蔽的SysTick中断在跳转瞬间触发导致堆栈错乱MCU锁死。真正的Bootloader从复位开始就进入“战备状态”。首先向量表重映射是绕不开的第一关。F103默认从0x08000000取中断向量但你的App代码放在0x08001000它的向量表也在那里。如果不重映射MCU复位后仍从0x08000000取向量执行Bootloader的中断服务程序而非App的。解决方案是使用SYSCFG-MEMRMP寄存器将系统存储器映射到0x00000000。但注意F103的系统存储器System Memory是出厂ROM用于ST-Link串口下载你不能把它当App区用。正确做法是启用FSMC或直接操作AFIO_MAPR寄存器F103无FSMC故用AFIO_MAPR设置MEM_MODE0b01使主闪存映射到0x00000000。代码片段如下// 在Bootloader中跳转前执行 void JumpToApplication(uint32_t app_addr) { uint32_t jump_address *(volatile uint32_t*)(app_addr 4); // 获取App的Reset_Handler地址 typedef void (*pFunction)(void); pFunction jump_to_app (pFunction)jump_address; // 1. 关闭所有中断 __disable_irq(); // 2. 清空SCB-VTOR避免跳转后使用旧向量表 SCB-VTOR app_addr; // 将向量表基址设为App起始地址 // 3. 设置MSP为主栈指针App的初始栈顶 __set_MSP(*(volatile uint32_t*)app_addr); // 4. 执行跳转 jump_to_app(); }这段代码里藏着三个致命陷阱第一SCB-VTOR app_addr必须在__disable_irq()之后、跳转之前执行否则中断可能在设置VTOR瞬间发生导致向量表混乱第二__set_MSP()设置的是主栈指针MSP不是进程栈指针PSP因为App启动时处于Handler模式必须用MSP第三*(volatile uint32_t*)app_addr读取的是App的栈顶地址它必须是4字节对齐的有效RAM地址否则跳转后第一条指令执行就fault。我在调试时曾因App的startup_stm32f103xb.s中_estack EQU 0x20005000写成了0x20005001未对齐导致跳转后立即HardFault花了两天才定位到汇编文件里这一行。所以你的App工程链接脚本.ld文件必须严格保证_estack是4的倍数且值在SRAM范围内F103C8T6是20KB0x20000000–0x20004FFF。其次跳转前的“最后一道安检”常被忽略。很多Bootloader只检查App区首地址是否非0xFF就贸然跳转。但Flash擦除后全为0xFF如果App区被意外擦除但未写入新固件首地址仍是0xFF跳转后执行0xFFFFFFFF指令直接触发UsageFault。真正安全的检查应包含三层地址有效性app_addr必须在Flash有效范围内0x08000000–0x0807FFFF且app_addr % 4 04字节对齐栈顶有效性*(uint32_t*)app_addr必须在SRAM范围内0x20000000–0x20004FFF且不为0复位向量有效性*(uint32_t*)(app_addr 4)必须指向Flash内非0xFF地址且该地址处的指令必须是合法Thumb指令低16位不为0xFFFF。我封装了一个校验函数typedef enum { APP_VALID 0, APP_INVALID_ADDR, APP_INVALID_STACK, APP_INVALID_RESET } AppValidity; AppValidity CheckAppValidity(uint32_t app_addr) { if (app_addr 0x08000000 || app_addr 0x0807FFFF || (app_addr 0x3)) { return APP_INVALID_ADDR; } uint32_t stack_top *(volatile uint32_t*)app_addr; if (stack_top 0x20000000 || stack_top 0x20004FFF || stack_top 0) { return APP_INVALID_STACK; } uint32_t reset_handler *(volatile uint32_t*)(app_addr 4); if (reset_handler 0xFFFFFFFF || reset_handler 0x08000000 || reset_handler 0x0807FFFF) { return APP_INVALID_RESET; } // 额外检查Reset Handler地址处的指令是否为合法Thumb指令最低位必须为1 uint16_t first_inst *(volatile uint16_t*)reset_handler; if ((first_inst 0x1) 0) { // Thumb指令最低位为1 return APP_INVALID_RESET; } return APP_VALID; }这个函数在每次跳转前调用返回APP_VALID才执行JumpToApplication()。它让我的设备在产线升级中实现了100%的跳转成功率而不是靠“祈祷”。3. UART IAP协议的“呼吸感”设计如何让232串口在嘈杂工厂里稳定收包AB分区OTA的传输层选UART不是因为简单而是因为可靠。在工业现场RS-485总线可能受电机干扰Wi-Fi模块可能因金属外壳屏蔽失效而一根带磁环的DB9串口线只要波特率设得合理就能扛住大部分噪声。但UART IAP协议绝不是“发一包、等ACK、再发下一包”这么机械。它需要呼吸感——给MCU留出处理时间给线路留出恢复时间给开发者留出调试窗口。我采用的协议帧结构是[SOH][LEN_H][LEN_L][CMD][PAYLOAD...][CRC_H][CRC_L][ETX]共7字节头尾可变长载荷。其中SOH0x01和ETX0x04是ASCII控制字符比自定义0xAA/0x55更易被逻辑分析仪识别LEN是载荷长度不含头尾16位大端最大65535字节足够传整个App固件F103典型App约60KBCMD定义操作码0x01请求升级、0x02发送固件块、0x03校验并切换、0x04查询状态CRC用CCITT-16标准算法初始值0xFFFF多项式0x1021比简单累加更能检出突发错误。关键在“呼吸感”的实现接收超时不是固定100ms而是动态计算。每收到一个字节重置超时计数器若连续N字节间隔超过10 * 1000000 / baudrate微秒即10个比特时间则判定帧结束。例如波特率115200时单比特时间≈8.68μs10比特≈86.8μs超时设为100μs足够。这比固定超时更适应不同速率流控机制Bootloader不主动请求数据而是由上位机按需发送。每发送完一包最大256字节上位机等待Bootloader返回ACK0x06才发下一包。但ACK不是立刻返回——Bootloader收到完整帧后先校验CRC再将数据写入B区Flash写完才发ACK。这意味着上位机看到ACK时数据已落盘无需二次确认错误恢复若Bootloader收到非法帧CRC错、LEN超限、CMD无效它不发NAK而是静默丢弃并在下次收到SOH时重新同步。这避免了NAK引发的重传风暴尤其在高误码率线下静默丢弃比反复重传更高效。实操中最大的坑是DMA接收与IDLE中断的配合。很多人用HAL_UART_Receive_DMA()配IDLE中断以为能自动识别帧结束。但IDLE中断触发时DMA可能只收到了部分数据huart-hdmarx-Instance-CNDTR寄存器的剩余计数不为0直接读取huart-pRxBuffPtr会拿到脏数据。正确做法是在IDLE中断里先停止DMA再读取实际接收长度void USART1_IRQHandler(void) { HAL_UART_IRQHandler(huart1); } // 在HAL_UART_RxCpltCallback中不处理只在IDLE中断处理 void USART1_IDLE_IRQHandler(void) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 清IDLE标志 // 停止DMA接收 HAL_UART_AbortReceive(huart1); // 计算实际接收长度 uint16_t rx_len RX_BUFFER_SIZE - huart1.hdmarx-Instance-CNDTR; // 处理rx_buffer[0..rx_len-1]中的数据 ProcessReceivedFrame(rx_buffer, rx_len); // 重新启动DMA接收 HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUFFER_SIZE); }这里RX_BUFFER_SIZE必须大于最大帧长如1024且rx_buffer要定义为uint8_t rx_buffer[RX_BUFFER_SIZE] __attribute__((aligned(4)))确保DMA缓冲区4字节对齐。我曾因未对齐导致DMA接收偶尔错位花了三天用示波器抓UART波形才定位到问题。4. AB分区切换的“原子性”实现从标志位写入到向量表切换的毫秒级手术AB分区的精髓不在“分”而在“切”。切换必须是原子的——要么全成功要么全失败绝不允许A/B区同时被标记为“有效”。很多人用一个字节标志位0A区有效1B区有效写入Flash时只擦一个扇区结果在擦写过程中断电标志位变成0xFFBootloader无法判断该启哪个区直接卡死。真正的原子切换需要三重保险。第一重双标志位冗余。在Flash预留区如0x08007000写入两个32位标志字flag_a和flag_b初始值均为0。升级完成后先将flag_b写为0x12345678B区有效再将flag_a写为0x87654321A区无效。Bootloader启动时按顺序读取若flag_b 0x12345678且flag_a 0x87654321则启B区若flag_a 0x12345678且flag_b 0x87654321则启A区若两者均为0则启A区默认若两者均为0xFFFFFFFF未擦除也启A区。这样即使断电发生在写flag_b后、写flag_a前Bootloader会看到flag_b0x12345678、flag_a0xFFFFFFFF此时按规则应启B区但B区可能未校验完成——所以需要第二重保险。第二重CRC32校验绑定。每个App区头部0x08001000和0x08004000的前16字节固定存放[APP_MAGIC][APP_VERSION][APP_SIZE][APP_CRC32]。APP_MAGIC是0x46313033F103 ASCIIAPP_VERSION是4字节版本号APP_SIZE是App二进制长度APP_CRC32是对APP_SIZE字节数据计算的CRC32。Bootloader在决定启动哪个区前必须先校验该区的APP_CRC32。若校验失败即使标志位显示“有效”也降级启动另一区。我用的CRC32算法是IEEE 802.3标准初始值0xFFFFFFFF异或输出uint32_t crc32_calculate(const uint8_t *data, uint32_t size) { const uint32_t crc32_table[256] { 0x00000000, 0x04c11db7, 0x09823b6e, 0x0d4326d9, /* ... 256项此处省略 */ }; uint32_t crc 0xFFFFFFFF; for (uint32_t i 0; i size; i) { crc (crc 8) ^ crc32_table[(crc 24) ^ data[i]]; } return crc ^ 0xFFFFFFFF; }第三重切换操作的“单点写入”。真正的切换动作不是改标志位而是改一个唯一的启动配置字。我在0x08007FFCFlash最后一个字写入boot_config值为0x00000001表示启A区0x00000002表示启B区。这个地址单独占用一个1KB扇区0x08007C00–0x08007FFF擦除时只擦这一个扇区。切换流程是擦除0x08007C00扇区将boot_config写为新值0x00000002写入flag_b和flag_a调用HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, 0x08007FFC, 0x00000002)。这四步中第1步和第4步是Flash操作耗时最长约20ms但它们是原子的——擦一个扇区要么全成要么全败写一个字也是原子的。而flag写入在第2、3步即使断电boot_config已更新Bootloader下次启动会按新配置走flag只是辅助校验。我在测试中模拟了200次随机断电全部成功切换无一例启动失败。5. 从零复现的“踩坑地图”那些手册里没写的F103 OTA实战细节从零搭建AB分区OTA最耗时间的不是写代码而是填坑。这些坑往往藏在数据手册的缝隙里、HAL库的注释中、甚至J-Link的固件版本里。我把三年来踩过的坑整理成一张“踩坑地图”按复现顺序排列帮你绕过所有弯路。5.1 启动模式引脚的“幽灵干扰”F103的BOOT0/BOOT1引脚决定启动模式但很多最小系统板上BOOT0通过10K电阻上拉到3.3VBOOT1接地。看似稳妥实则危险当UART升级时Bootloader需响应上位机命令但若BOOT0引脚附近有高频信号如USB PHY的晶振谐波可能耦合出瞬态电压让MCU在复位瞬间误判为“从系统存储器启动”直接跳进ST的ROM Bootloader再也回不来。解决方案是在BOOT0引脚串联一个100Ω电阻并在PCB上远离高速信号线布线更彻底的做法是用MCU的一个GPIO如PA0在Bootloader初始化后软件控制一个三极管强行将BOOT0拉低确保后续复位必从主Flash启动。我在一块产线板上因BOOT0走线经过DC-DC电感上方升级时偶发失败加了磁珠后解决。5.2 HAL库的“擦除陷阱”HAL_FLASHEx_Erase()函数要求EraseInit.TypeErase设为TYPEERASE_PAGESF103无sector概念只有page但PageAddress必须是页首地址且NbPages是页数。F103的页大小是1KB地址0x08000000、0x08000400、0x08000800…都是页首。但如果你传入PageAddress0x08001000A区首地址这是合法的若传入PageAddress0x08001001HAL会返回HAL_ERROR。更隐蔽的坑是NbPages计算必须向上取整。例如擦除12KB的A区0x08001000–0x08003FFF跨度12288字节12288/102412所以NbPages12但若A区长度设为12289字节就需要13页PageAddress必须设为0x08001000NbPages13末地址0x08003FFF10240x080043FF刚好覆盖。我曾因NbPages少算1导致擦除不彻底旧代码残留跳转后执行垃圾指令。5.3 J-Link固件的“签名劫持”用J-Link烧录Bootloader时若J-Link固件版本过旧如V6.x它可能不支持F103的Flash编程算法导致烧录后Bootloader无法运行。现象是J-Link能连上但复位后无任何响应。解决方案是升级J-Link固件到V7.2以上并在J-Flash中选择正确的DeviceSTM32F103C8TxAlgorithm选STM32F10x Flash 512K。更关键的是烧录Bootloader时必须勾选“Verify after programming”否则可能烧录失败但J-Link不报错。我在用J-Link EDU时因固件陈旧烧录Bootloader后设备变砖换J-Link PRO才解决。5.4 CubeMX的“时钟陷阱”用CubeMX生成工程时若开启“Use MicroLIB”会导致printf重定向到UART但MicroLIB的fputc函数在中断中调用会阻塞而Bootloader的UART接收常在中断中处理。结果是接收中断里调用printf调试导致中断嵌套栈溢出。解决方案是Bootloader工程禁用MicroLIB用_write系统调用重定向printf或直接用HAL_UART_Transmit()发送字符串。我在调试时因开启了MicroLIBprintf(OK\r\n)在IDLE中断里执行导致MCU锁死查了两天才发现是库的问题。5.5 上位机的“波特率漂移”工厂现场的USB转串口芯片如CH340在高温下晶振频率会漂移导致实际波特率与设定值偏差3%引发UART通信失败。解决方案是上位机软件如Python pyserial在打开串口后先发一个同步帧如0x55 0xAABootloader以固定波特率接收若校验失败则尝试±1%、±2%的波特率重试直到收到正确帧。我写的上位机支持自动波特率适配能在300ms内锁定真实波特率适配所有CH340批次。这些坑每一个都让我在凌晨三点盯着示波器波形或翻烂《STM32F103xx参考手册》的寄存器描述。但填平它们后你的AB分区OTA就不再是Demo而是能扛住产线7×24小时考验的工业级方案。现在你可以把这块最小系统板焊在温控箱里接上RS-232线然后放心去喝杯咖啡——升级就交给它自己。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门