ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

GD32F407裸机LWIP移植:ENET DMA、PHY与排错验证

GD32F407裸机LWIP移植:ENET DMA、PHY与排错验证

简介:面向使用GD32F407进行裸机网络开发的嵌入式工程师与学生,这份资料聚焦在无操作系统环境下移植LWIP这一典型难题,覆盖以太网MAC驱动适配、内存池与pbuf管理、接收中断处理、定时轮询及socket应用接口等关键环节,适合希望打通MCU联网链路的初、中级开发者参考。压缩包共641个文件,约12.15MB,以216个h头文件和165个c源文件为主体,辅以Keil工程、编译中间文件、映射与说明文档,可较完整地还原工程结构与构建过程。已有1317人学习下载,说明该主题具备一定关注度。内容中包含LWIP协议栈源码、GD32F4xx以太网驱动、TCP/UDP、HTTP服务及mDNS等模块,并提供可直接对照的工程配置与排错线索,便于读者理解裸机TCP/IP移植的目录组织、依赖关系和调试要点。

1. 先别急着写代码:GD32F407 裸机跑 LWIP 的版本与硬件前提

GD32F407 无操作系统移植 LWIP,最容易让人误判的一步不是写 lwipopts.h,而是先入为主地以为“裸机等于简单”。我见过太多板子卡在 PHY 能读 ID、网口灯也亮,但 ping 始终不通;最后发现问题不在协议栈,而在 ENET DMA 描述符放错内存区、RMII 参考时钟没起来,或者 sys_now 没给 LWIP 心跳。这套环境在工业网关、串口服务器、采集板上非常常见,适合已经能把 GD32F4 点灯跑通、想省掉 RTOS 开销的人。装不好不会报编译错误,只会给你一个沉默的网口,所以下面按“环境准备—配置—驱动—排错—验证”走一遍。

2. 环境准备与工程骨架:GD32F407 固件库与 LWIP 2.x 源码

2.1 选版本:固件库、LWIP 和工具链怎么配

GD32F407 的 ENET 外设和常见 F4 系列用法接近,但固件库的寄存器命名、位定义、启动文件不能混用。我一般先确认三件事:芯片型号是 GD32F407,工程里用的是对应型号的 GD32F4xx 固件库,LWIP 选 2.x 系列源码。LWIP 1.4 和 2.x 的 API 差异不小,尤其是netif->linkoutput、sys_now、pbuf类型和tcpip相关接口,网上不少“移植成功”的代码是 1.4 的,直接拖进 2.x 工程会编译出一串类型错误。

工具链用 Keil MDK 或arm-none-eabi-gcc都行,关键是调试器能读内存、能看 map 文件。裸机 LWIP 出问题时,能打印 PHY 寄存器、能看描述符地址,比单步断点有用得多。抓包工具建议在 PC 端准备好,后面验证 ARP 和 ICMP 时会省很多时间。

组件建议选择说明
MCUGD32F407ENET 使用 RMII 接口,DMA 描述符在 SRAM
固件库与芯片型号匹配的 GD32F4xx 库不要拿其他系列库改宏硬套
LWIP2.x 系列裸机用NO_SYS=1,不移植sys_arch信号量
PHYLAN8720A 等 RMII PHYPHY 地址由硬件引脚决定,常见 0 或 1
工具链Keil MDK / GCC能输出 map,便于检查描述符地址
调试SWD + 串口打印先看 PHY ID,再看描述符 own 位

版本确定后,工程目录不要随手摊在根目录。我习惯把 LWIP 源码、移植层、应用层分开,后面换芯片或换 PHY 时只动移植层。目录结构可以照下面搭:

mkdir -p Middlewares/Third_Party/LwIP/{src,include,arch} mkdir -p Drivers/GD32F4xx/{inc,src} mkdir -p Application/Network # 将 lwip-2.x 的 src、include 拷入 Middlewares/Third_Party/LwIP # arch 下放 ethernetif.c、lwipopts.h;NO_SYS=1 时 sys_arch.c 可留空实现

