UDP
- 前言
- 正式开始
- UDP报文
- UDP报文
- 如何将UDP报文和报头进行分离和封装
- UDP如何将有效载荷交付给上层
- 如何提取出完整报文
- 报头是啥
- 报头中的检验和
- UDP的特点
- IO接口
- 乱序问题
- UDP是全双工的
- 注意事项
- 基于UDP的应用层协议
- 再次谈论端口
- 五元组
- 端口号范围划分
- netstat
- xargs
前言
本篇比较偏理论。基于我前面一篇UDP实践的博客进行补充:【网络】网络编程入门篇——了解接口,快速上手,带你手搓简易UDP服务器和客户端(简易远端shell、简易群聊功能以及跨平台群聊)
主要讲解内容有:
- UDP协议报头讲解
- UDP协议缓冲区、注意事项等
- 五元组
- netstat命令讲解
正式开始
UDP报文
下面根据以下内容进行讲解:
- 任何协议都要解决两个问题:
a. 如何分离/封装报头和报文
b. 如何交付有效载荷 - 理解UDP报文本身
- 详谈具体报文字段
UDP报文
很简单,就这张图:
加点东西:
上面的8字节就是报头,存储了四个字段,每个字段两个字节。
很多教材上都有这张图,但是有的书上详细讲了,有的书没有讲,这里我来讲讲。
如何将UDP报文和报头进行分离和封装
采用定长报头的策略,UDP的报头为8字节的固定长度报头,对端传输层只需要将前8个字节提取出来,剩下的就是有效载荷。(等会说怎么提有效载荷)
无论是我前面博客中HTTP中用空行来区分报头和有效载荷,还是这里的用定长报头来区分报头和有效载荷,都是协议,双方都要严格遵守,那一层没有遵守哪一层就是一个无用的报文,无法向上交付。
UDP如何将有效载荷交付给上层
提取出报头后,其中有一个字段为目的端口号:
就通过这个目的端口号进行向上交付,因为进程bind了端口号,我前面写服务端的时候每次端口类型都是uint16_t,即2字节,因为协议中端口号是16位的。
如何提取出完整报文
先读固定长度的报头,报头中有16位的UDP长度字段:
这个16位的udp长度就是整个报文的长度,假如说是x,整个报文长度为x,报头长度位8,那么有效载荷的长度就是x - 8。读完报头后,再往后读x - 8个字节就行。
所以UDP具有将报文一个一个正确接收的能力。
报头是啥
报头其实就是一个结构体:
struct udp_hdr
{
uint32_t src_port:16;
uint32_t dst_port:16;
uint32_t udp_len:16;
uint32_t udp_check:16;
}
用结构体实现位段,如果你不懂位段的话可以看看我这篇博客:【C进阶】自定义类型。
再看一下这张图:
这里为什么要四字节为一行,因为结构体中用的类型是uint32_t,也就是4个字节,这里4个字节就是报头的宽度,所有的报头其实就是一个结构体类型。
封装一个报头,当应用层发来一个报文(就是传输层的有效载荷)时,传输层只需要定义一个udp_hdr对象,将对象中的字段填好,源端口、目的端口、报文长度(应用层的报文大小 + 8)、检验和,然后将这个对象中的数据拷贝到内核的缓冲区中就行。看图:
向上交付的时候就定义指针就行,报文长度和报头长度都有了,就根据这两个长度读取就行。
这里只是简单的原理,os真正做起来的话要比这复杂的多,毕竟要考虑大小端、平台位数等问题,还是很复杂的。
报头中的检验和
这个检验和不细讲了,说一下有啥用。
接收方UDP检验成功就向上交付,检验失败报文就直接丢弃了,发送方不知道、不重传、不关心。
UDP的特点
其实我前面那篇UDP的博客中也讲过了,这里就再说一下。
- 无连接: 知道对端的IP和端口号就直接进行传输, 不需要建立连接;
如果你看了我前面手搓UDP服务器和TCP服务器的话,你一定懂这句话是什么意思。
因为UDP的服务器只需要这几步:
- 创建套接字
- bind绑定端口号
- recvfrom接收客户端请求并用sendto进行响应
而TCP的服务器是这样的:
- 创建套接字
- bind绑定端口号
- 设置套接字为listen状态
- 接收连接
- recv/read获取客户端请求并通过write/send进行响应
这里UDP要比TCP少了listen和接收连接这两步,也就是UDP不需要建立连接。
- 不可靠: 没有确认机制, 没有重传机制; 如果因为网络故障该段无法发到对方, UDP协议层也不会给应用层返回任何错误信息;
下面我截一下前面我UDP博客中的一段话:
屏幕前的你先不要拿这一点来判断谁好谁坏。TCP和UDP都能够存在说明是都能被接受的,各有各的特色。
先说点UDP不好的,UDP会出现丢包问题,TCP不会出现。不过现在的网络出现丢包问题概率并不大,即使出现了大部分场景下也是可以容忍的。先不要对UDP产生偏见,等会讲的时候你就知道为啥了。
二者的可不可靠只是二者的特点。并不是单方的缺点。
TCP比UDP可靠,那么TCP就要做更多的工作,可靠性是要靠大量的编码和数据处理工作来实现的,所以TCP想要保证可靠性就一定会使得其在数据通信时为了可靠性而设计出更多的策略,如面向连接、确认应答、超时重传、流量控制、拥塞控制等机制,而这些机制都要靠TCP协议自己来完成,所以说虽然保证了可靠性,但是也就导致了该协议更复杂,维护起来成本更高。此时UDP就可以对TCP说:咋俩都是协议,你把自己搞那么累干嘛,丢包就丢包了,心放大点,这都不是事,所以UDP只要把数据交给下层就行了,然后就啥也不用管了。而TCP就得管一大堆事,即TCP更安全,UDP更简单。
但是我们大部分情况下传输层协议用的是TCP。像直播、视频这种数据可以用UDP,比如我们有时候看着直播 / 视频时突然卡一下/没声了/屏幕花了等等问题,就可能是因为传输层用的是UDP协议导致丢包了,但是这样就卡一两秒对整体的观看体验影响不大(足球比赛快要进球的时候卡了当我没说😅)。不过有钱的公司也在直播或视频这种资源上也可以用TCP协议,不过TCP协议花费更多,但是能够保证数据的安全,客户能有更好的体验。
- 面向数据报: 不能够灵活的控制读写数据的次数和数量
IO接口
前面TCP和UDP中IO的接口本质上都是拷贝函数。
TCP的 write、send和read、recv。
UDP的 sendto和recvfrom。
都是拷贝函数。
所以这里的wirte、send、sendto都不是直接将数据发送到网络当中的,而是将数据拷贝到了内核缓冲区中就完事了,后续工作都是os做的。
TCP和UDP是传输层中应用最广泛的协议,而发送缓冲区(UDP不需要,等会说)和接收缓冲区是由传输层提供的,传输层决定什么时候发,发多少,出错了怎么办的问题,我们无法决定这些问题,就像寄快递一样,你只是把东西交给了快递公司,至于快递啥时候发是快递公司的事情,不是我们操心的。
UDP不需要发送缓冲区,调用sendto只是将数据拷贝给os,os会立即将数据经过网络层、数据链路层发送出去,所以对于发送缓冲区没有特别强的需求。
但是UDP需要接收缓冲区,UDP上面是应用层,应用层就是用户,也就是程序员,需要手动调用recvfrom,但是如果程序员来不及调用呢?同一时刻服务端可能会接收到很多的UDP报文请求,如果某些报文来不及接收,就会先放到接收缓冲区中,所以UDP需要接收缓冲区,但如果缓冲区满了,那么后面到来的报文会被直接丢弃。
乱序问题
如果某一时刻客户端发送了5个报文,如果是按照ABCDE的顺序来发的话,可能会出现接收到的报文为BDCEA的顺序,就像我们在某宝上下单一样,同一天下的单,后面收到的时间不一样,顺序也不一样。
UDP乱序问题通常是因为路由之间的存储转发引起的。解决方法是在发送端的数据段中加入数据报序号的方式,只需要在接收方对数据的头端进行简单排序处理即可。具体步骤如下:
- 在发送端的数据段中加入数据报序号。
- 接收方接收到数据后,将数据报文按照序号进行排序。
- 将排序后的数据报文进行组装,得到完整的数据。
需要注意的是,如果数据报文丢失,可能会导致数据报文序号不连续,因此需要在接收方设置一个超时时间,如果在超时时间内没有收到连续的数据报文,则需要重新请求发送方发送数据报文。
不过这里的做法已经很像TCP了,下一篇博客我会详细讲解TCP。
UDP是全双工的
一个文件描述符,再多线程场景下既可以读也可以写,如何保证呢?
只需要读写缓冲区不冲突就行。
UDP虽然没有发送缓冲区,但是发送时并不会影响接收缓冲区,所以支持全双工。
至于全双工和半双工是啥我实在是不想讲了,前面博客中说了好几次了,不懂的同学搜一下或者翻一下我前面的博客吧。
后一篇要讲的TCP也是全双工的。
注意事项
我们注意到, UDP协议首部中有一个16位的最大长度. 也就是说一个UDP能传输的数据最大长度是64K(包含UDP首部)。然而64K在当今的互联网环境下, 是一个非常小的数字。如果我们需要传输的数据超过64K, 就需要在应用层手动的分包, 多次发送, 并在接收端手动拼装。
基于UDP的应用层协议
- NFS: 网络文件系统
- TFTP: 简单文件传输协议
- DHCP: 动态主机配置协议
- BOOTP: 启动协议(用于无盘设备启动)
- DNS: 域名解析协议
当然, 也包括你自己写UDP程序时自定义的应用层协议;
说说DHCP,当你的电脑没连网的时候,没法上网,本质上是你的电脑没有IP,当连接上WiFi的时候(或者其他能连网方法),电脑会自动获取一个IP地址,这个IP是路由器给的,路由器内部署了DHCP服务,关了电脑之后IP会被自动回收。
再次谈论端口
再我前面写UDP和TCP服务器时,都要让服务端bind一个端口号,客户端也要绑定,但是不需要我们手动绑定,os会自动帮我们做。当本机拿到数据后向上交付时,要根据端口号交付给特定进程。
系统是如何快速找到端口号对应的进程的呢?
用哈希,可以理解为K值就是端口号,V值就是进程ID,要把有效载荷提供给应用层,就要找到进程的文件描述符,通过文件描述符把有效载荷拷贝到通信的文件中即可,找到文件描述符就先找到进程PCB就行,就这样通过PID找PCB,然后通过PCB找文件描述符。
五元组
在TCP/IP协议中, 用 “源IP”, “源端口号”, “目的IP”, “目的端口号”, “协议号” 这样一个五元组来标识一个通信。
源IP和源端口号标定发送方的唯一进程。
目的IP和目的端口号标定接收方唯一进程。
协议号可以理解为标定传输层用的是哪一个协议,通常就是TCP或UDP。
这样的一个五元组就可以标定一个通信:
端口号范围划分
端口号是一个16位的二进制数,所以取值范围为0~65535。
0 - 1023: 知名端口号, HTTP, FTP, SSH等这些广为使用的应用层协议, 他们的端口号都是固定的.
就像是110就是警察,120就是急救,119就是火警一样。
有些服务器是非常常用的, 为了使用方便, 人们约定一些常用的服务器, 都是用以下这些固定的端口号:
- ssh服务器, 使用22端口
ftp服务器, 使用21端口
telnet服务器, 使用23端口
http服务器, 使用80端口
https服务器, 使用443端口
/etc/services里面存放了常见端口号。
不要随意绑定0 ~ 1023的端口号。
1024 - 65535: 操作系统动态分配的端口号. 客户端程序的端口号, 就是由操作系统从这个范围分配的。我们自己也能选择其中的进行绑定。像我前面我在搞服务器的时候端口号用的基本上都是8080。
netstat
netstat是一个用来查看网络状态的重要工具.
语法:netstat [选项]
功能:查看网络状态
常用选项:
- n 拒绝显示别名,能显示数字的全部转化成数字
- l 仅列出有在 Listen (监听) 的服務状态
- p 显示建立相关链接的程序名
- t (tcp)仅显示tcp相关选项
- u (udp)仅显示udp相关选项
- a (all)显示所有选项,默认不显示LISTEN相关
这里查看的时候带的选项是natp。
n就是能显示成数字的就显示成数字,看第二行的sshd,如果我这里去掉n:
就变成了ssh,上面将协议固定端口的时候也说了,ssh的端口就是22。
a就是把状态为listen的也显示,我去掉a之后:
t就是显示tcp的服务,t换成u就是显示udp的服务:
p就是显示进程和其PID,也就是最后面的字段,去掉:
这里udp在接收服务的时候不会进行listen,至于什么原因我前面的博客中有,代码写过就知道了。
再看回来前面的这张图:
来看看sshd进程:
这个进程是一个守护进程(前面博客讲过,不懂点链接:【网络】用代码讲解协议 + 序列化和反序列化 + 守护进程 + jsoncpp),大部分以d结尾的进程一般都是守护进程(d就是daemon),三个ID相同,父进程是1os。
我这里用的是云服务器,登录的时候sshd会在终端为我开一个bash进程,并让我输入指令,输入后就将字符串发送给远在异地(北京、广东等)的服务器执行,并将执行后的结果返回。
xargs
这是一个命令行上的东西,可以将标准输入的内容转为命令行参数。
比如说我用pidof来获取一个进程的pid,然后用管道 + args交给kill命令,就可以关掉这个进程。
kill命令想使用的话必须得是命令行参数来搞,管道传输的数据是通过文件(标准输入)来实现的,没法直接给成命令行参数:
这里管道会将pidof的结果转成标准输入送给kill -9,但是kill -9执行的时候是将各个字段通过命令行参数来交给进程的,标准输入和命令行参数没有啥关系,无法直接执行kill命令,但是xargs会将标准输入的转成命令行参数,这样就可以传给进程执行了。
再比如说ls | xargs touch可以更新所有文件的ACM时间,改成最新的时间:
UDP比较简单,没有什么好讲的,你只要能写出我前面简易UDP服务器,懂本篇所讲的UDP报头就行。
到此结束。。。