3. lwIP 源码结构解析:从目录到核心机制

好,咱们直接进入 lwIP 的源码世界。说实话,我第一次打开 lwIP 源码包时,第一反应是「嗯,比 Linux 内核清爽多了」。但别被它的「轻量」二字骗了——麻雀虽小,五脏俱全。这一章,我带你把 lwIP 的骨架摸清楚。

3.1 目录结构:一张地图走遍全城

先看顶层目录。我习惯把 lwIP 的目录结构比作一个城市的规划:

  • src/ —— 核心城区,所有逻辑代码都在这里
  • doc/ —— 档案馆,文档和示例
  • test/ —— 测试场,单元测试和验证
  • contrib/ —— 外挂区,第三方移植和工具

真正干活的地方是 src/。咱们再往下挖一层:

子目录 功能 我常关注的文件
src/core/ 协议栈核心:TCP、UDP、IP、ICMP tcp.c, udp.c, ip.c, pbuf.c
src/core/ipv4/ IPv4 专属逻辑 ip4.c, ip4_addr.c
src/core/ipv6/ IPv6 专属逻辑 ip6.c, ip6_addr.c
src/netif/ 网络接口层,驱动对接 ethernet.c, bridgeif.c
src/api/ 上层 API:socket、netconn、raw sockets.c, api_lib.c
src/include/ 头文件,所有对外接口 lwip/opt.h, lwip/pbuf.h
我的习惯: 移植一个新平台时,我从来不会从头读所有文件。先看 src/include/lwip/opt.h —— 这里藏着所有配置开关。调优的第一步,就是知道哪些功能可以关掉。

3.2 核心文件:tcp.c, udp.c, ip.c 到底在干什么?

这三个文件是协议栈的「三驾马车」。咱们一个一个说。

3.2.1 ip.c —— 网络层的交通指挥

ip.c 负责 IP 数据报的收发。核心函数就两个:

  • ip_output() —— 把数据包发出去,选择路由、分片
  • ip_input() —— 收到包后,判断是 TCP、UDP 还是 ICMP,然后分发

你看,它其实不干重活,就是个交通指挥。收到一个包,检查版本号,如果是 IPv4 就走 ip4_input,IPv6 就走 ip6_input。然后根据协议字段,把包扔给 tcp_input 或 udp_input。

避坑指南: 我曾经在一个项目里发现,收到的 TCP 包总是被丢弃。查了半天,原来是 ip.c 里的校验和检查没关——而我的网卡硬件已经算过一次了。记住:硬件做了的事,软件就别重复做。

3.2.2 udp.c —— 简单粗暴,但快

UDP 的代码比 TCP 少一个数量级。核心逻辑:

  • udp_input() —— 收到 UDP 报文,根据端口号找到对应的 PCB(协议控制块),然后回调上层
  • udp_send() —— 构造 UDP 头,调用 ip_output 发出去

没有重传、没有拥塞控制、没有状态机。说白了,就是个「发出去就不管了」的协议。但正因为简单,它在 IoT 设备里用得特别多——我做过一个传感器网关,所有数据都用 UDP 上报,CPU 占用率不到 5%。

3.2.3 tcp.c —— 协议栈里最复杂的家伙

TCP 的代码量大概是 UDP 的 10 倍。核心是状态机:

状态 含义 触发事件
SYN_SENT 主动发起连接 调用 tcp_connect()
ESTABLISHED 连接建立,正常通信 收到 SYN+ACK
FIN_WAIT_1 主动关闭,发了 FIN 调用 tcp_close()
TIME_WAIT 等待 2MSL,防止旧包干扰 收到 FIN+ACK

我建议你重点关注 tcp_process() 这个函数——它实现了整个状态机。每次收到一个 TCP 段,都会跑到这里来,根据当前状态决定下一步动作。嗯,这里要注意:状态机里最容易出 bug 的地方是 SYN_RCVD 到 ESTABLISHED 的转换,我曾经在这里漏了一个 tcp_backlog 的检查,导致服务器在高并发下丢连接。

3.3 内存管理机制:pbuf 与内存池

