一、再谈端口号

端口号port用于标识了一个主机上进行通信的不同的应用程序。
在这里插入图片描述
TCP/IP协议中,使用“源IP”,“源端口号”,“目的IP”、“目的端口号”、“协议号” 这样一个五元组来标识一个通信(可以通过指令netstat -n查看)。

在这里插入图片描述
关于端口号的划分,在一般情况下,0~1023用于表示知名端口号,例如HTTPFTPSSH等这些广为人知的应用层协议,它们的端口号都是固定的。而1024~65535端口号则为操作系统动态分配的端口号。客户端进程的端口号,就是由操作系统从这个范围随机分配的。
当然,针对服务端来讲,也会存在一些知名的端口号在1024~65535这个范围以内,例如我们熟知的mysql数据库,就是固定绑定在3306端口号。

对于一些知名端口号,我们需要对它们有一定的了解,例如:

协议 端口号
HTTP 80
HTTPS 443
TELNET 23
FTP 21
SSD 22

事实上,我们可以在我的Linux机器上面执行此指令cat /etc/services,就可以看到知名端口号。我们在日常写项目时,一定要避开这些端口号。在这里插入图片描述
最后,我们回答两个问题来彻底解决端口号相关的问题:

问题1️⃣:在同一台主机上,一个端口号是否可以被多个进程bind?

是绝对不行的,端口号属于一个key值,一次只能够被一个进程bind。

问题2️⃣:在同一台主机上,一个进程能否bind多个端口号?

是可以的。一个进程bind多个端口号,这是Socket编程中的常规操作,在学习Socket编程的过程中,我们也能够亲身感受到。一个进程可以创建多个套接字,分别绑定到不同的端口上,同时监听或收发数据。

二、UDP协议🌟🌟

2.1、UDP报文格式

在这里插入图片描述
UDP报文的结构非常简单,由16位源端口号16位目的端口号16位UDP长度16位UDP校验和以及数据段,5部分组成。

基于这个报文格式,我们能够阐明以下几个问题:

问题1️⃣:为什么Socket编程中,端口号的类型为uint16呢??

因为内核中的UDP协议是这么规定的(TCP协议同理)。

问题2️⃣:UDP协议如何解决“报头与有效载荷分离的问题”以及“有效载荷的分用问题”呢??

我们在网络基础部分中便提出过这两个问题,这是每一个协议都必须面对的问题。
从上图中我们可以看出,UDP协议采用8Byte固定长度的报头。数据报可以非常轻易地被操作系统解包。其中,报头中含有16位UDP长度的字段,用于标识整个UDP报文的长度,谈股票简单的减法运算可以很轻易的得到数据部分的长度。
至于有效载荷的分用问题,操作系统可以依靠报头中16位目的端口号字段,将有效载荷顺利地交付给对应的进程。

问题3️⃣:如何理解UDP报头的本质呢??

在网络基础部分中我们曾说过,协议报头本质就是一个结构体变量。传输层与网络层是内嵌在操作系统中的,而操作系统又是由C语言写的,因此我们很轻易地能够得出结论,UDP报头本质就是一个C语言实现的结构体。下图是Linux内核的部分源码:
在这里插入图片描述

问题4️⃣:如何理解封装的过程??🌟🌟

这个问题我们曾经也在网络基础部分详细讲过,这里基于UDP协议再次宏观地理解它!!在下一个问题的解答中我们还会继续探讨这个问题。

在正式封装报文之前,内核专门提供一个缓冲区,用于封装报文,随后便根据每一层不同的协议进行下图操作:
在这里插入图片描述
以上过程便是一个协议的封装封装的本质就是结构体变量的拷贝

问题5️⃣:如何理解报文??重新理解封装与解包的过程。🌟🌟

