Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

4.9 内核发包路径

本章回答

一次 write 之后,数据在什么时候被真正发到网线上?tcp_sendmsg 拷贝到哪,tcp_write_xmit 什么时候被调用?qdisc 是什么,为什么回环没有队列?TSO 让 64 KB 的「超级包」交给网卡,抓包时看到的大包是怎么回事?发送缓冲区满了会阻塞在哪一行代码?

读完你会

画出从 sendmsg 到 ndo_start_xmit 的调用链;用 tc -s qdisc 看排队与丢包;用 ss -tm 判断发送阻塞在缓冲区还是窗口;用 ethtool -k 确认 TSO/GSO 是否开启并解释抓包里的超大包。

收包是「被动」的:网卡来了就处理,内核决定什么时候交给应用。发包是「主动」的:应用调用 write,内核决定什么时候真正发出去。这个「什么时候」是发包路径的核心——它涉及三个队列(socket 写队列、qdisc、TX ring)和两个窗口(拥塞窗口、接收窗口)。

发包路径比收包长,因为多了「排队」这一层。排队是为了让不同流共享带宽、保证公平、支持优先级。代价是延迟和内存。理解 qdisc 的存在,就能理解为什么 tc 能限速、为什么 noqueue 的回环没有排队延迟、为什么高负载下 Send-Q 会堆积。

这一章按「数据流」走:从系统调用进入内核,到数据被放进 skb,到 skb 被交给 qdisc,到 qdisc 把 skb 交给驱动,到驱动把它交给网卡。每一段都有一个「可能卡住」的点,最后会给出对应的观测命令。

4.9.1 发包全景:三个队列与两次排队

应用 write()
   │
   ▼  拷贝用户数据到 skb(一次拷贝)
tcp_sendmsg
   │  挂到 sk->sk_write_queue
   ▼
tcp_write_xmit  ←── 受 cwnd / snd_wnd / Nagle 限制,不一定立刻发
   │
   ▼  加 TCP 首部(skb_push,不拷贝数据)
tcp_transmit_skb → ip_queue_xmit
   │  加 IP 首部、查路由
   ▼
ip_output → ip_finish_output → dev_queue_xmit
   │
   ▼  ┌──────────────────────────────────────┐
      │ qdisc(排队规则)                       │  ← 第二次排队
      │ noqueue / pfifo_fast / fq_codel / tbf │
      └──────────────────────────────────────┘
   │
   ▼
驱动的 ndo_start_xmit(如 igb_xmit_frame)
   │  把 skb 的 DMA 地址写进 TX ring 描述符
   ▼
网卡 DMA 读走 → 发上线路 → 回写完成 → 触发 TX 中断
   │
   ▼
napi_poll 里的 tx 清理 → dev_kfree_skb → 回收 skb 与 socket 记账

读这张图要抓住「两次排队」。第一次在 socket 的 sk_write_queue:数据已经在内核里,但还没满足发送条件(窗口、Nagle)。第二次在 qdisc:数据已经准备好发,但网卡忙或者要限速。这两次排队的观测方法不同:第一次看 ss 的 Send-Q,第二次看 tc -s qdisc。

还有一个容易忽略的点:write 返回成功不代表数据发出去了,只代表数据已经被内核接收。真正的发送可能发生在几微秒后(快路径),也可能等到下一个 ACK(Nagle)。这就是为什么「write 成功但对方没收到」是正常现象——只要连接没断,内核会负责重传。

4.9.2 sendmsg → tcp_sendmsg:拷贝与记账

sendmsg 进入内核后,最终调用 tcp_sendmsg。它做四件事:

/* net/ipv4/tcp.c(简化) */
int tcp_sendmsg_locked(struct sock *sk, struct msghdr *msg, size_t size)
{
    while (size > 0) {
        /* 1. 取发送队列末尾的 skb,看还有没有空间 */
        skb = tcp_write_queue_tail(sk);
        if (!skb || skb->len >= mss_now) {
            /* 2. 没有就新分配一个,大小受 sk_sndbuf 限制 */
            skb = sk_stream_alloc_skb(sk, 0, sk->sk_allocation, ...);
            if (!skb) return -ENOMEM;
            skb_entail(sk, skb);              /* 挂到 sk_write_queue */
        }
        /* 3. 从用户态拷贝数据(copy_from_iter) */
        copied = skb_add_data_nocache(sk, skb, &msg->msg_iter, copy);
        /* 4. 尝试立刻发送 */
        tcp_push(sk, flags, mss_now, tp->nonagle, size_goal);
    }
    return copied;
}

关键在第四步的 tcp_push。它不一定真的发,而是调用 __tcp_push_pending_frames → tcp_write_xmit,由后者判断能不能发。

sk_sndbuf 是发送缓冲区的上限,由 SO_SNDBUF 或 tcp_wmem 决定。队列里的 skb 总大小超过它时,sk_stream_alloc_skb 会失败,tcp_sendmsg 会阻塞(等待内存回收)或返回 EAGAIN(非阻塞)。这就是「发送缓冲区满」的准确位置。

# 看发送缓冲区的三个值(最小/默认/最大)
sysctl net.ipv4.tcp_wmem
# 看一条连接的发送队列与缓冲区
ss -tm state established '( sport = :8080 )'
# 看因为缓冲区满而阻塞的次数
nstat -az | grep -E 'TcpExtTCPMemoryPressures|TcpExtTCPWantZeroWindow'

ss -tm 的输出里有 skmem:(r0,rb131072,t0,tb87040,...),其中 tb 是发送缓冲区上限,t 是当前排队字节数。t 接近 tb 就是发送队列快满了。

SO_SNDBUF 有一个容易踩的坑:内核会把设置值翻倍(sk_sndbuf = 2 * val),并且忽略小于 SOCK_MIN_SNDBUF 的值。所以 setsockopt(SO_SNDBUF, 4096) 实际得到的是 8192 以上。设了 SO_SNDBUF 还会关闭自动调优(tcp_wmem 不再生效),这是很多人「调了反而更慢」的原因。

4.9.3 tcp_write_xmit:什么时候真的发

tcp_write_xmit 是发送的「闸门」。它遍历 sk_write_queue,对每个 skb 判断:

  1. 拥塞窗口:tp->snd_nxt - tp->snd_una < tp->snd_cwnd。
  2. 接收窗口:不超过 tp->snd_wnd。
  3. Nagle:没有未确认的小数据,或者这个 skb 够大。
  4. TSQ(TCP Small Queues):这个 socket 在 qdisc 里排队的字节数不超过 tcp_limit_output_bytes(默认约 1 MB)。

四条都满足才调用 tcp_transmit_skb。任何一条不满足,skb 就留在队列里,等下次 ACK、窗口更新或定时器触发。

TSQ 是一个容易被忽略但很重要的机制。它限制单个 socket 在 qdisc 里堆积的字节数,防止一个大流把队列占满、把延迟推高。代价是发送速率可能受限于「ACK 回来的速度」,所以它对 BBR 这类靠 pacing 的算法有影响。

# 看 TSQ 限制
sysctl net.ipv4.tcp_limit_output_bytes
# 看因为 TSQ 而暂停的次数
nstat -az | grep -i TcpSmallQueue
定义 4.9.1 · TSQ 与 BQL

TSQ(TCP Small Queues) 限制单个 TCP 连接在 qdisc 与驱动队列中的在途字节数,避免一条流占满整个队列,默认上限 tcp_limit_output_bytes。BQL(Byte Queue Limits) 是它的驱动侧对应物,限制整个 TX ring 的排队字节数,由驱动在 netdev_tx_sent_queue / netdev_tx_completed_queue 中维护。两者共同保证「排队时间」不会被单流或单设备无限放大。

4.9.4 tcp_transmit_skb:加 TCP 首部

tcp_transmit_skb 负责「把 skb 变成一个合法的 TCP 段」:

/* net/ipv4/tcp_output.c(简化) */
static int tcp_transmit_skb(struct sock *sk, struct sk_buff *skb, int clone_it,
                            gfp_t gfp_mask)
{
    /* 1. 需要保留原 skb(重传用)时先克隆 */
    if (clone_it) skb = skb_clone(skb, gfp_mask);

    /* 2. skb_push 预留 TCP 首部空间,填字段 */
    th = tcp_hdr(skb);
    th->source = inet->inet_sport;
    th->dest   = inet->inet_dport;
    th->seq    = htonl(tcb->seq);
    th->ack_seq= htonl(tp->rcv_nxt);
    th->doff   = (sizeof(*th) + tp->tcp_header_len) / 4;
    tcp_options_write(th + 1, tp, &opts);     /* SACK、时间戳、MSS */

    /* 3. 交给网络层 */
    return icsk->icsk_af_ops->queue_xmit(sk, skb, &inet->cork.fl);
}

注意 clone_it:重传需要保留原 skb,所以第一次发送时克隆一份,原 skb 留在 sk_write_queue 里直到被 ACK。这也解释了为什么发送队列的内存要等到确认后才释放——不是内核偷懒,是为了重传。

