引言:一次数据包旅行的全貌

当一个应用程序调用 send() 发送一段数据时,这段数据要经历 socket 层、TCP/UDP 传输层、IP 网络层、邻居子系统、设备驱动层,最终变成物理信号离开网卡。反过来,从网卡触发硬件中断到 recv() 读取到数据,又经历一条逆向的全链路。这趟"数据包旅行"是 Linux 内核最复杂的子系统之间的协同工作。

本文以一次完整的 send/recv 路径为线索,逐层剖析 Linux 内核网络栈的实现细节、性能优化和实战调试技巧,涵盖 socket 层、TCP 协议栈、IP 路由与 netfilter、邻居子系统、Qdisc 队列规则,直至网卡驱动层的 NAPI 轮询与零拷贝技术。

第一层:Socket 层——用户空间与内核的桥梁

Socket 层是用户空间和内核协议栈之间的抽象边界。每个 socket 在内核中对应一个 struct socket 和一个 struct sock,后者是协议无关的网络层核心数据结构。

// include/linux/net.h — socket 核心结构
struct socket {
    socket_state          state;
    short                 type;       // SOCK_STREAM / SOCK_DGRAM
    unsigned long         flags;
    struct file           *file;      // 关联的文件描述符
    struct sock           *sk;        // 底层协议 socket
    const struct proto_ops *ops;      // 协议操作向量表
    struct socket_wq      wq;         // 等待队列(阻塞 I/O)
};

// include/net/sock.h — 协议无关 sock(超大结构,约 500+ 字段)
struct sock {
    struct sock_common  __sk_common;  // 协议公共字段
    struct sk_buff_head sk_receive_queue;  // 接收队列
    struct sk_buff_head sk_write_queue;    // 发送队列
    struct sk_buff_backlog backlog;        // 预队列(软中断用)
    int                 sk_rcvbuf;     // 接收缓冲区大小
    int                 sk_sndbuf;     // 发送缓冲区大小
    atomic_t            sk_drops;      // 丢包计数(缓冲区满时)
    // ... 约 500+ 行字段
};

关键设计:proto_ops 向量表实现了多态——TCP 注册 inet_stream_ops,UDP 注册 inet_dgram_ops,原始套接字注册 inet_sockraw_ops。这使得 sys_sendmsg() 无需关心底层协议。

// net/socket.c — 发送系统调用核心路径
SYSCALL_DEFINE3(sendmsg, int, fd, struct user_msghdr __user *, msg, unsigned, flags)
{
    struct socket *sock = sockfd_lookup_light(fd, &err, &fput_needed);
    err = sock_sendmsg(sock, &msg_sys);  // 派发到 proto_ops->sendmsg()
}

对于阻塞式 socket,如果发送缓冲区已满,进程会被放入 sk->sk_sleep 等待队列,socket_wq_wait_for_sndbuf() 在内核空间调度让出 CPU,直到 TCP 层确认数据发送完毕腾出缓冲区空间。

第二层:TCP 传输层——可靠字节流的工程实现

TCP 是整个网络栈中最复杂的协议模块,负责可靠传输、流量控制、拥塞控制和连接管理。Linux 内核的 TCP 实现约 15000 行代码。

2.1 TCP 发送路径

tcp_sendmsg() 是 TCP 发送的核心入口,它做了以下几件关键事:

  1. 复制用户数据:skb_copy_datag_iovec() 将用户空间的 iovec 数据拷贝到内核 sk_buff
  2. 队列管理:将新 skb 加入 sk_write_queue,但只有当 sk_sndbuf 未满时才允许
  3. 触发实际发送:调用 tcp_push() -> tcp_write_xmit() -> tcp_transmit_skb(),根据拥塞窗口和接收方通告窗口决定发送时机
// net/ipv4/tcp.c — TCP 发送核心
static int tcp_sendmsg_locked(struct sock *sk, struct msghdr *msg, size_t size)
{
    // 1. 检查连接状态(ESTABLISHED / CLOSE_WAIT 才可发)
    // 2. 处理 MSG_OOB 带外数据
    // 3. 分段:根据 MSS 拆分为合适大小的 skb
    while (msg_data_pending(msg)) {
        skb = tcp_write_queue_tail(sk);
        // 如果最后一个 skb 开了 TCP_COIT(合并),尝试追加
        if (tcp_sendmsg_fastopen_defer_connect(...) == 0)
            copy = tcp_skb_can_collapse(skb);
        
        // 空间不足:分配新 skb,加入 sk_write_queue
        if (!skb || !tcp_skb_can_collapse(skb)) {
            skb = alloc_skb(..., sk->sk_allocation);
            skb_entail(sk, skb);  // 入队 + 更新发送指针
        }
        
        // 从用户态拷贝数据到 skb
        copy = min_t(int, copy, msg_data_left(msg));
        if (skb_copy_datag_iovec(skb, 0, msg, copy))
            return -EFAULT;
        
        // 4. 尝试推送发送
        tcp_push(sk, flags, mss_now, TCP_NAGLE_OFF, size_goal);
    }
}

