Linux 内核中的软件包路径
大家好!我是大聪明-PLUS!
摘要:网络协议栈是通信和信息交换的基础。本文研究了Linux(目前应用最广泛的服务器操作系统)中的TCP/IPv4和UDP/IPv4网络协议栈。我们描述了如何追踪Linux 5.10.8内核中最重要的网络功能。尽管Linux网络代码的文档已经存在,但这些文档通常过时或仅涵盖部分方面,例如IP层或TCP层。本文全面解决了这个问题,涵盖了数据包进出Linux网络协议栈的路径。此外,我们还重点介绍了实现细节,并演示了Linux内核如何实现网络协议。本文可为性能优化、安全分析、网络监控或调试提供参考。
1. 引言
如今,从个人电脑到冰箱,几乎所有设备都连接到了网络。尽管网络技术对现代计算至关重要,但很少有人意识到数据包通过网络传输有多么复杂。鉴于基于 Linux 的服务器的普及,数据包通常会经过 Linux 网络协议栈。然而,理解 Linux 复杂的数据包处理机制需要时间和精力。尽管如此,这些知识往往至关重要,因为它有助于优化性能、分析安全性、调试和监控网络。
我们基于Linux内核5.10.8版本分析传入和传出的数据包路径。该版本文档齐全、稳定可靠,并包含一些现代特性,例如用于 Berkeley 数据包过滤器的即时 (JIT) 编译器。我们主要分析内核源代码,并将其与我们引用的内核符号关联起来。
尽管Linux内核网络技术日趋多样化,例如新增了多路径TCP,但大多数流量仍然使用标准的TCP和UDP协议栈。此外,尽管IPv6的普及速度加快,但大多数设备仍然通过IPv4进行通信。因此,在本分析中,我们仅限于TCP/IPv4和UDP/IPv4。
本文余下部分结构如下:首先,在第 2 节中,我们将本文工作与现有文献进行比较。然后,在第 3 节中,我们解释通用 Linux 网络协议栈和数据结构的设计sk_buff。在第 4 节中,我们探讨数据包入站和出站路径的复杂性。最后,在第 5 节中,我们简要总结最重要的结果。
2. 类似作品
我们尽我们所知对有关Linux网络协议栈的文献进行了评估。在此过程中,我们得出以下观察结果。
Linux 内核版本过旧。更详细的研究出现在 2000 年代,当时使用的是 Linux 内核版本 2 或 3。虽然网络协议栈中旧协议的实现仍然稳定,但时间已经过去很久。因此,我们需要调查可能存在的偏差。
信息碎片化。许多研究侧重于单个网络层,最常见的是TCP和IP协议的实现。其他研究则着重分析网络开销的成因。在这些情况下,都缺乏整体性的视角。尤其值得注意的是,即使作者描述了数据包在多个网络层中的路径,他们也往往忽略了UDP协议——这与本文截然不同。
虽然有一篇文章详细介绍了 Linux 5 的完整登录和注销流程,但它只是高屋建瓴地概述了网络层级,而且大多是直观的。因此,我们力求在特定层级的详细信息和高层次的网络堆栈跟踪之间找到一个平衡点。
3. 参考信息
我们假设读者已具备Linux和网络技术的基本知识。不过,我们仍会简要介绍与数据包传输相关的关键网络概念。
3.1. Linux 套接字网络协议栈 (INET)

图 1. 标准 Linux TCP/IP 和 UDP/IP 协议栈中使用的技术示意图,从用户空间到网络传输。
如图 1 所示,套接字要么向用户空间应用程序发送数据包,要么从传输层协议实现(例如 TCP 或 UDP)接收数据包。然后,IP 层将数据包路由到网络层。在网络层之下,Linux 允许使用防火墙规则过滤流量。网络接口卡 (NIC) 将从接收 (RX) 缓冲区接收到的数据包传递给内核,并将从发送 (TX) 缓冲区读取的数据包发送出去。
3.2. 套接字缓冲区 (sk_buff)
内核将数据包存储在名为 `data` 的 C 语言结构体中sk_buff。数据包路径上的几乎所有函数都会与 `data` 交互。`data`sk_buff跟踪数据包元数据,并维护指向内存中数据包数据的起始指针和结束指针。通过调整指针,例如在删除数据包头部时,可以利用对数据包数据的引用来高效地修改数据包。此外,sk_buff进程之间可以使用内存引用高效地共享 `data` 结构体。因此,克隆数据包也很高效,因为在只读工作负载下,只需要复制元数据。我们在图 2 中对此进行了说明。这些特性sk_buff构成了 Linux 中高效数据包处理的基础。