skb_push 只移动指针,不拷贝数据。TCP 首部的 20–60 字节是预留的 headroom,所以这一步几乎没有成本。这就是 4.8.9 讲的「headroom 的价值」在发送方向上的体现。

4.9.5 ip_queue_xmit:加 IP 首部与路由

网络层的工作是「加地址、选路」:

ip_queue_xmit
  → 查路由缓存(sk->sk_dst_cache),没有就 ip_route_output_ports
  → ip_local_out
    → __ip_local_out        # 填 IP 首部:版本、长度、id、TTL、协议、校验和
      → nf_hook(NF_INET_LOCAL_OUT)     # netfilter OUTPUT 钩子
        → dst_output → ip_output
          → ip_finish_output
            → 检查 MTU:超过就分片(或发 ICMP 并丢弃)
              → ip_finish_output2
                → neigh_output   # 查 ARP/邻居表,填目的 MAC
                  → dev_queue_xmit

三个点值得注意。第一,路由是缓存在 socket 上的(sk_dst_cache),所以同一条连接不用每个包都查表;路由变化时内核会失效缓存。第二,netfilter 的 OUTPUT 钩子在这里,iptables/nftables 的 filter 表规则会在这里执行,规则多会直接拖慢发包。第三,MTU 检查在 ip_finish_output,超过 MTU 且 DF=1 时,会回 ICMP 并丢包(这就是 4.1.7 的 PMTUD)。

# 看路由缓存是否生效(同一目的地址多次查询,第二次更快)
ip route get 192.0.2.1
# 看 netfilter 规则数量(规则越多,发包越慢)
sudo nft list ruleset | grep -c 'rule'
sudo iptables-save | wc -l

4.9.6 dev_queue_xmit 与 qdisc:第二次排队

dev_queue_xmit 是「设备层」的入口。它先选 qdisc,再把 skb 入队:

/* net/core/dev.c(简化) */
int dev_queue_xmit(struct sk_buff *skb)
{
    struct net_device *dev = skb->dev;
    struct Qdisc *q = rcu_dereference_bh(txq->qdisc);

    if (q->enqueue) {
        rc = __dev_xmit_skb(skb, q, dev, txq);   /* 入队,可能立刻触发发送 */
    } else {
        /* noqueue:直接交给驱动 */
        rc = dev_hard_start_xmit(skb, dev, txq, ...);
    }
}

qdisc 分两类:有队列的(pfifo_fast、fq_codel、tbf、htb)和无队列的(noqueue)。回环、veth、tun 通常是 noqueue,因为「排队」对它们没有意义——发送即刻完成。

qdisc特点适用
noqueue不排队,直接发回环、veth、虚拟设备
pfifo_fast三个优先级队列,简单老默认值
fq_codel公平排队 + 主动队列管理现代默认值,抗 bufferbloat
tbf令牌桶限速精确限速
htb分层令牌桶按租户/业务分配带宽
fq每流公平排队 + pacing配合 BBR

fq_codel 之所以成为默认,是因为它同时解决两个问题:公平(每条流一个队列,避免大流饿死小流)和缓冲膨胀(CoDel 主动丢弃排队太久的包,给 TCP 发拥塞信号,而不是让延迟无限增长)。

# 看 qdisc 类型与统计(dropped、backlog、requeues)
tc -s qdisc show dev eth0
# 看每个队列的详情
tc -s qdisc show dev eth0 | head -20
# 临时换一个 qdisc(实验用)
sudo tc qdisc replace dev eth0 root fq_codel

tc -s 的输出里,backlog 是当前排队字节数,dropped 是因队列满丢弃的包数,requeues 是驱动忙时退回队列的次数。dropped 增长说明发送速度超过了链路能力,这是拥塞的正常信号,但如果发生在本地虚拟设备上,说明配置有问题。

4.9.7 驱动发送:TX ring 与完成中断

qdisc 决定「发哪个」,驱动决定「怎么发」。ndo_start_xmit(比如 igb_xmit_frame)做三件事:

  1. 检查 TX ring 有没有空位,没有就返回 NETDEV_TX_BUSY(qdisc 会重试)。
  2. 把 skb 的 DMA 地址写进描述符,设置长度和标志(如 EOP、校验和卸载)。
  3. 更新 ring 的尾指针,通知网卡。

发送完成后,网卡回写描述符的 DD(done)位并触发 TX 中断。驱动的 napi_poll 里会顺带清理(igb_clean_tx_irq),调用 dev_kfree_skb 释放 skb,并 sock_wfree 减少 socket 的发送队列记账。

TX 完成是异步的。这意味着 write 返回时,skb 可能还在 ring 里。如果网卡挂了或者 ring 卡住,skb 不会释放,sk_wmem_alloc 不会减少,最终 tcp_sendmsg 会阻塞在「没有缓冲区」上。这就是「网卡假死导致应用写阻塞」的机制。

# 看 TX ring 大小与当前使用
ethtool -g eth0
ethtool -S eth0 | grep -E 'tx_queue_.*_(restart|stopped|full)'
# 看设备层统计:tx_dropped、tx_errors
ip -s link show eth0

ip -s link 的 TX: bytes packets errors dropped carrier 里,dropped 增长说明 qdisc 或驱动在丢包,errors 增长说明发送失败(通常是 ring 满或 DMA 问题)。

4.9.8 TSO/GSO:把分段推迟到网卡

TCP 按 MSS 分段是必需的,但「谁来分」可以商榷。软件分段意味着每个 1500 字节的段都要走一遍协议栈、都要一个 skb、都要一次描述符写入。TSO(TCP Segmentation Offload)把这件事交给网卡:

  • 协议栈把最多 64 KB 的数据放进一个 skb,只加一个 TCP 首部。
  • 网卡按 MSS 自动切成多个段,每个段重新算 IP ID、TCP 校验和。
  • 结果是协议栈处理次数减少 40 多倍。

GSO(Generic Segmentation Offload)是软件版的 TSO:如果网卡不支持,内核在 dev_hard_start_xmit 之前用 skb_gso_segment 切开。它比「每个段都走一遍协议栈」便宜,因为分段发生在最后一刻。

# 看 TSO/GSO 是否开启
ethtool -k eth0 | grep -E 'tcp-segmentation-offload|generic-segmentation-offload'
# 关闭 TSO(抓包时想让包看起来正常)
sudo ethtool -K eth0 tso off gso off

抓包时看到 length 60000 的 TCP 包,就是 TSO 的产物。它不是真的在网线上传了 64 KB(以太网不可能),而是抓包点在分段之前。这解释了两个常见困惑:

  • 为什么抓包看到的 MSS 和实际不符?因为大包还没被切。
  • 为什么 tcpdump 的 length 超过 MTU?同上。
发包相关的四个误解
  1. 「write 返回就发出去了」。只代表数据进了内核缓冲区,真正的发送由 tcp_write_xmit 决定。
  2. 「Send-Q 是网卡队列」。Send-Q 是 socket 发送队列里未确认的字节,不是 qdisc 或 TX ring。
  3. 「关掉 TSO 性能更好」。关掉会让软件分段,CPU 开销显著上升。只有在抓包或兼容性问题时才关。
  4. 「qdisc 只做限速」。它还做公平排队、优先级、AQM(主动队列管理),fq_codel 的主要作用其实是抗 bufferbloat。

4.9.9 发送缓冲区与阻塞点

一条连接的发送路径上有四个「容量」限制,任何一个满了都会阻塞或丢包:

限制参数满了会怎样观测
socket 发送缓冲区SO_SNDBUF / tcp_wmemwrite 阻塞或 EAGAINss -tm 的 t/tb
拥塞窗口cwnd数据留在 sk_write_queuess -ti 的 cwnd
接收窗口snd_wnd同上ss -ti 的 snd_wnd
qdisc/驱动队列txqueuelen、BQLqdisc 丢包或 NETDEV_TX_BUSYtc -s qdisc、ip -s link

阻塞点的顺序是:先受窗口限制(数据发不出去但缓冲区还有空),再受缓冲区限制(缓冲区满,write 阻塞)。调优时要分清是哪一个:ss -tm 的 t 接近 tb 是缓冲区满,ss -ti 的 cwnd 很小是窗口限制。

# 一条命令同时看队列与窗口
ss -tmi state established '( sport = :8080 )' | head -6
# 看 socket 内存压力
nstat -az | grep -E 'TcpExtTCPMemoryPressures|TcpExtTCPBacklogDrop'

TcpExtTCPMemoryPressures 增长说明系统级内存压力导致 TCP 收缩窗口,这已经不是单个连接的问题,而是整机内存不足。

4.9.10 观测实验:追踪一次发送

把调用链用 bpftrace 数一遍:

sudo bpftrace -e '
  kprobe:tcp_sendmsg       { @sendmsg = count(); }
  kprobe:tcp_write_xmit    { @write_xmit = count(); }
  kprobe:tcp_transmit_skb  { @transmit = count(); }
  kprobe:dev_queue_xmit    { @dev_queue = count(); }
  interval:s:5 { exit(); }'

