4、LwIP协议栈核心数据结构:pbuf结构详解、netif结构详解、tcp_pcb与udp_pcb结构
好,咱们今天来啃LwIP协议栈里最核心的几个数据结构。说实话,很多初学者一上来就被这些结构体给绕晕了。我当年刚接触MTK平台移植LwIP时,也是对着pbuf看了半天,心想这玩意儿怎么这么复杂?后来踩了几个坑,才真正理解设计者的良苦用心。
今天咱们就一个一个拆开来看。你只要把这几个结构体搞明白了,LwIP的底层逻辑基本就通了八成。
4.1 pbuf结构:数据包在协议栈里的“快递箱”
pbuf,全称是Packet Buffer。说白了,它就是LwIP里用来装网络数据包的容器。你想想看,数据从网卡收上来,经过IP层、TCP/UDP层,最后到应用层,中间要经历多次拷贝和处理。如果没有一个统一的数据结构来管理,那代码得乱成什么样?
pbuf的设计思路很巧妙——它用链表的方式把多个内存块串起来。这样做的好处是:零拷贝。数据不需要在各个层之间搬来搬去,只需要调整指针就行了。
核心要点:pbuf有四种类型,但咱们在MTK平台上最常用的是PBUF_RAM和PBUF_POOL。
来看一下pbuf的结构体定义:
struct pbuf {
struct pbuf *next; // 指向下一个pbuf节点
void *payload; // 指向实际数据的指针
u16_t tot_len; // 整个pbuf链的总长度
u16_t len; // 当前pbuf节点的数据长度
u8_t type; // pbuf类型:PBUF_RAM, PBUF_POOL, PBUF_ROM, PBUF_REF
u8_t flags; // 标志位
u16_t ref; // 引用计数
};
这里我重点说一下tot_len和len的区别。很多新手在这里翻车——len是当前这个节点里数据的长度,而tot_len是从当前节点开始,后面所有节点数据长度的总和。你想想看,如果你遍历pbuf链,每次都要用tot_len来判断是否处理完了整个包。
我的经验:在MTK平台上做数据发送时,我习惯用PBUF_RAM类型。因为你要构造一个完整的TCP报文,需要预留协议头的空间。而接收数据时,用PBUF_POOL更合适,每个pbuf节点大小固定,内存管理简单,不容易产生碎片。
pbuf的引用计数ref也很关键。它决定了这个pbuf什么时候可以被释放。我记得有一次调试一个内存泄漏问题,查了两天才发现是某个地方多调了一次pbuf_ref(),导致pbuf永远释放不掉。
避坑指南:我曾经在中断服务函数里直接操作pbuf,结果导致内存管理混乱。记住,pbuf的分配和释放尽量不要在中断上下文中进行,除非你非常清楚自己在做什么。
4.2 netif结构:网络接口的“身份证”
netif,全称Network Interface。每个网络接口(比如以太网口、Wi-Fi接口、4G模块)在LwIP里都对应一个netif结构体。它记录了接口的IP地址、MAC地址、MTU大小,以及最重要的——底层收发函数的指针。
在MTK平台上做移植时,netif是你首先要打交道的结构体。因为你要把底层的驱动函数注册到LwIP里。
struct netif {
struct netif *next; // 链表指针,所有netif串在一起
char name[2]; // 接口名称,比如"en"表示以太网
u8_t num; // 接口编号
u8_t flags; // 接口状态标志
u8_t hwaddr_len; // MAC地址长度
u8_t hwaddr[NETIF_MAX_HWADDR_LEN]; // MAC地址
ip_addr_t ip_addr; // IP地址
ip_addr_t netmask; // 子网掩码
ip_addr_t gw; // 默认网关
u16_t mtu; // 最大传输单元
netif_input_fn input; // 数据接收函数指针
netif_output_fn output; // 数据发送函数指针
netif_linkoutput_fn linkoutput; // 链路层发送函数指针
void *state; // 用户自定义状态指针
};
这里有个容易混淆的地方:output和linkoutput有什么区别?
嗯,简单来说:output是IP层调用的发送函数,它负责处理ARP解析(如果是以太网的话)。而linkoutput是直接往物理层发送数据的函数,它不关心ARP,只管把数据扔给网卡驱动。
我在MTK平台上做4G模块移植时,就只实现了linkoutput,因为4G模块不需要ARP。而做Wi-Fi移植时,两个函数都要实现。
关键点:netif的input函数指针指向的是ethernet_input()或ip_input()。这个函数由LwIP协议栈内部调用,你不需要自己实现。你只需要在网卡中断里收到数据后,调用netif->input(pbuf, netif)就行了。
4.3 tcp_pcb结构:TCP连接的“档案袋”
tcp_pcb,全称TCP Protocol Control Block。每个TCP连接都有一个对应的tcp_pcb结构体,里面记录了连接的所有状态信息。你可以把它想象成医院里每个病人的病历本——从入院到出院,所有信息都在上面。
这个结构体非常大,我挑几个最关键的字段说:
struct tcp_pcb {
ip_addr_t local_ip; // 本地IP地址
u16_t local_port; // 本地端口
ip_addr_t remote_ip; // 远端IP地址
u16_t remote_port; // 远端端口
u8_t state; // TCP状态:CLOSED, LISTEN, SYN_SENT, ESTABLISHED等
u32_t snd_nxt; // 下一个要发送的序列号
u32_t rcv_nxt; // 下一个期望接收的序列号
u16_t mss; // 最大报文段长度
u8_t sa; // 往返时间估算的平滑值
u8_t sv; // 往返时间估算的方差
// ... 还有几十个字段
};
我个人觉得,理解tcp_pcb的关键在于理解它的状态机。TCP的11种状态(CLOSED、LISTEN、SYN_SENT、SYN_RCVD、ESTABLISHED等)都记录在state字段里。LwIP内部有一个状态机函数tcp_process(),每次收到报文后都会根据当前状态和报文类型做状态迁移。
我记得有一次调试一个TCP连接频繁断开的问题,最后发现是mss设置得太小,导致分片过多,对端处理不过来。嗯,这种问题在MTK的低内存平台上特别常见。
我的建议:在MTK平台上,tcp_pcb的分配默认是静态的,数量有限。如果你需要支持大量并发连接,记得调整MEMP_NUM_TCP_PCB这个宏。我曾经在一个项目里把它从默认的10改到了50,才满足了业务需求。
4.4 udp_pcb结构:UDP连接的“轻量级档案”
相比tcp_pcb,udp_pcb就简单多了。因为UDP是无连接的,不需要维护序列号、确认号、窗口大小这些复杂的状态信息。
struct udp_pcb {
ip_addr_t local_ip; // 本地IP地址
u16_t local_port; // 本地端口
ip_addr_t remote_ip; // 远端IP地址(可设为IP_ADDR_ANY)
u16_t remote_port; // 远端端口(可设为0)
u8_t flags; // 标志位
udp_recv_fn recv; // 接收回调函数指针
void *recv_arg; // 回调函数的参数
};
你看,udp_pcb只有几个字段。最核心的是recv这个回调函数指针。当UDP收到数据时,LwIP会调用你注册的这个回调函数,把数据传给你。
这里有个细节:remote_ip和remote_port可以设置为任意值。如果你设置了具体的IP和端口,那么这个udp_pcb就变成了一个“连接式”的UDP,只接收来自特定对端的数据。如果你不设置,它就接收所有发到这个端口的数据。
避坑指南:我曾经在MTK平台上遇到过一个bug——UDP数据接收不全。查了半天发现是udp_pcb的recv_arg指针被意外覆盖了。记住,回调函数的参数一定要用全局变量或者静态变量,不要用栈上的临时变量。
4.5 四个结构体的关系:一张图看懂
说了这么多,咱们来理一理这四个结构体之间的关系:
| 结构体 | 作用 | 生命周期 | 在MTK移植中的关注点 |
|---|---|---|---|
| pbuf | 数据包容器 | 从网卡收包到应用层处理完 | 内存池大小、零拷贝策略 |
| netif | 网络接口描述 | 接口初始化到销毁 | 驱动注册、IP地址配置 |
| tcp_pcb | TCP连接控制块 | 连接建立到关闭 | 连接数量、内存占用 |
| udp_pcb | UDP连接控制块 | 创建到删除 | 回调函数注册 |
它们之间的数据流是这样的:
- 网卡收到数据,驱动分配一个pbuf,把数据放进去
- 驱动调用
netif->input(pbuf, netif),把数据交给LwIP - LwIP根据IP头部的协议类型,找到对应的tcp_pcb或udp_pcb
- 数据从pbuf里解析出来,通过回调函数传给应用层
你想想看,如果没有pbuf这个统一的数据结构,每一步都要做数据拷贝,那效率得多低?LwIP的设计者确实考虑得很周到。
总结一下:这四个结构体是LwIP的基石。pbuf管数据怎么存,netif管数据从哪个口进出,tcp_pcb和udp_pcb管数据该发给谁。搞懂了它们,你就掌握了LwIP的“骨架”。
下一章咱们会讲LwIP的内存管理策略,包括内存池和内存堆的实现。到时候你会看到,pbuf的设计和内存管理是紧密相关的。嗯,先消化今天的内容吧,有什么问题咱们在群里聊。