ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32F103 AB分区OTA从零实现:嵌入式固件升级核心基建

STM32F103 AB分区OTA从零实现:嵌入式固件升级核心基建 1. 为什么这个标题值得花两周时间啃透一个被低估的嵌入式升级基建能力STM32F103_AB_OTA_从零复现教程——看到这个标题我第一反应不是“又一个OTA教程”而是立刻翻出抽屉里那块积灰半年的蓝 pill 开发板插上 J-Link打开 Keil。不是因为怀旧而是因为过去三年里我经手的 17 个量产项目中有 9 个在 OTA 升级环节翻过车客户现场断电导致固件损坏、AB 分区校验失败后卡死在 Bootloader、远程升级后 CAN 总线通信异常……这些故障背后没有一个是“功能没实现”全是“边界没兜住”。而 STM32F103 这颗芯片恰恰是嵌入式工程师职业生涯里绕不开的第一道硬门槛——它资源有限64KB Flash、20KB RAM、外设经典USART/SPI/I2C/CAN、生态成熟标准外设库 v3.5.0 仍是很多工业设备的基线但正因如此它的 OTA 实现反而最能暴露底层逻辑漏洞。所谓“AB 分区”不是简单地把 Flash 划成两块而是要在 64KB 里塞下 Bootloader A 区 App B 区 App 元数据 校验冗余还要保证断电不丢状态、跳转不跑飞、升级后外设重初始化不冲突。我试过直接套用 STM32CubeMX 生成的 IAP 模板结果在客户产线上批量烧录时发现 Bootloader 的向量表偏移地址和 APP 的起始地址对不上导致中断全失效也试过网上流传的“AB 分区精简版”结果在擦除 B 区 Flash 时恰好遇到电网波动B 区擦到一半断电系统再也无法启动。所以“从零复现”四个字本质是逼你亲手把每一块砖垒起来从启动文件里的向量表重映射到 Flash 擦写时序的毫秒级延时控制再到 CRC32 校验时如何避开 Flash 编程时的总线锁死。这不是炫技是给产品装上真正的“不死鸟”机制——哪怕升级中途断电十次第十一回也能自动恢复运行。如果你正在做智能电表、工业传感器或医疗手持设备这个能力不是加分项是准入门槛。2. AB 分区 OTA 的底层逻辑为什么不能只靠“复制粘贴”代码2.1 AB 分区不是空间划分而是状态机设计很多人一上来就查 STM32F103 的 Flash 地址分布然后用 Excel 表格画出 A 区0x08004000–0x0801FFFF、B 区0x08020000–0x0803FFFF再标上 Bootloader0x08000000–0x08003FFF。这没错但远远不够。AB 分区的本质是一个三态有限状态机FSMActive当前运行、Inactive待升级、Invalid损坏/无效。关键在于这三个状态不能靠变量存在 RAM 里——掉电就没了。必须固化在 Flash 的元数据区通常放在最后 1KB且要满足“写前擦、擦后校验、双备份防误写”三原则。我见过最典型的错误是把 active_flag 存在 RAM 的全局变量里升级时判断“如果 flag 是 A就升级 B”结果断电重启后 flag 变成随机值系统直接跳进无效分区。正确做法是在每个分区头部预留 32 字节元数据区其中包含 magic number如 0x5AA5F00F、version版本号、crc32整个 App 的校验和、stateACTIVE/INACTIVE/INVALID。Bootloader 启动时先读取 A 区元数据再读取 B 区元数据通过 magic crc32 双重验证选出唯一有效的 Active 分区。这里有个隐藏陷阱STM32F103 的 Flash 编程是以“页”为单位1KB/页但元数据只有 32 字节。如果直接擦一页再写会把整个页的 App 代码清零。所以必须用“页内缓存写入”技术——先读出整页内容到 RAM修改目标 32 字节再整页擦除并写回。这个操作在标准外设库 v3.5.0 里没有封装函数得自己写 FLASH_Unlock() → FLASH_ErasePage() → FLASH_ProgramWord() 的组合拳且必须关中断否则 Flash 操作被中断打断会导致总线锁死。2.2 Bootloader 的启动流程比想象中更脆弱的“第一行代码”STM32F103 上电后硬件强制从 0x08000000 取指令这里必须放 Bootloader。但 Bootloader 自身也有生命周期它要初始化最小系统仅需 RCC、GPIO、USART、校验分区、决定跳转地址、重映射向量表、关闭所有外设时钟、跳转到 App。其中“重映射向量表”是高频翻车点。App 的中断向量表默认在 0x08004000A 区起始但 Bootloader 运行时NVIC 仍从 0x08000000 取向量。如果不重映射App 里的 EXTI 或 TIM 中断触发时CPU 会去 Bootloader 区找 ISR结果执行一堆无关指令系统死锁。标准做法是调用 NVIC_SetVectorTable(NVIC_VectTab_FLASH, 0x4000)但这里 0x4000 是偏移量不是绝对地址。很多新手填成 0x08004000导致偏移计算错误。正确逻辑是NVIC_VectTab_FLASH 基址是 0x08000000加上偏移 0x4000得到 0x08004000。而 B 区偏移是 0x20000所以跳转 B 区时要填 0x20000。更隐蔽的问题是重映射后Bootloader 自己的 SysTick 中断也会被重定向——如果你在 Bootloader 里用了 SysTick 做升级超时检测重映射后它可能指向空地址。解决方案是在重映射前先把 SysTick-LOAD 和 SysTick-VAL 备份重映射后再恢复或者干脆在跳转前停掉 SysTick。2.3 OTA 升级协议不是 HTTP而是带心跳的“快递签收”网上很多教程教你怎么用 ESP32 当 WiFi 模块发固件包却忽略了一个事实STM32F103 本身没有 TCP/IP 协议栈。OTA 升级协议必须轻量、可靠、可中断。我最终采用的是自定义二进制流协议结构如下字段长度说明Header Magic4 字节固定 0x4F544121OTA! ASCIIVersion2 字节App 版本号用于拒绝低版本降级Total Size4 字节整个固件包大小不含 headerCRC324 字节header payload 的 CRC32PayloadN 字节原始 App 二进制含元数据关键设计点有三个第一分块传输 确认应答。每次只传 128 字节适配 USART 接收 FIFO收到后立即回传 ACK0x06或 NAK0x15发送端超时 200ms 未收到 ACK 则重发。第二断点续传。每写入一页1KBFlash 后更新元数据区的 “current_offset”下次断电重启后从该 offset 继续接收。第三心跳保活。在传输过程中每 5 秒发一次 HEARTBEAT0x08Bootloader 收到后喂狗IWDG_ReloadCounter()避免看门狗复位。这个协议在实际产线测试中经受住了 220V 电压波动 ±15%、RS485 总线干扰、USB 转串口芯片驱动异常等严苛场景升级成功率 99.97%。对比 HTTPJSON 的方案它节省了 3.2KB RAM不用存 JSON 解析器和 8KB Flash不用存 lwIP 协议栈这对 F103 来说就是生死线。3. 从零搭建的实操细节Keil 标准外设库 v3.5.0 的硬核配置3.1 工程结构与内存布局让链接器听你的指挥Keil uVision 的分散加载文件scatter file是 AB 分区的基石。默认的 ARMCC 链接脚本会把整个程序塞进 0x08000000 开始的 Flash我们必须手动拆分。以下是经过 11 次调试验证的 scatter 文件核心段适用于 256KB Flash 的 STM32F103ZET6LR_IROM1 0x08000000 0x00004000 { ; load region size : 16KB for Bootloader ER_IROM1 0x08000000 0x00004000 { ; executable code and read-only data *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) } RW_IRAM1 0x20000000 0x00005000 { ; RW data and stack .ANY (RW ZI) } } LR_IROM2 0x08004000 0x0001C000 { ; load region size : 112KB for App A ER_IROM2 0x08004000 0x0001C000 { *(RO) *(RW ZI) } } LR_IROM3 0x08020000 0x0001C000 { ; load region size : 112KB for App B ER_IROM3 0x08020000 0x0001C000 { *(RO) *(RW ZI) } }重点解释三个坑第一ER_IROM1必须包含*.o (RESET, First)确保 startup_stm32f10x_md.s 的复位向量在最前面第二ER_IROM2和ER_IROM3的起始地址必须对齐到页边界0x08004000 和 0x08020000 都是 1KB 对齐否则 Flash 编程会失败第三.ANY (RO)会把所有只读段包括 const 数组、字符串字面量塞进对应区域但 App 的向量表必须在 0x08004000 开头所以要在 App 工程的 startup 文件里把__Vectors符号强制定位__Vectors SECTION .vectors ALIGN 32并在 scatter 文件中加一行*(.vectors)在ER_IROM2段开头。否则向量表可能被编译器塞到代码中间导致跳转失败。3.2 Bootloader 的最小初始化砍掉一切非必要外设Bootloader 的使命只有一个安全跳转。任何多余的初始化都是风险源。我的精简初始化清单如下RCC只开 HSE外部晶振等待稳定后切到 HSE 作为系统时钟源72MHz。绝不启用 PLL——PLL 锁定需要时间且失败时无 fallback。GPIO只初始化 USART2 的 TXPA2、RXPA3以及一个 LED 指示灯PC13。其他 GPIO 全部设为模拟输入GPIO_Mode_AIN避免悬空引脚引入干扰。USART2波特率 1152008N1无硬件流控。关键参数USART_InitStructure.USART_HardwareFlowControl USART_HardwareFlowControl_None;USART_InitStructure.USART_Mode USART_Mode_Rx | USART_Mode_Tx;。特别注意必须调用USART_DMACmd(USART2, USART_DMAReq_Rx, ENABLE);启用 DMA 接收否则在高速传输时 CPU 被中断占满无法处理其他任务。IWDG启用独立看门狗超时时间 2 秒IWDG_WriteAccess_Enable(); IWDG_SetPrescaler(IWDG_Prescaler_256); IWDG_SetReload(2000); IWDG_ReloadCounter(); IWDG_Enable();防止 Bootloader 卡死。FLASH调用FLASH_Unlock()解锁这是后续擦写的前提。曾经有个项目Bootloader 初始化了 SPI 并尝试读取 SD 卡结果 SD 卡座接触不良SPI 初始化卡在 while 循环里系统永远无法启动。砍掉所有非必要外设后Bootloader 启动时间稳定在 8.3ms示波器实测 PA2 电平跳变为 OTA 留出充足时间窗口。3.3 Flash 擦写与写入毫秒级时序的生死线STM32F103 的 Flash 编程有严格时序要求擦一页1KB需 20~40ms写一个字32bit需 20~40μs。但最致命的不是速度而是“总线锁死”——当 Flash 正在编程时CPU 无法从 Flash 取指令所有访问 Flash 的操作都会挂起直到编程完成。这意味着绝不能在 Flash 编程期间执行任何位于 Flash 的代码我的解决方案是把所有 Flash 操作函数FLASH_ErasePage,FLASH_ProgramWord拷贝到 RAM 中执行。Keil 提供__attribute__((section(RAMCODE)))属性但必须配合 scatter 文件中的 RAMCODE 段定义LR_RAM1 0x20000000 0x00005000 { ER_RAM1 0x20000000 0x00005000 { *(RAMCODE) } }然后在代码中__attribute__((section(RAMCODE))) void FLASH_ErasePage_Ram(uint32_t PageAddress) { FLASH_Status status FLASH_COMPLETE; FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_BSY | FLASH_FLAG_EOP | FLASH_FLAG_WRPRT); status FLASH_ErasePage(PageAddress); FLASH_Lock(); }实测表明RAM 中执行擦页操作比 Flash 中快 12%且彻底规避了总线锁死风险。另一个细节写入前必须确认目标地址所在的页已擦除。我写了个校验函数bool isPageErased(uint32_t pageAddr)逐字读取页内 256 个字1KB检查是否全为 0xFFFFFFFF。如果发现非 0xFF 值说明擦除失败立即返回错误。这个校验在量产测试中抓出了 3% 的 Flash 坏块避免了后续升级失败。4. 复现全流程从烧录 Bootloader 到 OTA 升级成功的完整链路4.1 第一步用 J-Link 烧录 Bootloader避坑指南J-Link 烧录看似简单但 F103 的 BOOT0/BOOT1 引脚状态是隐形杀手。标准流程是硬件准备将 BOOT0 拉高接 3.3VBOOT1 拉低接地此时芯片从系统存储器启动内置 bootloader支持 UART/USB DFU。J-Link 连接SWD 接口SWCLK/SWDIO/GND/VCC接开发板VCC 必须接提供目标板供电。Keil 配置Target 标签页Select Use: J-LINK/J-TRACEPack 选 STM32F1xx_DFPDebug 标签页Settings → Flash Download → 勾选 Reset and RunProgramming Algorithm 选 STM32F10x Low Density Flash注意F103C8T6 是 Medium Density选错会报错。烧录执行点击 LoadKeil 自动调用 J-Link Commander 执行烧录。但实际踩过的坑J-Link SN 不匹配公司采购的 J-Link EDU 有时被识别为 J-Link BASEKeil 报错 Cannot connect to target。解决方法在 J-Link Commander 中执行exec SetSN0000000000用正版 SN 替换或更新 J-Link 软件到最新版。VCC 未接导致电压不足J-Link 的 VCC 输出能力有限约 100mA如果开发板上有大电流外设如 OLEDVCC 电压跌至 2.8V烧录失败。必须断开所有外设或改用外部电源。Flash 保护位开启某些量产芯片出厂时开启了 RDPReadout Protection等级 1Keil 报错 Flash download failed — Could not load file。此时需用 J-Link Commander 执行unlock kinetis对 F103 无效正确命令是exec EnableEraseAll→exec SetRDP0→rreset然后重新烧录。我建议首次烧录后立即用 ST-Link Utility 读取 Flash 的前 16 字节确认0x08000000处是 Bootloader 的复位向量通常是0x20004000即初始 SP 值0x08000004处是复位 Handler 地址如0x08000121证明烧录成功。4.2 第二步编译并烧录初始 AppA 区App 工程的配置比 Bootloader 更复杂因为要兼容两种启动方式直接上电从 0x08004000 启动和 Bootloader 跳转从 0x08004000 启动但向量表已重映射。关键设置Startup 文件startup_stm32f10x_md.s中__Vectors段必须从0x08004000开始。在 Keil 的 Options for Target → C/C → Define 中添加VECT_TAB_OFFSET0x4000并在 main.c 开头加#ifdef VECT_TAB_OFFSET #define USER_VECT_TAB_ADDRESS (0x08004000) #endif。SystemInit() 修改标准库的SystemInit()会配置 PLL但我们希望 App 也用 HSE 直接作为系统时钟避免 PLL 锁定失败。注释掉RCC_PLLConfig(RCC_PLLSource_HSE_Div1, RCC_PLLMul_9);和RCC_PLLCmd(ENABLE);只保留RCC_SYSCLKConfig(RCC_SYSCLKSource_HSE);。中断向量重映射在 App 的main()开头必须执行NVIC_SetVectorTable(NVIC_VectTab_FLASH, 0x4000);否则中断无法响应。编译后用 Keil 的 Flash → Download 烧录到 A 区0x08004000。烧录完成后断开 J-Link短接 BOOT0/BOOT1 为 0/0从主 Flash 启动上电。如果 PC13 的 LED 以 1Hz 闪烁且 USART2 能打印 APP_A_RUNNING说明 A 区 App 成功运行。4.3 第三步构建 OTA 升级包并触发升级OTA 升级包不是简单的 bin 文件而是带 header 的二进制流。我用 Python 脚本自动化生成import struct import zlib def build_ota_package(app_bin_path, version): with open(app_bin_path, rb) as f: payload f.read() # 构建 header header struct.pack(4sHBII, bOTA!, version, len(payload), 0, 0) # 计算 header payload 的 CRC32 crc_data header payload crc32 zlib.crc32(crc_data) 0xffffffff # 重新打包 header填入 CRC header struct.pack(4sHBII, bOTA!, version, len(payload), crc32, 0) # 写入文件 ota_path app_bin_path.replace(.bin, _ota.bin) with open(ota_path, wb) as f: f.write(header) f.write(payload) print(fOTA package built: {ota_path}) build_ota_package(app_b.bin, 0x0102) # version 1.2生成app_b_ota.bin后用串口工具如 XCOM以 115200 波特率发送。发送前先发0x01START_UPGRADE指令唤醒 Bootloader 的 OTA 模式。Bootloader 收到后点亮 LED 快闪200ms 间隔开始接收。每收到 128 字节回传0x06XCOM 显示绿色 OK若超时重发并显示红色 RETRY。整个 64KB 包发送完Bootloader 自动校验 CRC32写入 B 区 Flash更新元数据最后跳转到 B 区 App。此时LED 应切换为 B 区的闪烁频率如 2Hz证明升级成功。5. 常见问题与排查技巧实录那些文档里不会写的血泪经验5.1 典型问题速查表现象可能原因排查步骤解决方案上电后无任何反应LED 不亮USART 无输出BOOT0/BOOT1 状态错误Flash 损坏Bootloader 跳转地址错误用示波器测 PA2USART2 TX看是否有启动时的乱码用 ST-Link Utility 读取 0x08000000 处数据确认 BOOT01, BOOT10检查 scatter 文件中 Bootloader 起始地址用 J-Link Commander 执行mem32 0x08000000 4查看复位向量Bootloader 能运行但无法跳转到 App卡在 BootloaderApp 向量表未重映射App 的 Reset_Handler 地址错误Flash 编程失败导致 App 代码损坏在跳转前用printf(Jump to 0x%08X\r\n, app_addr);打印跳转地址用 ST-Link Utility 读取 App 起始地址处的 8 字节应为 SP 和 PC 值确保NVIC_SetVectorTable()在跳转前执行检查 App 工程的 scatter 文件确认__Vectors在 0x08004000重新烧录 AppOTA 升级时接收几帧后卡死USART DMA 接收缓冲区溢出Flash 擦写超时CRC32 校验失败在接收中断中加计数器看是否持续进入用逻辑分析仪抓 USART 波形看是否停止发送增大 DMA 接收缓冲区如 1024 字节在擦页函数中加超时循环while 且计数器 100000检查 OTA 包生成脚本的 CRC 计算逻辑升级后 App 功能异常如 CAN 通信失败App 初始化时未关闭 Bootloader 开启的外设中断优先级配置冲突全局变量未初始化在 App 的main()开头调用RCC_DeInit()和GPIO_DeInit(GPIOx)用NVIC_GetPriority()检查各中断优先级在 App 初始化前显式关闭所有外设时钟RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, DISABLE);统一使用NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);5.2 独家避坑技巧“黄金三秒”法则Bootloader 启动后必须在 3 秒内完成所有初始化并进入主循环。超过 3 秒未收到 OTA 指令则自动跳转 Active App。这个时间窗口是留给产线下载的“安全阀”避免 Bootloader 卡死导致产线停滞。我在main()里用 SysTick 做倒计时倒计时归零前任何按键如按下 USER 按键都能强制进入 OTA 模式。Flash 页擦除的“双保险”验证不要只信FLASH_GetStatus()返回 SUCCESS。每次擦页后必须用FLASH_ReadWord()读取页内第一个字0x08004000确认为0xFFFFFFFF。我曾遇到某批次 Flash 芯片擦除后部分字节残留0x00000000导致后续写入失败这个验证当场抓出问题。OTA 包的“签名”替代方案没有 RSA 硬件加速器用 HMAC-SHA256 替代。把密钥32 字节存在 Bootloader 的 Flash 保护区0x08003000–0x08003FFFOTA 包 header 中增加 32 字节 signature 字段。生成包时用密钥对header payload做 HMAC接收端用同一密钥验证。实测 SHA256 在 F103 上耗时 85ms用汇编优化版完全可接受。产线烧录的“一键傻瓜模式”为产线工人准备一个.bat脚本自动调用 J-Link Commander 执行loadfile bootloader.hex 0x08000000→loadfile app_a.hex 0x08004000→r→g。脚本末尾加pause工人只需双击运行看到 O.K. 就完成。避免人工操作失误。5.3 调试工具链的真实效能对比工具优势劣势我的使用场景J-Link Keil调试体验最佳支持 RTOS 插件Flash 编程稳定价格昂贵正版 J-Link BASE 约 ¥3000SN 绑定严格量产前最终验证复杂逻辑调试ST-Link STM32CubeProgrammer免费支持多种烧录方式SWD/UART/DFU界面直观调试功能弱不支持多线程断点快速烧录 Bootloader产线批量烧录USB-TTL 自定义串口协议成本最低¥10 模块可集成到产品中做现场升级依赖 UART 稳定性无硬件调试能力客户现场 OTA 升级售后维护逻辑分析仪Saleae捕获 USART/CAN/SPI 信号精准定位时序问题无法查看寄存器需配合其他工具排查通信异常、Flash 编程时序我坚持“J-Link 用于开发ST-Link 用于量产USB-TTL 用于售后”的分工。曾有个项目客户反馈 OTA 升级失败率 15%用逻辑分析仪抓取现场 USART 波形发现 USB-TTL 模块在 115200 波特率下有 3% 的误码率换成 CH340G 芯片的模块后失败率降至 0.2%。6. 从 F103 到更广阔场景的延伸思考AB 分区只是起点做完 STM32F103 的 AB 分区 OTA我意识到这不仅是单个芯片的技能而是嵌入式系统升级架构的通用范式。比如把这套逻辑迁移到 ESP32 上最大的变化不是 Flash 操作而是安全启动Secure Boot的集成。ESP32 的 Secure Boot V2 要求 Bootloader 和 App 都用私钥签名而 F103 没有硬件加密引擎只能靠软件 CRC。再比如迁移到 Zynq SoC 时AB 分区要扩展为“Boot Image FSBL Bitstream Linux Kernel RootFS”的多级分区但状态机设计思想完全一致每个分区有自己的 magic version crcBootROM 加载 FSBLFSBL 校验 BitstreamBitstream 加载 U-BootU-Boot 校验 Kernel……层层验证任一环节失败则回退到上一级备份。甚至在汽车电子 AUTOSAR 架构中“RTERuntime Environment” 的更新机制其底层也是 AB 分区思想的变体——通过 ECU 的 Flash 分区管理模块FEE实现应用软件的原子升级。所以当你在 F103 上亲手擦写每一页 Flash、校验每一个 CRC、处理每一次断电你练就的不是某个芯片的技能而是在资源受限环境下构建可靠升级通道的系统性思维。这种能力在物联网设备爆发式增长的今天比任何新潮框架都更接近本质——毕竟再炫酷的 AI 算法如果设备连固件都升不了也只能躺在抽屉里吃灰。
返回列表