news 2026/10/26 11:05:46

GD32F407裸机移植LWIP实战:从网口灯亮ping不通到稳定TCP服务

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
GD32F407裸机移植LWIP实战:从网口灯亮ping不通到稳定TCP服务

简介:面向使用GD32F4系列芯片开展裸机网络开发的嵌入式工程师、学生与项目开发者,这份工程资料聚焦在没有操作系统时把LWIP轻量协议栈移植到GD32F407,解决以太网控制器驱动适配、内存池管理、接收中断处理、协议定时器轮询以及应用层套接字接入等关键问题。压缩包共六百四十一个文件、约十二点一五兆字节,以二百一十六个头文件和一百六十五个C源文件为主体,另含编译中间文件、工程配置、链接脚本和说明文档,既保留可编译工程,也方便按模块裁剪与二次开发。已有约一千三百一十七人学习下载,体现出该主题在裸机联网实践中的参考热度。资料覆盖底层网卡收发、协议栈配置、套接字通信示例和构建产物,读者可据此理解从硬件接口到上层接口的完整链路,并用于搭建网页服务、调试网络协议或排查收发包异常,适合需要从零搭建裸机联网原型的开发者对照学习。

1. 网口灯亮着,但就是 ping 不通

GD32F407 无操作系统移植 LWIP 的第一幕,基本都是这个场景:板子焊好了,RJ45 两个灯一个常亮一个闪,电脑网卡也显示“已连接”,可 cmd 里敲ping 192.168.1.30全是超时。有人以为是 PHY 没起来,换了两块板;有人以为是网线问题,换了三根线。实际八成的坑在软件侧:MAC 地址全 0、DMA 描述符的 OWN 位没交还、或者sys_now()返回了一个固定值导致 ARP 表永远不老化。裸机跑协议栈比挂 RTOS 更考验对链路的理解——没有线程帮你兜底,收包、超时、重传、ARP 全压在一个while(1)里,任何一环断掉,外面看到的现象都只是“灯亮着但不通”。这篇拆的是怎么把 LWIP 塞进 GD32F407 的裸机工程,怎么改网卡驱动,以及不通的时候从哪儿开始查。

2. 选型与工程骨架:LWIP 该放哪儿、开关怎么拨

2.1 先定 NO_SYS=1,别一上来就挂 RTOS

LWIP 提供三套编程接口:raw/callback API、netconn API、socket API。后两套底层依赖信号量和邮箱,必须有操作系统支撑。裸机上能用的只有 raw API,也就是全部靠回调函数驱动,对应的配置就是NO_SYS=1。

这个选择带来的直接收益是 RAM 和确定性。一套 RTOS 任务栈加内核对象,保守估计吃掉 8~16KB RAM;NO_SYS=1之后,sys_sem_new()、sys_mutex_lock()这类接口在sys.h里直接退化成空宏,连编译都不产生代码。对 GD32F407 这种 192KB SRAM 的片子来说,省下来的空间可以全给MEM_SIZE和 pbuf 池,收大包时不容易因为内存不足丢帧。

代价是编程模型变了。raw API 下所有回调——tcp_recv、tcp_sent、udp_recv——都跑在调用ethernetif_input()的那个上下文里,通常是主循环,也可能是接收中断。回调里绝对不能死等,不能写while(!flag),一旦阻塞,整条协议栈的超时处理就停摆,表现为“连接看着还在,实际已经僵死”。

维度裸机 + raw APIRTOS + netconn/socket
RAM 占用低,无任务栈高,每任务 1~4KB
编程难度回调嵌套,状态机要自己维护顺序化代码,接近 PC 写法
并发能力单线程,一次只能处理一件事多连接、长连接更从容
时序确定性高,无调度抖动受调度影响
调试手段打断点即断全栈可单步某个任务

判断标准很简单:如果设备只做一件事——采集数据上报、跑个 Modbus TCP 从站、做个简单的 Web 配置页——裸机完全够用。如果同时要跑文件系统、USB、UI,还要维持多路长连接,那就别硬扛,直接上 RTOS。我见过把裸机方案硬撑到八路 TCP 连接的工程,最后代码里全是手写的状态机,改一行要读三天。

2.2 目录怎么摆:固件库、协议栈、板级驱动三层切开

工程骨架没搭好,后面调驱动会非常痛苦。我一般按下面这个结构摆,好处是 LWIP 源码原封不动,升级版本时只替换Middlewares/Lwip整个目录,自己的改动全在target和app里。