这段命令只做目录骨架,不涉及具体下载来源。src和include保持 LWIP 原样,arch只放自己写的网卡驱动和配置,Application/Network放ping测试、TCP 回环或业务代码。后面编译报错时,能一眼看出是协议栈源码问题还是移植层问题。

2.2 建工程骨架:把 lwip 源码放进 Keil/GCC 工程

工程分组建议按“启动与时钟—固件库—LWIP 核心—LWIP 移植层—应用”分。启动文件、系统时钟、SysTick 属于板级基础,先保证delay_ms和串口打印能用。LWIP 核心源码文件很多,不要一股脑全加,至少包含core/、core/ipv4/、netif/ethernet.c、api/里实际用到的部分;NO_SYS=1时api/下很多 socket 相关文件可以不编。

GCC 工程可以用 Makefile 管理包含路径。下面是一段最小示例,重点是路径和宏:

# GD32F407 裸机 LWIP 工程片段 C_SOURCES += Middlewares/Third_Party/LwIP/src/core/init.c C_SOURCES += Middlewares/Third_Party/LwIP/src/core/mem.c C_SOURCES += Middlewares/Third_Party/LwIP/src/core/memp.c C_SOURCES += Middlewares/Third_Party/LwIP/src/core/pbuf.c C_SOURCES += Middlewares/Third_Party/LwIP/src/core/tcp.c C_SOURCES += Middlewares/Third_Party/LwIP/src/core/udp.c C_SOURCES += Middlewares/Third_Party/LwIP/src/core/ipv4/ip4.c C_SOURCES += Middlewares/Third_Party/LwIP/src/core/ipv4/etharp.c C_SOURCES += Middlewares/Third_Party/LwIP/src/netif/ethernet.c C_SOURCES += Middlewares/Third_Party/LwIP/arch/ethernetif.c C_INCLUDES += -IMiddlewares/Third_Party/LwIP/src/include C_INCLUDES += -IMiddlewares/Third_Party/LwIP/arch C_INCLUDES += -IDrivers/GD32F4xx/inc

这里的文件列表不是全集,而是裸机 ICMP、ARP、TCP、UDP 都打开时的核心部分。C_INCLUDES必须把arch放在前面或至少让编译器能找到lwipopts.h,否则 LWIP 会用默认配置,出现“代码里明明关了 socket,编译还在找sys/socket.h”的怪事。ethernetif.c放在arch下,不要塞进 LWIP 源码目录,升级源码时好替换。

堆栈也要提前看。裸机 LWIP 的MEM_SIZE和PBUF_POOL_SIZE都从 SRAM 里分配,启动文件的堆大小如果设得太小,mem_malloc返回空,现象是 TCP 能连但一发数据就断。GD32F407 的 SRAM 够用,但要把 ENET DMA 描述符和缓冲区放在 DMA 能访问的区域,不能放到 CCM 里。这个坑后面第 4 章会展开。

2.3 时钟与引脚:RMII 接口和 PHY 复位脚

RMII 接口第一件事不是配 GPIO,而是确认 50MHz 参考时钟从哪来。常见做法是 PHY 输出 50MHz 给 MCU 的RMII_REF_CLK,或者板子上有源晶振直接供。若时钟没起来,MAC 寄存器看起来能写,PHY 读 ID 也可能正常,但 DMA 收发包就是不动。我一般先量时钟引脚,再谈软件。

GPIO 配置按板级原理图来,下面是 GD32F4xx 固件库风格的片段,复用编号以数据手册为准:

/* 使能 ENET 和 RMII 相关 GPIO 时钟 */ rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_GPIOB); rcu_periph_clock_enable(RCU_GPIOC); rcu_periph_clock_enable(RCU_ENET); /* PA1=REF_CLK, PA2=MDIO, PA7=CRS_DV,实际复用号查手册 */ gpio_af_set(GPIOA, GPIO_AF_11, GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_7); gpio_mode_set(GPIOA, GPIO_MODE_AF, GPIO_PUPD_NONE, GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_7); gpio_output_options_set(GPIOA, GPIO_OTYPE_PP, GPIO_OSPEED_100M, GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_7); /* PC1=MDC, PC4=RXD0, PC5=RXD1 */ gpio_af_set(GPIOC, GPIO_AF_11, GPIO_PIN_1 | GPIO_PIN_4 | GPIO_PIN_5); gpio_mode_set(GPIOC, GPIO_MODE_AF, GPIO_PUPD_NONE, GPIO_PIN_1 | GPIO_PIN_4 | GPIO_PIN_5); gpio_output_options_set(GPIOC, GPIO_OTYPE_PP, GPIO_OSPEED_100M, GPIO_PIN_1 | GPIO_PIN_4 | GPIO_PIN_5);

GPIO_AF_11只是示例,GD32F4 不同封装、不同引脚可能有差异,必须对着数据手册复用表核对。GPIO_PUPD_NONE对 RMII 的 TX、REF_CLK 一般够用,RX 侧如果板子没有外部上拉,可以按 PHY 要求调整。GPIO_OSPEED_100M对 50MHz 参考时钟和 RMII 数据线是常见选择,速度太低会导致边沿变差。

PHY 复位不要省。很多模块上电后 PHY 处于不确定状态,MCU 跑起来直接读 ID 可能读到 0xFFFF。复位脚单独控制,时序按 PHY 手册留够:

/* PHY 硬复位:低电平至少 25ms,释放后等 100ms 再读 ID */ gpio_mode_set(PHY_RST_PORT, GPIO_MODE_OUTPUT, GPIO_PUPD_NONE, PHY_RST_PIN); gpio_output_options_set(PHY_RST_PORT, GPIO_OTYPE_PP, GPIO_OSPEED_2M, PHY_RST_PIN); gpio_bit_reset(PHY_RST_PORT, PHY_RST_PIN); delay_ms(50); gpio_bit_set(PHY_RST_PORT, PHY_RST_PIN); delay_ms(100);

这段代码放在 ENET MAC 初始化之前。复位后先读 PHY 的 ID 寄存器 2 和 3,能读到稳定值再继续配置自协商。若读回 0x0000 或 0xFFFF,先查 MDIO/MDC 引脚、PHY 地址和复位时序,不要急着改 LWIP 参数。

注意:PHY 地址不是固定 0,LAN8720A 这类芯片由硬件引脚决定,原理图上常见 0 或 1。扫描 0 到 31 找到能读 ID 的地址,比猜更快。

3. 配置 lwipopts.h:NO_SYS=1 下每个开关都要有理由

3.1 必须改的宏:NO_SYS、MEM_SIZE、MEMP_NUM_PBUF

裸机移植 LWIP 的第一行配置就是NO_SYS=1,它告诉协议栈没有操作系统,不能用信号量、邮箱和线程。紧接着SYS_LIGHTWEIGHT_PROT设为 0,LWIP_SOCKET和LWIP_NETCONN关掉,否则编译会去找sys/socket.h和sys_arch里的线程接口。很多人到这里能被编译错误拦住,反而是好事;真正麻烦的是内存参数配小了,编译通过,运行到 TCP 发送才死。

我一般从下面这组配置起步,再按 SRAM 和业务裁剪:

#ifndef LWIPOPTS_H #define LWIPOPTS_H #define NO_SYS 1 #define SYS_LIGHTWEIGHT_PROT 0 #define LWIP_SOCKET 0 #define LWIP_NETCONN 0 #define LWIP_NETIF_API 0 #define MEM_ALIGNMENT 4 #define MEM_SIZE (16 * 1024) #define MEMP_NUM_PBUF 16 #define PBUF_POOL_SIZE 16 #define PBUF_POOL_BUFSIZE 1524 #define LWIP_ARP 1 #define LWIP_ICMP 1 #define LWIP_UDP 1 #define LWIP_TCP 1 #define LWIP_DHCP 1 #define LWIP_DNS 1 #define TCP_MSS 1460 #define TCP_SND_BUF (4 * TCP_MSS) #define TCP_WND (4 * TCP_MSS) #define LWIP_NETIF_LINK_CALLBACK 1 #define LWIP_NETIF_STATUS_CALLBACK 1 #define LWIP_NETIF_HOSTNAME 1 #define CHECKSUM_GEN_IP 1 #define CHECKSUM_GEN_TCP 1 #define CHECKSUM_GEN_UDP 1 #define CHECKSUM_CHECK_IP 1 #define CHECKSUM_CHECK_TCP 1 #define CHECKSUM_CHECK_UDP 1 #endif

