3、IP协议栈核心:IPv4与IPv6协议实现、IP分片与重组、路由表管理

好,咱们今天聊聊IP层。说实话,TCP/IP协议栈里,IP层是最容易被忽视的。很多人觉得IP不就是个包头嘛,发出去就完事了。但真到嵌入式里跑起来,你会发现坑多得很。我当年第一次在Zephyr上调通一个IPv6的通信链路,整整花了两天,最后发现是分片重组没处理好。嗯,咱们今天就把它彻底讲透。

3.1 IPv4与IPv6协议实现

Zephyr的IP层实现,说白了就是两个核心结构体:net_ipv4_hdrnet_ipv6_hdr。它们定义在include/net/ip.h里。我个人习惯是先把这两个结构体背下来,因为后面所有的抓包、调试都离不开它们。

先看IPv4头:

struct net_ipv4_hdr {
    uint8_t  vhl;      /* 版本(4位) + 头长度(4位) */
    uint8_t  tos;      /* 服务类型 */
    uint16_t len;      /* 总长度 */
    uint16_t id;       /* 标识符 */
    uint16_t offset;   /* 标志(3位) + 片偏移(13位) */
    uint8_t  ttl;      /* 生存时间 */
    uint8_t  proto;    /* 上层协议 */
    uint16_t chksum;   /* 头部校验和 */
    uint8_t  src[4];   /* 源IP地址 */
    uint8_t  dst[4];   /* 目的IP地址 */
} __packed;

这里有个细节:vhl字段同时包含了版本和头长度。为什么这么设计?说白了就是为了节省那一个字节。你想想看,在嵌入式设备里,每一个字节都可能是压死骆驼的最后一根稻草。

IPv6的头就简洁多了:

struct net_ipv6_hdr {
    uint8_t  vtc;      /* 版本(4位) + 流量类别(8位) + 流标签(20位) */
    uint16_t len;      /* 有效载荷长度 */
    uint8_t  next;     /* 下一个头 */
    uint8_t  hop_limit;/* 跳数限制 */
    uint8_t  src[16];  /* 源IPv6地址 */
    uint8_t  dst[16];  /* 目的IPv6地址 */
} __packed;

你看,IPv6把校验和去掉了,为什么?因为链路层和上层协议已经做了校验,IP层再做就是重复劳动。这个设计思路值得学习——不要做别人已经做过的事。

核心要点:Zephyr中IP层的收发入口是net_recv_data()net_send_data()。IPv4和IPv6的协议号分别是IPPROTO_TCPIPPROTO_UDP,在net_context创建时指定。

3.2 IP分片与重组

分片这个问题,我在项目中遇到过好几次。最典型的一次是某个传感器上报的数据包超过了MTU(1500字节),结果对端收不到完整数据。查了半天,发现是分片重组超时了。

Zephyr的分片重组机制,核心是net_ipv4_fragment()net_ipv6_fragment()这两个函数。它们的工作流程是这样的:

  1. 检查数据包长度是否超过接口MTU
  2. 如果超过,计算需要分多少片
  3. 每个分片复制原始IP头,修改标识符和片偏移
  4. 最后一个分片设置MF=0,其他设置MF=1
  5. 发送所有分片

重组就更讲究了。Zephyr使用了一个重组缓冲区池,每个缓冲区可以暂存一个IP包的所有分片。重组的关键参数有三个:

参数 默认值 说明
重组超时 30秒 超过这个时间未收齐所有分片,丢弃
最大分片数 64 一个IP包最多分64片
缓冲区大小 65535字节 最大IP包长度

避坑指南:我曾经在调试一个低功耗设备时,发现设备偶尔会死机。后来定位到是分片重组缓冲区耗尽导致的。建议在资源受限的设备上,适当减小重组超时时间,比如改成10秒。另外,IPv6不允许中间路由器分片,只能由源端做路径MTU发现,这个区别一定要记住。

3.3 路由表管理

路由表,说白了就是一张“下一跳”的查询表。Zephyr的路由表实现非常轻量,适合嵌入式场景。核心数据结构是struct net_route_entry

struct net_route_entry {
    sys_snode_t node;           /* 链表节点 */
    struct net_if *iface;       /* 出接口 */
    struct in6_addr addr;       /* 目标网络地址 */
    struct in6_addr netmask;    /* 子网掩码 */
    struct in6_addr gw;         /* 网关地址 */
    uint8_t prefix_len;         /* 前缀长度(IPv6用) */
    uint8_t flags;              /* 路由标志 */
    uint32_t lifetime;          /* 生存时间 */
};

路由表的管理函数主要有这几个:

  • net_route_add() — 添加路由
  • net_route_del() — 删除路由
  • net_route_lookup() — 查找路由
  • net_route_foreach() — 遍历路由表

路由查找的算法是“最长前缀匹配”。什么意思?就是查路由表时,选那个匹配位数最多的条目。举个例子:

路由表条目:
    192.168.1.0/24 → eth0
    192.168.0.0/16 → eth1
    0.0.0.0/0      → eth2 (默认路由)

目标地址:192.168.1.100
匹配结果:192.168.1.0/24(前缀24位,比16位长)

个人经验:我建议在初始化时就把默认路由配好,否则设备可能无法访问外网。另外,Zephyr支持多网络接口,每个接口可以有自己的路由表。如果你在做双网卡设备(比如以太网+WiFi),记得检查路由优先级,避免数据走错接口。

3.4 实战:配置静态路由

咱们来个实际例子。假设设备有两个网络接口:eth0(192.168.1.0/24)和wlan0(10.0.0.0/8)。我们希望发往10.0.0.0/8的数据走wlan0,其他走eth0。

/* 添加默认路由 */
struct net_route_entry route;
route.iface = net_if_get_default();
route.addr = in6addr_any;
route.netmask = in6addr_any;
route.gw = in6addr_any;
route.prefix_len = 0;
route.flags = NET_ROUTE_DEFAULT;
route.lifetime = NET_ROUTE_LIFETIME_INFINITE;

net_route_add(&route);

/* 添加特定路由 */
struct in6_addr dest = NET_IPV6_ADDR_INIT(0, 0, 0, 0, 0, 0, 10, 0);
struct in6_addr mask = NET_IPV6_ADDR_INIT(0, 0, 0, 0, 0, 0, 255, 0);
route.addr = dest;
route.netmask = mask;
route.prefix_len = 8;
route.flags = 0;

net_route_add(&route);

这段代码看起来简单,但有个坑:Zephyr的路由表是IPv6格式的,IPv4地址需要映射成IPv4-mapped IPv6地址。我刚开始没注意这个,结果路由死活不生效。

好了,IP层的内容就讲到这里。下一章咱们聊聊传输层——TCP和UDP的实现。到时候我会分享一个我在Zephyr上调试TCP重传的案例,保证让你印象深刻。