GD32F407_LWIP_BARE ├── Firmware │ ├── CMSIS │ └── GD32F4xx_standard_peripheral # 官方外设库,不改 ├── Middlewares │ └── Lwip │ ├── src # lwip 内核,原封不动 │ │ ├── api │ │ ├── core │ │ └── netif │ └── arch # 裸机下只需要 cc.h ├── target │ ├── ethernetif.c # 网卡驱动,重点改这里 │ ├── ethernetif.h │ ├── lwipopts.h # 协议栈裁剪开关 │ └── sys_now.c # 时基 ├── app │ ├── main.c │ └── tcp_echo.c └── project └── MDK / CMakeLists.txt

几个坑点先说清楚。第一,arch目录在NO_SYS=1下不需要sys_arch.c,只需要cc.h(定义LWIP_PLATFORM_DIAG、字节序、数据类型),很多人把contrib/ports/unix或freertos整个拷进来,编译一堆未定义符号。第二,ethernetif.c官方模板里有大段#ifdef条件编译,直接删干净重写比改更省事。第三,lwipopts.h不要放在src里,放target下,靠头文件搜索路径优先级覆盖默认值。

2.3 lwipopts.h:裸机下必须拨的十几个开关

这个文件决定了协议栈的体重和能力边界,写错一个参数,现象可能千奇百怪。下面是我在 GD32F407 上跑通的配置骨架,逐条解释放在代码后面。

/* target/lwipopts.h —— 裸机模式的核心开关 */ #ifndef LWIPOPTS_H #define LWIPOPTS_H #define NO_SYS 1 /* 无 OS,raw API 模式 */ #define SYS_LIGHTWEIGHT_PROT 1 /* 用关中断实现临界区保护 */ #define LWIP_NETCONN 0 /* 依赖 OS,必须关 */ #define LWIP_SOCKET 0 #define LWIP_NETIF_API 0 #define MEM_ALIGNMENT 4 #define MEM_SIZE (16 * 1024) /* 堆,装 PBUF_RAM 和 PCB */ #define MEMP_NUM_PBUF 16 #define PBUF_POOL_SIZE 16 /* 网卡收包专用池 */ #define PBUF_POOL_BUFSIZE 1536 /* 单帧最大 1514 + 余量 */ #define ETH_PAD_SIZE 2 /* 让 IP 头 4 字节对齐,M4 上很值 */ #define CHECKSUM_BY_HARDWARE 1 /* MAC 硬件校验和,省 CPU */ #define LWIP_NETIF_TX_SINGLE_PBUF 1 /* 发送时合并成单个 pbuf */ #define LWIP_ARP 1 #define LWIP_ICMP 1 #define LWIP_UDP 1 #define LWIP_TCP 1 #define LWIP_DHCP 1 #define TCP_MSS 1460 #define TCP_WND (4 * TCP_MSS) #define TCP_SND_BUF (4 * TCP_MSS) #define MEMP_NUM_TCP_PCB 8 #define MEMP_NUM_TCP_SEG 32 #define LWIP_STATS 0 /* 发布版关掉,省 flash */ #endif

ETH_PAD_SIZE=2这个值值得单独说。GD32F407 是 Cortex-M4,访问非对齐地址不会直接 HardFault,但会走多次总线周期,更麻烦的是有些库函数(比如memcpy的优化版本、部分加密库)会假定 4 字节对齐。让 pbuf 的 payload 起始地址往前空两个字节,IP 头就天然对齐了,代价是每帧浪费 2 字节内存。这个配置必须和驱动里的地址偏移配合,改一边不改另一边,收到的包会整体错位两个字节,现象是“能收到帧但 IP 头解析全乱”。

PBUF_POOL_SIZE和MEM_SIZE的配比看场景。纯 ping 和 ARP,8 个池就够;跑 TCP 传输,接收窗口打满时内核会一次性吃掉好几个 pbuf,池子给小了会返回ERR_MEM,表现出来就是“传输大文件时速度突然掉到零又恢复”。TCP_WND设成 4 个 MSS 是保守值,想提速可以往上加,但每加一个 MSS,MEMP_NUM_TCP_SEG和 pbuf 池都得跟着加,否则内存先崩。

CHECKSUM_BY_HARDWARE=1打开后,驱动的low_level_output里要正确设置发送描述符的校验和插入位,否则对端会丢掉所有包——而且抓包时能看到完整的帧,非常迷惑。这个坑后面单独展开。

3. 网卡驱动与收发链路:一帧报文从描述符走到 pbuf

3.1 ENET 与 PHY 的初始化顺序,顺序错了后面全白搭