MEM_SIZE是 LWIP 自己管理的堆,16KB 对裸机 TCP 客户端不算大,但加上 pbuf pool 后总内存要算总账:PBUF_POOL_SIZE * PBUF_POOL_BUFSIZE约 24KB,再加描述符、发送缓冲和业务缓冲,40KB 左右是常见占用。GD32F407 的 SRAM 能承受,但前提是这些缓冲不在 CCM 里。MEMP_NUM_PBUF是 pbuf 结构数量,不是数据缓冲数量;PBUF_POOL_SIZE才是池里预分配的 pbuf 数。PBUF_POOL_BUFSIZE设 1524 是为了覆盖最大以太网帧并留余量,设 1500 容易在 VLAN 或对齐场景下踩边界。

TCP_SND_BUF和TCP_WND先用 4 个 MSS,稳定后再加到 8 个。有人一上来把窗口拉到 16 个 MSS,结果 SRAM 不够,mem_malloc失败,现象是 TCP 连接能建立,发几包就卡住。裸机环境没有 RTOS 帮忙回收,内存池宁小勿大,先跑通再压测。

3.2 协议裁剪:DHCP、DNS、TCP 与 UDP 取舍

LWIP_DHCP打开后,板子启动会发 DHCP Discover,网络里没有 DHCP 服务器时,要等超时才回落到静态 IP。调试阶段我通常先关 DHCP,写死 IP、网关和掩码,把 ARP 和 ICMP 跑通,再打开 DHCP 验证。DHCP 超时不是协议栈坏了,先看网线、交换机、DHCP 服务器和sys_now是否在走。

LWIP_DNS只在需要域名解析时开。裸机 DNS 查询会占用一个 UDP 控制块,查询超时依赖sys_check_timeouts,主循环卡住时 DNS 也会卡。TCP 和 UDP 按业务选:只做 Modbus TCP 就关 UDP;只做 SNMP 就关 TCP;两个都开也能跑,但内存和 pbuf 要留够。

链路状态回调也值得打开。LWIP_NETIF_LINK_CALLBACK和LWIP_NETIF_STATUS_CALLBACK打开后,可以在 PHY 插拔网线时更新netif标志,避免 LWIP 一直以为链路是 UP。回调里不要做耗时操作,裸机主循环里置个标志就行。

3.3 sys_now 与超时轮询:裸机的心跳从哪来

NO_SYS=1时 LWIP 不启动线程,所有超时都靠sys_check_timeouts()在主循环里推。sys_now()必须返回毫秒计数,否则 ARP 表项不过期、TCP 重传定时器不走、DHCP 永远超时。实现很简单,但必须保证中断里只做自增:

volatile uint32_t g_ms_ticks; void SysTick_Handler(void) { g_ms_ticks++; } uint32_t sys_now(void) { return g_ms_ticks; }

SysTick配 1ms 中断,g_ms_ticks用volatile修饰。sys_now返回 32 位毫秒值,约 49 天溢出,LWIP 的超时比较用无符号差值,短时间回绕能处理,但业务里不要拿它做绝对时间戳。主循环里要同时轮询网卡和超时:

while (1) { ethernetif_input(&gnetif); /* 从 ENET DMA 取包并交给 LWIP */ sys_check_timeouts(); /* 推进 ARP、TCP、DHCP 定时器 */ if ((g_ms_ticks - last_print) >= 1000) { last_print = g_ms_ticks; printf("link=%d ip=%s\r\n", netif_is_link_up(&gnetif), ip4addr_ntoa(netif_ip4_addr(&gnetif))); } }

