简介:这份资源面向嵌入式开发者与STM32学习者,聚焦通过HTTP协议实现设备固件的远程升级,解决传统现场烧录维护效率低的问题。压缩包共202个文件,约5.23MB,以C源码与头文件为核心,包含36个.c、38个.h,另有编译产物.o、.d、.crf,以及Keil工程文件.uvproj、.uvopt、启动文件.s、链接脚本.sct和可执行.hex、.axf等,构成一套可直接编译调试的完整工程。资源围绕HTTP_GETPkt()函数展开,演示STM32作为客户端发送GET请求、接收服务器分块下发的固件二进制流,并涉及lwIP网络栈适配、请求头设置、MD5或SHA-1完整性校验、闪存写入与安全模式重启等关键环节,同时提示局域网静态配置与公网NAT、动态域名解析的差异。已有5087人学习下载,适合希望掌握TCP/IP协议栈与HTTP远程升级流程的中高级开发者,可据此理解客户端与服务器接口的配合方式,并借鉴其工程组织与排错思路。1. 串口烧录救不了现场设备,HTTP 远程升级才是正解做过嵌入式现场维护的都懂那种无力感:设备装在十几米高的塔吊上、埋在农田的监测站里、封在配电柜的角落中,固件一旦有 bug,唯一的办法就是扛着笔记本和 USB-TTL 跑一趟现场。更别提有些设备压根没留调试口,外壳一装就只能拆机。这个基于 STM32 的 HTTP 远程升级项目,解决的正是这个痛点——让设备自己通过 HTTP 协议从服务器拉取固件包,校验后写入 Flash,重启即完成升级。整套逻辑跑在 STM32 上,配合以太网或 WiFi 模块,不需要上位机、不需要串口线,只要设备能联网,升级就是一次 GET 请求的事。适合正在做物联网终端、工业网关、远程监测设备的开发者,尤其是那些被“现场烧录”折磨过的团队。2. 整体架构拆解:从 HTTP 请求到 Flash 写入的完整链路2.1 系统分层与数据流向这个项目的架构可以拆成四层,从上到下依次是:应用层(升级触发逻辑)、协议层(HTTP 客户端 + 固件包解析)、存储层(Flash 驱动 + 分区管理)、硬件层(STM32 + 网络模块)。数据流向很清晰:设备上电后,应用层判断是否需要升级(可以定时轮询、也可以由服务器下发通知),然后协议层发起 HTTP GET 请求,从指定 URL 下载固件二进制文件,边下载边写入 Flash 的备份区,下载完成后校验完整性,最后跳转到 Bootloader 完成搬运和重启。这里有个关键设计决策:为什么用 HTTP GET 而不是 POST?因为固件包是静态资源,放在服务器上就是一个 .bin 文件,GET 请求天然适合这种场景,而且实现简单,不需要处理表单编码。服务器端只需要一个静态文件服务(Nginx、Apache 甚至 Python 的 http.server 都行),把编译好的 bin 文件放上去就能用。整个链路里最容易被忽视的是内存管理。STM32 的 RAM 通常只有几十到几百 KB,不可能把整个固件包缓存下来再写入。常见做法是分块下载:每次从 HTTP 响应流里读 1KB 到 4KB 的数据,立刻写入 Flash,然后继续读下一块。这样 RAM 占用极小,但要求 Flash 驱动支持按页写入,并且要做好地址偏移的累加。2.2 目录结构一览拿到源码后,先看目录结构,心里有个地图再动手改:stm32-http-ota/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── http_client.h # HTTP 客户端接口 │ │ ├── ota_manager.h # 升级流程管理 │ │ └── flash_if.h # Flash 操作接口 │ └── Src/ │ ├── main.c # 主循环与升级触发 │ ├── http_client.c # HTTP 请求组装与响应解析 │ ├── ota_manager.c # 升级状态机与校验逻辑 │ └── flash_if.c # Flash 擦写驱动 ├── Drivers/ │ ├── STM32F4xx_HAL_Driver/ # HAL 库 │ └── CMSIS/ # 内核头文件 ├── Middlewares/ │ └── Third_Party/ │ └── LwIP/ # 轻量级 TCP/IP 协议栈 ├── Bootloader/ │ └── Src/ │ └── boot_main.c # Bootloader 主逻辑 └── README.md这个结构是典型的 STM32 工程布局,Core 放业务代码,Drivers 放芯片支持包,Middlewares 放第三方协议栈。Bootloader 单独一个目录,因为它编译后要烧到 Flash 起始地址,和应用程序分开。实际项目中,Bootloader 和 App 是两个独立的工程,通过链接脚本控制各自的 Flash 起始地址和大小。2.3 Flash 分区规划Flash 分区是整个升级方案的基石,分错了后面全是坑。以 STM32F407(1MB Flash)为例,常见做法是:区域起始地址大小用途Bootloader0x0800000064KB启动引导,负责搬运固件App 运行区0x08010000448KB当前运行的应用程序备份区0x08080000448KB下载的新固件暂存参数区0x080F000064KB存储升级标志、版本号、CRCBootloader 永远不动,升级时新固件先写到备份区,校验通过后设置升级标志,重启后 Bootloader 把备份区的内容搬运到 App 运行区,搬运完成清除标志,跳转到 App。这样即使升级过程中断电,App 运行区的旧固件依然完整,设备不会变砖。注意:分区大小要根据实际固件体积调整,App 运行区和备份区必须一样大,且不能有重叠。链接脚本里的 FLASH 长度要对应修改,否则编译出来的 bin 文件可能超出分区范围。3. 核心模块实现:HTTP 客户端与 Flash 写入的代码细节3.1 HTTP GET 请求的组装与发送先看 HTTP 客户端的核心代码。这里假设底层已经有一个可用的 TCP socket(通过 LwIP 或 AT 指令模组实现),我们只需要组装 HTTP 请求报文并解析响应。// http_client.c #include "http_client.h" #include <string.h> #include <stdio.h> #define HTTP_BUF_SIZE 1024 /** * @brief 发起 HTTP GET 请求,下载固件到 Flash * @param host: 服务器域名或 IP * @param port: 端口号,HTTP 默认 80 * @param path: 固件文件路径,如 "/firmware/app_v2.bin" * @param flash_addr: 固件写入 Flash 的起始地址 * @return 0 成功,-1 失败 */ int http_download_firmware(const char *host, int port, const char *path, uint32_t flash_addr) { char request[256]; char resp_buf[HTTP_BUF_SIZE]; int sockfd, ret; uint32_t write_addr = flash_addr; uint32_t total_len = 0; int header_parsed = 0; // 1. 建立 TCP 连接 sockfd = tcp_connect(host, port); if (sockfd < 0) { printf("[OTA] TCP connect failed\r\n"); return -1; } // 2. 组装 HTTP GET 请求 snprintf(request, sizeof(request), "GET %s HTTP/1.1\r\n" "Host: %s\r\n" "Connection: close\r\n" "User-Agent: STM32-OTA/1.0\r\n" "\r\n", path, host); ret = tcp_send(sockfd, request, strlen(request)); if (ret < 0) { printf("[OTA] Send request failed\r\n"); tcp_close(sockfd); return -1; } // 3. 擦除备份区 Flash(按扇区擦除) flash_erase_sectors(flash_addr, APP_PARTITION_SIZE); // 4. 循环接收响应数据 while (1) { int len = tcp_recv(sockfd, resp_buf, HTTP_BUF_SIZE); if (len <= 0) break; // 连接关闭或出错 if (!header_parsed) { // 跳过 HTTP 响应头,找到 \r\n\r\n char *body = strstr(resp_buf, "\r\n\r\n"); if (body) { body += 4; // 跳过空行 int body_len = len - (body - resp_buf); if (body_len > 0) { flash_write(write_addr, (uint8_t *)body, body_len); write_addr += body_len; total_len += body_len; } header_parsed = 1; } continue; } // 已解析头部,后续数据直接写入 Flash flash_write(write_addr, (uint8_t *)resp_buf, len); write_addr += len; total_len += len; } tcp_close(sockfd); printf("[OTA] Download done, total %lu bytes\r\n", total_len); return (total_len > 0) ? 0 : -1; }这段代码有几个关键点。第一,HTTP 请求头里Connection: close告诉服务器响应完就关闭连接,这样我们不需要实现 chunked 编码解析,简化了逻辑。第二,响应头和数据可能在同一包或不同包里到达,所以用header_parsed标志位处理跨包的情况。第三,Flash 擦除在下载前完成,因为 STM32 的 Flash 只能按扇区擦除,不能按字节擦,必须先擦后写。参数说明:host可以是 IP 也可以是域名,如果域名需要底层支持 DNS 解析;flash_addr是备份区的起始地址,要和分区表一致;APP_PARTITION_SIZE是备份区大小,擦除时按扇区对齐。3.2 Flash 写入与校验逻辑Flash 写入不是简单的 memcpy,STM32 的 Flash 控制器有严格的时序要求。下面是基于 HAL 库的实现:// flash_if.c #include "flash_if.h" #include "stm32f4xx_hal.h" /** * @brief 向指定地址写入数据(自动处理扇区边界) * @param addr: 目标地址,必须是 4 字节对齐 * @param data: 数据指针 * @param len: 数据长度 * @return 0 成功,-1 失败 */ int flash_write(uint32_t addr, uint8_t *data, uint32_t len) { HAL_FLASH_Unlock(); for (uint32_t i = 0; i < len; i += 4) { uint32_t word = 0; // 处理末尾不足 4 字节的情况 uint32_t remain = len - i; if (remain >= 4) { memcpy(&word, data + i, 4); } else { memcpy(&word, data + i, remain); } if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr + i, word) != HAL_OK) { HAL_FLASH_Lock(); return -1; } } HAL_FLASH_Lock(); return 0; } /** * @brief 计算固件的 CRC32 校验值 * @param addr: 固件起始地址 * @param len: 固件长度 * @return CRC32 值 */ uint32_t flash_crc32(uint32_t addr, uint32_t len) { uint32_t crc = 0xFFFFFFFF; for (uint32_t i = 0; i < len; i += 4) { uint32_t word = *(volatile uint32_t *)(addr + i); crc ^= word; for (int j = 0; j < 32; j++) { if (crc & 0x80000000) crc = (crc << 1) ^ 0x04C11DB7; else crc <<= 1; } } return crc; }flash_write按 4 字节对齐写入,因为 STM32F4 的 Flash 编程单位是字(32 位)。如果数据长度不是 4 的倍数,末尾会补零写入,这在固件场景下没问题,因为 bin 文件本身通常会对齐。flash_crc32用于下载完成后校验固件完整性,服务器端在生成 bin 文件时也要计算同样的 CRC 值,通过 HTTP 响应头或单独的接口传给设备。注意:Flash 写入前必须解锁,写完立即上锁,否则可能误擦除其他区域。另外,写入地址必须在擦除过的扇区内,否则写入会失败。3.3 升级状态机与 Bootloader 跳转升级流程用一个状态机管理,状态包括:空闲、下载中、校验中、待搬运、搬运完成。状态值存在参数区 Flash 里,掉电不丢失。// ota_manager.c typedef enum { OTA_IDLE = 0, OTA_DOWNLOADING, OTA_VERIFYING, OTA_PENDING, // 下载校验完成,等待重启搬运 OTA_DONE } ota_state_t; // 参数区地址(最后 64KB 的第一个扇区) #define PARAM_ADDR 0x080F0000 typedef struct { uint32_t magic; // 魔数,标识参数有效 ota_state_t state; uint32_t fw_size; // 固件大小 uint32_t fw_crc; // 固件 CRC uint32_t version; // 版本号 } ota_param_t; void ota_set_state(ota_state_t state, uint32_t size, uint32_t crc) { ota_param_t param; param.magic = 0x4F544131; // "OTA1" param.state = state; param.fw_size = size; param.fw_crc = crc; param.version = get_current_version() + 1; flash_erase_sectors(PARAM_ADDR, 0x10000); flash_write(PARAM_ADDR, (uint8_t *)¶m, sizeof(param)); } // Bootloader 中的搬运逻辑 void boot_copy_firmware(void) { ota_param_t *param = (ota_param_t *)PARAM_ADDR; if (param->magic != 0x4F544131 || param->state != OTA_PENDING) return; // 从备份区搬运到 App 运行区 flash_erase_sectors(APP_ADDR, APP_PARTITION_SIZE); for (uint32_t i = 0; i < param->fw_size; i += 4) { uint32_t word = *(volatile uint32_t *)(BACKUP_ADDR + i); HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, APP_ADDR + i, word); } // 校验搬运后的数据 uint32_t crc = flash_crc32(APP_ADDR, param->fw_size); if (crc == param->fw_crc) { param->state = OTA_DONE; flash_erase_sectors(PARAM_ADDR, 0x10000); flash_write(PARAM_ADDR, (uint8_t *)param, sizeof(ota_param_t)); } }Bootloader 的搬运逻辑必须在跳转到 App 之前执行,而且搬运过程中不能被打断。实际项目中,Bootloader 会先检查参数区的状态,如果是OTA_PENDING就执行搬运,搬运完成后清除标志,最后跳转到 App 的复位向量。跳转代码用汇编或直接操作 MSP 和 PC 指针实现。4. 避坑与排查:远程升级最容易翻车的五个地方4.1 下载到一半断电,设备变砖现象:升级过程中断电,重新上电后设备无法启动,串口无输出。原因:新固件直接覆盖了 App 运行区,断电导致 App 区数据不完整,Bootloader 又跳转到了损坏的 App。解决:采用双区设计,新固件先写备份区,校验通过后再由 Bootloader 搬运。搬运过程如果断电,下次上电重新搬运即可,App 区始终是完整的旧固件。4.2 HTTP 响应头解析错误,固件写入偏移现象:下载完成后 CRC 校验失败,对比发现固件头部多了几十个字节。原因:HTTP 响应头没有正确跳过,把HTTP/1.1 200 OK\r\n...这些文本也写进了 Flash。解决:用strstr查找\r\n\r\n分隔符,确保只写入分隔符之后的数据。如果响应头跨多个 TCP 包,需要缓存头部直到找到分隔符。4.3 Flash 擦除不完整,写入数据错乱现象:固件写入后读取校验,部分扇区数据是 0xFF 或旧数据。原因:STM32 的 Flash 擦除是按扇区的,如果只擦了部分扇区,未擦除的区域写入会失败。解决:擦除时按扇区对齐,从起始地址到结束地址覆盖所有涉及的扇区。可以用flash_erase_sectors封装,内部循环调用 HAL 的扇区擦除函数。4.4 网络不稳定导致下载中断现象:下载到 80% 时 TCP 连接断开,升级失败。解决:实现断点续传。在 HTTP 请求头里加Range: bytes=xxx-,服务器返回 206 Partial Content,设备从上次中断的地址继续写入。同时记录已下载的字节数到参数区,重启后可以恢复。4.5 跳转到 App 后外设异常现象:Bootloader 跳转到 App 后,串口、定时器工作不正常。原因:Bootloader 初始化了外设,跳转前没有反初始化,App 再次初始化时冲突。解决:跳转前调用HAL_DeInit()关闭所有外设,关闭全局中断,设置 MSP 为 App 的栈顶地址,然后跳转到 App 的复位向量。5. 二次开发改造:从能跑到好用5.1 加入 HTTPS 支持HTTP 是明文传输,固件包容易被篡改。改造方向是加入 TLS,可以用 mbedTLS 库,在 TCP 之上加一层 SSL。核心改动在tcp_connect之后加ssl_handshake,发送和接收都走 SSL 接口。服务器端需要配置证书,设备端内置 CA 证书或使用预共享密钥。这个改造工作量不小,但安全性提升明显。5.2 差分升级减小流量完整固件包动辄几百 KB,对于 NB-IoT 这类低带宽场景,流量成本很高。差分升级的思路是只下载新旧固件的差异部分,设备端用差分算法(如 bsdiff)合并生成新固件。服务器端需要保存旧版本固件,生成差分包。这个方案能把升级包缩小到原来的 10% 到 20%,但实现复杂度较高,需要额外的差分合并代码。5.3 升级策略优化当前是设备主动轮询,可以改成服务器推送。设备保持一个长连接(MQTT 或 WebSocket),服务器有新固件时下发通知,设备再发起 HTTP 下载。这样升级更及时,也减少了无效轮询。另外可以加入灰度发布逻辑,根据设备 ID 或版本号决定是否升级,避免一次性全量推送导致服务器带宽打满。5.4 验证方法改造完成后,验证要覆盖几个场景:正常升级、下载中断后恢复、固件 CRC 错误、断电重启后继续搬运、跳转后外设正常。我一般会写一个 Python 脚本模拟服务器,控制响应速度和数据包大小,测试设备在各种网络条件下的表现。脚本核心逻辑:# ota_server_sim.py from http.server import HTTPServer, BaseHTTPRequestHandler import time class OTAServer(BaseHTTPRequestHandler): def do_GET(self): if self.path == "/firmware/app.bin": with open("app.bin", "rb") as f: data = f.read() self.send_response(200) self.send_header("Content-Type", "application/octet-stream") self.send_header("Content-Length", str(len(data))) self.end_headers() # 模拟慢速网络,每 1KB 延迟 10ms for i in range(0, len(data), 1024): self.wfile.write(data[i:i+1024]) time.sleep(0.01) HTTPServer(("0.0.0.0", 8080), OTAServer).serve_forever()这个脚本可以控制发送速率,模拟弱网环境。改time.sleep的参数就能调整延迟,改分块大小就能模拟不同的 MTU。从那以后我每次做远程升级,都强制走一遍「断电测试」:下载到一半直接拔电,看设备能不能恢复。这个习惯帮我拦住了至少三次可能批量返工的 bug。希望帮到你。本文还有配套的精品资源,点击获取
简介:这份资源面向嵌入式开发者与STM32学习者,聚焦通过HTTP协议实现设备固件的远程升级,解决传统现场烧录维护效率低的问题。压缩包共202个文件,约5.23MB,以C源码与头文件为核心,包含36个.c、38个.h,另有编译产物.o、.d、.crf,以及Keil工程文件.uvproj、.uvopt、启动文件.s、链接脚本.sct和可执行.hex、.axf等,构成一套可直接编译调试的完整工程。资源围绕HTTP_GETPkt()函数展开,演示STM32作为客户端发送GET请求、接收服务器分块下发的固件二进制流,并涉及lwIP网络栈适配、请求头设置、MD5或SHA-1完整性校验、闪存写入与安全模式重启等关键环节,同时提示局域网静态配置与公网NAT、动态域名解析的差异。已有5087人学习下载,适合希望掌握TCP/IP协议栈与HTTP远程升级流程的中高级开发者,可据此理解客户端与服务器接口的配合方式,并借鉴其工程组织与排错思路。1. 串口烧录救不了现场设备,HTTP 远程升级才是正解做过嵌入式现场维护的都懂那种无力感:设备装在十几米高的塔吊上、埋在农田的监测站里、封在配电柜的角落中,固件一旦有 bug,唯一的办法就是扛着笔记本和 USB-TTL 跑一趟现场。更别提有些设备压根没留调试口,外壳一装就只能拆机。这个基于 STM32 的 HTTP 远程升级项目,解决的正是这个痛点——让设备自己通过 HTTP 协议从服务器拉取固件包,校验后写入 Flash,重启即完成升级。整套逻辑跑在 STM32 上,配合以太网或 WiFi 模块,不需要上位机、不需要串口线,只要设备能联网,升级就是一次 GET 请求的事。适合正在做物联网终端、工业网关、远程监测设备的开发者,尤其是那些被“现场烧录”折磨过的团队。2. 整体架构拆解:从 HTTP 请求到 Flash 写入的完整链路2.1 系统分层与数据流向这个项目的架构可以拆成四层,从上到下依次是:应用层(升级触发逻辑)、协议层(HTTP 客户端 + 固件包解析)、存储层(Flash 驱动 + 分区管理)、硬件层(STM32 + 网络模块)。数据流向很清晰:设备上电后,应用层判断是否需要升级(可以定时轮询、也可以由服务器下发通知),然后协议层发起 HTTP GET 请求,从指定 URL 下载固件二进制文件,边下载边写入 Flash 的备份区,下载完成后校验完整性,最后跳转到 Bootloader 完成搬运和重启。这里有个关键设计决策:为什么用 HTTP GET 而不是 POST?因为固件包是静态资源,放在服务器上就是一个 .bin 文件,GET 请求天然适合这种场景,而且实现简单,不需要处理表单编码。服务器端只需要一个静态文件服务(Nginx、Apache 甚至 Python 的 http.server 都行),把编译好的 bin 文件放上去就能用。整个链路里最容易被忽视的是内存管理。STM32 的 RAM 通常只有几十到几百 KB,不可能把整个固件包缓存下来再写入。常见做法是分块下载:每次从 HTTP 响应流里读 1KB 到 4KB 的数据,立刻写入 Flash,然后继续读下一块。这样 RAM 占用极小,但要求 Flash 驱动支持按页写入,并且要做好地址偏移的累加。2.2 目录结构一览拿到源码后,先看目录结构,心里有个地图再动手改:stm32-http-ota/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── http_client.h # HTTP 客户端接口 │ │ ├── ota_manager.h # 升级流程管理 │ │ └── flash_if.h # Flash 操作接口 │ └── Src/ │ ├── main.c # 主循环与升级触发 │ ├── http_client.c # HTTP 请求组装与响应解析 │ ├── ota_manager.c # 升级状态机与校验逻辑 │ └── flash_if.c # Flash 擦写驱动 ├── Drivers/ │ ├── STM32F4xx_HAL_Driver/ # HAL 库 │ └── CMSIS/ # 内核头文件 ├── Middlewares/ │ └── Third_Party/ │ └── LwIP/ # 轻量级 TCP/IP 协议栈 ├── Bootloader/ │ └── Src/ │ └── boot_main.c # Bootloader 主逻辑 └── README.md这个结构是典型的 STM32 工程布局,Core 放业务代码,Drivers 放芯片支持包,Middlewares 放第三方协议栈。Bootloader 单独一个目录,因为它编译后要烧到 Flash 起始地址,和应用程序分开。实际项目中,Bootloader 和 App 是两个独立的工程,通过链接脚本控制各自的 Flash 起始地址和大小。2.3 Flash 分区规划Flash 分区是整个升级方案的基石,分错了后面全是坑。以 STM32F407(1MB Flash)为例,常见做法是:区域起始地址大小用途Bootloader0x0800000064KB启动引导,负责搬运固件App 运行区0x08010000448KB当前运行的应用程序备份区0x08080000448KB下载的新固件暂存参数区0x080F000064KB存储升级标志、版本号、CRCBootloader 永远不动,升级时新固件先写到备份区,校验通过后设置升级标志,重启后 Bootloader 把备份区的内容搬运到 App 运行区,搬运完成清除标志,跳转到 App。这样即使升级过程中断电,App 运行区的旧固件依然完整,设备不会变砖。注意:分区大小要根据实际固件体积调整,App 运行区和备份区必须一样大,且不能有重叠。链接脚本里的 FLASH 长度要对应修改,否则编译出来的 bin 文件可能超出分区范围。3. 核心模块实现:HTTP 客户端与 Flash 写入的代码细节3.1 HTTP GET 请求的组装与发送先看 HTTP 客户端的核心代码。这里假设底层已经有一个可用的 TCP socket(通过 LwIP 或 AT 指令模组实现),我们只需要组装 HTTP 请求报文并解析响应。// http_client.c #include "http_client.h" #include <string.h> #include <stdio.h> #define HTTP_BUF_SIZE 1024 /** * @brief 发起 HTTP GET 请求,下载固件到 Flash * @param host: 服务器域名或 IP * @param port: 端口号,HTTP 默认 80 * @param path: 固件文件路径,如 "/firmware/app_v2.bin" * @param flash_addr: 固件写入 Flash 的起始地址 * @return 0 成功,-1 失败 */ int http_download_firmware(const char *host, int port, const char *path, uint32_t flash_addr) { char request[256]; char resp_buf[HTTP_BUF_SIZE]; int sockfd, ret; uint32_t write_addr = flash_addr; uint32_t total_len = 0; int header_parsed = 0; // 1. 建立 TCP 连接 sockfd = tcp_connect(host, port); if (sockfd < 0) { printf("[OTA] TCP connect failed\r\n"); return -1; } // 2. 组装 HTTP GET 请求 snprintf(request, sizeof(request), "GET %s HTTP/1.1\r\n" "Host: %s\r\n" "Connection: close\r\n" "User-Agent: STM32-OTA/1.0\r\n" "\r\n", path, host); ret = tcp_send(sockfd, request, strlen(request)); if (ret < 0) { printf("[OTA] Send request failed\r\n"); tcp_close(sockfd); return -1; } // 3. 擦除备份区 Flash(按扇区擦除) flash_erase_sectors(flash_addr, APP_PARTITION_SIZE); // 4. 循环接收响应数据 while (1) { int len = tcp_recv(sockfd, resp_buf, HTTP_BUF_SIZE); if (len <= 0) break; // 连接关闭或出错 if (!header_parsed) { // 跳过 HTTP 响应头,找到 \r\n\r\n char *body = strstr(resp_buf, "\r\n\r\n"); if (body) { body += 4; // 跳过空行 int body_len = len - (body - resp_buf); if (body_len > 0) { flash_write(write_addr, (uint8_t *)body, body_len); write_addr += body_len; total_len += body_len; } header_parsed = 1; } continue; } // 已解析头部,后续数据直接写入 Flash flash_write(write_addr, (uint8_t *)resp_buf, len); write_addr += len; total_len += len; } tcp_close(sockfd); printf("[OTA] Download done, total %lu bytes\r\n", total_len); return (total_len > 0) ? 0 : -1; }这段代码有几个关键点。第一,HTTP 请求头里Connection: close告诉服务器响应完就关闭连接,这样我们不需要实现 chunked 编码解析,简化了逻辑。第二,响应头和数据可能在同一包或不同包里到达,所以用header_parsed标志位处理跨包的情况。第三,Flash 擦除在下载前完成,因为 STM32 的 Flash 只能按扇区擦除,不能按字节擦,必须先擦后写。参数说明:host可以是 IP 也可以是域名,如果域名需要底层支持 DNS 解析;flash_addr是备份区的起始地址,要和分区表一致;APP_PARTITION_SIZE是备份区大小,擦除时按扇区对齐。3.2 Flash 写入与校验逻辑Flash 写入不是简单的 memcpy,STM32 的 Flash 控制器有严格的时序要求。下面是基于 HAL 库的实现:// flash_if.c #include "flash_if.h" #include "stm32f4xx_hal.h" /** * @brief 向指定地址写入数据(自动处理扇区边界) * @param addr: 目标地址,必须是 4 字节对齐 * @param data: 数据指针 * @param len: 数据长度 * @return 0 成功,-1 失败 */ int flash_write(uint32_t addr, uint8_t *data, uint32_t len) { HAL_FLASH_Unlock(); for (uint32_t i = 0; i < len; i += 4) { uint32_t word = 0; // 处理末尾不足 4 字节的情况 uint32_t remain = len - i; if (remain >= 4) { memcpy(&word, data + i, 4); } else { memcpy(&word, data + i, remain); } if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr + i, word) != HAL_OK) { HAL_FLASH_Lock(); return -1; } } HAL_FLASH_Lock(); return 0; } /** * @brief 计算固件的 CRC32 校验值 * @param addr: 固件起始地址 * @param len: 固件长度 * @return CRC32 值 */ uint32_t flash_crc32(uint32_t addr, uint32_t len) { uint32_t crc = 0xFFFFFFFF; for (uint32_t i = 0; i < len; i += 4) { uint32_t word = *(volatile uint32_t *)(addr + i); crc ^= word; for (int j = 0; j < 32; j++) { if (crc & 0x80000000) crc = (crc << 1) ^ 0x04C11DB7; else crc <<= 1; } } return crc; }flash_write按 4 字节对齐写入,因为 STM32F4 的 Flash 编程单位是字(32 位)。如果数据长度不是 4 的倍数,末尾会补零写入,这在固件场景下没问题,因为 bin 文件本身通常会对齐。flash_crc32用于下载完成后校验固件完整性,服务器端在生成 bin 文件时也要计算同样的 CRC 值,通过 HTTP 响应头或单独的接口传给设备。注意:Flash 写入前必须解锁,写完立即上锁,否则可能误擦除其他区域。另外,写入地址必须在擦除过的扇区内,否则写入会失败。3.3 升级状态机与 Bootloader 跳转升级流程用一个状态机管理,状态包括:空闲、下载中、校验中、待搬运、搬运完成。状态值存在参数区 Flash 里,掉电不丢失。// ota_manager.c typedef enum { OTA_IDLE = 0, OTA_DOWNLOADING, OTA_VERIFYING, OTA_PENDING, // 下载校验完成,等待重启搬运 OTA_DONE } ota_state_t; // 参数区地址(最后 64KB 的第一个扇区) #define PARAM_ADDR 0x080F0000 typedef struct { uint32_t magic; // 魔数,标识参数有效 ota_state_t state; uint32_t fw_size; // 固件大小 uint32_t fw_crc; // 固件 CRC uint32_t version; // 版本号 } ota_param_t; void ota_set_state(ota_state_t state, uint32_t size, uint32_t crc) { ota_param_t param; param.magic = 0x4F544131; // "OTA1" param.state = state; param.fw_size = size; param.fw_crc = crc; param.version = get_current_version() + 1; flash_erase_sectors(PARAM_ADDR, 0x10000); flash_write(PARAM_ADDR, (uint8_t *)¶m, sizeof(param)); } // Bootloader 中的搬运逻辑 void boot_copy_firmware(void) { ota_param_t *param = (ota_param_t *)PARAM_ADDR; if (param->magic != 0x4F544131 || param->state != OTA_PENDING) return; // 从备份区搬运到 App 运行区 flash_erase_sectors(APP_ADDR, APP_PARTITION_SIZE); for (uint32_t i = 0; i < param->fw_size; i += 4) { uint32_t word = *(volatile uint32_t *)(BACKUP_ADDR + i); HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, APP_ADDR + i, word); } // 校验搬运后的数据 uint32_t crc = flash_crc32(APP_ADDR, param->fw_size); if (crc == param->fw_crc) { param->state = OTA_DONE; flash_erase_sectors(PARAM_ADDR, 0x10000); flash_write(PARAM_ADDR, (uint8_t *)param, sizeof(ota_param_t)); } }Bootloader 的搬运逻辑必须在跳转到 App 之前执行,而且搬运过程中不能被打断。实际项目中,Bootloader 会先检查参数区的状态,如果是OTA_PENDING就执行搬运,搬运完成后清除标志,最后跳转到 App 的复位向量。跳转代码用汇编或直接操作 MSP 和 PC 指针实现。4. 避坑与排查:远程升级最容易翻车的五个地方4.1 下载到一半断电,设备变砖现象:升级过程中断电,重新上电后设备无法启动,串口无输出。原因:新固件直接覆盖了 App 运行区,断电导致 App 区数据不完整,Bootloader 又跳转到了损坏的 App。解决:采用双区设计,新固件先写备份区,校验通过后再由 Bootloader 搬运。搬运过程如果断电,下次上电重新搬运即可,App 区始终是完整的旧固件。4.2 HTTP 响应头解析错误,固件写入偏移现象:下载完成后 CRC 校验失败,对比发现固件头部多了几十个字节。原因:HTTP 响应头没有正确跳过,把HTTP/1.1 200 OK\r\n...这些文本也写进了 Flash。解决:用strstr查找\r\n\r\n分隔符,确保只写入分隔符之后的数据。如果响应头跨多个 TCP 包,需要缓存头部直到找到分隔符。4.3 Flash 擦除不完整,写入数据错乱现象:固件写入后读取校验,部分扇区数据是 0xFF 或旧数据。原因:STM32 的 Flash 擦除是按扇区的,如果只擦了部分扇区,未擦除的区域写入会失败。解决:擦除时按扇区对齐,从起始地址到结束地址覆盖所有涉及的扇区。可以用flash_erase_sectors封装,内部循环调用 HAL 的扇区擦除函数。4.4 网络不稳定导致下载中断现象:下载到 80% 时 TCP 连接断开,升级失败。解决:实现断点续传。在 HTTP 请求头里加Range: bytes=xxx-,服务器返回 206 Partial Content,设备从上次中断的地址继续写入。同时记录已下载的字节数到参数区,重启后可以恢复。4.5 跳转到 App 后外设异常现象:Bootloader 跳转到 App 后,串口、定时器工作不正常。原因:Bootloader 初始化了外设,跳转前没有反初始化,App 再次初始化时冲突。解决:跳转前调用HAL_DeInit()关闭所有外设,关闭全局中断,设置 MSP 为 App 的栈顶地址,然后跳转到 App 的复位向量。5. 二次开发改造:从能跑到好用5.1 加入 HTTPS 支持HTTP 是明文传输,固件包容易被篡改。改造方向是加入 TLS,可以用 mbedTLS 库,在 TCP 之上加一层 SSL。核心改动在tcp_connect之后加ssl_handshake,发送和接收都走 SSL 接口。服务器端需要配置证书,设备端内置 CA 证书或使用预共享密钥。这个改造工作量不小,但安全性提升明显。5.2 差分升级减小流量完整固件包动辄几百 KB,对于 NB-IoT 这类低带宽场景,流量成本很高。差分升级的思路是只下载新旧固件的差异部分,设备端用差分算法(如 bsdiff)合并生成新固件。服务器端需要保存旧版本固件,生成差分包。这个方案能把升级包缩小到原来的 10% 到 20%,但实现复杂度较高,需要额外的差分合并代码。5.3 升级策略优化当前是设备主动轮询,可以改成服务器推送。设备保持一个长连接(MQTT 或 WebSocket),服务器有新固件时下发通知,设备再发起 HTTP 下载。这样升级更及时,也减少了无效轮询。另外可以加入灰度发布逻辑,根据设备 ID 或版本号决定是否升级,避免一次性全量推送导致服务器带宽打满。5.4 验证方法改造完成后,验证要覆盖几个场景:正常升级、下载中断后恢复、固件 CRC 错误、断电重启后继续搬运、跳转后外设正常。我一般会写一个 Python 脚本模拟服务器,控制响应速度和数据包大小,测试设备在各种网络条件下的表现。脚本核心逻辑:# ota_server_sim.py from http.server import HTTPServer, BaseHTTPRequestHandler import time class OTAServer(BaseHTTPRequestHandler): def do_GET(self): if self.path == "/firmware/app.bin": with open("app.bin", "rb") as f: data = f.read() self.send_response(200) self.send_header("Content-Type", "application/octet-stream") self.send_header("Content-Length", str(len(data))) self.end_headers() # 模拟慢速网络,每 1KB 延迟 10ms for i in range(0, len(data), 1024): self.wfile.write(data[i:i+1024]) time.sleep(0.01) HTTPServer(("0.0.0.0", 8080), OTAServer).serve_forever()这个脚本可以控制发送速率,模拟弱网环境。改time.sleep的参数就能调整延迟,改分块大小就能模拟不同的 MTU。从那以后我每次做远程升级,都强制走一遍「断电测试」:下载到一半直接拔电,看设备能不能恢复。这个习惯帮我拦住了至少三次可能批量返工的 bug。希望帮到你。本文还有配套的精品资源,点击获取