比例关系能说明问题:

  • tcp_sendmsg 次数 ≈ 应用调用 write 的次数。
  • tcp_transmit_skb 次数 ≈ 实际发出的段数。如果远小于 write 次数,说明 Nagle 或窗口在合并。
  • dev_queue_xmit 次数应约等于 tcp_transmit_skb(加上其他协议)。
# 看发送路径的耗时分布(从 sendmsg 到交给 qdisc)
sudo bpftrace -e '
  kprobe:tcp_sendmsg { @start[tid] = nsecs; }
  kretprobe:tcp_sendmsg /@start[tid]/ {
    @us = hist((nsecs - @start[tid]) / 1000); delete(@start[tid]); }'

如果 tcp_sendmsg 的 P99 很高,通常是缓冲区满导致的阻塞,而不是 CPU 慢。这时要看 ss -tm 和 nstat 的内存压力计数。

4.9.11 零拷贝发送:sendfile、splice 与 MSG_ZEROCOPY

普通 write 有一次用户态到内核态的拷贝:数据从应用缓冲区复制到 skb。对一个静态文件服务器来说,这个拷贝毫无必要——文件已经在页缓存里,直接让网卡 DMA 读它就行。三种零拷贝方案解决这个问题,代价各不相同。

sendfile 是最常用的:sendfile(out_fd, in_fd, NULL, count) 把文件内容直接送进 socket,内核只做「页缓存 → skb 的 frags」的引用,不复制数据。

/* 用 sendfile 发送文件,没有用户态缓冲区 */
off_t off = 0;
ssize_t n = sendfile(sockfd, filefd, &off, st.st_size);

它的限制是 in_fd 必须是支持 mmap 的文件(普通文件可以,socket 不行),所以不能用来在两个 socket 之间转发。

splice 更通用:它通过一个管道在两个 fd 之间移动数据,也不需要拷贝。

/* 把源 socket 的数据经管道搬到目标 socket */
int p[2];
pipe(p);
splice(src, NULL, p[1], NULL, 4096, SPLICE_F_MOVE);
splice(p[0], NULL, dst, NULL, 4096, SPLICE_F_MOVE);

splice 的代价是多一个管道 fd 和两次系统调用。Nginx 在「反向代理静态文件」的场景里就用它。

MSG_ZEROCOPY(Linux 4.14+)走另一条路:应用把用户态缓冲区「借」给内核,内核直接用它构造 skb,不拷贝。代价是页被 pin 住(不能换出、不能复用),而且完成通知是异步的。

int one = 1;
setsockopt(fd, SOL_SOCKET, SO_ZEROCOPY, &one, sizeof one);
send(fd, buf, len, MSG_ZEROCOPY);
/* 完成后通过 socket 错误队列收到 SO_EE_ORIGIN_ZEROCOPY 通知 */
方案拷贝次数适用代价
write1通用一次内存拷贝
sendfile0文件 → socket只能用于文件
splice0fd → fd多一个管道
MSG_ZEROCOPY0任意用户缓冲区页 pin、异步通知

这张表的关键是最后一列。零拷贝不是「免费加速」,它把成本从「拷贝」换成「页管理」或「编程复杂度」。数据量小的时候,零拷贝反而更慢(系统调用和通知的开销超过拷贝)。经验阈值是几十 KB 以上才值得考虑。

零拷贝的三个误解
  1. 「零拷贝完全不拷贝」。它去掉的是「用户态 ↔ 内核态」的拷贝,设备 DMA 仍然要读内存。
  2. 「sendfile 一定比 read+write 快」。小文件、页缓存未命中时优势不明显,甚至更慢。
  3. 「MSG_ZEROCOPY 适合所有场景」。页 pin 会增加内存压力,且完成通知需要应用处理,否则会丢通知、泄漏缓冲区。

4.9.12 多队列发送与流控

收包有 RSS/RPS,发包也有对应的机制。多队列网卡的每个 TX 队列有自己的 ring 和完成中断,需要决定「哪个 CPU 的包发到哪个队列」。

XPS(Transmit Packet Steering) 就是干这个的:它给每个 TX 队列指定一组 CPU,让同一个 CPU 上发出的包总是走同一个队列,提高缓存局部性。

# 看队列数与 XPS 配置
ethtool -l eth0
ls /sys/class/net/eth0/queues/
cat /sys/class/net/eth0/queues/tx-0/xps_cpus
# 把 tx-0 绑定到 CPU0(位掩码 1)
echo 1 | sudo tee /sys/class/net/eth0/queues/tx-0/xps_cpus

txqueuelen 是每个队列的长度(默认 1000)。它决定了「网卡忙时能排多少包」:

ip link show eth0 | grep qlen
sudo ip link set eth0 txqueuelen 4000

队列太长会增加延迟(bufferbloat),太短会丢包。fq_codel 之类的 AQM 用「排队时间」而不是「队列长度」来判断,所以它能在长队列下仍然保持低延迟。

流控(pause frames) 是链路层的反压机制:接收方缓冲区快满时,向对端发 PAUSE 帧,让对方暂停发送。它是「不丢包」的最后一招,但用不好会造成「拥塞扩散」——一个端口的问题会传染给整个网络。

# 看流控状态
ethtool -a eth0
# 关闭(数据中心里通常关闭,靠上层拥塞控制)
sudo ethtool -A eth0 autoneg off rx off tx off

ethtool -S 里的 tx_pause_frames、rx_pause_frames 能看到流控的实际发生次数。如果这个数字很高,说明链路已经在靠反压维持,网络处于过载状态。

4.9.13 发送方向的过滤与 eBPF

发包路径上有三个可以「动手脚」的位置,代价和灵活性递增:

位置机制能做什么开销
ip_output 之前netfilter OUTPUT过滤、NAT、mark规则越多越慢
dev_queue_xmit 之前tc egress / eBPF clsact分类、限速、重写一次 eBPF 调用
驱动收包点XDP(XDP_TX)直接回包、丢弃最早,也最快

netfilter 的 OUTPUT 钩子每个包都要走一遍规则链。规则数量到几千条时,发包延迟会明显上升。优化手段是「提前分流」:用 nft 的 meta mark 或 ipset 把常见流量先匹配掉,减少线性遍历。

# 看 OUTPUT 链的规则数量
sudo nft list chain ip filter output | grep -c 'rule'
# 用计数器确认规则命中情况
sudo nft list ruleset -a | grep -A2 'counter'

tc 的 egress 钩子(clsact)可以挂 eBPF 程序,用于限速、重写首部、丢弃特定流量。它的开销比 netfilter 小(一次程序调用 vs 一条规则链),但编程复杂:

# 挂一个 clsact qdisc,之后可以用 tc filter 挂 eBPF
sudo tc qdisc add dev eth0 clsact
sudo tc filter show dev eth0 egress

XDP 是「最早」的钩子——它在驱动收到包、分配 skb 之前就执行,所以能做 XDP_DROP(丢弃)、XDP_TX(原路返回)、XDP_REDIRECT(转发到另一个网卡)。它的性能优势来自「还没分配 skb」,代价是只能看到原始帧,没有协议栈的解析结果。

对发包路径来说,XDP 的意义在于「回包」:一个 SYN flood 防御程序可以在 XDP 层直接回 RST,完全不进协议栈。这就是 Cloudflare 那类 DDoS 清洗的常见做法。

三个位置的选型建议

能用 netfilter 解决的就别上 eBPF——可读性和可维护性更重要。只有在「每包开销必须极低」或「需要访问原始帧」时才用 XDP/tc-eBPF。判断标准是 PPS:低于 10 万 PPS,netfilter 足够;高于 100 万 PPS,才值得考虑 XDP。

4.9.14 分段、分片与 GSO 的交互

TCP 的「分段」(segmentation)和 IP 的「分片」(fragmentation)是两件事,但在 GSO/TSO 场景下容易混。先把三个长度摆清楚:

MSS         = MTU - IP 首部 - TCP 首部          (1500 - 20 - 20 = 1460)
GSO size    = 一个 skb 最多装多少(通常 64 KB 减首部)
实际段长     = min(MSS, 窗口剩余, 应用可发)

开启 TSO 后,tcp_write_xmit 不再按 MSS 切分,而是把最多 64 KB 放进一个 skb,并在 skb_shinfo(skb)->gso_size 里记下「每个段应该是多大」。这个 skb 一路走到驱动,网卡按 gso_size 切分并重算校验和。

如果设备不支持 GSO,内核会在最后一刻软件切分:

dev_hard_start_xmit
  → validate_xmit_skb
    → skb_gso_segment        # 按 gso_size 切成多个 skb
      → tcp_gso_segment      # 重算 TCP 校验和、设置 IP ID

这就是 GSO 和 TSO 的区别:TSO 是硬件切,GSO 是软件切但「推迟到最后一刻」。两者对协议栈的收益一样(少走几十遍协议栈),只是切分的位置不同。

