首页 / 资讯中心 / 文章详情

STM32 HTTP远程升级实战:Bootloader设计、Flash分区与避坑指南

STM32 HTTP远程升级实战:Bootloader设计、Flash分区与避坑指南 ★ FEATURED ARTICLE
简介这份资源面向嵌入式开发工程师与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 分区方案以 STM32F4071MB Flash为例我一般会这样分区域起始地址大小用途Bootloader0x0800000064KB启动、升级逻辑、HTTP 客户端App0x08010000448KB业务固件升级标志页0x0808000016KB存放升级请求标志和固件信息下载缓存区0x08084000496KB存放从 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: bytes0-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[i1] \n buf[i2] \r buf[i3] \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, sector_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 的中断向量表默认在 0x08000000App 在 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-SizeBootloader 在下载之前先发一个 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 预检问题就没了。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站