GD32F407 的 ENET 外设和 STM32F4 那一套非常接近,MAC + DMA 双模块,外挂 RMII 接口的 PHY。初始化有个严格顺序,跳步就会出问题:先开时钟和 GPIO 复用,再硬件复位 PHY 并等自协商完成,然后配 MAC(速率、双工、地址、过滤),接着建 DMA 描述符环,最后才使能收发。

最容易翻车的是“先使能 DMA 再配 MAC”。DMA 一开就会去读描述符,如果你这时候环还没挂好,DMA 读到的是全 0,直接进错误状态,后面怎么配都不通,而且错误标志埋在ENET_DMA_STAT里,不主动读根本看不见。

/* target/ethernetif.c —— 精简后的初始化骨架 */ static void enet_gpio_config(void) { /* ENET 在 GD32F4 上占三路时钟,少开一路引脚就没输出 */ rcu_periph_clock_enable(RCU_ENET); rcu_periph_clock_enable(RCU_ENETTX); rcu_periph_clock_enable(RCU_ENETRX); rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_GPIOB); rcu_periph_clock_enable(RCU_GPIOC); /* RMII: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_200MHZ, GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_7); /* PB11=TX_EN PB12=TXD0 PB13=TXD1 */ gpio_af_set(GPIOB, GPIO_AF_11, GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_13); gpio_mode_set(GPIOB, GPIO_MODE_AF, GPIO_PUPD_NONE, GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_13); gpio_output_options_set(GPIOB, GPIO_OTYPE_PP, GPIO_OSPEED_200MHZ, GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_13); /* 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_200MHZ, GPIO_PIN_1 | GPIO_PIN_4 | GPIO_PIN_5); }

AF 编号必须查对应型号的数据手册,GD32F4 系列 ENET 用的是 AF11,这个值和某些 STM32 型号不一致,直接抄 STM32 例程会发现 MDIO 完全没波形。引脚复用没配对,MDC/MDIO 上测不到时钟,PHY 寄存器一个都读不出来,而网口灯因为 PHY 自己上电自协商,照样会亮——这就是“灯亮不通”最典型的成因之一。

PHY 自协商要给它时间。上电复位后,PHY_BMSR的 link 位要几百毫秒才置起,代码里必须轮询等待,不能复位完立刻去读速率寄存器。

/* 等自协商完成,超时 2 秒。别省这个等待,很多板子冷启动会失败 */ static int phy_wait_link(uint16_t phy_addr, uint32_t timeout_ms) { uint32_t start = sys_now(); uint16_t bmsr; while ((sys_now() - start) < timeout_ms) { if (phy_reg_read(phy_addr, PHY_BMSR) & PHY_LINK_UP) { return 0; } } return -1; }

sys_now()在这里已经被用上了,说明时基必须在网卡初始化之前就跑起来。顺序上我一般把 SysTick 配置成 1ms 中断放在main()最前面,比任何外设初始化都早。

3.2 low_level_output:pbuf 链怎么塞进 DMA 发送描述符

这是整个驱动里最核心的函数,签名固定:err_t low_level_output(struct netif *netif, struct pbuf *p)。LWIP 传进来的p可能是一条链表,报文头在一节、载荷在另一节,而 DMA 发送描述符虽然支持链式(scatter-gather),但配置复杂、出错难查。我的做法是开一块 1524 字节的静态缓冲,先把链表拍平,再交给单个描述符。

/* 发送缓冲必须 4 字节对齐,DMA 不做非对齐搬运 */ static uint8_t tx_buf[1524] __attribute__((aligned(4))); static enet_dma_desc tx_desc[ENET_TX_DESC_CNT] __attribute__((aligned(4))); static err_t low_level_output(struct netif *netif, struct pbuf *p) { struct pbuf *q; uint32_t len = 0; uint8_t *buf = tx_buf; LWIP_UNUSED_ARG(netif); /* 1. 超长帧直接拒掉,别指望硬件帮你截断 */ if (p->tot_len > sizeof(tx_buf)) { return ERR_MEM; } /* 2. 把 pbuf 链拷成连续内存 */ for (q = p; q != NULL; q = q->next) { memcpy(buf + len, q->payload, q->len); len += q->len; } /* 3. 以太网最小帧 60 字节(不含 CRC),不够要补零 */ if (len < 60) { memset(buf + len, 0, 60 - len); len = 60; } /* 4. 等上一个描述符被 DMA 归还(OWN 位清 0) */ uint32_t guard = 0; while (tx_desc[0].status & ENET_TDES_OWN) { if (++guard > 1000000U) { return ERR_TIMEOUT; /* 链路挂了或者 DMA 卡死 */ } } /* 5. 填描述符,把 OWN 交还给 DMA */ tx_desc[0].ctlsize = len & 0x1FFF; /* 缓冲区大小 */ tx_desc[0].status = ENET_TDES_FS | ENET_TDES_LS | ENET_TDES_IC | ENET_TDES_OWN; /* 首尾合一 + 插入校验和 */ ENET_DMA->TDTADDR = (uint32_t)&tx_desc[0]; /* 通知硬件有新帧 */ return ERR_OK; }

