STM32F407以太网IAP远程固件升级实战指南
2026/9/14 17:45:44 网站建设 项目流程

简介:STM32F4x7以太网IAP升级工程是一份面向嵌入式开发者的固件在线升级参考实现,围绕STM32F407的高性能Cortex-M4平台,完整演示了如何通过以太网与TCP/IP协议栈完成远程固件更新。压缩包共476个文件,以C源码和头文件为主,包含lwIP协议栈、STM32标准外设库、ETH驱动、Bootloader相关工程配置、编译脚本及说明文档;另含bin/hex/axf等固件产物,便于直接烧录验证。已有504人学习下载,适合正在研究IAP机制、Bootloader设计或网络固件升级的嵌入式工程师参考。内容涵盖固件分区规划、以太网接口配置、协议栈移植、升级流程与错误恢复等关键模块,可作为从零搭建STM32F407远程升级方案的起步模板。

1. 在线升级的方式很多,为什么单把以太网 IAP 拎出来

升级 STM32F407 固件最常见的做法是接串口线、开串口助手、按住 Boot 引脚重启,最后祈祷断电不发生在擦除到一半的时候。工业设备、传感器网关、无人值守机柜里的控制板,走这套流程的成本远高于芯片本身的价格。以太网 IAP 把固件下发从物理接触变成网络请求,Bootloader 通过 ETH 和协议栈接收固件包,校验后写入 App 区并跳转,整个过程不需要拆机、不需要额外工具,甚至可以通过 Web 页面远程触发。对联网的 STM32F4x7 设备来说,这是一条值得优先考虑的生产级升级链路。

这篇博文围绕 STM32F407 以太网 IAP 从架构到落地展开:先讲 Flash 分区与 Bootloader 边界,再给出 CubeMX 下 ETH 和 lwIP 的最小工程,然后讨论固件包校验与 Flash 写入,最后是 PHY 调试和回滚方案。适合做产品维护和远程升级的嵌入式工程师,也适合准备把网络功能嵌进存量 F407 项目的开发者。

2. Flash 分区、PHY 选型与跳转:IAP 不出问题的前置设计

2.1 Flash 分区规划:Boot 区、App 区与参数区的边界

IAP 的本质是“程序自己改写自己所在的 Flash”。STM32F407 的单 bank Flash 共 1MB,从 0x08000000 开始按扇区分成 4×16KB、1×64KB、7×128KB。Bootloader 和 App 必须落在互不重叠的扇区里。常见做法的分区如下:

区域起始地址大小用途
Bootloader0x0800000064KB网络初始化与 IAP 逻辑
App0x08010000896KB业务固件
参数区0x080FF0004KB升级标记、当前版本号

这里把 Bootloader 设为 64KB,正好占据前两个 16KB 扇区和一个 32KB 扇区。对 ETH 驱动加轻量协议栈来说绰绰有余,未来要加 HTTP 服务器也不至于动分区。App 从 0x08010000 开始,中断向量表、链接脚本、编译选项都要跟着改。参数区放升级状态机用的标记,比如“本次升级是否完成”“App CRC 是否有效”,避免把状态存到极易误擦的 App 区头部。

Flash 分区方案一旦定下来,硬件上就锁死了,后期改动成本极高。改链接脚本时要注意:App 工程的FLASH起始地址、RAM起始地址可以不变,但中断向量表偏移必须与分区一致。如果 Bootloader 和 App 用了不同的编译工具链,还要确认各自对.isr_vector段的对齐要求。

2.2 中断向量表重映射与跳转:一个函数解决 App 首启动崩溃

Bootloader 的本质是“先执行、再跳转”。跳转不是简单调用一个函数,而是把栈指针、PC 指针、中断向量表基址全部切换到 App。最容易犯的错误是只重新赋值 VTOR 忘了关闭全局中断——跳转前的以太网中断挂起状态会被 App 继承,导致 App 的 NVIC 行为完全错乱。一个能直接用的跳转函数如下:

typedef void (*FunctionPtr)(void); static void JumpToApp(uint32_t app_addr) { uint32_t stack_addr = *(volatile uint32_t *)app_addr; uint32_t reset_addr = *(volatile uint32_t *)(app_addr + 4); FunctionPtr jump_fn = (FunctionPtr)reset_addr; HAL_RCC_DeInit(); /* 还原时钟树 */ SysTick->CTRL = 0; /* 停掉 SysTick 中断 */ __disable_irq(); /* 全局关中断 */ SCB->VTOR = app_addr; /* 重映射中断向量表 */ __set_MSP(stack_addr); /* 设置主栈指针 */ jump_fn(); /* 跳转 */ }

逻辑顺序是固定的:先读 App 头部前 4 字节作为栈顶地址,再读第 5~8 字节作为复位向量,然后关闭外设时钟、关闭全局中断、搬移 VTOR、设置 MSP,最后通过函数指针跳转。参数说明里最容易被忽略的是HAL_RCC_DeInit()——如果 Bootloader 里初始化过 ETH 或 lwIP,时钟树不还原的话 App 里 PLL 配置会和当前硬件状态对不上,SystemInit 之后外设全乱。

提示:App 编译时必须在链接脚本里把FLASH起始地址改成 0x08010000,否则 App 的中断向量表编译出来永远指向 0x08000000,跳过去必跑飞。

跳转之后,App 侧的SystemInit会重新配置时钟,但SCB->VTOR已经被 Bootloader 设置好,App 不需要再改。如果 App 用的是标准库而非 HAL,需要在system_stm32f4xx.c里把VECT_TAB_OFFSET定义为 0x10000,效果相同。

2.3 以太网 PHY 芯片差异:DP83848 与 LAN8720 的接线和复位时序

以太网 IAP 的硬件前提是 PHY 能正常 link。STM32F407 内置 MAC,不集成 PHY,必须外接。常用 PHY 有 TI 的 DP83848 和国产替代较多的 LAN8720A。两者的核心差别在接口模式:DP83848 走 MII,时钟由外部 50MHz 晶振或 F407 的 MCO 输出提供;LAN8720A 走 RMII,只需要 50MHz 参考时钟。实际项目中两个点最容易踩坑。

第一是 PHY 地址。DP83848 的地址由 PHYAD[4:0] 引脚决定,常见配置是 0x01;LAN8720A 固定为 0x00。lwIP 或 HAL 初始化时要用对 PHY 地址,否则读不到 PHY ID,HAL_ETH_ReadPHYRegister返回超时,以太网 IAP 就卡死在第一步。

第二是复位时序。PHY 的 nRST 引脚通常由 GPIO 控制,上电后需要拉低至少 10ms 再释放,然后等待 PHY 时钟稳定后才能访问寄存器。很多“仿真器跑通、独立运行不 link”的案例,都是复位脉冲太短导致 PHY 状态机没完成初始化。简单做法是把 PHY 复位放到 ETH 初始化前,单独用一个 50ms 延时兜底。DP83848 和 LAN8720 的差异参考下表:

对比项DP83848LAN8720A
接口MIIRMII
PHY 地址由引脚配置,常用 0x01固定 0x00
参考时钟需外部 50MHz可由 MCO 提供 50MHz
内核电压3.3V3.3V 或 1.2V(内部 LDO)
Bus 供电能力较强,布线要求宽松对 REF_CLK 质量敏感

3. CubeMX 下最小化 ETH + lwIP 工程:从引脚到 TCP 服务能连通

3.1 RMII 引脚与 50MHz 参考时钟:REF_CLK 不能随便接

以 LAN8720A 为例,RMII 接口信号包括 TXD[1:0]、RXD[1:0]、TX_EN、CRS_DV、MDIO、MDC 和 REF_CLK。STM32F407 的 RMII 有两种时钟源:一是外部 50MHz 晶振直接输入 PHY,PHY 侧输出 REF_CLK;二是 STM32 的 MCO1 引脚输出 50MHz 给 PHY。第二种方式要注意 MCO1 是 PA8,必须把 PA8 复用为 MCO1,并在 RCC 里配置 MCO1 时钟源为 PLLR 分频后的 50MHz。PA8 同时又是 USB OTG 的 VBUS 检测脚,复用功能冲突时容易莫名其妙连不上网。

