跳到主要内容

11.3 UDP (User Datagram Protocol)

还记得上一节我们在 msghdr 结构里看到的那些字段吗?msg_iov 存数据,msg_control 存辅助信息。当时你可能觉得这只是一堆枯燥的数据结构定义。

现在,这些结构要开始真正干活了。

我们先把最复杂的东西(TCP)放一放,从传输层里最「简单」的协议开始——UDP(User Datagram Protocol)。之所以说它简单,是因为它几乎没做任何额外的事情:不保证数据到达,不保证顺序,甚至不保证连接存在。它就像是 IP 层上面的一层极薄的包装纸,只加了个「端口号」的概念。

但也正因为它的简单,它是理解内核网络栈数据流向的最佳切入点。


UDP 协议概览与头部结构

UDP 协议早在 1980 年就以 RFC 768 的形式定稿了。它的设计哲学是「尽力而为」。很多对实时性要求高、但对丢包不那么敏感的应用层协议都跑在 UDP 上,比如 VoIP 中常用的 RTP(Real-time Transport Protocol)。音频视频丢几帧也就是画面花一下或者声音卡一下,总比为了重传而延迟几秒钟要好。虽然 RFC 4571 说 RTP 也能跑在 TCP 上,但那是为了应对防火墙穿透等特殊场景的权宜之计,并非主流。

扩展阅读:UDP-Lite 你可能会遇到一个叫 UDP-Lite 的东西(RFC 3828)。它是 UDP 的一个变种,允许只对数据包的部分内容做校验和(Partial Checksum)。这在某些无线场景下很有用,因为那怕数据部分错了,只要头部是对的,我们也愿意收下来处理。 它的大部分实现复用了 UDP 的代码,主要逻辑在 net/ipv4/udplite.c,但在 udp.c 里你也常能见到它的影子。

不管普通 UDP 还是 UDP-Lite,它们的头部长度都是固定的 8 字节。内核里定义如下(include/uapi/linux/udp.h):

struct udphdr {
__be16 source; // 源端口
__be16 dest; // 目的端口
__be16 len; // 长度(包含头部)
__sum16 check; // 校验和
};

图 11-1:IPv4 UDP 头部结构 (此处展示源端口、目的端口、长度和校验和各占 16 位的布局)

虽然头部长度只有 8 字节,但为了把这 8 个字节正确地填进去并发送出去,内核需要在启动时做不少准备工作。


初始化:把自己注册到内核

UDP 协议要想工作,得先在内核的两个核心表格里「登记注册」。

1. 注册到网络层协议表

首先,内核定义了一个 udp_protocol 对象。这是一个 net_protocol 结构,它的作用是告诉网络层(IP 层):「嘿,如果收到了协议号为 IPPROTO_UDP 的包,请调用我这里的 handler 回调函数」。注册动作发生在系统初始化的 inet_init() 函数中。

static const struct net_protocol udp_protocol = {
.handler = udp_rcv, // 收包处理函数
.err_handler = udp_err, // 错误处理函数(如ICMP报错)
.no_policy = 1,
.netns_ok = 1, // 支持网络命名空间
};

inet_init() 中,通过 inet_add_protocol() 把它加入全局的 inet_protos 数组:

static int __init inet_init(void)
{
...
if (inet_add_protocol(&udp_protocol, IPPROTO_UDP) < 0)
pr_crit("%s: Cannot add UDP protocol\n", __func__);
...
}

2. 注册到 Socket 操作表

光有收包入口还不够,用户空间的程序是通过 socket()sendmsg() 这些系统调用来跟内核打交道的。内核需要知道:当用户创建了一个 SOCK_DGRAM 类型的 socket 后,具体的操作函数(比如 .sendmsg)应该映射到哪里。

这是通过 proto_register(&udp_prot, 1) 来完成的。udp_prot 结构体里存满了回调函数指针:

struct proto udp_prot = {
.name = "UDP",
.close = udp_lib_close,
.connect = ip4_datagram_connect,
.disconnect = udp_disconnect,
.ioctl = udp_ioctl,
.setsockopt = udp_setsockopt,
.getsockopt = udp_getsockopt,
.sendmsg = udp_sendmsg, // 重点!
.recvmsg = udp_recvmsg,
.sendpage = udp_sendpage,
...
};

Note: UDP 协议连同其他核心协议,都是在启动时通过 inet_init() 方法完成初始化的。

初始化做完后,万事俱备。接下来,我们看看当用户调用 sendmsg 发送数据时,内核里到底发生了什么。


发送数据包:udp_sendmsg 的深度漫游

当用户空间程序调用 send()sendmsg() 发送 UDP 数据时,最终都会落脚到内核的 udp_sendmsg() 函数。

