☰
GD32F407裸机LWIP以太网移植与ping调通实战
2026/10/26 2:42:29 网站建设 项目流程

简介:面向GD32F407裸机联网开发的完整工程源码,聚焦无操作系统环境下移植LWIP轻量级TCP/IP协议栈这一常见难点,适合具备Cortex-M4外设开发基础、希望打通以太网通信链路的嵌入式工程师与高年级学生参考。压缩包共641个文件、约12.15MB,以216个h头文件与165个c源文件为主体,辅以78个d、78个o、77个crf等编译中间文件,并包含Keil工程文件、分散加载脚本与map、lst映射清单,readme及数份txt补充了移植说明。目前已有1317人学习下载。可据此对照以太网MAC初始化、DMA收发描述符配置与中断收包流程,理解静态内存池划分与协议选项裁剪思路,并借助httpd、mdns、socket等应用层示例完成从底层驱动到上层接口的贯通,也可将map与lst作为排查内存占用和链接问题的参照。

1. 一块没上系统的 GD32F407,怎么把 ping 打通

手上这块 GD32F407 板子插着网线,串口里能打印 CS8900 的移植日志,但 PC 那边ping 192.168.1.100一直返回Request timed out。没有 RTOS,没有tcpip_thread,甚至连信号量都没有——这就是裸机移植 LWIP 的起点。GD32F4 系列的 ENET 外设和常见的那套 STM32F4 以太网 MAC 高度同源,但引脚复用、RCU 时钟门控、DMA 描述符地址对齐这几处细节一旦对不上,表现出来就是「PHY 显示 link up、协议栈一声不响」。这篇笔记拆的就是这条链路:从 RMII 时钟怎么喂、lwipopts.h 里六个必须自己算的参数、ethernetif.c 四个函数怎么改,一直到 ping 通之后怎么用丢包率和时延做回归。适合已经能把工程编译通过、但卡在「网口就是不通」这一步的开发者。

2. 裸机模型与 ENET 外设:先把数据通路对上再谈移植

移植 LWIP 这件事,玄学感最强的地方不在协议栈本身,而在「数据到底有没有从 PHY 走进内存」。协议栈是纯软件,逻辑写错了顶多跑不通;硬件通路上有一个环节没配好,现象和软件 bug 一模一样,但排查方向完全相反。所以动手之前先把三件事定下来:PHY 接口用 RMII 还是 MII、裸机下收包走哪种模型、DMA 描述符和缓冲区放在哪块 RAM。

2.1 RMII 还是 MII:引脚、时钟和 PHY 地址的三笔账

MII 要 16 根信号线,RMII 只要 9 根,代价是 MAC 和 PHY 必须共享一个 50MHz 参考时钟。GD32F407 上这个选择不是软件配置出来的,而是硬件决定之后在 SYSCFG 里写一位。RMII 模式下需要把SYSCFG_CFG1的ENET_PHY_SEL置位,很多工程忘了这一步,结果 MAC 一直按 MII 的时序去采样,收到的全是垃圾帧。

50MHz 参考时钟有两种常见供法,账要算清楚:

供法走线优点代价
PHY 侧 25MHz 晶振 + 内部 PLL 倍频到 50MHz,从 REF_CLK 引脚输出给 MCUPHY 出、MCU 进(PA1)MCU 时钟配置简单,不占 MCOPHY 的 PLL 上电稳定需要时间,初始化要留延时
MCU 用 MCO 输出 25MHz 给 PHY 的 XTAL1MCU 出、PHY 进省一颗晶振MCO 分频必须精确 25MHz,PA8 要配成 AF0,且 PHY 内部再倍频引入额外抖动

我自己更倾向第一种,理由是出问题时可以用示波器直接量 PHY 的 REF_CLK 引脚,有 50MHz 方波就说明时钟这一层没问题,排查范围立刻收窄一半。第二种供法一旦 MCO 频率偏了,现象是「偶尔能通几个包」,比彻底不通更难查。