CubeMX 里配置 ETH 时选择 RMII,勾选 TXD0/TXD1/RXD0/RXD1/TX_EN/CRS_DV/MDIO/MDC 引脚,再在RCC -> MCO1中使能并选定 50MHz 输出,最后回到时钟树确认 MCO1 输出恰好 50MHz。部分开发板要求 PA8 串 33 欧姆电阻抑制过冲,如果按 PHY 数据手册要求布线,直连也能工作。有一个容易被忽略的点:RMII 的 CRS_DV 信号在 F407 上必须接到 PA7,而 PA7 同时可能是 SPI1_MOSI 或 ADC 的输入,外设复用冲突时 CubeMX 会提示,但部分旧版本 HAL 库不会自动处理。

3.2 lwIP 内存参数:Bootloader 只有 192KB SRAM,怎么省

STM32F407 的 SRAM 分三块:112KB 主 RAM、16KB 备份 RAM、64KB CCRAM。以太网 IAP 的 Bootloader 里跑 lwIP,最紧张的不是 CPU 而是内存。ETH 的 DMA 描述符和接收缓冲区如果全部放在主 RAM,一个 TCP 连接加几个 pbuf 就能吃掉 30KB。常见的做法是把 ETH DMA 描述符和 DMA 接收缓冲区放到 CCRAM 里。

CCRAM 位于 0x10000000,CPU 可以直接访问,但 DMA1/DMA2 是访问不到 CCRAM 的。ETH 外设的 DMA 控制器自带访问 CCRAM 的能力,所以把描述符放进去没问题。CubeMX 生成的ethernetif.c里默认描述符数组定义在ETH_DMADescTypeDef类型下,可以通过__attribute__((at(0x10000000)))或链接脚本的 SECTION 语法放到 CCRAM:

__attribute__((section(".ccmram"))) ETH_DMADescTypeDef DMARxDscrTab[ETH_RX_DESC_CNT]; __attribute__((section(".ccmram"))) ETH_DMADescTypeDef DMATxDscrTab[ETH_TX_DESC_CNT]; __attribute__((section(".ccmram"))) uint8_t Rx_Buff[ETH_RX_DESC_CNT][ETH_MAX_PACKET_SIZE];

这样省下的 20~30KB 主 RAM 足够给 lwIP 的MEM_SIZEPBUF_POOL使用。CubeMX 生成的链接脚本里如果没有.ccmram段,需要在.ld文件的 SECTIONS 里手动加。另一个细节是:CCRAM 不支持 DMA1/DMA2 访问,如果 lwIP 的校验和计算交给硬件加速,数据直接从 ETH DMA 送到内存,不受影响;但如果用软件校验,要注意 pbuf 的数据指针可能指向 CCRAM,库函数访问没问题。

lwIP 在 CubeMX 配置页里最关键的四项是 MEM_SIZE、PBUF_POOL_SIZE、TCP_MSS、TCP_WND:

参数推荐值作用
MEM_SIZE40*1024lwIP 堆上限,控制总内存开销
PBUF_POOL_SIZE8接收/发送 pbuf 池数量
TCP_MSS1024单个 TCP 报文最大分段
TCP_WND4*TCP_MSS窗口大小,决定吞吐
NO_SYS1无操作系统模式,不开 RTOS

MEM_SIZE 和 PBUF_POOL_SIZE 是一对矛盾:MEM_SIZE 太小,tcp_write频繁返回 MEMP_ERR_MEM;PBUF_POOL_SIZE 太小,高负载下丢包率上升。Bootloader 场景没有并发连接,只建立一个 TCP 监听端口,8 个 pbuf 够用。NO_SYS=1时 lwIP 不建线程,所有协议栈跑在 ETH 中断和周期性调用tcp_tmr()的上下文里。

3.3 初始化序列:PHY 复位、netif 注册、TCP 监听