2.2 TCP 接收路径(软中断驱动)

TCP 接收发生在网络中断的 softirq 上下文(NET_RX_SOFTIRQ),核心流程:

  1. tcp_v4_rcv() 根据四元组(src IP, dst IP, src port, dst port)查找对应的 struct sock(使用哈希表 tcp_hashinfo)
  2. 根据 sk->sk_state 判断连接状态,进入不同处理分支
  3. ESTABLISHED 状态下调用 tcp_rcv_established(),执行数据重组、ACK 应答、窗口更新
  4. tcp_queue_rcv() 将有效载荷加入 socket 的接收队列 sk_receive_queue
  5. 预队列优化:如果 socket 正被用户进程读取,使用 backlog 预队列直接在内核中完成拷贝(tcp_prequeue()),避免在中断返回后再次调度
// net/ipv4/tcp_ipv4.c — TCP 数据包接收入口
int tcp_v4_rcv(struct sk_buff *skb)
{
    // 1. 查找 socket
    sk = __inet_lookup_skb(&tcp_hashinfo, skb, ...);
    
    // 2. 状态路由
    switch (sk->sk_state) {
    case TCP_TIME_WAIT:
        goto do_time_wait;
    case TCP_NEW_SYN_RECV:
        goto process;
    case TCP_LISTEN:
        // 半连接:可能是新连接请求或重传 SYN
        // 查找 listener sock
        maybe_new_skn = tcp_v4_cookie_check();
        // ...
    }
    
process:
    // 3. 处理 ESTABLISHED 状态的数据
    ret = tcp_rcv_state_process(sk, skb);
    // -> tcp_rcv_established(sk, skb) 处理数据段
}

2.3 TCP 拥塞控制

Linux 内核支持多种拥塞控制算法,通过 tcp_cong_list 注册。默认是 cubic(Linux 2.6.19+),其拥塞窗口增长函数为三次函数:

// net/ipv4/tcp_cubic.c — CUBIC 拥塞控制
static void bictcp_cong_avoid(struct sock *sk, u32 ack, u32 acked)
{
    struct tcp_sock *tp = tcp_sk(sk);
    
    if (!tcp_is_cwnd_limited(sk))
        return;
    
    // 增长函数: W(t) = C*(t-K)^3 + W_max
    // 其中 K = cbrt(W_max * beta / C)
    if (tp->snd_cwnd_cnt >= bictcp_cwnd_event_ca) {
        tp->snd_cwnd_cnt = 0;
        // 凹区间(远离 W_max)—— 快速探测带宽
        // 凸区间(接近 W_max)—— 谨慎逼近,避免丢包
    }
    tp->snd_cwnd_cnt += acked;
}

除了 CUBIC,内核还内置了 BBR(Bottleneck Bandwidth and Round-trip propagation time),其模型不依赖丢包,而是通过测量带宽和 RTT 直接设置发送速率,在高丢包率场景下性能远超 CUBIC。

第三层:IP 网络层——路由、分片与 netfilter

3.1 IP 输出路径

TCP 层的 tcp_transmit_skb() 最终调用 ip_local_out() -> ip_output() -> ip_finish_output(),在此过程中:

  1. 路由查找:ip_route_output_ports() 在 FIB(Forwarding Information Base)中查找出口路由,确定下一跳 IP 和出口设备
  2. MTU 分片:如果数据包超出路径 MTU,ip_fragment() 按 8 字节对齐切分,每个分片独立发送
  3. netfilter 钩子:ip_local_out() 经过 POST_ROUTING 钩子(iptables 的 nat/filter 表在此处生效)
// net/ipv4/ip_output.c — IP 输出核心
static int ip_finish_output(struct net *net, struct sock *sk, struct sk_buff *skb)
{
    // 1. 检查是否需要分片
    if (skb->len > mtu && !skb_is_gso(skb))
        return ip_fragment(net, sk, skb, ip_finish_output2);
    
    // 2. 邻居子系统解析,最终到 dev_queue_xmit()
    return ip_finish_output2(net, sk, skb);
}

static int ip_finish_output2(struct net *net, struct sock *sk, struct sk_buff *skb)
{
    // 查找邻居(ARP 解析后的 MAC 地址)
    neigh = __ipv4_neigh_lookup_noref(dst->dev, nexthop);
    if (!neigh)
        neigh = __neigh_create(&arp_tbl, ...);
    
    // 调用邻居子系统的 output
    return neigh_output(sk, skb);
}

3.2 netfilter 钩子与 iptables