ethernetif_input里如果一次取到多个包,可以循环取到没有为止,避免高流量下丢包。sys_check_timeouts调用频率不要低于 1ms 一次,主循环里有长延时就会拖慢 TCP 重传。串口打印别放在 1ms 里,放 1 秒一次足够。

提示:先确认sys_now每秒增加约 1000,再看 DHCP 和 ARP。心跳不对,后面所有超时都是玄学。

4. 网卡驱动落地:ethernetif.c 与 GD32F4 ENET DMA 描述符

4.1 ENET 初始化顺序:GPIO、时钟、DMA 描述符、PHY

ethernetif.c是 LWIP 和 GD32F4 ENET 之间的翻译层,初始化顺序错一步,后面就会用现象折磨人。我一般按“GPIO 和时钟—PHY 复位—DMA 描述符—MAC 配置—DMA 启动—netif 注册”走。描述符必须在 DMA 启动前初始化,而且地址要 4 字节对齐,放在 DMA 能访问的 SRAM。

typedef struct { volatile uint32_t status; uint32_t control; uint32_t buffer1; uint32_t buffer2; } enet_dma_desc_t; /* 描述符和缓冲放普通 SRAM,不要放 CCM */ __attribute__((aligned(4))) static enet_dma_desc_t tx_desc[4]; __attribute__((aligned(4))) static enet_dma_desc_t rx_desc[4]; __attribute__((aligned(4))) static uint8_t tx_buf[4][1524]; __attribute__((aligned(4))) static uint8_t rx_buf[4][1524];

aligned(4)对 Cortex-M4 的 DMA 描述符是底线,缓冲区也建议 4 字节对齐。发送和接收各 4 个描述符是保守配置,跑百兆全双工够用;要压测吞吐可以加到 8 个,但内存也要跟着加。描述符里的status有 OWN 位,CPU 写、DMA 读,初始化时要把 OWN 位清掉,接收描述符则相反,交给 DMA 持有。

low_level_init里先配 GPIO 和 PHY 复位,再初始化描述符环,最后开 MAC 和 DMA。PHY 自协商可以放在 MAC 配置之后,等链路 UP 再更新netif标志。初始化函数里不要加太多printf,串口太慢会拖长启动时间,先点灯或置标志。

4.2 收发包路径:low_level_output 与 low_level_input

发送路径的核心是把 LWIP 的pbuf链拷到连续发送缓冲,然后让 DMA 描述符持有 OWN 位并启动发送。下面是最小实现,省略了错误恢复:

static err_t low_level_output(struct netif *netif, struct pbuf *p) { struct pbuf *q; uint8_t *dst = tx_buf[tx_idx]; uint32_t len = p->tot_len; if (len > 1524) { return ERR_BUF; } /* pbuf 可能是链式,逐段拷贝到连续 DMA 缓冲 */ for (q = p; q != NULL; q = q->next) { memcpy(dst, q->payload, q->len); dst += q->len; } tx_desc[tx_idx].status = ENET_DMATXDESC_OWN; tx_desc[tx_idx].control = len | ENET_DMATXDESC_LS | ENET_DMATXDESC_FS; tx_desc[tx_idx].buffer1 = (uint32_t)tx_buf[tx_idx]; ENET_DMA_TX_POLL_DEMAND = 1; /* 通知 DMA 取发送描述符 */ tx_idx = (tx_idx + 1) % 4; return ERR_OK; }

ENET_DMATXDESC_OWN、LS、FS这些宏名按固件库实际定义替换。LS表示最后一个段,FS表示第一个段,单缓冲发送两个都要置。ENET_DMA_TX_POLL_DEMAND是发送轮询寄存器,写 1 触发 DMA。发送索引自增后要处理描述符是否被 DMA 释放,简单做法是发送前检查 OWN 位,忙等或丢包;更稳的做法是在发送完成中断里回收。

接收路径反过来:从接收描述符拿到帧长度,分配pbuf,把数据拷进去,再交给ethernetif_input上层。接收描述符的 OWN 位由 DMA 置位,CPU 取包后要清 OWN 并更新缓冲地址。长度要减去 4 字节 FCS,除非 MAC 配置里保留了 FCS。拷贝时注意pbuf_alloc的层类型用PBUF_RAW,让 LWIP 按以太网帧解析。

