网络原理——传输层协议UDP

发布于:2025-09-10 ⋅ 阅读:(17) ⋅ 点赞:(0)

传输层协议UDP

学习网络部分的时候,我们并没有一上来就理解一堆的原理,而是通过使用网络的接口,理解网络基础,认识基础协议,通过简单的网络实践来进行学习。
当然,代码也不可能一直写,还是需要了解一下相关的原理的。

所以,本篇文章就从来先了解一下,传输层协议中的UDP协议!

先了解UDP的原因是:
1.我们是先实践UDP的。
2.UDP的原理比TCP简单!

重谈端口号

所谓的网络通信,其实就是不同的两台主机上的两个进程在做进程间通信,只不过说,这一次通信的载体变成了网络!为了标识通信的进程,也需要尽可能地与系统解耦,所以传输层使用了端口号来标识进程:
端口号(Port)标识了一个主机上进行通信的不同的应用程序:
在这里插入图片描述
有了端口号(port) + ip地址,就可以标识出全网唯一进程!

端口号的划分

  • 0 - 1023: 知名端口号, HTTP, FTP, SSH 等这些广为使用的应用层协议:
    他们的都是固定的
  • 1024 - 65535: 操作系统动态分配的端口号。客户端程序的端口号:
    就是由操作系统从这个范围分配的

我们在TCP和UDP实践的时候:
也说过了服务器需要显示绑定具体的端口号!但是,客户端不要显示绑定,需要由操作系统自动分配。原因:1.服务器是具体服务,需要知道具体端口。2.防止客户端的进程端口号冲突!

了解一些知名的端口号:

ssh 服务器, 使用 22 端口
ftp 服务器, 使用 21 端口
telnet 服务器, 使用 23 端口
http 服务器, 使用 80 端口
https 服务器, 使用 443

协议号

虽然说,通过port + ip确实能够标识全网内唯一的一个进程!但是,今天这里,我们需要再加入一个标识,即协议号

什么是协议号呢?简单点说就是一个数字,这个数字代表着具体的协议!


首先,我们以往就有的认知:
所谓网络通信,就是两个相同的网络协议栈在进行通信。
在这里插入图片描述
我们也知道,协议中的每一层,都具有该层的协议!当数据自顶向下传输时候,需要添加报头!这个过程我们叫做封装!当数据自底向上的时候,需要把报头和有效载荷进行分离,这个过程我们叫做解包与分用

我们也知道,向上解包分用的时候,是需要知道具体交付给上一层的什么协议的!而这件事情,就会放在这层解包的报头内:
在这里插入图片描述
所以,今天来讲,我们不仅仅是需要通过源ip地址加目的ip地址、源端口号加目的端口号来标识一个网络的通信,还需要再加一个协议号!

这样子,就能很清晰的知道:
网络中的存在的一个通信,起始地点和终点,以及它们的通信协议!

理解UDP协议

UDP协议的格式

我们一直在说,每一层协议有对应的报头,我们来看看UDP的报头格式:
在这里插入图片描述
在网络协议栈中,所有的报头都需要关注以下两个问题:
1.报头和有效载荷之间如何分离?
2.如何分用?

我们来看看UDP的报头是如何做的:
分离
我们发现,在UDP协议的时候,报头和数据是分离开来的!而且固定了报头的大小为8字节
那么,读取报头后,包头中的一个字段:16为UDP长度(UDP 首部+UDP 数据),这个是标识整个UDP报文的长度的!所以,这个时候就能够很轻松的分离了:先读取报头(8byte),然后通过对应的字段,得到整个报文的长度,从而可以计算出有效载荷的大小,然后分开读取即可!

分用
现在已经可以把对应的报头和数据分离了,如何进行分用呢?
报头中有对应的源端口号和目的端口号!通过特定的方法读取出这些字段,存储起来。
如我们可以通过对应的接口recvfrom中的输出型参数struct sockaddr* srcaddr来获取到对应的发送信息的端口号!

或者是拿着设置好的端口号使用sendto接口,然后把数据发送出去!

面向数据报

我们现在来看看,为什么说UDP这个协议是面向数据报的!
原因:固定报头大小 + 16位UDP报文长度 -> UDP的结构大小确定!

我们可以很清楚的从UDP协议的格式来看出:
UDP协议的各个部分边界是清晰的!而且,不同的UDP报文,就算产生粘报,也可以很轻松的将不同的报文分开!因为每个报文都有固定的报头,报头内标识了整个报文的范围。
本质上就是,数据报之间的分界特别清晰!

至于UDP是怎么保证这个边界问题的,我们会在后面UDP的缓冲区部分来说明!

UDP协议结构

协议的本质其实也就是一个个的结构体,我们来看看udp协议对应的结构体:

struct udphdr {
    __u16  source;     // 源端口号(16位)
    __u16  dest;       // 目标端口号(16位)
    __u16  len;        // UDP 数据包长度(头部+数据,单位:字节)
    __u16  check;      // 校验和(覆盖头部和数据,可选)
};

udp的协议非常简单,就是4个16位的字段!

