跳到主要内容

4.9 快速参考

走完这趟旅程,现在让我们停下来整理一下背包。

这一章我们看了很多代码,追踪了很多路径,从数据包如何被构建,到它如何被小心翼翼地分片,再到它如何在内核的转发路径中穿梭。在这个过程中,我们遇到了一大堆函数名、宏定义和结构体——它们就像散落在地上的工具,现在我们需要把它们捡回来,分门别类地放回工具箱。

这不是那种用来应付考试的「名词解释」,而是一张作弊条。当你以后在调试网络问题时,在 crash 工具里看着内存不知所措,或者在代码里想不起「那个处理分片转发选项的函数叫什么来着」,回过头来看这一节,能帮你找回那个关键的线索。

以下列出的是本章频繁出场的那些「主角」和「配角」。


方法速查

这里列出的是 IPv4 层最核心的方法。

发送路径:从传输层到网络层

这些函数负责把数据从传输层(L4)推送到网络层(L3)。虽然目的地都是网络层,但出发地和方式各有不同。

int ip_queue_xmit(struct sk_buff *skb, struct flowi *fl);

用于:TCPv4 发送数据。

这是 TCP 老大哥常用的方法。它负责把 TCP 段封装进 IP 数据包并送走。

int ip_append_data(struct sock *sk, struct flowi4 *fl4, ... );

用于:UDPv4(Corked 模式)和 ICMPv4。

这是一个「攒数据」的过程。当你在使用 UDP 的 UDP_CORK 选项,或者在发送 ICMP 报文时,内核不会每来一丁点数据就发一个包,而是先用这个方法把数据积累起来,直到显式调用发送。

struct sk_buff *ip_make_skb(struct sock *sk, struct flowi4 *fl4, ... );

历史背景:内核 2.6.39 引入。

为什么引入它? 为了给 UDP 实现一个无锁发送的快速路径。 何时调用? 当你没有设置 UDP_CORK 选项时,UDP 倾向于直接调用这个方法,一次性把数据准备好并发送,避免锁的开销。

int ip_generic_getfrag(void *from, char *to, int offset, int len, int odd, struct sk_buff *skb);

功能:通用的「搬运工」。

这是一个通用的回调函数,专门负责把数据从用户空间拷贝到 SKB 的指定位置。如果你在写自己的协议,不想重复造轮子,可以直接用它。

static int icmp_glue_bits(void *from, char *to, int offset, int len, int odd, struct sk_buff *skb);

功能:ICMPv4 专属的 getfrag 回调。

ICMP 模块调用 ip_append_data() 时,会把这个函数传进去作为 getfrag 参数。本质上还是拷贝数据,但带上了 ICMP 的特定逻辑。


接收与路由:数据包的归宿

数据包进来了,接下来怎么办?这是这些函数要解决的问题。

int ip_rcv(struct sk_buff *skb, struct net_device *dev, struct packet_type *pt, struct net_device *orig_dev);

身份:IPv4 的「总接待员」。

这是 IPv4 数据包的主接收处理函数。所有进入机器的 IPv4 包,第一站就是这里。它做最基本的安检(版本号、校验和),然后把它交给 Netfilter。

int ip_local_deliver(struct sk_buff *skb);

终点站:本地上层协议。

当路由查询确认这个包是发给本机的,ip_rcv 最终会把包交给这个函数。如果有必要,它会先执行重组,然后敲开 L4 协议(TCP/UDP)的门。

int ip_forward(struct sk_buff *skb);

中转站:转发路径。

如果你把 Linux 配置成了一台路由器,这个函数就是核心。它负责 TTL 减 1、校验和更新,然后把包推给下一个出口。

int ip_route_input_noref(...)

(此函数虽在原文逻辑中隐含,但补充以完善上下文) 这是路由查找的执行者。虽然不在上面的列表里,但它是决定调用 ip_local_deliver 还是 ip_forward 的判官。


组播:小众但重要

组播有自己的一套逻辑,虽然路径类似,但处理函数是专门的。

int ip_mr_input(struct sk_buff *skb);

功能:处理进入的组播包。

如果这是一个组播包,内核会调用它来决定是把包交给本机,还是转发到其他 MFC 表指定的接口。

static int ipmr_queue_xmit(struct net *net, struct mr_table *mrt, ...);

功能:组播的专用发送方法。

int ip_mr_forward(struct net *net, struct mr_table *mrt, ...);

功能:组播转发。


碎片化:剪切与拼贴

这是最容易出错,也最考验细节的部分。

int ip_fragment(struct sk_buff *skb, int (*output)(struct sk_buff *));

功能:分片的主宰。