4.3 缓存与对齐:Cortex-M4 上最容易翻车的一公里

GD32F407 是 Cortex-M4,通常没有 M7 那样的 D-Cache,所以不用做 cache invalidate,但有一个更隐蔽的坑:CCM 内存不能被 ENET DMA 访问。GD32F4 的 CCM 地址从0x10000000开始,链接脚本如果默认把.bss或大数组放进去,描述符和缓冲地址落在 CCM,DMA 读写就是空的。现象很怪:PHY 正常、MAC 寄存器正常、发送计数也涨,但线上没有包。

我一般做三件事:第一,链接脚本里确认 ENET 描述符和缓冲段放在0x20000000起始的 SRAM;第二,编译后看 map 文件,搜tx_desc、rx_desc、tx_buf的地址;第三,在初始化时打印这几个地址,低于0x10000000或高于 CCM 范围都要警惕。对齐用aligned(4),缓冲区不要用局部变量,局部变量在栈上,栈也可能被链接到不可 DMA 的区域。

还有内存屏障。CPU 写完描述符和控制字后,要保证 DMA 看到的是最新值,Cortex-M4 上可以用__DSB()或在启动 DMA 前加一条屏障指令。接收侧 CPU 读完数据再清 OWN 位,顺序不能反。裸机没有 RTOS 的临界区保护,主循环和中断都会碰描述符,简单做法是收包只在主循环做,中断里只置标志,减少竞争。

5. 避坑与排查:GD32F407 裸机 LWIP 的五个现场故障

5.1 现象:PHY 能读 ID,但 ping 不通

现象很典型:串口打印 PHY ID 正常,网口灯亮,PC 上arp -a看不到板子,ping全超时。原因通常不在 LWIP,而是 ENET DMA 没真正启动,或者 RMII 参考时钟没起来。PHY 读 ID 走的是 MDIO/MDC,慢速管理接口能通,不代表数据路径能通。另一个常见原因是 MAC 配置里没有开收发使能,描述符 OWN 位初始化反了。

解决顺序:先量RMII_REF_CLK是否有 50MHz,再看 ENET 的 MAC 配置寄存器收发位是否置位,然后打印发送描述符的 OWN 位和ENET_DMA_TX_POLL_DEMAND是否写进去。用调试器看tx_desc[0].status,如果 CPU 写了 OWN 但 DMA 一直不 clears,说明 DMA 没取描述符。最后检查描述符地址是否落在 CCM,map 文件比猜靠谱。

5.2 现象:收到包但协议栈没反应

抓包能看到 PC 发来的 ARP 请求,板子侧接收描述符也有数据,但 LWIP 不回 ARP。原因多是ethernetif_input没有把 pbuf 交给netif->input,或者帧长度算错,把 FCS 也当数据传上去。还有一种情况是pbuf_alloc失败,代码里没判断返回值,直接memcpy到空指针,程序跑飞后看门狗复位,现象像“收到包但不处理”。

解决:在low_level_input里打印帧长度和目的 MAC,确认长度在 60 到 1514 之间;pbuf_alloc返回值必须判空;交给上层时用netif->input(p, netif),不要直接调ethernet_input。如果 ARP 请求能进但回复发不出去,回头查发送描述符和low_level_output的返回值。

5.3 现象:跑几分钟后死机或丢包

短时间 ping 正常,跑几分钟或一压测就死机、丢包、串口打印乱码。原因常见是内存池不够或描述符环没有回收。MEM_SIZE太小,TCP 发送申请不到内存;PBUF_POOL_SIZE太小,接收路径频繁丢包;发送描述符 OWN 位没检查,CPU 覆盖了 DMA 正在发送的缓冲。还有一种是主循环里sys_check_timeouts被长任务拖住,TCP 重传定时器失准。