所以,为什么端口号是16位呢?因为这是UDP协议的规定!不管是什么操作系统,只要是想要进行网络通信,都需要遵守这么一个协议的规定(网络基础部分讲过)!

这里我们还需要再最后说一下,校验和的问题
由于网络传输是比较复杂的,又要经过网卡,路由,集线器,有时候又可能因为带宽导致需要传输的数据在网络中可能发生一些错误,比如部分比特位翻转(0 -> 1, 1 -> 0)。

而这个校验和,我们不做过多理解,我们只需要知道一点:
校验和就是用来检测传输的数据是否发生错误的!如果发生错误,就会直接丢弃该数据!


现在还剩下一个关于UDP协议结构体的问题:
我们在自定义协议的时候,传输的数据,接受的数据都是序列化后的一个字节流串!那么,双方在进行网络通信的时候,这个UDP协议的结构体需要进行序列化和反序列化吗?
答案:答案是可以,但没必要!实际上也没有进行序列化和反序列化!

为什么这里不需要呢?反而是自定义协议的时候需要呢?
最大的区别:之前传输的协议结构,在应用层!而今天这个udp协议,在传输层,属于内核!

我们之前说的协议,要进行序列化和反序列化,是因为那是应用层的协议数据!
应用层是有很多种类的:比如不同的平台结构体对齐数不一致、甚至可能是语言不一致!最大的问题就是不同的应用层,不一定有支持结构体的语法!导致跨平台性差!

但是,今天这个UDP协议,它在传输层,它向下传递,经过网卡发送给对方的传输层。我们会发现,这个UDP协议是不会到达应用层的!因为它属于内核!
基本上,市面上的操作系统都是以c开发的,所以,是支持结构体这种语法的。不同平台的内存对齐不一致没关系,这个是很好协调的!所以,对于UDP协议来说,直接把该协议结构以二进制添加到正文的部分是可以的!

因为对方也要进行网络通信,它能认识这个结构体,它也有对应的这个语法!而且,要进行序列化和反序列化,必定导致效率降低。所以,综合上述考量,UDP协议结构直接发二进制的!

UDP缓冲区

在学习自定义协议的时候,我们就已了解过TCP支持全双工的原因了:
在这里插入图片描述
因为TCP是有两对发送<->接收缓冲区的!但是,UDP也支持全双工,那么,UDP也是这样的吗?答案:不是!UDP协议没有发发送缓冲区!

UDP 没有真正意义上的 发送缓冲区. 调用 sendto 会直接交给内核, 由内核将数据传给网络层协议进行后续的传输动作;
UDP 具有接收缓冲区. 但是这个接收缓冲区不能保证收到的 UDP 报的顺序和发送 UDP 报的顺序一致; 如果缓冲区满了, 再到达的 UDP 数据就会被丢弃。


为什么没有发送缓冲区
首先,UDP的数据报,是有很明显的边界的!UDP最大的特点就是面向数据报!直接把整个数据报交给内核,内核是有办法对粘报进行分离的!因为数据报的边界很明显!
不像是TCP,TCP面对的是字节流!TCP其实并不清楚到底发送来的到底是不是刚好一条完整的报文,在TCP眼里看来,这就是一个个的字节!直接交付到发送缓冲区,内核自行决定什么时候发送给对方的接收缓冲区!需要对方的应用层自行对报文完整性做解析!

接收缓冲区作用
但是,UDP是有接收缓冲区的,为什么?
因为很可能,当前上层比较繁忙,还没来得及接受对应的报文!那么,就需要有一个缓冲机制,要不然发过来的报文没办法接收就直接丢掉是很不合理的!
所以,就会有一个接收缓冲区,来接收对应的发来的报文!当然,也是有上限的!这就是一个小的生产者消费者模型,它可以支持忙闲不均!

UDP的特点

1.无连接: 知道对端的 IP 和端口号就直接进行传输, 不需要建立连接。
2.不可靠: 没有确认机制, 没有重传机制; 如果因为网络故障该段无法发到对方, UDP 协议层也不会给应用层返回任何错误信息。
3.面向数据报: 不能够灵活的控制读写数据的次数和数量;

针对于第三点:
在UDP这里,读写其实是比较同步的!这边发送三次,那边就只能读三次!因为有读的基本单位是一个完整的报文!但是,我们需要理解为这是特点,而不是缺点或者优点!


UDP就好比我们寄信,我们不管对方到底有没有时间和我们知会什么时候收信,直接就把信发送过去。如果对方不在家就放在它的信箱内!也可能会越积越多!
这就类似于UDP不建立链接,面向数据报的特点!因为信件也是有很明显的边界区分的。而且,收取信件次数取决于发送信件的次数!

以UDP作为载体理解协议栈报头

现在提出一个问题:
如果应用层正在解析报文,会影响内核接收报文和发送吗?

我们需要结合操作系统的本质来谈:即一款基于中断进行相应的软件!
在学习过操作系统的运行流程后,我们其实就应该知道,这个答案是不会!因为系统会进行一系列的进程调度,切换等操作,都是基于各种中断(如时钟中断、异常中断等)。

