土法炼钢兴趣小组的算法知识备份

【Linux 网络子系统深度拆解】发包路径全解:从 send() 到网线

文章导航

分类入口
linuxnetworking
标签入口
#tx-path#linux-kernel#tcp-sendmsg#qdisc#gso#tso#dev_queue_xmit#ndo_start_xmit#bql#bpftrace

目录

上一篇我们拆解了 收包路径——从 NIC 中断到 socket 接收队列。现在反过来:用户态调用 send() 之后,数据在内核中怎么走到网线上?

这个问题比收包更复杂。收包路径是”被动的”——包来了就处理;发包路径是”主动的”——内核需要在多个约束下做决策:

本文从 sendmsg() 系统调用开始,逐函数追踪完整的发包路径。

本文基于 Linux 6.6 LTS 内核源码。6.8 的差异会单独标注。


一、全景图:TX 发包路径的六个阶段

TX 发包路径全景
阶段 关键函数 上下文
Socket / 传输层 tcp_sendmsg()tcp_write_xmit()__tcp_transmit_skb() 进程上下文(持有 socket 锁)
IP 层 ip_queue_xmit()ip_local_out()ip_output() 进程上下文
Netfilter NF_INET_LOCAL_OUTNF_INET_POST_ROUTING 进程上下文
TC / Qdisc dev_queue_xmit()qdisc->enqueue()qdisc->dequeue() 进程上下文或软中断
驱动 dev_hard_start_xmit()ndo_start_xmit() 持有 TX 队列锁
TX 完成 TX 完成中断 → napi_pollconsume_skb() 软中断上下文(异步)

与收包路径不同,发包路径通常在用户进程上下文中执行——从 sendmsg() 系统调用一路同步执行到驱动写入 DMA 描述符。只有 TX 完成(释放 skb、回收内存)是异步在软中断中做的。


二、Socket 层:sendmsg 系统调用

2.1 从系统调用到协议处理

用户态 send()/sendmsg()/write() 最终都进入内核的 __sys_sendto()sock_sendmsg()sock->ops->sendmsg()。对于 TCP socket,这就是 tcp_sendmsg()

// include/net/tcp.h:338
int tcp_sendmsg(struct sock *sk, struct msghdr *msg, size_t size);

2.2 tcp_sendmsg:从用户数据到 skb

tcp_sendmsg() 是 TCP 发包的入口。它的核心任务是把用户态数据复制到内核的 skb 中,但不一定立即触发发送

// net/ipv4/tcp.c (简化关键路径)
int tcp_sendmsg_locked(struct sock *sk, struct msghdr *msg, size_t size)
{
    struct tcp_sock *tp = tcp_sk(sk);
    struct sk_buff *skb;
    int mss_now, size_goal;
    int copied = 0;

    // 1. 获取当前 MSS 和 size_goal(GSO 场景下可能远大于 MSS)
    mss_now = tcp_send_mss(sk, &size_goal, msg->msg_flags);

    while (msg_data_left(msg)) {
        // 2. 获取发送队列尾部的 skb,如果满了就分配新的
        skb = tcp_write_queue_tail(sk);
        if (!skb || skb->len >= size_goal) {
            skb = tcp_stream_alloc_skb(sk, sk->sk_allocation, false);
            if (!skb)
                goto wait_for_memory;
            tcp_skb_entail(sk, skb);  // 加入发送队列
        }

        // 3. 从用户态复制数据到 skb
        copy = min_t(int, size_goal - skb->len, msg_data_left(msg));

        if (skb_availroom(skb) > 0) {
            // 线性区域有空间,直接复制
            copy = min_t(int, copy, skb_availroom(skb));
            skb_add_data_nocache(sk, skb, &msg->msg_iter, copy);
        } else {
            // 线性区域满了,追加到 page fragment
            skb_copy_to_page_nocache(sk, &msg->msg_iter, skb,
                                     pfrag->page, pfrag->offset, copy);
        }

        copied += copy;
        tp->write_seq += copy;

        // 4. 如果填满了一个 size_goal,或者设置了 MSG_MORE 之外的标志,推送发送
        if (skb->len >= size_goal || (msg->msg_flags & MSG_OOB))
            __tcp_push_pending_frames(sk, mss_now, tp->nonagle);
    }

    // 5. 最终推送
    tcp_push(sk, msg->msg_flags, mss_now, tp->nonagle, size_goal);

    return copied;
}

关键细节:

size_goal vs MSS:在支持 GSO 的环境下(几乎所有现代系统),size_goal 远大于 MSS。例如,MSS 为 1448 字节时,size_goal 可能是 65536 字节——这意味着一个 skb 可以装下 64KB 数据,到后面的 GSO/TSO 阶段再分段。这大幅减少了 skb 分配次数。