首先我们得想明白一个问题,在同一时间内,操作系统内部是否可能存在多个报文??答案是一定的。我们以日常生活为例,一个人可以在电脑上与朋友聊天的同时下载文件、听歌、刷短视频,这样的操作必然会从网络中获取大量的报文,而操作系统的处理能力又有限,所以一定会存在大量报文逗留在操作系统中,这些报文可能正在被处理,也可能在等待被处理。因此,这又不得不回到一个老生常谈的问题上了——操作系统内部存在这么多的报文,操作系统需不需要对其进行管理,答案是肯定的:先描述,再组织!!于是,Linux操作系统中便用这样一个结构体来描述struct sk_buff{}这是linux-2.6.34.5的内核源码,不同版本的可能略微不同

struct sk_buff
{
	/* These two members must be first. */
	struct sk_buff *next;
	struct sk_buff *prev;

	ktime_t tstamp;

	struct sock *sk;
	struct net_device *dev;

	/*
	 * This is the control buffer. It is free to use for every
	 * layer. Please put your private variables there. If you
	 * want to keep them across layers you have to do a skb_clone()
	 * first. This is owned by whoever has the skb queued ATM.
	 */
	char cb[48] __aligned(8);

	unsigned long _skb_dst;
#ifdef CONFIG_XFRM
	struct sec_path *sp;
#endif
	unsigned int len,
		data_len;
	__u16 mac_len,
		hdr_len;
	union
	{
		__wsum csum;
		struct
		{
			__u16 csum_start;
			__u16 csum_offset;
		};
	};
	__u32 priority;
	kmemcheck_bitfield_begin(flags1);
	__u8 local_df : 1,
		cloned : 1,
		ip_summed : 2,
		nohdr : 1,
		nfctinfo : 3;
	__u8 pkt_type : 3,
		fclone : 2,
		ipvs_property : 1,
		peeked : 1,
		nf_trace : 1;
	kmemcheck_bitfield_end(flags1);
	__be16 protocol;

	void (*destructor)(struct sk_buff *skb);
#if defined(CONFIG_NF_CONNTRACK) || defined(CONFIG_NF_CONNTRACK_MODULE)
	struct nf_conntrack *nfct;
	struct sk_buff *nfct_reasm;
#endif
#ifdef CONFIG_BRIDGE_NETFILTER
	struct nf_bridge_info *nf_bridge;
#endif

	int skb_iif;
#ifdef CONFIG_NET_SCHED
	__u16 tc_index; /* traffic control index */
#ifdef CONFIG_NET_CLS_ACT
	__u16 tc_verd; /* traffic control verdict */
#endif
#endif

	kmemcheck_bitfield_begin(flags2);
	__u16 queue_mapping : 16;
#ifdef CONFIG_IPV6_NDISC_NODETYPE
	__u8 ndisc_nodetype : 2;
#endif
	kmemcheck_bitfield_end(flags2);

	/* 0/14 bit hole */

#ifdef CONFIG_NET_DMA
	dma_cookie_t dma_cookie;
#endif
#ifdef CONFIG_NETWORK_SECMARK
	__u32 secmark;
#endif
	union
	{
		__u32 mark;
		__u32 dropcount;
	};

	__u16 vlan_tci;

	sk_buff_data_t transport_header;
	sk_buff_data_t network_header;
	sk_buff_data_t mac_header;
	/* These elements must be at the end, see alloc_skb() for details.  */
	sk_buff_data_t tail;
	sk_buff_data_t end;
	unsigned char *head, *data;
	unsigned int truesize;
	atomic_t users;
};

早期的内核源码中,sk_buff 中直接使用三个联合体union指针来指向不同的协议头,这样处理使得代码非常直观,处理代码的可读性高。例如,在接收或构造 TCP 报文时,可以直接通过skb->h.th来访问 TCP 头部,易于理解和使用。这也是我们上一个问题中图示的思想。但缺点是,在 64 位系统上,一个指针占 8 字节,三个指针就是 24 字节,所用空间太大。为了节省这宝贵的结构体空间,并提升缓存效率,于是便对其进行优化,得到我们上文的源码。它将原来的指针替换为了偏移量,这样不仅仅节省了内存开销,而且计算更加灵活。
在这里插入图片描述
根据上文所述,我们从内核源码的角度来看封装与解包,能够得到一个结论:封装与解包的核心过程就是data指针的移动。

