
简介面向STM32F407嵌入式开发者的以太网UDP通信工程包以主机发送与接收程序为主线演示了EMAC以太网控制器、PHY芯片如LAN8720及lwIP协议栈的协同配置适用于物联网设备、工业数据采集和远程控制等网络应用场景。压缩包共506个文件约7.65MB以C源码和头文件为核心含148个.h、127个.c同时包含编译生成的目标文件、调试映射、链接脚本及Keil工程配置文件便于直接打开工程对照学习另有部分说明文档和辅助脚本可帮助快速理清项目结构。已有3679人学习下载。程序实现了UDP套接字创建、绑定、收发与关闭等关键流程深入涉及DMA传输、中断配置以及内存管理通过分析代码可掌握网络事件处理、UDP报文封装解析和基于STM32硬件加速的性能优化方法为在嵌入式平台上高效搭建网络服务提供了完整可参考的基础框架值得开发者用于实战演练与二次开发。1. 为什么要自己写一套F407的以太网UDP主机程序很多人手里都有一块F407开发板串口调腻了想把数据搬到以太网上但一进到 STM32 以太网 UDP server 的调试就卡住裸机样例能跑加上实时系统后开始丢包PHY 换了一个型号寄存器读不到UDP 包发送函数调用成功上位机就是没反应。这套 F407 以太网 UDP 主机发送接收程序把 PHY 初始化、LWIP 协议栈裁剪、UDP PCB 创建、收发回调、上位机联调压测完整串在一起解决的不只是“通不通”而是把板子变成一个可靠、可控、能自己改的 UDP 网络节点。适合手里有一定基础、想用有线网替代串口做数据上送或指令下发的嵌入式开发者也适合第一次碰 LWIP 的人照着把工程搭起来。2. 链路分层F407 的 MAC、PHY 与 LWIP 是怎么连起来的以太网不像串口那样一根线搞定F407 内部只集成了 MAC 控制器物理层收发要靠外部 PHY 芯片LWIP 则跑在 MAC 之上负责 IP 层和 UDP/TCP 协议处理。调试时出现的问题百分之八十出在这三层的接口上引脚被复用、时钟没给对、配置的 MAC 地址全零、LWIP 的内存池太小。所以在动业务代码之前先按数据链路、管理链路、协议栈三条线把底摸一遍。2.1 RMII 模式下的引脚、时钟与初始化顺序F407 的以太网支持 MII 和 RMII 两种物理接口。MII 要十几根信号线RMII 把数据线压缩到两根控制线也少开发板上基本都用 RMII。RMII 的关键是 REF_CLK 必须是精确的 50MHz来源有两种常见做法一是外部有源晶振直接给 PHY 和 MCU 同时供时钟二是 PHY 芯片自己输出 50MHz 给 MCU。很多板子用的是第二种此时初始化顺序很重要——先把 PHY 的复位脚拉释放等待时钟稳定再配置 MCU 的 RMII 引脚顺序反了会出现 MAC 寄存器能写、但 link 状态始终 down 的怪问题。引脚配置上F407 的以太网外设复用功能是 GPIO_AF11_ETHRMII 模式占用 PA1REF_CLK、PA2MDIO、PA7CRS_DV、PC4RXD0、PC5RXD1、PG11TX_EN、PG13TXD0、PG14TXD1和 PG15MDC。不同板子引脚会从这组里改但复用功能号基本一致。初始化代码里最需要注意的是把速度拉到最高档否则高速数据线上的边沿会变得很难看void ETH_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOC_CLK_ENABLE(); __HAL_RCC_GPIOG_CLK_ENABLE(); // RMII 数据线与控制线具体引脚以板子原理图为准 GPIO_InitStruct.Pin GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_7; GPIO_InitStruct.Mode GPIO_MODE_AF_PP; GPIO_InitStruct.Pull GPIO_PULLUP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_VERY_HIGH; GPIO_InitStruct.Alternate GPIO_AF11_ETH; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); GPIO_InitStruct.Pin GPIO_PIN_4 | GPIO_PIN_5; HAL_GPIO_Init(GPIOC, GPIO_InitStruct); GPIO_InitStruct.Pin GPIO_PIN_11 | GPIO_PIN_13 | GPIO_PIN_14 | GPIO_PIN_15; HAL_GPIO_Init(GPIOG, GPIO_InitStruct); }PA1 这个脚极容易踩坑有些评估板把它同时引到定时器输入或 ADC如果你在别的外设初始化里把 PA1 改成普通 IO 或第二复用功能以太网就收不到任何时钟。调试时先用示波器或逻辑分析仪看 PA1如果是 MCU 输入模式有没有 50MHz 方波这一步能排除一半以上的“PHY 读不到 ID”问题。RMII 模式下的数据线是双沿采样100Mbps 信号下时钟只有 50MHz对布线要求不像 MII 那样苛刻但这不代表可以乱飞线。我一般会把 REF_CLK 单独走一根短线不要和 TX_EN 挨着否则近距离干扰会让偶发包 CRC 错误。2.2 通过 MDIO 读 PHY 寄存器确认物理层真的起来了PHY 芯片的类型五花八门但管理接口都遵循 IEEE 802.3 定义的 MDIO/MDC 协议寄存器布局也是标准的。读寄存器 2 和 3 能拿到厂商 ID 和设备 ID读寄存器 1BMSR能拿到自协商状态位。判断 PHY 是否就绪最可靠的不是靠 GPIO 拉高拉低而是轮询 BMSR 的 bit 5这个位表示“自协商是否完成”。刚上电时 PHY 自动进入自协商约 2 到 3 秒后完成。如果直接读 BMSR 就判断链路已通往往看到的是 bit 5 还没置位。常见的处理是复位后延时再循环读取并加超时uint8_t ETH_PHY_WaitAutoNegotiation(ETH_HandleTypeDef *heth, uint8_t phy_addr) { uint32_t reg_value 0; uint32_t retry 0; // 标准管理寄存器 1 是 BMSRbit5 表示自协商完成 while (retry 100) { if (HAL_ETH_ReadPHYRegister(heth, phy_addr, 0x01, reg_value) ! HAL_OK) { return 0; } if (reg_value 0x0020) { return 1; } HAL_Delay(10); retry; } return 0; }这里的 phy_addr 不是随便定的。PHY 芯片的地址由芯片的 PHYAD 引脚电平决定常见是 0x00 到 0x1F 之间。很多板厂在原理图上把 PHYAD 引脚固定成某个体值实际地址要看原理图标注不要想当然认为是 0。如果读寄存器返回全 1说明地址错了或者 MDIO 引脚配置有问题。我在调试时一般会先写一个小函数扫描 32 个地址找到能正确读到 ID 的那个再把它固化成宏。这一步看起来多余却能避免“软件明明一样换块板子就死”的玄学问题。2.3 LWIP 协议栈裁剪NO_SYS、PBUF 池与 UDP PCB 数量LWIP 可以跑在裸机上也可以跑在 RTOS 上。在裸机环境下把NO_SYS置为 1LWIP 内部不创建线程所有处理靠周期调用ethernetif_input和sys_check_timeouts驱动。在 FreeRTOS 环境下更推荐NO_SYS置为 0让 LWIP 自己运行在 tcpip_thread 线程用户的应用程序通过 API 与它交互。最影响 UDP 收发稳定性的配置项是 PBUF 池。PBUF 是 LWIP 处理报文的基本内存单元接收路径从 DMA 描述符里取数据时需要从 PBUF_POOL 里分配内存。如果池子太小高负载下分配失败包就会被静默丢掉。lwipopts.h里这几个配置直接决定 UDP 能扛多大压力配置项裸机推荐RTOS 推荐说明NO_SYS10无操作系统模式MEM_SIZE16004096内存堆大小影响连接数PBUF_POOL_SIZE1632接收缓冲池数量越大越抗突发PBUF_POOL_BUFSIZE15241524单个缓冲能装下最大以太网帧MEMP_NUM_UDP_PCB48最多同时创建的 UDP 控制块我习惯把PBUF_POOL_BUFSIZE保持在 1524这是 1500 字节 MTU 加 14 字节以太网头、20 字节 IP 头、8 字节 UDP 头后的余量。如果改成 2048 虽然也能跑但内存占用多出不少对低频数据上报来说是浪费。MEMP_NUM_UDP_PCB每加一个RAM 多消耗约一百多字节做单路数据上报时 4 足够扩展多个端口再加。3. UDP 收发代码路径从 PCB 创建到数据拷贝的完整实现UDP 服务端的核心概念是 Protocol Control BlockLWIP 用struct udp_pcb描述一个 UDP 端点。创建它、绑定端口、注册回调之后内核收到匹配的四元组数据就会自动调用你注册的函数。这一步非常简单但有不少环境相关细节回调里能不能延时能不能调用 printf能不能分配内存答案都和NO_SYS的取值有关。3.1 创建 UDP 控制块并绑定本地端口#include lwip/udp.h static struct udp_pcb *udp_server_pcb; void UdpServer_Init(uint16_t local_port) { err_t err; // 申请一个 UDP 控制块 udp_server_pcb udp_new(); if (udp_server_pcb NULL) { // 这里一般是 MEMP_NUM_UDP_PCB 配置太小导致分配失败 return; } // 绑定本地端口IP 地址用 IP_ADDR_ANY 表示不关心是哪块网卡 err udp_bind(udp_server_pcb, IP_ADDR_ANY, local_port); if (err ! ERR_OK) { udp_remove(udp_server_pcb); udp_server_pcb NULL; return; } // 注册收包回调收到数据后自动进入 UdpServer_RecvCallback udp_recv(udp_server_pcb, UdpServer_RecvCallback, NULL); }udp_new返回空指针时常规检查方向有三个MEMP_NUM_UDP_PCB太小、堆内存耗尽、当前在中断上下文里调用了这个函数。裸机环境下如果在以太网接收中断里初始化 UDP PCB大概率失败应该在主循环开始前做。udp_bind的第二个参数用IP_ADDR_ANY时会绑定到所有网络接口如果 F407 上有多个 netif而你只想在某个接口上收这里要传入该接口的 IP。3.2 接收回调快速拷贝不要在回调里做慢操作LWIP 收到 UDP 报文后先由 netif 层把数据放进 pbuf 链然后协议层根据端口号找到对应的udp_pcb最后调用回调。pbuf 的内存来源是 PBUF_POOL属于内核共享资源回调返回后内核会回收。因此回调里只做两件事把数据拷贝到自己的应用缓冲然后释放 pbuf。static uint8_t rx_buffer[2048]; static void UdpServer_RecvCallback(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port) { uint16_t copy_len; if (p NULL) { return; } // tot_len 是整条消息长度注意多 pbuf 链表的情况 copy_len p-tot_len; if (copy_len sizeof(rx_buffer)) { pbuf_free(p); return; } // 把数据完整拷贝到线性内存方便后续解析 pbuf_copy_partial(p, rx_buffer, copy_len, 0); pbuf_free(p); // 这里可以做一个简单的回环测试 UdpServer_SendPacket(rx_buffer, copy_len, addr, port); }pbuf_copy_partial相比自己写循环遍历p-payload更稳它内部处理了 pbuf 链的跨段切换。有人在回调里不回发而是置一个全局标志位等主循环去处理这种设计也是对的但要注意主循环的处理频率必须高于包的到达率否则缓冲区会覆盖。参数里的addr和port是发送方的地址和端口回包时直接使用它们不需要额外记录。3.3 主动发送三种发送机制和内存释放时机LWIP 的 UDP 发送接口有三个常用选择udp_send发给 bind 时绑定的远端、udp_sendto发往任意指定地址、udp_sendto_if额外指定从哪个网卡发出。对于主机主动上报的场景udp_sendto用得最多。一个隐蔽的坑点是 pbuf 与用户缓冲的关系。如果把一个用户自定义数组成员直接包进 pbuf 再发给udp_sendtoLWIP 在低层发送时不会等待但发送完成后访问已释放是不安全的。稳妥做法是先分配 PBUF_RAM拷贝数据再调用发送void UdpServer_SendPacket(const uint8_t *data, uint16_t len, const ip_addr_t *dst_ip, uint16_t dst_port) { struct pbuf *p; err_t err; // PBUF_TRANSPORT 表示从 UDP 头开始构建预留了 IP 和 MAC 头空间 p pbuf_alloc(PBUF_TRANSPORT, len, PBUF_RAM); if (p NULL) { return; } memcpy(p-payload, data, len); err udp_sendto(udp_server_pcb, p, dst_ip, dst_port); pbuf_free(p); if (err ! ERR_OK) { // 发送失败大概率是 ARP 未解析完成或网线断开 // 可以在这里记录一个计数供主循环上报 } }这里我用 PBUF_RAM 而不是 PBUF_POOL是因为 PBUF_RAM 能确保 pbuf 内存长度任意、连续适用于动态拼接的上报数据。PBUF_POOL 分段的特性适合接收路径发送路径用起来容易在memcpy时出现跨段处理麻烦。PBUF_TRANSPORT告诉 LWIP 在数据前面预留多少头部空间默认已经包含 UDP 头、IP 头和以太网头不用手动偏移。发送失败时最常见的现象就是目标 IP 的 ARP 没有缓存udp_sendto会返回 ERR_USE 或 ERR_ARG此时重试几次即可不要一失败就重建 PCB。4. 与 PC 端联调静态 IP、端口绑定与收发压测代码编译通过只是第一步真正的问题在板子和电脑连上网线后才暴露。联调时如果一上来就开业务协议很难判断是网络层问题还是应用层问题。我的习惯是先做最原始的 UDP 回环测试上位机发什么板子原样回什么通了以后再逐步替换成真实应用数据。4.1 静态 IP 设置与网络调试助手验证STM32 的以太网 MAC 地址可以随意设置但要注意不能用全 0也不能和局域网内其他设备重复。IP 地址如果走 DHCP板子启动会有 2 到 5 秒等待对于固定设备不如直接静态 IP。联调时常用配置是板子192.168.1.10PC 设置192.168.1.100子网掩码一致。端口号建议避免使用常见的 7、9、69 等选一个 1024 以上的自定义端口比如 6000因为有些上层协议会占用特定低端口。打开网络调试助手建立一个 UDP server监听本地端口 6000。板子上电后主动向 PC 的 6000 端口发一条固定报文PC 端能看到并回发说明链路已经通了。如果定时发、PC 收不到先用 ping 验证二层三层是否正常。ping 不通时问题集中在 PHY 自协商、MAC 地址、IP 配置这三处ping 能通但 UDP 不通问题多半在 LWIP 的 PCB 绑定或防火墙。这里有个经常误导人的现象PC 上关闭防火墙后 UDP 通了但板子给 PC 回的包在别的电脑上能收到唯独目标 PC 收不到。原因是 Windows 的防火墙有时会拦截入站 UDP哪怕规则显示允许。联调时直接关掉防火墙或加一条出站入站规则省掉半小时排查。4.2 参数怎么从“能通”调到“不丢包”裸机环境下UDP 收包的瓶颈通常在 PBUF 池大小和主循环调用ethernetif_input的频率。如果主循环里有耗时操作比如 Flash 擦写或延时等待网卡接收中断把数据放入了 DMA 描述符但ethernetif_input迟迟不来取新包就会覆盖旧包。野生解决办法是提高中断优先级或把ethernetif_input放进高优先级任务但根本做法是保证主循环单次迭代时间短于一个包的到达间隔。另一个参数是 DMA 描述符的 RX buffer 大小。HAL 库初始化以太网时DmaRxDesc和DmaTxDesc数组大小正好等于描述符数量。如果一次突发 20 个包而接收描述符只有 4 个后续帧会被硬件丢弃。增加描述符数量比如 RX 描述符 8 个、TX 描述符 4 个能显著提升突发缓存能力。代价是 RAM 占用增加每个描述符对应一个 buffer约 1524 字节。在 RTOS 环境下需要确认 tcpip_thread 的栈空间不小于 1024 字节否则回调里稍加处理就栈溢出表现是系统跑一会儿后 HardFault。如果使用 FreeRTOS 的裸机 LWIP 移植方式也就是NO_SYS1但跑在 RTOS 里要定期调用sys_check_timeouts否则 ARP 表会老化发送一段时间后就开始 ARP 解析失败。4.3 用压力测试找出临界包长和丢包边界回环测试通过后做一个简单的压测上位机连续发送 1000 个包板子每收一个就回一个。可以统计回包数量来判断是否有丢失。改变包长从 100 字节到 1400 字节观察丢包率变化。我常用的压测代码就是板子端回环PC 端用脚本发包并计数void UdpServer_TestLoop(void) { uint32_t send_count 0; uint32_t last_time HAL_GetTick(); while (1) { if (rx_flag) { rx_flag 0; // 回发收到的那条数据长度就是上次收到的长度 UdpServer_SendPacket(rx_buffer, rx_len, peer_addr, peer_port); send_count; } // 每秒打印一次当前回包数粗略估算吞吐 if (HAL_GetTick() - last_time 1000) { last_time HAL_GetTick(); // 这里用串口打印 send_count然后清零 send_count 0; } } }测试时如果小包不丢、大包丢优先检查PBUF_POOL_BUFSIZE是否小于 MTU。如果随机丢包且与包长无关大概率是 PC 端发送太快超过了板子的接收处理能力。此时把上位机发送间隔从 0ms 改成 5ms 或者 10ms如果不再丢说明板子的 UDP 吞吐在接受范围内问题不是网络而是发送频率。5. 避坑记录以太网 UDP 调试中的五个常见排查点UDP 调不通的坑看起来千奇百怪归纳下来就几种原因。这里记录五个我实际踩过的问题每个都按现象、原因、解决方式整理方便你对着排查。5.1 现象PC 能 ping 通板子UDP 回调却不触发ping 走的是 ICMP说明 IP 层和链路层都正常。但 UDP 数据到了板子后LWIP 没有找到匹配的 PCB数据直接被丢弃。常见原因有两个板子注册了udp_pcb但绑定的是某个特定 IP而上位机发到了另一个 IP或者是udp_bind时端口写错与上位机目标端口不一致。解决方法是把udp_bind的地址参数改成IP_ADDR_ANY端口用宏定义并确保上位机的目标端口和这里一致。另外要检查是否调用过netif_set_default如果系统里有多个 netifUDP 的发送选路依赖 default 网卡没有 default 网卡可能导致回调注册后完全收不到数据。5.2 现象收到的数据比上位机发的长甚至两包粘在一起UDP 是报文边界清晰的协议IP 层不会把两个 UDP 包合成一个但应用层很容易自己制造“粘包”上位机发了两条数据板子回调触发了两次如果每次回调都往同一个环形缓冲区追加数据而协议解析按固定长度取就可能出现错位。解决的关键是每次回调都用p-tot_len作为单独一包的长度不要在两次回调之间做长度拼接。如果业务上需要对多条小报文重组一定要在数据格式里定义长度字段而不是靠推断。5.3 现象FreeRTOS 下跑 LWIP内存配置明明够但隔一段时间就 HardFault这个问题最常见的原因是堆栈溢出。tcpip_thread 的栈空间默认 1024在回调里做打印、浮点运算或较大内存拷贝都会爆栈。另一个原因是LWIP_RAND未实现随机数函数在 ARP 超时或 TCP 序列号生成时被调用访问野指针。解决方法是把 tcpip_thread 的栈加大到 2048同时在lwipopts.h里把LWIP_RAND自定义成一个基于计时器的伪随机数函数。如果用的是 CubeMX 生成的 LWIP 工程它默认配置可能没开这个宏需要手动补。5.4 现象发送超过 1500 字节的大包对端完全收不到回应UDP 载荷超过 1472 字节时IP 层会做分片。LWIP 默认支持 IP 分片但对端不一定支持分片重组尤其是一些简单网络设备。更麻烦的是F407 的 DMA 传输长度也有限制以太网帧超过最大长度会被硬件截断。最稳的做法是应用层限制发送长度不超过 1472这是用 MTU 1500 减去 20 字节 IP 头和 8 字节 UDP 头后的安全值。如果非要发大包可以自己实现应用层分包和重组而不是依赖 IP 分片。5.5 现象换了一块 PHY 芯片的板子同样的代码初始化失败PHY 芯片不同MDIO 的读时序、寄存器地址映射和自协商位定义虽然大同小异但地址位上可能不同。某些 PHY 上电后地址是 0x00另一些是 0x01如果ETH_PHY_ADDR写死换板必挂。解决方式是把 PHY 地址改成一个宏并在初始化时做一次地址扫描。另外不同 PHY 的复位延时不同有的需要 50ms 才稳定有的 10ms 就绪。代码里不要硬编码 10ms用轮询等待自协商完成的方式代替固定延时。6. 进阶用法应用层 ACK 重传与 Wireshark 抓包验证UDP 比 TCP 省去连接管理实现简单代价是不可靠。在工业数据上报场景里你可以自己做一个极简的 ACK 确认机制板子收到数据后立即回一个确认包上位机发送后等待确认超时则重发。这个机制的关键是协议格式里要有序号方便判定哪包丢了。我建议的报文结构是固定 2 字节魔数0xAA55、2 字节类型、2 字节序号、2 字节长度、最后是数据正文。板子收到后用同样的类型加 0x80 表示 ACK序号填原报文的序号。上位机收到 ACK 后只增计数不重复发送如果超时 500ms重发同一序号。这样既省了 TCP 的握手开销又保证了数据最终到达。想要验证这套机制是否正确最好的工具是抓包软件。PC 端抓包时过滤udp.port 6000看到的是两个方向的包。重点看两点第一板子回的包序号和 PC 发的序号是否一一对应第二有没有大量重传。如果 PC 发了 100 个包而板子只回了 98 个 ACK说明有 2 个包丢了这时不要急着骂协议栈先在板子上查 CPU 占用和 PBUF 池空闲数。有一个我反复遇到的奇怪现象板子上电后第一次发 UDP 包PC 端收到很快但第二次发送要等 1 到 2 秒。这在第一次联网时是正常的因为 ARP 缓存没有目标地址发包前要先发 ARP 请求等待对方回应后才组装 UDP 帧。局域网内 ARP 解析一般几十毫秒就能完成如果你看到 1 秒以上的延迟多半是 PC 端防火墙拦截了 ARP 响应。此时不需要改代码在 PC 端允许 ICMP 和 ARP 即可。从那以后我每次搭建 UDP 工程都会强制走一遍流程先扫描 PHY 地址再跑小包回环然后调整包长到 1472 压测最后开抓包工具验证序号连续性。这套流程看起来繁琐但能把每一层的隐患在半小时内暴露出来比出了问题再猜快得多。希望帮到你。本文还有配套的精品资源点击获取