int udp_sendmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg, size_t len)
{

这里我们要引入一个非常有用的概念:UDP_CORK(软木塞)

默认情况下,UDP 包是「即发即走」的——你给一个 10 字节的数据,它就立刻发一个 10 字节的 IP 包。这在大多数情况下没问题。但如果你需要把多次小的写操作合并成一个大的 UDP 包发出去(比如应用层分块组装数据),就需要用到这个机制。

有两种方式开启这个行为:

  1. 设置 UDP_CORK 的 socket 选项(Kernel 2.5.44 引入)。
  2. sendmsg 的 flags 里带上 MSG_MORE 标志。

udp_sendmsg 开头,内核会先检查是否需要「塞住」瓶口:

int corkreq = up->corkflag || msg->msg_flags & MSG_MORE;
struct inet_sock *inet = inet_sk(sk);
...

接下来是例行检查。比如数据长度 len 不能超过 65535 字节。为什么?因为 UDP 头部的 len 字段只有 16 位,最大只能表示 65535。

if (len > 0xFFFF)
return -EMSGSIZE;

现在,我们需要知道发给谁。这涉及确定目标地址和目标端口,以便构建路由查找所需的 flowi4 对象。目标端口绝对不能是 0,这是 IANA 的规定(早在 RFC 1010 时期就定了)。

这里分两种情况:

情况 A:直接指定目标地址 用户在 msg->msg_name 里传了 sockaddr_in 结构。

if (msg->msg_name) {
struct sockaddr_in *usin = (struct sockaddr_in *)msg->msg_name;
if (msg->msg_namelen < sizeof(*usin))
return -EINVAL;
if (usin->sin_family != AF_INET) {
if (usin->sin_family != AF_UNSPEC)
return -EAFNOSUPPORT;
}

daddr = usin->sin_addr.s_addr;
dport = usin->sin_port;

// 目标端口为 0 是非法的
if (dport == 0)
return -EINVAL;

情况 B:使用已连接的 Socket 如果用户没有在 msg_name 里指定地址,那这个 Socket 之前必须调用过 connect()。此时 Socket 的状态会被标记为 TCP_ESTABLISHED(注意:UDP 用这个状态并不代表它真的像 TCP 那样建立了连接,仅仅表示它已经指定了默认的对端地址,通过了一些内核检查)。

} else {
if (sk->sk_state != TCP_ESTABLISHED)
return -EDESTADDRREQ;

daddr = inet->inet_daddr;
dport = inet->inet_dport;
/* 开启已连接 Socket 的快路径 */
connected = 1;
}
...

处理辅助数据

还记得上一节提到的 msg_control 吗?这里就要用上了。用户可以通过它传递 Ancillary Data(辅助数据)

辅助数据其实是一串 cmsghdr 结构(详见 man 3 cmsg)。通过它,你可以做一些普通参数做不到的事情,比如在一个未连接的 UDP Socket 上指定源地址(使用 IP_PKTINFO)。

如果 msg_controllen 不为 0,内核就会调用 ip_cmsg_send() 来解析这些消息,并构建出一个 ipcm_cookie 结构。

struct ipcm_cookie {
__be32 addr; // 指定的源地址等
int oif; // 出接口索引
struct ip_options_rcu *opt; // IP 选项
__u8 tx_flags; // 传输标志
};

代码逻辑如下:

if (msg->msg_controllen) {
err = ip_cmsg_send(sock_net(sk), msg, &ipc);
if (err)
return err;
if (ipc.opt)
free = 1;
connected = 0;
}
...
if (connected)
rt = (struct rtable *)sk_dst_check(sk, 0);
...

路由查找

如果 Socket 的缓存路由项 (rt) 为空,说明还没查过路由(或者路由缓存失效了)。这时候就需要构建 flowi4 对象,并调用 ip_route_output_flow() 去查路由表。

if (rt == NULL) {
struct net *net = sock_net(sk);

fl4 = &fl4_stack;
flowi4_init_output(fl4, ipc.oif, sk->sk_mark, tos,
RT_SCOPE_UNIVERSE, sk->sk_protocol,
inet_sk_flowi_flags(sk) | FLOWI_FLAG_CAN_SLEEP,
faddr, saddr, dport, inet->inet_sport);

security_sk_classify_flow(sk, flowi4_to_flowi(fl4));
rt = ip_route_output_flow(net, fl4, sk);
if (IS_ERR(rt)) {
err = PTR_ERR(rt);
rt = NULL;
if (err == -ENETUNREACH)
IP_INC_STATS_BH(net, IPSTATS_MIB_OUTNOROUTES);
goto out;
}
...

发送路径:快与慢

Kernel 2.6.39 引入了一个重要的优化:无锁发送快路径

快路径:如果没有开启 UDP_CORK,也就是不需要攒包,那就没必要去拿那个沉重的 Socket 锁(lock_sock)。直接调用 ip_make_skb() 构建好 SKB,然后 udp_send_skb() 发走。

/* 无需上锁的快路径 */
if (!corkreq) {
skb = ip_make_skb(sk, fl4, getfrag, msg->msg_iov, ulen,
sizeof(struct udphdr), &ipc, &rt,
msg->msg_flags);
err = PTR_ERR(skb);
if (!IS_ERR_OR_NULL(skb))
err = udp_send_skb(skb, fl4);
goto out;
}

慢路径:如果开启了 corkreq(软木塞),我们需要上锁,因为涉及到状态维护(比如累积长度)。

lock_sock(sk);
do_append_data:
up->len += ulen;

接着调用 ip_append_data()。这个函数并不直接发送,而是把数据拷贝到内核的缓存队列里(sk_write_queue)。最后,当攒够了或者取消了 CORK 选项,再调用 udp_push_pending_frames() 真正触发发送和分片。

err = ip_append_data(sk, fl4, getfrag, msg->msg_iov, ulen,
sizeof(struct udphdr), &ipc, &rt,
corkreq ? msg->msg_flags | MSG_MORE : msg->msg_flags);

如果中间出错了,必须把积攒在队列里的 SKB 全部冲掉,否则内存就泄露了。

if (err)
udp_flush_pending_frames(sk); // 释放 sk_write_queue
else if (!corkreq)
err = udp_push_pending_frames(sk); // 真正发送
else if (unlikely(skb_queue_empty(&sk->sk_write_queue)))
up->pending = 0;
release_sock(sk);

发送流程就像是在寄信:要么你写一封扔邮筒一个(快路径),要么你把好几封信攒在一起,打个包再叫快递员(慢路径/CORK)。


接收数据包:从网络层到 Socket

发送看完了,现在看接收。当网络层(L3)收到一个 UDP 数据包时,它会调用我们在初始化时注册的 udp_rcv() 函数。这个函数非常简单,只是个二传手,直接调 __udp4_lib_rcv()

int udp_rcv(struct sk_buff *skb)
{
return __udp4_lib_rcv(skb, &udp_table, IPPROTO_UDP);
}

让我们深入 __udp4_lib_rcv()

首先,从 SKB 中把 UDP 头、长度、源地址、目的地址都取出来:

int __udp4_lib_rcv(struct sk_buff *skb, struct udp_table *udptable, int proto)
{
struct sock *sk;
struct udphdr *uh;
unsigned short ulen;
struct rtable *rt = skb_rtable(skb);
__be32 saddr, daddr;
struct net *net = dev_net(skb->dev);
...

uh = udp_hdr(skb);
ulen = ntohs(uh->len);
saddr = ip_hdr(skb)->saddr;
daddr = ip_hdr(skb)->daddr;

如果这事儿是个广播或者组播包,处理逻辑比较特殊,会交给 __udp4_lib_mcast_deliver() 去处理,这里略过不提。

if (rt->rt_flags & (RTCF_BROADCAST | RTCF_MULTICAST))
return __udp4_lib_mcast_deliver(net, skb, uh,
saddr, daddr, udptable);

对于最常用的单播包,内核要干的一件核心事情是:找 Socket

它在 UDP 哈希表(udp_table)里查,通过四元组(源IP、源端口、目的IP、目的端口)去匹配。

sk = __udp4_lib_lookup_skb(skb, uh->source, uh->dest, udptable);
if (sk != NULL) {

如果找到了!这意味着有某个应用程序正在这个端口监听。下一步就是把包放到该 Socket 的接收队列里。

调用 udp_queue_rcv_skb() -> sock_queue_rcv_skb() -> __skb_queue_tail(),把 SKB 挂到 sk->sk_receive_queue 的尾巴上。

int ret = udp_queue_rcv_skb(sk, skb);
sock_put(sk);

/* 返回值 > 0 意味着需要重新提交,但这里返回 -protocol 或 0 */
if (ret > 0)
return -ret;

return 0; // 成功
}
...

如果没找到 Socket 呢?

这说明包虽然到了机器上,但没有应用程序在这个端口监听。这时候不能悄无声息地就丢了(除非校验和错了)。

  1. 校验和检查:如果校验和错误,直接丢包。

    if (udp_lib_checksum_complete(skb))
    goto csum_error;
  2. 发送 ICMP 报错:校验和没问题,说明地址是对的,只是端口没人认。于是内核发送一个 ICMP "Destination Unreachable" (Code 3: Port Unreachable) 给发信方,礼貌地告诉它:「别发了,没人收」。 同时增加 UDP_MIB 的 NoPorts 计数器(可以通过 netstat -s 看到)。

UDP_INC_STATS_BH(net, UDP_MIB_NOPORTS, proto == IPPROTO_UDPLITE);
icmp_send(skb, ICMP_DEST_UNREACH, ICMP_PORT_UNREACH, 0);

/*
* Hmm. We got an UDP packet to a port to which we
* don't wanna listen. Ignore it.
*/
kfree_skb(skb);
return 0;

图 11-2:UDP 接收流程示意图 (此处展示从数据包到达 -> 哈希表查找 -> 找到 Socket 入队 / 找不到发送 ICMP 的流程)

到这里,关于 UDP 的内核之旅就结束了。它是这样直白:写数据、查路由、发;收数据、查 Socket、入队列。

但这只是暴风雨前的宁静。下一节,我们要面对网络协议里最复杂的怪兽——TCP。那里有状态机,有复杂的超时重传,还有让你头秃的拥塞控制。