几个参数逐个说。tx_buf的aligned(4)不能省,GD32F4 的 DMA 对非对齐的源地址会静默丢帧,不报错。p->tot_len是整条链的总长,不是单节的p->len,拿错了会只发出去报文头。补零到 60 字节这一步,很多驱动示例漏掉,后果是发出去的 ARP 请求对端收不到——因为以太网规范要求最小帧长 64 字节(含 4 字节 CRC),小于这个值的帧会被交换机或对端 PHY 直接丢弃,抓包软件也看不到。

ENET_TDES_FS | ENET_TDES_LS表示这既是一个帧的第一个描述符也是最后一个,一帧装在一个缓冲区里。IC位是“插入校验和”,配合CHECKSUM_BY_HARDWARE=1,MAC 会自动把 IP/TCP/UDP 的校验和字段填对。如果这位置了但lwipopts.h里没开硬件校验和,或者反过来,都会导致对端校验失败丢包——而且抓包看帧结构完全正常,只有算一遍校验和才发现是错的。

最后那个while循环是血泪经验。DMA 发送是异步的,你填完描述符就返回,硬件还在慢慢发。如果应用层发得比网线快(比如本地回环压测),描述符会一直被占着,不等待就直接改写缓冲区内容,发出去的是半新半旧的帧。加超时保护是为了防止 DMA 真挂了以后主循环卡死在这里,宁可丢一帧也不能让整个系统停摆。

3.3 low_level_input 与 ethernetif_input:收包为什么要拆成两层

收包方向拆两个函数,是 LWIP 的约定,也确实是好设计。low_level_input只负责跟硬件打交道,返回一个struct pbuf *;ethernetif_input负责把 pbuf 喂给协议栈,处理好引用计数。分开之后,前者是纯驱动,后者是纯粘合,调试时边界很清楚。

static struct pbuf *low_level_input(struct netif *netif) { enet_dma_desc *desc = &rx_desc[rx_idx]; struct pbuf *p = NULL, *q = NULL; uint32_t len; /* 1. OWN=1 说明 DMA 还占着,没收到新帧 */ if (desc->status & ENET_RDES_OWN) { return NULL; } /* 2. 检查错误位,坏帧直接归还描述符 */ if (desc->status & (ENET_RDES_ES | ENET_RDES_LE | ENET_RDES_OE)) { goto release; } /* 3. 帧长包含 4 字节 CRC,要减掉 */ len = (desc->status & 0x3FFF) - 4; if (len == 0 || len > 1518) { goto release; } /* 4. 按需申请 pbuf 链,PBUF_POOL 不够会自动降级到 PBUF_RAM */ p = pbuf_alloc(PBUF_RAW, len + ETH_PAD_SIZE, PBUF_POOL); if (p == NULL) { goto release; } /* 5. 逐节拷贝,注意跳过 ETH_PAD_SIZE 的对齐填充 */ uint32_t copied = 0; for (q = p; q != NULL && copied < len; q = q->next) { uint32_t chunk = LWIP_MIN(q->len, len - copied); memcpy((uint8_t *)q->payload, (uint8_t *)desc->addr + copied, chunk); copied += chunk; } release: /* 6. 无论如何都要把描述符还给 DMA,否则收满一圈就彻底停了 */ desc->status = ENET_RDES_OWN; ENET_DMA->RDTADDR = (uint32_t)&rx_desc[rx_idx]; rx_idx = (rx_idx + 1) % ENET_RX_DESC_CNT; return p; } void ethernetif_input(struct netif *netif) { struct pbuf *p; /* 一次把环里攒的帧全部处理掉,避免高负载时积压 */ while ((p = low_level_input(netif)) != NULL) { if (netif->input(p, netif) != ERR_OK) { pbuf_free(p); /* 协议栈不收,必须自己释放 */ } } }

第 6 步是整个函数最容易漏的地方。DMA 接收环是一个环,描述符用 OWN 位做握手:硬件收到帧把 OWN 置 1,软件处理完必须清 0 并重新挂到尾指针。如果中间有任何一个return路径忘了归还,那一格描述符就永久失效,环越转越小,最后完全收不到包。现象很有欺骗性——刚上电一切正常,跑几分钟或者收了几千个包以后突然不通,重启又好了。我一般会把归还逻辑放进一个统一的release:标签,所有出口都跳过去。