场景谁切分抓包看到
网卡支持 TSO网卡硬件大包(抓包点在切分前)
网卡不支持,走 GSO内核 skb_gso_segment小包(切分后)
关闭 GSO/TSOTCP 发送时按 MSS小包
经过 veth/bridge通常保留 GSO,对端再切大包

最后一行值得注意:veth 和 bridge 支持 GSO 透传,所以容器之间传大包时,skb 可能是 64 KB 的「超级包」,直到某一段真正出物理网卡才切。这让容器网络的协议栈开销接近宿主机,但也意味着在 veth 上抓包会看到不符合 MTU 的大包。

# 看 GSO 相关的统计
nstat -az | grep -iE 'gso|gro'
# 用 bpftrace 看软件切分的次数
sudo bpftrace -e 'kprobe:skb_gso_segment { @[comm] = count(); }'

如果 skb_gso_segment 的调用次数很高,说明有大量包在做软件切分——通常是某个虚拟设备不支持 GSO,或者 ethtool -K 把卸载关了。这是「CPU 花在协议栈上」的一个隐蔽来源。

例 4.9.1 · 一个 64 KB 的 skb 是怎么变成 44 个帧的

应用一次写入 64 KB,TCP 把它放进一个 skb,gso_size = 1460。

  1. tcp_transmit_skb 只加一个 TCP 首部,skb->len ≈ 65536。
  2. 驱动把「一个描述符 + 64 KB 缓冲区」交给网卡(需要网卡支持分散聚合)。
  3. 网卡按 1460 切分,生成 44 个段,每段重算 IP ID 和 TCP 校验和。
  4. 线路上出现 44 个 1514 字节的帧。

如果中间经过一个不支持 TSO 的 veth,第 2 步会变成 skb_gso_segment,在软件里生成 44 个 skb,再逐个交给 veth。CPU 开销就从「1 次」变成了「44 次」,这就是卸载开关对性能影响巨大的原因。

4.9.15 发送路径上的四个结构体:dst、neighbour、qdisc、netdev_queue

4.9.5 到 4.9.7 按时间顺序走了发送路径,这一节把路径上「被查、被排队、被锁」的四类对象拆开。它们的生命周期和竞争点,决定了发送路径能优化到哪里。

结构体头文件代表什么生命周期关键字段
struct dst_entryinclude/net/dst.h一条路由结果路由缓存命中时长期存在dev、ops、flags、obsolete
struct rtableinclude/net/route.hIPv4 路由表项(dst_entry 的子类)同上rt_gateway、rt_flags、rt_type
struct neighbourinclude/net/neighbour.h下一跳的 L2 地址(ARP/NDP)解析成功后缓存ha、nud_state、ops、arp_queue
struct Qdiscinclude/net/sch_generic.h发送队列的排队规则设备存在期间enqueue、dequeue、q.qlen、gso_skb
struct netdev_queueinclude/linux/netdevice.h每个 TX 队列的锁与状态与设备队列数一致qdisc、_xmit_lock、trans_start

dst_entry 是路由缓存。ip_queue_xmit() 在 sk->sk_dst_cache 里取路由,命中就不用再查 FIB;ip_route_output_flow() 只在缓存缺失时走慢路径。dst->obsolete 非 0 表示路由已失效(例如接口 down、策略路由变更),下一个包会重新查表。看到「改完路由不生效」时,先确认旧 dst 是否还被 socket 缓存着——长连接可能一直用旧路由,直到 dst_ops->check() 发现失效。

neighbour 是 ARP/NDP 缓存。它有一个状态机:NUD_INCOMPLETE(正在解析)、NUD_REACHABLE(确认可达)、NUD_STALE(过期但可用)、NUD_DELAY、NUD_PROBE、NUD_FAILED。发送时如果邻居状态是 STALE,内核先发数据同时触发确认(arp_solicit),不会阻塞;只有 INCOMPLETE 才把 skb 挂进 arp_queue 等待。「第一个包丢、后面正常」 often 是邻居解析的排队效应,不是链路丢包。

Qdisc 是第二次排队。dev_queue_xmit() 先尝试 qdisc->enqueue(),成功则返回;如果队列已满(qlen >= limit),按 qdisc 类型丢弃或返回 NET_XMIT_DROP。默认的 fq_codel 用「流哈希 + CoDel」控制排队延迟;pfifo_fast 是老的三个优先级队列;noqueue 用于回环和虚拟设备。

netdev_queue 是每队列的发送锁。多队列网卡有多个 netdev_queue,每个对应一个硬件 TX ring。__dev_xmit_skb() 先尝试无锁入队(qdisc_run_begin),失败则拿 _xmit_lock,再失败就交给 qdisc_run 的软中断。发送方向的 CPU 竞争主要发生在这把锁和 qdisc 锁上,不是网卡本身。

四个对象的查找顺序可以画成:

tcp_transmit_skb
  → ip_queue_xmit
      → sk_dst_cache 命中?──否──→ ip_route_output_flow(FIB)
      → dst_neigh_lookup(ARP/NDP 缓存)
          → NUD_INCOMPLETE?──是──→ 挂 arp_queue,等解析
  → ip_finish_output → dev_queue_xmit
      → qdisc->enqueue
          → 队列满?──是──→ 按 qdisc 策略丢包(qdisc drops)
      → __qdisc_run → sch_direct_xmit → 驱动 ndo_start_xmit

这张图解释了三个常见计数:

计数位置含义
tx_droppeddev_queue_xmit / qdisc队列满或设备 down
qdisc dropstc -s qdisc当前 qdisc 的丢弃(fq_codel 的 drop_overlimit)
tx_errors驱动网卡发送失败(ring 满、DMA 错误)

三者含义不同:tx_dropped 是协议栈丢的,qdisc drops 是排队策略丢的,tx_errors 是硬件/驱动丢的。排「发送丢包」时必须三个都看,只看 ifconfig 的 dropped 会漏掉 qdisc。

`noqueue` 与 `mq` 的区别

回环、veth、tun 这类设备常用 noqueue:没有队列,enqueue 直接返回成功,dequeue 立刻交给驱动。物理多队列网卡默认是 mq:每个硬件队列一个 qdisc,天然并行。在 noqueue 设备上加 netem 会失败,必须先换成 pfifo_fast 或 fq_codel;这也是 4.6.15 建议用 veth 做实验的原因之一。

4.9.16 XDP 与 TC eBPF:钩子位置、返回码与程序类型

eBPF 在发送/接收路径上有三个常用挂载点,位置不同,能看到的数据和能做的事也不同:

挂载点位置程序类型看到的数据典型用途
XDP驱动收包后、skb 分配前BPF_PROG_TYPE_XDPxdp_md(DMA 缓冲区)DDoS 清洗、负载均衡、AF_XDP
TC ingressskb 已分配、进入协议栈前BPF_PROG_TYPE_SCHED_CLS__sk_buff容器策略、流量镜像、NAT
TC egressdev_queue_xmit 之前同上__sk_buff出站限速、标记、重定向

XDP 的最大特点是早:它在 napi_poll 里、skb 还没分配时执行,所以吞吐最高,但也最受限——不能使用 skb 相关 helper,不能分片,不能访问 socket。返回码只有五个:

返回码值行为
XDP_ABORTED0丢包并记录 tracepoint(程序出错)
XDP_DROP1丢包,最常用
XDP_PASS2交给协议栈
XDP_TX3从同一网卡发回
XDP_REDIRECT4重定向到其他网卡/CPU/socket

XDP_REDIRECT 需要 map 配合:BPF_MAP_TYPE_DEVMAP(按 ifindex 转发)、CPUMAP(把包交给指定 CPU 的协议栈)、XSKMAP(送到 AF_XDP socket)。重定向的目标在 bpf_redirect_map(map, key, flags) 里指定,失败时返回 XDP_PASS 或 XDP_DROP 由 flags 决定。

一个最小的 XDP 程序(丢弃目的端口 23 的 TCP 包):

/* xdp_drop_telnet.c:XDP 层丢弃目的端口 23 的 TCP 包 */
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/tcp.h>
#include <bpf/bpf_helpers.h>

SEC("xdp")
int xdp_drop_telnet(struct xdp_md *ctx)
{
    void *data = (void *)(long)ctx->data;
    void *data_end = (void *)(long)ctx->data_end;
    struct ethhdr *eth = data;

    if ((void *)(eth + 1) > data_end)
        return XDP_PASS;
    if (eth->h_proto != __constant_htons(ETH_P_IP))
        return XDP_PASS;
    struct iphdr *iph = (void *)(eth + 1);
    if ((void *)(iph + 1) > data_end || iph->protocol != IPPROTO_TCP)
        return XDP_PASS;
    struct tcphdr *th = (void *)iph + iph->ihl * 4;
    if ((void *)(th + 1) > data_end)
        return XDP_PASS;
    if (th->dest == __constant_htons(23))
        return XDP_DROP;
    return XDP_PASS;
}

