简介:这份例程包面向嵌入式开发学习者与工程师,聚焦STM32F407平台上LWIP协议栈与DP83848以太网PHY的移植与联调,提供一套无操作系统环境下的TCP/IP server实现,可直接验证网络数据收发。硬件设计不依赖外部内存,只需一块STM32F407开发板和DP83848模块即可搭建实验环境,降低了入门与复现门槛。压缩包共3458个文件,约22.92MB,以C源码和头文件为主体,辅以启动汇编、工程配置、网页与说明文档等,既保留完整工程结构,也便于按模块查阅移植细节。已有4769人学习下载,说明其在STM32网络开发方向具有一定参考价值。读者可从中获取协议栈适配、PHY驱动、TCP服务端收发流程及工程组织思路,也可重点参考内存池配置、收发回调与PHY初始化等关键环节,适合课程设计、项目预研和移植排错时对照使用。
1. 从“网口灯不亮”说起
手里这块 F407 板子,网口用的是 DP83848,不是 ST 官方例程默认的 LAN8720。很多人直接把 STM32CubeMX 生成的 LwIP 工程烧进去,结果网口灯不亮、ping超时,盯了一下午也找不到原因。问题多半出在两个地方:一是 PHY 地址没配对,二是 50MHz 的 RMII 参考时钟根本没进来。这份 STM32F407+LWIP+DP83848 移植例程,就是把 Cortex-M4 的 ETH MAC、DP83848 这颗物理层芯片和 LwIP 协议栈这三层拼成一条完整的 TCP/IP 链路:从 GPIO 复用、PHY 复位、netif 注册,一直走到能稳定收发 TCP 报文。适合手里已经有 F407 板子、想把裸机 TCP/IP 跑起来的开发者,也适合把它当参照,去对接自己板子上那颗 PHY。
2. 硬件与协议栈选型:F407 的 MAC、DP83848 的 RMII 和 LwIP 各自负责什么
这三者在系统里各管一段,边界很清晰,但交界处最容易出问题。STM32F407 内部集成的是以太网 MAC 控制器,负责帧的组装、CRC 校验、DMA 搬运;DP83848 是物理层收发器,负责把 MAC 出来的数字信号变成双绞线上的差分电平,同时完成自动协商、链路检测;LwIP 是跑在 MAC 之上的协议栈,处理 IP、ARP、ICMP、TCP 这些上层逻辑。MAC 和 PHY 之间走的是 RMII 接口,协议栈和 MAC 之间靠ethernetif.c这一层胶水代码连接。
2.1 RMII 九根线和那根 50MHz 时钟
RMII 相对 MII 最大的区别是把数据线从 4 位砍到 2 位,引脚数从 16 根降到 9 根,代价是时钟必须跑到 50MHz。这九根线在 F407 上全部走 AF11 复用,具体对应关系如下:
| 信号名 | F407 引脚 | 方向 | 说明 |
|---|---|---|---|
| RMII_REF_CLK | PA1 | 输入 | 50MHz 参考时钟 |
| RMII_MDIO | PA2 | 双向 | 管理接口数据 |
| RMII_MDC | PC1 | 输出 | 管理接口时钟 |
| RMII_CRS_DV | PA7 | 输入 | 载波侦听/数据有效 |
| RMII_TX_EN | PB11 | 输出 | 发送使能 |
| RMII_TXD0 | PB12 | 输出 | 发送数据 bit0 |
| RMII_TXD1 | PB13 | 输出 | 发送数据 bit1 |
| RMII_RXD0 | PC4 | 输入 | 接收数据 bit0 |
| RMII_RXD1 | PC5 | 输入 | 接收数据 bit1 |
REF_CLK 这根线是整条链路的命门。RMII 规定 MAC 和 PHY 必须共用一个 50MHz 时钟源,至于这个时钟由谁产生,取决于板子的设计。常见的三种做法:一是板上有 50MHz 有源晶振,同时接到 PHY 的 X1 和 MCU 的 PA1;二是用 STM32 的 MCO1(PA8)配置成 50MHz 输出,送到 PHY 的 X1,再从 PHY 的某个引脚回送到 PA1;三是 PHY 自己带 25MHz 晶振,在 RMII 模式下把内部 50MHz 输出到 PA1。这三种都能跑,但代码里 MCO1 的配置项必须和实际硬件对得上,否则 PHY 就是死的。
提示:上电第一件事,拿示波器量 PA1 上有没有 50MHz、峰峰值 3.3V 左右的方波。量不到就别往下看了,后面全是白费。
GPIO 复用部分在HAL_ETH_MspInit里完成,CubeMX 生成的版本基本就是这个样子:
void HAL_ETH_MspInit(ETH_HandleTypeDef *heth) { GPIO_InitTypeDef GPIO_InitStruct = {0}; /* 三个端口的时钟必须全部打开,漏一个就是部分信号没输出 */ __HAL_RCC_ETH_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); __HAL_RCC_GPIOC_CLK_ENABLE(); /* PA1=REF_CLK PA2=MDIO PA7=CRS_DV */ GPIO_InitStruct.Pin = GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_7; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; /* MDIO 是双向线,外部没有上拉时这里要配成上拉,否则读 PHY 会随机出错 */ GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH; GPIO_InitStruct.Alternate = GPIO_AF11_ETH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); /* PB11=TX_EN PB12=TXD0 PB13=TXD1 */ GPIO_InitStruct.Pin = GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_13; HAL_GPIO_Init(GPIOB, &GPIO_InitStruct); /* PC1=MDC PC4=RXD0 PC5=RXD1 */ GPIO_InitStruct.Pin = GPIO_PIN_1 | GPIO_PIN_4 | GPIO_PIN_5; HAL_GPIO_Init(GPIOB == NULL ? GPIOC : GPIOC, &GPIO_InitStruct); /* DP83848 的 RESET_N 接在普通 GPIO 上,这里一并配成推挽输出 */ GPIO_InitStruct.Pin = GPIO_PIN_12; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOD, &GPIO_InitStruct); }这段代码里几处要留意。Speed必须设成VERY_HIGH,RMII 的 50MHz 时钟和数据沿很陡,速度档位低一个等级就可能出现 CRC 错误。PA2 的Pull参数取决于板上有没有 1.5k~10k 的 MDIO 上拉电阻,有就填GPIO_NOPULL,没有就得填GPIO_PULLUP,不然读 PHY 寄存器返回值会在 0xFFFF 和正确值之间跳。最后那段 PHY 复位引脚是我自己加的,CubeMX 不会自动生成,实际板子上 DP83848 的 RESET_N 一般都挂在某个 GPIO 上,不主动拉低复位一次,芯片可能停在未知状态。代码里GPIOB == NULL ? GPIOC : GPIOC那行是走位写法,正经工程直接写GPIOC就行。
2.2 裸机和 FreeRTOS 两条路:NO_SYS 决定 LwIP 的接法
LwIP 有个总开关叫NO_SYS,写在lwipopts.h里,这个值一改,整个工程的调用方式都跟着变。
| 配置 | NO_SYS=1(裸机) | NO_SYS=0(RTOS) |
|---|---|---|
| 协议栈运行位置 | 主循环里轮询 | 独立的tcpip_thread |
| 应用 API | raw API(回调式) | netconn / socket API |
| 收包触发 | MX_LWIP_Process() | 信号量唤醒 |
| 内存保护 | 无 | 有SYS_LIGHTWEIGHT_PROT |
| 典型资源占用 | Flash 约 40KB | Flash 约 60KB,多一个线程栈 |
移植例程两种都给了,默认按裸机跑。裸机模式的优点是链路清晰,没有线程抢占,调试时断点随便打;缺点是主循环里一旦有耗时操作,协议栈就停止喂狗,TCP 重传、ARP 老化全乱套。选哪条路,看你的应用里有没有必须跑的后台任务。只有简单的 Modbus TCP、HTTP 服务,裸机足够;要同时做串口协议解析、SD 卡记录、屏幕刷新,那就上 FreeRTOS,让tcpip_thread自己转。
选 FreeRTOS 时要格外注意一点:LwIP 的sys_arch.c需要适配 FreeRTOS 的邮箱和信号量,ST 提供的中间件里带了现成的sys_arch.c,但如果 CubeMX 版本和 LwIP 版本对不上,编译时会冒出一堆sys_mbox_t未定义的错误。我一般会直接把中间件里的system/OS目录整体拷过来,不去手动改。
3. 工程目录与启动链路:从 main() 走到 netif 挂上协议栈
工程目录看着杂,其实真正需要动的就那几个文件。搞清楚哪些是“只读”的库文件、哪些是“必须改”的板级适配文件,后面改起来就不容易出事。
3.1 目录分布:哪些能改,哪些不该碰
解压后的目录大致是这个结构:
STM32F407_LWIP_DP83848/ ├── Drivers/ │ ├── CMSIS/ # 内核头文件和启动文件,只读 │ └── STM32F4xx_HAL_Driver/ # HAL 库,只读 ├── Middlewares/ │ └── Third_Party/LwIP/ # LwIP 2.x 源码,只读 ├── Application/ │ ├── User/ │ │ ├── main.c # 主循环、外设初始化入口 │ │ ├── lwipopts.h # 协议栈裁剪参数,必改 │ │ └── stm32f4xx_hal_conf.h # HAL 模块开关,必改 │ └── Ethernet/ │ ├── ethernetif.c # MAC 与 LwIP 的胶水层,必改 │ └── ethernetif.h ├── BSP/ │ ├── dp83848.c # PHY 复位、地址探测、链路检测 │ └── led.c / usart.c # 调试用的指示灯和串口 └── MDK-ARM/ # 工程文件与启动配置Drivers和Middlewares这两个目录我建议直接标成只读,改库文件是自找麻烦,升级的时候一合并全丢。真正的战场是Application/Ethernet/ethernetif.c和BSP/dp83848.c这两个文件,前者管帧的进出,后者管 PHY。lwipopts.h里的MEM_SIZE、PBUF_POOL_SIZE、TCP_MSS这些参数,后面调性能时全在这里动。
3.2 上电后的启动顺序:六步走完才轮到 ping
main()里看着一堆初始化函数,实际逻辑是一条直线。顺序错了就会出各种奇怪现象,比如 ETH 初始化在时钟配置之前,MAC 拿到的时钟就是错的。
int main(void) { /* 1. HAL 初始化,把 SysTick 和中断优先级组配好 */ HAL_Init(); /* 2. 系统时钟:168MHz 主频,同时确认 MCO1 输出 50MHz(若板子用 MCO1 供时钟) */ SystemClock_Config(); /* 3. 调试外设,串口打印 + LED 指示,出问题时全靠它们 */ MX_GPIO_Init(); MX_USART1_UART_Init(); /* 4. PHY 复位,拉低 RESET_N 至少 10ms,再等 100ms 让内部 PLL 稳定 */ DP83848_Reset(); /* 5. 以太网外设初始化,此时 GPIO 复用、DMA 描述符、MAC 参数一并配好 */ MX_ETH_Init(); /* 6. LwIP 初始化:netif 添加、IP 配置、启动协议栈 */ MX_LWIP_Init(); while (1) { /* 裸机模式下,协议栈的收包、定时器全靠这一句驱动 */ MX_LWIP_Process(); } }SystemClock_Config()里如果板子用的是 MCO1 供时钟,要额外配 PA8 复用成RCC_MCO1,时钟源选 PLLCLK 分频到 50MHz。这一步漏了,前面说的 REF_CLK 就是死的。DP83848_Reset()里的等待时间别省,10ms 复位加 100ms 稳定,省掉那 100ms 会出现“第一次上电能通、复位一次就不通”的玄学现象。
MX_LWIP_Init()是 CubeMX 自动生成的,核心就是这几句:
void MX_LWIP_Init(void) { /* 添加 netif,ethernetif_init 是胶水层入口,里面会去探测 PHY */ tcpip_init(NULL, NULL); netif_add(&gnetif, &ipaddr, &netmask, &gw, NULL, ðernetif_init, &tcpip_input); /* 设为默认网卡,否则发包时找不到出口 */ netif_set_default(&gnetif); /* 探测到链路后才真正把网卡置为 UP */ if (netif_is_link_up(&gnetif)) { netif_set_up(&gnetif); } else { netif_set_down(&gnetif); } }netif_add的倒数第二个参数ethernetif_init是关键,LwIP 在添加网卡时会回调它,由它去初始化底层硬件、注册output和linkoutput两个函数指针。netif_set_up只有在链路已经建立时才调用,否则协议栈会误以为网卡通着,一直往一个死链路里发 ARP 请求。IP 地址这里是静态配的,写在main.c开头的宏定义里,一般设成192.168.1.10 / 255.255.255.0 / 192.168.1.1,跟电脑同网段就行。
4. 核心模块拆解:PHY 探测、netif 注册和 DMA 收发
真正决定能不能通的,就是这三块:PHY 有没有被正确识别、netif 有没有被正确注册、DMA 有没有把帧搬对地方。任何一环断了,现象都是 ping 不通。
4.1 PHY 复位与地址探测:先确认对面坐着的是谁
DP83848 的 PHY 地址由 PHYAD0 引脚决定,悬空或上拉是 0x01,下拉是 0x00。ST 的例程默认按 LAN8720 的 0x00 写,直接拿来用大概率读不到东西。稳妥的做法是复位后把 0x00~0x1F 全扫一遍,读寄存器 2 和寄存器 3 拼出 ID,跟 DP83848 的0x20005C90比对。
uint32_t DP83848_Probe(void) { uint32_t id1 = 0, id2 = 0; uint16_t addr; /* 硬件复位:RESET_N 拉低 10ms 以上 */ HAL_GPIO_WritePin(PHY_RST_PORT, PHY_RST_PIN, GPIO_PIN_RESET); HAL_Delay(20); HAL_GPIO_WritePin(PHY_RST_PORT, PHY_RST_PIN, GPIO_PIN_SET); HAL_Delay(100); /* 等内部 PLL 锁定,少于 50ms 容易读到全 0 */ for (addr = 0; addr < 32; addr++) { /* 寄存器 2/3 是 PHY Identifier,所有标准 MII 芯片都有 */ if (HAL_ETH_ReadPHYRegister(&heth, addr, 0x02, &id1) != HAL_OK) continue; if (HAL_ETH_ReadPHYRegister(&heth, addr, 0x03, &id2) != HAL_OK) continue; uint32_t phy_id = (id1 << 16) | id2; if (phy_id == 0x20005C90U) /* DP83848 的固定 ID */ { return addr; /* 返回 0x00 或 0x01 */ } } return 0xFFFFFFFFU; /* 全扫完都没有,返回无效值 */ }HAL_ETH_ReadPHYRegister内部会做 MDIO 时序,超时时间默认是PHY_TIMEOUT,在stm32f4xx_hal_eth.h里定义。扫地址这一步如果全部返回HAL_TIMEOUT,基本可以确定是 MDC/MDIO 的 GPIO 没配好,或者 REF_CLK 没起来。如果扫到某个地址有返回但 ID 对不上,说明那里挂着别的芯片,比如有些板子在同一个 MDIO 总线上还挂了 EEPROM。返回值交给后面配置heth.Init.PHYAddress,别硬编码。
4.2 ethernetif_init:把 MAC 和协议栈缝在一起
这个函数是整份例程的核心,LwIP 靠它拿到收发入口。
err_t ethernetif_init(struct netif *netif) { struct ethernetif *ethernetif; LWIP_ASSERT("netif != NULL", (netif != NULL)); ethernetif = mem_malloc(sizeof(struct ethernetif)); if (ethernetif == NULL) return ERR_MEM; netif->state = ethernetif; netif->name[0] = IFNAME0; /* 'e' */ netif->name[1] = IFNAME1; /* 'n' */ netif->output = etharp_output; /* IP 层 -> ARP 层 */ netif->linkoutput = low_level_output; /* ARP 层 -> MAC 硬件 */ netif->mtu = NETIF_MTU; netif->flags = NETIF_FLAG_BROADCAST | NETIF_FLAG_ETHARP | NETIF_FLAG_ETHERNET; /* 从 MAC 地址寄存器里读出出厂的 6 字节地址 */ low_level_init(netif); /* 探测 PHY,失败就返回错误,让上层知道网卡不可用 */ if (ethernetif_init_phy(netif) != ERR_OK) { return ERR_IF; } netif->flags |= NETIF_FLAG_LINK_UP; return ERR_OK; }netif->output和netif->linkoutput这两个函数指针是整个 LwIP 的数据出口。上层协议组好 IP 包,交给etharp_output填 MAC 头,再交给low_level_output丢进 DMA。这两个指针没设对,包会直接消失,串口上什么都看不到,就是个黑匣子。NETIF_FLAG_LINK_UP这个标志位很重要,DHCP、自动协商、链路检测都看它,探测失败时不能设。
low_level_output负责把 pbuf 链里的数据拷进发送缓冲,再启动 DMA:
static err_t low_level_output(struct netif *netif, struct pbuf *p) { struct pbuf *q; uint8_t *buffer = (uint8_t *)ETH_TX_BUF; /* pbuf 可能是链式的,要逐个节点拷进连续的 DMA 缓冲 */ for (q = p; q != NULL; q = q->next) { memcpy(buffer, q->payload, q->len); buffer += q->len; } /* 告诉 HAL 这一帧多长,然后启动发送 */ if (HAL_ETH_TransmitFrame(&heth, p->tot_len) != HAL_OK) { return ERR_IF; } return ERR_OK; }HAL_ETH_TransmitFrame触发 DMA 后是异步完成的,函数返回只代表“已交给硬件”,不代表“已发到线上”。所以这里返回值判断不了真实发送结果,线路上的冲突、CRC 错误得看 MAC 的中断状态寄存器。我一般会在发送失败时点亮一个 LED,方便现场排查。
4.3 ethernetif_input 与主循环:收包这条路要短
收包路径比发包敏感得多,因为它是中断或者轮询触发的,处理慢了会丢包。
err_t ethernetif_input(struct netif *netif) { struct pbuf *p = NULL; err_t err = ERR_OK; /* 从 DMA 环形缓冲里取一帧,取到就返回 HAL_OK */ if (HAL_ETH_GetReceivedFrame_IT(&heth) != HAL_OK) { return ERR_OK; /* 没有新帧,正常返回,不是错误 */ } uint16_t len = heth.RxFrameInfos.length; uint8_t *buf = (uint8_t *)heth.RxFrameInfos.buffer; if (len > 0) { /* 从内存池里申请 pbuf,PBUF_POOL_SIZE 不够时这里返回 NULL */ p = pbuf_alloc(PBUF_RAW, len, PBUF_POOL); if (p != NULL) { pbuf_take(p, buf, len); /* 整帧拷进 pbuf 链 */ /* 交给协议栈,成功与否直接反馈给网卡层 */ if (netif->input(p, netif) != ERR_OK) { pbuf_free(p); err = ERR_IF; } } else { err = ERR_MEM; /* 内存池耗尽,计数后丢弃 */ } } /* 释放 DMA 描述符,让硬件能继续收下一帧,少这一句收几帧就卡死 */ HAL_ETH_ReleaseFrame(&heth); return err; }HAL_ETH_ReleaseFrame这行绝对不能漏,DMA 描述符的所有权是靠引用计数管理的,不释放就再也不会有新帧进来。我见过一次,现象特别怪:ping前三个包能通,第四个开始全丢,查了半天发现是ethernetif_input里少了一句释放。裸机模式下,ethernetif_input在MX_LWIP_Process()里被调用,轮询节奏取决于主循环跑多快;FreeRTOS 模式下则是在 ETH 中断里发信号量,由tcpip_thread处理。两种模式下PBUF_POOL_SIZE都得留够,一般 16 个起步,收大包多的情况下给到 32。
5. 避坑与排查:从烧录到 ping 通要过的五道坎
5.1 PHY 探测返回全 F
现象:串口打印 PHY 扫描结果,32 个地址全部返回0xFFFF,或者HAL_ETH_ReadPHYRegister直接超时。
原因:九成是 REF_CLK 没起来,MDIO 的时序是靠 50MHz 时钟驱动的,没时钟就没有 MDIO 的位传输;剩下的一成是 MDC/MDIO 的 GPIO 复用没打开,或者 PA2 没做上拉。时钟来源和硬件设计对不上也会这样。
解决:示波器先量 PA1,没有 50MHz 就去查SystemClock_Config里的 MCO1 配置,或者查板上有源晶振有没有焊;有 50MHz 就量 PA2 和 PC1,看 MDIO 在扫描期间有没有跳变,纹丝不动说明 GPIO 复用配错了端口。
5.2 链路建立但 ping 不通
现象:串口显示自动协商成功,100M 全双工,netif_is_link_up返回真,但电脑ping 192.168.1.10一直超时。
原因:这种情况优先怀疑 IP 不在同一网段,或者电脑那边的防火墙把 ICMP 拦了。还有一个容易忽略的点:例程里netif_set_up只在链路建立后调用,如果链路建立的时机晚于MX_LWIP_Init返回,网卡就一直处于 down 状态。
解决:先把电脑网卡设成固定 IP192.168.1.100、掩码255.255.255.0,网关留空,再关掉防火墙试一次。还不行就打开 LwIP 的LWIP_DEBUG和ICMP_DEBUG,在串口上看 ARP 请求有没有发出去、电脑有没有回。netif_set_up的时机问题,一般靠 ETH 的中断里检测链路变化,检测到 up 再调用一次netif_set_up。
5.3 小包能通,大包一收就丢
现象:ping -l 32通,ping -l 1400就超时,TCP 上传文件传到一半卡死。
原因:PBUF_POOL_SIZE太小,加上PBUF_POOL_BUFSIZE小于一个完整以太帧(1518 字节),大包过来时pbuf_alloc返回 NULL,直接被丢弃,而且不会报错,只在 LwIP 的统计计数里加一。
解决:在lwipopts.h里把PBUF_POOL_SIZE调到 32,PBUF_POOL_BUFSIZE显式设成1520,同时把MEM_SIZE从默认的 1600 提到 8192 以上。改完重新编译时注意lwipopts.h里的MEM_ALIGNMENT要设成 4,否则结构体会因为对齐问题浪费内存。
5.4 中断模式下偶发死机
现象:跑一段时间后程序跳进HardFault_Handler,或者死在一个 while 循环里不动。
原因:ethernetif_input被放进了 ETH 中断回调,而回调里又调用了pbuf_alloc,这个函数内部有内存池操作,在有 RTOS 的场合会申请信号量,中断上下文里调阻塞式 API 是致命的。
解决:中断里只做一件事,xSemaphoreGiveFromISR释放一个信号量,真正解析和投递放到tcpip_thread或者一个专门的任务里做。裸机模式下则反过来,把 ETH 中断关掉,纯靠MX_LWIP_Process()轮询,虽然 CPU 占用高一点,但绝对稳定。
5.5 换 FreeRTOS 后协议栈不工作
现象:编译链接都过,程序也跑起来了,但tcpip_init之后再没有任何协议栈活动。
原因:NO_SYS改了但lwipopts.h里的LWIP_NETCONN、LWIP_SOCKET、MEMP_NUM_NETCONN没跟着打开,或者sys_arch.c里的邮箱实现没和 FreeRTOS 的版本对应上,导致tcpip_thread起来了又在等一个永远不来的消息。
解决:确认NO_SYS=0,LWIP_NETCONN=1,LWIP_SOCKET=0(只用 netconn 就不用开 socket),MEMP_NUM_NETCONN给到 8。sys_arch.c直接用中间件里的那份,不要自己写,然后检查tcpip_init传入的参数是不是 NULL,传了非 NULL 的回调但要它做的事情又没实现,也会卡住。
6. 二次开发:从 ping 通到真正能用的三处改造
ping通了只是起点,真要拿它做产品,接下来这几处是必须动的。
6.1 接一个 TCP echo 服务器,验证协议栈能承载业务
最快的验证方式是在裸机模式下挂一个 raw API 的 TCP 服务,客户端发什么就回什么,能打通说明整条链路都活了。
static err_t echo_recv(void *arg, struct tcp_pcb *tpcb, struct pbuf *p, err_t err) { if (p == NULL) /* 对端关闭连接 */ { tcp_close(tpcb); return ERR_OK; } /* 直接把收到的 pbuf 回显,注意引用计数,别 double free */ tcp_write(tpcb, p->payload, p->tot_len, TCP_WRITE_FLAG_COPY); tcp_output(tpcb); pbuf_free(p); return ERR_OK; } static err_t echo_accept(void *arg, struct tcp_pcb *newpcb, err_t err) { /* 注册接收回调,客户端连上来后所有数据都走 echo_recv */ tcp_recv(newpcb, echo_recv); return ERR_OK; } void echo_server_init(void) { struct tcp_pcb *pcb = tcp_new(); ip_addr_t ip = IPADDR4_INIT_BYTES(192, 168, 1, 10); ip_addr_t any = IPADDR4_INIT_BYTES(0, 0, 0, 0); tcp_bind(pcb, &any, 5000); /* 监听 5000 端口 */ pcb = tcp_listen(pcb); /* listen 会返回一个新的 pcb */ tcp_accept(pcb, echo_accept); LWIP_UNUSED_ARG(ip); }tcp_recv注册的回调里,参数p为 NULL 代表对端发起了 FIN,这时必须调用tcp_close把控制块释放掉,不然连接数越积越多,最后tcp_new返回 NULL。tcp_write的第四个参数用TCP_WRITE_FLAG_COPY,让协议栈自己拷贝一份数据,这样pbuf_free之后不会出现野指针。tcp_listen的返回值是一个新的 pcb,原来那个已经被内部释放了,不能再对它做任何操作。写完这个服务,用网络调试工具连上去,发几十个字节看看回显对不对,再发几 KB 看看会不会断,基本就能判断出TCP_MSS和TCP_SND_BUF设得够不够。
6.2 参数调优:吞吐和稳定性的取舍
ping通之后最常见的抱怨是“能通但特别慢”。LwIP 默认参数是按低资源场景配的,拿来跑文件传输肯定不够。
| 参数 | 默认值 | 建议值 | 影响 |
|---|---|---|---|
| TCP_MSS | 536 | 1460 | 单包最大载荷 |
| TCP_SND_BUF | 2*MSS | 8*MSS | 发送窗口,太小拷不住数据 |
| TCP_WND | 2*MSS | 8*MSS | 接收窗口,直接限制对方发送速度 |
| MEM_SIZE | 1600 | 16384 | 协议栈堆,pbuf 和连接控制块都从这里分 |
| PBUF_POOL_SIZE | 16 | 32 | 收包缓冲池,决定能扛多少突发 |
| ETH_RXBUFNB | 4 | 8 | DMA 接收描述符个数 |
改TCP_MSS的时候有个前提,netif->mtu必须跟着改成 1500,两者不一致会导致分片逻辑出错。MEM_SIZE提到 16KB 之后,F407 的 128KB SRAM 还算宽裕,但要是同时用 FreeRTOS 开好几个任务,得算一下栈加起来会不会超。我一般会在main.c里留一个空闲内存的打印,跑压力测试的时候盯着它,掉到 2KB 以下就该加内存或者砍功能了。
6.3 链路检测和重连不能省
现场设备拔网线是常态,不处理的话协议栈会一直往一个不存在的链路上发数据,TCP_SND_BUF满了之后应用层直接卡住。常见的做法是 500ms 轮询一次 BSR 寄存器的 bit2,状态翻转时调用netif_set_link_up或者netif_set_link_down,并把现有连接全部清掉。
void link_check_task(void) { uint32_t bsr = 0; static uint8_t last_state = 0; /* BSR 的 bit2 是链路状态,读两次能消掉锁存带来的抖动 */ HAL_ETH_ReadPHYRegister(&heth, phy_addr, 0x01, (uint16_t *)&bsr); uint8_t now = (bsr & 0x0004) ? 1 : 0; if (now != last_state) { if (now) netif_set_link_up(&gnetif); else netif_set_link_down(&gnetif); last_state = now; } }netif_set_link_down只是把网卡标成不可用,已经建立的 TCP 连接不会自己断,要在调用之前遍历tcp_active_pcbs链表,逐个tcp_abort。这段逻辑不写,设备重启后就会出现“网线插着但连不上”的情况,客户现场第一反应就是设备坏了。
那次因为少写HAL_ETH_ReleaseFrame导致收三包丢一包的问题,我调了整整一个下午才定位到,从那以后每次移植新 PHY,我都会先把ethernetif_input从头到尾数一遍释放路径,再上电。这套例程的代码量不大,但把 F407 + LwIP + DP83848 这条路上的典型问题基本都覆盖到了,拿来对着改自己的板子,能省掉不少重复折腾。希望帮到你。
本文还有配套的精品资源,点击获取