len + ETH_PAD_SIZE这个申请长度,是配合lwipopts.h里的ETH_PAD_SIZE=2。pbuf 分配时会自动预留这 2 字节,所以后续memcpy的偏移是正确的。如果这两个值不一致,收到的包整体偏移两个字节,IP 头的版本号字段会变成0x40之外的值,ip4_input直接丢弃,表现为“能收到以太网帧但协议栈无反应”。

3.4 中断还是轮询:我更倾向混合模式

纯轮询的写法最简单,主循环里不停调ethernetif_input(),缺点是空转耗 CPU,而且在低优先级任务里跑会拉高延迟。纯中断的写法是在ENET_IRQHandler里直接调ethernetif_input(),风险是回调上下文变成了中断——你的tcp_recv里如果做了稍微复杂的事(比如写 flash),中断服务程序就被拖长了。

实际工程里我用混合模式:中断里只清标志、置一个volatile uint8_t rx_pending,主循环看到标志再真正收包。

void ENET_IRQHandler(void) { if (ENET_DMA->STAT & ENET_DMA_STAT_RI) { /* 接收完成 */ ENET_DMA->STAT = ENET_DMA_STAT_RI; /* 写 1 清除 */ eth_rx_pending = 1; } if (ENET_DMA->STAT & ENET_DMA_STAT_NIS) { ENET_DMA->STAT = ENET_DMA_STAT_NIS; /* 清掉汇总标志 */ } }

中断里只做两件事:清标志、置位。中断服务程序越短越好,这是裸机系统的铁律。主循环那边if (eth_rx_pending) { eth_rx_pending = 0; ethernetif_input(&gnetif); },收包上下文稳定在主循环,回调里想干什么都行。

有个细节:ENET_DMA_STAT的清除方式是写 1 清零,写 0 无效。如果写成&= ~flag,标志永远清不掉,中断会反复触发,主循环被淹没,表现出来就是“程序像死机一样,但打断点又能进中断”。

4. 协议栈的心跳:sys_now、临界区与主循环顺序

4.1 裸机下这两个函数必须自己写

NO_SYS=1之后,LWIP 内核仍然需要两样东西:一个毫秒级时间戳,一个能暂时屏蔽中断的临界区。它们对应的就是sys_now()和sys_arch_protect()/sys_arch_unprotect()。

/* target/sys_now.c —— 时基,必须单调递增 */ static volatile uint32_t g_ms_ticks = 0; void SysTick_Handler(void) { g_ms_ticks++; } uint32_t sys_now(void) { return g_ms_ticks; } /* 用 PRIMASK 做临界区,比关总中断干净,能嵌套 */ static uint32_t g_primask_save; sys_prot_t sys_arch_protect(void) { uint32_t pm = __get_PRIMASK(); __disable_irq(); return pm; } void sys_arch_unprotect(sys_prot_t pval) { __set_PRIMASK(pval); /* 恢复进入前的状态,不是无脑开中断 */ }

sya_arch_unprotect里必须恢复进入前的PRIMASK,不能直接__enable_irq()。因为sys_arch_protect可能嵌套调用(内核里确实有嵌套),无条件开中断会把外层的保护提前解除,导致链表被中断打断,表现出来是很随机的内存踩踏。

sys_now()返回uint32_t,49 天溢出一次。LWIP 内部所有超时比较都写成(u32_t)(now - start) > timeout的无符号差值形式,溢出本身是安全的。但如果你的实现里用了有符号比较,或者返回了一个每次调用都变的值(比如直接从 SysTick 的 VAL 寄存器算),定时器就会永远不触发,TCP 连上以后不发数据、也不断开,像被冻住一样。

还有一个隐蔽的坑:sys_now()必须返回毫秒。如果 SysTick 配成了 1ms 但你在中断里做了分频,实际是 10ms 跳一次,所有超时时间都会被拉长 10 倍,ARP 表项该老化的时候不老化,两台设备改 IP 之后互相 ping 不通,得等很久才恢复。

4.2 从 netif_add 到 netif_set_up:网卡注册的完整顺序

协议栈启动的流程是固定的,顺序错了就不会有人调用你的low_level_init。