char _license[] SEC("license") = "GPL";

这段代码的每个边界检查都是必须的:eBPF 校验器要求所有指针访问都能证明在 data_end 之内,少一个 if 就加载失败。编译用 clang 的 -target bpf,加载用 ip link set dev eth0 xdp obj xdp_drop_telnet.o sec xdp(native 模式)或 xdpgeneric(generic 模式)。

TC 程序看到的是 struct __sk_buff,字段是内核 sk_buff 的稳定子集:

字段含义可写
len / data / data_end包长与数据边界len 可写(配合 bpf_skb_change_tail)
markfwmark是
priorityskb priority / VLAN PCP是
protocol三层协议是(改包时)
vlan_present / vlan_tciVLAN 信息是
ifindex / ingress_ifindex出入接口只读
cb[5]控制块是(程序间传递)
tc_classid分类 ID是
hash流哈希只读

TC 的返回码与 XDP 不同:TC_ACT_OK(0) 放行、TC_ACT_SHOT(2) 丢弃、TC_ACT_REDIRECT(7) 重定向、TC_ACT_STOLEN(4) 表示程序「拿走」了包、不再继续。返回 TC_ACT_SHOT 与 XDP_DROP 的计数不在同一个地方:前者进 qdisc/TC 统计,后者进网卡的 xdp_drop 或 tracepoint。

挂载方式:

# XDP:native 模式(驱动支持时),失败退回 generic
sudo ip link set dev eth0 xdp obj xdp_drop_telnet.o sec xdp
sudo ip link set dev eth0 xdpgeneric obj xdp_drop_telnet.o sec xdp
# TC:clsact qdisc + ingress/egress 过滤器,da 表示 direct-action
sudo tc qdisc add dev eth0 clsact
sudo tc filter add dev eth0 ingress bpf da obj tc_prog.o sec classifier
sudo tc filter add dev eth0 egress  bpf da obj tc_prog.o sec classifier
# 看统计
sudo tc -s filter show dev eth0 ingress
sudo bpftool net show

内核路径:

阶段函数文件
XDP 执行bpf_prog_run_xdpnet/core/dev.c
generic XDPnetif_receive_generic_xdp同上
XDP 重定向xdp_do_redirect → dev_map_enqueuenet/core/filter.c、kernel/bpf/devmap.c
TC ingresssch_handle_ingress → tcf_classifynet/core/dev.c、net/sched/cls_api.c
TC egresssch_handle_egressnet/core/dev.c
cls_bpfcls_bpf_classify → bpf_prog_runnet/sched/cls_bpf.c
XDP/TC 的五个边界
  1. XDP 不能用 skb helper:bpf_skb_load_bytes、bpf_skb_store_bytes 在 XDP 里不可用,只能直接读写 data。
  2. generic XDP 不是「零成本」:它要把包先构造成 skb 再跑程序,吞吐远低于 native,适合验证逻辑而非生产。
  3. XDP 不能分片:程序改大包后超过 MTU 会被丢,必须自己处理或返回 XDP_PASS 交给协议栈。
  4. TC egress 在 qdisc 之前还是之后取决于挂载点:clsact 的 egress 在 dev_queue_xmit 内、qdisc 入队前;想限制已经排队的流量要用 qdisc,不是 TC。
  5. TC_ACT_STOLEN 不是丢弃:包被程序接管(例如重定向到 AF_XDP),不再走协议栈,但也不计入 drop。

4.9.17 程序一:用 bpftrace 统计发送路径的字节与 GSO 大小

这段脚本不写 C,直接用 bpftrace 挂内核函数,回答三个问题:谁在发、每次发多大、GSO 把包合并到了多少。

#!/usr/bin/env bash
# txstat.bt:按进程统计 dev_queue_xmit 的字节数与 GSO 段大小
sudo bpftrace -e '
kprobe:dev_queue_xmit
{
    $skb = (struct sk_buff *)arg0;
    @bytes[comm] = sum($skb->len);
    @pkts[comm] = count();
    @gso[comm] = hist($skb->len);
}
kprobe:tcp_transmit_skb
{
    @tcp_bytes[comm] = sum(((struct sk_buff *)arg1)->len);
}
interval:s:5
{
    printf("=== 5s ===\n");
    print(@bytes); print(@pkts); print(@gso); print(@tcp_bytes);
    clear(@bytes); clear(@pkts); clear(@gso); clear(@tcp_bytes);
    exit();
}'

三个 map 各有用途:

  • @bytes 是「交给 qdisc 的总字节」,包含重传和首部;与 ss -ti 的 bytes_acked 对比,差值就是重传与协议开销。
  • @gso 的直方图能看出 GSO 是否生效:大量 64 KB 的桶说明 TSO/GSO 在工作;全是 1500 说明被关闭或网卡不支持。
  • @tcp_bytes 按 tcp_transmit_skb 统计,比 dev_queue_xmit 少了 IP/qdisc 层,两者差值反映封装与丢弃。

典型输出:

@bytes[nginx]: 48234496
@pkts[nginx]: 812
@gso[nginx]:
[64K, 128K)          790 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[16K, 32K)            22 |@                                                   |
@tcp_bytes[nginx]: 47185920

dev_queue_xmit 的 812 个包发出 48 MB,平均 59 KB/包——这是 TSO 合并后的结果,不是逐包 1500 字节。看到这个分布,就不该再问「为什么包这么大」:GSO/TSO 的包在抓包点上可能是 64 KB,到了网卡才被切成 MTU 大小的帧。

4.9.18 程序二:sendfile 与 read/write 的拷贝账

零拷贝不是一个开关,而是一组系统调用。这段程序对同一个文件做两次发送:一次 read + write,一次 sendfile,比较耗时与系统调用次数。它验证 4.9.11 的说法:少一次用户/内核拷贝,收益在大文件上才明显。

/* copybench.c:比较 read/write 与 sendfile 的吞吐 */
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef __linux__
#include <sys/sendfile.h>
#endif
#include <sys/socket.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>

static double now_s(void)
{
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec + ts.tv_nsec / 1e9;
}

/* 用 socketpair 模拟对端,读线程在 fork 出的子进程里 */
static long run_rw(int fd, int sock, off_t size)
{
    char buf[65536];
    long calls = 0;
    off_t left = size;

    while (left > 0) {
        ssize_t n = read(fd, buf, sizeof buf);
        if (n <= 0)
            break;
        ssize_t off = 0;
        while (off < n) {
            ssize_t w = write(sock, buf + off, (size_t)(n - off));
            if (w <= 0)
                return -1;
            off += w;
            calls++;
        }
        left -= n;
    }
    return calls;
}

#ifdef __linux__
static long run_sendfile(int fd, int sock, off_t size)
{
    off_t off = 0;
    long calls = 0;

    while (off < size) {
        ssize_t n = sendfile(sock, fd, &off, (size_t)(size - off));
        if (n <= 0) {
            if (errno == EINTR)
                continue;
            return -1;
        }
        calls++;
    }
    return calls;
}
#else
static long run_sendfile(int fd, int sock, off_t size)
{
    (void)fd; (void)sock; (void)size;
    return -1;
}
#endif

int main(int argc, char **argv)
{
    if (argc != 2) {
        fprintf(stderr, "用法: %s <file>\n", argv[0]);
        return 2;
    }
    struct stat st;
    if (stat(argv[1], &st) < 0) {
        perror("stat");
        return 1;
    }
    for (int mode = 0; mode < 2; mode++) {
        int fd = open(argv[1], O_RDONLY);
        int sv[2];
        if (fd < 0 || socketpair(AF_UNIX, SOCK_STREAM, 0, sv) < 0) {
            perror("open/socketpair");
            return 1;
        }
        pid_t pid = fork();
        if (pid == 0) {                     /* 子进程:只读,模拟接收端 */
            close(sv[0]);
            char buf[65536];
            while (read(sv[1], buf, sizeof buf) > 0)
                ;
            _exit(0);
        }
        close(sv[1]);
        double t0 = now_s();
#ifndef __linux__
        if (mode == 1) {
            printf("sendfile 基准仅 Linux 分支启用;本机跳过\n");
            close(sv[0]); close(fd); wait(NULL);
            continue;
        }
#endif
        long calls = mode == 0 ? run_rw(fd, sv[0], st.st_size)
                               : run_sendfile(fd, sv[0], st.st_size);
        double dt = now_s() - t0;
        close(sv[0]);
        close(fd);
        wait(NULL);
        if (calls < 0) {
            perror("send");
            return 1;
        }
        printf("%s: %.1f MiB, %.3f s, %.1f MiB/s, %ld 次发送调用\n",
               mode == 0 ? "read/write" : "sendfile",
               st.st_size / 1048576.0, dt,
               st.st_size / 1048576.0 / (dt > 0 ? dt : 1), calls);
    }
    return 0;
}

编译运行:

cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 copybench.c -o copybench
dd if=/dev/urandom of=/tmp/big.bin bs=1M count=256
./copybench /tmp/big.bin

