4.5 发送 IPv4 数据包
现在,我们要把角色换过来。
在此之前,我们一直是那个站在门口拆快递的人(接收路径),检查包裹上的标签,撕掉包装,决定是签收还是转交给邻居。但网络不是单向的,Linux 内核同样需要主动往网络上扔东西。
这就是发送路径。
当传输层(TCP 或 UDP)准备好数据,想把它交给链路层发出去时,IPv4 层就得介入了。这一节的任务,就是看清楚内核是如何把一张「传输层的发票」打包成标准的「IPv4 包裹」,并递送出去的。
这里有一个很有意思的分歧点:TCP 和 UDP 在内核里对待发送这件事的态度截然不同。你会看到两种完全不同的发送流程,这背后是两种协议设计哲学的体现。
两条路,两种性格
从传输层下来,发送 IPv4 包主要有两种方法。这在内核源码里分得清清楚楚(主要在 net/ipv4/ip_output.c)。
第一条路是给「操心型」协议走的——典型代表是 TCP。
用的方法是 ip_queue_xmit()。
TCP 为什么叫操心型?因为它特别在乎分包这件事。TCP 自己有一套复杂的机制来处理数据分段,它不希望 IP 层插手。所以,当 TCP 调用 ip_queue_xmit() 时,它往往是拿着一个已经想好怎么切分的 SKB 下来的。
顺便说一句,ip_queue_xmit() 并不是 TCP 唯一的出口。比如在发送 SYN_ACK 握手包时,TCP 会用另一个叫 ip_build_and_send_pkt() 的函数(参考 tcp_v4_send_synack)。这说明即使是同一个协议,在不同场景下也会选择最顺手的工具。
第二条路是给「甩手掌柜型」协议走的——典型代表是 UDP 和 ICMP。
用的方法是 ip_append_data()。
UDP 本身是不管分片的,它甚至不管包会不会太大。它把数据一股脑丢给 IP 层:「给你了,爱怎么发怎么发。」
但这里有个细节:ip_append_data() 这个名字其实有误导性——它并不发送数据包。它只是把数据准备好,塞到一个叫 sk_write_queue 的队列里排队。真正触发发送的是另一个函数:ip_push_pending_frames()。
这对组合就像是这样:ip_append_data 负责把所有要寄的东西装进箱子封好,堆在门口;而 ip_push_pending_frames 则是喊快递员上门取件的那个人。
不过在内核 2.6.39 之后,事情发生了一些变化。为了追求极致性能(我们后面会讲无锁发送),UDP 又有了一条新的快速通道,使用 ip_make_skb()。这个函数把「装箱」和「喊快递员」两步合二为一了。
还有一种旁门左道:Raw Socket(原始套接字)。
有些应用(比如 ping 或 nmap)喜欢自己动手丰衣足食,它们在用户空间就把 IP 头部给构造好了。这时候它们会开启 IP_HDRINCL 选项。对于这种包,内核根本不需要 ip_queue_xmit 或 ip_append_data,而是直接调用 raw_send_hdrinc(),把做好的包扔给 Netfilter 的 LOCAL_OUT 钩子:
static int raw_send_hdrinc(struct sock *sk, struct flowi4 *fl4,
void *from, size_t length,
struct rtable **rtp,
unsigned int flags)
{
...
/* 既然用户都把头造好了,直接扔给 LOCAL_OUT 钩子点 */
err = NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_OUT, skb, NULL,
rt->dst.dev, dst_output);
...
}
这就是为什么你能用 ping -ttl 128 这种命令手动指定 TTL 的原因——因为那个 IP 头根本不是内核生成的,是你(通过 ping 工具)生成的。
路径一:ip_queue_xmit() —— TCP 的选择
让我们先走那条简单的路:ip_queue_xmit()。这通常是 TCP 的主场。
这个函数一上来,首先要解决的问题是:这货该发往哪儿?
int ip_queue_xmit(struct sk_buff *skb, struct flowi *fl)
. . .
/* 先确认一下我们能不能路由这个包 */
rt = (struct rtable *)__sk_dst_check(sk, 0);
这里的 rtable 对象就是路由子系统的查表结果。
情况一:路由缓存还没建好
如果 rt 是空(NULL),说明这是这个连接刚开始发数据,或者路由缓存过期了。这时候得去查路由表。
在查表之前,有个小插曲:严格源路由。
还记得我们在 IP 选项那一节讲的「Strict Source Route」吗?如果这个包启用了这个选项,那它的「目的地址」其实并不是它最终要去的地方,而是选项里指定的第一个跳板地址。
if (rt == NULL) {
__be32 daddr;
/* 如果有选项,用选项里指定的地址 */
daddr = inet->inet_daddr;
if (inet_opt && inet_opt->opt.srr)
daddr = inet_opt->opt.faddr;
拿到地址(无论是正常的 daddr 还是 SSR 的跳板地址)之后,调用 ip_route_output_ports() 去查路由:
/* 如果查失败,传输层的重传机制会重试,直到连上或超时 */
rt = ip_route_output_ports(sock_net(sk), fl4, sk,
daddr, inet->inet_saddr,
inet->inet_dport,
inet->inet_sport,
sk->sk_protocol,
RT_CONN_FLAGS(sk),
sk->sk_bound_dev_if);
if (IS_ERR(rt))
goto no_route;
sk_setup_caps(sk, &rt->dst);
}
skb_dst_set_noref(skb, &rt->dst);
如果路由查找失败(比如网络断了),直接 goto no_route,包会被丢弃,返回 -EHOSTUNREACH。这时候上层协议(比如 TCP)会负责重试。
情况二:路由查到了,但发现有冲突
这里有一个很隐蔽的坑:如果你同时启用了「严格源路由」和「网关」。
想象一下:你说「我必须严格走这条路径(A→B→C)」,但路由查出来的结果却是「你必须先走网关 G」。这就是自相矛盾了。内核会直接拒绝发送这个包:
if (inet_opt && inet_opt->opt.is_strictroute && rt->rt_uses_gateway)
goto no_route;
这个设计是合理的——如果非要兼容这种情况,代码逻辑会变得极度复杂,不如直接报错,强制你修正路由表或 IP 选项。
构建头部
好了,路找着了,现在可以装箱了。
这时候 SKB 从传输层下来,它的 skb->data 指针是指着传输层头部的(比如 TCP 头)。我们需要先把指针往前挪,给 IP 头腾位置。
这一步由 skb_push() 完成:
/* 知道去哪了,分配并构建 IP 头 */
skb_push(skb, sizeof(struct iphdr) + (inet_opt ? inet_opt->opt.optlen : 0));
skb_reset_network_header(skb);
iph = ip_hdr(skb);
接下来就是填字段了。这里有一行看着比较晕的位运算:
*((__be16 *)iph) = htons((4 << 12) | (5 << 8) | (inet->tos & 0xff));
这是在干什么? 它一次性把 IP 头的前 16 位(Version + IHL + Type of Service)填进去了。
4 << 12:版本号是 4,放在最高的 4 位。5 << 8:头部长度(IHL)默认是 5(即 20 字节),放在接下来的 4 位。inet->tos:服务类型,填在低 8 位。
接着处理分片标志(DF 位):
if (ip_dont_fragment(sk, &rt->dst) && !skb->local_df)
iph->frag_off = htons(IP_DF);
else
iph->frag_off = 0;
如果设置了不允许分片(DF 标志),就要在 frag_off 里把 IP_DF 标志位(0x4000)置 1。否则就置 0。
后面就是常规操作了:填 TTL、协议号、源目地址:
iph->ttl = ip_select_ttl(inet, &rt->dst);
iph->protocol = sk->sk_protocol;
ip_copy_addrs(iph, fl4);
最后,千万别忘了 IP 选项。如果有选项,头部长度(IHL)是要变的:
if (inet_opt && inet_opt->opt.optlen) {
/* IHL 字段单位是 4 字节,所以 optlen 要除以 4(右移 2 位) */
iph->ihl += inet_opt->opt.optlen >> 2;
/* 把选项填进去 */
ip_options_build(skb, &inet_opt->opt, inet->inet_daddr, rt, 0);
}
这行 iph->ihl += inet_opt->opt.optlen >> 2 很关键。IP 头里的 IHL 表示的是「有多少个 4 字节」。如果你的选项长度是 20 字节(optlen = 20),那么右移 2 位就是 5。加上基础的 5,IHL 就变成了 10,代表头部长度是 40 字节。
发出去
包造好了,最后一步是设置包的 ID(用于分片重组),然后丢给下一层:
ip_select_ident_more(iph, &rt->dst, sk,
(skb_shinfo(skb)->gso_segs ?: 1) - 1);
skb->priority = sk->sk_priority;
skb->mark = sk->sk_mark;
/* 上路 */
res = ip_local_out(skb);
到这里,ip_queue_xmit 的使命就完成了。这是 TCP 最常用的发送路径。
路径二:ip_append_data() —— UDP 的慢速路
UDP 的世界稍微复杂一点。
在深入代码之前,我得先提一个你可能没听说过的东西:UDP_CORK(软木塞)。
这名字很形象。你往瓶子里倒水(数据),如果塞上了软木塞(开启 UDP_CORK 选项),水就流不出来,全积攒在瓶子里。直到你拔掉塞子,水才会一次性喷涌而出。
在内核里,这对应着一种优化:当你有很多小块数据要发时,与其每小块都发一个包,不如把它们攒成一个大数据包,效率更高(协议开销更小)。这个特性是内核 2.5.44 加进来的。
ip_append_data() 的逻辑
这个函数并不直接发送,它的主要工作是把用户空间的数据拷贝到内核,并挂到 socket 的发送队列上。
它的函数签名很长,有一个参数特别显眼:
int ip_append_data(struct sock *sk, struct flowi4 *fl4,
int getfrag(void *from, char *to, int offset, int len,
int odd, struct sk_buff *skb),
...
这个 getfrag 是个回调函数。因为数据还在用户空间,内核怎么把它搬到 SKB 里来呢?
- 对于 UDP,这个回调通常是
ip_generic_getfrag()。 - 对于 ICMP,则是
icmp_glue_bits()。
这就像你请搬家公司,公司派来的工人(getfrag)负责把旧房子的东西(用户态数据)搬到新箱子(SKB)里。
我们来看代码逻辑:
struct inet_sock *inet = inet_sk(sk);
int err;
/* 如果只是探测一下(比如 PMTU 发现),不真正发数据 */
if (flags&MSG_PROBE)
return 0;
第一步:初始化软木塞
如果是这个 socket 的第一次发送(队列是空的),内核需要初始化一些状态:
if (skb_queue_empty(&sk->sk_write_queue)) {
/* 设置 cork,比如处理 IP 选项 */
err = ip_setup_cork(sk, &inet->cork.base, ipc, rtp);
if (err)
return err;
} else {
/* 如果队列里已经有东西了,说明这是后续的分片,不需要头了 */
transhdrlen = 0;
}
ip_setup_cork() 会把 IP 选项等信息锁定下来。因为一旦你开始攒包,这期间如果路由变了或者选项变了,前面攒的一半和后面攒的一半可能对不上。所以「Cork」期间,很多参数是被冻结的。
第二步:搬运数据
真正的苦力活是 __ip_append_data() 做的。这个函数极其复杂,里面处理了两种情况:
-
硬件支持 Scatter/Gather (NETIF_F_SG): 如果网卡支持 SG,内核会开心得多,因为它可以使用
skb_shinfo(skb)->frags,直接把用户空间的数据页映射到 SKB 上,甚至不需要拷贝数据(零拷贝)。 -
硬件不支持 SG: 那就没辙了,只能老老实实拷贝,通常会把分片挂在
skb_shinfo(skb)->frag_list上。
还有个细节叫 MSG_MORE。如果用户发数据时带了这个标志(类似 UDP_CORK 的效果,告诉内核「还有数据要来」),__ip_append_data 在分配内存时会更有策略性,尽量填满一个页。
return __ip_append_data(sk, fl4, &sk->sk_write_queue, &inet->cork.base,
sk_page_frag(sk), getfrag,
from, length, transhdrlen, flags);
路径三:ip_make_skb() —— 现代 UDP 的快速路
上面提到的 ip_append_data + ip_push_pending_frames 组合,虽然在逻辑上很清晰(先攒,后发),但在多核时代有个大问题:锁。
为了保护 sk->sk_write_queue 这个队列,传统的 UDP 发送路径往往需要拿着 socket 锁。这在多核高并发场景下是个瓶颈。
于是,内核在 2.6.39 引入了新的 API:ip_make_skb()。
它的设计思路是:能不能不触碰 socket 的公共队列?
它像是一个「临时工」。当不需要 UDP_CORK(不需要攒包)时,它在本地栈上或者临时的空间里把 SKB 拼好,封装好,然后直接交给 ip_send_skb() 发送。
旧路径:
UDP → (加锁) → ip_append_data → 塞入队列 → (解锁) → ip_push_pending_frames → 发送
新路径:
UDP → ip_make_skb (在本地构建) → ip_send_skb → 发送
这样新路径完全绕过了那个需要加锁的队列,这就是所谓的「无锁发送快速路径」。
离开本地
无论你走的是哪条路,最终这些殊途同归的数据包都会流向同一个出口:ip_local_out()(或者在 raw socket 情况下直接调用 dst_output)。
下一站,我们将面临一个很现实的问题:如果这个包裹太大了,快递员(网卡)扛不动怎么办?
这就是下一节的主题:分片。
在进入分片之前,你可以先想一想:既然 TCP 层已经尽量避免交给 IP 层大于 MSS 的数据包了,那么是谁在触发 IP 层的分片?除了分片,还有没有别的办法?我们下一节见。