简介:这份资源面向使用GD32F407进行嵌入式网络开发的工程师与学习者,聚焦在无操作系统环境下移植LWIP轻量级TCP/IP协议栈这一典型难题。GD32F407基于Cortex-M4内核并带FPU,适合网络通信、工业控制与智能家居等场景,而裸机移植LWIP需自行处理硬件接口适配、内存管理、中断处理与任务调度等环节,本资源正是围绕这些关键点提供的完整工程参考。压缩包共641个文件,以216个h头文件、165个c源文件为核心,辅以78个d依赖文件、78个o目标文件及Keil工程文件、链接脚本、map映射与说明文档,整体约12.15MB,目录结构清晰,便于对照源码理解协议栈与驱动的衔接方式。目前已有1315人学习下载。读者可从中获取LWIP配置、以太网MAC驱动、内存池与pbuf管理、中断收发及回调调度等可复用代码,并借助调试与性能监控思路排查丢包、内存泄漏等问题,快速搭建裸机网络通信原型。
1. 从裸机到联网:GD32F407 上跑通 LWIP 的最小闭环
手里有一块 GD32F407 的板子,网口 PHY 是常见的 LAN8720,想让它联网,但不想上 RTOS,也不想碰那些庞大到看不懂的协议栈。这个项目就是干这件事的:把 LWIP 协议栈移植到 GD32F407 裸机环境,不依赖任何操作系统,直接跑在 main 循环里。适合谁?适合那些做工业网关、串口服务器、Modbus TCP 转以太网这类场景的开发者——功能单一、实时性要求不高、但需要稳定联网,且不想为一个小功能背上一整套 RTOS 的负担。
裸机跑 LWIP 的核心矛盾在于:协议栈本身是按“有操作系统”的假设写的,它需要定时器、需要内存管理、需要任务调度。没有 OS,这些都得自己用 SysTick 和中断来模拟。这个项目把这件事拆得很清楚:PHY 驱动、MAC 收发、内存池配置、超时处理,每一块都有对应的裸机适配层。读完你至少能搞清楚,一个没有 OS 的 LWIP 到底靠什么在跑。
2. 架构拆解:裸机 LWIP 的四个关键适配层
2.1 整体分层与数据流向
先看这个项目的分层结构,从下往上依次是:
- 硬件层:GD32F407 的 ENET 外设 + LAN8720 PHY,负责物理层和 MAC 层的数据收发。
- 驱动层:
gd32f4xx_enet.c和lan8720.c,完成 PHY 初始化、自协商、链路状态检测,以及 ENET 的 DMA 描述符配置。 - 协议栈适配层:
ethernetif.c,这是 LWIP 和硬件之间的桥梁,负责把 ENET 收到的数据包递给 LWIP,把 LWIP 要发的包塞进 ENET 的发送描述符。 - 协议栈层:LWIP 本体,包括 IP、ICMP、UDP、TCP 等协议实现。
- 应用层:
main.c里的主循环,调用ethernetif_input()处理接收,调用sys_check_timeouts()处理超时。
数据流向是这样的:网线进来 → PHY → ENET DMA 写入接收缓冲区 →ethernetif_input()从缓冲区取出 pbuf → LWIP 协议栈处理 → 应用层拿到数据。发送方向反过来:应用层调tcp_write()→ LWIP 封装 →ethernetif_output()→ ENET DMA 发送 → PHY → 网线出去。
这个分层里,最容易被忽略的是中断和主循环的配合。ENET 收包是中断触发的,但 LWIP 的处理是在主循环里做的。中断里只做一件事:把收到的包挂到一个队列上,然后置个标志位。主循环看到标志位再去处理。这样做的原因是 LWIP 的很多函数不是中断安全的,在中断里直接调会出玄学问题。
2.2 内存池与 pbuf 配置:裸机下的内存从哪来
LWIP 在裸机下没有malloc可用,它依赖自己的一套内存管理机制。这个项目里用的是MEM_LIBC_MALLOC=0,也就是完全用 LWIP 自带的内存池。配置在lwipopts.h里:
// lwipopts.h 关键内存配置 #define MEM_SIZE (16 * 1024) // 堆大小,16KB #define MEMP_NUM_PBUF 16 // pbuf 池数量 #define MEMP_NUM_UDP_PCB 4 // UDP 控制块数量 #define MEMP_NUM_TCP_PCB 4 // TCP 控制块数量 #define MEMP_NUM_TCP_PCB_LISTEN 2 // 监听用 TCP 控制块 #define PBUF_POOL_SIZE 16 // pbuf 池大小 #define PBUF_POOL_BUFSIZE 1524 // 每个 pbuf 的缓冲区大小这里有几个参数需要根据实际场景调:
MEM_SIZE:LWIP 内部堆,用于动态分配。如果只跑 TCP 客户端,16KB 够用;如果要跑 HTTP 服务器,建议加到 32KB 以上。PBUF_POOL_SIZE:接收缓冲区数量。如果网络流量大,收包快于处理,这个值要加大,否则会丢包。PBUF_POOL_BUFSIZE:单个缓冲区大小,1524 是标准以太网帧最大长度(1500 + 14 帧头 + 4 CRC + 对齐)。不要改小,否则大包会被截断。
常见做法是把PBUF_POOL_SIZE设成 16 到 32 之间,MEM_SIZE设成 16KB 到 64KB 之间。具体值要看你的 GD32F407 有多少 RAM 可用——F407 有 192KB SRAM,跑 LWIP 加应用逻辑,留 64KB 给 LWIP 是比较稳妥的。
2.3 超时处理:没有 OS 的定时器怎么模拟
LWIP 内部有大量超时逻辑:TCP 重传、ARP 表项老化、IP 分片重组超时等等。有 OS 的时候,这些靠sys_timeout()和 OS 的定时器来实现。裸机下,这个项目用的是sys_check_timeouts()轮询方式。
具体做法是在 SysTick 中断里维护一个全局毫秒计数器:
// 在 SysTick 中断服务函数里 volatile uint32_t g_sys_tick = 0; void SysTick_Handler(void) { g_sys_tick++; } // LWIP 需要的毫秒时间戳 u32_t sys_now(void) { return g_sys_tick; }然后在主循环里定期调用sys_check_timeouts():
while (1) { ethernetif_input(&gnetif); // 处理接收 sys_check_timeouts(); // 处理 LWIP 超时 // 其他应用逻辑 }这里有个坑:sys_check_timeouts()的调用频率不能太低。如果主循环里有阻塞操作,比如delay_ms(100),那超时处理就会被推迟,TCP 重传会变慢,严重时连接会断。我一般会在主循环里保证sys_check_timeouts()至少每 10ms 被调用一次。如果应用里有长时间操作,就把它拆成状态机,或者用定时器中断来触发。
2.4 PHY 驱动与链路检测
LAN8720 的驱动本身不复杂,但裸机下有个细节要注意:链路状态检测不能只靠中断,得轮询。
// lan8720.c 链路状态读取 uint8_t lan8720_get_link_status(void) { uint16_t bsr = 0; // 读 PHY 基本状态寄存器 bsr = enet_phy_read(LAN8720_ADDR, LAN8720_BSR); // bit2 为 link status,1 表示链路 up if (bsr & 0x0004) { return 1; } return 0; }在主循环里定期调用这个函数,检测到链路状态变化时,重新初始化 ENET 的 DMA 描述符。为什么?因为网线拔掉再插上,PHY 会重新自协商,ENET 的 DMA 状态可能已经乱了,不重新初始化的话,收发包会卡死。这个坑我在实际项目里踩过——网线拔了再插,ping 不通,但 PHY 寄存器显示链路是 up 的,最后发现是 DMA 描述符没复位。
3. 跑起来:从零搭建裸机 LWIP 工程
3.1 工程目录结构与文件清单
这个项目的目录结构是这样的:
GD32F407_LWIP_NoOS/ ├── Drivers/ │ ├── GD32F4xx_standard_peripheral/ // GD32 标准外设库 │ └── CMSIS/ // 内核相关 ├── Middlewares/ │ └── lwip-2.1.2/ // LWIP 源码 │ ├── src/ │ │ ├── core/ // 协议栈核心 │ │ ├── netif/ // 网络接口层 │ │ └── apps/ // 应用层(可选) │ └── system/ // 适配层 ├── Hardware/ │ ├── enet/ // ENET 驱动 │ │ ├── gd32f4xx_enet.c │ │ └── lan8720.c │ └── timer/ // SysTick 配置 ├── User/ │ ├── main.c // 主程序 │ ├── lwipopts.h // LWIP 配置 │ └── ethernetif.c // 网卡适配层 └── Project/ └── MDK-ARM/ // Keil 工程文件关键文件就三个:lwipopts.h(配置)、ethernetif.c(适配)、main.c(主循环)。其他都是标准库和 LWIP 源码,不需要改。
3.2 关键配置:lwipopts.h 里必须改的几项
lwipopts.h是 LWIP 的裁剪配置文件,裸机下有几个必须改的:
// 必须定义,表示无操作系统 #define NO_SYS 1 #define SYS_LIGHTWEIGHT_PROT 0 // 使用自带的内存管理 #define MEM_LIBC_MALLOC 0 #define MEMP_MEM_MALLOC 0 // 关闭不需要的协议,节省空间 #define LWIP_IPV6 0 #define LWIP_DHCP 1 // 如果需要 DHCP 就开 #define LWIP_DNS 0 // 不需要 DNS 就关 #define LWIP_NETCONN 0 // 裸机下不用 netconn API #define LWIP_SOCKET 0 // 裸机下不用 socket API // 使用 raw API #define LWIP_RAW 1 // 超时相关 #define LWIP_TIMERS 1 #define LWIP_TIMERS_CUSTOM 0 // 用 sys_check_timeouts 轮询这里重点说两个:
NO_SYS=1:告诉 LWIP 没有操作系统,所有需要 OS 支持的功能都会被裁剪掉。LWIP_NETCONN=0和LWIP_SOCKET=0:这两个 API 需要 OS 支持,裸机下必须关掉,否则编译会报错。裸机下只能用 raw API,也就是回调函数风格的接口。
3.3 主循环与中断的配合
主循环的代码很简单,但顺序有讲究:
int main(void) { // 系统初始化 systick_config(); enet_system_setup(); lan8720_init(); // LWIP 初始化 lwip_init(); netif_add(&gnetif, &ip, &mask, &gw, NULL, ethernetif_init, ethernet_input); netif_set_default(&gnetif); // 等待链路 up while (!lan8720_get_link_status()) { delay_ms(100); } netif_set_up(&gnetif); // 主循环 while (1) { ethernetif_input(&gnetif); // 处理接收 sys_check_timeouts(); // 处理超时 // 应用逻辑 } }中断那边,ENET 的接收中断只做一件事:置标志位。
// ENET 接收中断 void ENET_IRQHandler(void) { if (enet_interrupt_flag_get(ENET_INT_RX)) { enet_interrupt_flag_clear(ENET_INT_RX); g_enet_rx_flag = 1; // 只置标志,不处理 } }然后在ethernetif_input()里检查这个标志:
err_t ethernetif_input(struct netif *netif) { if (g_enet_rx_flag) { g_enet_rx_flag = 0; // 从 DMA 接收描述符取数据,封装成 pbuf // 递给 LWIP ... } return ERR_OK; }这样做的原因是:LWIP 的ethernet_input()内部会调用memcpy、pbuf_alloc等函数,这些函数在中断上下文里调用可能出问题。放到主循环里处理,虽然延迟高一点,但稳定。
3.4 验证:ping 通与 TCP 连接测试
跑通的第一步是 ping。把板子 IP 设成192.168.1.100,电脑 IP 设成192.168.1.10,网线直连或者接同一个交换机。在电脑上 ping:
ping 192.168.1.100如果 ping 不通,按这个顺序排查:
- 看 PHY 链路状态:
lan8720_get_link_status()返回 1 吗?返回 0 说明物理链路没通,检查网线、PHY 地址、自协商配置。 - 看 ENET 中断有没有触发:在接收中断里翻转一个 GPIO,用示波器或者逻辑分析仪看有没有波形。没有波形说明 ENET 没收到包,检查 DMA 描述符配置。
- 看 LWIP 有没有收到包:在
ethernetif_input()里加打印,看有没有进这个函数。进了但 ping 不通,检查 IP 地址、子网掩码、网关配置。
ping 通了之后,可以跑一个 TCP 客户端测试。用 raw API 写一个简单的 TCP 连接:
// TCP 连接回调 err_t tcp_connected_cb(void *arg, struct tcp_pcb *tpcb, err_t err) { if (err == ERR_OK) { // 连接成功,发送数据 tcp_write(tpcb, "Hello", 5, TCP_WRITE_FLAG_COPY); tcp_output(tpcb); } return ERR_OK; } // 创建 TCP 客户端 void tcp_client_test(void) { struct tcp_pcb *pcb = tcp_new(); ip_addr_t server_ip; IP4_ADDR(&server_ip, 192, 168, 1, 10); tcp_connect(pcb, &server_ip, 8080, tcp_connected_cb); }在电脑上用netcat或者自己写个 TCP 服务器监听 8080 端口,就能看到板子发过来的数据。
4. 避坑指南:裸机 LWIP 最容易翻车的五个地方
4.1 ping 不通但 PHY 显示链路 up
现象:PHY 寄存器读出来链路是 up 的,但 ping 不通,ENET 中断也不触发。
原因:ENET 的 DMA 描述符没有正确初始化,或者描述符地址没有对齐。GD32F407 的 ENET DMA 描述符要求 4 字节对齐,如果用了uint8_t数组来定义描述符,地址可能不对齐,导致 DMA 无法正常工作。
解决:用__attribute__((aligned(4)))或者ALIGN(4)来定义描述符数组:
// 正确做法:4 字节对齐 __attribute__((aligned(4))) enet_dma_desc_t rx_desc[ENET_RXBUF_NUM]; __attribute__((aligned(4))) enet_dma_desc_t tx_desc[ENET_TXBUF_NUM];4.2 TCP 连接建立后很快断开
现象:TCP 能连上,但几秒后自动断开,抓包看到大量重传。
原因:sys_check_timeouts()调用频率太低。TCP 重传依赖这个函数来触发,如果主循环里有delay_ms(500)这种阻塞操作,重传定时器就会延迟,导致 TCP 认为丢包,触发拥塞控制,最终断开。
解决:把阻塞操作改成状态机,或者用定时器中断来触发sys_check_timeouts()。我一般会在 SysTick 中断里置个标志,主循环里检查这个标志,保证至少每 10ms 调用一次。
4.3 收包一段时间后丢包严重
现象:刚开始 ping 正常,跑一段时间后丢包率上升,甚至完全不通。
原因:PBUF_POOL_SIZE太小,收包速度快于处理速度,pbuf 池耗尽。LWIP 在 pbuf 分配失败时会直接丢包,不会阻塞等待。
解决:加大PBUF_POOL_SIZE,从 16 加到 32 或 64。同时检查ethernetif_input()里有没有及时释放 pbuf——如果处理完没有pbuf_free(),内存会泄漏。
4.4 网线拔插后无法恢复
现象:网线拔掉再插上,PHY 显示链路 up,但 ping 不通。
原因:ENET 的 DMA 描述符在链路断开时可能进入了错误状态,重新插上网线后没有复位。
解决:在链路状态检测里加复位逻辑:
if (link_status_changed) { enet_dma_desc_reset(); // 复位 DMA 描述符 enet_interrupt_enable(ENET_INT_RX); }4.5 编译报错 “undefined reference to memcpy”
现象:编译 LWIP 时提示找不到memcpy、memset等函数。
原因:LWIP 默认使用标准 C 库的memcpy,但裸机工程可能没有链接标准库,或者链接了但被裁剪掉了。
解决:在lwipopts.h里定义MEMCPY宏,指向自己实现的函数:
#define MEMCPY(dst, src, len) my_memcpy(dst, src, len) #define MEMSET(dst, val, len) my_memset(dst, val, len)然后自己实现my_memcpy和my_memset,用volatile指针防止编译器优化。
5. 二次开发:从能跑到好用,几个改造方向
5.1 加一个 HTTP 服务器:用 raw API 还是移植 httpd
LWIP 自带一个httpd应用,在apps/httpd/目录下。裸机下可以直接用,但需要改几个地方:
httpd_init()需要传入struct netif *,在netif_set_up()之后调用。httpd默认使用LWIP_HTTPD_SSI和LWIP_HTTPD_CGI,如果不需要动态页面,可以关掉,节省空间。- 文件系统方面,裸机下没有文件系统,可以用
fsdata.c把网页文件编译成 C 数组。
如果只是想要一个简单的配置页面,我一般会自己用 raw API 写一个轻量 HTTP 服务器,只处理 GET 请求,返回固定的 JSON 或者 HTML。代码量不大,但可控性更强。
5.2 内存优化:把 LWIP 塞进更小的 RAM
GD32F407 有 192KB SRAM,但有些项目里 SRAM 要分给其他外设(比如 LCD 显存、DMA 缓冲区)。如果 LWIP 占太多,就得优化。
几个方向:
- 关掉不用的协议:
LWIP_ICMP=0(不需要 ping 就关)、LWIP_DHCP=0(用静态 IP)、LWIP_IGMP=0。 - 减小
MEM_SIZE:如果只用 raw API,不用netconn,MEM_SIZE可以降到 8KB。 - 减小
PBUF_POOL_SIZE:如果网络流量不大,16 够用,甚至可以降到 8。 - 用
MEM_ALIGNMENT=4:GD32F407 是 32 位内核,4 字节对齐就够了,不用 8 字节。
我一般会先跑起来,然后用mem_malloc的统计功能看内存使用峰值,再根据峰值来调参数。LWIP 有MEM_STATS和MEMP_STATS可以打开,在lwipopts.h里定义LWIP_STATS=1和MEMP_STATS=1就行。
5.3 稳定性加固:看门狗与异常恢复
裸机跑 LWIP,最怕的是协议栈卡死。加一个看门狗是基本操作,但要注意:喂狗不能放在主循环里,否则主循环卡死了看门狗也喂不了。我一般会在 SysTick 中断里喂狗,同时用一个变量记录主循环的“心跳”:
volatile uint32_t g_main_loop_heartbeat = 0; // 主循环里 g_main_loop_heartbeat++; // SysTick 中断里 if (g_main_loop_heartbeat == last_heartbeat) { // 主循环卡死了,不喂狗,让看门狗复位 } else { last_heartbeat = g_main_loop_heartbeat; watchdog_feed(); }这样主循环卡死时,看门狗会复位系统,比协议栈卡死但系统还在跑要好——至少能自动恢复。
5.4 一个具体的改造技巧:用状态机替代阻塞延时
裸机 LWIP 项目里最常见的坏味道就是delay_ms()。我见过一个项目,主循环里有个delay_ms(1000),结果 TCP 连接每隔几秒就断一次。后来把延时改成状态机,问题就消失了。
改造方法很简单:把delay_ms(1000)拆成 100 个 10ms 的 tick,每个 tick 里调用ethernetif_input()和sys_check_timeouts()。代码大概长这样:
typedef enum { STATE_IDLE, STATE_WAIT, STATE_DONE } app_state_t; app_state_t g_state = STATE_IDLE; uint32_t g_wait_counter = 0; void app_task(void) { switch (g_state) { case STATE_IDLE: // 做初始化 g_wait_counter = 0; g_state = STATE_WAIT; break; case STATE_WAIT: g_wait_counter++; if (g_wait_counter >= 100) { // 100 * 10ms = 1s g_state = STATE_DONE; } break; case STATE_DONE: // 做后续处理 break; } }然后在主循环里每 10ms 调用一次app_task()。这样既实现了延时,又不会阻塞 LWIP 的超时处理。
从那以后我每次在裸机项目里看到delay_ms()都会条件反射地检查它会不会影响sys_check_timeouts()的调用频率,这个习惯帮我省了很多调试时间。希望帮到你。
本文还有配套的精品资源,点击获取