err_t lwip_netif_init(void) { ip4_addr_t ipaddr, netmask, gw; struct netif *ret; lwip_init(); /* 内核初始化,必须第一个调 */ IP4_ADDR(&ipaddr, 192, 168, 1, 30); IP4_ADDR(&netmask, 255, 255, 255, 0); IP4_ADDR(&gw, 192, 168, 1, 1); /* netif_add 内部会回调 ethernetif_init,最终调到 low_level_init */ ret = netif_add(&gnetif, &ipaddr, &netmask, &gw, NULL, ethernetif_init, ethernet_input); if (ret == NULL) { return ERR_IF; } netif_set_default(&gnetif); netif_set_up(&gnetif); /* 到这一步网卡才真正开始收包 */ return ERR_OK; }

ethernetif_init里要做三件事:填netif->hwaddr(6 字节 MAC)、设netif->hwaddr_len = 6、设netif->mtu = 1500,然后调low_level_init去配硬件。MAC 地址千万别留全 0——很多交换机会丢弃源 MAC 为 0 的帧,而且 ARP 请求发出去对面也不会理。我一般直接把芯片 UID 的哈希后三字节拼上前三字节的固定 OUI,保证每块板子不重复。

netif_set_up之前调ethernetif_input是没意义的,因为netif->input还没挂上,收上来的包会被直接丢掉。如果代码顺序反了,现象是“网卡初始化日志全对,但协议栈统计里rx一直是 0”。

4.3 主循环里到底按什么顺序调

裸机协议栈的“调度器”就是这个while(1),顺序和内容都很讲究。

int main(void) { systick_config(); /* 1ms 心跳,必须最先跑 */ enet_gpio_config(); lwip_netif_init(); while (1) { /* 1. 收包:把 DMA 环里攒的帧全部消化掉 */ if (eth_rx_pending) { eth_rx_pending = 0; ethernetif_input(&gnetif); } /* 2. 跑协议栈定时器:重传、ARP 老化、TCP 保活全靠它 */ sys_check_timeouts(); /* 3. 应用层逻辑:这里才是你的业务代码 */ app_poll(); /* 4. 空闲处理:有 ETHIF 的话处理一下发送完成 */ eth_tx_cleanup(); } }

sys_check_timeouts()绝对不能漏。它是整个协议栈的时间引擎,TCP 的重传定时器、ARP 表项的老化、DHCP 的租期续约、IP 分片的重组超时,全在这一个函数里驱动。漏掉它,TCP 遇到丢包就永远不重传,连接卡死;ARP 表项永远不过期,对端换了网卡就再也连不上。这个函数内部会遍历定时器链表,调用频率不要太高,主循环跑一趟调一次刚好。

顺序上,收包要放在sys_check_timeouts()前面。如果反了,超时处理完才收包,那这一轮收到的数据要等到下一轮才被处理,在网络重传窗口比较紧的时候会加剧重传。

5. 避坑与排查:GD32F407 裸机 LWIP 最容易翻车的五个点

5.1 ping 小包通、大包全超时

现象:ping 192.168.1.30全通,改成ping -l 2000 192.168.1.30就全是“请求超时”,或者偶尔回一个“需要拆分数据包但已设置 DF 标志”。

原因:PBUF_POOL_BUFSIZE设成了 1524 左右,一个 pbuf 装不下超过 MTU 的 IP 分片;或者MEM_SIZE太小,ip_reass重组时申请PBUF_RAM失败;还有一种可能是IP_FRAG没打开,发出的包不允许分片。

解决:PBUF_POOL_BUFSIZE保持 1536,把MEM_SIZE提到 16KB 以上,确认IP_FRAG=1、IP_REASSEMBLY=1、MEMP_NUM_REASSDATA至少给 4。同时用 Wireshark 抓包确认回的是 ICMP 超时还是根本没回——没回就是重组失败,回frag needed就是 MTU 配错了。

5.2 网口灯亮,抓包能看到 ARP 请求,但对端不回

现象:板子上电后,电脑侧 Wireshark 收到大量Who has 192.168.1.30? Tell 192.168.1.1,板子完全不回应;或者板子发出的 ARP 请求里源 MAC 是00:00:00:00:00:00。

原因:netif->hwaddr没填或者填成全 0;netif->hwaddr_len忘了设成 6;low_level_init里没把 MAC 地址写进ENET_MAC_ADDR0H/ADDR0L寄存器,硬件过滤时把发往本机的帧全丢了。

解决:在ethernetif_init里先把 MAC 填好再调low_level_init,驱动里把netif->hwaddr[0..5]拼成两个 32 位值写进 MAC 地址寄存器,高位那个寄存器还要置AE位表示地址有效。写完读回来比对一遍,确认真的写进去了。

5.3 跑几分钟就断网,重启又正常