PHY 地址是第二个容易翻车的地方。MDIO 只有 5 位地址空间(0~31),LAN8720A 由 PHYAD0 引脚决定是 0x00 还是 0x01,DP83848 常见 0x01。板子上的下拉电阻焊没焊、焊的是 4.7k 还是 10k,都会改变地址。与其翻原理图,不如上电直接扫一遍:

/* 遍历 MDIO 0~31,读 PHYIDR1(0x02)/PHYIDR2(0x03),全 0xFFFF 视为该地址无器件 */ static uint8_t phy_scan(void) { for (uint8_t addr = 0; addr < 32; addr++) { uint16_t id1 = enet_phy_read(addr, 0x02); uint16_t id2 = enet_phy_read(addr, 0x03); if ((id1 != 0xFFFF) && (id1 != 0x0000)) { printf("PHY addr=%u id=%04X%04X\r\n", addr, id1, id2); return addr; } } return 0xFF; /* 一个都没扫到,先回去查 MDC/MDIO 引脚和 PHY 供电 */ } /* GD32 的 MDIO 读:PHY 地址在 [15:11],寄存器地址在 [10:6],置 MB 后等硬件清位 */ static uint16_t enet_phy_read(uint8_t phy_addr, uint8_t reg) { ENET_MAC_PHY_CTL = ((uint32_t)phy_addr << 11) | ((uint32_t)reg << 6) | ENET_MAC_PHY_CTL_MB; uint32_t timeout = 0x000FFFFF; while ((ENET_MAC_PHY_CTL & ENET_MAC_PHY_CTL_MB) && --timeout) { } if (timeout == 0) { return 0xFFFF; /* 超时,MDC 没波形或者 PHY 没上电 */ } return (uint16_t)(ENET_MAC_PHY_DATA & 0xFFFF); }

phy_scan的返回值直接写进ethernetif->phy_addr,后面链路检测全用它。这里有个参数细节:ENET_MAC_PHY_CTL_MB是硬件自清位,轮询它比死等固定延时靠谱;超时值给 0xFFFFF 是经验值,主频 168MHz 下大约几十毫秒,足够覆盖一次 MDIO 事务,又不会在 PHY 掉电时把主循环卡死。

2.2 NO_SYS=1 下的三种收包模型

裸机意味着没有tcpip_thread,netif->input最终会同步走到ethernet_input,整个协议栈都在调用者的上下文里跑。收包模型就三种,选错了要么丢包,要么在主循环里埋一颗定时炸弹。

纯轮询:主循环里不停查接收描述符的 OWN 位。实现最简单,代价是主循环转得再快也有间隙,100M 线速下几个包连发就可能覆盖还没处理的描述符。

中断置标志 + 主循环处理:ENET 的接收中断里只做一件事——把描述符从 DMA 环上摘下来挂到一个软件队列,然后置rx_pending标志;主循环看到标志再调ethernetif_input。这是我最常用的做法,中断服务程序短到几十个周期,重活全在主循环,不会出现「中断里跑 TCP 状态机」这种让时序彻底失控的场面。

中断里直接处理:能跑,但等于把协议栈塞进中断上下文,任何一次pbuf_alloc失败或者 ARP 表操作都会拉长中断响应时间。串口、定时器这些外设的中断会被拖后,现象是「网通了,串口偶发丢字节」。

选第二种的时候要注意一件事:软件队列的长度必须大于等于接收描述符数量,否则中断里摘下来的描述符没地方放,还是得丢。我一般让描述符和队列长度都是 4 的幂,位掩码取模比取余快,也不容易写错边界。

2.3 描述符、pbuf 与内存布局:CCM 是个陷阱

GD32F407 有 192KB SRAM,其中 64KB 是 CCM(Core Coupled Memory),地址从 0x10000000 起。CCM 直连内核,CPU 访问零等待,看起来很香——但 ENET 的 DMA 走的是 AHB 总线矩阵,根本访问不到 CCM。描述符或者收发缓冲区一旦被链接脚本分配到 CCM,DMA 读到的就是一片无效地址,表现是发送计数涨、接收计数不动,或者收到的帧长度全是 0,而且不会有任何硬件报错。

