文章目录
- 一、传输层(运输层)
- 运输层的特点
- 复用和分用
- 再谈端口号
- 端口号范围划分
- 认识知名端口号(Well-Know Port Number)
- 两个问题
- ① 一个进程是否可以绑定多个端口号?
- ② 一个端口号是否可以被多个进程绑定?
- netstat命令
- pidof命令
- 二、UDP协议
- UDP协议格式
- 理解报头
- UDP的特点
- 面向数据报
- UDP的缓冲区
- ① 为什么UDP只有接收缓冲区,而没有发送缓冲区?
- ② 如何理解缓冲区?
- 操作系统中的缓冲区管理
- UDP使用的注意事项
- 基于UDP的应用层协议
一、传输层(运输层)
运输层的特点
运输层向它上面的应用层提供通信服务
我们知道,IP协议能够把源主机A发送出的分组,按照首部中的目的地址,送交到目的主机B。
都已经送到目标主机了,为什么还需要运输层呢?对IP层来说,通信的两端是两台主机。IP数据报的首部明确地标志了这两台主机的IP地址。但“两台主机之间的通信”这种说法还不够明确。真正进行通信的实体是在主机中的哪个构件呢?是主机中的应用进程,是一台主机中的应用进程和另一台主机中的应用进程在交换数据。
因此严格地讲,两台主机进行网络通信的本质是两台主机中的应用的进程之间互相通信,端到端的通信是应用进程之间的通信。IP协议虽然能把分组送到目的主机,但是这个分组还停留在主机的网络层而没有交付主机中的应用进程。也就是说,网络层的IP协议只是解决了数据包从哪台主机发送到哪台主机,而并没有具体指出是从哪个进程到哪个进程。而运输层做的工作正是负责将数据从发送端进程传输到接收端的进程。
一句话总结:
网络层为主机之间的通信提供服务,而运输层则在网络层的基础上,为应用进程之间的通信提供服务。
我们还应指出,运输层向高层用户屏蔽了下面网络核心的细节(如网络拓扑、所采用的路由选择协议等),它使应用进程看见的就是好像在两个运输层实体之间有一条端到端的逻辑通信信道,但这条逻辑通信信道对上层的表现却因运输层使用的不同协议而有很大的差别:
- 当运输层采用面向连接的TCP协议时,尽管下面的网络是不可靠的(只提供尽最大努力服务),但这种逻辑通信信道就相当于一条全双工的可靠信道:
- 但当运输层采用无连接的UDP协议时,这种逻辑通信信道仍然是一条不可靠信道。
复用和分用
在一台主机中经常有多个应用进程同时分别和另一台主机中的多个应用进程通信。例如,某用户在使用浏览器查找某网站的信息时,其主机的应用层运行浏览器客户进程。如果在浏览网页的同时,还要用电子邮件给网站发送反馈意见,那么主机的应用层就还要运行电子邮件的客户进程。在图5-1中,主机A的应用进程AP1和主机B的应用进程AP3通信,而与此同时,应用进程AP2也和对方的应用进程AP4通信。
这表明运输层有两个个很重要的功能:
- 复用(multiplexing)
- 分用(demultiplexing)
这里的“复用”是指在发送方不同的应用进程都可以使用同一个运输层协议传送数据(当然需要加上适当的首部),而“分用”是指接收方的运输层在剥去报文的首部后能够把这些数据正确交付目的应用进程。
图5-1中两个运输层之间有一个深色双向粗箭头,写明“运输层提供应用进程间的逻辑通信”。“逻辑通信”的意思是:从应用层来看,只要把应用层报文交给下面的运输层,运输层就可以把这报文传送到对方的运输层,好像这种通信就是沿水平方向直接传送数据(因为传输层帮它屏蔽了底层的通信细节)。但事实上这两个运输层之间并没有一条水平方向的物理连接。数据的传送是沿着图中的虚线方向(经过多个层次)传送的。
再谈端口号
端口号(Port)标识了一个主机上进行通信的不同的应用程序
在TCP/IP协议中,用“源IP地址”,“源端口号”,“目的IP地址”,“目的端口号”,“协议号”这样一个五元组来标识一个通信。因为网络通信的本质是网络中的两个进程在通信,用一组IP地址唯一标识两台主机,用一组端口号就可以唯一标识两台主机上各自的一个进程了。
比如有多台客户端主机同时访问服务器,这些客户端主机上可能有一个客户端进程,也可能有多个客户端进程,它们都在访问同一台服务器:
通过netstat
命令可以查看到这样的五元组信息:
在 netstat -nltp
命令的输出中,“Local Address” 和 “Foreign Address” 列分别表示本地地址和远程地址。
- Local Address 中的
0.0.0.0
:服务器在所有本地网络接口上监听指定端口。 - Foreign Address 中的
0.0.0.0
和*
:服务器可以接受来自任何远程地址和端口的连接。
端口号范围划分
端口号的长度是16位,因此端口号的范围是0 ~ 65535:
- 0 ~ 1023:知名端口号。比如HTTP,FTP,SSH等这些广为使用的应用层协议,它们的端口号都是固定的。
- 1024 ~ 65535:操作系统动态分配的端口号。客户端程序的端口号就是由操作系统从这个范围分配的。
认识知名端口号(Well-Know Port Number)
有些服务器是非常常用的,为了使用方便,人们约定一些常用的服务器,都是用以下这些固定的端口号:
服务 | 协议 | 端口号 |
---|---|---|
SSH | TCP | 22 |
FTP | TCP | 21 |
Telnet | TCP | 23 |
HTTP | TCP | 80 |
HTTPS | TCP | 443 |
下面的路径,可以看到知名端口号:
/etc/services
我们自己写一个程序使用端口号时,要避开这些知名端口号。
两个问题
① 一个进程是否可以绑定多个端口号?
是的,一个进程可以绑定多个端口号。通常情况下,这可以通过创建多个套接字(socket)并分别绑定到不同的端口来实现。以下是实现这个功能的一种常见方法:
- 创建多个套接字: 每个套接字绑定到不同的端口。
- 在每个套接字上监听连接: 进程可以在多个端口上监听传入的连接。
例如,一个 HTTP 服务器可以同时监听 80 端口和 8080 端口。
② 一个端口号是否可以被多个进程绑定?
通常情况下,一个端口号不能被多个进程同时绑定。如果一个进程已经绑定了一个特定端口号,其他进程将无法再绑定同一个端口号,这会导致 Address already in use
错误。这是为了防止端口冲突和数据包混乱。
但是,有一些特例和高级配置可以允许这种情况:
- SO_REUSEADDR 选项: 在某些情况下,两个进程可以设置
SO_REUSEADDR
套接字选项,这允许在某些条件下重新使用同一端口。例如,两个进程在一个端口上同时监听 UDP 数据报(datagram)。 - 端口复用: 在某些负载均衡器和代理服务器的情况下,可以配置端口复用,以便多个进程处理同一端口上的流量,但这通常由一个主进程管理。
总结
- 一个进程可以绑定多个端口号。
- 一个端口号通常不能被多个进程同时绑定,但在某些特例和高级配置下,可以实现端口复用。
这种设计是为了保证网络通信的稳定和数据传输的明确性。
netstat命令
netstat是一个用来查看网络状态的重要工具。
语法:netstat [选项]
功能:查看网络状态
常用选项:
- n 拒绝显示别名,能显示数字的全部转化成数字
- l 仅列出有在 Listen (监听) 的服務状态
- p 显示建立相关链接的程序名
- t (tcp)仅显示tcp相关选项
- u (udp)仅显示udp相关选项
- a (all)显示所有选项,默认不显示LISTEN相关
pidof命令
在查看服务器的进程id时非常方便。
语法:pidof [进程名]
功能:通过进程名,查看进程id
比如我们可以查看自己写的服务器,通过进程的名称查询pid,进程名称就是可执行程序的名称:
二、UDP协议
UDP协议格式
- 16位源端口号:表示数据从哪里来。
- 16位目的端口号:表示数据要到哪里去。
- 16位UDP长度:表示整个数据报(UDP首部+UDP数据)的长度。
- 16位UDP检验和:如果UDP报文的检验和出错,就会直接将报文丢弃。
UDP如何将报头与有效载荷进行分离?
UDP的报头当中只包含四个字段,每个字段的长度都是16位,总共8字节。因此UDP采用的实际上是一种定长报头,UDP在读取报文时读取完前8个字节后剩下的就都是有效载荷了。
UDP如何决定将有效载荷交付给上层的哪一个协议?
UDP上层也有很多应用层协议,因此UDP必须想办法将有效载荷交给对应的上层协议,也就是交给应用层对应的进程。内核中用哈希的方式维护了端口号与进程ID之间的映射关系,因此传输层可以通过端口号快速找到其对应的进程ID,进而找到对应的应用层进程。
理解报头
如何理解UDP的报头呢?
Linux操作系统是C语言写的,而UDP协议又是属于内核协议栈的,因此UDP协议也一定是用C语言编写的,UDP报头实际就是一个C语言位段类型(或使用短整型):
展开就是:
struct udphdr {
unsigned short source; //源端口号
unsigned short dest; //目的端口
unsigned short len; //UDP长度
unsigned short check; //检验和
};
复习一下C语言结构体中的位段(Bit-Fields)
位段是结构体的一部分,它允许我们在结构体中定义和访问比普通数据类型更小的位。位段通常用于需要直接访问特定位的场景,例如硬件寄存器编程、协议解析等。
位段不是一种独立的类型,而是结构体成员的一种特殊声明方式。
定义位段
位段在结构体中定义,语法如下:
struct { unsigned int field_name : bit_width; };
unsigned int
或signed int
:位段的类型(通常为无符号整型)。field_name
:位段的名称。bit_width
:位段的位宽,指定该字段占用的位数。
示例
下面是一个简单的例子,演示如何定义和使用位段:
#include <stdio.h> struct { unsigned int a : 3; // 3 bits for a unsigned int b : 5; // 5 bits for b unsigned int c : 8; // 8 bits for c } myBitField; int main() { myBitField.a = 5; myBitField.b = 17; myBitField.c = 255; printf("a: %u\n", myBitField.a); printf("b: %u\n", myBitField.b); printf("c: %u\n", myBitField.c); return 0; }
在这个示例中,结构体包含三个位段:
a
、b
和c
。位段a
占用3位,b
占用5位,c
占用8位。输出结果如下:a: 5 b: 17 c: 255
注意事项
位段的总宽度和对齐:位段的总宽度不能超过结构体成员的类型宽度。如果需要跨越边界,编译器可能会插入填充位。位段的对齐也需要注意,不同编译器的行为可能不同。
可移植性:由于位段的对齐和填充方式依赖于编译器,因此不同编译器之间的位段布局可能不同,位段结构体在不同平台上的可移植性较差。
使用位段的类型:尽量使用无符号类型,因为有符号类型的行为可能不明确,尤其是在跨平台使用时。
位段的访问和操作:由于位段通常需要用来操作具体的位,因此直接操作其值时需要注意位操作的基本原理。
总结
位段是C语言中一种强大的工具,可以高效地管理和操作小于标准数据类型宽度的数据。正确使用位段可以显著提高程序的性能和可读性,但同时也需要注意跨平台的兼容性和编译器的实现细节。
UDP的特点
-
无连接:知道对端的IP和端口号就直接进行传输,不需要建立连接,因此减少了开销和发送数据之前的时延。
-
尽最大努力交付:即不保证可靠交付,因此主机不需要维持复杂的连接状态表,没有确认机制,没有重传机制;如果因为网络故障该段无法发到对方,UDP协议层也不会给应用层返回任何错误信息。
-
面向数据报:不能够灵活的控制读写数据的次数和数量。UDP对应用层交下来的报文,既不合并,也不拆分,而是保留这些报文的边界。这就是说,应用层交给UDP多长的报文,UDP就照样发送,即一次发送一个报文,如图5-3所示。
在接收方的UDP,对IP层交上来的UDP用户数据报,在去除首部后就原封不动地交付上层的应用进程。也就是说,UDP一次交付一个完整的报文。因此,应用程序必须选择合适大小的报文:- 若报文太长,UDP把它交给IP层后,IP层在传送时可能要进行分片,这会降低IP层的效率。
- 若报文太短,UDP把它交给IP层后,会使IP数据报的首部的相对长度太大,这也降低了IP层的效率。
-
UDP没有拥塞控制,因此网络出现的拥塞不会使源主机的发送速率降低。这对某些实时应用是很重要的。很多的实时应用(如IP电话、实时视频会议等)要求源主机以恒定的速率发送数据,并且允许在网络发生拥塞时丢失一些数据,但却不允许数据有太大的时延。UDP正好适合这种要求。
-
UDP支持一对一、一对多、多对一和多对多的交互通信。
-
UDP的首部开销小,只有8个字节,比TCP的20个字节的首部要短。
面向数据报
应用层交给UDP多长的报文,UDP原样发送,既不会拆分,也不会合并。
用UDP传输100个字节的数据,是整体发送整体接收的:
- 如果发送端调用一次
sendto
,发送100个字节,那么接收端也必须调用对应的一次recvfrom,接收100个字节。 - 而不能循环调用10次
recvfrom
,每次接收10个字节。
UDP的缓冲区
- UDP没有真正意义上的发送缓冲区。调用sendto会直接交给内核,由内核将数据传给网络层协议进行后续的传输动作;
- UDP具有接收缓冲区。但是这个接收缓冲区不能保证收到的UDP报的顺序和发送UDP报的顺序一致;如果缓冲区满了,再到达的UDP数据就会被丢弃;
- UDP的socket既能读,也能写,因此UDP是全双工的。
UDP协议本身是不面向连接的,不保证数据的可靠性、顺序性和无重复性。因此,UDP在设计上更加简单和轻量级,主要是为了快速传输数据。但是,UDP仍然需要接收缓冲区来暂存接收到的数据包,避免数据丢失。
① 为什么UDP只有接收缓冲区,而没有发送缓冲区?
-
接收缓冲区的必要性:
- 当UDP接收到数据包时,如果上层应用程序没有及时读取数据包,那么这些数据包需要一个临时的存放地方,以避免数据丢失。接收缓冲区就是用来暂存这些数据包的。
- 如果没有接收缓冲区,数据包可能会在到达时被直接丢弃,因为上层应用程序可能没有及时处理所有的数据包。接收缓冲区的存在可以减少数据丢失,确保尽可能多的数据包能够被上层应用程序接收和处理。
-
发送缓冲区的可选性:
- 虽然UDP协议本身不提供发送缓冲区的概念,但这并不意味着实际实现中没有类似机制。在某些操作系统和网络栈中,可能会为UDP提供一些发送缓冲机制,以便上层应用程序可以快速发送数据而不必等待底层网络接口完全空闲。
- 但是,从协议设计的角度来看,UDP的发送操作是非阻塞的,也就是说,应用程序调用发送函数后,数据包会立即被提交给底层网络栈,网络栈会尽快将数据包发送出去。这种设计减少了延迟,提高了数据传输的实时性。
-
发送数据的处理方式:
- UDP不需要像TCP那样维护连接状态、重传丢失的数据包、保证数据包顺序等复杂的机制。因此,UDP的发送操作非常简单,数据包一旦生成,就会立即尝试发送出去,而不需要在发送缓冲区中等待处理。
- 如果底层网络接口忙碌,发送操作可能会失败(例如,返回错误代码),应用程序可以根据需要重试发送数据或采取其他措施。由于UDP本身不保证数据传输的可靠性,因此发送缓冲区的需求不如接收缓冲区那么强烈。
UDP的设计初衷是为了提供一种简单、快速的传输方式,而不是为了提供可靠的数据传输。接收缓冲区的存在是为了应对数据包到达时应用程序处理不及时的情况,防止数据丢失。而发送缓冲区则不是UDP的核心需求,因为UDP的发送操作本身就是快速、非阻塞的。
② 如何理解缓冲区?
在Linux系统中,网络协议栈处理网络数据包的过程涉及多个缓冲区和数据结构。UDP的缓冲区的本质实际上是sk_buff
结构体对象连接起来的双向链表。
-
sk_buff结构:在Linux内核中,
sk_buff
(socket buffer)结构体用于管理和存储网络数据包。下面是sk_buff
结构体的主要组成部分:-
数据指针:指向实际的数据缓冲区。
-
头部和尾部指针:用于管理数据的头部和尾部,支持在数据包前后动态添加协议头部或其他信息。
-
链表指针:将多个
sk_buff
结构体链接在一起,形成发送或接收队列。 -
控制信息:如网络设备信息、协议信息、时间戳等。
我们目前关心的是指向数据的指针字段:struct sk_buff { struct sk_buff *next; struct sk_buff *prev; unsigned char *head; // 内存块的起始位置 unsigned char *data; // 实际数据的起始位置 unsigned char *tail; // 当前数据的结尾位置 unsigned char *end; // 内存块的末尾位置 ... };
-
-
双向链表:
sk_buff
结构体通过双向链表链接在一起,这张链表就是所谓的“UDP接收缓冲区”:
sk_buff
结构体通过指针指向数据包实际占据的内存。而实际存数据的位置的内存通常是动态分配的,并且各个数据包的内存区域在物理上不必是连续的。
操作系统中的缓冲区管理
因为要进行UDP的网络通信,所以一台机器上可能会存在大量发过来的UDP报文,此时操作系统就不得不对这些连接进行管理。
操作系统在管理这些报文时需要“先描述,再组织”:
- 在操作系统中一定有一个描述连接的结构体,该结构体当中包含了连接的各种属性字段,这个结构体就是上面的
sk_buff
。 - 所有定义出来的连接结构体最终都会以某种数据结构组织起来,这里采用双向链表把
sk_buff
连接起来,此时操作系统对连接的管理就变成了对该双向链表的增删查改。
UDP使用的注意事项
需要注意的是,UDP协议报头当中的UDP最大长度是16位的,因此一个UDP报文的最大长度是64K(包含UDP报头的大小)。
然而64K在当今的互联网环境下,是一个非常小的数字。如果需要传输的数据超过64K,就需要在应用层进行手动分包,多次发送,并在接收端进行手动拼装。
基于UDP的应用层协议
- NFS:网络文件系统。
- TFTP:简单文件传输协议。
- DHCP:动态主机配置协议。
- BOOTP:启动协议(用于无盘设备启动)。
- DNS:域名解析协议。