典型输出(Linux,tmpfs 或页缓存命中):

read/write: 256.0 MiB, 0.412 s, 621.4 MiB/s, 4096 次发送调用
sendfile: 256.0 MiB, 0.238 s, 1075.6 MiB/s, 256 次发送调用

两个差别:

  • 系统调用次数:read/write 每次 64 KB 要两次调用(read + write),sendfile 一次调用可以传更多(受 MAX_RW_COUNT 限制,通常 2 GB)。
  • 拷贝次数:read/write 是「磁盘/页缓存 → 用户缓冲 → socket 缓冲」,两次拷贝;sendfile 直接把页缓存描述符挂到 socket,省掉用户缓冲那一份。

但这不是「零拷贝」的终点:sendfile 仍有 socket 层的首部与 skb 开销;真正的零拷贝要么是 MSG_ZEROCOPY(发送完成通过错误队列通知),要么是网卡的 DMA 直接读页(需要驱动支持)。在 1 KB 小文件上,sendfile 可能比 read/write 更慢,因为系统调用与页管理的固定开销占比更高。

sendfile 的边界:

限制说明
输入必须是普通文件管道、socket 不能作为 in_fd
输出必须是 socket(Linux)普通文件之间用 copy_file_range
不能改数据需要加密/压缩时用 splice + 用户态处理
大文件受 MAX_RW_COUNT 限制单次最多约 2 GB,循环调用即可

4.9.19 实验:qdisc、TSO/GSO 与发送耗时

实验一看「包从协议栈到网卡之间排了多久」,实验二看「GSO 关掉后发送路径的变化」。

实验一:qdisc 与队列。

# 当前 qdisc、队列长度、丢弃、requeues
tc -s qdisc show dev eth0
# 每个 TX 队列的状态(需要多队列网卡)
tc -s class show dev eth0
# 发送方向的错误与 ring 使用
ethtool -S eth0 | grep -E 'tx_queue_.*(stop|restart|drops)|tx_errors|tx_dropped'
# 协议栈的发送统计
nstat -az | grep -E 'TcpOutSegs|TcpRetransSegs|TcpExtTCPTSQ|TcpExtTCPBacklog'

tc -s qdisc 的输出要这样读:

qdisc fq_codel 0: root refcnt 2 limit 10240p flows 1024 quantum 1514 target 5ms interval 100ms
 Sent 18432000 bytes 12000 pkt (dropped 3, overlimits 0 requeues 12)
 backlog 0b 0p requeues 12
  • dropped 3:qdisc 因队列满丢的包;overlimits 是限速触发次数。
  • requeues 12:驱动忙、包被退回 qdisc 的次数;持续增长说明 TX ring 太小或驱动停止队列。
  • backlog:当前排队的字节与包数;长期非 0 说明出口带宽小于入口。

实验二:关掉 GSO/TSO,看发送路径的变化。

# 看当前卸载状态
ethtool -k eth0 | grep -E 'tcp-segmentation-offload|generic-segmentation-offload|generic-receive-offload'
# 临时关闭 TSO/GSO(仅实验;生产关闭会显著增加 CPU)
sudo ethtool -K eth0 tso off gso off
# 重跑发送统计,观察 @gso 直方图与包数
sudo bpftrace -e 'kprobe:dev_queue_xmit { @len = hist(((struct sk_buff *)arg0)->len); } interval:s:5 { print(@len); exit(); }'
# 恢复
sudo ethtool -K eth0 tso on gso on

关闭后 @len 的直方图会集中到 MTU 附近(1500 或 9000),包数增加一个数量级,dev_queue_xmit 的调用次数同步上升。这正是 TSO 的价值:把「每 1500 字节一次协议栈 + qdisc + 驱动调用」变成「每 64 KB 一次」,CPU 节省来自减少函数调用与锁竞争,不是减少字节。

如果想看单次发送在协议栈里花了多久:

sudo bpftrace -e '
kprobe:tcp_transmit_skb { @start[tid] = nsecs; }
kretprobe:tcp_transmit_skb /@start[tid]/ { @ns = hist(nsecs - @start[tid]); delete(@start[tid]); }
interval:s:5 { print(@ns); clear(@ns); exit(); }'

回环上通常在几百纳秒到几微秒;加上 netfilter 规则、cgroup BPF、qdisc 后会明显变长。这个直方图是判断「发送慢在内核还是网卡」的第一手证据。

4.9.20 eBPF 的加载链:verifier、map 与 JIT

4.9.16 讲了 XDP/TC 挂在哪、返回码是什么;这一节回答「一个 C 程序怎么变成内核里可执行的 eBPF」。这条链上的每一步都可能失败,而且失败信息通常只给一个 EACCES,不会告诉你哪条指令越界。

阶段用户态动作内核入口失败表现
编译clang -target bpf -O2 -g -c—ELF 重定位错误、缺少 BTF
打开bpf_object__open_file()—找不到 program/map section
创建 mapBPF_MAP_CREATEmap_create()EINVAL(类型/参数)、ENOMEM
加载程序BPF_PROG_LOADbpf_check()EACCES/EINVAL,verifier 日志在 log_buf
校验—check_cfg()、do_check()指针越界、栈未初始化、循环不收敛
JIT—bpf_prog_select_runtime()架构不支持时退回解释器
挂载bpf_xdp_attach()/netlinkdev_change_xdp_fd() → ndo_bpfEBUSY、EOPNOTSUPP

verifier 到底检查什么。它把程序当成状态机,对每条指令做抽象解释,跟踪每个寄存器的类型、取值范围和「是否可空」:

检查函数典型拒绝原因
控制流无环/有界check_cfg()循环没有可证明的上界
指针范围check_mem_access()data + 14 没先和 data_end 比较
栈初始化check_stack_read()读了未写的栈槽
helper 参数check_helper_call()把 ARG_PTR_TO_MEM 传成整数
map 与程序类型匹配check_map_func_compatibility()XDP 程序里用 bpf_skb_load_bytes
指令数/栈大小do_check()超过 100 万条指令或 512 字节栈

bpf_check() 的顺序可以压成:

bpf_check
  → check_cfg                 # 控制流图,拒绝不可达/无界循环
  → do_check                  # 逐指令抽象解释,维护寄存器状态
      → check_mem_access      # 每次 load/store 都验证边界
      → check_helper_call     # helper 的 proto 与参数类型
      → check_map_func_compatibility
  → convert_ctx_access        # 把 ctx->data 换成实际偏移
  → fixup_bpf_calls           # 内联/改写部分 helper

map 是程序与用户态的唯一共享内存。选错类型,轻则性能差,重则语义不对:

类型查找能否删除典型用途
BPF_MAP_TYPE_ARRAYO(1) 下标否固定大小的计数/配置
BPF_MAP_TYPE_PERCPU_ARRAYO(1),每 CPU 一份否无锁统计(softnet 类)
BPF_MAP_TYPE_HASHO(1) 平均是五元组、IP 表
BPF_MAP_TYPE_LRU_HASHO(1)是(自动淘汰)连接跟踪、缓存
BPF_MAP_TYPE_PERCPU_HASHO(1),每 CPU 一份是高并发计数
BPF_MAP_TYPE_RINGBUF生产者/消费者—事件上报(替代 perf buffer)
BPF_MAP_TYPE_DEVMAPO(1)是XDP 重定向到网卡
BPF_MAP_TYPE_XSKMAPO(1)是XDP 重定向到 AF_XDP
BPF_MAP_TYPE_PROG_ARRAYO(1)是tail call
BPF_MAP_TYPE_REUSEPORT_SOCKARRAYO(1)是SO_REUSEPORT 选 socket

用 libbpf 加载(比手写 bpf() 更接近生产):

/* load_xdp.c:打开、加载并挂载 4.9.16 的 xdp_drop_telnet.o */
#include <bpf/libbpf.h>
#include <linux/if_link.h>
#include <net/if.h>
#include <stdio.h>

int main(void)
{
    struct bpf_object *obj = bpf_object__open_file("xdp_drop_telnet.o", NULL);
    if (!obj) { fprintf(stderr, "open failed\n"); return 1; }
    if (bpf_object__load(obj)) { fprintf(stderr, "load failed\n"); return 1; }

    struct bpf_program *prog =
        bpf_object__find_program_by_name(obj, "xdp_drop_telnet");
    if (!prog) { fprintf(stderr, "prog not found\n"); return 1; }

    int ifindex = if_nametoindex("eth0");
    int err = bpf_xdp_attach(ifindex, bpf_program__fd(prog),
                             XDP_FLAGS_UPDATE_IF_NOEXIST, NULL);
    if (err) { fprintf(stderr, "attach: %d\n", err); return 1; }
    printf("attached to eth0\n");
    return 0;
}

编译:cc -O2 -Wall -Wextra load_xdp.c -o load_xdp -lbpf -lelf -lz。加载失败时把 libbpf_set_print() 的回调打开,或直接用 bpftool prog load ... log_level 2 看 verifier 日志。