这类问题的排查成本极高,因为代码逻辑完全正确。血泪经验是:在链接脚本里给以太网缓冲单独开一个段,显式落在 0x20000000 起始的 SRAM1 上,并且加编译期断言。

/* 描述符必须 4 字节对齐;缓冲区大小按最大帧 1518 向上取整 */ #define ENET_RX_DESC_NUM 4 #define ENET_TX_DESC_NUM 4 #define ENET_FRAME_MAX 1524 /* 用 section 属性把 DMA 可见的内存钉在 SRAM1,别让它掉进 CCM */ __attribute__((section(".enet_dma"), aligned(4))) static enet_dma_desc_t g_rx_desc[ENET_RX_DESC_NUM]; __attribute__((section(".enet_dma"), aligned(4))) static enet_dma_desc_t g_tx_desc[ENET_TX_DESC_NUM]; __attribute__((section(".enet_dma"), aligned(4))) static uint8_t g_rx_buf[ENET_RX_DESC_NUM][ENET_FRAME_MAX]; __attribute__((section(".enet_dma"), aligned(4))) static uint8_t g_tx_buf[ENET_TX_DESC_NUM][ENET_FRAME_MAX]; /* 编译期兜底:地址落在 0x10000000 段就直接报错,别等到板子上抓瞎 */ _Static_assert(sizeof(g_rx_desc) == ENET_RX_DESC_NUM * 16, "desc size mismatch");

链接脚本里对应加一段.enet_dma (NOLOAD) : { *(.enet_dma) } > SRAM1。NOLOAD很关键,这段内存不需要初始化,加了它启动文件的.data拷贝会跳过,省下几毫秒启动时间。_Static_assert那句检查的是描述符结构体大小,ENET 的描述符是 4 个 32 位字共 16 字节,如果哪天结构体定义被人加了字段,这里会直接编译失败,比运行时收到错帧再回头查强得多。

另外一个容易忽略的点是 pbuf 的对齐。PBUF_POOL_BUFSIZE必须按MEM_ALIGNMENT(Cortex-M4 上是 4)向上取整,LwIP 内部的mem_malloc会对齐,但pbuf_alloc从池里切的时候如果大小不是 4 的倍数,后续的memcpy会走非对齐访问,M4F 支持非对齐但会多花周期,极端情况下配合 DMA 缓冲区偏移还会踩到总线错误。

3. lwipopts.h 与 ethernetif.c:把协议栈焊进工程的两个文件

LWIP 的移植面其实很窄,真正需要动的就两个文件:lwipopts.h决定协议栈的形态和内存预算,ethernetif.c决定数据怎么进出 MAC。剩下的sys_arch.c在 NO_SYS=1 下只剩几个符号要补。把这三块拆清楚,移植工作量从「几天」压到「半天」。

3.1 lwipopts.h 里必须自己算的六个参数

网上流传的lwipopts.h大多是抄来抄去,值给得偏小,能 ping 通但一跑 TCP 就 assert。下面这六个参数得按自己的场景算,不能照抄:

宏建议值怎么算出来的
NO_SYS1裸机必须为 1,否则会去找sys_thread_new
LWIP_SOCKET/LWIP_NETCONN0 / 0关掉 socket 和 netconn API,只留 raw API,代码体积能省 30KB 以上
PBUF_POOL_SIZE16至少 2 倍于接收描述符数量,留出 ARP 队列和 TCP 重传占用的余量
PBUF_POOL_BUFSIZE1524最大帧 1518 + 2 字节对齐余量,给 1516 会在收到 1518 字节带 VLAN tag 的帧时分配失败
MEM_SIZE16 * 1024raw API 下mem_malloc主要给应用层缓存用,16KB 够跑一个 echo 服务
TCP_MSS14601500(MTU)- 20(IP 头)- 20(TCP 头),设大了会触发 IP 分片,设小了浪费带宽