图 2. 两个简化的 sk_buff 结构指向同一个数据包缓冲区中的不同位置。head 表示缓冲区的开头,tail 表示实际数据包的结尾。data 表示当前正在处理的头部。
4. 数据包流
这里我们既关注入站路径也关注出站路径。这两条路径彼此独立运行。
4.1. 出口路径
首先,我们将分析输出路径,即 Linux 如何将数据包从用户应用程序发送到网卡,如图 3 所示。本质上,输出端会形成协议头,并将它们放入结构中,sk_buff然后发送出去。
4.1.1 套接字层一切都始于一个套接字,它具有一个合适的域,例如 `<domain>`AF_UNIX或AF_XDP`<domain>`,就像我们这里提到AF_INET的 IPv4 一样。系统调用包装函数,例如 `<system.socket>`write()或 `< sendto()system.socket>`,允许我们通过套接字发送数据,就像 GNU C 库中那样。在本文中write(filedescriptor, buffer, length),我们选择避免不必要的复杂性。写入文件描述符是 UNIX “一切皆文件”理念的一个典型例子,因为文件描述符抽象了套接字。

图 3. TCP 情况下的数据包轨迹,如第 4.1 节所述。
对于套接字,该函数write()会调用另一个函数。它会从用户空间应用程序提供的文件描述符中sock_sendmsg()获取套接字。套接字通常使用包含进程标识符 (PID)、用户标识符 (UID) 和组标识符 (GID) 的套接字控制消息。该函数会从包含调用进程这些信息的 Linux 数据结构中获取此控制消息。收到此信息后,它通常会将数据包传递给 Linux 安全模块(例如 SELinux)以过滤流量。struct socksock_sendmsg()task_structsock_sendmsg()
最后,它使用宏调用相应的传输层处理程序,在本例中为 TCP 或 UDP 。该宏会根据- 字段INDIRECT_CALL_INET中指定的协议自动选择相应的 IPv4 或 IPv6 传输协议输入函数。sk_protsk_buff
4.1.2 传输层。这里我们来看与 IPv4 相关的tcp_sendmsg()TCP 和udp_sendmsg()UDP 输入函数。
TCP 处理首先tcp_sendmsg()等待 TCP 连接建立。然后,它为数据段分配结构体,并将它们放入套接字sk_buff的写队列中,如图 3 所示。它还会确保遵守最大段大小 (MSS) 的限制。处理完队列后,内核会tcp_sendmsg()调用` get_header ...tcp_write_queue_tail()sk_buffskb_add_data_nocache()transport_headerAF_INETtcp_write_xmit()tcp_transmit_skb()queue_xmit()
UDP 的工作原理类似udp_sendmsg()。同样,该函数会将数据写入套接字的写入队列。然后,该函数会等待,直到没有未处理的 UDP 数据报帧为止。与之前一样,该函数会创建一个头部,设置目标端口和其他字段。
UDP 协议分为阻塞式和非阻塞式两种情况:阻塞式是指等待帧以传输多个 UDP 数据报;非阻塞式则涉及直接构建sk_buff。构建数据报后,ip_route_output_flow()它会路由数据包并创建网络层协议头。最后,ip_append_data()它会创建一个连接多个 UDP 数据报的 IP 数据包。总的来说,UDP 协议的简洁性和无阻塞特性表明,其性能优于 TCP 协议。
4.1.3 IP 层。IP 处理始于 `route_path` 函数__ip_queue_xmit()。首先,该函数确定到达目标的路由。如果路由已存在于 `route_path` 中,则跳过路由过程。在这种情况下,该函数立即创建一个 IP 头部。但是,如果目标不存在,则路由过程继续。它根据`route_path` 中的sk_buff->_skb_refdst`destination` 字段确定目标,例如,如果套接字之前已收到 IP 数据包,则该字段会被设置。如果无法确定目标,则查询路由缓存(称为转发信息库 (FIB))——一个由 IP 路由表构成的表。最后,如果仍然找不到路由,则返回“主机不可达”并停止处理。否则,如果找到路由,内核将添加一个 IP 头部。socketsk_buff
现在调用该函数来设置 IP 选项。它用`from`ip_options_build()字段标记标头的开头。接下来,它运行Linux 防火墙机制的阶段。之后,它通过函数指针调用实际的路由函数。network_headersk_buffLOCAL_OUTnetfilterdst_output()
内核随后调用路由函数ip_output()处理最常见的单播数据包。由于路由已完成,因此该阶段称为“路由完成” POST_ROUTING。它会更新数据包的元数据并调用钩子函数NF_INET_POST_ROUTING。该钩子函数会设置元数据sk_buff并调用“分片”函数netfilter。如果数据包长度超过最大传输单元 (MTU),它还会对数据包进行分片。
然后,数据包经过钩子后NF_INET_LOCAL_OUT,ip_output()会调用 `catch` 函数ip_finish_output()。该函数会递增组播和广播数据包的计数器,并检查是否有足够的空间sk_buff容纳 MAC 头部。目标 MAC 地址要么被缓存,要么由邻居的输出函数确定neigh_resolve_output()。后者使用地址解析协议 (ARP)。如果没有 ARP 响应,则会将数据包重新排队。内核在收到 MAC 地址后,会将以太网头部添加到数据包中sk_buff。
4.1.4 以太网层。首先,它在`<netcast_name>` 中dev_queue_xmit()设置一个字段,然后将其传递给 `<netcast_name>` 。它按队列顺序(`<netcast_name> `)将数据包入队。当网卡的缓冲区已满时,它会从缓冲区中移除数据包。在 `<netcast_name>` 中进行一些后处理(例如计算以太网校验和或添加 VLAN 标签)后,内核调用 `<netcast_name>`并将数据包相应地添加到网卡的 TX 环中。最终,网卡的队列可能会溢出。在这种情况下,内核停止并将数据包入队。最后,在添加额外的元数据后,它将数据包分配到用于直接内存访问 (DMA) 的固定内存位置。` <netcast_name> ` 允许您绕过 `<netcast_name> `,直接将数据包写入网卡的 TX 环。例如,eXpress Data Path (XDP)。最后,`<netcast_name>` 函数通过中断通知网卡处理完成并释放 `<netcast_name>` 。mac_headersk_bufftc_egress()qdisc__qdisc_run()validate_xmit_skb()ndo_start_xmitqdiscsk_buffsk_buffdev_direct_xmitqdiscsk_buff
4.2 入口路线
现在,我们跟踪数据包到达网卡的路径,直到用户应用程序通过套接字读取它,如图 4 所示。具体来说,它会解析标头以确定下一个函数调用并将其删除。

图 4. TCP 情况下的数据包到达路径,如第 4.2 节所述。
4.2.1 以太网层。在验证并重置以太网校验和以及应用 MAC 地址过滤器后,网卡通过 DMA 将数据包复制到系统内存中。然后,它通过中断通知操作系统,并指示数据包数据的位置。操作系统随后可以分配数据sk_buff。内核现在插入sk_buff元数据,例如protocol[Ethernet] 字段、interface[received] 字段和 [type packet] 字段,在本例中为 IP。
此时,内核知道以太网报头的起始位置,因此将该字段设置mac_header为sk_buff。最后,它从 中移除以太网报头。最后,它将sk_buff数据包传递到网络协议栈的更高层级。接下来,数据包到达netif_receive_skb()。该函数会克隆数据包sk_buff并将其转发到 TAP 虚拟接口。TAP 接口提供同一网络上的虚拟机 (VM) 和主机之间的通信。另一个重要的例子是将带有 VLAN 标签的数据包转发到 VLAN 接口。此外,如果该接口连接到物理主机(即它是虚拟接口或网络桥接的一部分),rx_handler()它也会接收该数据包。rx_handler()还会将该字段设置network_header为sk_buff。最后,它调用 IPv4 协议处理函数ip_rcv()。
4.2.2 IP 层。以太网层使用函数将数据包传递给 IP 层ip_rcv()。它再次ip_rcv()检查 MAC 地址并丢弃任何无关的 MAC 地址。然后检查版本、长度和校验和字段。接下来,该函数将字段设置transport_header为sk_buff。它还应用规则PRE_ROUTING netfilter。它通过将数据包转发到钩子来实现过滤器NF_INET_PRE_ROUTING。钩子接收一个指向函数的指针ip_rcv_finish(),并在完成后调用该函数。如果注册了网络层主节点,它会将其传递sk_buff给其处理程序。它调用函数ip_route_input_noref(),该函数从 读取 IP 头部sk_buff。接下来,内核使用 处理 IP 选项ip_rcv_options()。之后,通过 调用先前选择的路由函数dst_input()。数据包路由有三种选项:
-
ip_forward此功能针对非当前机器发送的数据包启用。它会转发数据包而不做任何额外处理。 -
ip_local_deliver()如果我们是数据包的最终接收者(localhost),内核不会转发数据包,而是将其传递给网络协议栈。 -
ip_mr_input()此功能适用于组播数据包,即发往组播的数据包。
由于我们主要关注数据包在最终接收方如何处理(包括所有层),因此我们继续使用 `get_interface_name()`ip_local_deliver()函数。最重要的是,该函数通过调用 `get_interface_name()` 来处理 IP 分片ip_defrag(),并将数据包排队,直到所有分片都被接收。之后,`get_interface_name()` 事件(该事件NF_INET_LOCAL_IN又会调用 ` get_interface_name()`)会从数据包ip_local_deliver_finish()中移除 IP 头部sk_buff。最后,它通过 `get_interface_name()` 函数将数据包从 IP 层传递到 TCP/UDP 层dst_input()。tcp_v4_rcv()该函数通过检查指向 `get_interface_name()` 的头部来确定合适的协议处理程序sk_buff。
4.2.3 传输层。现在我们来考虑 TCP 和 UDP 函数的输出对应部分。TCP
。首先,一个数据包进入传输层函数tcp_ipv4_recv(),其头部指针sk_buff位于 TCP 或 UDP 头部的开头。然后,它使用函数检查传输头部pskb_may_pull(),验证 TCP 校验和。与之前一样,它从中移除 TCP 头部sk_buff。为了进一步转发数据包,它使用函数找到合适的 TCP 套接字。它将数据包写入套接字的接收队列(参见图 4),并通过例如或来__inet_lookup_skb()发出新数据可用信号。这种通知机制可以实现高效的套接字轮询。在输出端,内核在数据包处理期间维护 TCP 状态机制。例如,它不会处理通过终止的 TCP 连接的新数据包。SIGIOSIGURGTCP_CLOSING
我们将简要介绍处理过程中的两种重要情况:TCP_NEW_SYN_RECV和TCP_TIME_WAIT。TCP_NEW_SYN_RECV表示已建立新连接。在这种情况下,内核会通过 TCP 层丢弃连接tcp_filter()。在期间,TCP_TIME_WAIT内核会丢弃所有后续的 TCP 段。
此外,还有慢速路径和快速路径。慢速路径涉及更多的错误检查和查找操作。相比之下,快速路径针对速度进行了优化,因此不会进行内省和流量分析。在慢速路径中,我们会等待状态机转换到状态TCP_ESTABLISHED。tcp_v4_do_rcv()更新状态后,tcp_v4_do_rcv()它会调用处理函数tcp_rcv_established(),该函数会处理快速路径和慢速路径中的数据包。它还会确保序列号递增。快速路径直接将数据包复制到用户空间。内核总是尽可能尝试使用快速路径。但是,例如,当建立 TCP 连接时,由于内核必须跟踪新连接,因此无法使用快速路径。
在处理完 TCP 状态机制和路径选择后,内核会将数据包放入队列,供用户程序读取(参见图 4)。由于 TCP 非常复杂,对这些方面的进一步探讨超出了本文的范围。
UDP。与 TCP 相比,UDP 的实现更为简单。它始于IP 层的udp_rcvmsg()`get` 函数。首先,该函数调用`read` 函数从预先计算的偏移量处的套接字读取数据报。具体来说,它会持续尝试从套接字读取数据,直到收到新的 UDP 数据报为止。然后,验证数据报的校验和。接着,该函数复制目标 IP 地址和 UDP 端口,使数据报与正确的套接字匹配。之后,它使用 `get` 函数处理 UDP 数据报。最后,调整峰值偏移量,处理引用计数器,并通过`resolve` 函数释放资源。dst_input()__skb_recv_udp()sk_buffskb_consume_udp()sk_buff__consume_stateless_skb()
4.2.4. 套接字层。内核在此使用read()套接字函数从 TCP 或 UDP 套接字的接收队列中取出新写入的数据(参见图 4)。为了与 IPv4 出口通信保持一致,我们使用接收套接字函数AF_INET。该函数sys_recv()首先调用`getSocket()` 函数sys_recvfrom()查找套接字,然后调用 ` read()` 函数sock_recvmsg()从套接字读取数据,并将接收到的消息传递给 Linux 安全模块,这与出口通信类似。对于 IPv4 ,该函数会调用 `remove()`或inet_recvmsg()`write ()` 函数。它们会将数据包的内容从队列中移除,并将其写入用户空间缓冲区,例如基于堆的数组。最后,它们会释放队列。tcp_recvmsg()udp_recvmsg()sk_buff
5. 结论

图 5:第 4 节中描述的 UDP 最重要的输出和输入函数的概述。
本文介绍了 TCP/IPv4 和 UDP/IPv4 数据包在 Linux 内核中的传输过程。图 5 展示了数据包在出口层和入口层的路径,并重点介绍了每一层的主要功能。我们还详细描述了数据包处理的复杂性,包括 Linux 内核使用的路由、过滤和排队机制。此外,我们还演示了各个内核层之间的交互方式。利用这些知识,网络管理员和开发人员在优化网络性能、制定安全措施或排查网络问题时可以做出明智的决策。
总体而言,现有文献中观察到的变化大多是改进而非重写,例如重构或安全改进。TCP 初始序列号的选择就是一个典型的例子。出于安全考虑,内核作者已经多次修改了底层哈希算法。保守的改动是合理的,因为协议本身基本保持不变,而漏洞的影响却很大。对多路径 TCP 或 QUIC 进行类似的分析将是未来的工作。
更多推荐



所有评论(0)