当包长度超过 MTU 且允许分片时,这个函数会被调用。它包含了快慢路径的处理逻辑,把大包切成符合 MTU 的小片。

int ip_defrag(struct sk_buff *skb, u32 user);

功能:重组的主宰。

在接收端,它负责把这些散落的碎片按 ID、源地址、协议重新拼成一个完整的包。

参数 user:这个参数告诉我们是谁在请求重组(比如是普通的 IP 层,还是 Netfilter 连接跟踪)。完整的定义列表在 include/net/ip.hip_defrag_users 枚举里。

bool ip_is_fragment(const struct iphdr *iph);

功能:一眼识别碎片。

如果这个包只是个碎片(MF 标志位为 1 或 FragOffset 不为 0),它返回 true。

bool skb_has_frag_list(const struct sk_buff *skb);

功能:检查 SKB 的分片链表。

这可能是最容易让人晕的一个名字。 SKB 有两种分片方式:一种是页数组 (frags[]),另一种是 SKB 链表 (frag_list)。这个函数检查的是后者。

历史改名:以前它叫 skb_has_frags(),但在内核 2.6.37 改了名。为什么?因为太容易误导了,让人以为是在检查前者。改名后的清晰度挽救了无数工程师的 sanity。


IP 选项:那些被历史尘封的功能

现代网络很少用 IP 选项,但内核依然保留了完整的处理代码。

int ip_options_compile(struct net *net, struct ip_options *opt, struct sk_buff *skb);

功能:解析。

它把 IP 头部里的那些选项字节流解析成内核内部能懂的 ip_options 对象。

void ip_options_fragment(struct sk_buff *skb);

功能:分片时的选项清理。

当分片发生时,有些选项(比如记录路由)不需要复制到每一个碎片里。这个函数把不需要复制的选项替换成 IPOPT_NOOP。注意,只在处理第一个碎片时调用。

void ip_options_build(struct sk_buff *skb, struct ip_options *opt, __be32 daddr, struct rtable *rt, int is_frag);

功能:构建。

把解析好的 ip_options 对象写回到 IPv4 头部。参数 is_frag 实际上在所有调用里都是 0。

void ip_forward_options(struct sk_buff *skb);

功能:处理转发时的选项。

ip_rcv_options(struct sk_buff *skb);

功能:处理接收时的选项。

int ip_options_rcv_srr(struct sk_buff *skb);

功能:处理严格源路由。

如果包里带着 Strict Source Route 选项,这个函数负责确保包真的走了指定的路。

int ip_call_ra_chain(struct sk_buff *skb);

功能:处理 Router Alert 选项。

int ip_options_get_from_user(struct net *net, ...);

功能:从用户空间获取选项。

当你通过 setsockopt() 系统调用设置 IP_OPTIONS 时,内核就是用它来把你的设置拿进来。


底层与辅助

static int raw_send_hdrinc(struct sock *sk, struct flowi4 *fl4, ...);

功能:Raw Socket 的直通车。

当你使用 Raw Socket 并设置了 IP_HDRINCL 选项(意思是「我自己构造 IP 头」),内核就会调用这个方法。它直接调用 dst_output(),绕过了很多内核的自动封装逻辑。

int ip_decrease_ttl(struct iphdr *iph);

功能:TTL 减 1 并更新校验和。

别小看它。转发时每次都要调它。因为 TTL 变了,校验和必须重新算,它利用了 RFC 1072 提到的增量更新算法,效率很高。

int ip_build_and_send_pkt(struct sk_buff *skb, struct sock *sk, ...);

功能:发送 SYN ACK。

TCPv4 专用。你可以去看看 net/ipv4/tcp_ipv4.c 里的 tcp_v4_send_synack(),它就是调用的这个。


宏速查

最后是几个关键的宏,它们往往隐藏在代码的细节里。

IPCB(skb)

功能:拿到控制块。

它返回 skb->cb 指向的 inet_skb_parm 对象。在这个对象里,藏着我们需要访问的 ip_options 对象。

FRAG_CB(skb)

功能:分片专用的控制块。

它返回 skb->cb 指向的 ipfrag_skb_cb 对象。这是分片模块用来在 SKB 里藏私货的地方。

int NF_HOOK(uint8_t pf, unsigned int hook, struct sk_buff *skb, ...)

功能:Netfilter 钩子大门口。

这是 Netfilter 的核心宏。第一个参数 pf 是协议族(IPv4 是 NFPROTO_IPV4)。第二个参数是五个钩子点之一(PRE_ROUTING, LOCAL_IN 等)。如果注册的钩子没有把包扔掉,它就会调用最后的 okfn 回调继续流程。

int NF_HOOK_COND(..., bool cond)

功能:带条件的 Netfilter 钩子。