在操作系统极快的时间片下,我们大概率感受不到进程的切换的!所以,操作系统不断地调度各个进程,所以,我们基本上可以看作是:应用层和内核是同时在工作!所以不会影响!只不过都需要占用一点时间片罢了!


所以,内核中可能存在一系列的报文(客户端多个),有的要发送,有的要接收,有的要解包…
所以,还是老问题,系统必然要管理这个报文 -> 先描述,再组织!

我们来看看系统是如何描述报文的:

struct sk_buff {
    union {
        struct {
            struct sk_buff *next;   // 指向下一个 skb(用于链表)
            struct sk_buff *prev;   // 指向上一个 skb
        };
        struct rb_node rbnode;     // 红黑树节点(用于某些队列)
    };

    struct sock *sk;               // 关联的 socket
    struct net_device *dev;        // 网络设备(发送/接收的网卡)

    unsigned int len;              // 数据总长度(包括协议头+载荷)
    unsigned int data_len;         // 分片数据的长度
    __u16 mac_len;                 // MAC 头长度
    __u16 hdr_len;                 // 协议头长度

    void *data;                    // 指向当前协议层的数据起始位置
    void *head;                    // 指向数据缓冲区起始地址
    void *tail;                    // 指向数据缓冲区末尾
    void *end;                     // 指向缓冲区结束地址

    unsigned char *headroom;       // 头部预留空间(用于添加协议头)
    unsigned char *tailroom;       // 尾部预留空间(用于扩展数据)

    __u32 priority;                // QoS 优先级
    __u8  pkt_type;                // 包类型(如单播、广播)
    __u8  protocol;                // 协议类型(ETH_P_IP / ETH_P_ARP 等)

    // 校验和、时间戳、引用计数等字段...
    __u16 transport_header;        // 传输层头(如 TCP/UDP)
    __u16 network_header;          // 网络层头(如 IP)
    __u16 mac_header;              // MAC 层头(如以太网帧)

    atomic_t users;                // 引用计数
    unsigned int truesize;         // 缓冲区实际大小

    // 其他字段(省略统计、扩展功能等)...
};

我们可以发现,这个sk_buff中有很多的字段,就是用来描述报文的!
我们需要重点理解的是:sk_buff如何进行解包和封装?如何对其管理?

其实有点类似于虚拟地址空间:
上面sk_buff的,都是一些描述性的,但是,真正的报文其实应该存在内存空间上!因为报文中有具体的数据,具体的字段!

在这里插入图片描述

我们需要重点关注的就是下面这四个指针!
在这里插入图片描述
结合上面展示的sk_buff与内存联系图理解:
head指针和end指针就是分别指向报文数据存储空间的头部和尾部的!

data指针,指向的是整个有效报文的起始位置(即第一个报头的起始位置)。
tail指针,指向的是整个报文的最后一个位置,即正文结束的位置!

我们又知道,协议其实就是结构体!在内核中,直接以二进制结构体拼接到报文头部即可!

所以,什么是封装?什么是解包?
假设我们现在用户层发送数据向下传输(data此时只能指向正文部分):
到传输层,就把传输层的结构体拼接到正文前面,怎么做呢?直接把data指针向上移动,使得data前后差的空间正好满足传输层报头的大小!然后把data指针强转为(如udphdr*),然后填充字段,这不就完成了报头的封装吗?
再往下也是一样的!

解包就更简单了,到哪一层,直接把对应的字段读取出来,然后让data指针向下跳过该层协议报头的位置即可!这个也是十分简单的!

所谓的封装和解包:只不过是data指针在缓冲区移动罢了。
移动的长度,恰好是该层协议报头对应的长度!


管理报文
对于报文的管理,我们肯定能知道:最后转化为对报文所在的数据结构的管理!
这里我们不关心数据结构到底是什么,只要知道有这么一个思想即可!

所以,最后报文向上传输向下传输,我们可以简单地理解为:
对报文进行解包和封装(本质就是移动sk_buff的data指针),然后传输的时候,只需要把该结构体对象从这层的数据结构脱离,进入到要被传输到的层的那个数据结构即可!

UDP使用注意事项

我们注意到, UDP 协议首部中有一个 16 位的最大长度. 也就是说一个 UDP 能传输的数据最大长度是 64K(包含 UDP 首部).。
计算方式:16位 -> 表示216长度 -> 26 * 1024字节 -> 64KB

然而 64K 在当今的互联网环境下, 是一个非常小的数字. 如果我们需要传输的数据超过 64K, 就需要在应用层手动的分包, 多次发送, 并在接收端手动拼装。但是,一般来说,使用UDP的话,数据一般都不会超过64K!而且,单纯使用UDP的情况也比较少了。

基于UDP的应用层协议

NFS: 网络文件系统
TFTP: 简单文件传输协议
DHCP: 动态主机配置协议
BOOTP: 启动协议(用于无盘设备启动) • DNS: 域名解析协议
DNS: 域名解析协议

我们可以在/etc/services这个文件下查看,不同应用层服务对应的是什么协议:
在这里插入图片描述
如上图所示,常见的ssh服务确实是TCP协议,端口号为22!


网站公告

今日签到

点亮在社区的每一天
去签到