发送时机tcp_sendmsg 不是每复制一字节就发一个包。它积累数据到 size_goal,然后通过 tcp_push() 触发实际发送。tcp_push() 还要考虑 Nagle 算法(小包合并)和 Cork 选项(TCP_CORK,显式延迟发送)。

2.3 发送缓冲区管理

TCP socket 的发送缓冲区大小由 sk->sk_sndbuf 控制。tcp_sendmsg 在复制数据时会检查发送缓冲区是否已满:

// sk_wmem_alloc 跟踪已提交到协议栈的 skb 占用的内存
// 如果 sk_wmem_alloc + 新数据 > sk_sndbuf,进程需要等待
if (!sk_stream_memory_free(sk))
    goto wait_for_memory;

sk_wmem_allocinclude/net/sock.h:435)是一个原子引用计数,跟踪所有”已离开 socket 但还在协议栈中”的 skb 占用的内存。当 skb 被驱动发送完毕后(TX 完成中断),sk_wmem_free_skb() 减少这个计数,并调用 sk->sk_write_space() 唤醒等待发送缓冲区的进程。


三、TCP 层:分段、拥塞控制与 TSQ

3.1 tcp_push 和 tcp_write_xmit

tcp_push() 设置好推送标志后,最终调用 __tcp_push_pending_frames()tcp_write_xmit()——这是 TCP 发包的核心调度函数。

// include/net/tcp.h:345
void tcp_push(struct sock *sk, int flags, int mss_now, int nonagle,
              int size_goal);
// net/ipv4/tcp_output.c (简化)
static bool tcp_write_xmit(struct sock *sk, unsigned int mss_now,
                            int nonagle, int push_one, gfp_t gfp)
{
    struct tcp_sock *tp = tcp_sk(sk);
    struct sk_buff *skb;
    int cwnd_quota;

    while ((skb = tcp_send_head(sk))) {
        // 1. 拥塞窗口检查
        cwnd_quota = tcp_cwnd_test(tp, skb);
        if (!cwnd_quota)
            break;  // 拥塞窗口不允许

        // 2. 接收窗口检查
        if (!tcp_snd_wnd_test(tp, skb, mss_now))
            break;

        // 3. TSQ 限流检查
        if (tcp_small_queue_check(sk, skb, 0))
            break;

        // 4. 如果 skb 超过 MSS,需要分段(TSO/GSO)
        if (skb->len > mss_now) {
            if (tcp_fragment(sk, TCP_FRAG_IN_WRITE_QUEUE,
                             skb, mss_now, mss_now, gfp))
                break;
        }

        // 5. 实际发送
        if (tcp_transmit_skb(sk, skb, 1, gfp))
            break;

        // 6. 移出发送队列头部,更新 SND.NXT
        tcp_event_new_data_sent(sk, skb);
    }
    return !tp->packets_out && !tcp_send_head(sk);
}

3.2 拥塞窗口与发送窗口

tcp_write_xmit() 的循环受两个窗口限制:

每次迭代检查 cwnd_quota = tcp_cwnd_test(tp, skb)——如果已发送但未确认的数据(packets_out)达到了拥塞窗口上限,停止发送。

3.3 TSQ:TCP Small Queues

TSQ(TCP Small Queues)是一个经常被忽略但极其重要的机制。它限制每个 TCP 流在 Qdisc 和驱动队列中排队的数据量,防止单个大流塞满队列导致其他流饿死(bufferbloat)。

// net/ipv4/tcp_output.c
static bool tcp_small_queue_check(struct sock *sk, const struct sk_buff *skb,
                                  unsigned int factor)
{
    unsigned int limit;

    limit = max_t(unsigned int, 2 * skb->truesize,
                  READ_ONCE(sk->sk_pacing_rate) >> READ_ONCE(sk->sk_pacing_shift));
    limit = min_t(unsigned int, limit,
                  READ_ONCE(sock_net(sk)->ipv4.sysctl_tcp_limit_output_bytes));
    limit <<= factor;

    if (refcount_read(&sk->sk_wmem_alloc) > limit) {
        // 队列中数据太多,设置 TSQ_THROTTLED 标志
        set_bit(TSQ_THROTTLED, &sk->sk_tsq_flags);
        // 检查是否在设置标志和检查之间有包被释放
        if (refcount_read(&sk->sk_wmem_alloc) > limit)
            return true;  // 停止发送
    }
    return false;
}

TSQ 的限制由 net.ipv4.tcp_limit_output_bytes(默认 1048576,即 1MB)控制。

TSQ 的释放时机:当 TX 完成中断释放 skb 时,tcp_wfree() 回调清除 TSQ_THROTTLED 标志,并通过 tcp_release_cb() 触发 tcp_write_xmit() 继续发送。

3.4 __tcp_transmit_skb:构造 TCP 段