和上面的一样,但多了一个布尔参数 cond。只有当 cond 为真时,才会真正去调用 Netfilter 钩子。这是一个性能优化手段。

IPOPT_COPIED(option)

功能:检查选项标志位。

它返回选项类型中的 Copied 标志位(最高位)。如果是 1,说明这个选项必须被复制到所有分片中;如果是 0,只在第一个分片里出现。


本章小结

至此,我们走完了 IPv4 协议栈的全程。

这不仅仅是一堆函数和宏的堆砌。在这些 API 背后,隐藏着网络协议设计的权衡:为什么分片要在发送端做而重组在接收端做?为什么 IP 选项几乎没人用了但内核还要花大力气去兼容?为什么 Raw Socket 允许用户自己构造头部?

我们在这一章里构建的,不仅仅是关于 IPv4 的知识,更是一种「协议实现者」的视角。你不再只是一个调用 send() 的用户,你看到了内核如何在比特流中穿梭,如何在安全和效率之间走钢丝。

下一章,我们将把目光转向这台庞大机器的「大脑」——路由子系统。数据包不仅知道怎么到达终点,更关键的是,它怎么知道该往哪走?那个决定 ip_local_deliverip_forward 分岔路口的路由表,到底长什么样?我们下章见。


练习题

练习 1:understanding

题目:在 struct iphdr 结构体中,Internet Header Length (ihl) 字段占 4 位。假设内核读取到一个 IPv4 数据包,其 iph->ihl 的值为 7。请问该数据包的 IP 头部长度是多少字节?是否包含 IP 选项?

答案与解析

答案:28 字节;包含 IP 选项。

解析:根据知识点定义,ihl 表示头部长度是以 4 字节为单位。计算公式为:头部长度 = ihl * 4。当 ihl=7 时,长度为 7 * 4 = 28 字节。IPv4 头部固定部分为 20 字节(对应 ihl=5)。任何大于 20 字节的头部都意味着存在 IP 选项(可选部分)。28 > 20,因此该数据包包含 8 字节的 IP 选项。

练习 2:understanding

题目:内核函数 ip_rcv() 在处理接收到的 IPv4 数据包时,会先执行一系列的合理性检查。请问检查失败时(如版本号不是 4 或校验和错误),内核会更新哪个统计计数器?(请写出宏定义名称)

答案与解析

答案:IPSTATS_MIB_INHDRERRORS

解析:根据章节原文中的代码片段描述,当 iph->ihl < 5 或 iph->version != 4 时,代码会跳转到 inhdr_error 标签。原文明确提到:“the packet is dropped and the statistics (IPSTATS_MIB_INHDRERRORS) are updated.” 这用于记录接收到的包含头部错误的 IP 数据包数量。

练习 3:application

题目:假设你需要开发一个通过 Raw Socket 发送自定义 IP 数据包的程序,并且不希望该数据包在传输过程中被路由器分片(例如,用于路径 MTU 发现测试)。你应该手动设置 IPv4 头部 frag_off 字段中的哪个标志位?请给出该标志位的常量名称及其对应的十六进制值。

答案与解析

