目录
前言(必读)
网络字节序
网络中的大小端问题
为什么网络字节序采用的是大端而不是小端?
网络字节序与主机字节序之间的转换
字符串IP和整数IP
整数IP存在的意义
字符串IP和整数IP相互转换的方式
inet_addr函数(会自动将转化出的整数IP从主机字节序变为网络字节序)
inet_ntoa函数(会自动先把从网络中读取到的整数IP从网络字节序转化成主机字节序)
sockaddr、sockaddr_in、sockaddr_un结构体
对sockaddr_in的补充说明
socket编程的常见函数
socket函数
sendto函数
recvfrom函数
bind函数
本地环回地址和INADDR_ANY地址
为什么云服务器上的进程在bind绑定INADDR_ANY后,其他主机就可以通过云服务器的虚拟的ip地址访问该进程了呢?
云服务器上的进程bind绑定云服务器的公网IP失败的问题
注意本文中说明的是套接字socket编程的基础知识点,关于这些知识点的更深入的使用方式和场景,还是得在笔者关于【基于UDP协议的网络服务器的模拟实现】和【基于TCP协议的网络服务器的模拟实现】的文章中才能体现出来
计算机在存储数据时是有大小端的概念的:
如果编写的程序只在本地机器上运行,那么是不需要考虑大小端问题的,因为同一台机器上的数据采用的存储方式都是一样的,要么采用的都是大端存储模式,要么采用的都是小端存储模式。但如果涉及网络通信,那就必须考虑大小端的问题,否则对端主机识别出来的数据可能与发送端想要发送的数据是不一致的。如下图,现在两台主机之间在进行网络通信,其中发送端是小端机,而接收端是大端机。发送端将发送缓冲区中的数据按内存地址从低到高的顺序发出后,接收端从网络中获取数据依次保存在接收缓冲区时,也是按内存地址从低到高的顺序保存的。
但由于发送端和接收端采用的分别是小端存储和大端存储,此时对于内存地址从低到高为44332211的序列,发送端按小端的方式识别出来是0x11223344,而接收端按大端的方式识别出来是0x44332211,此时接收端识别到的数据与发送端原本想要发送的数据就不一样了,这就是由于大小端的偏差导致数据识别出现了错误。
由于我们不能保证通信双方存储数据的方式是一样的,因此网络当中传输的数据必须考虑大小端问题。因此TCP/IP协议规定,网络数据流采用大端字节序,即低地址高字节。无论是大端机还是小端机,都必须按照TCP/IP协议规定的网络字节序来发送和接收数据。
在这个例子中,由于发送端是小端机,因此在发送数据前需要先将数据转成大端,然后再发送到网络当中,而由于接收端是大端机,因此接收端接收到数据后可以直接进行数据识别,此时接收端识别出来的数据就与发送端原本想要发送的数据相同了。
需要注意的是,所有的大小端的转化工作是由操作系统来完成的,因为该操作属于通信细节,不过也有部分的信息需要我们自行进行处理,比如端口号和IP地址。
问题:网络字节序采用的是大端,而主机字节序一般采用的是小端,那为什么网络字节序不采用小端呢,毕竟如果网络字节序采用小端的话,发送端和接收端在发生和接收数据时就不用进行大小端的转换了。
答案:该问题有很多不同说法,下面列举了两种说法:
说法一: TCP在Unix时代就有了,以前Unix机器都是大端机,因此网络字节序也就采用的是大端,但之后人们发现用小端能简化硬件设计,所以现在主流的都是小端机,但协议已经不好改了。
说法二: 大端序更符合现代人的读写习惯。
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,系统提供了四个函数,可以通过调用以下库函数实现网络字节序和主机字节序之间的转换:
(头文件都是#include
函数名当中的h表示host,n表示network,l表示32位长整数,s表示16位短整数。例如htonl表示将32位长整数从主机字节序转换为网络字节序。如果主机是小端字节序,则这些函数将参数做相应的大小端转换然后返回。如果主机是大端字节序,则这些函数不做任何转换,将参数原封不动地返回。
IP地址的表现形式有两种:
192.168.233.123
这种字符串形式的IP地址,叫做基于字符串的点分十进制IP地址。网络传输数据时是寸土寸金的,如果我们在网络传输时直接以基于字符串的点分十进制IP的形式进行IP地址的传送,那么此时一个IP地址至少就需要15个字节,但实际并不需要耗费这么多字节。
IP地址实际可以划分为四个区域,其中每一个区域的取值都是0~255,而这个范围的数字只需要用8个比特位就能表示,因此我们实际只需要32个比特位就能够表示一个IP地址。其中这个32位的整数的每一个字节对应的就是IP地址中的某个区域,我们将IP地址的这种表示方法称之为整数IP,此时表示一个IP地址只需要4个字节。
因为采用整数IP的方案表示一个IP地址只需要4个字节,并且在网络通信也能表示同样的含义,因此在网络通信时就没有用字符串IP而用的是整数IP,因为这样能够减少网络通信时数据的传送。
实际在进行字符串IP和整数IP的转换时,我们不需要自己编写转换逻辑,系统已经为我们提供了相应的转换函数,我们直接调用即可。
函数用于【先将字符串IP转化成整数IP,然后把整数IP从主机字节序转化成网络字节序】,该函数的函数原型如下:
in_addr_t inet_addr(const char *cp);
该函数使用起来非常简单,我们只需传入待转换的字符串IP,该函数返回的就是转换后的整数IP。除此之外,inet_aton函数也可以将字符串IP转换成整数IP,不过该函数使用起来没有inet_addr简单。再次强调,inet_addr会做两个操作,1、将点分十进制字符串变为整数后,2、还会将整数从主机字节序变为网络字节序。
函数用于【先将整数IP从网络字节序转化成主机字节序,然后将主机字节序的整数IP转换成字符串IP】,该函数的函数原型如下:
char *inet_ntoa(struct in_addr in);
需要注意的是,传入inet_ntoa函数的参数类型是in_addr,因此我们在传参时不需要选中in_addr结构当中的32位的成员传入,直接传入in_addr结构体即可。
套接字不仅支持跨网络的进程间通信,还支持本地的进程间通信(域间套接字)。在进行跨网络通信时我们需要传递的端口号和IP地址,而本地通信则不需要,因此套接字提供了sockaddr_in结构体和sockaddr_un结构体,其中sockaddr_in结构体是用于跨网络通信的,而sockaddr_un结构体是用于本地通信的。
为了让套接字的网络通信和本地通信能够使用同一套函数接口,于是就出现了sockeaddr结构体,该结构体与sockaddr_in和sockaddr_un的结构都不相同,但这三个结构体头部的16个比特位都是一样的,这个字段叫做协议家族。
此时当我们在调用sendto、recvfrom或者其他函数需要传参时,就不用传入sockeaddr_in或sockeaddr_un这样的结构体,而统一传入sockeaddr这样的结构体。我们在设置参数时就可以通过设置协议家族这个字段,来表明我们是要进行网络通信还是本地通信,在这些API(即sendto、recvfrom或者其他函数)内部就可以提取sockeaddr结构头部的16位进行识别,进而得出我们是要进行网络通信还是本地通信,然后执行对应的操作。此时我们就通过通用sockaddr结构,将套接字网络通信和本地通信的参数类型进行了统一。(注意实际我们在进行网络通信时,定义的还是sockaddr_in这样的结构体,只不过在调用sendto、recvfrom或者其他函数时,在传参时需要将该结构体的地址类型进行强转为sockaddr*)
问题:读了上一段我们可能会有一个疑问,即为什么没有用void*代替struct sockaddr*类型?我们可以将这些函数的struct sockaddr*参数类型改为void*,此时在函数内部也可以直接指定提取头部的16个比特位进行识别,最终也能够判断是需要进行网络通信还是本地通信,那为什么还要设计出sockaddr这样的结构呢?
答案:实际在设计这一套网络接口的时候C语言还不支持void*,于是就设计出了sockaddr这样的解决方案。并且在C语言支持了void*之后也没有将它改回来,因为这些接口是系统接口,系统接口是所有上层软件接口的基石,系统接口是不能轻易更改的,否则引发的后果是不可想的,这也就是为什么现在依旧保留sockaddr结构的原因。
sockaddr_in结构体的定义如下图右半部分,可以看到struct sockaddr_in中的成员有:
剩下的字段一般不做处理,当然你也可以进行初始化。其中sin_addr的类型是struct in_addr,实际该结构体当中就只有一个成员(如上图左半部分),该成员就是一个32位的整数,IP地址实际就是存储在这个整数当中的。
int socket(int domain, int type, int protocol);
参数说明:
返回值说明:
功能说明:
问题:socket为什么可以具备这样的功能呢?它的底层干了什么?
答案:(结合下图思考)socket函数是被进程所调用的,而每一个进程在系统层面上都有一个进程地址空间PCB(task_struct)、文件描述符表(files_struct)以及对应打开的各种文件。而文件描述符表里面包含了一个数组fd_array成员,其中数组中的0、1、2下标依次对应的就是标准输入、标准输出以及标准错误。
(结合下图思考)当我们调用socket函数创建套接字时,实际相当于我们打开了一个“网络文件”,打开后在内核层面上就形成了一个对应的struct file结构体,同时该结构体被连入到了该进程对应的文件双链表,并将该结构体的首地址填入到了fd_array数组当中下标为3的位置,此时fd_array数组中下标为3的指针就指向了这个打开的“网络文件”,最后3号文件描述符作为socket函数的返回值返回给了用户。
其中每一个struct file结构体中包含的就是对应打开文件各种信息,比如文件的属性信息、操作方法以及文件缓冲区等。其中文件对应的属性在内核当中是由struct inode结构体来维护的;而文件对应的操作方法实际就是一堆的函数指针(比如read*和write*),在内核当中就是由struct file_operations结构体来维护的。
而对于文件缓冲区,OS会为不同的文件都分配一块内存,用于暂时在内存中保存属于文件的数据:
如上图红框处。
参数说明:
返回值说明:
如上图红框处。
参数说明:
返回值说明:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
调用完socket函数成功创建了套接字文件后,需要调用bind函数将【当前进程】和【某个ip与某个port】进行绑定,原因为:
套接字socket文件用于通信,首先,如果想要网络通信,则必须通过网卡,所以你必须得指定从哪个网卡(ip)读取数据送到socket文件,这就是bind ip的原因,数据读取完毕后,送到哪个端口(进程)呢?所以你必须指定一个端口号。
参数说明:
返回值说明:
如果bind函数成功执行,它将返回 0
。这表示套接字成功绑定到指定的地址和端口。
如果bind函数执行失败,它将返回 -1
。这表示绑定操作未成功,并且通常会伴随着设置全局变量errno来指示错误的原因。通过检查bind函数的返回值和检查errno变量的值,你可以确定bind失败的原因,以便进行适当的错误处理。一些常见的bind失败原因包括:1、端口已经被占用:如果指定的端口已经被其他程序占用,bind将失败,并且errno可能会被设置为 EADDRINUSE
。2、无效的地址或端口:如果指定的地址或端口无效,bind也会失败,并且errno的值会指示具体的错误类型。3、权限不足:有些系统可能要求程序拥有特定的权限才能绑定到某些端口,如果权限不足,bind也会失败,并且errno的值可能会指示权限相关的错误。因此,当你调用bind函数时,应该检查其返回值,如果返回值是-1,则通过查看errno的值来确定失败的原因,并根据错误原因进行适当的错误处理。
本地环回地址
本地环回地址就是值为127.0.0.1的ip地址。将服务端server进程的ip地址设置成127.0.0.1后,客户端进程client和服务端进程server收发数据时就只在本地协议栈中进行数据流动,不会把我们的数据发送到网络中。
本地环回的作用:主要用于在本地测试网络服务器,只要将服务端server进程的ip地址设置成127.0.0.1,此后如果客户端向服务端发送信息时,服务端能收到信息,那么这个网络服务器的编写逻辑就有99%的可能性是正确的。在本地测试通过后,如果在网络中测试发现无法正常收发信息,则有99%的可能性是因为网络不好。
INADDR_ANY地址
(说一下,当前进程bind绑定INADDR_ANY地址后,不光可以让其他主机上的进程访问当前进程,是还可以让本地(即本机)上的其他进程访问当前进程的。也就是说当前进程bind绑定INADDR_ANY地址后就涵盖了当前进程bind绑定本地环回地址的功能。)
INADDR_ANY就是指定地址为0.0.0.0的地址,这个地址事实上表示不确定地址,或者说可以表示“所有地址”、“任意地址”。INADDR_ANY是个宏, 一般来说,在各个系统中均定义成为0值,如下图所示。
INADDR_ANY地址的作用:当一台机器的带宽足够大时,一台机器接收数据的能力就决定了这台机器的IO效率,因此一台服务器底层可能装有多张网卡,有几张网卡就有几个ip地址。对于一个进程来说,如果将当前进程和本机的某个固定的ip地址进行bind绑定,那么当前进程只能从这个固定的ip对应的网卡中接收该信息,那么当有网络中的其他主机上的进程想向本机的当前进程发送信息时,只有其他主机上的进程在发送信息时指定向这个固定ip对应的机器(即本机)上的当前进程发送信息,本机上的当前进程才能收到该信息;
而如果本机上的当前进程不和某个固定的ip地址进行bind绑定,而是bind绑定INADDR_ANY地址,那么当前进程可以从本机的任意一个ip对应的网卡中接收该信息,那么以后其他主机上的进程想向本机上的当前进程发送信息时,只要其他主机上的进程在发送信息时指定的ip是属于本机的、指定的端口号port对应的是当前进程,那么不管是从哪个网卡(ip)中收到的数据,都统统交给当前进程,本机上的当前进程都能收到该信息。
因此服务端绑定INADDR_ANY这种方案也是强烈推荐的方案,所有的服务器具体在操作的时候用的也就是这种方案。在编写服务端进程时,除了一些特殊场景,基本都是让服务端进程bind在绑定ip时绑定INADDR_ANY地址。
(说一下,当前进程bind绑定INADDR_ANY地址后,不光可以让其他主机上的进程访问当前进程,是还可以让本地(即本机)上的其他进程访问当前进程的。也就是说当前进程bind绑定INADDR_ANY地址后就涵盖了当前进程bind绑定本地环回地址的功能。)
当进程在云服务器上绑定到INADDR_ANY
后,它会监听在该服务器(即主机)上所有可用的网络接口上的连接请求,包括云服务器的物理网络接口和虚拟网络接口。这是因为INADDR_ANY
表示进程可以接受来自本地或者网络的任何连接请求,而不限制于特定的IP地址。
云服务器通常会有一个虚拟IP地址(或者说公共IP地址),这个IP地址是公开可访问的(但不能被进程bind绑定),所以其他主机可以通过该虚拟IP地址访问云服务器上的进程。当其他主机向云服务器的虚拟IP地址(或者说公共IP地址)发送请求时,云服务器上的进程会接受这些请求,因为该进程已经绑定到INADDR_ANY
,可以接受来自来自本地或者网络的任何连接请求。
这种方式使得云服务器上的进程可以被外部主机访问,这对于提供公共服务或多网卡服务器来说非常有用。总之,绑定到INADDR_ANY
的进程可以接受来自所有可用网络接口的连接请求,包括云服务器的虚拟IP地址所在的网络接口,从而允许其他主机通过该虚拟IP地址访问它。
说一下,如下图,云服务器上的进程是无法bind绑定云服务器的公网IP的,只能bind绑定【本地回环ip地址127.0.0.1】和【ip地址0.0.0.0】。为什么呢?因为云服务器上的公网IP实际上是厂商虚拟出来的,并不是真正的公网IP,当然无法bind成功了。
有人可能会说【既然云服务器上的进程不能绑定云服务器的公网IP,那我就bind绑定一个普通主机的公网IP】,这里笔者想说的是:因为权限问题或者其他原因,本机上的进程如果绑定其他主机的ip一般都是绑定失败,并且就算绑定成功也没有意义,因为本机上的进程绑定了其他主机的ip,那么给本机发送信息的进程就不会把信息发到本机上,而是发到了其他主机上,而其他主机压根不会搭理这个信息,最终就导致本应该通信的双方进程压根就无法正常通信。