现象:上电后一切正常,跑三到十分钟突然 ping 不通,抓包看不到任何来自板子的帧,板子对其他请求也不响应,但主循环没死(串口还在打印)。

原因:DMA 接收描述符没有正确归还。某条错误分支(CRC 错、溢出、长度异常)提前return,OWN位没清 0,那一格描述符永久失效。环里的描述符一个个被吃掉,吃满之后就收不到任何包了。

解决:把描述符归还逻辑抽成一个函数,所有出口统一调用;或者在错误分支里直接goto release。调试阶段可以在low_level_input里加一个计数器,统计每种错误各出现多少次,跑一晚上看哪个分支在涨。我更狠一点,直接在主循环里定期打印rx_desc数组的OWN位状态,环里如果出现连续多个非 OWN,说明有格子卡住了。

5.4 TCP 连接建立后不发数据,也不断开

现象:用调试工具connect上去,三次握手成功,但发数据没反应,服务器端也收不到;过很久也不会超时断开。

原因:sys_now()返回值不对。要么没实现,返回了常数 0;要么单位不是毫秒;要么实现里读的是SysTick->VAL这种递减计数器,算出来的时间是倒着走的。TCP 的重传定时器和保活定时器全靠sys_now()的差值,时间不走,定时器永远不触发。

解决:在sys_now()里加个断点或者串口打印,连续读几次,确认数值在稳定递增、大约每 1000 对应 1 秒。如果用的是 FreeRTOS 的xTaskGetTickCount(),注意换算成毫秒。另外确认sys_check_timeouts()真的在主循环里被调到了——用个变量计数,看是不是一直在涨。

5.5 编译链接都过,一上电就 HardFault

现象:程序下载进去,串口还没来得及打印第一行就挂了,调试器停在 HardFault 处理函数。

原因:DMA 描述符或收发缓冲区没有 4 字节对齐;tx_desc/rx_desc数组被编译器优化到栈上(局部变量),出函数就失效;栈大小不够,ethernetif_input里的 pbuf 操作把栈打穿。

解决:描述符和缓冲区一律用static加__attribute__((aligned(4))),绝对不要定义在函数内部。启动文件里的栈大小从默认的 1KB 提到 4KB 以上。如果还是进 HardFault,打开HardFault_Handler,把LR、PC、PSP打出来,用addr2line反查是哪一行——这一步比盲猜快十倍。

提示:以上五条里,第 3 条和第 5 条最容易在“看起来已经跑通”之后才爆发,建议在功能验证阶段就把长时间运行和错误注入做进去,别等到现场部署。

6. 二次开发:把 ping 通改成能扛事的 TCP 服务端

ping 通只能算及格线,真正的项目落地是从“协议栈能收发包”到“业务能稳定跑”这一段。我一般先写一个最小的 TCP echo 服务端,用它验证从三次握手到数据往返的整条链路,再往上叠业务。