PBUF_POOL_BUFSIZE这个值最容易被低估。给 1516 的时候,绝大多数 ping 和 HTTP 请求都没问题,但一旦对端发来带 802.1Q tag 的帧,长度变 1522,pbuf_alloc返回 NULL,low_level_input里如果没判空就直接memcpy,HardFault 就来了。这种事现场调试能查一整天,因为「平时都是好的」。

另外两个先关掉、跑通之后再考虑开的开关:

/* 第一版先关硬件校验和卸载,用软件算,把变量降到最少 */ #define CHECKSUM_BY_HARDWARE 0 /* 发送时如果 pbuf 是链,直接拷进单块 DMA 缓冲,避免描述符链的复杂度 */ #define LWIP_NETIF_TX_SINGLE_PBUF 1 /* 调试期把统计打开,memp 的 used/max 是排查内存泄漏的唯一黑匣子 */ #define LWIP_STATS 1 #define MEM_STATS 1 #define MEMP_STATS 1 #define LWIP_STATS_DISPLAY 1

CHECKSUM_BY_HARDWARE这一项值得单独说。GD32 的 ENET 确实支持 IP/TCP/UDP 校验和卸载,但需要在 TDES0 的 CIC 位域里指定「哪些层由硬件算」,而且初始化时要把伪首部的累加和预置进 TDES1 的高 16 位。只要偏移算错一个字节,发出去的包校验和就是错的,对端静默丢弃,你在本机抓包看到的帧完全正常——这就是典型的「软件层查不出问题,硬件层配置错了」。先用软件校验和把链路跑通,再回头开硬件卸载并做 A/B 对比,是更稳的顺序。

3.2 ethernetif.c 的四个函数逐个改

ethernetif.c是 LwIP 提供的模板,四个函数需要按自己的硬件重写。这里面low_level_output和low_level_input是核心,一个管发一个管收,剩下的low_level_init管初始化,ethernetif_input管把 pbuf 递交给协议栈。

发送路径的关键是「单缓冲 + 补零到 60 字节」:

static err_t low_level_output(struct netif *netif, struct pbuf *p) { struct pbuf *q; uint32_t len = 0; uint8_t *buf; /* 等上一轮的发送描述符被 DMA 释放,128 次轮询大概是 1us 量级 */ uint32_t guard = 128; while ((ENET_TDES0(g_tx_idx) & ENET_TDES0_OWN) && --guard) { } if (guard == 0) { return ERR_MEM; /* 描述符还没空出来,交给上层重试 */ } buf = g_tx_buf[g_tx_idx]; for (q = p; q != NULL; q = q->next) { if (len + q->len > ENET_FRAME_MAX) { return ERR_MEM; /* 超长帧直接拒,截断发出去会污染对端 */ } memcpy(&buf[len], q->payload, q->len); len += q->len; } /* 以太网最小帧 60 字节(不含 FCS),不补齐对端会当 runt 丢弃 */ if (len < 60) { memset(&buf[len], 0, 60 - len); len = 60; } ENET_TDES1(g_tx_idx) = len; ENET_TDES2(g_tx_idx) = (uint32_t)buf; /* 置 OWN 让 DMA 接管,同时标记首片和末片,单缓冲帧必须两个都置 */ ENET_TDES0(g_tx_idx) = ENET_TDES0_OWN | ENET_TDES0_FD | ENET_TDES0_LD; if (ENET_DMA_STAT & ENET_DMA_STAT_TBU) { ENET_DMA_TPOLL |= ENET_DMA_TPOLL_TPD; /* 发送缓冲空闲时手动拉一次 */ } g_tx_idx = (g_tx_idx + 1) % ENET_TX_DESC_NUM; return ERR_OK; }

guard那个轮询循环是最容易出争议的地方。理论上发送应该异步,描述符满了就返回ERR_MEM让上层重试,但裸机下没有tcpip_thread帮你重试,raw API 的tcp_write返回ERR_MEM之后应用层必须自己处理。实践中给一个短轮询,绝大多数情况下一次就过,只有在连续大量发送时才会触发重试路径,这样应用层代码能简单很多。

接收路径的两个判断必须做:

static struct pbuf *low_level_input(struct netif *netif) { struct pbuf *p = NULL, *q; uint32_t len; uint8_t *src; /* DMA 还占着这个描述符,说明没有新帧 */ if (ENET_RDES0(g_rx_idx) & ENET_RDES0_OWN) { return NULL; } /* 错误帧:CRC 错、对齐错、runt 都汇总在 ES 位,必须先归还描述符再返回 */ if (ENET_RDES0(g_rx_idx) & ENET_RDES0_ES) { ENET_RDES0(g_rx_idx) = ENET_RDES0_OWN; g_rx_idx = (g_rx_idx + 1) % ENET_RX_DESC_NUM; return NULL; } len = (ENET_RDES0(g_rx_idx) & ENET_RDES0_FL_MASK) >> ENET_RDES0_FL_SHIFT; p = pbuf_alloc(PBUF_RAW, len, PBUF_POOL); if (p != NULL) { src = (uint8_t *)ENET_RDES2(g_rx_idx); for (q = p; q != NULL; q = q->next) { memcpy(q->payload, src, q->len); src += q->len; } } /* 无论分配成功与否,描述符都要还给 DMA,否则接收环跑几帧就卡死 */ ENET_RDES0(g_rx_idx) = ENET_RDES0_OWN; g_rx_idx = (g_rx_idx + 1) % ENET_RX_DESC_NUM; return p; }

这两个ENET_RDES0(g_rx_idx) = ENET_RDES0_OWN是整个接收函数里最不能漏的语句。漏了第一处(错误帧分支),错误帧会一直占着描述符,接收环很快耗尽;漏了第二处(正常路径),现象是「ping 通第一个包之后再也没反应」,因为 DMA 已经没有可写的描述符了。这两处我在不同项目里都踩过,现在写完必查。

3.3 sys_now 与 LWIP_RAND:裸机下最容易漏的两个符号

NO_SYS=1让很多人以为sys_arch.c可以整个删掉,编译时才发现链接器不认账。LwIP 内部至少还依赖两个符号:sys_now()提供毫秒时基,给 TCP 重传定时器、ARP 表老化、sys_check_timeouts用;LWIP_RAND()给 TCP 初始序列号、DHCP 事务 ID 提供随机源。

sys_now的实现有一个隐蔽的坑:必须单调递增,而且不能被优化成每次都从硬件计数器重读。用 SysTick 中断里累加一个volatile uint32_t是最稳的:

static volatile uint32_t g_ms_tick = 0; /* SysTick_Handler 里每 1ms 调一次 */ void SysTick_Handler(void) { g_ms_tick++; } /* LwIP 的时基,单位毫秒,32 位回绕在 49 天后,裸机场景基本碰不到 */ u32_t sys_now(void) { return g_ms_tick; }

如果 SysTick 被别的模块占用了做 1us 的调度,那就得用DWT->CYCCNT换算,但要注意CYCCNT是 32 位、168MHz 下 25 秒就回绕一次,直接返回会触发 LwIP 的定时器逻辑错乱。这类问题特别隐蔽,现象是「设备跑 25 秒之后 TCP 连接莫名断开」。

LWIP_RAND在 LwIP 2.1 的opt.h里有默认定义,会调rand()。裸机工程如果没链标准库的rand,链接阶段报未定义引用;如果链了但没调srand,每次上电序列号都一样,某些对端会触发 PAWS(保护 Against Wrapped Sequence)检查,导致新建连接被拒。我一般直接覆盖掉:

/* 用 SysTick 的低位和 ADC 的噪声做种子,避免每次上电序列号相同 */ u32_t LWIP_RAND(void) { static u32_t seed = 0; if (seed == 0) { seed = SysTick->VAL ^ 0x5A5A5A5A; } seed = seed * 1103515245u + 12345u; return seed; }

SysTick->VAL在启动时读出来是个不确定值,配合固定的乘数就够用了,不需要真随机。这个函数被调用的频率很低(每次 TCP 建连一次),性能不用考虑。

4. 从 ping 到 TCP echo:协议栈跑通的三级验证

