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_lenlen的区别。很多新手在这里翻车——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;                          // 用户自定义状态指针
};

这里有个容易混淆的地方:outputlinkoutput有什么区别?

嗯,简单来说: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_ipremote_port可以设置为任意值。如果你设置了具体的IP和端口,那么这个udp_pcb就变成了一个“连接式”的UDP,只接收来自特定对端的数据。如果你不设置,它就接收所有发到这个端口的数据。

避坑指南:我曾经在MTK平台上遇到过一个bug——UDP数据接收不全。查了半天发现是udp_pcb的recv_arg指针被意外覆盖了。记住,回调函数的参数一定要用全局变量或者静态变量,不要用栈上的临时变量。

4.5 四个结构体的关系:一张图看懂

说了这么多,咱们来理一理这四个结构体之间的关系:

结构体 作用 生命周期 在MTK移植中的关注点
pbuf 数据包容器 从网卡收包到应用层处理完 内存池大小、零拷贝策略
netif 网络接口描述 接口初始化到销毁 驱动注册、IP地址配置
tcp_pcb TCP连接控制块 连接建立到关闭 连接数量、内存占用
udp_pcb UDP连接控制块 创建到删除 回调函数注册

它们之间的数据流是这样的:

  1. 网卡收到数据,驱动分配一个pbuf,把数据放进去
  2. 驱动调用netif->input(pbuf, netif),把数据交给LwIP
  3. LwIP根据IP头部的协议类型,找到对应的tcp_pcb或udp_pcb
  4. 数据从pbuf里解析出来,通过回调函数传给应用层

你想想看,如果没有pbuf这个统一的数据结构,每一步都要做数据拷贝,那效率得多低?LwIP的设计者确实考虑得很周到。

总结一下:这四个结构体是LwIP的基石。pbuf管数据怎么存,netif管数据从哪个口进出,tcp_pcb和udp_pcb管数据该发给谁。搞懂了它们,你就掌握了LwIP的“骨架”。

下一章咱们会讲LwIP的内存管理策略,包括内存池和内存堆的实现。到时候你会看到,pbuf的设计和内存管理是紧密相关的。嗯,先消化今天的内容吧,有什么问题咱们在群里聊。