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 的目录结构、核心文件、内存管理串起来了。你可以把它当作学习路线图:
3.5 小结:你该从哪里开始读?
如果你刚接触 lwIP,我建议按这个顺序读:
- 先看
pbuf.c—— 理解数据怎么装、怎么传 - 再看
memp.c—— 理解内存怎么分配、怎么回收 - 然后看
ip.c—— 理解包怎么路由 - 最后啃
tcp.c—— 这是最难的部分,但也是最有价值的
记住:lwIP 的设计哲学是「够用就好」。它的代码量不大,但每一行都有它的道理。你读懂了 pbuf 和内存池,就等于拿到了理解整个协议栈的钥匙。