嵌入式设备最缺什么?内存。lwIP 的内存管理是它的灵魂。核心就是 pbuf 和 内存池。

3.3.1 pbuf 结构体——数据包的容器

pbuf 是 lwIP 里所有数据包的载体。它的结构体定义在 src/include/lwip/pbuf.h:

struct pbuf {
  struct pbuf *next;    // 链表下一个节点
  void *payload;        // 数据指针
  u16_t tot_len;        // 整个链的总长度
  u16_t len;            // 当前节点的长度
  u8_t type;            // 类型:PBUF_RAM, PBUF_POOL, PBUF_ROM
  u8_t flags;           // 标志位
  u16_t ref;            // 引用计数
};

pbuf 有四种类型,我整理了一张对比表:

类型 内存来源 特点 适用场景
PBUF_RAM 堆(malloc) 可读写,大小灵活 发送数据,需要修改内容
PBUF_POOL 内存池 固定大小,分配快 接收数据,中断上下文
PBUF_ROM 只读数据区 不可修改,零拷贝 发送静态数据(如固件版本)
PBUF_REF 外部内存 引用外部缓冲区 与 DMA 配合使用
注意: 千万别在中断里分配 PBUF_RAM!malloc 不是线程安全的,而且可能引起优先级反转。我在一个项目中吃过这个亏——网卡中断里分配 pbuf,结果 malloc 触发了内存整理,导致系统死锁。后来全部改成 PBUF_POOL,问题解决。

3.3.2 内存池——预分配的艺术

lwIP 的内存池在 memp.c 里实现。它预分配了固定大小的内存块,比如 64 字节、256 字节、1518 字节(标准以太网帧大小)。

为什么用内存池?两个原因:

  • 分配快:O(1) 时间复杂度,没有碎片
  • 确定性:不会出现「分配失败但还有空闲内存」的尴尬

配置内存池大小的地方在 lwipopts.h:

// 每个池的大小和数量
#define MEMP_NUM_PBUF          16    // pbuf 数量
#define MEMP_NUM_TCP_PCB       10    // TCP 控制块数量
#define MEMP_NUM_TCP_SEG       16    // TCP 段数量
#define MEMP_NUM_UDP_PCB       6     // UDP 控制块数量

我个人习惯:先按默认值跑,然后用 memp_stats 监控实际使用量。如果某个池的 max_used 接近 size,就调大;如果大部分时间都是空的,就调小。别贪心——每个池多 10 个,可能就多占几百字节,但堆碎片可能少很多。

3.4 知识体系总览:一张图看懂 lwIP 源码

下面这张 SVG 图,把 lwIP 的目录结构、核心文件、内存管理串起来了。你可以把它当作学习路线图:

lwIP 源码结构总览 lwIP 源码结构总览 lwIP 源码根目录 src/ 核心代码 doc/ 文档 contrib/ 移植 core/ 协议核心 netif/ 接口层 api/ 上层 API include/ 头文件 tcp.c udp.c ip.c pbuf.c memp.c 内存管理机制 pbuf 类型 内存池配置 PBUF_RAM PBUF_POOL PBUF_ROM PBUF_REF 内存池配置示例 (lwipopts.h) #define MEMP_NUM_PBUF 16 // pbuf 数量 #define MEMP_NUM_TCP_PCB 10 // TCP 控制块 #define MEMP_NUM_UDP_PCB 6 // UDP 控制块 图:lwIP 源码结构、核心文件与内存管理关系

3.5 小结:你该从哪里开始读?

如果你刚接触 lwIP,我建议按这个顺序读:

  1. 先看 pbuf.c —— 理解数据怎么装、怎么传
  2. 再看 memp.c —— 理解内存怎么分配、怎么回收
  3. 然后看 ip.c —— 理解包怎么路由
  4. 最后啃 tcp.c —— 这是最难的部分,但也是最有价值的

记住:lwIP 的设计哲学是「够用就好」。它的代码量不大,但每一行都有它的道理。你读懂了 pbuf 和内存池,就等于拿到了理解整个协议栈的钥匙。