netfilter 提供了 5 个钩子点,构成了 Linux 防火墙的基础架构:

  • PREROUTING:数据包到达后、路由决策前
  • INPUT:路由决策确定为本机接收后
  • FORWARD:路由决策确定为转发时
  • OUTPUT:本地进程发出数据包后
  • POSTROUTING:数据包离开本机前

iptables 的 nat/mangle/filter 表在这五个点上注册钩子链,按优先级顺序执行。Linux 4.19+ 引入了 nftables,作为 iptables 的继任者,使用虚拟机字节码替代线性链表,规则匹配效率更高。

第四层:邻居子系统与 Qdisc 排队规则

4.1 邻居子系统(ARP)

邻居子系统负责将 IP 层地址解析为链路层地址(ARP 之于 IPv4,NDP 之于 IPv6)。struct neigh_table 维护一个邻居哈希表,每个 struct neighbour 有四种状态:

  • NUD_NONE:初始状态,无缓存
  • NUD_INCOMPLETE:已发出 ARP 请求,等待应答
  • NUD_REACHABLE:已确认可达,有 MAC 缓存
  • NUD_STALE:缓存过期但未清除,可被直接使用(发送 gratuitous ARP 触发验证)

4.2 Qdisc 排队规则

Qdisc(Queueing Discipline)是内核与网卡驱动之间的流量整形层。默认的 pfifo_fast 实现三个优先级队列(band 0 > band 1 > band 2)。Linux 4.19+ 引入了 fq_codel(Flow Queueing with Controlled Delay),是现代主机网络栈的标配:

// net/sched/sch_fq_codel.c — FQ_CODEL
static int fq_codel_enqueue(struct sk_buff *skb, struct Qdisc *sch, ...)
{
    // 1. 根据流(五元组哈希)将数据包分到对应的 flow queue
    idx = fq_codel_hash(skb);
    flow = &q->flows[idx];
    
    // 2. 如果 flow queue 超额(超过 quantum),标记为 new_flow
    // 3. DRR 轮询调度各 flow(Deficit Round Robin)
    // 4. Codel 算法在队列延迟超过 target(默认 5ms)时丢包
}

FQ_CODEL 的 Codel 组件是一种"基于间隔丢包"的 AQM 算法——只有当数据包在队列中停留时间超过目标延迟时才丢包,这种设计让 TCP 流在检测到丢包信号前就能主动降速,从而消除 Bufferbloat(缓冲区膨胀)问题。

第五层:网卡驱动与 NAPI 轮询

5.1 硬件中断与软中断

网卡收到数据包后,首先触发硬中断,硬中断处理程序只做最小工作(禁用硬件中断、调度 softirq),然后进入 NET_RX_SOFTIRQ 软中断:

// net/core/dev.c — NAPI 轮询框架
static int net_rx_action(struct softirq_action *h)
{
    list_for_each_entry_safe(n, &sd->poll_list, poll_list) {
        work = n->poll(n, weight);  // 驱动轮询函数
        budget -= work;             // NAPI_BUDGET = 64
        if (work >= weight)         // 未完成全部预算
            goto softnet_break;     // 退出软中断处理
    }
    
    // 如果 budget 用完但仍有数据包,重新调度 softirq
    if (!list_empty(&sd->poll_list))
        __raise_softirq_irqoff(NET_RX_SOFTIRQ);
}

5.2 NAPI 轮询机制

NAPI(New API)是现代 Linux 网卡驱动的标准模式,它结合了中断和轮询的优点:

  • 网卡发出数据包后触发一次中断,中断处理程序关闭硬件中断并调用 napi_schedule() 加入轮询列表
  • 软中断中的轮询函数一次性处理多个数据包(budget 限制)
  • 所有数据包处理完毕后,才重新启用硬件中断

这种模式在高流量场景下避免了"中断风暴"——当数据包到达速率超过 CPU 中断处理能力时,NAPI 保证系统进入轮询模式而非持续响应中断。

5.3 DMA 与 Ring Buffer

高性能网卡使用 DMA(Direct Memory Access)将数据包直接写入内核预分配的 Ring Buffer(环形缓冲区):

// Intel igb 驱动示例:Ring Buffer 定义
struct igb_ring {
    struct igb_rx_buffer *rx_buffer_info;   // 接收缓冲区数组
    dma_addr_t dma;                         // DMA 映射地址
    u16  count;                             // 描述符数量(Ring 大小)
    u16  next_to_use;                       // 驱动消费指针
    u16  next_to_clean;                     // 驱动清空指针
};

Ring Buffer 有两个关键参数可以调优:ethtool -G rx 4096 可以增大 Ring 大小以应对突发流量,但这会增加延迟(更多数据滞留在缓冲区中)。

性能优化与零拷贝技术

