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 判断:
- 拥塞窗口:
tp->snd_nxt - tp->snd_una < tp->snd_cwnd。 - 接收窗口:不超过
tp->snd_wnd。 - Nagle:没有未确认的小数据,或者这个 skb 够大。
- 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
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)做三件事:
- 检查 TX ring 有没有空位,没有就返回
NETDEV_TX_BUSY(qdisc 会重试)。 - 把 skb 的 DMA 地址写进描述符,设置长度和标志(如
EOP、校验和卸载)。 - 更新 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?同上。
- 「
write返回就发出去了」。只代表数据进了内核缓冲区,真正的发送由tcp_write_xmit决定。 - 「
Send-Q是网卡队列」。Send-Q是 socket 发送队列里未确认的字节,不是 qdisc 或 TX ring。 - 「关掉 TSO 性能更好」。关掉会让软件分段,CPU 开销显著上升。只有在抓包或兼容性问题时才关。
- 「qdisc 只做限速」。它还做公平排队、优先级、AQM(主动队列管理),
fq_codel的主要作用其实是抗 bufferbloat。
4.9.9 发送缓冲区与阻塞点
一条连接的发送路径上有四个「容量」限制,任何一个满了都会阻塞或丢包:
| 限制 | 参数 | 满了会怎样 | 观测 |
|---|---|---|---|
| socket 发送缓冲区 | SO_SNDBUF / tcp_wmem | write 阻塞或 EAGAIN | ss -tm 的 t/tb |
| 拥塞窗口 | cwnd | 数据留在 sk_write_queue | ss -ti 的 cwnd |
| 接收窗口 | snd_wnd | 同上 | ss -ti 的 snd_wnd |
| qdisc/驱动队列 | txqueuelen、BQL | qdisc 丢包或 NETDEV_TX_BUSY | tc -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 通知 */
| 方案 | 拷贝次数 | 适用 | 代价 |
|---|---|---|---|
write | 1 | 通用 | 一次内存拷贝 |
sendfile | 0 | 文件 → socket | 只能用于文件 |
splice | 0 | fd → fd | 多一个管道 |
MSG_ZEROCOPY | 0 | 任意用户缓冲区 | 页 pin、异步通知 |
这张表的关键是最后一列。零拷贝不是「免费加速」,它把成本从「拷贝」换成「页管理」或「编程复杂度」。数据量小的时候,零拷贝反而更慢(系统调用和通知的开销超过拷贝)。经验阈值是几十 KB 以上才值得考虑。
- 「零拷贝完全不拷贝」。它去掉的是「用户态 ↔ 内核态」的拷贝,设备 DMA 仍然要读内存。
- 「sendfile 一定比 read+write 快」。小文件、页缓存未命中时优势不明显,甚至更慢。
- 「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/TSO | TCP 发送时按 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 花在协议栈上」的一个隐蔽来源。
应用一次写入 64 KB,TCP 把它放进一个 skb,gso_size = 1460。
tcp_transmit_skb只加一个 TCP 首部,skb->len ≈ 65536。- 驱动把「一个描述符 + 64 KB 缓冲区」交给网卡(需要网卡支持分散聚合)。
- 网卡按 1460 切分,生成 44 个段,每段重算 IP ID 和 TCP 校验和。
- 线路上出现 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_entry | include/net/dst.h | 一条路由结果 | 路由缓存命中时长期存在 | dev、ops、flags、obsolete |
struct rtable | include/net/route.h | IPv4 路由表项(dst_entry 的子类) | 同上 | rt_gateway、rt_flags、rt_type |
struct neighbour | include/net/neighbour.h | 下一跳的 L2 地址(ARP/NDP) | 解析成功后缓存 | ha、nud_state、ops、arp_queue |
struct Qdisc | include/net/sch_generic.h | 发送队列的排队规则 | 设备存在期间 | enqueue、dequeue、q.qlen、gso_skb |
struct netdev_queue | include/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_dropped | dev_queue_xmit / qdisc | 队列满或设备 down |
qdisc drops | tc -s qdisc | 当前 qdisc 的丢弃(fq_codel 的 drop_overlimit) |
tx_errors | 驱动 | 网卡发送失败(ring 满、DMA 错误) |
三者含义不同:tx_dropped 是协议栈丢的,qdisc drops 是排队策略丢的,tx_errors 是硬件/驱动丢的。排「发送丢包」时必须三个都看,只看 ifconfig 的 dropped 会漏掉 qdisc。
回环、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_XDP | xdp_md(DMA 缓冲区) | DDoS 清洗、负载均衡、AF_XDP |
| TC ingress | skb 已分配、进入协议栈前 | BPF_PROG_TYPE_SCHED_CLS | __sk_buff | 容器策略、流量镜像、NAT |
| TC egress | dev_queue_xmit 之前 | 同上 | __sk_buff | 出站限速、标记、重定向 |
XDP 的最大特点是早:它在 napi_poll 里、skb 还没分配时执行,所以吞吐最高,但也最受限——不能使用 skb 相关 helper,不能分片,不能访问 socket。返回码只有五个:
| 返回码 | 值 | 行为 |
|---|---|---|
XDP_ABORTED | 0 | 丢包并记录 tracepoint(程序出错) |
XDP_DROP | 1 | 丢包,最常用 |
XDP_PASS | 2 | 交给协议栈 |
XDP_TX | 3 | 从同一网卡发回 |
XDP_REDIRECT | 4 | 重定向到其他网卡/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) |
mark | fwmark | 是 |
priority | skb priority / VLAN PCP | 是 |
protocol | 三层协议 | 是(改包时) |
vlan_present / vlan_tci | VLAN 信息 | 是 |
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_xdp | net/core/dev.c |
| generic XDP | netif_receive_generic_xdp | 同上 |
| XDP 重定向 | xdp_do_redirect → dev_map_enqueue | net/core/filter.c、kernel/bpf/devmap.c |
| TC ingress | sch_handle_ingress → tcf_classify | net/core/dev.c、net/sched/cls_api.c |
| TC egress | sch_handle_egress | net/core/dev.c |
| cls_bpf | cls_bpf_classify → bpf_prog_run | net/sched/cls_bpf.c |
- XDP 不能用
skbhelper:bpf_skb_load_bytes、bpf_skb_store_bytes在 XDP 里不可用,只能直接读写data。 - generic XDP 不是「零成本」:它要把包先构造成
skb再跑程序,吞吐远低于 native,适合验证逻辑而非生产。 - XDP 不能分片:程序改大包后超过 MTU 会被丢,必须自己处理或返回
XDP_PASS交给协议栈。 - TC egress 在 qdisc 之前还是之后取决于挂载点:
clsact的 egress 在dev_queue_xmit内、qdisc 入队前;想限制已经排队的流量要用 qdisc,不是 TC。 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 |
| 创建 map | BPF_MAP_CREATE | map_create() | EINVAL(类型/参数)、ENOMEM |
| 加载程序 | BPF_PROG_LOAD | bpf_check() | EACCES/EINVAL,verifier 日志在 log_buf |
| 校验 | — | check_cfg()、do_check() | 指针越界、栈未初始化、循环不收敛 |
| JIT | — | bpf_prog_select_runtime() | 架构不支持时退回解释器 |
| 挂载 | bpf_xdp_attach()/netlink | dev_change_xdp_fd() → ndo_bpf | EBUSY、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_ARRAY | O(1) 下标 | 否 | 固定大小的计数/配置 |
BPF_MAP_TYPE_PERCPU_ARRAY | O(1),每 CPU 一份 | 否 | 无锁统计(softnet 类) |
BPF_MAP_TYPE_HASH | O(1) 平均 | 是 | 五元组、IP 表 |
BPF_MAP_TYPE_LRU_HASH | O(1) | 是(自动淘汰) | 连接跟踪、缓存 |
BPF_MAP_TYPE_PERCPU_HASH | O(1),每 CPU 一份 | 是 | 高并发计数 |
BPF_MAP_TYPE_RINGBUF | 生产者/消费者 | — | 事件上报(替代 perf buffer) |
BPF_MAP_TYPE_DEVMAP | O(1) | 是 | XDP 重定向到网卡 |
BPF_MAP_TYPE_XSKMAP | O(1) | 是 | XDP 重定向到 AF_XDP |
BPF_MAP_TYPE_PROG_ARRAY | O(1) | 是 | tail call |
BPF_MAP_TYPE_REUSEPORT_SOCKARRAY | O(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 程序在老内核上跑不了」的最常见原因。
- 只看
EACCES不看 verifier 日志:log_buf里才有「invalid mem access at insn 12」这类定位信息;先开log_level=2。 - 把
BPF_MAP_TYPE_ARRAY当可删除的哈希:数组 map 的 key 必须小于max_entries,删除返回EINVAL;需要删除就用 HASH/LRU_HASH。 - per-CPU map 当普通 map 读:
bpf_map_lookup_elem()返回「每 CPU 副本的指针」,用户态BPF_MAP_LOOKUP_ELEM要按 CPU 逐个读,不能直接 memcpy 成单值。 - 忘记
BPF_F_MMAPABLE/页对齐:大数组 map 想 mmap 共享,必须带这个 flag,并按页对齐。 - 在有 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 次 | 独占网卡 | 运维成本高 |
- XSKMAP 的 key 与
rx_queue_index不一致:bpf_redirect_map()找不到目标就 fallback;看起来「程序跑了但没收到包」,先打印队列号。 - UMEM 没按
frame_size对齐:xsk_umem__create()返回EINVAL;frame_size 必须是 2 的幂,且 ≥ 2048。 - FILL 环不补:收完包不把 frame 还回 FILL,内核很快没有空 frame,RX 停止。
- TX 完成不读 COMPLETION:发送环满后
sendto()返回EAGAIN/ENOBUFS;要定期回收。 - 在
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 本章陷阱清单
- 把
skb->len当载荷长度:它包含各层首部;载荷要看len - tcp_header_len - ip_header_len。 - 认为
skb->data_len是「数据总长」:它是分片(paged)部分,线性部分在len - data_len。 - 在
noqueue设备上加netem:回环/veth 默认无队列,qdisc 替换会失败;先换pfifo_fast。 - 把
tx_dropped与 qdiscdropped混为一谈:前者是设备层,后者是排队策略层。 - 认为 GSO 包能原样发出:GSO 只是延迟分段,网卡或
dev_hard_start_xmit最终仍按 MTU 切。 - 关闭 TSO 来「降低延迟」:小包场景可能有效,但大流量会显著增加 CPU;先确认瓶颈。
- XDP 程序里用
bpf_skb_*helper:XDP 没有 skb,只能用xdp_md与 XDP 专用 helper。 - 把
TC_ACT_SHOT当XDP_DROP:两者在不同钩子、不同计数器里,排障时不能互换。 - generic XDP 当生产方案:它需要先构造 skb,吞吐不如 native,通常只用于验证。
sendfile用于管道或普通文件输出:Linux 要求输出是 socket;文件到文件用copy_file_range。MSG_ZEROCOPY不读错误队列:发送完成通知在sk_error_queue,不读会耗尽SO_EE_ORIGIN_ZEROCOPY的完成项并回退到拷贝。- 在容器里看 qdisc 却看的是宿主机:veth 的两端在不同 netns,
tc要进容器或指定dev的另一端。 - eBPF 加载失败只看
EACCES:verifier 的真实原因在log_buf,用log_level=2或 libbpf 的libbpf_set_print()打出来。 - map 类型选错:
ARRAY不能删除、LRU_HASH会自动淘汰、per-CPU map 用户态要按 CPU 读;把它们当普通 HASH 用会得到「写进去了但读不到」。 - 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、TSQ | 4.9.3 |
解释 clone_it 与重传的关系 | 知道发送队列何时释放 | 4.9.4 |
| 说清 qdisc 的作用与常见类型 | 知道回环为什么是 noqueue | 4.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 |
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.c | 4.9.20–4.9.22 | 完整:verifier、map、AF_XDP |
| Linux 文档 | Documentation/networking/af_xdp.rst | 4.9.21 | 完整:UMEM 与四个环 |
下一站:收发路径都讲完了,但应用怎么知道「有数据可读」?进入 4.10 socket 与 epoll 的内核协作,看等待队列、回调与就绪链表。