解决:先把MEM_SIZE和PBUF_POOL_SIZE加一档,观察是否还死;再在发送函数里检查描述符 OWN 位,忙时返回ERR_WOULDBLOCK或丢包;主循环里把长任务切片,保证sys_check_timeouts至少 1ms 进一次。用串口打印mem_malloc失败计数,比看现象快。

5.4 现象:DHCP 一直超时

DHCP 打开后,串口一直打印超时,静态 IP 却能用。原因可能是sys_now没走、DHCP Discover 没发出去、或者网络里没有 DHCP 服务器。先确认sys_now每秒增加约 1000,再抓包看板子有没有发 DHCP Discover。如果 Discover 发了但没 Offer,查交换机端口和 DHCP 服务器;如果连 Discover 都没有,查 UDP 发送路径和netif的广播标志。

解决:调试阶段先写静态 IP 跑通 ARP/ICMP,再开 DHCP;确认LWIP_DHCP=1且netif->flags有NETIF_FLAG_BROADCAST;DHCP 超时时间不要设太短,裸机主循环卡顿时容易误判。DHCP 拿到地址后,LWIP_NETIF_STATUS_CALLBACK里更新业务状态。

5.5 现象:TCP 连接建立慢、吞吐低

TCP 能连上,但握手慢、发数据吞吐只有几十 KB/s。原因可能是TCP_WND和TCP_SND_BUF太小、PBUF_POOL_BUFSIZE不够、或者每包都走memcpy且主循环收包不及时。裸机没有线程,接收窗口靠主循环推,主循环里做 Flash 写或长延时,TCP 窗口就卡住。

解决:先把TCP_WND和TCP_SND_BUF加到 8 个 MSS,看吞吐是否上升;再把ethernetif_input改成循环取包直到空;发送路径如果 pbuf 是单链,可以开LWIP_NETIF_TX_SINGLE_PBUF减少拷贝。校验和先软件算,稳定后再考虑硬件卸载,硬件校验和配错会让包被对端丢弃,现象像“连接建立但数据不通”。

6. 跑通验证:从 PHY 寄存器、ARP 到 iperf 的逐层确认

装完不要直接 ping 一下就说成功。我一般按层验证,哪层失败就停在哪层,不跨层猜。第一层看 PHY ID 和链路状态,第二层看 MAC 收发计数,第三层看 ARP,第四层看 ICMP,最后才看 TCP/UDP 吞吐。下面这张表是我常用的确认顺序:

层级检查点工具/命令通过标准
PHYID 寄存器 2/3串口打印读出稳定值,不是 0x0000/0xFFFF
链路BSR 寄存器链路位串口打印插网线后链路 UP
MAC收发描述符 OWN 位调试器看内存发送能清 OWN,接收能置 OWN
ARPPC 的 ARP 表arp -a能看到板子 IP 和 MAC
ICMPpingping -c 4 192.168.1.1004 包全通,延迟稳定
TCP自写回环或 iperfiperf -c 192.168.1.100连接建立,吞吐稳定不崩

PC 端用 Linux 或 Windows 都行,命令略有差异:

# Linux/macOS 下确认 ARP 和 ICMP ping -c 4 192.168.1.100 arp -a | grep 192.168.1.100 # 如果板子端跑了 TCP 回环服务,可以用 nc 做一次短连接测试 printf "hello\r\n" | nc -w 2 192.168.1.100 5001

ping -c 4发 4 个 ICMP 包,先看有没有回包,再看延迟是否稳定。arp -a能看到板子 MAC,说明二层 ARP 已经通了;如果 ARP 表里是incomplete,回头查接收路径和 ARP 回复。nc只测 TCP 连接和短数据,不依赖板子端有完整 iperf 服务;要测吞吐,板子端需要有对应的 TCP 服务端,PC 端再用 iperf 压。

最后一步是稳定性:让板子连续 ping 10 分钟,同时串口打印mem_malloc失败计数和描述符错误计数。计数不涨、ping 不丢、TCP 连接不断,才算真正跑通。从那以后我每次移植裸机 LWIP,都先把 PHY ID、sys_now 和描述符地址这三件事打印出来,再谈 ping;这套习惯救过我好几次,也让我少熬了很多夜。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进