Skip to content

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):

c
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() 函数中。

c
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 数组:

c
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 结构体里存满了回调函数指针:

c
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() 函数。

c
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 开头,内核会先检查是否需要「塞住」瓶口:

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

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

c
    if (len > 0xFFFF)
             return -EMSGSIZE;

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

这里分两种情况:

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

c
    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 那样建立了连接,仅仅表示它已经指定了默认的对端地址,通过了一些内核检查)。

c
    } 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 结构。

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

代码逻辑如下:

c
    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() 去查路由表。

c
    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() 发走。

c
    /* 无需上锁的快路径 */
    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(软木塞),我们需要上锁,因为涉及到状态维护(比如累积长度)。

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

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

c
    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 全部冲掉,否则内存就泄露了。

c
    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()

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

让我们深入 __udp4_lib_rcv()

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

c
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() 去处理,这里略过不提。

c
    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、目的端口)去匹配。

c
    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 的尾巴上。

c
        int ret = udp_queue_rcv_skb(sk, skb);
        sock_put(sk);
 
        /* 返回值 > 0 意味着需要重新提交,但这里返回 -protocol 或 0 */
        if (ret > 0)
             return -ret;
 
        return 0; // 成功
    }
    ...

如果没找到 Socket 呢?

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

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

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

c
    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。那里有状态机,有复杂的超时重传,还有让你头秃的拥塞控制。

基于 VitePress 构建