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

STM32F103 AB_OTA在线升级实战:从Flash分区到Bootloader与回滚机制

做嵌入式开发的迟早都要面对一个灵魂拷问产品已经铺到现场客户打来电话说固件出了个低级Bug怎么办早几年我碰到这种事只能买张机票飞过去拿JTAG重新烧一遍回来还要被同事调侃一句“又出去旅游了”。后来把OTA方案玩明白了才意识到这种痛苦本来可以避免。这篇文章就把我在STM32F103上从零复现AB_OTAA/B分区在线升级的全过程整理出来包含Flash分区规划、Bootloader跳转逻辑、App中断向量表重映射、固件传输协议设计以及一大堆文档里根本不会写的坑。这套内容适合两类人一类是想给量产设备加在线升级功能、但不知道从哪里下手的开发者另一类是已经跑通过简单IAP但想弄明白AB双分区、掉电保护和失败回滚到底怎么做的人。看完之后你至少能独立搭出一个升级失败不会变砖、升级中途断电也不会丢固件的可靠升级系统。1. 开机第一问为什么要用AB_OTA方案1.1 先看三种常见IAP方案为什么AB最稳在做在线升级这件事上业内其实有好几条技术路线我实际接触到的项目里最常见的是下面三种。第一种是最原始的“全擦写法”也是很多新手第一次实现IAP时用的方案。流程很简单Bootloader接收完整个新固件把旧App区全部擦掉再把新固件写进去。问题在于擦除和写入这个过程一旦掉电Flash里就是一片空白或半截固件下次开机Bootloader找不到能用的App设备直接变砖。我在早期一个实验板上就因为这个操作毁过好几块Flash的引导区虽然芯片没坏但那个“明明只差最后一步却前功尽弃”的滋味实在不好受。第二种是“RAM缓冲或外部存储中转法”。Bootloader先把新固件收进单片机内部RAM或者外挂的SPI Flash、SD卡里校验完整之后再从缓冲区往App区写入。这种做法确实能降低掉电风险因为正式写入之前固件已经有了完整备份。但代价也很明显STM32F103内部RAM一般只有20KB到64KB装不下稍大一点的固件外挂Flash又要多花硬件成本还得处理文件系统和磨损均衡工程复杂度一下就上去了。第三种就是本文要讲的AB_OTA方案。它的核心思路是给App准备两份独立空间分别叫A区和B区。平时跑在其中一份里要升级时往另一份空间写新固件写完校验通过后再切换启动目标。整个过程里任何时候都至少有一份完整的固件躺在Flash里升级失败、中途断电、新固件跑不起来都能回滚到旧版本。这就是AB分区在安全性上的底气。1.2 AB_OTA解决了什么代价又是什么AB_OTA最大的价值是解决了“升级不可逆”这个行业痛点。尤其对于现场已经部署的设备比如工业控制器、电力采集终端、医疗仪器一旦升级过程中出问题工程师跑现场的成本极高。有了AB双区升级动作就变成了“先准备后切换”出问题最多是停留在旧版本不影响设备基本功能。当然没有免费的午餐。AB_OTA的代价主要有三个第一Flash空间占用翻倍一颗64KB Flash的STM32F103C8T6扣掉Bootloader之后AB两份各分十几KB基本上就只能跑很小的裸机程序所以真要做AB_OTA我建议芯片Flash至少128KB起步第二Bootloader逻辑比普通IAP复杂要处理升级标志、分区有效性判断、跳转前的外设清理第三要有一份能独立运行的App并且App必须正确处理中断向量表重映射这恰恰是很多新手卡住的地方。如果你是做小家电遥控器那种超小固件整颗Flash都塞不下两份App那AB方案不适合老老实实用外部存储中转吧。如果产品对可靠性要求高、固件体积能控制在几十KB以内那AB_OTA几乎是最优解。2. 硬件准备与Flash分区规划2.1 芯片选型与最小系统先说芯片型号。STM32F103家族区分度最高的参数就是Flash大小C8T6是64KBRCT6是256KBZET6是512KB。做AB_OTABootloader无论如何要占掉32KB左右剩下空间要能装下两份可用的App。我推荐用RCT6起步它性价比高、Flash充足64KB一份的App分区足够跑一个带RTOS和一堆外设驱动的真实产品。如果你只是验证流程手头只有C8T6也可以把Bootloader压缩到16KBA/B区各24KB但App就只能做一些LED、串口之类的最小演示。最小系统这一块热词里很多人搜“stm32f103最小系统”这里顺便说清楚。F103要跑起来硬件上最少要有8MHz无源晶振配两个20pF左右负载电容以及一个1MΩ并联电阻复位电路用10kΩ上拉电阻加0.1uF电容BOOT0和BOOT1都要拉低确保从主Flash启动电源部分每个VDD引脚就近放一个100nF去耦电容再在总电源处放一颗10uF到100uF的电解电容。把这些搭好芯片上电就能从0x08000000跑起来。开发板选择上市面上的“STM32F103最小系统板”几十块钱就能买到非常适合这套教程。但要注意如果后面你想走CAN通道升级板卡上得预留CAN收发器TJA1050这类接口想走RS485就得有MAX485电路想用NRF24L01那就需要SPI引脚引出。最小系统板通常没有这些外设需要你自己在面包板上补或者直接买带对应接口的核心板。2.2 256KB Flash的一张分区表分区规划是整个AB_OTA项目的基石这个表没设计好后面写代码会非常别扭。以STM32F103RCT6为例256KB Flash从0x08000000到0x0803FFFF我建议分成下面几个区域。区域起始地址大小用途Bootloader区0x0800000032KB引导程序、升级接收逻辑App_A区0x0800800096KB正式运行版本AApp_B区0x0801800096KB正式运行版本B参数/标志区0x080280008KB升级标志、版本信息、CRC预留区0x0802A00088KB扩展用后续可扩大App分区这个分区有几个讲究。第一Bootloader放最前面因为STM32F103上电后硬件固定从0x08000000取栈顶指针和复位向量这是芯片设计决定的没得商量。第二App_A和App_B大小保持一致这样同一份编译产物只需要改链接脚本里的起始地址逻辑完全不用动。第三参数区单独划一页放在两个App之后避免升级App时误伤标志数据。注意F103的Flash页大小不是统一的中容量芯片64KB、128KB每页1KB高容量芯片256KB及以上每页2KB。擦除是按页来的所以你在写擦除逻辑时要先确认自己芯片的页大小。RCT6每页2KBBootloader区32KB就是16页App区96KB就是48页8KB参数区4页这些边界数据写死在宏定义里就行。2.3 Bootloader为什么必须占住低地址区有人问能不能把Bootloader放最后把App放前面答案是不建议。F103上电后CPU固定从Flash最低地址0x08000000加载初始栈指针MSP和复位向量如果你想用软件跳转的方式从高地址的Bootloader开始执行需要先在低地址放一个极小的引导段本质上还是把“低地址段”让给了引导逻辑。更重要的是中断向量表默认也是在0x08000000如果Bootloader不在这个位置Bootloader要处理中断得先做向量表重映射而Bootloader本身通常还需要串口中断来接收固件这里就会陷入鸡生蛋的麻烦。所以最省心的做法就是让Bootloader占住Flash最低地址它天然拥有默认向量表不需要任何重映射逻辑。App区虽然靠后但可以通过改VTOR寄存器把中断向量表指过去这个后面再细讲。分区这件事我的建议是先在Excel或纸上画出来把每个地址段、大小、用途都标注清楚再动手建工程。我见过不少人在写代码时临时改分区结果Bootloader和App的地址对不上升级后跳转直接HardFault最后怎么排查都找不到原因因为根源在分区表本身就矛盾。3. 双工程搭建Bootloader与App的分工3.1 标准外设库工程的基础配置关于固件库热词里频繁出现“stm32f103库v3.50下载”。STM32F103目前有两种主流开发方式标准外设库StdPeriphv3.5和STM32CubeMXHAL库。我的建议是纯学习原理、想彻底搞懂寄存器层面的逻辑用标准库如果追求开发速度、擅长可视化配置用HAL库。本文核心逻辑以标准库为指导HAL库的对应接口我会在关键点指出来。搭建Bootloader工程时不需要把整个标准库的源文件全部加进来至少要用到这些模块RCC时钟配置、GPIO、USART、FLASH、NVIC。在Keil MDK里新建工程芯片选择STM32F103RCT6然后在Manage Run-Time Environment里选好CMSIS和Device相关组件再把标准库的源码路径加进Include Paths。启动文件用startup_stm32f10x_hd.s这个文件网上可以直接搜到也可以从标准库工程模板里拷贝。注意如果是C8T6、RCT6这样的高密度芯片启动文件是hd版本如果是C6T6这种中密度芯片得用md版本选错了启动文件工程直接编译不过。时钟这一块升级过程中串口波特率依赖系统时钟稳定所以一定要把时钟配置到72MHz。标准库里有个SystemInit函数会默认把HSE 8MHz倍频到72MHz。如果你用的是8MHz晶振直接调用SystemInit就行。如果板子不是8MHz晶振需要改system_stm32f10x.c里的PLL倍频系数否则串口波特率会漂这个问题我后面还会在坑里提到。3.2 链接脚本与IROM1地址修改双工程最核心的区别就是Flash起始地址和大小不同。这里以Keil MDK为例打开Options for Target - Target页每个工程都要单独设置IROM1。Bootloader工程配置如下IROM1 Start0x0Size0x800032KB。IRAM1保持默认0x20000000Size按芯片RAM大小填RCT6是0xC00048KB。App工程就复杂一点。App_A的IROM1 Start0x8000Size0x1800096KBApp_B的IROM1 Start0x18000Size0x1800096KB。注意这里的Start是相对0x08000000的偏移Keil会自动把链接地址定位到0x08008000和0x08018000。两个App工程除了这个起始地址不同其他代码完全一样这也是AB方案的一大优点。很多新手会在这里出错他们把App的起始地址写在SystemInit或main函数里却没改链接脚本。结果编译出来的App代码段仍然从0x08000000开始烧录时把Bootloader覆盖了或者跳转时跑飞。正确的顺序是先改地址后写代码。改完地址编译一次用生成的map文件确认代码段确实落在预期地址再往下写逻辑。3.3 用map文件守住固件体积红线热词里有人搜“stm32f103的map文件”这里正好讲一下它的用途。Keil每次编译链接后都会生成一个.map文件里面详细列出了每个模块占用的Flash和RAM大小。做OTA时这个文件的作用特别重要你要确认最终固件的RO只读代码常量大小不能超过App分区的容量。具体操作是编译后在.map文件里搜索“Total RO Size”或“Total ROM Size”比如编译完显示Total RO Size 48000 bytes那这个固件就不能塞进一个48KB以下的分区否则即使链接成功实际写入时也会覆盖到其他区域引起各种玄学问题。我之前帮人排查过一个案子App编译只有70KB硬塞进64KB的分区看起来能跑一旦升级到B区启动后外设配置全是乱的就是固件尾巴越界覆盖了参数区。另外建议在两个App工程里定义一个编译期常量比如#define APP_START_ADDR 0x08008000然后在SystemInit或者main函数里读一下这个地址判断当前固件是不是预期分区。如果Bootloader跳错位置这个检查能立刻暴露问题比直接看跑飞到哪里省事得多。4. Bootloader核心逻辑升级标志、Flash驱动与App跳转4.1 升级标志区结构设计升级标志是整个AB_OTA的安全核心。它必须记录当前应该启动哪个分区、新固件是否接收完整、固件版本是什么。我把升级标志设计成下面这个结构体存放在参数区头两个Flash页里。#define FLAG_AREA_ADDR 0x08028000u #define FLAG_AREA_SIZE (2 * 2048u) // 两页高容量芯片每页2KB typedef struct { uint32_t magic; // 固定魔数 0x4F544157用于判断标志是否有效 uint32_t boot_target; // 0App_A1App_B uint32_t firmware_version;// 新固件版本号 uint32_t firmware_size; // 新固件字节数 uint32_t firmware_crc; // 新固件整体CRC32 uint32_t update_status; // 0空闲1接收中2待启动3启动成功 } upgrade_flag_t;这个结构体看起来简单但每个字段都有意义。magic字段用来判断Flash里有没有写过有效的标志如果读出不是魔数说明这是第一次上电或者标志被清过Bootloader默认走“启动App_A”的分支。boot_target是核心字段它决定了跳转地址。update_status是掉电保护的关键新固件边收边写时状态一直保持“接收中”只有整个固件都校验通过才把状态改成“待启动”然后执行复位。为什么要特别强调“先写标志再复位”的顺序因为F103的Flash写入是按页擦除的如果状态字段和目标分区字段不在同一页掉电可能只写入了一半标志。所以我把整个结构体固定塞进同一页Flash写入时一次写完配合Flash半字写操作基本能保证标志要么没写、要么完整。另外参数区我用两页是因为擦除时需要先备份旧标志再擦除新页防止中途掉电导致标志全丢。4.2 Flash擦写驱动与掉电安全F103的Flash控制器操作有不少规矩说白了就是先解锁再擦除按半字写入操作完后重新上锁。下面这段是标准库风格的底层驱动我在Bootloader里直接用的就是这套。#define APP_A_ADDR 0x08008000u #define APP_B_ADDR 0x08018000u #define FLASH_PAGE_SIZE 2048u // RCT6为2KB/页 static void flash_unlock(void) { FLASH-KEYR 0x45670123u; FLASH-KEYR 0xCDEF89ABu; } static void flash_lock(void) { FLASH-CR | FLASH_CR_LOCK; } static uint8_t flash_erase_page(uint32_t page_addr) { flash_unlock(); while (FLASH-SR FLASH_SR_BSY); if ((FLASH-CR FLASH_CR_LOCK) ! 0) { return 1; } FLASH-CR | FLASH_CR_PER; FLASH-AR page_addr; FLASH-CR | FLASH_CR_STRT; while (FLASH-SR FLASH_SR_BSY); if (FLASH-SR FLASH_SR_PGERR) { FLASH-SR FLASH_SR_PGERR; return 1; } FLASH-CR ~FLASH_CR_PER; flash_lock(); return 0; } static uint8_t flash_write_halfword(uint32_t addr, uint16_t data) { flash_unlock(); while (FLASH-SR FLASH_SR_BSY); FLASH-CR | FLASH_CR_PG; *(volatile uint16_t *)addr data; while (FLASH-SR FLASH_SR_BSY); if (FLASH-SR FLASH_SR_PGERR) { FLASH-SR FLASH_SR_PGERR; return 1; } FLASH-CR ~FLASH_CR_PG; flash_lock(); return 0; }有几个细节要特别提醒。第一F103的Flash必须按16位半字写入不能用32位直接写否则会触发PGERR。你在把固件数据从缓冲区拷到Flash时要按两个字节一组来写如果固件长度是奇数最后一个字节要补0xFF。第二擦除是按页来的写入之前必须确保目标页已经是擦除状态。在OTA接收过程中每收到一包数据先计算这一包落在哪个页如果页首地址还没擦过就先把整页擦掉再写数据。第三所有操作之后都要检查BSY标志。标准库的函数内部其实已经做了等待但如果你在中断里操作Flash或者跳转之前没有把Flash忙等待做完很容易出现写入不完整。稳妥的做法是每次写完一整包后调用一次FLASH_WaitForLastOperation或者上面的while轮询。HAL库的对应接口我顺手列一下HAL_FLASH_Unlock、HAL_FLASHEx_Erase、HAL_FLASH_Program、HAL_FLASH_Lock。HAL库的好处是接口封装得更规整坏处是擦除和编程结构体比较繁琐而且HAL库内部用了超时机制调试时如果断点停在Flash操作里回来后容易进入超时错误需要手动清除错误标志。4.3 关键跳转函数的完整实现App跳转是Bootloader最核心的一段代码也是新手最容易写错的地方。跳转前要做几件事关闭全局中断、把SysTick和外设中断全部复位、设置主栈指针MSP、最后跳转到App的复位向量。typedef void (*app_entry_t)(void); static void jump_to_app(uint32_t app_addr) { uint32_t stack_addr *(volatile uint32_t *)app_addr; uint32_t reset_addr *(volatile uint32_t *)(app_addr 4); app_entry_t app_entry; // 检查栈顶地址是否在RAM范围内防止跳到一个空的Flash地址 if ((stack_addr 0xFFF00000u) ! 0x20000000u) { return; } // 检查复位向量是否在Flash范围内 if ((reset_addr 0xFFF00000u) ! 0x08000000u) { return; } __disable_irq(); SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; // 关闭所有外设中断复位到默认状态 for (uint32_t i 0; i 8; i) { NVIC-ICER[i] 0xFFFFFFFFu; NVIC-ICPR[i] 0xFFFFFFFFu; } __set_MSP(stack_addr); app_entry (app_entry_t)reset_addr; app_entry(); }注意这里有一个关键的检查跳转前要验证App开头两个字是否合理。第一个字是初始栈顶指针一定落在0x20000000到0x2000FFFF这个RAM区间第二个字是复位函数入口一定落在0x08000000到0x0803FFFF这个Flash区间。如果这两个检查不过说明目标地址里存的不是合法固件硬跳过去就是HardFault。我最初实现跳转时就是因为没做这个合法性检查App_B区还没烧录时就试图跳过去结果芯片直接死在启动阶段。后来加了检查Bootloader发现B区不合法就自动回退到A区系统才算真正有了容错能力。跳转前关闭中断这步也特别重要。F103在跳转到App的一瞬间如果还有中断挂起App的向量表还没有完全生效CPU会立刻进异常。所以要在跳转前把所有NVIC中断全部Disable和Clear再把SysTick停掉。这样App的main函数从头初始化所有外设整套系统才能干净地重新跑起来。5. App端改造中断向量表重映射5.1 不改向量表的典型翻车现场很多人在第一次把App编译到0x08008000后发现一个问题串口中断完全不来程序卡死在某个地方。原因就是中断向量表没有改。F103的中断向量表默认放在0x08000000也就是Flash起始地址。App在0x08008000运行发生串口中断时CPU会去0x08000000取串口中断处理函数的地址。可0x08000000是Bootloader的地盘它的向量表里串口中断处理函数指向的是Bootloader里的代码于是CPU跳进Bootloader的串口处理函数而那个函数里可能什么都没做结果就是App的外设中断全部失效。解决思路很简单让CPU知道“我的中断向量表在0x08008000别去0x08000000找了”。这就是中断向量表重映射。5.2 两种重映射方案改VTOR还是搬RAMF103的Cortex-M3内核有一个VTOR寄存器地址是0xE000ED08专门用来设置向量表偏移。标准库的system_stm32f10x.c里已经留好了开关你只需要打开它。// 在system_stm32f10x.c中App_A工程设置为 #define VECT_TAB_OFFSET 0x8000u // 对应App_A的Flash偏移 // App_B工程设置为 #define VECT_TAB_OFFSET 0x18000u // 对应App_B的Flash偏移SystemInit函数里会有这样一段#ifdef VECT_TAB_SRAM SCB-VTOR SRAM_BASE | VECT_TAB_OFFSET; #else SCB-VTOR FLASH_BASE | VECT_TAB_OFFSET; #endif默认情况下VECT_TAB_SRAM是不定义的所以走的是FLASH_BASE | VECT_TAB_OFFSET也就是把向量表指向0x08008000或0x08018000。但有个坑要注意F103使用的Cortex-M3内核版本较老按照ST官方勘误在某些情况下VTOR在复位后可能不会立即生效。我实测下来直接设置VTOR指向Flash在绝大多数情况下是能工作的但如果你的项目对可靠性要求极高或者调试时发现在跳转后第一次中断就异常建议用第二种方案把向量表搬到SRAM里。搬RAM的做法是在main函数最开头手动复制整个向量表到SRAM起始地址然后把VTOR指向RAM#define VECTOR_TABLE_SIZE 0x400u // 256字节共64个中断向量 void vector_table_relocate(void) { uint32_t i; uint32_t *src (uint32_t *)APP_A_ADDR; // 注意换成当前App起始地址 uint32_t *dst (uint32_t *)0x20000000u; for (i 0; i VECTOR_TABLE_SIZE / 4; i) { dst[i] src[i]; } SCB-VTOR 0x20000000u; }复制完再设置VTOR这样即使Flash读取出现小概率的延迟问题向量表也已经在SRAM里取指更快也更稳定。缺点是占用了一点宝贵的SRAM总共也就256到512字节工程上完全可接受。5.3 Keil工程中App的启动配置App工程除了要改IROM1地址、打开VECT_TAB_OFFSET还有一个容易忽略的地方中断向量表本身必须放在App段的最前面。Keil里链接脚本默认会把中断向量表从ROM起始地址开始放这正好符合要求。但如果你在工程里加了自定义段、或者分散加载文件配置不对可能导致向量表被放到别处这时即使VTOR改了也没用因为App起始地址前256字节根本不是向量表。最稳妥的验证方法是用Keil的Memory Map窗口看0x08008000地址开始的前几百个字节。正常情况下最前面的4字节应该是0x2000xxxx栈顶指针紧接着4字节是0x0800xxxxReset_Handler地址再往后是一串中断服务函数地址。如果看到这个规律说明App固件结构正常可以放心烧录。还有一个细节App的启动文件startup_stm32f10x_hd.s里定义了SystemInit的调用。如果你的App工程在跳转进来时需要关闭看门狗、清理某些外设可以在SystemInit里做但要注意不要在跳转前就把App要用的外设初始化好了因为Bootloader已经关过一遍中断和SysTickApp里重新初始化才是正路。6. 固件传输协议与升级状态机6.1 一帧数据怎么设计固件传输协议是Bootloader和上位机之间的“共同语言”。这一层设计得好不好直接影响升级成功率。我的协议帧设计是这样的每个字段都有明确目的。字段长度说明帧头2字节固定0xAA 0x55用于同步和识别帧起始命令1字节0x01握手0x10开始传输0x11数据包0x12结束0x13查询版本包序号4字节从0递增用于丢包检测和ACK确认数据长度2字节数据域的字节数最大1024数据域N字节固件数据或参数CRC162字节对“命令包序号长度数据”计算用于单帧完整性校验这里的关键点是每帧都要有独立的CRC16不要指望上层协议再帮你做校验。串口本身可能偶尔出现误码如果一帧数据错了没发现写进Flash的固件就是坏的整体校验时才暴露那就需要整包重传浪费时间。单帧CRC能把错误拦截在写入之前收到错误帧直接回NACK上位机重发这一帧就行。CRC16算法我推荐用Modbus CRC16多项式是0x8005初值0xFFFF查表法实现速度很快。网上有大把参考实现直接移植过来用即可。整个固件包最后再算一个CRC32可以用F103自带的硬件CRC32外设但要注意硬件CRC的初始值固定是0xFFFFFFFF而且输出结果和其他平台的标准CRC32可能差一个取反建议在结束帧里两边统一用软件CRC32计算避免平台差异。6.2 升级状态机拆解Bootloader里的升级逻辑用一个简单的状态机就能描述清楚。我个人调试时习惯把状态机画在纸上每个状态、每个触发条件都标清楚再开始写代码这样逻辑不容易乱。空闲状态IDLEBootloader复位后先读取升级标志。如果状态是“接收中”或“待启动”说明上次升级被打断先清理掉无效标志然后选择A区或者B区启动。如果标志是“空闲”或者“启动成功”就正常跳转App。握手状态HANDSHAKEBootloader进入升级模式后可以由串口收到特定命令进入也可以由App软复位前设置标志进入等待上位机发送握手帧。收到握手帧后Bootloader回一帧包含Bootloader版本、芯片型号、最大分包长度的信息。上位机根据这些信息决定分包策略。接收状态RECEIVING上位机开始逐包发送数据帧。Bootloader收到一帧先校验CRC校验通过就把数据写入目标Flash分区。这里要注意Bootloader不能等收完整个固件再写因为Flash写入慢RAM又很小必须边收边写。每写一个页记录当前写在哪个地址方便断点续传。校验提交状态VERIFYING所有数据帧收完后上位机发送结束帧里面包含固件总长度和整体CRC32。Bootloader把已写入Flash的所有字节重新读出来算CRC和结束帧的CRC对比。一致则更新升级标志boot_target指向新分区update_status设为“待启动”然后复位。不一致则丢弃目标分区保持旧分区启动。启动成功确认状态CONFIRMING跳转到新App后如果App能正常初始化并跑起来App应当主动向Bootloader写一个“启动成功”标志update_status3。Bootloader下次启动看到这个标志就知道新分区可用可以清理掉旧分区里的待升级标志。我特别想强调“App主动确认启动成功”这一步。它的作用是防止一种极端情况新固件能跳转但跑了半分钟就崩溃、复位后又跳回来反复死循环。有了确认标志Bootloader可以设计成“启动新分区后如果一段时间内没收到App确认就自动回退到旧分区”。这在无人值守设备上非常重要。6.3 传输通道怎么选串口/RS485/CAN/NRF24L01传输协议设计好之后具体走什么物理通道要看你的产品形态。这里对应热词里的几类问题我把常见通道逐个说清楚。串口UART/USART是最简单的选择适合开发调试和有线升级。F103的USART1挂APB2波特率可以到1.5Mbps以上但建议最多用115200或者460800因为还需要处理Flash擦写波特率太高容易在擦写期间丢数据。记得串口接收要用中断环形缓冲区不能用阻塞查询否则擦一页Flash的时间里后面来的数据全丢了。热词里有人搜“stm32f103基于cubemx hal库的串口开启”用CubeMX的话记得把USART全局中断打开并且设置合理的缓冲区大小一般256字节起步。RS485是在串口基础上加了半双工和差分传输适合工业现场长距离通信。核心痛点是方向切换。MAX485的DE/RE引脚控制发送使能发送时必须拉高DE发完必须等最后一个字节完全送出去等TC标志不只是TXE再拉低DE否则最后一个字节甚至最后几个字节会被截断。我早期测试485 OTA时升级十个设备有一半失败最后发现就是方向切换太早串口还没发完就切到了接收方向。正确代码如下RS485_DE_HIGH(); // 置为发送模式 USART_SendData(USART1, data); while (USART_GetFlagStatus(USART1, USART_FLAG_TC) RESET); RS485_DE_LOW(); // 等发送完成再切回接收CAN总线也是工业设备很常见的升级通道。热词里有“can stm32f103 sjw同步跳跃宽度”这是CAN位时序配置里的一个参数。CAN波特率和采样点设置得不对总线可能直接通信失败。拿APB136MHz为例如果目标是500kbps可以配置成Prescaler4Tq9MHzBS113TqBS24TqSJW1Tq这样位时间合计18Tq波特率正好500kbps采样点大约78%。要注意SJW范围是1到4取值小了同步能力弱取值大了抗干扰能力差常规用1就够了。F103的bxCAN每帧最多带8字节数据所以固件数据要切成8字节的小包加上协议头尾传输效率远低于串口但CAN总线天然支持多节点、带总线仲裁在工控环境可靠性很高。NRF24L01无线通道是最后一步可以考虑的方案。它走SPI接口速率最高2Mbps但实际吞吐受ACK和重传机制影响实际有效速率通常不到标称的一半。更麻烦的是NRF24L01的Enhanced ShockBurst模式下每包最多32字节需要自己做分片、排序、重传。我建议先把有线通道的OTA调通再考虑无线因为无线链路出问题时排查面太大新手很容易被劝退。7. 上位机工具与完整联调流程7.1 用Python写一个简单升级发送端上位机工具不一定要写得很复杂调试阶段用Python就够了。核心逻辑是按协议组包、逐帧发送、等待ACK。import serial import struct import time PORT COM3 BAUD 115200 CHUNK_SIZE 256 crc_table [0] * 256 for i in range(256): crc i for _ in range(8): crc (crc 1) ^ 0xA001 if crc 1 else crc 1 crc_table[i] crc def crc16(data: bytes) - int: crc 0xFFFF for b in data: crc (crc 8) ^ crc_table[(crc ^ b) 0xFF] return crc def build_frame(cmd: int, seq: int, payload: bytes) - bytes: length len(payload) head b\xAA\x55 body struct.pack(BIH, cmd, seq, length) crc crc16(body payload) return head body payload struct.pack(H, crc) ser serial.Serial(PORT, BAUD, timeout1) firmware open(app.bin, rb).read() # 发送开始传输帧 ser.write(build_frame(0x10, 0, struct.pack(II, len(firmware), 0))) ack ser.read(5) print(start ack:, ack.hex()) # 逐包发送固件数据 seq 0 for offset in range(0, len(firmware), CHUNK_SIZE): chunk firmware[offset:offset CHUNK_SIZE] if len(chunk) CHUNK_SIZE: chunk b\xFF * (CHUNK_SIZE - len(chunk)) ser.write(build_frame(0x11, seq, chunk)) ack ser.read(5) while len(ack) 5 or ack[2] ! (seq 0xFF): ser.write(build_frame(0x11, seq, chunk)) ack ser.read(5) seq 1 if seq % 100 0: print(progress:, seq * CHUNK_SIZE, /, len(firmware)) # 发送结束帧附带整体CRC32 import zlib firmware_crc zlib.crc32(firmware) 0xFFFFFFFF ser.write(build_frame(0x12, seq, struct.pack(II, len(firmware), firmware_crc))) ack ser.read(5) print(finish ack:, ack.hex())这段代码里我做了三个关键设计其一最后一包不足256字节时用0xFF补齐正好对应Flash擦除后的默认值不影响固件数据其二每发送一包就等待ACKACK里的包序号必须匹配否则重发当前包这是最基础的可靠传输保障其三结束帧携带整个固件的长度和CRC32Bootloader据此做最终校验。拿到这个脚本配合串口助手你就能在电脑上对板子进行完整的OTA升级测试了。实际产品中上位机通常还会做超时重试、进度条、日志记录但核心协议跟这段代码一致。7.2 从烧录到回滚的完整验证清单联调时不要一上来就想着“一把成功”我每次移植OTA都是分步骤验证每一步都有明确的通过标准。第一步验证Bootloader基础显示。先把Bootloader烧进0x08000000上电后它应该能从串口打印日志然后尝试跳转到A区。此时A/B区都是空的Bootloader应该给出“找不到合法固件”的提示而不是死机。这一步通过说明Flash读取和基本时钟没问题。第二步验证App单区启动。用ST-Link把App_A固件烧到0x08008000Bootloader上电后跳转过去LED点亮、串口打印“App A running”。这一步通过说明跳转逻辑和App的向量表重映射没问题。第三步验证通过上位机升级到App_B。Bootloader进入升级模式用Python脚本发送App_B固件升级完成后软件复位系统自动启动新分区串口打印“App B running”。这一步通过说明传输协议、Flash写入和标志切换都正常。第四步验证失败回滚。故意把App_B固件最后一个字节改错或者在上位机发送到一半时拔掉串口模拟升级中断再重新上电。预期结果是Bootloader发现B区不合法或标志不完整自动回退到A区运行。这一步通过说明掉电保护和回滚策略真正生效。第五步验证升级掉电恢复。在升级过程中任意时刻突然断电重新上电后系统仍然能启动旧版本App且可以进行下一次升级。这一步通过AB_OTA的核心价值就体现出来了。这套验证清单每一条我都在不同芯片上跑过其中最容易卡住的是第四步和第五步因为它们涉及Flash状态切换的场景比正常升级多一旦标志设计有漏洞就会暴露。8. 常见问题排查与踩坑实录8.1 问题速查表我把实际调试中最常遇到的几类问题整理成了表格方便你对照排查。现象可能原因排查方法跳转App后死在启动阶段App起始地址处不是合法固件跳转前未关闭中断检查App首字是否为0x2000xxxx跳转前加合法性检查确认NVIC已DisableApp能跑但串口中断不来中断向量表未重映射检查VECT_TAB_OFFSET是否正确用VTOR指向0x08008000或RAM备份方案升级一半后设备反复重启升级标志写入不完整Flash写入失败检查参数区是否被App越界覆盖检查Flash擦写等待BSY是否够串口收到的数据全是乱码波特率误差过大确认晶振是否为8MHz检查SystemInit时钟配置实际波特率用示波器测量RS485升级总是少最后几字节DE/DE方向切换过早发送完成后必须等USART_FLAG_TC不能只等TXECAN升级时断时续位时序配置不对检查SJW、BS1、BS2的Tq数用示波器看CAN_H/CAN_L波形确认波特率准确固件闪存空间恰好超出一页擦除边界与写入边界没对齐按页取整计算擦除范围不能只从App起始地址擦到结束地址8.2 几个反复出现的坑第一个坑是跳转前没关外设中断导致HardFault。在F103上如果App没有重新初始化RCC但Bootloader里已经把USART1打开并开启了接收中断跳转瞬间一个串口数据刚好到达CPU会尝试从中断向量表取USART1_IRQHandler而跳转瞬间App的向量表可能还没准备好直接HardFault。解决方案就是我在4.3里写的跳转前把NVIC全部Disable和Clear。这不是可选项是必选项。第二个坑是F103的VTOR在复位后的行为有风险。我遇到过两次设置SCB-VTOR指向Flash后第一次能正常跑但只要系统复位一次中断就全部失效。原因很微妙老版本的Cortex-M3核在复位后VTOR被清零App里SystemInit的VTOR赋值时机如果晚于外设中断使能就会有一个窗口期中断向量丢失。解决方法是把VTOR设置放到SystemInit里、main函数第一行并且在外设中断使能之前最好在复位后第一时间执行。更保险的方案是搬向量表到RAM。第三个坑是擦写Flash时把代码运行卡住了。F103的Flash控制器在擦写期间如果CPU正在从Flash取指的同一bank会有取指阻塞。标准库的FLASH_Program函数内部会等待BSY但你自己写驱动时千万别在Flash擦写期间从中断里读取另一个Flash地址的数据否则可能进入死锁。实际工程里我倾向于把擦写循环里的中断全部屏蔽或者保证擦写逻辑本身不从Flash里取指令比如把它放到RAM里运行这个细节在多线程或RTOS环境下尤其重要。第四个坑是App工程忘了改IROM1地址编译出来的固件还在0x08000000。这时候烧进0x08008000程序根本没法启动但你在Keil里看又觉得“烧录成功了”。排查方法是烧录后打开Debug看PC是否停在0x08008000如果不是说明固件本身地址就不对回编译设置里找原因。第五个坑是“升级成功但新固件跑起来功能不对”。这种问题往往不是OTA流程的问题而是新固件本身有Bug。我的建议是每次升级前先在老分区上验证过新固件能够正常工作再去做AB切换。AB分区只能防止你“变砖”不能防止你“把坏固件推上线”业务功能校验还是需要在上层做好。9. 结尾前的小经验做AB_OTA这件事技术本身不复杂真正复杂的是把所有边界情况都考虑到。我个人的体会是先把串口通道的最简流程跑通再逐步加RS485方向切换、CAN位时序、无线重传一次只引入一个新变量出问题时才好定位。另外强烈建议你在调试阶段保留Bootloader的串口日志输出把“当前读到的标志值”“目标分区CRC结果”“跳转地址”这些关键信息都打出来。我见过太多人一出问题就一头扎进代码里瞎猜其实日志里早就写了原因。最后再分享一个小技巧如果你手头没有第二块板子也可以用STM32F103的RAM区模拟一个“假的B区”来做跳转测试只要把App_B的固件加载到RAM里把跳转地址指向RAM先验证跳转函数本身有没有问题。等信心足了再烧到Flash里做真正的AB切换。这套方法帮我省了很多来回擦写Flash的时间希望你也能用上。
分享:

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

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