tcp_transmit_skb() 最终调用 __tcp_transmit_skb(),负责:

  1. 如果需要重传,skb_clone() 一份(原始 skb 留在重传队列)
  2. 构建 TCP 头(序列号、确认号、窗口大小、flags、TCP 选项)
  3. 计算 TCP checksum(或设置硬件卸载标志)
  4. 调用 icsk->icsk_af_ops->queue_xmit(sk, skb, &inet->cork.fl)——对 IPv4 就是 ip_queue_xmit()

四、IP 层:路由、分片与 Netfilter

4.1 ip_queue_xmit:TCP 发包的 IP 层入口

// include/net/ip.h:239
int ip_queue_xmit(struct sock *sk, struct sk_buff *skb, struct flowi *fl);

ip_queue_xmit() 的核心工作:

// net/ipv4/ip_output.c (简化)
int __ip_queue_xmit(struct sock *sk, struct sk_buff *skb,
                    struct flowi *fl, __u8 tos)
{
    struct inet_sock *inet = inet_sk(sk);
    struct rtable *rt;

    // 1. 查找路由(优先使用 socket 缓存的路由)
    rt = (struct rtable *)__sk_dst_check(sk, 0);
    if (!rt) {
        // socket 缓存的路由失效,重新查找
        rt = ip_route_output_ports(net, fl4, sk,
                                   daddr, saddr,
                                   inet->inet_dport,
                                   inet->inet_sport,
                                   sk->sk_protocol, tos, sk->sk_bound_dev_if);
        sk_setup_caps(sk, &rt->dst);
    }

    skb_dst_set_noref(skb, &rt->dst);

    // 2. 构建 IP 头
    iph = ip_hdr(skb);
    iph->version  = 4;
    iph->ihl      = 5;
    iph->tos      = tos;
    iph->tot_len  = htons(skb->len);
    iph->id       = ...;
    iph->frag_off = htons(IP_DF);  // 通常设置 Don't Fragment
    iph->ttl      = ip_select_ttl(inet, &rt->dst);
    iph->protocol = sk->sk_protocol;
    iph->saddr    = fl4->saddr;
    iph->daddr    = fl4->daddr;

    // 3. 进入 ip_local_out
    return ip_local_out(net, sk, skb);
}

路由缓存优化__sk_dst_check() 优先使用 socket 上缓存的路由结果(sk->sk_dst_cache)。对于长连接的 TCP 流,这避免了每个包都查 FIB 路由表。路由变化时(如链路断开、路由更新),缓存会被失效。

4.2 ip_local_out 与 Netfilter LOCAL_OUT

// include/net/ip.h:211-212
int __ip_local_out(struct net *net, struct sock *sk, struct sk_buff *skb);
int ip_local_out(struct net *net, struct sock *sk, struct sk_buff *skb);
// net/ipv4/ip_output.c
int __ip_local_out(struct net *net, struct sock *sk, struct sk_buff *skb)
{
    struct iphdr *iph = ip_hdr(skb);

    iph->tot_len = htons(skb->len);
    ip_send_check(iph);  // 计算 IP 头 checksum

    // Netfilter LOCAL_OUT 钩子
    return nf_hook(NFPROTO_IPV4, NF_INET_LOCAL_OUT,
                   net, sk, skb, NULL, skb_dst(skb)->dev,
                   dst_output);
}

NF_INET_LOCAL_OUT 是 iptables OUTPUT 链的执行点——SNAT(源 NAT)的第一个检查点、conntrack 的出向入口、以及自定义 OUTPUT 规则的执行位置。

4.3 ip_output 与 Netfilter POST_ROUTING

Netfilter LOCAL_OUT 通过后,dst_output() 调用 ip_output()

// include/net/ip.h:168
int ip_output(struct net *net, struct sock *sk, struct sk_buff *skb);
// net/ipv4/ip_output.c
int ip_output(struct net *net, struct sock *sk, struct sk_buff *skb)
{
    // Netfilter POST_ROUTING 钩子
    return NF_HOOK_COND(NFPROTO_IPV4, NF_INET_POST_ROUTING,
                        net, sk, skb, indev, skb->dev,
                        ip_finish_output,
                        !(IPCB(skb)->flags & IPSKB_REROUTED));
}

NF_INET_POST_ROUTING 是 SNAT 执行点、conntrack confirm 点——所有离开本机的包(不管是本地发出还是转发)都要经过这里

4.4 ip_finish_output:GSO 与分片决策

// net/ipv4/ip_output.c (简化)
static int ip_finish_output(struct net *net, struct sock *sk, struct sk_buff *skb)
{
    // GSO 包需要分段
    if (skb_is_gso(skb))
        return ip_finish_output_gso(net, sk, skb, mtu);

    // 超过 MTU 且不允许分片 → ICMP Fragmentation Needed
    if (skb->len > mtu) {
        if (IPCB(skb)->flags & IPSKB_XFRM_TRANSFORMED)
            return ip_finish_output2(net, sk, skb);
        return ip_fragment(net, sk, skb, mtu, ip_finish_output2);
    }

    return ip_finish_output2(net, sk, skb);
}