6.1 sendfile + DMA Scatter/Gather

sendfile() 允许在 kernel space 内将文件数据直接发送给 socket,避免了用户空间的中间拷贝。配合 DMA Scatter/Gather,当文件页在内存中不连续时,网卡可以直接从多个分散的物理地址收集数据。

// 传统方式:4 次拷贝
// 磁盘 --DMA--> 内核缓冲区 --CPU copy--> 用户缓冲区 --CPU copy--> socket 缓冲区 --DMA--> 网卡

// sendfile:2 次拷贝(DMA 完成)
// 磁盘 --DMA--> 内核页缓存 --DMA Scatter/Gather--> 网卡
// 注意:内核内部的 CPU 拷贝仍然由 DMA 的 Gather 功能替代

6.2 TCP Zero-Copy Send (MSG_ZEROCOPY)

Linux 4.14 引入了 MSG_ZEROCOPY 标志,允许将用户空间页面直接映射到内核网络栈,无需 copy_from_user():

// 使用 MSG_ZEROCOPY
ret = send(fd, buf, len, MSG_ZEROCOPY);
if (ret == -1 && errno == ENOBUFS) {
    // 内存锁定限制(RLIMIT_MEMLOCK),需要提升限制
}

// 通过 sock_extended_err 获取完成通知:
struct sock_extended_err *serr;
struct msghdr msg = {0};
msg.msg_control = cmsgbuf;
msg.msg_controllen = sizeof(cmsgbuf);
recvmsg(fd, &msg, MSG_ERRQUEUE);  // 获取 ZEROCOPY 完成事件
if (serr->ee_origin == SO_EE_ORIGIN_ZEROCOPY)
    // [serr->ee_info, serr->ee_data] 范围的发送已完成

注意:MSG_ZEROCOPY 避免了 CPU 拷贝,但 DMA 传输仍然发生——它真正优化的是当同一份数据被多次发送时的 CPU 开销(例如 Web 服务器向多个客户端发送同一文件)。

6.3 XDP (eXpress Data Path)

XDP 允许在网络包到达内核协议栈之前就执行 eBPF 程序决定其命运。这是 Linux 4.8+ 的最高性能网络路径:

// XDP 三种返回码
enum xdp_action {
    XDP_ABORTED = 0,    // 严重错误,触发 tracepoint
    XDP_DROP,           // 立即丢弃(DDoS 防护利器)
    XDP_PASS,           // 继续进入内核网络栈
    XDP_TX,             // 从收到的同一网卡发回
    XDP_REDIRECT,       // 转发到另一个网卡或 CPU(配合 XDP_MAP)
};

// 性能:XDP 在网卡驱动层就执行过滤
// 相比 iptables 的 100K pps/Xeon,XDP 可达 24M pps/Xeon
// DDoS 防护、负载均衡(Katran/L4LB 即基于此)

6.4 io_uring 异步网络

Linux 5.1+ 引入了 io_uring,统一了异步 I/O 接口。网络方面它解决了 epoll 的不足:

  • io_uring_prep_send() / io_uring_prep_recv() 直接在提交队列中准备操作
  • 完成事件在 CQ(Completion Queue)中批量消费,减少系统调用次数
  • 固定缓冲区(Fixed Buffers)注册后无需每次 kernel→user 拷贝
  • 配合 IORING_SETUP_SQPOLL,提交队列轮询在内核线程完成,用户空间甚至无需调用 io_uring_enter()

实战调试:工具链

Linux 网络栈的调试工具链非常丰富:

  • ss -ti:查看 TCP 连接详细信息(RTT、拥塞窗口、缓冲区状态)
  • perf trace -e net:*:追踪所有 net 相关 tracepoint
  • bpftrace -e 'kprobe:tcp_sendmsg { @bytes[comm] = count(arg2) }':实时统计每个进程的 TCP 发送字节数
  • tcpdump -i eth0 -w capture.pcap:抓包分析协议交互
  • strace -e sendmsg,recvmsg -p <pid>:系统调用层面追踪数据发送和接收
  • nstat -az:TCP/IP 层统计(重传率、缓冲区溢出次数等)

总结:分层设计的工程智慧

Linux 网络栈的分层架构每一层都解决了一个核心问题:Socket 层抽象 I/O、TCP 层保证可靠性、IP 层处理路由寻址、邻居层管理链路映射、Qdisc 实施流量整形、驱动层对接硬件设备。理解这一全链路不仅对性能调优至关重要,也是理解分布式系统、容器网络、服务网格等上层架构的基础。

随着 XDP、io_uring、(Generic) Segmentation Offload、TSO/GSO 等技术的出现,Linux 网络栈正在从"尽可能通用"向"极致性能"方向演进——在不改变语义的前提下,让数据尽可能少地穿越软件层,更快地抵达物理介质。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部