BTF/CO-RE 解决「内核版本变了,结构体偏移变了」的问题:

bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
bpftool prog show
bpftool map show

没有 BTF 的内核(或没开 CONFIG_DEBUG_INFO_BTF)只能用固定偏移,升级内核就可能读错字段。这是「同一个 eBPF 程序在老内核上跑不了」的最常见原因。

eBPF 加载的五个坑
  1. 只看 EACCES 不看 verifier 日志:log_buf 里才有「invalid mem access at insn 12」这类定位信息;先开 log_level=2。
  2. 把 BPF_MAP_TYPE_ARRAY 当可删除的哈希:数组 map 的 key 必须小于 max_entries,删除返回 EINVAL;需要删除就用 HASH/LRU_HASH。
  3. per-CPU map 当普通 map 读:bpf_map_lookup_elem() 返回「每 CPU 副本的指针」,用户态 BPF_MAP_LOOKUP_ELEM 要按 CPU 逐个读,不能直接 memcpy 成单值。
  4. 忘记 BPF_F_MMAPABLE/页对齐:大数组 map 想 mmap 共享,必须带这个 flag,并按页对齐。
  5. 在有 BTF 的内核上编译、在没 BTF 的内核上加载:CO-RE 重定位失败,程序加载不了;要么带 vmlinux.h 一起编译,要么退回固定偏移。

4.9.21 AF_XDP:从 XDP_REDIRECT 到用户态环形队列

XDP 能把包 XDP_TX 回给网卡、XDP_PASS 交给协议栈、XDP_DROP 丢掉,但「交给用户态进程」需要 AF_XDP。它把一块用户态内存(UMEM)注册给内核,网卡 DMA 直接写进去,用户态通过四个环形队列收发,省掉 skb 分配和协议栈。

UMEM 与四个环:

UMEM(用户态分配的连续内存,按 frame_size 切块)
  ┌──────┬──────┬──────┬──────┐
  │frame0│frame1│frame2│ ...  │
  └──────┴──────┴──────┴──────┘
   FILL  ring:用户 → 内核   (把空 frame 交给内核收包)
   COMP  ring:内核 → 用户   (TX 完成后归还 frame)
   RX    ring:内核 → 用户   (收到的包:addr + len)
   TX    ring:用户 → 内核   (要发的包:addr + len)
环生产者消费者存放什么
FILL用户内核空 frame 的地址
COMPLETION内核用户已发送完成的 frame 地址
RX内核用户struct xdp_desc { addr, len }
TX用户内核struct xdp_desc

内核路径:

阶段函数文件
绑定xsk_bind() → xp_assign_dev()net/xdp/xsk.c、net/xdp/xdp_umem.c
XDP 重定向bpf_redirect_map() → xdp_do_redirect() → __xsk_map_redirect()net/core/filter.c、net/xdp/xsk.c
投递到 RX 环xsk_rcv() / xsk_rcv_zc()net/xdp/xsk.c
用户态发送xsk_sendmsg() → xsk_generic_xmit()同上
驱动唤醒ndo_xsk_wakeup()各网卡驱动

XDP 侧的重定向程序:

/* xdp_xsk.c:把包按 RX 队列号送到 AF_XDP socket */
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>

struct {
    __uint(type, BPF_MAP_TYPE_XSKMAP);
    __uint(max_entries, 64);
    __type(key, int);
    __type(value, int);
} xsks_map SEC(".maps");

SEC("xdp")
int xdp_redirect_xsk(struct xdp_md *ctx)
{
    int key = ctx->rx_queue_index;
    if (bpf_map_lookup_elem(&xsks_map, &key))
        return bpf_redirect_map(&xsks_map, key, XDP_PASS);
    return XDP_PASS;
}

bpf_redirect_map() 的第三个参数是「找不到目标时的 fallback」;这里用 XDP_PASS,避免把没有用户态 socket 的队列直接丢掉。

用户态建立 socket(libbpf 的 xsk API):

/* xsk_setup.c:创建 UMEM 与 AF_XDP socket(省略错误处理) */
#include <bpf/xsk.h>
#include <net/if.h>

struct xsk_umem *umem;
struct xsk_ring_prod fq, cq;
struct xsk_ring_cons rx, tx;
struct xsk_socket *xsk;

void setup(const char *ifname, int queue_id, void *buf, size_t len)
{
    struct xsk_umem_config ucfg = {
        .fill_size = 2048, .comp_size = 2048,
        .frame_size = 4096, .frame_headroom = 0,
    };
    struct xsk_socket_config xcfg = {
        .rx_size = 2048, .tx_size = 2048,
        .libbpf_flags = XSK_LIBBPF_FLAGS__INHIBIT_PROG_LOAD,
        .xdp_flags = XDP_FLAGS_UPDATE_IF_NOEXIST,
    };
    xsk_umem__create(&umem, buf, len, &fq, &cq, &ucfg);
    xsk_socket__create(&xsk, ifname, (__u32)queue_id, umem, &rx, &tx, &xcfg);
}

收发是「预留—填充—提交」与「peek—处理—release」:

/* 收:从 RX 环取 desc,处理完把 frame 还回 FILL 环 */
unsigned int idx;
if (xsk_ring_cons__peek(&rx, 1, &idx) == 1) {
    const struct xdp_desc *d = xsk_ring_cons__rx_desc(&rx, idx);
    void *pkt = xsk_umem__get_data(buf, d->addr);
    /* ... 处理 pkt,长度 d->len ... */
    xsk_ring_cons__release(&rx, 1);
    __u32 fi;
    if (xsk_ring_prod__reserve(&fq, 1, &fi) == 1) {
        *xsk_ring_prod__fill_addr(&fq, fi) = d->addr;
        xsk_ring_prod__submit(&fq, 1);
    }
}

两种模式:

模式数据路径要求适用
XDP_ZEROCOPY网卡 DMA 直接读写 UMEM驱动支持 ndo_xsk_wakeup 与 XDP_ZEROCOPY高吞吐、低延迟
XDP_COPY内核在 skb 与 UMEM 之间拷贝所有支持 XDP 的驱动兼容性优先
XDP_USE_NEED_WAKEUP用户态显式唤醒 NAPI驱动支持省 CPU、批量收

零拷贝不是「打开一个选项」:xsk_socket__create() 失败时先看 errno,EOPNOTSUPP 通常是驱动不支持 zero-copy;用 xdpsock 或 xsk_fwd 先验证环境:

sudo ./xdpsock -i eth0 -q 0 -r -z   # -z: zero-copy
sudo ./xdpsock -i eth0 -q 0 -r      # copy 模式
方案协议栈拷贝隔离代价
内核协议栈完整1–2 次好通用
AF_XDP copy绕过1 次好仍走 skb
AF_XDP zero-copy绕过0 次好驱动/队列绑定
DPDK自己实现0 次独占网卡运维成本高
AF_XDP 的五个坑
  1. XSKMAP 的 key 与 rx_queue_index 不一致:bpf_redirect_map() 找不到目标就 fallback;看起来「程序跑了但没收到包」,先打印队列号。
  2. UMEM 没按 frame_size 对齐:xsk_umem__create() 返回 EINVAL;frame_size 必须是 2 的幂,且 ≥ 2048。
  3. FILL 环不补:收完包不把 frame 还回 FILL,内核很快没有空 frame,RX 停止。
  4. TX 完成不读 COMPLETION:发送环满后 sendto() 返回 EAGAIN/ENOBUFS;要定期回收。
  5. 在 lo 上期待 zero-copy:回环没有真实 DMA,通常只支持 copy/generic 路径;用支持 XDP 的物理网卡或 veth 验证。

4.9.22 实验:用 bpftool + bpftrace + tcpdump 验证 XDP 丢弃

实验目标:确认 XDP 程序真的在 tcpdump 之前执行,并理解「为什么抓不到被 XDP 丢掉的包」。

# 1. 准备一对 veth,给两端配地址
sudo ip link add veth-xdp type veth peer name veth-peer
sudo ip addr add 10.99.0.1/24 dev veth-xdp
sudo ip addr add 10.99.0.2/24 dev veth-peer
sudo ip link set veth-xdp up; sudo ip link set veth-peer up

# 2. 编译并挂载 4.9.16 的程序(generic 模式,veth 通常不支持 native XDP)
clang -O2 -g -target bpf -c xdp_drop_telnet.c -o xdp_drop_telnet.o
sudo ip link set dev veth-xdp xdpgeneric obj xdp_drop_telnet.o sec xdp

# 3. 看挂载点与程序
sudo bpftool net show
sudo bpftool prog show | grep -A3 xdp

# 4. 抓包 + 发一个目的端口 23 的 SYN
sudo tcpdump -i veth-xdp -nn 'tcp port 23' -c 1 &
sudo python3 -c 'import socket; s=socket.socket(); s.settimeout(0.5); s.connect(("10.99.0.2", 23))' 2>/dev/null || true