移植完成之后不要直接上应用,按「链路 → ARP/ICMP → TCP」三级往下走,每一级都有明确的判断依据。这样出问题时能立刻定位到是哪一层,而不是对着一堆抓包文件猜。

4.1 第一级:PHY 自协商与链路状态

PHY 的链路状态位在 BMSR(寄存器 0x01)的 bit2,但这个位是 latch-low 的:链路断过一次之后,读出来是 0,直到你读两次才恢复真实值。第一次读清 latch,第二次读才是当前状态。这个细节在自协商过程中尤其重要,因为协商期间链路会短暂 down 再 up。

/* 每 100ms 在主循环里调一次,返回 1 表示链路 up */ uint8_t phy_link_poll(uint8_t phy_addr) { /* 第一次读清 latch-low,第二次读才是当前值,这个顺序不能省 */ (void)enet_phy_read(phy_addr, 0x01); uint16_t bmsr = enet_phy_read(phy_addr, 0x01); uint16_t bmcr = enet_phy_read(phy_addr, 0x00); if (!(bmsr & 0x0004)) { return 0; /* link down */ } /* bit11 是自协商完成,bit8 是 100M,bit13 是全双工 */ if ((bmsr & 0x0020) && (bmcr & 0x0100)) { printf("link up: 100M %s\r\n", (bmcr & 0x2000) ? "full" : "half"); } else { printf("link up: 10M\r\n"); } return 1; }

判断里用 BMCR 而不是 BMSR 读速率,是因为 BMSR 的 bit8/bit13 反映的是「对端通告能力」,BMCR 的 bit8/bit13 才是「本端当前协商结果」。半双工在 100M 下必须开 CSMA/CD,LwIP 的netif->flags里有对应的处理,配错了现象是「小流量正常、大流量丢包严重」。

链路状态变化的时候要通知协议栈,调netif_set_link_up/netif_set_link_down。开了LWIP_NETIF_LINK_CALLBACK的话,这两个函数会回调netif_status_callback,可以在里面重连 DHCP 或者清 ARP 表。

4.2 第二级:ARP 与 ICMP,ping 通的判断顺序

ping 不通的时候按下面的顺序走,每一步都有明确的观测点,不要跳步:

  1. 看 ENET 的收包计数。GD32 的 MAC 有Frames Received统计寄存器,主循环里每秒打印一次。如果 PC 在 ping 而计数完全不涨,问题在硬件层(PHY、时钟、DMA),协议栈一行都不用看。
  2. 计数涨了但 ping 不通,看netif->ip_addr、netif->netmask、netif->gw是否设置正确,特别注意netif_set_up之后才能收包。
  3. IP 对了还不通,看 ARP。PC 上arp -a有没有学到板子的 MAC。学不到说明 ARP 请求收到了但回复没发出去,回头查low_level_output的描述符 OWN 位是不是被别的地方误清了。
  4. ARP 学到了但 ICMP 不回,查lwipopts.h里的LWIP_ICMP是否为 1,以及ethernetif_input的返回值处理。
/* 主循环里的收包入口,返回值不是 ERR_OK 时必须 free,否则内存池几秒就漏光 */ void ethernetif_input(struct netif *netif) { struct pbuf *p; do { p = low_level_input(netif); if (p != NULL) { if (netif->input(p, netif) != ERR_OK) { pbuf_free(p); /* 上层拒收,所有权还在我们手上 */ } } } while (p != NULL); /* 一次把接收环取空,别留到下一轮 */ }

do-while循环一次性把接收环清空,这一点在 4 个描述符的配置下很重要。如果主循环一次只处理一个包,而 PC 正在快速发 ARP 请求,描述符很快被 DMA 覆盖,丢包率会莫名其妙很高。

4.3 第三级:TCP echo 服务端的最小实现

TCP 跑通意味着 pbuf 的分配释放、序列号管理、超时重传都正常了,是比 ping 强得多的验证。裸机下用 raw API,代码不长但有两个所有权陷阱。

