全面解析 Linux 网络 I/O:数据是如何“跑”起来的
1. 全面解析 Linux 网络 I/O:数据是如何“跑”起来的
数字化时代,网络已深度融入生活与工作,从日常上网、观影到企业数据传输,都离不开网络支持。
而 Linux 内核作为操作系统核心,在网络数据处理中扮演关键角色,其核心功能之一便是管理网络数据的输入输出。
Linux 内核因开源、高效、稳定,被广泛应用于服务器、嵌入式设备、超级计算机等场景。
全球超 90% 的超级计算机运行 Linux 系统,谷歌、亚马逊等互联网巨头的服务器集群,也依赖其强大网络处理能力,支撑海量数据传输与高并发请求。
那么,Linux 内核如何实现网络数据的输入输出?
本文将深入其源码,解析网络数据处理的技术原理,探究从接收、协议解析到发送的全过程,展现 Linux 内核在网络领域的精妙设计。
[!info] 阅读导览
本文围绕一条主线展开:数据如何从网卡进入内核(接收路径),又如何在应用请求下离开内核(发送路径)。
- 网络栈总览 —— 分层架构、Socket 与各层职责(1.1)
- 数据输入 —— 网卡中断 → 驱动收包 → 网络层校验 → 路由决策 → 本地交付(1.2)
- 数据输出 —— 传输层发起 → IP 层处理 → 分片 → 邻居子系统 → 网卡发送(1.3)
- 实例与代码 —— Web 场景全流程 + 内核代码片段解析(1.4)
1.1. Linux 网络栈架构总览
Linux 网络栈采用分层架构,这种架构设计就如同建造高楼,每一层都有其独特的功能和职责,层层协作,共同构建起强大的网络通信体系。
从下往上,Linux 网络栈主要分为链路层、网络层、传输层和应用层,各层紧密配合,实现网络数据的高效传输。
各层职责速览:
| 层次 | 核心协议 / 接口 | 数据单元 | 主要职责 |
|---|---|---|---|
| 应用层 | HTTP / FTP / SMTP / DNS | 报文(Message) | 提供具体的网络服务,通过 Socket 访问传输层 |
| 传输层 | TCP / UDP | 报文段(Segment)/ 数据报(Datagram) | 端到端通信:TCP 保证可靠有序,UDP 追求低延迟 |
| 网络层 | IP / ICMP / IGMP | 数据包(Packet) | 逻辑寻址与路由,决定数据包下一跳 |
| 链路层 | 以太网 / PPP / HDLC | 帧(Frame) | 相邻节点间的成帧、寻址与差错控制 |
1.1.1. 应用层:用户的交互界面
应用层是用户与网络应用程序进行交互的界面,就像是各种商店,为用户提供各种具体的网络服务。
它负责处理特定的应用程序细节,如 HTTP(Hypertext Transfer Protocol)协议用于网页浏览、SMTP(Simple Mail Transfer Protocol)协议用于邮件发送、FTP 协议用于文件传输等。
应用层通过调用传输层提供的接口,实现数据的传输和交互。
例如,当我们在浏览器中输入网址并按下回车键后,浏览器会使用 HTTP 协议向服务器发送请求,服务器接收到请求后,会根据请求内容返回相应的网页数据,浏览器再将这些数据解析并显示出来,供用户浏览。
1.1.2. Socket
应用层的各种网络应用程序基本上都是通过 Linux Socket 编程接口来和内核空间的网络协议栈通信的。
Linux Socket 是从 BSD Socket 发展而来的,它是 Linux 操作系统的重要组成部分之一,它是网络应用程序的基础。
从层次上来说,它位于应用层,是操作系统为应用程序员提供的 API,通过它,应用程序可以访问传输层协议。
- socket 位于传输层协议之上,屏蔽了不同网络协议之间的差异
- socket 是网络编程的入口,它提供了大量的系统调用,构成了网络程序的主体
- 在 Linux 系统中,socket 属于文件系统的一部分,网络通信可以被看作是对文件的读取,使得我们对网络的控制和对文件的控制一样方便
1.1.3. 应用层处理流程
- 网络应用调用 Socket API
socket(int domain, int type, int protocol)创建一个 socket(第一个参数domain表示协议族,如AF_INET),该调用最终会进入 Linux system callsocket(),并最终调用 Linux Kernel 的sock_create()方法。
sock_create()负责创建内核中的struct socket;而文件描述符(file descriptor)则由系统调用socket()在返回前通过sock_map_fd()生成并关联到该 socket。
对于每一个 userspace 网络应用创建的 socket,在内核中都有一个对应的struct socket和struct sock。
其中,struct sock有三个队列(queue),分别是接收队列sk_receive_queue、发送队列sk_write_queue和错误队列sk_error_queue,在 sock 结构被初始化的时候,这些缓冲队列也被初始化完成;在数据收发过程中,每个 queue 中保存要发送或者接收的每个 packet 对应的 Linux 网络栈 sk_buffer 数据结构的实例 skb。 - 对于 TCP socket 来说,应用调用
connect()API,使得客户端和服务器端通过该 socket 建立一个虚拟连接。
在此过程中,TCP 协议栈通过三次握手会建立 TCP 连接。
默认地,该 API 会等到 TCP 握手完成连接建立后才返回。
在建立连接的过程中的一个重要步骤是,确定双方使用的 Maximum Segment Size(MSS)。
因为 UDP 是面向无连接的协议,因此它是不需要该步骤的。 - 应用调用 Linux Socket 的 send 或者 write API 来发出一个 message 给接收端。
sock_sendmsg被调用,它使用 socket descriptor 获取 sock struct,创建 message header 和 socket control message。__sock_sendmsg被调用,根据 socket 的协议类型,调用相应协议的发送函数。
对于 TCP,调用tcp_sendmsg函数。
对于 UDP 来说,userspace 应用可以调用send()/sendto()/sendmsg()三个 system call 中的任意一个来发送 UDP message,它们最终都会调用内核中的udp_sendmsg()函数。
1.1.4. 传输层:数据的可靠保障
传输层是网络通信的可靠保障,如同快递服务中的包裹跟踪系统,确保数据能够准确无误地从发送方传输到接收方。
它主要提供端到端的通信服务,常见的传输层协议有 TCP(Transmission Control Protocol)和 UDP(User Datagram Protocol)。
TCP 协议是一种面向连接的、可靠的传输协议,它通过三次握手建立连接,在数据传输过程中进行流量控制和拥塞控制,确保数据的完整性和有序性;
TCP 栈的简要过程包括连接建立、数据传输和连接关闭三个主要阶段:
TCP 与 UDP 的核心差异:
| 对比项 | TCP | UDP |
|---|---|---|
| 连接性 | 面向连接(三次握手 / 四次挥手) | 无连接 |
| 可靠性 | 可靠:确认、重传、排序 | 不可靠:不确认、不重传 |
| 流量 / 拥塞控制 | 均有 | 无 |
| 传输效率 | 较低(开销大) | 较高(开销小) |
| 典型应用 | 文件传输、网页、邮件 | 视频直播、音频通话、DNS 查询 |
1.1.4.1. 连接建立(三次握手)
- 客户端选择一个初始序列号 x,发送一个带有 SYN 标志的数据包到服务器,此时客户端进入 SYN_SENT 状态。
- 服务器收到 SYN 包后,如果同意连接,会分配 TCP 资源,选择自己的初始序列号 y,并发送一个 SYN-ACK 包,确认客户端的序列号为 x+1。此时服务器进入 SYN_RCVD 状态。
- 客户端收到 SYN-ACK 包后,发送一个 ACK 包给服务器,确认服务器的序列号为 y+1,客户端进入 ESTABLISHED 状态。服务器收到 ACK 包后,也进入 ESTABLISHED 状态,至此 TCP 连接建立成功。
sequenceDiagram
participant C as 客户端
participant S as 服务端
C->>S: SYN(seq=x)
Note right of C: SYN_SENT
S->>C: SYN+ACK(seq=y, ack=x+1)
Note right of S: SYN_RCVD
C->>S: ACK(ack=y+1)
Note over C,S: 双方进入 ESTABLISHED,连接建立
1.1.4.2. 数据传输
- 应用程序创建要发送的数据,通过系统调用(如 write)将数据从用户空间拷贝到内核空间的发送 socket 缓冲区。
- TCP 协议从发送缓冲区中取出数据,构造 TCP 段,包括添加 TCP 头部,设置序列号、确认号等字段,并计算 TCP 校验和。TCP 段的 payload(有效载荷)大小受接收窗口、拥塞窗口和最大段大小(MSS)限制。
- TCP 段被传输到 IP 层,IP 层在 TCP 段头部加上 IP 头信息,用于路由。然后 IP 包被传输到链路层,链路层添加以太网头部信息,并通过 ARP 协议获取下一跳的 MAC 地址,最终将数据包发送到网络中。
- 接收方收到数据包后,从链路层依次向上解封装,到达 TCP 层。TCP 层根据序列号和确认号对数据进行排序和确认,将正确的数据放入接收 socket 缓冲区,供应用程序读取。如果发现数据丢失或错误,接收方会请求发送方重发相应数据,发送方会缓存未收到 ACK 的数据,在超时未收到确认时进行重发。同时,接收方会根据自己的接收能力,通过窗口字段告知发送方自己能接收的最大字节数,进行流控制。发送方还会根据网络状况进行拥塞控制,避免网络拥塞。
1.1.4.3. 连接关闭(四次挥手)
- 当客户端完成数据传输并希望断开连接时,它发送一个 FIN 包,进入 FIN-WAIT-1 状态。
- 服务器收到 FIN 包后,发送一个 ACK 包确认,进入 CLOSE-WAIT 状态。客户端收到 ACK 包后,进入 FIN-WAIT-2 状态。
- 服务器完成数据传输后,发送一个 FIN 包给客户端,请求关闭其端的连接,此时服务器进入 LAST-ACK 状态。
- 客户端收到服务器的 FIN 包后,发送一个 ACK 包进行确认,然后进入 TIME-WAIT 状态。经过 2MSL(最大报文段生存时间的两倍)后,客户端确保服务器接收到最终的 ACK 包,然后关闭连接。服务器收到 ACK 后,也关闭连接。
sequenceDiagram
participant C as 主动关闭方(如客户端)
participant S as 被动关闭方(如服务端)
C->>S: FIN
Note right of C: FIN_WAIT_1
S->>C: ACK
Note right of S: CLOSE_WAIT
Note right of C: FIN_WAIT_2
S->>C: FIN
Note right of S: LAST_ACK
C->>S: ACK
Note right of C: TIME_WAIT(等待 2MSL)
Note over S: 收到 ACK 后关闭连接
UDP 协议则是一种无连接的、不可靠的传输协议,它不保证数据的可靠传输,但具有传输速度快、开销小的特点,适用于对实时性要求较高但对数据准确性要求相对较低的应用场景,如视频直播、音频通话等。
例如,当我们使用 FTP(File Transfer Protocol)进行文件传输时,通常会使用 TCP 协议,以确保文件的完整传输;
而在观看在线视频时,由于对实时性要求较高,即使少量数据丢失也不会对观看体验造成太大影响,因此可能会使用 UDP 协议。
UDP 栈的简要过程包括数据发送和数据接收两个主要方面:
1.1.4.4. 数据发送过程
- 创建套接字:应用层通过调用 Socket API 中的 socket 函数创建一个 UDP 套接字。该函数会最终调用内核中的 sock_create 方法创建对应的 struct socket 和 struct sock 结构体,并由 socket 系统调用返回一个文件描述符,其中 struct sock 包含接收队列(sk_receive_queue)、发送队列(sk_write_queue)和错误队列(sk_error_queue)。
- 构建 UDP 数据报:应用程序调用 send、sendto 或 sendmsg 等函数发送数据,这些函数最终会调用内核中的 udp_sendmsg 函数。udp_sendmsg 函数根据目的地址和端口号,将应用层数据封装成 UDP 数据报,添加 UDP 首部,首部包含源端口号、目的端口号、长度和校验和(校验和在 IPv4 下可选,IPv6 下为必填项)。
- 发送到网络层:UDP 将构建好的数据报通过 ip_append_data 方法追加到发送队列,最终由 ip_push_pending_frames / ip_send_skb 函数将数据包交给网络层(IP 层)。注意:ip_queue_xmit 是 TCP 建立连接后使用的发送入口,UDP 不走该路径。
- 网络层处理:IP 层接收到数据包后,添加 IP 首部,进行路由处理,确定下一跳地址。如果数据包长度超过网络最大传输单元(MTU),可能会进行分片处理,最后将处理好的数据包发送到链路层。
- 链路层发送:链路层收到数据包后,添加链路层首部(如以太网首部),包含源 MAC 地址和目的 MAC 地址等信息,并通过物理层将数据发送到网络中。
1.1.4.5. 数据接收过程
- 物理层接收:物理层从网络中接收数据帧,将其传递给链路层。
- 链路层解析:链路层去除链路层首部,根据首部中的协议类型字段,判断出是 UDP 数据报,将其传递给网络层。
- 网络层处理:IP 层检查 IP 首部,进行路由相关处理和校验等,根据协议字段确定上层协议为 UDP 后,去除 IP 首部,将 UDP 数据报传递给 UDP 层。
- UDP 层解析:UDP 层根据 UDP 首部中的目的端口号,将数据报从接收队列中取出,计算校验和(若有),验证数据完整性。如果校验通过,提取数据载荷部分。
- 交付应用层:UDP 将提取出的数据载荷通过 Socket 接口发送给对应的应用程序,应用程序通过调用 recv 或 recvfrom 等函数接收数据,完成数据接收过程。
1.1.5. 网络层:数据的导航者
网络层如同交通枢纽的调度员,负责数据包在网络中的传输和路由选择。
其核心协议是 IP(Internet Protocol)协议,它为每个网络节点分配唯一的 IP 地址,使得数据包能够在不同的网络之间进行传输。
当网络层接收到链路层传来的数据包后,会根据数据包中的目的 IP 地址,通过路由表查找最佳的传输路径,然后将数据包转发到下一个网络节点。
此外,网络层还包括 ICMP(Internet Control Message Protocol)协议,用于网络诊断和错误报告;
IGMP(Internet Group Management Protocol)协议,用于多播通信。
例如,当我们在浏览器中输入一个网址并访问时,网络层会根据目标服务器的 IP 地址,规划出数据从本地计算机到目标服务器的传输路径,确保数据能够准确无误地到达。
网络层的任务就是选择合适的网间路由和交换结点,确保数据及时传送。
网络层将从传输层收到的数据封装成数据包(IP 数据报),包中封装有网络层包头,其中含有逻辑地址信息——源站点和目的站点地址的网络地址。
其主要任务包括:(1)路由处理,即选择下一跳;(2)添加 IP header;(3)计算 IP header checksum,用于检测 IP 报文头部在传播过程中是否出错;(4)可能的话,进行 IP 分片;(5)处理完毕,获取下一跳的 MAC 地址,设置链路层报文头,然后转入链路层处理。
IP 栈基本处理过程主要包括数据包的接收、校验、路由选择、分片以及发送等步骤:
1.1.5.1. 步骤一:链路层接收分组并送至网络层
当网卡收到与自己 MAC 地址匹配(或开启混杂模式)的以太网帧时,会通过 DMA 将帧直接写入内存中的环形缓冲区,随后产生硬件中断通知 CPU;网卡驱动在硬中断处理中关闭中断并调度 NAPI 软中断(标记 NET_RX_SOFTIRQ)。
CPU 调用 do_softirq() 处理软中断,再调用 net_rx_action();驱动在 NAPI 轮询回调中把帧封装为套接字缓冲区 skb,并通过 napi_gro_receive() 送入协议栈;协议栈在 netif_receive_skb 函数中,根据协议字段将 skb 分发给相关协议处理函数,如 ip_rcv() 用于处理 IP 协议。
1.1.5.2. 步骤二:网络层处理分组(以 IPv4 为例)
- ip_rcv 函数校验:skb 被送到 ip_rcv() 函数,验证 IP 分组,检查目的地是否为本机地址、校验和是否正确等。若正确,交给 netfilter 的 NF_IP_PRE_ROUTING;否则,丢弃数据包。
- ip_rcv_finish 函数路由判断:随后分组进入 ip_rcv_finish() 函数,根据 skb 结构中的目的或路由信息进行处理。若 skb->dst 无路由信息,则通过 ip_route_input_noref()(旧内核为 ip_route_input())查找路由,若目的地不可达,丢弃数据包。最后执行 dst_input,根据结果决定数据包下一步处理方式:本机分组由 ip_local_deliver 处理;需要转发的数据由 ip_forward() 函数处理;组播数据包由 ip_mr_input() 函数处理。
- ip_forward 转发数据包:若需转发,ip_forward() 函数会先处理 IP 头选项,记录本地 IP 地址和时间戳等,确认分组可转发后,将 TTL 减 1,若 TTL 为 0 则丢弃。接着根据 MTU 大小和路由信息对数据分组进行分片,最后将数据分组送往外出设备。若转发失败,则回应 ICMP 消息说明原因。之后执行 ip_forward_finish() 函数准备发送,再通过 dst_output(skb) 将分组发到转发的目的主机或本地主机,最后调用 ip_finish_output() 进入邻居子系统。
- ip_local_deliver 本地处理:对于本机分组,ip_local_deliver 中会对 IP 分片进行重组,经过 LOCAL_IN 钩子点,然后调用 ip_local_deliver_finish。ip_local_deliver_finish 函数处理原始套接字的数据接收,并调用上层协议的包接收函数,将数据包传递到传输层。
1.1.6. 链路层:网络通信的基石
链路层,如同高楼的地基,是网络通信的基础。
它负责处理与物理网络设备的直接交互,包括网卡驱动程序以及数据链路协议的实现。
其主要功能是将网络层传来的数据包封装成数据帧,并通过物理网络介质进行传输。
在接收数据时,则进行相反的操作,将接收到的数据帧解封装,提取出数据包传递给网络层。
常见的链路层协议有以太网协议、PPP(Point-to-Point Protocol)协议等。
例如,在以太网中,链路层会在数据包前后添加以太网头部和尾部,形成以太网帧,其中头部包含源 MAC 地址和目的 MAC 地址等信息,用于在局域网内标识数据的发送方和接收方。
功能上,在物理层提供比特流服务的基础上,建立相邻结点之间的数据链路,通过差错控制提供数据帧(Frame)在信道上无差错的传输,并进行各电路上的动作系列。
数据链路层在不可靠的物理介质上提供可靠的传输。
该层的作用包括:物理地址寻址、数据的成帧、流量控制、数据的检错、重发等。
在这一层,数据的单位称为帧(frame)。
数据链路层协议的代表包括:SDLC、HDLC、PPP、STP、帧中继等。
实现上,Linux 提供了一个 Network device 的抽象层,其实现在 linux/net/core/dev.c。
具体的物理网络设备在设备驱动中(如 drivers/net/ethernet/intel/e1000/e1000_main.c)需要实现其中的函数指针表(struct net_device_ops),Network Device 抽象层通过调用这些回调函数来驱动具体网络设备。
1.2. 数据输入:从网卡到内核的旅程
1.2.1. 网卡接收与中断处理
在 Linux 系统中,网络数据的输入旅程始于网卡。
网卡,作为计算机与网络之间的物理接口,如同一位勤劳的快递员,时刻监听着网络上的数据传输。
当网卡接收到数据包时,它首先会进行初步的筛选。
如果目的地址不是该网卡,且该网卡没有开启混杂模式,那么这个数据包就会被网卡无情地丢弃,就好像快递员发现包裹不是送到自己负责的区域,便会直接退回。
对于符合条件的数据包,网卡会通过 DMA(Direct Memory Access)技术,将其写入到预先分配好的内存地址中。
DMA 技术就像是一条数据高速公路,允许设备直接与内存进行数据传输,而无需 CPU 的频繁干预,大大提高了数据传输的效率。
例如,在一台配备千兆网卡的服务器上,通过 DMA 技术,网卡可以快速地将大量的网络数据包写入内存,为后续的处理做好准备。
在完成数据写入后,网卡会通过硬件中断(IRQ)通知 CPU,就如同快递员按响门铃,告诉主人有新的包裹送达。
硬件中断是一种由硬件设备发出的电信号,它会使 CPU 立即暂停当前正在执行的任务,保存现场信息,然后跳转到对应的中断处理程序。
在 Linux 系统中,每个硬件设备的中断源都有一个对应的中断号(多队列网卡可为每个队列申请独立的中断号),用于标识该设备发出的中断请求,这样 CPU 就能准确地知道是哪个设备在请求服务。
1.2.2. 网络驱动的接力
当 CPU 收到网卡发出的中断信号后,会调用已经注册的中断函数,这个中断函数会进一步调用到网卡驱动程序中相应的函数。
网络驱动程序就像是一位翻译官,负责将网卡接收到的原始数据转换为内核能够理解的格式。
以常见的 e1000 网卡驱动为例,其硬中断处理函数 e1000_intr() 会首先禁用网卡的中断,表示驱动程序已经知晓内存中有数据,让网卡下次收到数据包时直接写入内存即可,无需再通知 CPU,以此提高效率,避免 CPU 被频繁中断。
接着,启动软中断(NAPI 调度),将耗时较长的数据处理任务交给软中断处理函数。
在软中断处理函数中,网络驱动会从内存(DMA 环形队列)中取出数据包,并将其转换为内核网络模块能识别的 skb(socket buffer)格式。
skb 是 Linux 内核中用于表示网络数据包的数据结构,它包含了数据包的各种信息,如数据内容、源地址、目的地址等,就像是一个包裹的详细清单,方便内核后续的处理。
完成格式转换后,驱动会调用 napi_gro_receive 函数,该函数会处理 GRO(Generic Receive Offload)相关的内容,即将可以合并的数据包进行合并,这样就只需要调用一次协议栈,减少了系统开销。
1.2.3. 网络层的初次校验
当数据包以 skb 格式进入网络层后,首先会调用 ip_rcv 函数进行处理。
这个函数就像是一位严格的质检员,会对数据包进行多项严格的检查,以确保数据包的完整性和正确性。
它会检查数据包的 IP 首部长度是否符合要求。
IP 首部包含了数据包的各种控制信息,如版本号、首部长度、服务类型、总长度等,如果首部长度不正确,那么数据包可能在传输过程中出现了错误,无法被正确处理。
它还会检查数据包的校验和。
校验和是一种用于检测数据传输错误的机制,通过对数据包的内容进行特定的算法计算得出一个值,接收方在收到数据包后,会重新计算校验和并与发送方发送的校验和进行比较,如果两者不一致,就说明数据包在传输过程中可能发生了错误。
在进行这些基本检查的同时,ip_rcv 函数还会与 netfilter 的 NF_IP_PRE_ROUTING 钩子点交互。
netfilter 是 Linux 内核中的一个框架,它提供了一系列的钩子点,允许用户通过编写规则对网络数据包进行过滤、修改等操作。
例如,在一些网络安全场景中,管理员可以通过在 NF_IP_PRE_ROUTING 钩子点设置规则,对进入网络层的数据包进行安全检查,如检测是否存在恶意攻击的特征,如果发现可疑数据包,则可以直接丢弃或进行其他处理,从而保护系统的安全。
1.2.4. 路由决策与后续走向
经过网络层的初次校验后,数据包会进入 ip_rcv_finish 函数,在这里,路由决策将决定数据包的后续走向。
路由决策就像是为数据包规划一条旅行路线,根据数据包的目的 IP 地址,查找最佳的传输路径。
在进行路由决策时,内核会直接查询路由表。
路由表就像是一本详细的地图集,包含了网络中所有可能的路由信息;内核根据目的 IP 地址、子网掩码等信息,在路由表(FIB)中查找最匹配的条目,得到最佳的下一跳地址。
[!note] 关于“路由缓存”
早期内核(3.6 之前)曾维护一个“路由缓存”用于加速查表,并区分快路径 / 慢路径(如 ip_route_input_slow)。
现代内核(3.6 起)已移除路由缓存,统一通过ip_route_input_noref()查询 FIB 路由表,依靠更精确的查找算法保证性能。
根据路由结果,数据包有两种可能的走向。
如果目的 IP 地址是本地主机的地址,那么数据包将走向本地交付,即被传递到本地的应用程序进行处理;
如果目的 IP 地址是其他网络主机的地址,那么数据包将被转发到下一个网络节点。
在不同的走向下,会设置不同的处理函数。
对于本地交付,会设置 ip_local_deliver 函数,负责将数据包传递到本地的传输层协议进行进一步处理;
对于转发,会设置 ip_forward 函数,负责将数据包转发到合适的网络接口,继续其传输旅程。
1.2.5. 本地交付的深入剖析
当数据包被确定为本地交付后,会进入 ip_local_deliver 函数进行处理。
在这个函数中,首先会对 IP 报文进行分片处理。
在网络传输过程中,由于不同网络链路的最大传输单元(MTU)可能不同,当数据包的大小超过了链路的 MTU 时,就需要将数据包进行分片,将其拆分成多个较小的片段进行传输。
例如,在一个以太网链路中,MTU 通常为 1500 字节,如果一个数据包的大小为 2000 字节,那么就需要将其分成两个片段,分别为 1500 字节和 500 字节。
ip_local_deliver 函数会对这些分片进行重组,将它们还原成完整的数据包。
它会根据 IP 首部中的分片标识、标志位和片偏移等信息,判断各个分片之间的顺序和关系,然后将它们正确地组合起来。
完成分片重组后,数据包会被传递到 netfilter 的 NF_IP_LOCAL_IN 钩子点,在这里,用户可以再次对数据包进行过滤、修改等操作。
经过 NF_IP_LOCAL_IN 钩子点的处理后,数据包最终会到达传输层,根据其协议类型(如 TCP、UDP 等),被传递到相应的传输层协议模块进行后续的处理,如 TCP 协议的连接管理、数据确认等。
在实际的网络场景中,分片处理非常重要。
例如,当我们从互联网上下载一个大型文件时,文件会被分成多个数据包进行传输,这些数据包可能会经过不同的网络链路,由于各链路的 MTU 不同,数据包可能会被分片。
如果接收方不能正确地对这些分片进行重组,那么就无法得到完整的文件,导致下载失败。
1.3. 数据输出:从内核到网卡的征程
1.3.1. 传输层的发起
在 Linux 内核中,网络数据的输出是一个复杂而有序的过程,传输层在其中扮演着重要的发起角色。
以常见的 TCP 和 UDP 协议为例,当应用程序调用 send() 或 sendto() 等系统调用发送数据时,数据首先进入传输层。
对于 TCP 协议,以一个 Web 服务器向客户端发送网页数据为例,当 Web 服务器的应用程序调用 send() 函数时,数据会被传递到 TCP 层。
TCP 层会为数据添加 TCP 首部,首部中包含源端口号、目的端口号、序列号、确认号等重要信息。
这些信息就像是货物运输中的详细清单,用于建立和维护可靠的连接。
在这个过程中,TCP 协议会进行一系列的操作,如拥塞控制、流量控制等,以确保数据能够稳定、高效地传输。
拥塞控制就像是交通警察,通过调整发送窗口的大小,避免网络拥塞,确保数据能够顺利传输;
流量控制则是根据接收方的接收能力,调整发送方的发送速率,防止接收方缓冲区溢出。
而 UDP 协议则相对简单,它不提供可靠的传输机制,也没有拥塞控制和流量控制。
在视频直播应用中,为了保证直播的实时性,通常会使用 UDP 协议。
当直播服务器调用 sendto() 函数发送视频数据时,UDP 层同样会添加 UDP 首部,首部包含源端口号和目的端口号等信息。
由于 UDP 协议不需要建立连接,也不保证数据的可靠传输,所以它的传输效率较高,能够满足视频直播对实时性的要求。
1.3.2. IP 层的处理前奏
当数据从传输层传递到 IP 层后,本地发送的数据包首先会到达 netfilter 的 NF_IP_LOCAL_OUT 钩子点。
这个钩子点就像是一个关卡,在这里可以对数据包进行各种自定义的处理,如过滤、修改等。
在一些安全防护场景中,管理员可能会在 NF_IP_LOCAL_OUT 钩子点设置规则,对本地发送的数据包进行安全检查。
如果发现数据包中包含恶意代码或违反安全策略的内容,就可以直接丢弃该数据包,从而保护系统的安全。
而对于转发的数据包,由于其来源并非本地生成,所以会跳过 NF_IP_LOCAL_OUT 钩子点,直接进入后续的处理流程。
这是因为转发数据包的处理逻辑与本地发送数据包有所不同,转发数据包更关注的是如何快速、准确地将数据转发到下一个网络节点,而不是进行本地的安全检查等操作。
钩子点的处理对数据包流向起着关键的控制作用,通过合理设置钩子点的规则,可以实现对网络数据的有效管理和安全防护。
1.3.3. 路由与协议设置
在经过 NF_IP_LOCAL_OUT 钩子点的处理后,数据包会进入 ip_output 函数。
在这个函数中,会进行一系列重要的设置,以确保数据包能够正确地传输到目标地址。
ip_output 函数会根据数据包的目的 IP 地址,查找路由表,确定数据包应该从哪个网络设备发送出去,以及下一跳的地址。
这个过程就像是为数据包规划一条旅行路线,根据目的地的不同,选择最合适的交通路线。
它会设置数据包的网络设备和协议类型。
以一个企业内部网络为例,当一台计算机要向外部网络发送数据时,ip_output 函数会根据路由表的信息,选择合适的出口网络设备,如企业的网关设备,并将数据包的协议类型设置为 IP 协议。
完成这些设置后,数据包会被传递到 netfilter 的 NF_IP_POST_ROUTING 钩子点。
在这个钩子点,同样可以对数据包进行一些最后的处理,如网络地址转换(NAT)等。
NAT 技术可以将企业内部网络的私有 IP 地址转换为合法的公网 IP 地址,使得企业内部的计算机能够访问外部网络。
这些设置对数据包传输至关重要,它们确保了数据包能够在复杂的网络环境中准确无误地找到自己的传输路径,最终到达目标地址。
1.3.4. 数据分片与最终发送
当数据包到达 ip_finish_output 函数时,会对报文长度与 MTU(最大传输单元)进行比较。
**MTU(最大传输单元)**是指数据链路层一次能够传输的最大数据量,不同的网络链路类型,其 MTU 值也不同,例如以太网的 MTU 通常为 1500 字节。
如果报文长度超过了 MTU,就需要进行分片处理。
这就好比将一个大包裹拆分成多个小包裹进行邮寄,以确保每个小包裹都能顺利通过传输链路。
在分片过程中,会为每个分片分配一个标识,以及片偏移等信息,以便在接收端能够正确地重组这些分片。
以一个发送大型文件的场景为例,假设文件被封装成一个大小为 3000 字节的数据包,而网络链路的 MTU 为 1500 字节,那么这个数据包就需要被分成两个分片,第一个分片包含 1500 字节的数据,第二个分片包含剩余的 1500 字节数据。
每个分片都会有自己的 IP 首部,首部中的标识字段相同,用于表示它们属于同一个原始数据包,片偏移字段则表示该分片在原始数据包中的位置。
经过分片处理后,或者如果报文长度小于等于 MTU,数据包会通过邻居子系统发送到网络设备。
邻居子系统负责解析目标 IP 地址对应的 MAC 地址,就像是根据收件人的地址找到对应的门牌号。
通过 **ARP(地址解析协议)**等机制,邻居子系统可以获取到目标 IP 地址对应的 MAC 地址,并将其添加到数据包的链路层首部中。
最后,数据包会被发送到网络设备,通过物理网络介质传输到目标地址。
在实际的网络传输中,网络带宽是一个重要的因素。
如果网络带宽较低,而数据包过大,就容易导致网络拥塞,影响数据传输的效率。
因此,分片处理确保超过链路 MTU 的数据包也能顺利传输,避免因报文过长而被中间网络设备直接丢弃;同时拆分后更小的分片也能更充分地利用网络带宽,提高数据传输的成功率。
1.4. 实例分析与代码解读
1.4.1. 选取典型场景
为了更直观地理解 Linux 内核中网络数据的输入输出流程,我们以 Web 服务器与客户端的通信为例进行分析。
在这个场景中,客户端通过浏览器访问 Web 服务器,请求获取网页内容。
当我们在浏览器地址栏输入网址并按下回车键后,一系列复杂的网络数据交互就此展开。
首先,客户端的浏览器会解析 URL,提取出服务器的域名和请求的资源路径。
接着,通过 DNS 解析将域名转换为对应的 IP 地址。
在获取到服务器的 IP 地址后,客户端与服务器之间建立 TCP 连接,这一过程通过著名的三次握手来完成。
三次握手确保了双方的通信连接是可靠的,就像在正式开始对话之前,双方先确认彼此都准备好进行交流。
TCP 连接建立成功后,客户端向服务器发送 HTTP 请求,请求中包含了请求方法(如 GET、POST 等)、请求头和请求体等信息。
服务器接收到 HTTP 请求后,会根据请求内容进行处理,可能会读取相应的网页文件或调用后端程序生成动态内容。
然后,服务器将处理结果封装在 HTTP 响应中返回给客户端。
客户端收到 HTTP 响应后,对其进行解析,提取出网页内容,并通过浏览器进行渲染,最终将网页展示在用户面前。
当通信结束后,客户端和服务器之间会通过四次挥手来断开 TCP 连接。
1.4.2. 关键代码片段解析
结合上述 Web 服务器与客户端通信的场景,我们来看一些 Linux 内核中实现网络数据输入输出的关键代码片段。
[!warning] 说明
以下代码为高度简化的示意代码,仅用于展示调用链和核心思路,并非内核源码原文;具体实现随内核版本演进而变化。
在网络数据输入方面,以 e1000 网卡驱动为例,其硬中断处理与 NAPI 轮询收包的简化流程如下:
/* 高度简化的示意代码,仅用于展示调用链,非内核源码原文 */
/* 硬中断处理:只做“前奏”,把耗时的收包交给软中断(NAPI) */
irqreturn_t e1000_intr(int irq, void *data)
{
struct e1000_adapter *adapter = data;
e1000_disable_irq(adapter); /* 先关闭硬中断,避免收包期间反复被打断 */
napi_schedule(&adapter->napi); /* 调度 NAPI 软中断,进入轮询收包 */
return IRQ_HANDLED;
}
/* NAPI 轮询回调(软中断上下文):真正的批量收包循环 */
static int e1000_poll(struct napi_struct *napi, int budget)
{
struct e1000_adapter *adapter =
container_of(napi, struct e1000_adapter, napi);
int work_done = 0;
/* 循环从 DMA 环形队列取出帧并封装成 skb;
e1000_clean_rx_irq 内部会调用 napi_gro_receive() 将 skb 送入协议栈 */
while (work_done < budget &&
e1000_clean_rx_irq(adapter, &adapter->rx_ring[0],
&work_done, budget))
;
/* 本批次处理完,重新打开硬中断 */
if (work_done < budget) {
napi_complete(napi);
e1000_enable_irq(adapter);
}
return work_done;
}
这段代码体现了中断处理的经典分工:硬中断只做“前奏”(禁用硬中断 + 调度 NAPI 软中断),真正的收包循环在软中断的轮询回调 e1000_poll 中完成,每取出一帧就通过 napi_gro_receive() 送入协议栈(GRO 会先尝试合并同类数据包),本批次收完后才重新使能硬中断。
这样把中断处理的耗时压缩到最低,避免 CPU 被高频中断拖垮。
在网络数据输出方面,当应用程序调用 send() 函数发送数据时,最终会调用到 tcp_sendmsg 函数。
下面是 tcp_sendmsg 函数的简化示意代码:
/* 高度简化的示意代码(调用链参考早期内核),仅用于理解流程 */
int tcp_sendmsg(struct sock *sk, struct msghdr *msg, size_t size)
{
struct tcp_sock *tp = tcp_sk(sk); /* 真实实现还需用 tp 维护序号、MSS 缓存等状态 */
size_t copied = 0;
/* 用户数据通常远大于单个 TCP 段,需要按当前 MSS 分块处理 */
while (copied < size) {
struct sk_buff *skb;
int mss_now = tcp_current_mss(sk);
int chunk = min(size - copied, (size_t)mss_now);
/* 1) 分配承载一个 TCP 段的 skb,并预留 TCP/IP 头部空间 */
skb = alloc_skb_fclone(MAX_TCP_HEADER + chunk, sk->sk_allocation);
if (!skb)
return copied ? copied : -ENOMEM;
skb_reserve(skb, MAX_TCP_HEADER);
/* 2) 将用户空间数据拷贝进 skb(即真正写入“发送缓冲区”) */
if (skb_add_data_nocache(skb, &msg->msg_iter, chunk)) {
kfree_skb(skb);
return copied ? copied : -EFAULT;
}
skb->len = chunk;
/* 3) 挂入发送队列,并尝试立即推送 */
__skb_queue_tail(&sk->sk_write_queue, skb);
tcp_push(sk, msg->msg_flags, chunk, 0, 0, GFP_KERNEL);
copied += chunk;
}
return copied;
}
在 tcp_sendmsg 中,用户数据并不是一次性整包发出,而是按当前 MSS 分块、逐段封装成 skb:先分配带头部预留空间的 skb,把用户空间数据拷贝进 skb(这一步才是真正进入内核“发送缓冲区”),挂入发送队列后调用 tcp_push() 触发发送。
完整的发送调用链如下:
flowchart LR
A[应用层 send / write] --> B[syscall: sendto / sendmsg]
B --> C[sock_sendmsg]
C --> D[tcp_sendmsg]
D --> E[tcp_push]
E --> F[tcp_write_xmit]
F --> G[tcp_transmit_skb]
G --> H[ip_queue_xmit]
H --> I[IP 层输出]
可以看到,tcp_sendmsg 并不直接调用 ip_queue_xmit,而是经由 tcp_push → tcp_write_xmit → tcp_transmit_skb 之后,才由 tcp_transmit_skb 调用 ip_queue_xmit 把 skb 交给 IP 层,完成向网络层的移交。
通过对这些关键代码片段的解析,我们可以更深入地理解 Linux 内核中网络数据输入输出的实际代码逻辑,感受到内核开发者在实现网络通信功能时的精妙设计和严谨思考。
[!success] 核心要点回顾
- 接收路径:网卡 DMA → 硬中断 → NAPI 软中断 → 驱动收包为 skb → ip_rcv 校验 → 路由决策 → ip_local_deliver / ip_forward → 传输层 → Socket 接收队列 → 应用。
- 发送路径:应用 send / write → sock_sendmsg → tcp_sendmsg / udp_sendmsg → ip_queue_xmit / ip_append_data → IP 层(LOCAL_OUT → 路由 → POST_ROUTING → 分片)→ 邻居子系统(ARP 解析 MAC)→ 网卡。
- 两个关键抽象:
skb(贯穿整个协议栈的数据载体)与 netfilter 钩子(在 PRE_ROUTING / LOCAL_IN / FORWARD / LOCAL_OUT / POST_ROUTING 等点提供过滤、改造能力)。- 两个易错点:TCP 面向连接且可靠(三次握手建立、四次挥手关闭、确认重传);UDP 无连接、无重传,校验和在 IPv4 下可选、IPv6 下必填。