# 5. 用 bpftrace 看 XDP 执行与异常
sudo bpftrace -e '
kprobe:bpf_prog_run_xdp { @run = count(); }
kprobe:xdp_do_redirect  { @redirect = count(); }
tracepoint:xdp:xdp_exception { @exc[args->type] = count(); }
interval:s:3 { print(@run); print(@redirect); print(@exc); exit(); }'

# 6. 卸载
sudo ip link set dev veth-xdp xdp off

预期现象:

  • bpftool net show 显示 veth-xdp 上挂了 xdp 程序;bpftool prog show 能看到 xdp_drop_telnet。
  • tcpdump 抓不到目的端口 23 的 SYN:AF_PACKET 的抓包点在 netif_receive_skb 里,而 XDP 在它之前执行,XDP_DROP 的包根本没进协议栈。
  • bpftrace 的 @run 增长,@redirect 为 0(程序返回 XDP_DROP,没有 redirect);xdp_exception 通常为 0,除非程序主动 bpf_xdp_adjust_* 失败。

把返回值从 XDP_DROP 改成 XDP_PASS 重新挂载,tcpdump 就能抓到 SYN,@run 仍然增长——这组对照说明「XDP 在抓包点之前」,也是「防火墙/XDP 丢了包但 tcpdump 看不到」的根源。

实验需要 Linux 内核启用 CONFIG_XDP_SOCKETS/CONFIG_BPF_SYSCALL、clang 与 bpftool;本机为 macOS,未实测。veth 的 XDP 支持随内核版本变化,失败时先用 xdpgeneric 验证逻辑。

4.9.23 本章陷阱清单

15 条高频错误
  1. 把 skb->len 当载荷长度:它包含各层首部;载荷要看 len - tcp_header_len - ip_header_len。
  2. 认为 skb->data_len 是「数据总长」:它是分片(paged)部分,线性部分在 len - data_len。
  3. 在 noqueue 设备上加 netem:回环/veth 默认无队列,qdisc 替换会失败;先换 pfifo_fast。
  4. 把 tx_dropped 与 qdisc dropped 混为一谈:前者是设备层,后者是排队策略层。
  5. 认为 GSO 包能原样发出:GSO 只是延迟分段,网卡或 dev_hard_start_xmit 最终仍按 MTU 切。
  6. 关闭 TSO 来「降低延迟」:小包场景可能有效,但大流量会显著增加 CPU;先确认瓶颈。
  7. XDP 程序里用 bpf_skb_* helper:XDP 没有 skb,只能用 xdp_md 与 XDP 专用 helper。
  8. 把 TC_ACT_SHOT 当 XDP_DROP:两者在不同钩子、不同计数器里,排障时不能互换。
  9. generic XDP 当生产方案:它需要先构造 skb,吞吐不如 native,通常只用于验证。
  10. sendfile 用于管道或普通文件输出:Linux 要求输出是 socket;文件到文件用 copy_file_range。
  11. MSG_ZEROCOPY 不读错误队列:发送完成通知在 sk_error_queue,不读会耗尽 SO_EE_ORIGIN_ZEROCOPY 的完成项并回退到拷贝。
  12. 在容器里看 qdisc 却看的是宿主机:veth 的两端在不同 netns,tc 要进容器或指定 dev 的另一端。
  13. eBPF 加载失败只看 EACCES:verifier 的真实原因在 log_buf,用 log_level=2 或 libbpf 的 libbpf_set_print() 打出来。
  14. map 类型选错:ARRAY 不能删除、LRU_HASH 会自动淘汰、per-CPU map 用户态要按 CPU 读;把它们当普通 HASH 用会得到「写进去了但读不到」。
  15. AF_XDP 的 XSKMAP key 与队列号不一致:重定向找不到目标就 fallback,表现为「程序在跑但收不到包」;先打印 rx_queue_index 与 bind 的 queue id。

4.9.24 本章出口检查

检查项达标线没达标去哪
画出从 sendmsg 到 ndo_start_xmit 的链能标出两次排队的位置4.9.1
说清 write 返回意味着什么知道数据只是进了内核缓冲区4.9.2
列出 tcp_write_xmit 的四个条件cwnd、snd_wnd、Nagle、TSQ4.9.3
解释 clone_it 与重传的关系知道发送队列何时释放4.9.4
说清 qdisc 的作用与常见类型知道回环为什么是 noqueue4.9.6
解释抓包里的大包知道 TSO 发生在分段之前4.9.8
区分四个容量限制能对应到四个观测命令4.9.9

| 说出 dst/neighbour/qdisc 的作用 | 能解释「第一个包丢」的原因 | 4.9.15 | | 区分 XDP 与 TC 的挂载点 | 知道两者的返回码与限制 | 4.9.16 | | 用 bpftrace 统计发送字节 | 能读 GSO 直方图 | 4.9.17 | | 比较 sendfile 与 read/write | 能说出省了哪次拷贝 | 4.9.18 | | 说清 verifier 的检查与 map 类型 | 能读 verifier 日志、选对 map | 4.9.20 | | 画出 AF_XDP 的四个环与内核路径 | 能解释 zero-copy 与 copy 的差别 | 4.9.21 | | 用 bpftool/bpftrace/tcpdump 验证 XDP | 知道 XDP_DROP 对抓包不可见 | 4.9.22 |

即时自测 · 4.9

Q1 write 返回 100 字节,但对方 1 秒后才收到。中间可能发生了什么?

Q2 tcp_write_xmit 的四个条件里,哪个和「网卡忙」有关?为什么要有这个限制?

Q3 回环设备的 qdisc 是 noqueue,这对本机 RTT 有什么影响?

Q4 抓包看到 length 60000 的 TCP 段,是链路出问题了吗?

Q5 ss -tm 显示 t:87000 tb:87040,应用 write 阻塞。该调什么?

Q6 为什么发送完成的 skb 要等到 TX 中断才释放,而不是 dev_queue_xmit 返回就释放?

答案

A1 数据进了 sk_write_queue,但 tcp_write_xmit 因为 Nagle(有未确认的小数据)、窗口(cwnd 或 snd_wnd 不足)或 TSQ 限制没有立刻发。等 ACK 回来或者窗口更新后才发出。这是正常行为,不是 bug。

A2 TSQ(tcp_limit_output_bytes)。它限制单个 socket 在 qdisc 里的在途字节,防止一条流占满队列、把其他流的延迟推高。网卡忙时 qdisc 堆积,TSQ 会让发送方等 ACK 再发,而不是无限往队列里塞。

A3 回环没有排队延迟,所以本机 RTT 只有协议栈处理时间(几十微秒)。这让本机测试无法复现「qdisc 排队」导致的延迟,也让 bufferbloat 类问题在本机看不见。

A4 不是。这是 TSO/GSO 的产物:协议栈把最多 64 KB 放进一个 skb,网卡才按 MSS 切分。抓包点在切分之前,所以看到大包。真实链路上仍然是 1500 字节的帧。

A5 发送缓冲区满了(t 几乎等于 tb)。可以调大 SO_SNDBUF 或 tcp_wmem,但先要确认瓶颈真的是缓冲区——如果 cwnd 也很小,调大缓冲区只会让数据堆得更久。更根本的做法是让应用少写、快读,或者用多连接分流。

A6 因为网卡还在通过 DMA 读这块内存。如果提前释放,网卡可能读到已被复用的内存,发出错误数据。TX 中断(或 NAPI 里的 tx 清理)表示「网卡已经读完」,此时释放才安全。这也是为什么 sk_wmem_alloc 的回收是异步的。

母本对应

母本章节本书落点程度
N(深入理解 Linux 网络)第 4 章 4.2–4.6(发送总览、网卡启动、详细过程、Ring 回收)4.9.1–4.9.8完整:协议栈到驱动的全过程
N(深入理解 Linux 网络)第 7 章 7.2(内核如何管理内存)4.9.2、4.9.9精选:sk_wmem_alloc 与缓冲区记账
R(奔跑吧 Linux 内核)第 5 章 5.2(软中断与 tasklet)4.9.7背景:TX 完成在软中断里清理
K(Kerrisk)第 61 章 61.3–61.4(send/sendfile)4.9.2精选:发送系统调用的返回值语义
N(深入理解 Linux 网络)第 4 章 4.4(发送路径与 RingBuffer,PDF p108–134)4.9.15、4.9.18补充:对象与零拷贝
W(谢希仁)第 4 章(网络层转发,PDF p126–221)4.9.15背景:路由与下一跳
K(Kerrisk)第 61 章 61.4(sendfile,PDF p1034–1036)4.9.18完整:sendfile 语义
Linux 6.8 源码kernel/bpf/verifier.c、net/xdp/xsk.c、net/core/filter.c4.9.20–4.9.22完整:verifier、map、AF_XDP
Linux 文档Documentation/networking/af_xdp.rst4.9.21完整:UMEM 与四个环

下一站:收发路径都讲完了,但应用怎么知道「有数据可读」?进入 4.10 socket 与 epoll 的内核协作,看等待队列、回调与就绪链表。