无 OS 模式下初始化顺序是:配置时钟 -> 复位 PHY -> HAL_ETH_Init -> 配置 MAC 地址 -> 设置 lwIP 网络接口 -> 启动静态 IP -> 注册 TCP 回调。下面给出一段能直接在main()里调用的初始化骨架:

static void EthIapInit(void) { /* PHY 复位:nRST 引脚,低有效,拉低 50ms */ HAL_GPIO_WritePin(PHY_RST_GPIO_Port, PHY_RST_Pin, GPIO_PIN_RESET); HAL_Delay(50); HAL_GPIO_WritePin(PHY_RST_GPIO_Port, PHY_RST_Pin, GPIO_PIN_SET); HAL_Delay(50); /* lwIP 初始化:必须先于 netif_add */ tcpip_init(NULL, NULL); IP4_ADDR(&ip, 192, 168, 1, 50); IP4_ADDR(&mask, 255, 255, 255, 0); IP4_ADDR(&gw, 192, 168, 1, 1); netif_add(&netif, &ip, &mask, &gw, NULL, ethernetif_init, tcpip_input); netif_set_default(&netif); netif_set_up(&netif); IapTcpServerStart(); }

这段代码的关键逻辑是先做 PHY 物理复位,再初始化 lwIP,最后注册网络接口。静态 IP 适合 Bootloader 场景,因为升级主机可以直接访问固定地址;如果产品网络里没有 DHCP 服务,静态 IP 是唯一可靠选择。IP 地址建议做成宏定义,与 App 区保持一致,避免升级工具维护两套地址。

TCP 监听端的代码同样简单:

static void IapTcpServerStart(void) { struct tcp_pcb *pcb = tcp_new(); tcp_bind(pcb, IP_ADDR_ANY, 6688); pcb = tcp_listen(pcb); tcp_accept(pcb, IapTcpAcceptCallback); }

服务端口建议避开常用端口,选产品自己的固定端口,比如 6688。接受连接后,在IapTcpAcceptCallback里用tcp_recv注册接收函数。每次tcp_recv_fn被调用,数据就放在 pbuf 里,用完必须pbuf_free,否则内存泄漏积累到一定程度会让升级中断。

4. 固件下发的完整链路:固件包校验、Flash 擦写与跳转

4.1 传输协议选型:TFTP 的轻量与 HTTP 的便利

以太网 IAP 的传输层有两条主流路径:TFTP 和 HTTP。TFTP 基于 UDP,实现简单、代码量小,Bootloader 里不到 2KB 额外代码,且固件包超过一定大小后不需要在内存中缓存——TFTP 服务端按 512 字节块发送,收到一块就写一块 Flash。HTTP 的优势在于升级终端是浏览器,可以用 Web 页面点选文件、看进度,适合带交互界面的产品。

TFTP 的坑在于 UDP 没有确认重传和排序保证,包顺序由 TFTP 层的 ACK 控制。文件总大小超过 32MB 时需要适应块号回绕,但 F407 的 App 区最大 896KB,根本到不了这个极限。HTTP 方案如果不想移植完整的 HTTP 服务器,也可以只在 Bootloader 里实现一个极简的 POST 端点,接收multipart/form-data形式的字段,但处理分块编码会让 Bootloader 代码膨胀到 90KB 以上。对大多数嵌入式设备,选 TFTP 更稳妥——Bootloader 只监听一个固定端口,升级软件写一次,后续不用维护。

4.2 固件包结构:版本、长度、魔数、CRC32

不管用 TFTP 还是 HTTP,固件文件本身的结构比传输协议重要。裸编译出的.bin文件没有校验信息,接收端无法确认完整性。常见的做法是在 .bin 前加一个 32 字节的文件头,格式如下:

typedef struct __attribute__((packed)) { uint32_t magic; /* 0xAA55AA55 固定魔数 */ uint32_t version; /* 固件版本号,单调递增 */ uint32_t total_len; /* 文件头之后的固件长度 */ uint32_t crc32; /* 固件数据的 CRC32 */ uint32_t boot_addr; /* 跳转地址,默认 0x08010000 */ uint8_t reserved[8]; /* 保留 */ } fw_file_header_t;

