上一篇我们拆解了 收包路径——从 NIC
中断到 socket 接收队列。现在反过来:用户态调用
send() 之后,数据在内核中怎么走到网线上?
这个问题比收包更复杂。收包路径是”被动的”——包来了就处理;发包路径是”主动的”——内核需要在多个约束下做决策:
- TCP 什么时候触发实际发送?(Nagle 算法、拥塞窗口、TSQ 限流)
- 一个 64KB 的 TCP 段怎么变成多个 1500 字节的 IP 包?(GSO/TSO 分段)
- 哪个 CPU 的哪个 TX 队列来发?(XPS、队列选择)
- Qdisc
排队还是直接发?(
TCQ_F_CAN_BYPASS) - 驱动忙怎么办?(
NETDEV_TX_BUSY、netif_tx_stop_queue)
本文从 sendmsg()
系统调用开始,逐函数追踪完整的发包路径。
本文基于 Linux 6.6 LTS 内核源码。6.8 的差异会单独标注。
一、全景图:TX 发包路径的六个阶段
| 阶段 | 关键函数 | 上下文 |
|---|---|---|
| Socket / 传输层 | tcp_sendmsg() →
tcp_write_xmit() →
__tcp_transmit_skb() |
进程上下文(持有 socket 锁) |
| IP 层 | ip_queue_xmit() →
ip_local_out() → ip_output() |
进程上下文 |
| Netfilter | NF_INET_LOCAL_OUT →
NF_INET_POST_ROUTING |
进程上下文 |
| TC / Qdisc | dev_queue_xmit() →
qdisc->enqueue() →
qdisc->dequeue() |
进程上下文或软中断 |
| 驱动 | dev_hard_start_xmit() →
ndo_start_xmit() |
持有 TX 队列锁 |
| TX 完成 | TX 完成中断 → napi_poll →
consume_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_alloc(include/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() 的循环受两个窗口限制:
- 拥塞窗口(
tp->snd_cwnd):由拥塞控制算法(Cubic/BBR 等)管理,反映网络路径的容量 - 接收窗口(
tp->snd_wnd):对端通告的可接收数据量
每次迭代检查
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(),负责:
- 如果需要重传,
skb_clone()一份(原始 skb 留在重传队列) - 构建 TCP 头(序列号、确认号、窗口大小、flags、TCP 选项)
- 计算 TCP checksum(或设置硬件卸载标志)
- 调用
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()
会根据实际需要选择路径:
- 如果出接口支持硬件 TSO:保持大包不分段,交给硬件分段
- 如果不支持 TSO:调用
skb_gso_segment()做软件分段,产生多个 MTU 大小的 skb
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);选择逻辑的优先级:
- skb 已标记队列:如果
skb->queue_mapping已设置(如 TC 或 eBPF 指定),直接使用 - XPS 映射:如果启用了 XPS(Transmit Packet Steering),按发送 CPU 映射到 TX 队列
- 驱动的
ndo_select_queue:如果驱动注册了自定义队列选择回调 - 哈希选择:使用
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”,直到最后一刻再分段。这带来两个好处:
- 减少 per-packet 开销:45 个包变成 1 个包经过协议栈
- 如果硬件支持 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 = 1448,gso_segs = 45,gso_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,需要:
- 把 skb 的数据(可能包含 fragment pages)映射到 DMA 地址
- 在 TX 描述符中设置 TSO 参数(MSS、header 长度)
- 网卡硬件自动按 MSS 分割数据,为每个分段生成独立的 TCP/IP 头
- 每个分段独立发送到网线
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() 的关键操作:
- 减少
sk_wmem_alloc:释放 skb 占用的发送缓冲区额度 - 清除 TSQ 限制:如果设置了
TSQ_THROTTLED,清除它并安排tcp_release_cb()继续发送 - 唤醒等待的进程:通过
sk->sk_write_space()唤醒在tcp_sendmsg中因缓冲区满而阻塞的进程
这形成了一个反馈环路:进程发送 → 填满发送缓冲区 → 阻塞 → TX 完成 → 释放缓冲区 → 唤醒进程 → 继续发送。
8.3 BQL:Byte Queue Limits
BQL(Byte Queue Limits)是在驱动层面防止 bufferbloat 的机制。它动态限制驱动可以接受的排队字节数:
- 驱动在
ndo_start_xmit入队时调用netdev_tx_sent_queue(txq, bytes) - 驱动在 TX 完成清理时调用
netdev_tx_completed_queue(txq, pkts, bytes) - BQL 算法根据”in-flight 字节数”和”完成速率”动态调整限制
如果 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参考文献
- Linux 内核源码,
net/core/dev.c,6.6 LTS / 6.8 - Linux 内核源码,
net/ipv4/tcp_output.c,6.6 LTS - Linux 内核源码,
net/ipv4/ip_output.c,6.6 LTS - Linux
内核源码,
include/linux/netdevice.h,6.8(dev_queue_xmit定义于第 3175 行,netdev_start_xmit定义于第 5014 行) - Linux
内核源码,
include/net/sch_generic.h,6.8(struct Qdisc定义于第 73 行,Qdisc_ops定义于第 287 行) - Linux
内核源码,
include/net/tcp.h,6.8(tcp_sendmsg声明于第 338 行) - Linux
内核文档,
Documentation/networking/scaling.rst(XPS)
上一篇:收包路径全解:从 NIC 中断到 socket 接收队列
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【Linux 网络子系统深度拆解】TCP 内核实现(下):数据传输与拥塞控制
tcp_sendmsg 把用户数据拷到 sk_buff 就完事了?远没有。后面还有 Nagle 合并、TSQ 限流、cwnd/rwnd 双窗口门控、RACK-TLP 丢包检测、拥塞状态机五态跳转、sk_pacing_rate 软件限速。本文从 Linux 6.6 内核源码拆解 TCP 数据传输的完整路径——从 send() 到 ACK 处理——以及拥塞控制框架 tcp_congestion_ops 的可插拔架构。
【Linux 网络子系统深度拆解】分段卸载:GRO/GSO/TSO 的内核实现与陷阱
从内核源码拆解 GRO 聚合引擎、GSO 延迟分段、TSO 硬件卸载的完整实现,分析 LRO 废弃原因,以及卸载机制对 Netfilter/TC/隧道的影响与常见陷阱。
【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 的关系。
【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 头缓存优化。