资讯动态

STM32F407实现TCP服务器:lwIP协议栈与PHY调试实战

发布时间:2026/9/13 6:21:11 来源:尧图企业网站定制
简介面向STM32F407平台、基于LwIP协议栈的TCP服务器数据收发实验工程包适合正在学习嵌入式以太网通信、协议栈移植以及标准外设库开发的开发者同时适用于有一定单片机基础、希望向网络协议栈方向进阶的学习者。工程实现了服务器端的TCP数据收发框架可直接编译运行也能通过源码理解网卡驱动、内存管理、报文收发等关键环节尤其适合对照实际收发流程进行调试与二次开发。压缩包内共505个文件约7.22MB其中112个头文件与92个C源文件构成主要代码主体覆盖外设初始化、以太网驱动及TCP协议栈逻辑众多源文件分工明确便于按模块阅读uvproj/uvopt提供Keil工程配置axf与hex为编译烧录文件map和htm可辅助分析内存与编译报告txt和readme便于快速理清目录结构。对于需要快速搭建STM32F407以太网服务器或参考TCP通信代码的读者完整工程结构、可直接使用的固件以及底层驱动调用关系能明显减少环境配置与排错时间。目前已有673人学习下载资源虽以源码工程为主但压缩包目录层次清楚学习路径较为直观适合作为课程设计、毕业设计或项目初期的入门参考。1. STM32F407 上的 TCP 服务器比裸机串口收发多出来的三件事把 STM32F407 做成 TCP 服务器端很多人以为只是把串口换成网口动手才发现问题多数出在启动后的前几百毫秒PHY 没复位成功、link 一直 down、lwIP 的 netif 起来了却没有流量。这个实验真正要处理的其实是三件事PHY 芯片的 RMII 时序、lwIP 协议栈的调度以及 TCP 连接建立之后的数据流控制。文章按一套可复现的最小 TCP 服务器落地路径组织从外设选型讲到初始化代码再到收发回调与抓包验证。手里有 F407 开发板和 LAN8720A、DP83848 这类 PHY 网卡的人跟着走就能得到一个能连 PC 客户端收发的 server 端代码遇到上电不通、收发卡死这类问题也能按参数表一步步定位。如果你正在做设备联网、Modbus TCP 从站或者远程固件升级这个实验就是最基础的那块拼图。2. lwIP 选型与 F407 的资源边界RAM、PHY 和 API 怎么选F407 的以太网外设只封装了 MAC 层和 DMA 引擎物理层的编码、自动协商和载波侦听全部由外部 PHY 完成这就决定了整个实验有两个硬依赖一个是 PHY 芯片的驱动与复位时序另一个是协议栈怎么在有限的 SRAM 里跑起来。lwIP 是这类开发板上最常用的 TCP/IP 协议栈但它有 raw、netconn、socket 三种 API选错会让后面排查变得很痛苦。2.1 F407 的以太网外设MAC 与 PHY 的分工F407 的内核里集成的是 10/100M 以太网 MAC它负责把内存中的帧通过 DMA 送到 MII/RMII 接口同时解析收到的帧并填充 DMA 描述符。真正和网线打交道的是外部 PHY比如最常见的 LAN8720A 或 DP83848它们完成 4B/5B 编码、时钟恢复、电平转换。F407 的 MAC 和 PHY 之间常见连接方式是 RMII只需要 TXD[1:0]、RXD[1:0]、TX_EN、MDC、MDIO 再加上一个 50MHz 参考时钟比 MII 少了一半引脚这也是多数开发板采用 RMII 的原因。PHY 的寄存器通过 MDIO 接口访问lwIP 的移植层靠它读取链路状态、速率和双工模式。实验中最常见的失败原因是 PHY 复位不彻底有的板子把 nRST 直接接到 MCU 的 GPIO有的把它接到 RC 复位电路上电后 MCU 已经跑起来了PHY 还在复位里这时候netif的 link 标志一直是 down。另一个常见坑是 PHY 地址不一致LAN8720A 默认地址是 0DP83848 在个别板子上会被配置成 0x1F驱动里地址写错MDIO 读回来的寄存器全是 0xFFFF。2.2 lwIP 的三种 APIraw、netconn 和 socket 怎么选lwIP 提供三套编程接口它们不代表功能强弱而是代表不同的延迟和内存折中。raw API 也叫回调 API协议栈在 tcpip_thread 上下文里直接调用你注册的函数指针例如收到数据时调用tcp_recv注册的回调发送完成时触发tcp_sent回调。它的优势是不需要任务间同步、不需要额外的线程栈缺点是回调函数里不能做阻塞操作否则整个协议栈都被卡住。netconn API 是阻塞式接口内部用信号量和邮箱把 lwIP 的操作包装成类似 RTOS 消息队列的模式编程模型接近 BSD socket但每个连接要额外消耗信号量内存。socket API 又在 netconn 之上加了一层 POSIX 风格的封装函数名和桌面编程一致代价是 RAM 占用更高。对 192KB SRAM 的 F407 来说如果只是做实验和简单产品原型raw API 最稳也是 ST 官方例程里默认的用法。下面这段代码展示 raw API 的建立连接流程不算完整实现但能看出它的回调风格连接建立后注册接收和错误回调之后协议栈会在事件发生时反向调用我们。static err_t tcp_server_accept(void *arg, struct tcp_pcb *pcb, err_t err) { // 新连接建立后把后续数据收发交给这两个回调 tcp_recv(pcb, tcp_server_recv); tcp_err(pcb, tcp_server_err); tcp_poll(pcb, tcp_server_poll, 2); // 每 2 秒检查一次空闲连接 return ERR_OK; } struct tcp_pcb *server_pcb tcp_new(); tcp_bind(server_pcb, IP_ADDR_ANY, 8080); server_pcb tcp_listen(server_pcb); tcp_accept(server_pcb, tcp_server_accept);这段代码中tcp_listen返回的是新的监听控制块原 pcb 会被内部释放所以不能把旧指针继续放在全局变量里。tcp_accept注册的不是“接收数据”回调而是在三次握手完成、连接进入 ESTABLISHED 状态后才触发的回调。这个区分很多人会搞混tcp_accept管的是新连接tcp_recv管的是已建立连接上的数据。2.3 lwipopts.h 内存参数与 F407 的 SRAM 预算lwIP 的性能和稳定性几乎都由lwipopts.h里几个宏决定抄例程时最容易忽略它们。F407 的 SRAM 有 192KB看起来很大但 ETH DMA 描述符、接收缓冲、RTOS 任务栈都要从这里分。下表是做过几个 TCP 服务器实验后比较稳的参数组合按单客户端 1KB 左右的应用层数据交换场景设置。宏推荐值说明MEM_SIZE4096lwIP 动态堆大小用于 PCB、网卡结构体分配PBUF_POOL_SIZE20pbuf 池数量每个大小由PBUF_POOL_BUFSIZE决定PBUF_POOL_BUFSIZE1512必须能装下一个标准以太网帧默认 1512TCP_MSS1460最大分段大小通常等于 MTU 1500 减 40 字节头TCP_WND5840接收窗口典型值取 4 倍 TCP_MSSTCP_SND_BUF5840发送缓冲区够 echo 实验使用TCP_SND_QUEUELEN32发送队列允许的最大 pbuf 数对应lwipopts.h里的写法是#define MEM_SIZE 4096 #define PBUF_POOL_SIZE 20 #define PBUF_POOL_BUFSIZE 1512 #define TCP_MSS 1460 #define TCP_WND (4 * TCP_MSS) #define TCP_SND_BUF (4 * TCP_MSS) #define TCP_SND_QUEUELEN (8 * TCP_SND_BUF / TCP_MSS)TCP_SND_QUEUELEN是发送队列里允许排队的 TCP 段数量tcp_write并不立刻把数据发到网线它只是把数据拷贝进发送队列真正发出要靠tcp_output主动调用或等待协议栈触发。tcp_write返回ERR_MEM时不是内存泄漏通常是TCP_SND_QUEUELEN太小发送回调还没把数据清走。有一点需要特别注意F407 的 CCM RAM 虽然容量有 64KB但 DMA 访问不到ETH 的发送和接收描述符、DMA 缓冲都放在普通 SRAM 里。PBUF_POOL_BUFSIZE的 pbuf 最终会被 DMA 搬运所以不要在lwipopts.h里把MEM_LIBC_MALLOC打开也不要把 pbuf 池手动分配到 CCM 区域。这个错误非常隐蔽现象是收发偶尔丢包、DMA 错误标志被置位。3. 最小 TCP 服务器实验初始化 PHY、lwIP 与绑定监听理论部分讲完下面进入能编译、能下载的代码路径。完整的 TCP 服务器实验至少有四步时钟和 GPIO 初始化、PHY 复位、ETH 外设初始化、把 netif 挂到 lwIP 上。每一步出错的表现都不一样所以我把它们拆开并给出排查依据。3.1 时钟与 GPIO先让 RMII 引脚就位F407 的 RMII 接口引脚是固定的通常占 PA1、PA2、PA7、PC1、PC4、PC5、PG11这些引脚需要复用为 AF11。另一个重点是 PHY 的 50MHz 参考时钟LAN8720A 可以由 MCU 的 MCO1 输出 50MHz 供入也可以使用板载有源晶振两种方案的初始化代码不同。如果你的开发板在插上网线后灯不亮大概率不是代码问题而是 MCO 时钟没有输出。PHY 复位引脚的 GPIO 配置逻辑是通用的以某个开发板将复位脚接到 PG11 为例static void PHY_Reset(void) { GPIO_InitTypeDef gpio; RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOG, ENABLE); gpio.GPIO_Pin GPIO_Pin_11; gpio.GPIO_Mode GPIO_Mode_OUT; gpio.GPIO_OType GPIO_OType_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOG, gpio); GPIO_SetBits(GPIOG, GPIO_Pin_11); // 先拉到高电平 delay_ms(50); // 等待电源稳定 GPIO_ResetBits(GPIOG, GPIO_Pin_11); // 拉低进入复位状态 delay_ms(50); // 保持至少 10ms GPIO_SetBits(GPIOG, GPIO_Pin_11); // 释放复位 delay_ms(50); // 等待 PHY 内部 LDO 稳定 }这段代码在示例工程里常被简化成两条 GPIO 语句但我习惯保留三段延时上电稳定、复位脉冲、释放后稳定。LAN8720A 的数据手册要求 nRST 低电平保持 10ms 以上而 50ms 是为了兼容 DP83848 这类上电时间更长的 PHY。如果复位后立刻读 PHY 寄存器经常读到 0xFFFF这就是 PHY 还没准备好的典型表现。提示PHY 复位脚在不同板子上差异很大有的接在 PC13有的直接和网口座子的电阻分压相连。改动 GPIO 时先看原理图确认复位脚是低有效还是高有效不要照搬别人的引脚定义。3.2 初始化 ETH 外设与 lwIP netifGPIO 配好之后是 ETH 外设本身。这个实验里通常固定为 100M 全双工同时开启自动协商让 PHY 自行和对端交换能力void ETH_Init_Default(void) { ETH_InitTypeDef eth; eth.USpeed ETH_Speed_100M; eth.UDuplexMode ETH_Mode_FullDuplex; eth.UAutoNegotiation ENABLE; eth.UPhyAddress 0; // LAN8720A 默认地址DP83848 可能是 31 ETH_DeInit(); ETH_Init(eth, 0); }ETH_Init的第二个参数是 PHY 寄存器操作的延时基数倍频由ETH_Init内部根据系统时钟计算。UPhyAddress写错了的话ETH_Init返回值不会是ETH_SUCCESS因为驱动会尝试读取 PHY 的 ID 寄存器来确认链路能力。初始化后建议顺手把 MAC 地址写入uint8_t mac_addr[6] {0x02, 0x00, 0x00, 0x12, 0x34, 0x56}; ETH_MACAddressConfig(ETH_MAC_Address0, mac_addr);MAC 地址的高字节要求最低位为 0表示单播地址0x02是本地管理地址适合开发板使用。接下来把 netif 注册到 lwIP配置静态 IPstruct netif g_netif; ip4_addr_t ip, mask, gw; IP4_ADDR(gw, 192, 168, 1, 1); IP4_ADDR(ip, 192, 168, 1, 99); IP4_ADDR(mask, 255, 255, 255, 0); netif_add(g_netif, ip, mask, gw, NULL, ethernetif_init, tcpip_input); netif_set_default(g_netif); netif_set_up(g_netif);ethernetif_init是移植层提供的函数负责把 ETH 外设的 DMA 描述符和 lwIP 的 pbuf 池对接起来tcpip_input则是协议栈在上层处理以太网帧的入口。这个流程在 ST 官方移植包里是固定套路只要底层ETH_Init成功这里基本不会出问题。静态 IP 用 192.168.1.99 是为了和多数路由器默认网段一致如果你用网线直连电脑需要在 PC 端手动配置 192.168.1.x 的静态地址。3.3 用 raw API 建立监听端口连接底层和协议栈后真正建立 TCP 服务器只需要四行核心代码但每一步都要检查返回值struct tcp_pcb *server_pcb tcp_new(); if (server_pcb NULL) { printf(tcp_new failed\r\n); return; } if (tcp_bind(server_pcb, IP_ADDR_ANY, 8080) ! ERR_OK) { printf(tcp_bind failed\r\n); return; } server_pcb tcp_listen(server_pcb); if (server_pcb NULL) { printf(tcp_listen failed\r\n); return; } tcp_accept(server_pcb, tcp_server_accept);tcp_new分配一个 TCP 控制块这时 pcb 处于 CLOSED 状态tcp_bind把端口和本地 IP 绑定IP_ADDR_ANY表示监听所有本地地址tcp_listen把 pcb 从普通连接变成监听模式这个函数内部会释放原 pcb 并返回新的 pcb所以必须重新赋值。很多人会在tcp_listen之后继续用旧的server_pcb导致 accept 回调永远不触发因为旧指针已经被协议栈释放了。端口 8080 只是示例实验时如果 PC 端有服务占用同端口连接会失败。建议选择大于 5000 的端口并在tcp_bind失败时用串口把err打印出来。lwIP 的错误码定义在err.h里ERR_USE表示端口被占用ERR_MEM表示内存不足。3.4 调度与心跳tcp_tmr 不能被饿死lwIP 的 TCP 状态机依赖周期性的超时处理最典型的是 SYN 重传、FIN 超时和保活探测。在 FreeRTOS 环境下这部分由tcpip_thread内部调用sys_check_timeouts完成裸机环境下则需要自己把它放进主循环或定时器中断while (1) { // 协议栈收包入口 if (ETH_CheckFrame()) { tcpip_input(g_netif); } // 每 200ms 左右驱动一次 lwIP 超时 sys_check_timeouts(); }ETH_CheckFrame检查 DMA 是否收到新帧有帧则交给tcpip_inputsys_check_timeouts会遍历所有活跃的 pcb处理重传、RTO 计算等问题。如果漏掉这个调用TCP 连接会卡在 SYN_SENT 或 SYN_RCVD 状态表现为客户端能发出 SYN 但永远收不到 SYN-ACKWireshark 里能看到客户端疯狂重传而 300 秒后连接直接被放弃。4. 数据收发接收回调、窗口更新和多客户端处理服务器能监听、能 accept 只是骨架数据收发才是这个实验的核心。 lwIP raw API 的数据接收和裸机串口中断很不一样数据不是写在某个全局缓冲区里的而是以 pbuf 链的形式交给回调函数处理完后要释放 pbuf还要调用tcp_recved归还接收窗口。这里每一步都有对应的“如果不做会怎样”。4.1 TCP 三次握手在 lwIP 中的处理位置客户端发起连接时F407 的协议栈会自动完成三次握手你的代码不需要手动回 SYN-ACK。从状态机上看监听 pcb 收到 SYN 后进入 SYN_RCVD协议栈分配一个新的 pcb 并发送 SYN-ACK收到客户端的 ACK 后这个新 pcb 进入 ESTABLISHED之后才触发tcp_accept回调。这也是为什么在 accept 回调里调用tcp_recv是安全的因为此刻连接已经完全建立。如果客户端显示连接成功但服务器的 accept 没进去先检查tcp_listen的返回值再看是不是多个 pcb 的监听端口冲突。还有一点容易被忽略lwIP 的 raw API 中监听 pcb 不属于任何线程它的超时检查依赖sys_check_timeouts裸机跑的时候如果主循环被阻塞在某个延时函数里握手就会一直重传。4.2 recv 回调里的 pbuf 生命周期收数据回调是 TCP 服务器里最核心的函数一个最简单的 echo 服务器长这样static err_t tcp_server_recv(void *arg, struct tcp_pcb *pcb, struct pbuf *p, err_t err) { if (p NULL) { // 对端关闭连接释放资源 tcp_close(pcb); return ERR_OK; } // 处理完数据后立即归还接收窗口 tcp_recved(pcb, p-tot_len); // echo 回发第三个参数 1 表示 TCP_MSG_OOB 标志 tcp_write(pcb, p-payload, p-tot_len, 1); tcp_output(pcb); // pbuf 是协议栈分配的必须释放 pbuf_free(p); return ERR_OK; }这个函数里藏了 TCP 接收路径上最重要的规则p指向的 pbuf 内存属于协议栈必须在回调里释放否则内存池会被耗尽表现为收发一段时间后连接卡死。tcp_recved通知协议栈“应用层已经取走这么多字节”发送窗口才会向前滑动。如果只释放 pbuf 而不调用tcp_recvedTCP 窗口会慢慢变成 0对端还能发送但你会再也收不到数据。tcp_write会把 payload 拷贝到发送缓冲区所以pbuf_free放在tcp_write之后是安全的不用担心发送指向已释放内存。tcp_write的最后一个参数1表示在 TCP 头中设置 PSH 标志提示对端立即把数据交给应用层。对交互式的 echo 实验设 1 能让响应延迟最小如果做文件传输这个标志反而会降低吞吐。4.3 多客户端连接管理PCB 链表和错误回调F407 的 raw API 天然支持多客户端每个连接用一个tcp_pcb表示但代码需要自己保存连接上下文。一个简单策略是在 accept 回调里把 pcb 指针存进数组用一个结构体记录它的状态和数据缓冲#define MAX_CLIENTS 4 struct client { struct tcp_pcb *pcb; uint8_t connected; uint8_t rx_buffer[1024]; uint16_t rx_len; } clients[MAX_CLIENTS]; static err_t tcp_server_accept(void *arg, struct tcp_pcb *pcb, err_t err) { for (int i 0; i MAX_CLIENTS; i) { if (!clients[i].connected) { clients[i].pcb pcb; clients[i].connected 1; tcp_recv(pcb, tcp_server_recv); tcp_err(pcb, tcp_server_err); tcp_arg(pcb, clients[i]); // 把上下文绑定到连接 return ERR_OK; } } // 连接数满直接拒绝 tcp_abort(pcb); return ERR_ABRT; }tcp_arg是 raw API 里很实用的函数它把任意指针绑定到 pcb 上后续所有回调的第一个参数都会拿到这个指针。回调里取struct client *c (struct client *)arg;就能知道是哪个客户端发来的数据。这个设计避免了用全局变量在不同连接之间互相串数据是 raw API 多连接场景的标准做法。tcp_err回调负责处理连接异常比如对端断电、RST 被触发、重传超时。p NULL分发只处理正常 close异常断开不会走 recv 回调而是触发 err 回调。对比一下两个路径呼叫来源回调函数典型返回对端主动 FINtcp_recvp NULL清理资源并调用tcp_close对端 RSTtcp_errerr ERR_RST立即释放上下文重传超时tcp_errerr ERR_CONN重置并释放连接空闲tcp_poll心跳探测或关闭4.4 发送方向tcp_write 返回值与背压处理普通实验里 echo 代码能通就结束了但真正做数据上送时你会发现连续tcp_write几个大包返回值会突然变成ERR_MEM。这不是内存泄漏而是发送队列已经满了。TCP 发送窗口、TCP_SND_BUF、TCP_SND_QUEUELEN共同限制了在途数据量。生产级的处理方式是应用层把要发的数据放进自己的队列在tcp_sent回调里检查并继续发送。tcp_sent在对端 ACK 一段数据、发送缓冲腾出空间时被调用static err_t tcp_server_sent(void *arg, struct tcp_pcb *pcb, u16_t len) { struct client *c (struct client *)arg; // 有暂存数据就继续发送 if (c-tx_len 0) { err_t err tcp_write(pcb, c-tx_buffer, c-tx_len, 1); if (err ERR_OK) { tcp_output(pcb); c-tx_len 0; } } return ERR_OK; }这段代码的关键点在于不要把tcp_write当 printf 用它的成功只代表数据进了协议栈缓冲不代表已经到网线上。tcp_output负责把缓冲里的数据立刻打包发出如果不调用只能等协议栈内部的快速定时器触发实时性会差很多。在自己写的发送函数里tcp_write返回ERR_MEM时设一个标志位然后等tcp_sent再补发这是最简单也最稳妥的背压处理方式。5. 用网线连电脑实测抓包、复位时序验证和 Modbus TCP 收尾实验做到这里服务器代码已经能跑但怎么证明链路真正通、窗口没泄漏、重传超时正常需要动手验证。这一章讲三个最实用的操作Wireshark 抓包看三次握手、PHY 寄存器验证上电时序和把这个 TCP 服务器直接改造成 Modbus TCP 从站。5.1 用 Wireshark 验证三次握手与 echo 响应把 F407 用网线直连电脑PC 静态 IP 设为 192.168.1.100F407 保持 192.168.1.99。启动 Wireshark 捕获过滤条件填tcp.port 8080然后在 PC 上用 Python 客户端连接import socket s socket.create_connection((192.168.1.99, 8080), timeout3) s.sendall(bhello f407) print(s.recv(100))正常情况下 Wireshark 里会依次出现[SYN]、[SYN, ACK]、[ACK]随后是客户端发 TCP 段、服务器回 ACK然后是 echo 回来的数据。如果只有 SYN 重传回到sys_check_timeouts的调用是否正常如果能看到 SYN-ACK 但连接被 RST检查端口是否真的 bind 成功。5.2 用 MDIO 读寄存器验证 PHY 上电时序有时候串口打印显示ETH_Init成功但 link 就是起不来。用驱动读 PHY 的基本状态寄存器地址 1bit 1 和 bit 2 分别表示链路状态和自动协商完成uint32_t status ETH_ReadPHYRegister(0, 1); printf(PHY BSR: 0x%08X\r\n, status); if (status 0x0004) { printf(link up\r\n); }打印读回值 0xFFFF 说明 MDIO 根本没通优先查 PHY 地址和复位脚读回0x782D这类正常值但 link 一直是 0查网线两端速率是否都是 100M 全双工。把这段读寄存器的代码放在主循环里还能顺带做掉线重连。5.3 把 TCP 服务器改成 Modbus TCP 从站前面的 TCP 收发回调已经具备完整的数据通路改造 Modbus TCP 只差解析 MBAP 头。MBAP 有 7 个字节事务标识符 2 字节、协议标识符 2 字节、长度 2 字节、单元标识符 1 字节所以功能码从payload[7]开始uint8_t *data (uint8_t *)p-payload; if (p-len 12 data[7] 0x03) { // 功能码 0x03 读保持寄存器 // 按 MBAP 格式组响应帧 }用同样的 recv 回调、同样的tcp_write发送路径只是加一个协议分支即可。上面 Python 客户端里的recv逻辑也要对应改成发 MBAP 请求并解析响应。这类设备接入上位机系统时上位机会把 F407 当作标准 Modbus TCP 从站来读寄存器工业场景里比自造 TCP 协议实用得多。实验全部收尾后检查两件事一是长时间跑 echo 时观察tcp_recved是否被正确调用可用串口记录tcp_pcb的 snd_wnd 和 rcv_wnd二是拔掉网线再插回确认 PHY link 状态能否恢复。只要这两项正常这个服务器端就能从实验板挪到真实项目里继续用。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价