static err_t echo_recv(void *arg, struct tcp_pcb *tpcb, struct pbuf *p, err_t err) { if (err != ERR_OK) { if (p != NULL) pbuf_free(p); return err; } if (p == NULL) { /* 对端发了 FIN,关连接并清回调,顺序不能反 */ tcp_arg(tpcb, NULL); tcp_recv(tpcb, NULL); tcp_close(tpcb); return ERR_OK; } /* 收到的数据原样写回;tcp_write 只是入队,要 tcp_output 才真的发 */ if (tcp_sndbuf(tpcb) >= p->tot_len) { tcp_write(tpcb, p->payload, p->tot_len, TCP_WRITE_FLAG_COPY); tcp_output(tpcb); } /* 通知协议栈这段数据已消费,窗口才会往前推;漏了这句大流量下会卡死 */ tcp_recved(tpcb, p->tot_len); pbuf_free(p); return ERR_OK; } static err_t echo_accept(void *arg, struct tcp_pcb *newpcb, err_t err) { tcp_recv(newpcb, echo_recv); tcp_arg(newpcb, NULL); tcp_nagle_disable(newpcb); /* echo 场景关掉 Nagle,否则小包合并会加延迟 */ return ERR_OK; }

两个陷阱写在注释里了但值得再说一遍:tcp_write带TCP_WRITE_FLAG_COPY时会自己拷一份数据,所以pbuf_free是安全的;如果不带这个标志,tcp_write只是引用指针,pbuf_free之后 DMA 发出去的就是已释放内存,现象是「收到的回显数据偶尔是乱码」。tcp_recved漏调不会立刻出错,但接收窗口会逐渐收缩到 0,对端发不动数据,表现为「TCP 连接还在但传不了东西」。

5. 避坑:五个把调试时间从半天拖到一周的坑

下面这五条都是实际调试中记录下来的,按「现象 → 原因 → 解决」写,每条都对应前面某一节的具体配置。看的时候对照自己的工程,中了的立刻改。

5.1 链路与 DMA 层的坑

现象一:PHY 显示 link up,ENET 的接收帧计数一直是 0。

原因:DMA 描述符或者收发缓冲区被链接脚本分配到了 CCM(0x10000000 起始的 64KB)。CPU 能读写这块内存,所以代码里的memset、memcpy都不会报错,但 ENET 的 DMA 走 AHB 总线,物理上访问不到 CCM,读到的全是无效数据。

解决:用__attribute__((section(".enet_dma")))把描述符和缓冲区钉到 SRAM1,链接脚本里显式声明这一段,并在启动后打印一下缓冲区地址,确认在 0x20000000 范围内。

现象二:能 ping 通十几个包,然后彻底没反应,复位后重复。

原因:接收描述符数量太少(常见是配了 2 个),而主循环里还有其他耗时任务,DMA 在描述符还没被处理的时候就绕回来覆盖了。ENET 的 DMA 是环形结构,CPU 处理速度跟不上就丢帧,丢到一定程度描述符的 OWN 位状态错乱,环就死了。

解决:描述符数量至少 4 个,处理速度跟不上的场景给 8 个;ethernetif_input里用do-while一次取空接收环,别一个主循环只处理一个包。

现象三:小包 ping 通,ping -l 1400全丢。

原因:g_rx_buf每个缓冲区只分配了 512 字节,或者PBUF_POOL_BUFSIZE给得比 MTU 小。大帧进来时 DMA 写入超出缓冲区范围,直接踩坏相邻内存,或者描述符的帧长度字段读到异常值。

解决:接收缓冲区每个至少 1524 字节,PBUF_POOL_BUFSIZE同样给 1524;如果 SRAM 紧张,宁可减描述符数量也别减缓冲区大小。

5.2 内存与协议栈配置层的坑

现象四:跑几分钟后pbuf_alloc返回 NULL,串口里memp_malloc: out of memory刷屏。

原因:PBUF_POOL_SIZE太小,或者应用层收到 pbuf 之后没释放。最容易漏的是netif->input返回值不是ERR_OK的分支——上层拒收时 pbuf 的所有权还在调用者手上,不pbuf_free就漏一个。