对于 GSO 包(skb_is_gso(skb) 为真),ip_finish_output_gso() 会根据实际需要选择路径:

ip_finish_output2() 最终调用 neigh_output()dev_queue_xmit(),把 skb 交给链路层。


五、Qdisc 层:排队、调度与直接发送

5.1 dev_queue_xmit:TX 路径的中枢

// include/linux/netdevice.h:3175
static inline int dev_queue_xmit(struct sk_buff *skb)
{
    return __dev_queue_xmit(skb, NULL);
}

__dev_queue_xmit() 是发包路径中最复杂的函数之一:

// net/core/dev.c (简化关键路径)
static int __dev_queue_xmit(struct sk_buff *skb, struct net_device *sb_dev)
{
    struct net_device *dev = skb->dev;
    struct netdev_queue *txq;
    struct Qdisc *q;

    // 1. 选择 TX 队列
    txq = netdev_core_pick_tx(dev, skb, sb_dev);
    q = rcu_dereference_bh(txq->qdisc);

    // 2. 如果设备有 qdisc
    if (q->enqueue) {
        // 走 qdisc 排队路径
        rc = __dev_xmit_skb(skb, q, dev, txq);
        goto out;
    }

    // 3. 如果设备没有 qdisc(如 loopback、veth)
    // 直接发送
    if (dev->flags & IFF_UP) {
        skb = validate_xmit_skb(skb, dev, &again);
        rc = dev_hard_start_xmit(skb, dev, txq, &rc2);
    }

out:
    return rc;
}

5.2 TX 队列选择

netdev_core_pick_tx() 选择使用哪个 TX 队列:

// include/linux/netdevice.h:2596
u16 netdev_pick_tx(struct net_device *dev, struct sk_buff *skb,
                   struct net_device *sb_dev);

选择逻辑的优先级:

  1. skb 已标记队列:如果 skb->queue_mapping 已设置(如 TC 或 eBPF 指定),直接使用
  2. XPS 映射:如果启用了 XPS(Transmit Packet Steering),按发送 CPU 映射到 TX 队列
  3. 驱动的 ndo_select_queue:如果驱动注册了自定义队列选择回调
  4. 哈希选择:使用 skb_get_hash() 按流哈希选择队列,保证同一 TCP 流的所有包走同一队列(保序)

5.3 __dev_xmit_skb:Qdisc 入队与直接发送

// net/core/dev.c (简化)
static inline int __dev_xmit_skb(struct sk_buff *skb, struct Qdisc *q,
                                  struct net_device *dev,
                                  struct netdev_queue *txq)
{
    // 尝试绕过 qdisc 直接发送(快速路径)
    if ((q->flags & TCQ_F_CAN_BYPASS) && qdisc_empty(q) &&
        qdisc_run_begin(q)) {
        // qdisc 为空且没有其他线程在运行 → 直接发送
        skb = validate_xmit_skb(skb, dev, &again);
        if (skb) {
            sch_direct_xmit(skb, q, dev, txq, ...);
        }
        qdisc_run_end(q);
        return NET_XMIT_SUCCESS;
    }

    // 入队 qdisc
    rc = q->enqueue(skb, q, &to_free);

    // 如果没有其他线程在运行 qdisc,启动出队发送
    if (qdisc_run_begin(q)) {
        __qdisc_run(q);  // 循环出队并发送
        qdisc_run_end(q);
    }

    return rc;
}

TCQ_F_CAN_BYPASS:对于默认的 pfifo_fast qdisc,如果当前队列为空,包可以绕过入队-出队过程直接发送。这个优化在低负载场景下显著减少延迟。

5.4 Qdisc 数据结构

// include/net/sch_generic.h:73
struct Qdisc {
    int (*enqueue)(struct sk_buff *skb, struct Qdisc *sch,
                   struct sk_buff **to_free);
    struct sk_buff *(*dequeue)(struct Qdisc *sch);
    unsigned int            flags;
    u32                     limit;
    const struct Qdisc_ops  *ops;
    struct netdev_queue     *dev_queue;  // 关联的 TX 队列
    struct qdisc_skb_head   q;          // 内部包队列
    // ...
};
// include/net/sch_generic.h:287
struct Qdisc_ops {
    struct Qdisc_ops    *next;
    char                id[IFNAMSIZ];
    int (*enqueue)(struct sk_buff *, struct Qdisc *, struct sk_buff **);
    struct sk_buff *(*dequeue)(struct Qdisc *);
    struct sk_buff *(*peek)(struct Qdisc *);
    int (*init)(struct Qdisc *, struct nlattr *, struct netlink_ext_ack *);
    void (*reset)(struct Qdisc *);
    void (*destroy)(struct Qdisc *);
    // ...
};