答案:标志位名称:IP_DF (Don't Fragment flag);十六进制值:0x02。

解析:根据知识点,frag_off 字片字段的高 3 位是标志位。其中 IP_DF (Don't Fragment) 表示不允许对该数据包进行分片。原文指出:“010 is DF (Don't Fragment)”。在二进制中 010 对应十六进制的 0x02。设置此位后,如果数据包遇到 MTU 小于数据包长度的链路,路由器将丢弃该数据包并返回 ICMP Fragmentation Needed 消息,这正是 Path MTU Discovery 机制所需要的。

练习 4:application

题目:内核在转发路径中调用 ip_forward() 处理数据包时,会调用 ip_decrease_ttl() 函数。除了将 IPv4 头部的 TTL 字段减 1 之外,该函数还需要执行哪一个关键操作以保持数据包的有效性?

答案与解析

答案:重新计算并更新 IPv4 头部的校验和。

解析:IPv4 头部的校验和(check 字段)是覆盖整个头部的。当 TTL 字段发生变化时,原本的校验和就不再正确了。根据知识点定义,ip_decrease_ttl() 函数负责“将 IPv4 头部的 TTL 减 1 并重新计算校验和”。如果只修改 TTL 而不更新校验和,下一跳接收到该数据包时会因为校验和错误将其丢弃。

练习 5:thinking

题目:在设计网络协议栈时,Linux 内核区分了 ip_append_data() 和 ip_push_pending_frames() 两个主要发送辅助函数,而后又引入了 ip_make_skb() 用于 UDP 快速路径。请对比分析为什么在多处理器(SMP)环境下,ip_make_skb() 的“无锁发送”设计相比传统模式能提供更好的性能?这种设计主要避免了传统路径中的哪类开销?

答案与解析

答案:ip_make_skb() 集成了数据准备和 SKB 构建过程,允许在获取 Socket 锁之前完成数据包的构建(或减少持有锁的时间)。传统模式(如 ip_append_data 配合 ip_push_pending_frames)通常需要在持有 Socket 锁的状态下进行复杂的队列管理和分片计算,导致锁竞争激烈。ip_make_skb() 旨在实现无锁发送快速路径,主要避免了在 Socket 锁(BH 锁或 Socket 锁)内部进行繁重处理的开销以及多次上下文切换,从而提高并发吞吐量。

解析:这是一道深度分析题。根据知识点,ip_append_data() 用于准备数据但不发送,需配合 ip_push_pending_frames(),这通常意味着需要维护 Socket 的发送队列状态,涉及加锁。而 ip_make_skb() 是内核 2.6.39 后引入的,旨在“实现了无锁发送快速路径”。其核心优势在于它将数据包的构建逻辑与 Socket 的状态管理解耦或优化,使得大部分工作可以在没有锁竞争的情况下完成。在高并发 UDP 场景下,减少 Socket 锁的持有时间是提升性能的关键,因为锁竞争会导致 CPU 缓存失效和进程排队。


要点提炼

IPv4 头部虽然看似只有 20 字节,但每个比特都充满了设计陷阱。内核使用 struct iphdr 来描述这一结构,其中 ihl 字段以 4 字节为单位定义了头部的实际长度,这使得 IPv4 头部可以在 20 到 60 字节之间变化以容纳选项。服务类型字段(TOS)经过多次演变,现在主要用于 DSCP 流量控制和 ECN 显式拥塞通知。分片机制则将 16 位的 frag_off 复用,低 13 位记录以 8 字节为单位的偏移量,高 3 位则保留给 DF(禁止分片)和 MF(更多分片)标志,这种位域复用虽然节省空间,但也要求在解析时必须进行严格的掩码操作,否则极易被魔数误导。

IPv4 数据包的接收过程并非一蹴而就,而是层层把关的接力赛。ip_rcv 函数扮演“看门人”角色,它只负责严格校验头部格式、版本号和校验和,一旦发现异常(如 ihl 小于 5)便直接丢弃。通过安检后,包被移交给 ip_rcv_finish,其核心任务是执行路由查找。这一步通过查询路由子系统,将结果封装在 dst_entry 中,并根据目的地址不同,将包的 input 回调指针分别指向本地投递函数 ip_local_deliver、转发函数 ip_forward 或组播处理函数 ip_mr_input,从而决定数据包是“留下”还是“转发”。

组播数据包的处理逻辑比单播更为复杂,因为它需要同时判断“接收者”和“转发者”的双重身份。内核在路由查找阶段会调用 ip_check_mc_rcu 检查本地接口是否订阅了该组播组,同时检查是否开启了组播转发(由用户态的 pimd 守护进程控制内核开关 mc_forwarding)。如果是发给本地的,包会进入 ip_local_deliver 最终交由 Socket;如果是需要转发的,则进入 ip_mr_input。有趣的是,即使是组播转发,内核依然严格遵守 IP 网络的基本法则,在发出前调用 ip_decrease_ttl 减少 TTL 并重算校验和,以防止环路。

现代网络中较少出现的 IP 选项,在内核内部却拥有一套完整的编译与处理机制。因为选项会导致路由器性能下降,且分片时只有部分选项(由 Copied Flag 决定)会被复制到后续分片,处理起来极其繁琐。内核通过 ip_options_compile 将原始字节流解析为 struct ip_options 结构体,针对记录路由(RR)和时间戳等选项,需要内核动态填入当前接口 IP 或时间,这会修改头部并导致校验和失效。此外,出于安全考虑,系统默认禁用源路由(SSRR/LSRR)选项,防止攻击者利用其进行 IP 欺骗或绕过防火墙。

发送路径上,TCP 和 UDP 展现出了截然不同的性格,分别使用了 ip_queue_xmitip_append_data 两种机制。作为“操心型”协议的 TCP,倾向于自己管理数据分段,它通常携带一个已经组装好的 SKB 下发,只需快速查询路由缓存即可发送。而 UDP 则像“甩手掌柜”,把数据一股脑交给 IP 层,由 ip_append_data 负责在内核空间进行分页组装、合并数据和构建头部,最后才由 ip_push_pending_frames 统一发送。这种设计差异反映了两种协议对可靠性控制权的不同分配:TCP 希望掌握一切细节,而 UDP 则追求高效和极简。