简介这份资源面向嵌入式开发者与STM32学习者聚焦通过HTTP协议实现设备固件远程升级这一实用场景帮助读者理解如何在无物理接触的情况下完成固件更新提升设备可维护性。压缩包共202个文件约5.23MB以38个h头文件、36个c源文件为核心配合32个o目标文件、32个d依赖文件及31个crf交叉引用文件另有uvproj、uvopt等Keil工程文件、sct分散加载脚本、hex与axf可执行文件、map映射文件及lst列表文件完整呈现从源码到编译产物的工程结构。资源围绕HTTP_GETPkt()请求发送、URL参数配置、lwIP网络通信、固件分块接收与MD5/SHA-1校验、闪存写入及安全模式等关键环节展开读者可据此掌握TCP/IP协议栈应用、GET方法实现与服务器接口设计思路。目前已有5087人学习下载适合具备一定STM32与网络编程基础、希望深入远程升级方案的开发者参考。1. 从一次现场翻车说起为什么我要把 HTTP 远程升级拆开讲去年冬天某实验室的一套环境监测节点装在离市区两小时车程的站点里STM32F407 主控跑着采集和上报逻辑。某天发现一个边界条件处理错了需要改固件。如果按传统做法得派人带笔记本和 ST-Link 过去现场拆壳、接线、烧录、再装回去来回一整天。当时我就想板子上明明有 4G 模块能联网为什么不能让它自己从 HTTP 服务器把新固件拉下来、自己刷进去这就是基于 STM32 的 HTTP 远程升级要解决的问题让设备通过 HTTP GET 从服务器下载固件包写入片内或外部 Flash校验通过后跳转到新固件运行。它适合已经具备网络能力以太网、Wi-Fi 模块、蜂窝模组的 STM32 项目尤其是部署后物理接触成本高的场景。但真做起来坑比想象中多——Flash 分区怎么划、跳转前关哪些中断、HTTP 分块传输怎么处理、断电了怎么办。这篇就把这套流程从原理到代码拆一遍让你能照着复现。2. HTTP 远程升级的整体链路从 GET 请求到跳转执行2.1 为什么选 HTTP GET 而不是别的方案远程升级的传输层选择其实不少MQTT 推固件、TFTP、自定义 TCP 协议、HTTP GET。对于 STM32 这类资源受限设备HTTP GET 有几个实际优势。第一服务器侧零成本——固件包往 Web 目录一放或者用对象存储挂个静态文件不需要额外写服务端逻辑。第二GET 请求语义简单拼接一个 URL 就能拿到二进制流不需要处理 MQTT 的 topic 订阅和 QoS 状态机。第三方便做版本管理URL 里带版本号或文件名服务器换个文件就完成发布。代价是 HTTP 头部有额外开销且明文传输没有加密。常见做法是在应用层对固件包做 CRC 或哈希校验来弥补完整性至于传输加密如果模组支持 HTTPS 就直接用不支持的话至少保证校验环节不出错。2.2 片内 Flash 分区Bootloader 和 App 怎么分STM32 的 Flash 通常从 0x08000000 开始。远程升级需要两个独立区域Bootloader 区和 App 区。Bootloader 负责联网、下载、写 Flash、跳转它自己永远不被覆盖。App 区存放实际业务固件。以 STM32F407ZGT61MB Flash为例一个常见的分区方案区域起始地址大小用途Bootloader0x0800000064KB联网下载、Flash 写入、跳转App0x08010000448KB业务固件运行区下载缓存0x08080000448KB新固件暂存区参数区0x080F000064KB版本号、升级标志、校验值这个方案的核心思路是新固件先下载到缓存区校验通过后再搬运到 App 区或者直接在缓存区运行。两种做法各有取舍后面会细说。提示分区大小要根据实际固件体积调整App 区至少留出固件大小的 1.5 倍余量否则后续功能增加会很快撑爆。2.3 升级流程的状态机整个升级过程可以拆成几个明确的状态空闲 → 检查版本 → 下载中 → 校验 → 写入 → 跳转。每个状态之间的转换条件要写清楚尤其是失败回退路径。typedef enum { OTA_STATE_IDLE 0, // 空闲正常运行 OTA_STATE_CHECK, // 向服务器查询最新版本 OTA_STATE_DOWNLOAD, // 正在下载固件 OTA_STATE_VERIFY, // 校验下载数据 OTA_STATE_WRITE, // 写入 App 区 OTA_STATE_DONE, // 升级完成准备跳转 OTA_STATE_ERROR // 出错回退到旧固件 } ota_state_t; // 状态机主循环放在 Bootloader 的 main 里 void ota_run(void) { ota_state_t state ota_load_state(); // 从参数区读取上次状态 while (1) { switch (state) { case OTA_STATE_CHECK: if (http_check_version() 0) { state OTA_STATE_DOWNLOAD; } else { state OTA_STATE_IDLE; // 无新版本直接跳 App } break; case OTA_STATE_DOWNLOAD: if (http_download_firmware() 0) { state OTA_STATE_VERIFY; } else { state OTA_STATE_ERROR; } break; case OTA_STATE_VERIFY: if (verify_crc() 0) { state OTA_STATE_WRITE; } else { state OTA_STATE_ERROR; } break; case OTA_STATE_WRITE: if (flash_write_app() 0) { state OTA_STATE_DONE; } else { state OTA_STATE_ERROR; } break; case OTA_STATE_DONE: ota_save_state(OTA_STATE_IDLE); jump_to_app(); break; case OTA_STATE_ERROR: ota_save_state(OTA_STATE_IDLE); jump_to_app(); // 回退到旧固件 break; default: jump_to_app(); break; } } }这段状态机的关键点在于每个状态执行完都持久化到参数区这样即使中途断电重新上电后能从上次的状态继续而不是从头再来。ota_load_state和ota_save_state操作的是 Flash 参数区注意写入前要先擦除整个扇区。3. HTTP 客户端实现GET 请求、分块接收与 Flash 写入3.1 用模组还是用 LwIP 自己撸STM32 上跑 HTTP 客户端有两条路。一条是用现成的联网模组比如 4G Cat.1 模组、Wi-Fi 模组模组内部集成了 TCP/IP 协议栈和 AT 指令集你只需要发 AT 指令让它去 GET 一个 URL数据通过串口吐回来。另一条是芯片自带以太网 MAC跑 LwIP 协议栈自己实现 HTTP 客户端。模组方案开发快但受限于模组的 AT 指令集和缓冲区大小大文件下载容易丢数据。LwIP 方案可控性强但需要自己处理 TCP 粘包、HTTP 分块传输编码。我一般建议如果模组支持透传模式且缓冲区够大至少 4KB优先用模组否则老老实实上 LwIP。3.2 拼接 GET 请求并解析响应头HTTP GET 请求的格式很固定关键是 URL 和 Host 头不能少。下面是一个最小化的请求拼接函数// 构造 HTTP GET 请求url_path 是服务器上的文件路径 int http_build_get(char *buf, int buf_size, const char *host, const char *url_path) { int len snprintf(buf, buf_size, GET %s HTTP/1.1\r\n Host: %s\r\n User-Agent: STM32-OTA/1.0\r\n Connection: close\r\n \r\n, url_path, host); if (len 0 || len buf_size) { return -1; // 缓冲区不够 } return len; }Connection: close很重要它告诉服务器发完数据就关连接这样客户端不需要处理 keep-alive 的超时和复用逻辑。User-Agent可以留空但有些服务器会检查加上更稳妥。发送请求后接收响应时要先解析头部找到\r\n\r\n的位置之后才是真正的固件数据。响应头里要关注两个字段Content-Length告诉你固件总大小Transfer-Encoding: chunked表示分块传输。3.3 分块传输的解析与 Flash 写入如果服务器返回Transfer-Encoding: chunked每个数据块前面会有一个十六进制的长度行比如1000\r\n表示后面跟 4096 字节数据。解析逻辑如下// 从 chunked 编码的响应中提取固件数据并写入 Flash int http_recv_chunked(uint32_t flash_addr) { char line[16]; uint32_t offset 0; while (1) { // 读取块长度行 if (read_line(line, sizeof(line)) 0) return -1; int chunk_size strtol(line, NULL, 16); // 十六进制转十进制 if (chunk_size 0) break; // 最后一块长度为 0 // 读取 chunk_size 字节数据 uint8_t buf[1024]; int remaining chunk_size; while (remaining 0) { int to_read (remaining sizeof(buf)) ? sizeof(buf) : remaining; int n tcp_recv(buf, to_read); if (n 0) return -1; // 写入 Flash注意按扇区对齐 if (flash_write(flash_addr offset, buf, n) ! 0) return -1; offset n; remaining - n; } read_line(line, sizeof(line)); // 跳过块尾的 \r\n } return offset; // 返回总写入字节数 }这里有个容易翻车的点Flash 写入必须按扇区对齐。STM32 的 Flash 扇区大小不一F4 系列从 16KB 到 128KB 不等。如果每次收到 1KB 就写一次而扇区是 16KB那前 15 次写入都会失败。正确做法是先在 RAM 里攒够一个扇区的数据再统一擦除和写入。或者用外部 Flash比如 SPI Flash它的擦除粒度小得多通常是 4KB 一个扇区处理起来灵活很多。注意写入前一定要先擦除对应扇区。STM32 片内 Flash 只能把 1 写成 0不能把 0 写回 1擦除操作会把整个扇区恢复为 0xFF。4. 跳转与中断向量表从 Bootloader 到 App 的关键几步4.1 跳转前必须关掉的东西从 Bootloader 跳转到 App 之前有几件事必须做否则 App 跑起来会出现各种玄学问题。第一关闭所有外设中断尤其是 SysTick、串口、DMA。第二关闭全局中断__disable_irq()。第三把 SysTick 的 CTRL 寄存器清零否则 App 的延时函数会错乱。第四如果用了 RTOS要先关掉调度器。void jump_to_app(uint32_t app_addr) { // 1. 关闭全局中断 __disable_irq(); // 2. 关闭 SysTick SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; // 3. 关闭所有外设中断根据实际使用的外设逐个关闭 NVIC-ICER[0] 0xFFFFFFFF; NVIC-ICER[1] 0xFFFFFFFF; NVIC-ICPR[0] 0xFFFFFFFF; NVIC-ICPR[1] 0xFFFFFFFF; // 4. 设置栈指针和跳转地址 uint32_t app_stack *(volatile uint32_t *)app_addr; uint32_t app_entry *(volatile uint32_t *)(app_addr 4); __set_MSP(app_stack); // 5. 跳转 void (*app_reset_handler)(void) (void (*)(void))app_entry; app_reset_handler(); }app_addr是 App 区的起始地址比如 0x08010000。前 4 个字节是栈顶地址第 5 到第 8 个字节是复位向量地址。跳转前把主栈指针设成 App 的栈顶然后调用复位处理函数。4.2 App 侧的向量表偏移App 固件编译时链接脚本里的 Flash 起始地址要改成 App 区的地址同时中断向量表要偏移。在system_stm32f4xx.c里有一行VECT_TAB_OFFSET默认是 0x00改成 App 区相对于 0x08000000 的偏移量比如 0x10000。// 在 App 的 main 函数最开始调用 void app_set_vector_table(void) { SCB-VTOR FLASH_BASE | 0x10000; // 偏移 64KB }如果忘了这一步App 里的中断会跳到 Bootloader 的向量表去结果就是中断响应错乱串口收不到数据、定时器不触发但主循环还能跑——这种半死不活的状态最难查。4.3 版本号与升级标志的存储参数区用来存版本号和升级标志。版本号可以用一个 32 位整数比如 0x00010002 表示 V1.2。升级标志用一个魔数比如 0x5A5A5A5A 表示需要升级其他值表示正常运行。#define OTA_FLAG_ADDR 0x080F0000 #define OTA_FLAG_MAGIC 0x5A5A5A5A // 检查是否需要进入升级模式 int ota_should_upgrade(void) { uint32_t flag *(volatile uint32_t *)OTA_FLAG_ADDR; return (flag OTA_FLAG_MAGIC); } // 设置升级标志在 App 里收到升级命令时调用 void ota_set_upgrade_flag(void) { HAL_FLASH_Unlock(); FLASH_Erase_Sector(FLASH_SECTOR_11, VOLTAGE_RANGE_3); // 擦除参数区 HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, OTA_FLAG_ADDR, OTA_FLAG_MAGIC); HAL_FLASH_Lock(); }App 在收到服务器下发的升级指令后往参数区写魔数然后软复位。Bootloader 启动后先读这个标志如果是魔数就进入升级流程否则直接跳 App。5. 避坑与排查那些让我熬夜的升级失败现场5.1 下载到一半卡死串口打印停在某个 chunk现象固件下载到 60% 左右不动了串口最后一条打印是某个 chunk 的长度。原因通常是 TCP 接收缓冲区溢出。模组或 LwIP 的接收窗口有限如果 Flash 写入耗时太长来不及取走数据缓冲区满了之后对端停止发送而你的代码还在等数据就死锁了。解决把 Flash 写入放到独立的低优先级任务或 DMA 完成回调里接收和写入解耦。或者调大 TCP 接收窗口但根本办法还是加快消费速度。我一般会在接收循环里加一个超时计数连续 5 秒没收到新数据就主动断开重连。5.2 跳转后 App 不运行HardFault 直接挂现象Bootloader 打印跳转成功但 App 没有任何输出调试器显示 HardFault。原因多半是栈指针设置错了。app_addr指向的必须是 App 固件的起始地址而不是随便一个地址。如果 App 编译时链接地址是 0x08010000那app_addr就传 0x08010000。另外跳转前一定要关中断否则 App 初始化过程中可能被 Bootloader 残留的中断打断。解决用__set_MSP设置栈顶后加一个__DSB()和__ISB()确保指令同步。然后在跳转前把SCB-VTOR也设成 App 的向量表地址这样即使 App 还没来得及设置中断也能正确响应。5.3 校验总是失败CRC 对不上现象固件下载完了但 CRC 校验不通过。原因可能是多方面的服务器上的固件包和本地编译的不是同一个下载过程中有字节丢失或重复CRC 计算范围搞错了比如把 HTTP 头也算进去了。解决先在 PC 上算一遍服务器固件文件的 CRC32和 STM32 算出来的对比。如果 PC 上算出来是对的STM32 算出来不对那就是下载环节丢数据了。检查 chunked 解析逻辑尤其是块尾的\r\n有没有正确跳过。另外注意 CRC 的初始值和多项式要和 PC 端一致常见的是 CRC-32/ISO-HDLC初始值 0xFFFFFFFF多项式 0x04C11DB7。5.4 断电后设备变砖连 Bootloader 都进不去现象升级过程中断电重新上电后设备无响应串口没输出。原因通常是 Bootloader 被意外擦除了或者参数区的标志位处于一个非法状态Bootloader 读出来后进入了死循环。解决Bootloader 区一定要在选项字节里设置写保护防止被误擦。参数区的写入要加双重校验比如写入魔数后再写入魔数的反码读取时两个都验证。如果参数区数据非法默认进入 App 而不是停在 Bootloader。最坏情况下留一个串口强制升级的入口上电时检测某个引脚电平拉低就进入等待串口升级的模式。5.5 升级成功但功能不对像是跑了旧固件现象升级流程全部走完跳转也成功了但设备行为还是旧版本。原因可能是 App 区没有真正被覆盖或者跳转地址指向了旧固件。检查 Flash 写入的地址是否正确以及写入前是否执行了擦除。STM32 的 Flash 写入如果不擦除新数据是写不进去的但也不会报错读出来还是旧数据。解决在写入前打印目标扇区的首地址和擦除后的读回值应该是 0xFF写入后再读回几个字节对比。如果读回值和写入值不一致说明擦除或写入有问题。另外确认链接脚本里 App 的起始地址和 Bootloader 里定义的APP_ADDR一致。6. 进阶技巧差分升级、双备份与自动化验证6.1 差分升级只传变化的部分完整固件动辄几百 KB如果每次只改了几行代码全量下载很浪费流量和时间。差分升级的思路是在服务器端用工具对比新旧固件生成一个差分包patch设备下载差分包后在本地和旧固件合并生成新固件。常用的算法是 bsdiff它生成的差分包通常只有原固件的 5% 到 10%。在 STM32 上做差分升级的难点在于内存。bsdiff 合并时需要同时访问旧固件、差分包和新固件对 RAM 和 Flash 都有要求。常见做法是把旧固件从 App 区读到外部 Flash 或 SD 卡在外部存储上完成合并再写回 App 区。如果资源实在不够可以退而求其次只对变化频繁的常量表或配置区做差分代码区还是全量。6.2 双备份机制A/B 分区A/B 分区是指 Flash 里存两份 AppApp A 和 App B。正常运行时跑 A升级时把新固件写到 B写完后设置启动标志指向 B重启后跑 B。如果 B 启动失败比如看门狗复位次数超限自动回退到 A。这样即使升级失败设备也不会变砖。实现上Bootloader 需要维护一个启动计数器。每次启动 App 时计数器加一App 正常运行后定期清零。如果计数器超过阈值比如 3 次说明 App 启动有问题Bootloader 就切换到另一个分区。// Bootloader 里的启动选择逻辑 int select_boot_partition(void) { uint32_t boot_count read_boot_count(); uint32_t active read_active_partition(); // 0A, 1B if (boot_count 3) { // 当前分区启动失败切换到另一个 active !active; write_active_partition(active); write_boot_count(0); } else { write_boot_count(boot_count 1); } return (active 0) ? APP_A_ADDR : APP_B_ADDR; }这个机制的代价是 Flash 空间要翻倍对于 1MB Flash 的芯片来说两个 App 分区各 448KB刚好够用。如果固件超过 400KB就得换更大 Flash 的型号或者用外部 Flash。6.3 自动化验证用脚本模拟升级流程每次改完 Bootloader 或 App手动测试升级流程很繁琐。我一般会写一个 Python 脚本在 PC 上模拟 HTTP 服务器提供固件文件然后通过串口给 STM32 发升级命令监控串口输出自动判断升级是否成功。import serial import time import hashlib def test_ota(port, firmware_path): ser serial.Serial(port, 115200, timeout1) # 计算固件 CRC用于后续对比 with open(firmware_path, rb) as f: fw_data f.read() fw_crc hashlib.crc32(fw_data) 0xFFFFFFFF # 发送升级命令 ser.write(bOTA_START\r\n) time.sleep(0.5) # 监控串口输出等待升级完成 start time.time() while time.time() - start 60: # 最多等 60 秒 line ser.readline().decode(utf-8, errorsignore).strip() if line: print(line) if OTA_DONE in line: # 升级完成读取设备上报的 CRC ser.write(bGET_CRC\r\n) crc_line ser.readline().decode().strip() device_crc int(crc_line.split(:)[1], 16) if device_crc fw_crc: print(升级成功CRC 匹配) return True else: print(fCRC 不匹配: 设备 {device_crc:08X}, 期望 {fw_crc:08X}) return False if OTA_ERROR in line: print(升级失败) return False print(超时) return False这个脚本的核心是发命令、等结果、对比 CRC。CRC 对比通过说明从下载到写入的整条链路都是通的。如果失败串口日志会告诉你卡在哪一步。我一般会在 CI 里跑这个脚本每次提交 Bootloader 代码都自动验证一遍。从那以后我每次改完 Bootloader都强制走一遍这个自动化脚本确认升级、回退、断电恢复三个场景都能过才敢把固件推到现场设备。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?