解决:打开LWIP_STATS和MEMP_STATS,每秒打印一次memp_pbuf_pool的 used/max,看 used 是不是单调上涨。同时检查ethernetif_input和echo_recv里所有pbuf_free的路径。

现象五:开了CHECKSUM_BY_HARDWARE之后 TCP 连不上,但 ARP 和 ICMP 正常。

原因:GD32 的校验和卸载需要在 TDES0 的 CIC 位域指定卸载层级,并且把伪首部累加和预置到 TDES1 的高 16 位。ICMP 走的是 IP 层校验和,配置错也可能碰巧对;TCP 要同时算伪首部,偏移错一个字节就全错,对端静默丢弃。

解决:先把CHECKSUM_BY_HARDWARE设回 0 用软件校验和验证链路,再单独开硬件卸载,用 Wireshark 抓包对比开关前后 TCP 头的 checksum 字段,确认一致再上生产。

提示:这五条里有三条是「代码逻辑正确但硬件配置错」,现象和软件 bug 无法区分。建议在low_level_init结束时打印一行完整的配置摘要(PHY 地址、描述符地址、缓冲区地址、RMII 模式位、时钟源),出问题时先看这一行。

6. 用 ping 时延和吞吐做回归:把移植成果变成可量化的指标

「能 ping 通」只说明协议栈活着,不说明它健康。每次改配置、换 PHY、调描述符数量之后,如果没有量化指标,很容易把一次性能退化当成「网络本来就这样」。我现在固定用三个指标做回归,都在 PC 侧一条命令能跑完。

指标测法裸机 LwIP 的合理区间退化通常意味着
丢包率ping -n 200 -l 1400 <ip>0%,偶发 0.5% 以内可接受描述符不够或缓冲区太小
平均时延同上,看 avg1~3ms(百兆直连)主循环里有阻塞操作,或者 Nagle 没关
TCP 吞吐iperf3 或自写的 sink 服务单缓冲 memcpy 方式 10~20Mbps;零拷贝能到 40Mbps+pbuf 拷贝次数多,或者TCP_SND_BUF太小

吞吐这一项差距很大,原因全在low_level_output的拷贝次数上。上面给的实现是「pbuf 链 → 单块 DMA 缓冲」一次拷贝,配合LWIP_NETIF_TX_SINGLE_PBUF=1,在 168MHz 的 GD32F407 上实测能到 15Mbps 左右。要往上走就得做零拷贝:让low_level_output直接把 pbuf 的 payload 地址填进描述符,用多描述符链式发送,省掉那次memcpy。代价是 pbuf 的生命周期要跟 DMA 绑定,发送完成中断里才能pbuf_free,中断处理复杂度上一个台阶。

还有一个我每次都做的动作:把 ENET 的统计寄存器(Frames Received、Frames Transmitted、Receive CRC Error、Alignment Error)在每次回归前后各读一次,差值记在表格里。CRC Error 涨说明物理层有问题(网线、变压器、时钟抖动),Alignment Error 涨说明帧长度异常,这两类错误在协议栈层面完全看不出来。这个习惯救过我一次:某块板子 ping 时延从 2ms 抖到 20ms,协议栈日志一切正常,最后是 CRC Error 计数在以每秒几十个的速度上涨,换了一根网线就好了。

# 回归脚本:200 个大包 + 200 个小包,输出丢包和时延分布 ping -n 200 -l 1400 192.168.1.100 > big.txt ping -n 200 -l 32 192.168.1.100 > small.txt grep -E "Lost|Average" big.txt small.txt

大包和小包分开测是有讲究的。小包考验的是中断响应和协议栈处理速度,大包考验的是缓冲区大小和 DMA 搬运能力,两类问题的现象完全不同,混在一起测会互相掩盖。

从那以后我每次改完lwipopts.h或者碰了ethernetif.c,都强制走一遍「小包 200 + 大包 200 + 吞吐 + 统计寄存器差值」这套流程,哪怕只改了一个宏。裸机环境没有内存保护,一次配置失误可能要花两天才能从现象倒推回原因,而跑一遍回归只要五分钟。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询