接收端收到第一个数据块时解析头部,先检查 magic 是否符合,不符合直接断开连接。随后用 total_len 判断固件容量是否超过 App 分区剩余空间,通过后再开启逐步接收。CRC32 可以使用硬件 CRC 外设计算:

uint32_t calc_crc32(uint8_t *buf, uint32_t len) { __HAL_RCC_CRC_CLK_ENABLE(); CRC->CR |= CRC_CR_RESET; for (uint32_t i = 0; i < len; i++) { *(volatile uint8_t *)&CRC->DR = buf[i]; /* 按字节写入 */ } return CRC->DR; }

使用硬件 CRC 时需要注意字节序。STM32 的 CRC 外设按小端序读输入,对一个 32 位字写入时最低有效字节先参与计算;如果 PC 端生成的 CRC 是按大端字节流算出来的,结果会不一致。常见处理方法是 PC 端按小端序排布数据,或 MCU 端按对齐的 32 位字读取固件数据后逐字节喂给外设。PC 端制作固件包的脚本用 Python 或 C 都行,核心逻辑是把目标 .bin 文件加上文件头,再追加 CRC 值。

4.3 Flash 擦除与写入:扇区对齐是硬约束

写入 Flash 前先要擦除目标扇区。F407 的 Flash 擦除以扇区为单位,最小粒度是 16KB(Sector0),App 区从 Sector2 开始,大小按 128KB 对齐。接收固件时并不知道要覆盖哪些扇区,因此最稳妥的做法是接收前先把整个 App 区擦掉,再逐块写入。注意擦除接口的返回值,HAL_FLASHEx_Erase返回不是HAL_OK就立即停止接收,避免写入半成品。

static int EraseAppSectors(void) { FLASH_EraseInitTypeDef erase; uint32_t sector_error = 0; erase.TypeErase = FLASH_TYPEERASE_SECTORS; erase.Sector = FLASH_SECTOR_2; /* 从 Sector2 起 */ erase.NbSectors = 6; /* 覆盖到 Sector7,共 768KB */ erase.VoltageRange = FLASH_VOLTAGE_RANGE_3; HAL_FLASH_Unlock(); if (HAL_FLASHEx_Erase(&erase, &sector_error) != HAL_OK) { HAL_FLASH_Lock(); return -1; } HAL_FLASH_Lock(); return 0; }

写入时每次编程一个 32 位字,这是 Flash 编程中最细粒度、最容易保证对齐的方式。FLASH_TYPEPROGRAM_WORD模式下,一次HAL_FLASH_Program需要传入 32 位对齐的目标地址和 32 位数据,不能按字节写入。

static void WriteFirmware(uint32_t flash_addr, uint8_t *data, uint32_t len) { uint32_t addr = flash_addr; 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) { break; } } HAL_FLASH_Lock(); }

每次HAL_FLASH_Program调用前不需要重新擦除,因为目标扇区在接收前已经整体擦过。如果 TFTP 传输中途失败,已经写入的半个固件会留在 Flash 里,但因为有 CRC 校验和升级标记,Bootloader 不会跳转到残废的 App。

4.4 TCP 块确认与最终校验:边收边写还不够

TFTP 每收到 512 字节就要回一个 ACK,否则对端会一直重发。在这个流程里,收到一包数据后先写入 Flash,再回 ACK。如果先回 ACK 再写 Flash,对端发了下一包,而 Flash 写入还在忙,缓冲会溢出。写入 Flash 本身是阻塞的,单次 512 字节写入耗时在毫秒级,TFTP 的重传定时器默认 5 秒,完全来得及。

固件写完后不要立刻跳转。电源抖动可能让最后几个扇区的数据损坏,而 Flash 编程对写入地址的校验并不保证数据内容正确。稳妥做法是边接收边计算 CRC,写入完成后读回 Flash 数据再算一次 CRC,与固件头里的 crc32 比对;只有两次 CRC 一致才执行跳转。