Linux 内置的 qdisc 类型在 sch_generic.h 中声明:

extern struct Qdisc_ops noop_qdisc_ops;       // 黑洞,丢弃所有包
extern struct Qdisc_ops pfifo_fast_ops;        // 默认 qdisc,3 优先级 band
extern struct Qdisc_ops mq_qdisc_ops;         // 多队列设备的根 qdisc
extern struct Qdisc_ops noqueue_qdisc_ops;    // 无队列(如 loopback)
extern const struct Qdisc_ops *default_qdisc_ops;  // 可通过 sysctl 修改

默认 qdisc 可以通过 net.core.default_qdisc sysctl 修改——常见选择是 fq(Fair Queue)或 fq_codel(Fair Queue + CoDel),配合 BBR 拥塞控制使用。

5.5 __qdisc_run:出队发送循环

__qdisc_run() 在一个循环中反复从 qdisc 出队并发送:

// net/sched/sch_generic.c (简化)
void __qdisc_run(struct Qdisc *q)
{
    int quota = dev_tx_weight;  // 默认 64
    int packets;

    while (qdisc_restart(q, &packets)) {
        quota -= packets;
        if (quota <= 0) {
            // 配额耗尽,延迟到 NET_TX_SOFTIRQ
            __netif_schedule(q);
            break;
        }
    }
}

qdisc_restart() 内部调用 dequeue_skb() 从 qdisc 取出 skb,然后通过 sch_direct_xmit() 发送。如果发送配额(dev_tx_weight,默认 64)耗尽,通过 __netif_schedule() 触发 NET_TX_SOFTIRQ,在软中断中继续发送——这是发包路径中从进程上下文切换到软中断上下文的时机。

5.6 qdisc_run_begin / qdisc_run_end:并发控制

Qdisc 的并发控制是一个精巧的机制——需要保证同一时刻只有一个 CPU 在执行一个 qdisc 的出队-发送循环

// include/net/sch_generic.h:194
static inline bool qdisc_run_begin(struct Qdisc *qdisc)
{
    if (qdisc->flags & TCQ_F_NOLOCK) {
        // 无锁 qdisc(如 fq):用 spin_trylock
        if (spin_trylock(&qdisc->seqlock))
            return true;
        // 设置 MISSED 标志,让当前执行者知道有新包
        if (test_and_set_bit(__QDISC_STATE_MISSED, &qdisc->state))
            return false;
        return spin_trylock(&qdisc->seqlock);
    }
    // 有锁 qdisc:设置 RUNNING 标志
    return !__test_and_set_bit(__QDISC_STATE2_RUNNING, &qdisc->state2);
}

MISSED 标志是解决无锁 qdisc 丢包问题的关键:当线程 A 正在运行 qdisc,线程 B 入队一个包并发现 qdisc 正忙,它设置 MISSED 标志。线程 A 在 qdisc_run_end() 时检查 MISSED——如果被设置,说明有新包等待发送,需要再次调度。


六、GSO / TSO:延迟分段的艺术

6.1 为什么需要延迟分段

传统做法是在 TCP 层按 MSS 分段——每个 skb 包含一个 TCP 段(约 1448 字节)。这意味着一个 64KB 的写操作需要分配 45 个 skb,每个 skb 独立经过 IP 层、Netfilter、Qdisc。

GSO(Generic Segmentation Offload)的思路是延迟分段——让 TCP 层构造一个包含 64KB 数据的”大 skb”,直到最后一刻再分段。这带来两个好处:

  1. 减少 per-packet 开销:45 个包变成 1 个包经过协议栈
  2. 如果硬件支持 TSO:分段直接由网卡硬件完成,CPU 零开销

6.2 GSO 标记

tcp_sendmsg 阶段,skb 就被标记为 GSO 包:

// skb_shared_info 中的 GSO 字段
struct skb_shared_info {
    unsigned short  gso_size;   // 每个分段的大小(= MSS)
    unsigned short  gso_segs;   // 分段数量
    unsigned int    gso_type;   // GSO 类型(SKB_GSO_TCPV4 等)
};

例如,一个 64KB 的 TCP GSO 包:gso_size = 1448gso_segs = 45gso_type = SKB_GSO_TCPV4

6.3 validate_xmit_skb:分段决策点

分段的实际决策发生在 validate_xmit_skb() 中——这是 skb 离开 Qdisc、进入驱动之前的最后一道关卡:

// net/core/dev.c (简化)
struct sk_buff *validate_xmit_skb(struct sk_buff *skb, struct net_device *dev,
                                   bool *again)
{
    // 检查硬件特性
    netdev_features_t features = netif_skb_features(skb);

    if (skb_is_gso(skb)) {
        // 硬件支持此类型的 TSO?
        if (netif_needs_gso(skb, features)) {
            // 不支持 → 软件分段
            struct sk_buff *segs = skb_gso_segment(skb, features);
            // 返回分段后的 skb 链表
            ...
        }
        // 支持 → 保持大包,硬件分段
    }

    // Checksum 卸载验证
    if (skb->ip_summed == CHECKSUM_PARTIAL) {
        if (!(features & (NETIF_F_HW_CSUM | ...)))
            skb_checksum_help(skb);  // 软件计算 checksum
    }

    return skb;
}

分段决策逻辑

场景 行为
硬件支持 TSO(NETIF_F_TSO 保持大包,硬件分段
硬件不支持 TSO skb_gso_segment() 软件分段
隧道设备(VXLAN 等) 检查 NETIF_F_GSO_UDP_TUNNEL 等特性
veth pair 通常软件 GSO 分段

6.4 TSO 与网卡硬件

当硬件支持 TSO 时,驱动收到一个 64KB 的 skb,需要:

  1. 把 skb 的数据(可能包含 fragment pages)映射到 DMA 地址
  2. 在 TX 描述符中设置 TSO 参数(MSS、header 长度)
  3. 网卡硬件自动按 MSS 分割数据,为每个分段生成独立的 TCP/IP 头
  4. 每个分段独立发送到网线

TSO 的效果:一个 64KB 的写操作,CPU 只需要构造 1 个 skb、1 套 TCP/IP 头,网卡产生 45 个独立的以太网帧。


七、驱动层:DMA 映射与门铃

7.1 dev_hard_start_xmit

// include/linux/netdevice.h:4065
struct sk_buff *dev_hard_start_xmit(struct sk_buff *skb,
                                     struct net_device *dev,
                                     struct netdev_queue *txq,
                                     int *ret);

dev_hard_start_xmit() 遍历 skb 链表(GSO 软件分段后可能有多个),对每个 skb 调用 netdev_start_xmit()

// include/linux/netdevice.h:5014
static inline netdev_tx_t netdev_start_xmit(struct sk_buff *skb,
                                             struct net_device *dev,
                                             struct netdev_queue *txq,
                                             bool more)
{
    const struct net_device_ops *ops = dev->netdev_ops;
    netdev_tx_t rc;

    rc = __netdev_start_xmit(ops, skb, dev, more);
    if (rc == NETDEV_TX_OK)
        txq_trans_update(txq);

    return rc;
}

__netdev_start_xmit() 最终调用 ops->ndo_start_xmit(skb, dev)——驱动注册的发送回调。

more 参数通过 __this_cpu_write(softnet_data.xmit.more, more) 传递给驱动——告诉驱动”后面还有更多包要发”,驱动可以延迟门铃(doorbell)写入以批量通知网卡,减少 PCIe 事务。

7.2 驱动 ndo_start_xmit

驱动的 ndo_start_xmit 实现(以典型 Intel 驱动为例):

// 驱动 ndo_start_xmit 的典型伪代码
netdev_tx_t driver_start_xmit(struct sk_buff *skb, struct net_device *dev)
{
    struct tx_ring *ring = get_tx_ring(dev, skb);

    // 1. 检查 TX ring buffer 是否有空间
    if (tx_ring_full(ring)) {
        netif_tx_stop_queue(txq);   // 告诉协议栈停止发送
        return NETDEV_TX_BUSY;
    }

    // 2. 处理 TSO 上下文描述符
    if (skb_is_gso(skb))
        setup_tso_context(ring, skb);

    // 3. DMA 映射 skb 数据
    dma_addr = dma_map_single(dev->dev.parent, skb->data,
                              skb_headlen(skb), DMA_TO_DEVICE);

    // 映射 fragment pages
    for (i = 0; i < skb_shinfo(skb)->nr_frags; i++) {
        frag = &skb_shinfo(skb)->frags[i];
        frag_dma = skb_frag_dma_map(...);
        setup_tx_desc(ring, frag_dma, skb_frag_size(frag));
    }

    // 4. 设置 TX 描述符
    setup_tx_desc(ring, dma_addr, skb_headlen(skb));

    // 5. 写门铃(doorbell)—— 通知网卡有新包
    if (!netdev_xmit_more())   // 没有更多包了,立即通知
        writel(ring->next_to_use, ring->tail);

    return NETDEV_TX_OK;
}

7.3 netif_tx_stop_queue / netif_tx_wake_queue

当 TX ring buffer 满时,驱动调用 netif_tx_stop_queue() 停止对应队列——协议栈检测到队列停止后,后续的包会在 Qdisc 中排队等待。

当 TX 完成中断释放了 ring buffer 条目后,驱动调用 netif_tx_wake_queue() 重新开启队列,并触发 Qdisc 出队发送。


八、TX 完成路径:异步释放

8.1 TX 完成中断

网卡在 DMA 传输完成后触发 TX 完成中断。在 NAPI 模型中,TX 完成通常和 RX 共享同一个 NAPI poll 回调——驱动在 poll 函数中同时清理 TX 和处理 RX。

// 驱动 TX 完成清理的典型伪代码
void driver_clean_tx(struct tx_ring *ring)
{
    while (ring->next_to_clean != ring->next_to_use) {
        struct tx_desc *desc = &ring->desc[ring->next_to_clean];

        if (!tx_desc_done(desc))
            break;

        // DMA 解映射
        dma_unmap_single(dev, desc->dma_addr, desc->len, DMA_TO_DEVICE);

        // 释放 skb
        struct sk_buff *skb = ring->skb[ring->next_to_clean];
        if (skb) {
            napi_consume_skb(skb, budget);  // 批量友好的 skb 释放
            ring->skb[ring->next_to_clean] = NULL;
        }

        ring->next_to_clean = next_desc(ring);
    }

    // 如果队列之前被停止,现在有空间了,重新开启
    if (netif_tx_queue_stopped(txq) && tx_ring_has_space(ring))
        netif_tx_wake_queue(txq);
}

8.2 skb 释放与发送缓冲区回收

napi_consume_skb() / consume_skb() 释放 skb 时,如果 skb 关联了 socket(skb->sk 不为 NULL),会调用 skb->destructor 回调——对 TCP 来说就是 tcp_wfree()

// include/net/tcp.h:348
void tcp_wfree(struct sk_buff *skb);

tcp_wfree() 的关键操作:

  1. 减少 sk_wmem_alloc:释放 skb 占用的发送缓冲区额度
  2. 清除 TSQ 限制:如果设置了 TSQ_THROTTLED,清除它并安排 tcp_release_cb() 继续发送
  3. 唤醒等待的进程:通过 sk->sk_write_space() 唤醒在 tcp_sendmsg 中因缓冲区满而阻塞的进程

这形成了一个反馈环路:进程发送 → 填满发送缓冲区 → 阻塞 → TX 完成 → 释放缓冲区 → 唤醒进程 → 继续发送。

8.3 BQL:Byte Queue Limits

BQL(Byte Queue Limits)是在驱动层面防止 bufferbloat 的机制。它动态限制驱动可以接受的排队字节数

如果 BQL 限制达到,netif_xmit_frozen_or_stopped() 返回 true,Qdisc 停止向驱动提交新的包。

BQL 状态可以通过 /sys/class/net/<dev>/queues/tx-<n>/byte_queue_limits/ 查看:

cat /sys/class/net/eth0/queues/tx-0/byte_queue_limits/inflight_bytes
cat /sys/class/net/eth0/queues/tx-0/byte_queue_limits/limit

九、可观测性:用 bpftrace 追踪发包路径

9.1 追踪 tcp_sendmsg 到 dev_queue_xmit 延迟

#!/usr/bin/env bpftrace

kprobe:tcp_sendmsg
{
    @send_ts[tid] = nsecs;
}

kprobe:dev_queue_xmit
{
    $ts = @send_ts[tid];
    if ($ts > 0) {
        @latency_us = hist((nsecs - $ts) / 1000);
        delete(@send_ts[tid]);
    }
}

END { clear(@send_ts); }

9.2 监控 Qdisc 队列深度

# 实时查看各 TX 队列的 qdisc 统计
tc -s qdisc show dev eth0

# 关键指标:
# - backlog: 当前排队的包数和字节数
# - drops: 因队列满而丢弃的包数
# - overlimits: 超过限速而延迟的包数

9.3 追踪驱动停止/唤醒队列

#!/usr/bin/env bpftrace

// 追踪 TX 队列停止事件
kprobe:netif_tx_stop_queue
{
    @stop_count[comm] = count();
    printf("TX queue stopped by %s (PID %d)\n", comm, pid);
}

kprobe:netif_tx_wake_queue
{
    @wake_count[comm] = count();
}

interval:s:5
{
    print(@stop_count);
    print(@wake_count);
    clear(@stop_count);
    clear(@wake_count);
}

9.4 追踪 TSQ 限流

#!/usr/bin/env bpftrace

// TSQ 限流导致 tcp_write_xmit 提前退出
kprobe:tcp_small_queue_check
{
    @tsq_check = count();
}

kretprobe:tcp_small_queue_check /retval == 1/
{
    @tsq_throttled = count();
}

interval:s:5
{
    printf("TSQ checks: %d, throttled: %d\n",
           @tsq_check, @tsq_throttled);
    clear(@tsq_check);
    clear(@tsq_throttled);
}

9.5 TX 完成延迟

#!/usr/bin/env bpftrace

// 追踪从 ndo_start_xmit 到 consume_skb 的 TX 完成延迟
tracepoint:net:net_dev_xmit
{
    @xmit_ts[args->skbaddr] = nsecs;
}

tracepoint:skb:consume_skb
{
    $ts = @xmit_ts[args->skbaddr];
    if ($ts > 0) {
        @tx_complete_us = hist((nsecs - $ts) / 1000);
        delete(@xmit_ts[args->skbaddr]);
    }
}

interval:s:10 { print(@tx_complete_us); }
END { clear(@xmit_ts); }

十、关键性能参数与调优

参数 路径/命令 默认值 作用
tcp_limit_output_bytes sysctl net.ipv4.tcp_limit_output_bytes 1048576 TSQ 限制:per-flow 排队上限
default_qdisc sysctl net.core.default_qdisc pfifo_fast 默认 qdisc,BBR 建议用 fq
dev_tx_weight sysctl net.core.dev_weight 64 __qdisc_run 每次最多发送的包数
TX Ring Buffer 大小 ethtool -G <dev> tx <N> 驱动默认 增大可缓冲突发
XPS /sys/class/net/<dev>/queues/tx-N/xps_cpus 未设置 CPU→TX 队列映射
TCP_CORK setsockopt(TCP_CORK) 延迟发送,积攒数据
TCP_NODELAY setsockopt(TCP_NODELAY) 禁用 Nagle,立即发送
BQL limit /sys/class/net/<dev>/queues/tx-N/byte_queue_limits/limit 自适应 驱动排队字节上限

调优示例:BBR + fq 组合

# 设置默认 qdisc 为 fq(BBR 需要 fq 实现精确 pacing)
sysctl -w net.core.default_qdisc=fq

# 启用 BBR 拥塞控制
sysctl -w net.ipv4.tcp_congestion_control=bbr

# 验证 qdisc
tc qdisc show dev eth0

# 查看 BQL 状态
for q in /sys/class/net/eth0/queues/tx-*/byte_queue_limits; do
    echo "$q: limit=$(cat $q/limit) inflight=$(cat $q/inflight_bytes)"
done

参考文献

  1. Linux 内核源码,net/core/dev.c,6.6 LTS / 6.8
  2. Linux 内核源码,net/ipv4/tcp_output.c,6.6 LTS
  3. Linux 内核源码,net/ipv4/ip_output.c,6.6 LTS
  4. Linux 内核源码,include/linux/netdevice.h,6.8(dev_queue_xmit 定义于第 3175 行,netdev_start_xmit 定义于第 5014 行)
  5. Linux 内核源码,include/net/sch_generic.h,6.8(struct Qdisc 定义于第 73 行,Qdisc_ops 定义于第 287 行)
  6. Linux 内核源码,include/net/tcp.h,6.8(tcp_sendmsg 声明于第 338 行)
  7. Linux 内核文档,Documentation/networking/scaling.rst(XPS)

上一篇收包路径全解:从 NIC 中断到 socket 接收队列

下一篇软中断与 ksoftirqd:网络包处理的调度引擎

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-20 · linux / networking

【Linux 网络子系统深度拆解】TCP 内核实现(下):数据传输与拥塞控制

tcp_sendmsg 把用户数据拷到 sk_buff 就完事了?远没有。后面还有 Nagle 合并、TSQ 限流、cwnd/rwnd 双窗口门控、RACK-TLP 丢包检测、拥塞状态机五态跳转、sk_pacing_rate 软件限速。本文从 Linux 6.6 内核源码拆解 TCP 数据传输的完整路径——从 send() 到 ACK 处理——以及拥塞控制框架 tcp_congestion_ops 的可插拔架构。

2026-04-23 · linux / networking

【Linux 网络子系统深度拆解】Traffic Control 深度拆解:qdisc、class 与 filter

dev_queue_xmit() 不是直接把包交给网卡——中间还有一层 Traffic Control。本文从 Linux 6.6 内核源码拆解 TC 框架的完整实现:struct Qdisc 与 Qdisc_ops 操作表、pfifo_fast/fq_codel/HTB/TBF 的内核实现差异、TCQ_F_CAN_BYPASS 快路径、TCQ_F_NOLOCK 无锁排队、EDT(Earliest Departure Time)调度模型、TC BPF direct-action 模式,以及 MQ 多队列根 qdisc 与 netdev_queue 的关系。

2026-04-20 · linux / networking

【Linux 网络子系统深度拆解】邻居子系统与 ARP:L2 地址解析的内核实现

IP 层知道下一跳是 10.0.0.1,但网卡发帧需要 MAC 地址。ARP 解析只是表面——底层是邻居子系统(neighbour subsystem)的完整状态机:NUD_INCOMPLETE → NUD_REACHABLE → NUD_STALE → NUD_DELAY → NUD_PROBE → NUD_FAILED。本文从 Linux 6.6 内核源码拆解 struct neighbour、neigh_table 双哈希表、ARP 请求/响应处理、NDP(IPv6)、Proxy ARP、GC 回收机制,以及 neigh_connected_output 快路径的 L2 头缓存优化。


By .