ARTICLE DETAIL

资讯详情

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

STM32网络IAP远程升级:Bootloader与App完整实现指南

STM32网络IAP远程升级:Bootloader与App完整实现指南 简介针对STM32嵌入式开发者的FOTA远程升级实践资料系统讲解IAPIn-Application Programming程序的实现与解析面向需要为物联网设备添加网络固件更新能力的开发者。资源体积仅30KB包含3个C语言源文件覆盖HTTP服务、CGI/SSI动态交互与主控制逻辑代码紧凑适合直接阅读和移植。已有1047人学习下载足见该主题的实用价值。深入解析了中断向量表重定位、闪存分区预留、擦除/写入/校验函数设计、网络传输协议集成以及固件安全验签机制同时涉及升级失败回滚和异常处理策略。读者可借此快速掌握STM32上实现网络远程升级的完整链路减少自行摸索的时间成本为MCU产品接入OTA能力提供可落地的工程参考。 做嵌入式开发的人迟早会遇到一个绕不开的坎产品已经发出去了固件里却有 bug 要修或者要新增一个功能总不能把设备寄回来重新插下载器吧。尤其是设备分布在不同城市、甚至不同国家的场景靠出差烧录根本不现实。这时候就该 IAPIn-Application Programming应用内编程上场了。所谓网络远程升级就是让 STM32 通过 WiFi、以太网或 4G 模块先把新固件下载到本地再自己擦写 Flash、跳转到新程序全程不需要人工干预。这篇文章我会把这套基于 STM32 的网络 IAP 升级方案从原理、分区规划、Bootloader 实现、App 配合改造到常见坑完整拆开讲一遍。1. 为什么需要自己做一套网络 IAP 升级1.1 现场烧录的痛点先看一个真实场景。我帮朋友做的那款设备用的是 STM32F103C8T6出货量不大不小大概几百台分布在几十个城市。第一次遇到固件升级需求是为了修复一个串口数据偶尔丢字节的 bug。按传统思路只能联系每个客户让人把设备寄回来我烧完再寄回去。算一下时间光快递就一周起步更别提客户抱怨。后来想想哪怕是用 J-Link OB 或者 ST-Link 做离线烧录也绕不开接触设备这个前提。远程升级的本质就是把原本烧录器干的事挪到设备自己身上来完成。那为什么不用现成的远程管理平台市面上确实有云平台能直接管设备但很多行业客户的数据要留在本地或者设备处于内网环境没法接入公网云平台。自己能写一套轻量 IAP 协议根据实际网络环境灵活部署才是最靠谱的。1.2 IAP 的基本原理与双区架构IAP 说白了就是在设备里放两份程序Bootloader 和 App。Bootloader 是启动引导员负责检查有没有升级请求、接收新固件、写入 FlashApp 是实际业务程序正常运行后干正经活。STM32 的 Flash 起始地址是 0x08000000默认情况下程序从这里开始跑。如果我们把 Bootloader 放在 0x08000000App 放在后面某个地址比如 0x08004000那 Bootloader 运行时就有两种选择要么直接跳转到 App 运行要么先通过网络接收新固件、擦写 Flash再跳转。这里的关键点在于Bootloader 和 App 各自独立编译但是 Bootloader 必须知道 App 的起始地址和跳转规则App 也要知道自己被放到了哪个地址、中断向量表怎么偏移。这两个配合不上的话就会出现网上常问的跳转后卡死之类的问题。2. 整体方案设计与通信链路选型2.1 通信方式怎么选网络远程升级网络这部分有很多选择。我用一张表把主流方案的对比列出来方案通信接口成本开发难度适用场景ESP8266 串口透传USART低低室内产品、WiFi 覆盖场景W5500 以太网SPI中中工业设备、有线网络环境4G 模组EC200S 等USART高中户外设备、无 WiFi 场景NB-IoTUSART中高中高低功耗物联网水表电表我在这个项目里选的是 ESP8266。原因很简单价格便宜、资料多、串口透传模式开发量最小。STM32 只需要把 ESP8266 当成一个透明管道发 AT 指令让它连上路由器然后往上位机建立的 TCP Server 发数据就行。STM32 侧的逻辑完全不用关心 TCP/IP 协议栈全部由 ESP8266 处理。选 ESP8266 还有一层考虑它支持串口透传的 AT 指令Bootloader 里做协议解析非常直接。当然如果设备本身是有线网络环境、对实时性和稳定性要求更高W5500 是更合适的路子它的 TCP/IP 协议栈是硬件实现的不会像 ESP8266 那样偶尔丢包。2.2 Flash 分区规划以最常见的 STM32F103C8T6 为例它的 Flash 总共 64KB。先说结论Bootloader 占 16KBApp 占 48KB。地址划分如下区域起始地址结束地址大小用途Bootloader0x080000000x08003FFF16KBIAP 升级引导程序App0x080040000x0800FFFF48KB业务程序升级标志区0x0800FC000x0800FFFF1KB记录升级状态也可放 App 区内这里有两个细节要注意。第一App 的起始地址最好选在扇区边界上。F103 中容量型号的前 16KB 每个扇区是 1KB后面的扇区是 2KB0x08004000 正好落在这两种扇区的交界处也就是 0x08004000 是 2KB 扇区的起始便于整块擦除。第二如果芯片 Flash 更大比如 F103ZET6 的 512KB那可以把 Bootloader 扩到 32KBApp 从 0x08008000 开始升级标志单独划一个扇区这样更从容。2.3 固件传输协议与帧格式设计网络传输最怕的就是数据丢一半、设备变砖。所以我给固件传输设计了一个简单的自定义协议每帧结构如下帧头(2B) 包序号(2B) 数据长度(2B) 数据(NB) CRC16(2B) 帧尾(2B) 0xAA 0x55 | 0x0001 | 0x0100 | ... | CRC高 CRC低 | 0x0D 0x0A帧头 0xAA55 用于同步定位包序号从 0 开始递增接收方根据序号判断是否有丢包数据长度表示本帧携带的固件字节数我一般固定 256 字节除最后一帧CRC16 用 Modbus 的标准多项式计算覆盖帧头之后、CRC 之前的所有字节帧尾 0x0D0A 用于校验一帧的结束上位机发送流程是先发一帧开始升级命令包含固件总长度、固件版本号然后分包发送固件数据Bootloader 每收一帧就回一个 ACK上位机收到 ACK 才发下一帧。这个停等协议虽然效率不是最高但胜在可靠升级 48KB 固件也在几秒内完成完全够用。3. Bootloader 端核心代码实现3.1 网络数据接收与帧解析逻辑Bootloader 的串口接收我用的是一个字节一个字节进中断 环形队列缓存的方式。ESP8266 透传过来的数据先全部进队列主循环再从队列里取字节做状态机解析。状态机比直接判断收到一帧更可靠因为网络数据随时可能断成半帧。帧解析状态机// 简单帧解析状态机 uint8_t UART_ParseByte(uint8_t byte) { static uint8_t state 0; static uint16_t idx 0; static uint16_t frame_len 0; static uint16_t total_len 0; static uint8_t buf[MAX_FRAME_LEN]; switch (state) { case 0: // 等待帧头 if (byte 0xAA) state 1; else state 0; break; case 1: // 等待第二个帧头字节 if (byte 0x55) { idx 0; state 2; } else if (byte 0xAA) state 1; else state 0; break; case 2: // 接收头部: 包序号(2) 数据长度(2) buf[idx] byte; if (idx 4) { frame_len buf[2] | (buf[3] 8); // 数据长度 total_len frame_len 8; // 后续所有内容长度 state 3; } break; case 3: // 接收数据 CRC 帧尾 buf[idx] byte; if (idx total_len) { // 到这里做 CRC 校验、帧尾匹配 ProcessFrame(buf, frame_len); state 0; } break; } return 1; }这段代码只展示状态机的思路真正的工程实现里还需要处理 CRC 校验、帧尾匹配、超时判断。有一个我踩过的坑如果一帧数据因为网络波动分成两段到达中间间隔几十毫秒如果只按长度到了就处理来做很容易出现解析错位。所以我在工程里加了接收超时机制两字节间隔超过 50ms 就把状态机重置避免一错错到底。3.2 Flash 擦写函数的正确写法STM32 Flash 写入前必须先擦除。HAL 库的擦写接口很直观但有几个点容易翻车Flash 解锁/上锁要配对擦除等待时间要足够如果擦写期间来了中断程序会卡死。我用的写函数// 擦除 App 区全部扇区 uint8_t Flash_EraseAppArea(uint32_t app_start, uint32_t app_size) { FLASH_EraseInitTypeDef erase; uint32_t address app_start; uint32_t sector_size 0; uint32_t error 0; HAL_FLASH_Unlock(); while (address app_start app_size) { // 根据地址判断扇区大小, 前16KB为1KB, 之后为2KB if (address 0x08004000) sector_size 1024; else sector_size 2048; erase.TypeErase FLASH_TYPEERASE_PAGES; erase.PageAddress address; erase.NbPages 1; if (HAL_FLASHEx_Erase(erase, error) ! HAL_OK) { HAL_FLASH_Lock(); return 1; } address sector_size; } HAL_FLASH_Lock(); return 0; }这里的核心逻辑是按扇区逐个擦。如果你把整个 App 区当成一个大块来擦HAL 库会报参数错误。另外如果升级过程中途失败Flash 可能处于擦了一半的状态所以要结合后面的升级标志来兜底。写数据的函数也有讲究。HAL_FLASH_Program 支持按字节、半字、字写入。为了效率我按 4 字节Word为单位写这样擦写次数少速度也快// 写一帧固件数据到 Flash uint8_t Flash_WriteData(uint32_t addr, uint8_t *data, uint16_t len) { HAL_FLASH_Unlock(); for (uint16_t i 0; i len; i 4) { uint32_t word *(uint32_t *)(data i); if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr i, word) ! HAL_OK) { HAL_FLASH_Lock(); return 1; } } HAL_FLASH_Lock(); return 0; }要注意data i的地址必须 4 字节对齐否则强转成 uint32_t 指针会触发 HardFault。这个问题我第一次跑的时候直接卡死在写 Flash 的函数里排查半天才发现是上位机发下来的缓冲数组没对齐。3.3 跳转到 App 的完整流程这是整个 IAP 中最容易出问题的环节。很多人在网上搜到过这段跳转代码但用起来还是卡死关键在于跳之前的中断处理。typedef void (*pFunction)(void); 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); pFunction jump_func; // 检查栈顶指针是否在 RAM 范围 if ((stack_addr 0xFFF00000) ! 0x20000000) return; __disable_irq(); // 1. 关闭全局中断 SysTick-CTRL 0; // 2. 关闭 SysTick 定时器 SysTick-VAL 0; for (int i 0; i 8; i) // 3. 清空所有中断 { NVIC-ICER[i] 0xFFFFFFFF; NVIC-ICPR[i] 0xFFFFFFFF; } HAL_RCC_DeInit(); // 4. 恢复时钟到复位状态 __set_MSP(stack_addr); // 5. 设置主栈指针 jump_func (pFunction)reset_addr; jump_func(); // 6. 跳转 }步骤顺序一个都不能乱。网上最常见的错误写法是只调用了__set_MSP和跳转结果 Bootloader 里的 SysTick、串口中断、DMA 中断全都还在运行跳到 App 后中断一来App 的向量表还没有初始化好直接跑飞表现就是卡死或者是进了 HardFault。另外跳转前一定要判断栈顶指针是否合理。如果是误触发跳转、或者 App 区是空白/乱码跳过去就是死路一条。0x20000000是 STM32 的 RAM 起始地址只要向上约 1MB 都在 RAM 范围这个判断能过滤掉绝大多数异常地址。进一步讲Bootloader 要判断 App 是否有效光靠栈顶指针检查还不够。我在 App 的固定偏移比如 App 0x40 处放了一个 4 字节有效标志每次 App 正常运行后会写一个魔数进去。Bootloader 跳转前先读这个魔数匹配才跳。这样哪怕 Flash 里有残留数据也不会误跳到一个不完整的程序上。3.4 升级失败保护与看门狗兜底远程升级最怕的就是升到一半断电。如果 App 区已经被擦空、新固件还没写完设备重启后 Bootloader 没有新固件可跳那就真砖了。我的做法是引入一个升级标志升级开始前Bootloader 在升级标志区写一个固定值比如 0xA5A5升级完成且校验通过后把它清掉Bootloader 启动时检查这个标志如果存在说明上次升级没完成或者刚完成还没跳转此时不跳 App而是继续停在固件接收状态配合硬件看门狗还能解决App 运行异常的情况App 启动后必须在规定时间内喂狗如果 App 卡死看门狗会复位芯片Bootloader 发现升级标志还在或新增一个异常重启标志就重新进入升级模式。这样设备永远有一条退路。4. App 端改造与配合要点4.1 Keil 中的 IROM1 地址设置很多人写好了 BootloaderApp 却还是默认从 0x08000000 编译。烧进去之后 Bootloader 跳过去跑的根本不是 App而是把 Bootloader 自己又跑了一遍或者直接跑飞。在 Keil MDK 里需要对 App 工程做两处修改Options for Target - Target - IROM1起始地址改为 App 的起始地址如 0x08004000Size 改成 App 区大小0xC000即 48KB如果用了 C/C 运行时库的初始化需要确保链接脚本不会把只读数据放到 IROM1 之外改完之后点编译再用 fromelf 生成 bin 文件。注意 bin 文件是从 App 起始地址开始的原始内存映像它不像 hex 文件自带地址信息所以刷写的时候还要在协议里带上目标地址信息。4.2 中断向量表重映射App 的中断向量表默认放在代码起始地址也就是 0x08004000。但是 STM32 上电后默认从 0x08000000 取向量如果 App 不重映射向量表一切中断都会指向错误的地方。F1 系列的重映射方式很简单在 SystemInit() 之后或者在 main() 最开始写一行SCB-VTOR 0x08004000;如果你用的是带 Flash 扇区多、支持任意地址映射的系列比如 F2/F4/F7这行代码同样适用。CubeMX 生成的工程里其实已经留了一个宏VECT_TAB_OFFSET在 system_stm32f1xx.c 里把它改成0x4000编译后会自动配置好 VTOR。有一个坑如果 App 用的是旧版标准外设库的工程可能没处理 VTOR这时候中断向量表不重映射的后果就是程序能跑但一有中断比如 SysTick、UART 中断就死机。症状和 Bootloader 跳转代码有问题很像排查时容易误导人。4.3 生成 bin 文件与版本管理Keil 默认只生成 hex 文件。IAP 升级需要 bin因为 bin 没有额外的地址信息固件大小更容易计算。在 Keil 的 User 选项卡的 After Build 里加一条fromelf --bin --output ..\Output\app.bin ..\Output\app.axf这样每次编译完后自动生成 app.bin。同时我习惯在工程里维护一个version.h把主版本号、次版本号、编译日期编进去Bootloader 或者上位机可以在升级开始前先查询版本避免重复刷同一个固件。这个查询流程也可以用来做强制回滚/降级控制比如新版本有严重 bug可以远程把设备刷回旧版本。5. 常见问题与排查技巧实录5.1 IAP 跳转后卡死、HAL_Delay 不工作的排查这个问题被问得最多我自己第一次跳转也卡过。先把排查路径整理出来现象可能原因检查方法跳转后完全黑屏/无反应App 区无有效程序读 App 起始地址判断栈顶是否合理跳转后程序启动卡在 HAL_DelaySysTick 中断没关或 VTOR 没配跳转前SysTick-CTRL0确认 App 的 VTOR 已设置跳转后跑一会儿死机外设中断没清跳转前清空所有 NVIC 中断使能与挂起跳转后能跑但进不了 mainMSP 被错误设置确认栈顶地址是否0x2000xxxx也就是说让 Bootloader 彻底安静下来很重要。我在跳转前不仅关中断还会复位所有外设时钟相当于把芯片恢复成上电初始状态再交给 App。这样一个干净的交接后面 App 初始化外设时才不会出现莫名奇妙的中断。5.2 固件传输中断、校验失败的坑传输中断有几个常见来源ESP8266 的透传超时默认 10ms 无数据就断开、TCP 连接不稳定、串口波特率过高导致丢字节。我用的 ESP8266 固件把透传超时调到了 50ms 以上串口波特率稳定在 115200 而不是 460800可靠性好很多。如果经常出现 CRC 校验失败可以在 Bootloader 里加一个重试请求机制收到 CRC 错误的帧不回 ACK而是回一个 NAK。上位机收到 NAK 或者超时未收到 ACK就重发当前帧。48KB 固件包成 256 字节一帧大概 192 帧加上心跳和重试机制整个升级过程还是很短的。5.3 升级中途断电的兜底方案前面提到过升级标志这里说具体实现。假设升级标志区是 0x0800FC00Bootloader 启动流程uint32_t flag *(volatile uint32_t *)0x0800FC00; if (flag 0xA5A5A5A5) { // 升级未完成进入等待升级状态 WaitForFirmware(); return; } else if (App 有效) { Jump_To_App(APP_START_ADDR); } else { WaitForFirmware(); }当固件全部接收成功、App 区的数据也校验通过后Bootloader 把 0x0800FC00 擦掉再跳转。App 每次启动后也可以把正在运行的标记写到这个位置。这样即使 App 运行后崩溃、看门狗复位Bootloader 也能判断出App 没跑起来。整个保护链条可以总结为擦 App 前写升级标志 - 接收固件过程中断电 - 重启后发现标志等待重发 - 升级成功清标志。整个过程设备都不会变砖最坏情况只是需要重新传一次固件。最后分享一点经验。IAP 这套东西写起来不难真正难的是各种边界情况对方断电、网络闪断、固件版本冲突、芯片 Flash 写入异常。我在实际项目里光是跳转卡死就调试了整整一个下午后来把所有中断都清了才解决。所以建议你第一次做的时候先把 Bootloader、App 分开在两个工程里跑通本地跳转再引入网络传输每一步验证清楚再往下走。这套方案我后来复制到好几个产品上固件升级从出差一个星期变成了远程十分钟当初踩的坑也算没白踩。本文还有配套的精品资源点击获取
返回列表