/* app/tcp_echo.c —— raw API 最小可用服务端 */ static err_t echo_recv(void *arg, struct tcp_pcb *pcb, struct pbuf *p, err_t err) { if (p == NULL) { /* 对端关了连接 */ tcp_close(pcb); return ERR_OK; } if (err != ERR_OK) { pbuf_free(p); return err; } /* 把收到的数据原样发回去,注意 tcp_write 的第三个参数 */ tcp_write(pcb, p->payload, p->len, TCP_WRITE_FLAG_COPY); tcp_output(pcb); /* 不调这句,数据会一直躺在发送队列 */ tcp_recved(pcb, p->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_setprio(newpcb, TCP_PRIO_NORMAL); return ERR_OK; } void tcp_echo_init(void) { struct tcp_pcb *pcb = tcp_new(); if (pcb == NULL) { return; } tcp_bind(pcb, IP_ADDR_ANY, 8080); pcb = tcp_listen(pcb); /* 返回的是新的 pcb,别用旧的 */ tcp_accept(pcb, echo_accept); }

这里有几个 raw API 特有的坑。tcp_write的最后一个参数是TCP_WRITE_FLAG_COPY,意思让内核把数据拷一份;不加这个标志,内核只存指针,你pbuf_free(p)之后那块内存就还回池子了,发出的数据是随机的。tcp_output()必须显式调,tcp_write只是把数据放进发送队列,攒够一个 MSS 才自动发,小包场景下不主动触发就会一直等着。tcp_recved()是窗口管理,漏掉这一句,接收窗口会一点点关到 0,对端发不动,表现为“连接还在,但几十个包之后彻底没数据了”。

tcp_listen返回的是一个新的 pcb,原来的pcb已经被释放了,如果继续用旧指针,表现是 bind 成功但 accept 永远不触发。这个错误在例程里很常见,编译器不会提醒。

验证顺序我建议按这个来:先用ping -l 1472 -n 100压一遍,看丢包率;再用iperf或者自己写个发包工具跑 10 分钟,观察MEM_SIZE剩余和PBUF_POOL的耗尽情况;最后拔插网线,模拟链路抖动,看协议栈能不能自己恢复。拔插网线这一步特别能暴露问题——很多工程在 PHY link down 之后没有重新初始化 MAC,网线插回来灯亮了但数据永远不通,必须调netif_set_link_down()再netif_set_link_up(),或者干脆重新走一遍low_level_init。

想做点有用的东西,下面这几个方向改造量都不大:

方向改动点注意事项
定时上报数据app_poll()里用sys_now()做软定时别在tcp_sent回调里死等 ACK
做 Modbus TCP 从站在echo_recv里解析功能码一次recv可能收到半个报文,要自己缓冲拼包
加 DHCPnetif_add后调dhcp_start(&gnetif)保留一个静态 IP 做兜底,DHCP 失败别变砖
加网页配置HTTP 服务 +fs里塞静态页MEM_SIZE至少给到 24KB

最后说个习惯。这个方案我调了小两周,前面一周全耗在“灯亮但不通”上,后来发现是一个描述符归还的分支写漏了。从那以后我每次接手裸机网络的项目,都会先在low_level_input和low_level_output的所有出口加计数器和超时保护,跑一晚上看计数增长是否正常,再动业务代码。慢是慢了点,但总比在现场被人追着问“为什么又断了”强。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 11:05:46

Simulink电力电子仿真:从跑通到跑对的步长与模型取舍

简介&#xff1a;面向电力电子课程学习、毕业设计与工程验证场景&#xff0c;这份 Simulink 仿真资料覆盖 DC-AC、DC-DC 与 AC-DC 三类变换电路&#xff0c;包含逆变、Boost/Buck/Buck-Boost、单相及三相整流等模型与配套源程序&#xff0c;可用于理解 PWM、滤波、闭环控制及稳…

作者头像 李华
网站建设 2026/10/26 11:04:19

分布式存储监控:核心挑战与智能解决方案

1. 分布式存储监控的行业痛点与核心挑战 在金融、医疗、互联网等数据密集型行业&#xff0c;分布式存储系统已经成为支撑业务运转的基础设施。与传统集中式存储相比&#xff0c;分布式架构在扩展性和容错性方面具有明显优势&#xff0c;但同时也带来了监控复杂度指数级上升的问…

作者头像 李华
网站建设 2026/10/26 11:03:27

Kafka分布式消息系统:核心架构与生产实践指南

1. Kafka核心概念与架构解析 Kafka本质上是一个分布式流处理平台&#xff0c;最初由LinkedIn开发并开源。它最核心的设计理念是"发布-订阅"消息系统&#xff0c;但与传统消息队列相比&#xff0c;Kafka在吞吐量、持久化和水平扩展方面有着显著优势。我最早接触Kafka是…

作者头像 李华
网站建设 2026/10/26 11:03:21

Copilot+MCP+本地AI架构在自动化测试中的实践与优化

1. 项目背景与核心思路 去年在微软某产品线的自动化测试实践中&#xff0c;我们遇到了一个典型的技术矛盾&#xff1a;测试用例维护成本与迭代速度的失衡。传统自动化测试框架需要人工编写大量重复性代码&#xff0c;而AI辅助工具又存在响应延迟和隐私顾虑。经过三个月的技术验…

作者头像 李华
网站建设 2026/10/26 11:03:16

视频OCX控件注册调用全流程:位数匹配、regsvr32与排错验证

简介&#xff1a;这份海康威视OCX控件开发包面向需要在Windows应用中集成视频监控能力的程序员&#xff0c;围绕ActiveX控件封装了实时预览、抓拍、录像、云台控制、双向对讲、音量与亮度调节等接口&#xff0c;可解决自有软件快速对接海康摄像头与NVR的问题。压缩包共56个文件…

作者头像 李华
网站建设 2026/10/26 11:03:10

Keil 6.12单片机工具链升级:编译器迁移、调试与可复现构建

简介&#xff1a;Keil μVision 6.12 面向单片机入门与进阶学习者&#xff0c;尤其适合配合郭天祥单片机开发教程使用&#xff0c;覆盖8051等传统单片机以及ARM Cortex-M、Cortex-A、Cortex-R系列的部分开发场景&#xff0c;可用于课堂实验、课程设计与嵌入式基础实践。该IDE集…

作者头像 李华