uint32_t calc_flash_crc(uint32_t start_addr, uint32_t len) { __HAL_RCC_CRC_CLK_ENABLE(); CRC->CR |= CRC_CR_RESET; uint32_t *p = (uint32_t *)start_addr; for (uint32_t i = 0; i < len / 4; i++) { *(volatile uint8_t *)&CRC->DR = (uint8_t)(p[i] & 0xFF); *(volatile uint8_t *)&CRC->DR = (uint8_t)((p[i] >> 8) & 0xFF); *(volatile uint8_t *)&CRC->DR = (uint8_t)((p[i] >> 16) & 0xFF); *(volatile uint8_t *)&CRC->DR = (uint8_t)((p[i] >> 24) & 0xFF); } return CRC->DR; }

这段代码按小端序把 Flash 的每个 32 位字拆成 4 字节喂给 CRC 外设,与 PC 端生成的字节流 CRC 保持一致。CRC 通过后,把“升级完成”标记写入参数区,然后调用跳转函数。如果 CRC 不通过,保留旧固件内容不动,等待下一次升级请求,这就是 Bootloader 天然的回滚保护。

5. 升级可靠性的细节:回滚、错误上报与发送失败定位

5.1 错误码上抛与回滚标记

TFTP 协议本身有 ERROR 报文,用来通知客户端升级失败原因。把错误码定义成与业务含义绑定的枚举,让升级工具能直接展示“Flash 擦除失败”而不是“网络错误”。常见的错误码分组实现如下:

typedef enum { IAP_ERR_NONE = 0, IAP_ERR_MAGIC, /* 魔数不对 */ IAP_ERR_SIZE, /* 固件超长 */ IAP_ERR_CRC, /* CRC32 不匹配 */ IAP_ERR_ERASE, /* Flash 擦除失败 */ IAP_ERR_WRITE /* Flash 写入失败 */ } iap_error_t;

升级失败时,把错误码写入参数区的升级标记字。Bootloader 启动时先读这个标记,如果发现上次升级失败,就停留在等待升级的状态,而不是直接跳转到 App。这样即使设备在升级过程中断电,重新上电后仍会进入 Bootloader,等待新的固件包,而不是卡在损坏的 App 里。升级成功的标记要在 CRC 通过之后再写,不能提前写。

5.2 发送失败 “eth transmit frame faild: 20” 这类问题怎么看

调试中出现类似eth transmit frame faild: 20的报错,第一反应不应该是查驱动代码,而是确认链路状态。这个错误发生在调用以太网驱动发送函数后,DMA 无法正常发出帧。常见原因有三个:PHY 没有 link 上;TX 描述符环形缓冲区被占满;MAC 配置的帧长度和实际发送内容不符。其中 PHY 不 link 占八成。

link 状态的检测必须周期执行。lwIP 的 netif 链接状态不会自动更新,要周期性读取 PHY BMSR 寄存器同步:

void EthernetLinkPoll(void) { uint32_t reg = 0; HAL_ETH_ReadPHYRegister(&heth, PHY_BSR, &reg); if (reg & PHY_LINKED_STATUS) { netif_set_link_up(&netif); } else { netif_set_link_down(&netif); } }

每 500ms 调用一次EthernetLinkPoll。如果 BMSR 始终为 0,优先查 PHY 复位引脚电平和 REF_CLK 有无时钟输出,再用示波器看 CRS_DV 和 TX_EN 是否有信号翻转。链路起来后,发送失败基本只剩 TX 描述符耗尽的情况,检查发送回调里tcp_output之后有没有及时pbuf_free,以及是否是长数据包把 DMA 缓冲区耗尽了。

5.3 版本回退控制:从 Bootloader 侧拦截降级

生产环境最怕的不是升级失败,而是升级到一半因为需求变更要回退固件版本。设计固件包头时把版本号放进去,Bootloader 在升级前检查新固件版本号是否大于当前 App 版本号;不大于就直接拒绝。这个逻辑看似简单,却能在量产时避免“忘改版本号导致误降级”的脏数据问题。版本号用单调递增的数值型,不要用日期字符串比较,日期跨时区、补丁版本讨论不清的时候,数值比较最简单可靠。每次发布固件时在 CI 里自动把编译时间和版本号写进固件头,从源头杜绝手改不一致。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询