简介:这份资源面向嵌入式开发工程师与STM32学习者,聚焦通过HTTP协议实现设备固件远程升级这一实用场景。包内共202个文件,以38个h头文件、36个c源文件为核心,配合32个o目标文件、32个d依赖文件及31个crf交叉引用文件,另有uvproj、uvopt工程配置、sct分散加载、hex与axf可执行文件、map映射表等,完整呈现从源码到编译产物的工程结构,压缩包约5.23MB。资源围绕HTTP GET方法请求服务器固件、接收二进制流、校验完整性并写入闪存的主线展开,涉及lwIP等网络库适配、请求头设置、MD5或SHA-1校验以及升级安全模式等关键环节,可帮助读者理解TCP/IP协议栈在STM32上的落地方式与服务器端接口设计思路。目前已有5086人学习下载,适合需要掌握网络编程与远程固件更新流程的中高级开发者参考借鉴。
1. 基于STM32的HTTP远程升级:为什么串口烧录迟早要换掉
做嵌入式这行的,大概都经历过这样的场景:设备装在现场某个角落,固件有个小 bug 要修,结果得派人带着笔记本和调试器跑一趟。一次两次还行,设备上了几十上百台之后,光是差旅成本就够养一个开发了。基于 STM32 的 HTTP 远程升级,解决的就是这个问题——让设备自己从 HTTP 服务器拉取固件包,自己完成校验、写入、跳转,全程不需要人到现场。
这个方案适合谁?适合那些设备已经联网(以太网或 WiFi 模组都行)、MCU 是 STM32F4/F7/H7 这类带足够 Flash 的型号、并且你希望用最通用的协议栈(HTTP + TCP/IP)来做 OTA 的团队。它不依赖私有云平台,不绑定任何厂商的 OTA 服务,一台普通的 HTTP 服务器就能当升级源。接下来我会把整个链路拆开:从 Flash 分区怎么划、Bootloader 怎么写、HTTP 分片下载怎么处理,一直讲到跳转前那几个容易翻车的细节。
2. 先想清楚 Flash 怎么分:Bootloader 与 App 的边界设计
2.1 为什么不能把升级逻辑塞进 App 里
最常见的错误做法是:在 App 里收到升级指令后,直接擦除自己所在的 Flash 区域,然后往里面写新固件。这个操作在擦除的那一瞬间,CPU 取指就会失败——因为你正在擦除的地址范围里,可能就包含了下一条要执行的指令。结果就是硬件异常或者直接死机,连后悔药都没得吃。
正确的做法是分成两个独立的程序:Bootloader 和 App。Bootloader 永远不参与升级,它只负责检查有没有新固件、下载、校验、写入 App 区域,然后跳转。App 负责业务逻辑,收到升级指令后写一个标志位到备份寄存器或 Flash 的特定页,然后软复位。复位后 Bootloader 先跑,看到标志位就去执行升级流程。
2.2 一个可落地的 Flash 分区方案
以 STM32F407(1MB Flash)为例,我一般会这样分:
| 区域 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Bootloader | 0x08000000 | 64KB | 启动、升级逻辑、HTTP 客户端 |
| App | 0x08010000 | 448KB | 业务固件 |
| 升级标志页 | 0x08080000 | 16KB | 存放升级请求标志和固件信息 |
| 下载缓存区 | 0x08084000 | 496KB | 存放从 HTTP 下载的固件数据 |
这个分区的逻辑是:Bootloader 要足够小,64KB 对于带 HTTP 客户端的 Bootloader 来说比较紧张,但用轻量级的实现(比如自己写一个精简的 HTTP 客户端,只支持 GET 和分块接收)是可以塞进去的。如果 Bootloader 超过 64KB,可以把 App 的起始地址往后挪,但要注意 App 的链接脚本也要同步改。
下载缓存区的大小必须大于等于 App 区域的最大可能大小。因为 HTTP 下载过程中,如果一边下载一边往 App 区域写,一旦下载中断,App 就被破坏了,设备变砖。所以稳妥的做法是:先把整个固件下载到缓存区,校验通过后再一次性搬运到 App 区域。搬运过程是内存到内存的复制,速度快,中断窗口极小。
2.3 链接脚本的修改不能忘
App 工程的链接脚本必须把起始地址改到 0x08010000,否则编译出来的固件是给 0x08000000 准备的,跳转过去必挂。以 GCC 为例,在.ld文件里改:
/* STM32F407 App 链接脚本片段 */ MEMORY { FLASH (rx) : ORIGIN = 0x08010000, LENGTH = 448K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K } /* 中断向量表偏移也要改 */同时在 App 的system_stm32f4xx.c里,把VECT_TAB_OFFSET改成0x10000。这个偏移量是相对于 Flash 起始地址的,不是绝对地址。很多人在这里填了绝对地址,结果中断一触发就跳飞到非法地址。
注意:Bootloader 跳转到 App 之前,必须重新设置主堆栈指针(MSP)。App 的向量表第一个字就是初始 MSP 值,第二个字是复位向量。跳转代码要先把这两个值取出来,设置 MSP,再跳转到复位向量。
3. HTTP 分片下载在 STM32 上怎么跑通
3.1 为什么不用 HTTP 的 Range 头做断点续传
HTTP/1.1 支持Range: bytes=0-1023这样的请求头,理论上可以做断点续传。但在 STM32 这种资源受限的环境下,我一般不建议用 Range。原因有两个:第一,很多轻量级 HTTP 服务器对 Range 的支持不完整,返回的 Content-Range 格式可能不标准;第二,断点续传需要记录已下载的偏移量,这个偏移量本身也要存到非易失存储里,增加了复杂度。
更简单的做法是:每次升级都完整下载整个固件。如果网络不稳定导致下载失败,就重新来一遍。固件包一般也就几百 KB,在局域网或稳定的互联网连接下,完整下载一次的时间是可以接受的。如果固件特别大(比如超过 1MB),再考虑分片,但分片逻辑要自己实现,不要依赖 Range。
3.2 用 LwIP 的 raw API 实现 HTTP GET
假设你用的是 STM32 以太网 + LwIP 协议栈,下面是一个精简的 HTTP GET 请求实现。这个函数在 Bootloader 里跑,负责从服务器拉取固件数据。
#include "lwip/tcp.h" #include "lwip/ip_addr.h" #define HTTP_SERVER_IP IPADDR4_INIT_BYTES(192, 168, 1, 100) #define HTTP_SERVER_PORT 8080 #define HTTP_PATH "/firmware/app.bin" static struct tcp_pcb *http_pcb = NULL; static uint32_t total_received = 0; static uint32_t content_length = 0; static uint8_t *download_buffer = (uint8_t *)0x08084000; // 缓存区起始地址 // 解析 HTTP 响应头,提取 Content-Length static uint32_t parse_content_length(const char *header) { const char *p = strstr(header, "Content-Length:"); if (p == NULL) return 0; p += strlen("Content-Length:"); while (*p == ' ') p++; return (uint32_t)atoi(p); } // 判断响应头是否接收完毕(遇到 \r\n\r\n) static int header_complete(const char *buf, uint16_t len) { for (uint16_t i = 0; i < len - 3; i++) { if (buf[i] == '\r' && buf[i+1] == '\n' && buf[i+2] == '\r' && buf[i+3] == '\n') { return i + 4; // 返回正文起始偏移 } } return 0; } static err_t http_recv_callback(void *arg, struct tcp_pcb *tpcb, struct pbuf *p, err_t err) { static char header_buf[512]; static uint16_t header_len = 0; static int header_done = 0; if (p == NULL) { // 服务器关闭连接 tcp_close(tpcb); http_pcb = NULL; return ERR_OK; } if (!header_done) { // 把数据拷贝到 header_buf 里解析 uint16_t copy_len = p->tot_len; if (header_len + copy_len > sizeof(header_buf) - 1) copy_len = sizeof(header_buf) - 1 - header_len; pbuf_copy_partial(p, header_buf + header_len, copy_len, 0); header_len += copy_len; int body_offset = header_complete(header_buf, header_len); if (body_offset > 0) { header_done = 1; content_length = parse_content_length(header_buf); // 处理 header 之后可能已经到达的正文数据 uint16_t body_in_this_pbuf = header_len - body_offset; if (body_in_this_pbuf > 0) { pbuf_copy_partial(p, download_buffer + total_received, body_in_this_pbuf, body_offset); total_received += body_in_this_pbuf; } } } else { // 纯正文数据,直接往缓存区写 uint16_t copy_len = p->tot_len; if (total_received + copy_len > MAX_FIRMWARE_SIZE) { // 超出缓存区,出错处理 tcp_abort(tpcb); return ERR_ABRT; } pbuf_copy_partial(p, download_buffer + total_received, copy_len, 0); total_received += copy_len; } tcp_recved(tpcb, p->tot_len); pbuf_free(p); return ERR_OK; } // 发起 HTTP GET 请求 int http_download_firmware(void) { ip4_addr_t server_ip; IP4_ADDR(&server_ip, 192, 168, 1, 100); http_pcb = tcp_new(); if (http_pcb == NULL) return -1; tcp_recv(http_pcb, http_recv_callback); if (tcp_connect(http_pcb, &server_ip, HTTP_SERVER_PORT, NULL) != ERR_OK) { tcp_close(http_pcb); http_pcb = NULL; return -2; } // 构造 HTTP 请求 char request[256]; int req_len = snprintf(request, sizeof(request), "GET %s HTTP/1.1\r\n" "Host: 192.168.1.100:8080\r\n" "Connection: close\r\n" "\r\n", HTTP_PATH); tcp_write(http_pcb, request, req_len, TCP_WRITE_FLAG_COPY); tcp_output(http_pcb); return 0; }这段代码的逻辑说明:http_recv_callback是 LwIP 在收到 TCP 数据时回调的。它先判断响应头是否接收完毕,如果没完,就把数据攒到header_buf里,直到遇到\r\n\r\n。一旦头部结束,解析出Content-Length,然后把后续数据直接写入download_buffer。download_buffer指向 Flash 的缓存区地址,但这里有个关键点:Flash 不能像 RAM 一样直接按字节写,必须先擦除再写,而且写的时候要按半字或字对齐。
所以上面的代码其实有个隐患:pbuf_copy_partial直接往 Flash 地址写,在 STM32 上是不行的。正确的做法是先把数据收到 RAM 缓冲区,攒够一页(比如 2KB)再擦除并写入 Flash。这个 RAM 缓冲区的大小取决于你的 RAM 余量,一般 2KB 到 4KB 比较合适。
3.3 Flash 写入的页对齐与擦除策略
STM32F4 的 Flash 扇区大小是 16KB(扇区 0-3)、64KB(扇区 4)、128KB(扇区 5-11)。擦除的最小单位是扇区,不是页。这意味着你不能只擦除 2KB,必须擦整个扇区。所以缓存区的设计要考虑到这一点:如果你要往缓存区写 448KB 的数据,需要擦除的扇区是扇区 4 到扇区 7(假设从 0x08080000 开始)。这些扇区在写入之前要全部擦除,擦除操作可以在下载开始之前一次性做完。
写入的时候,STM32F4 的 Flash 编程单位是字节、半字、字、双字。我一般用字(32 位)写入,速度比较快。下面是一个简单的 Flash 写入函数:
#include "stm32f4xx_hal.h" // 往指定地址写入数据,data 是 RAM 缓冲区,len 必须是 4 的倍数 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 = *(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; } // 擦除指定扇区 int flash_erase_sector(uint32_t sector) { FLASH_EraseInitTypeDef erase; uint32_t sector_error = 0; HAL_FLASH_Unlock(); erase.TypeErase = FLASH_TYPEERASE_SECTORS; erase.Sector = sector; erase.NbSectors = 1; erase.VoltageRange = FLASH_VOLTAGE_RANGE_3; if (HAL_FLASHEx_Erase(&erase, §or_error) != HAL_OK) { HAL_FLASH_Lock(); return -1; } HAL_FLASH_Lock(); return 0; }参数说明:FLASH_TYPEPROGRAM_WORD表示按 32 位字编程,FLASH_VOLTAGE_RANGE_3对应 2.7V-3.6V 供电。如果你的板子是 1.8V 供电,要改成FLASH_VOLTAGE_RANGE_1。擦除之前一定要确认扇区号,擦错了扇区可能把 Bootloader 自己擦掉,那就真的变砖了。
4. 校验、跳转与回滚:升级流程的最后三公里
4.1 CRC 校验放在下载完成后做
固件下载到缓存区之后,不要急着搬运到 App 区域。先做一次 CRC32 校验,把结果和服务器返回的校验值(可以放在 HTTP 响应头里,比如X-Firmware-CRC32)比对。如果一致,再搬运;不一致,丢弃缓存区的数据,重新下载。
CRC32 的计算可以用 STM32 硬件 CRC 外设,也可以用软件查表法。硬件 CRC 速度快,但要注意 STM32 的硬件 CRC 默认多项式是 0x04C11DB7,和标准 CRC32 一致。软件查表法占 Flash 空间,但移植性好。我一般用硬件 CRC,初始化代码如下:
static CRC_HandleTypeDef hcrc; void crc_init(void) { hcrc.Instance = CRC; hcrc.Init.DefaultPolynomialUse = DEFAULT_POLYNOMIAL_ENABLE; hcrc.Init.DefaultInitValueUse = DEFAULT_INIT_VALUE_ENABLE; hcrc.Init.InputDataInversionMode = CRC_INPUTDATA_INVERSION_BYTE; hcrc.Init.OutputDataInversionMode = CRC_OUTPUTDATA_INVERSION_ENABLE; hcrc.InputDataFormat = CRC_INPUTDATA_FORMAT_BYTES; HAL_CRC_Init(&hcrc); } uint32_t crc_calculate(uint8_t *data, uint32_t len) { return HAL_CRC_Calculate(&hcrc, (uint32_t *)data, len); }注意InputDataFormat要设成CRC_INPUTDATA_FORMAT_BYTES,否则按字计算的结果和按字节计算的不一样。这个坑我踩过,当时服务器端用软件 CRC 算出来是 A 值,STM32 硬件算出来是 B 值,查了半天才发现是数据格式没对齐。
4.2 跳转到 App 的完整代码
校验通过后,把缓存区的数据搬运到 App 区域。搬运之前要先擦除 App 区域的所有扇区。搬运完成后,设置 MSP 和跳转。
typedef void (*pFunction)(void); #define APP_ADDRESS 0x08010000 void jump_to_app(void) { uint32_t app_msp = *(uint32_t *)APP_ADDRESS; uint32_t app_reset = *(uint32_t *)(APP_ADDRESS + 4); // 检查栈顶地址是否合法(要在 RAM 范围内) if ((app_msp & 0x2FFE0000) != 0x20000000) { // 非法栈顶,不跳转 return; } pFunction app_entry = (pFunction)app_reset; // 关闭所有中断 __disable_irq(); // 设置主堆栈指针 __set_MSP(app_msp); // 跳转 app_entry(); }这段代码的关键在于__set_MSP(app_msp)。App 的向量表第一个字是初始 MSP 值,这个值在编译 App 的时候由链接脚本决定。如果 App 的链接脚本里 RAM 起始地址和 Bootloader 的不一样,这里设置的就是 App 自己的栈顶。跳转之前关中断是必须的,否则 Bootloader 里开的中断(比如 SysTick)会在 App 里继续触发,但中断向量表已经变了,会跳到错误的地方。
4.3 回滚机制:给升级加一道保险
如果新固件跑不起来怎么办?比如 App 启动后卡死,看门狗复位,又回到 Bootloader。这时候 Bootloader 要能识别出“上次升级失败了”,然后回滚到旧固件。
实现方式:在 Flash 里留一个“启动成功标志”页。App 启动后,在初始化完成之后,往这个页写一个特定的值(比如 0x5A5A5A5A)。Bootloader 每次启动时检查这个值:如果是 0x5A5A5A5A,说明上次 App 启动成功了,清除标志,正常跳转;如果不是,说明上次 App 没跑起来,触发回滚。
回滚需要保留旧固件。但 Flash 空间有限,不可能同时存两份 App。所以回滚的实现通常是:升级前先把旧固件的关键部分(比如前 64KB)备份到缓存区的末尾,如果新固件启动失败,就把备份恢复回去。这个方案不是万无一失,但能覆盖大部分“新固件有严重 bug 导致无法启动”的场景。
5. 避坑指南:HTTP 远程升级中最容易翻车的五个点
5.1 现象:下载到一半卡死,TCP 连接不关闭也不超时
原因:LwIP 的tcp_recv回调里如果处理时间过长(比如擦除 Flash 扇区),会导致 TCP 接收窗口耗尽,服务器发不出数据,双方互相等。STM32 的 Flash 擦除一个 128KB 扇区可能需要 1-2 秒,这段时间 CPU 被阻塞,LwIP 的定时器也得不到处理。
解决:不要在tcp_recv回调里直接擦除 Flash。把数据先收到 RAM 环形缓冲区,在主循环里再处理 Flash 擦除和写入。或者用 LwIP 的tcp_recv回调只做数据入队,实际处理放到tcpip_thread之外。如果 RAM 不够,至少要把擦除操作拆分成多次,每次擦一小块,中间调用sys_check_timeouts()让 LwIP 有机会处理超时。
5.2 现象:跳转到 App 后,中断不响应或者 HardFault
原因:App 的向量表偏移没设置,或者设置错了。STM32 的中断向量表默认在 0x08000000,App 在 0x08010000,如果不设置SCB->VTOR,中断触发时 CPU 还是去 0x08000000 取向量,那里现在是 Bootloader 的向量表,跳过去就乱了。
解决:在 App 的main()函数开头,第一件事就是设置SCB->VTOR = 0x08010000。或者在system_stm32f4xx.c里改VECT_TAB_OFFSET。两个地方改一个就行,但改VECT_TAB_OFFSET更彻底,因为它在SystemInit()里就被调用了,比main()更早。
5.3 现象:HTTP 响应头解析失败,Content-Length 读出来是 0
原因:服务器返回的响应头里,Content-Length的大小写可能不是标准的Content-Length,有些服务器返回content-length或者CONTENT-LENGTH。strstr是大小写敏感的,匹配不到。
解决:自己写一个大小写不敏感的字符串查找函数,或者把响应头先统一转成小写再查找。另外,有些服务器返回的是Transfer-Encoding: chunked,没有Content-Length,这种情况下要按 chunked 编码解析,不能直接读 Content-Length。
5.4 现象:固件搬运到 App 区域后,CRC 校验通过但程序跑不起来
原因:搬运过程中地址算错了,或者搬运的长度不对。比如缓存区的起始地址是 0x08084000,但实际下载的数据是从 0x08084000 + header_offset 开始的,搬运的时候把 HTTP 响应头也搬进去了。
解决:在下载回调里,要精确记录正文的起始偏移。如果响应头和正文在同一个 pbuf 里,要跳过头部长度再拷贝。搬运的时候,源地址是download_buffer + body_offset,目标地址是APP_ADDRESS,长度是content_length。搬运完成后,再对 App 区域做一次 CRC,和服务器给的比对,双重确认。
5.5 现象:设备在升级过程中断电,重新上电后变砖
原因:升级标志写入了,但固件没下载完,Bootloader 一启动就去下载,但网络又不通,卡在下载循环里。或者下载完了但搬运到一半断电,App 区域是半成品。
解决:升级标志要分状态。比如用两个字节:第一个字节表示“有升级请求”,第二个字节表示“下载完成”。Bootloader 启动时,如果只有第一个字节,说明下载没完成,清除标志,跳回旧 App。如果两个字节都有,才执行搬运和跳转。搬运过程中如果断电,下次启动时 App 区域的 CRC 对不上,Bootloader 要能检测到并回滚。
6. 进阶技巧:用 HTTP 头传递固件元数据与差分升级思路
前面讲的都是完整固件的下载和替换。实际产品里,固件包可能很大,每次升级都传完整包,流量和时间成本都高。这时候可以考虑差分升级:服务器端生成新旧固件的差分包,设备端下载差分包后,在本地和旧固件合并,生成新固件。差分算法可以用开源的 bsdiff 或者自己实现一个简单的块级差分。
但差分升级对 STM32 来说挑战不小:合并过程需要同时读取旧固件和新差分包,在 RAM 里做运算,再写回 Flash。STM32F4 的 128KB RAM 可能不够用。一个折中方案是:只对变化的部分做差分,比如只升级 App 里的某个模块,而不是整个 App。这需要你的 App 在设计时就支持模块化,每个模块有独立的版本号和存储区域。
另一个进阶方向是用 HTTP 头传递元数据。比如在响应头里加X-Firmware-Version、X-Firmware-CRC32、X-Firmware-Size,Bootloader 在下载之前先发一个 HEAD 请求,拿到这些元数据,判断是否需要升级、缓存区够不够大、CRC 预期值是多少。这样可以在下载之前就做一轮筛选,避免无谓的流量消耗。
// 发送 HEAD 请求获取固件元数据 int http_get_firmware_info(firmware_info_t *info) { // 构造 HEAD 请求 char request[256]; int len = snprintf(request, sizeof(request), "HEAD %s HTTP/1.1\r\n" "Host: %s:%d\r\n" "Connection: close\r\n" "\r\n", HTTP_PATH, SERVER_IP_STR, HTTP_SERVER_PORT); // 发送请求并解析响应头中的 X-Firmware-* 字段 // 解析逻辑和 GET 类似,只是不接收正文 // ... return 0; }这个 HEAD 请求的实现和 GET 几乎一样,区别在于服务器不会返回正文,所以Content-Length可能是 0,但自定义头字段X-Firmware-*会正常返回。解析这些字段的代码可以复用 GET 里的头部解析逻辑,只是不需要处理正文。
我自己的习惯是:在 Bootloader 里同时实现 HEAD 和 GET,先 HEAD 拿元数据,比对版本号和 CRC,如果和当前 App 一致就跳过升级,直接跳转。这样每次设备重启时,即使有升级标志,也不会盲目下载,省时省流量。这个习惯帮我省过好几次现场排查的时间——有一次设备反复重启,就是因为升级标志没清,每次启动都去下载同一个固件,加上网络慢,启动一次要等好几分钟。后来加了 HEAD 预检,问题就没了。
希望帮到你。
本文还有配套的精品资源,点击获取