STM32F407以太网IAP远程固件升级实战指南

发布时间:2026/9/12 12:58:26
STM32F407以太网IAP远程固件升级实战指南 简介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] 引脚决定常见配置是 0x01LAN8720A 固定为 0x00。lwIP 或 HAL 初始化时要用对 PHY 地址否则读不到 PHY IDHAL_ETH_ReadPHYRegister返回超时以太网 IAP 就卡死在第一步。第二是复位时序。PHY 的 nRST 引脚通常由 GPIO 控制上电后需要拉低至少 10ms 再释放然后等待 PHY 时钟稳定后才能访问寄存器。很多“仿真器跑通、独立运行不 link”的案例都是复位脉冲太短导致 PHY 状态机没完成初始化。简单做法是把 PHY 复位放到 ETH 初始化前单独用一个 50ms 延时兜底。DP83848 和 LAN8720 的差异参考下表对比项DP83848LAN8720A接口MIIRMIIPHY 地址由引脚配置常用 0x01固定 0x00参考时钟需外部 50MHz可由 MCO 提供 50MHz内核电压3.3V3.3V 或 1.2V内部 LDOBus 供电能力较强布线要求宽松对 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 晶振直接输入 PHYPHY 侧输出 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 位于 0x10000000CPU 可以直接访问但 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_SIZE和PBUF_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无操作系统模式不开 RTOSMEM_SIZE 和 PBUF_POOL_SIZE 是一对矛盾MEM_SIZE 太小tcp_write频繁返回 MEMP_ERR_MEMPBUF_POOL_SIZE 太小高负载下丢包率上升。Bootloader 场景没有并发连接只建立一个 TCP 监听端口8 个 pbuf 够用。NO_SYS1时 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 擦除以扇区为单位最小粒度是 16KBSector0App 区从 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 里自动把编译时间和版本号写进固件头从源头杜绝手改不一致。本文还有配套的精品资源点击获取