问题6️⃣:当我们收到一个报文,我们如何做到用文件原理,读数据到应用层的??🌟🌟

在用户的角度来看,仅需调用read()rece()recefrom()系统调用,即可读取到网络数据。那么在这之下,内核又为我们做了什么呢,和我们之前讲的struct sk_buff{}又有什么关系呢??

内核中存在这样一个套接字结构体struct socket{},其中包含一个struct sock* sk指向更加底层的套接字结构体,在对应结构体中又存在着两个队列sk_write_queue(发送缓冲区)sk_receive_queue(接受缓冲区),这两个缓冲区队列分别用于存放即将发送的报文和接收到的报文,也就是说,这两个缓冲区就是用于存放struct sk_buff{}的!!!
Linux下,我们创建的socket套接字本质就是一个文件描述符,因此我们对网络文件进行读写时必然逃不开我们Linux文件系统。通过socket我们可以找到当前进程下的struct socket{},而其中的struct file* file指针指向对应的struct file{}结构体,通过内置的IO接口,从sk_receive_queue中读取sk_buff,将数据存放拷贝到的用户级缓冲区中,这样就相当于交给了当前进程的应用层了。光看文字可能会比较抽象,结合以下图文思考
在这里插入图片描述


2.2、UDP协议特点

  1. 无连接:知道对端的IP和端口号就直接进行传输,不需要建立连接。
  2. 不可靠:无确认机制、重传机制,如果因为网络故障某段数据无法发送给对方,UDP协议层也不会给应用层返回任何错误信息。
  3. 面向数据报:不能够灵活的控制读写数据的次数和数量。应用层交给UDP多长的报文,UDP原样发送,既不会拆分,也不会合并,也因此,UDP协议不存在数据报粘包问题😋。例如使用UDP发送100字节的数据,如果发送端调用一次sendto(),发送100个字节,那么接收端也必须调用对应的一次recvfrom(),接收100个字节,而不能循环调用10次recvfrom(),每次只接收10个字节。

2.3、UDP的缓冲区

UDP没有真正意义上的发送缓冲区。调用sendto()会直接将报文交给内核,由内核将数据传给网络层协议进行后续的传输动作。
虽然UDP不像TCP协议有真正的发送缓冲区,但是UDP还是具有接收缓冲区的。但是这个接收缓冲区不能保证收到的UDP报的顺序和发送UDP报的顺序一致,此外如果缓冲区满了,再到达的UDP数据就会被丢弃。这里也能够体现UDP协议不可靠的特性。

很多人会非常疑惑,既然UDP协议都不可靠了,为什么还需要它呢🤔🤔??
我们应该纠正一个观念,UDP协议的 “不可靠”属于它的特点,而不是缺点!! 不可靠并不意味着不可用。不可靠意味着更简单,即UDP发送报文时工作量减少了,对比TCP在传送报文前还需要进行建立连接、传送报文途中还有大量保证可靠性的机制,UDP明显更加灵活轻便。
此外,UDP无法保证可靠性,并不意味着应用层不会保证,我们程序员在编写应用层代码时,可以自行添加可靠性机制,这种做法常出现在游戏之中,例如LOL、王者荣耀等这种极其追求低延迟的游戏。


2.4、UDP使用的注意事项

我们注意到,UDP协议首部中有一个16位的最大长度。也就是说一个UDP能传输的数据最大长度是64K(包含UDP首部)。这在当当今的互联网环境下绝对是一个非常小的数字。 因此,如果我们需要传输的数据超过64K,就需要在应用层手动的分包,然后多次发送,并在接收端手动拼装。


完🍟🍟🍟

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