目录
前言(必读)
网络字节序
网络中的大小端问题
为什么网络字节序采用的是大端而不是小端?
网络字节序与主机字节序之间的转换
字符串IP和整数IP
整数IP存在的意义
字符串IP和整数IP相互转换的方式
inet_addr函数(会自动将转化出的整数IP从主机字节序变为网络字节序)
inet_ntoa函数(会自动先把从网络中读取到的整数IP从网络字节序转化成主机字节序)
sockaddr、sockaddr_in、sockaddr_un结构体
对sockaddr_in的补充说明
socket编程的常见函数
socket函数
sendto函数
recvfrom函数
不要混淆socket套接字和文件的概念
bind函数(以及需要进行bind的原因)
listen函数
accept函数
本地环回地址和INADDR_ANY地址
为什么云服务器上的进程在bind绑定INADDR_ANY后,其他主机就可以通过云服务器的虚拟的ip地址访问该进程了呢?
云服务器上的进程bind绑定云服务器的公网IP失败的问题
前言(必读)
注意本文中说明的是套接字socket编程的基础知识点,关于这些知识点的更深入的使用方式和场景,还是得在笔者关于【基于UDP协议的网络服务器的模拟实现】和【基于TCP协议的网络服务器的模拟实现】的文章中才能体现出来
网络字节序
网络中的大小端问题
计算机在存储数据时是有大小端的概念的:
- 大端模式: 数据的高字节内容保存在内存的低地址处,数据的低字节内容保存在内存的高地址处。
- 小端模式: 数据的高字节内容保存在内存的高地址处,数据的低字节内容保存在内存的低地址处。
如果编写的程序只在本地机器上运行,那么是不需要考虑大小端问题的,因为同一台机器上的数据采用的存储方式都是一样的,要么采用的都是大端存储模式,要么采用的都是小端存储模式。但如果涉及网络通信,那就必须考虑大小端的问题,否则对端主机识别出来的数据可能与发送端想要发送的数据是不一致的。如下图,现在两台主机之间在进行网络通信,其中发送端是小端机,而接收端是大端机。发送端将发送缓冲区中的数据按内存地址从低到高的顺序发出后,接收端从网络中获取数据依次保存在接收缓冲区时,也是按内存地址从低到高的顺序保存的。
但由于发送端和接收端采用的分别是小端存储和大端存储,此时对于内存地址从低到高为44332211的序列,发送端按小端的方式识别出来是0x11223344,而接收端按大端的方式识别出来是0x44332211,此时接收端识别到的数据与发送端原本想要发送的数据就不一样了,这就是由于大小端的偏差导致数据识别出现了错误。
由于我们不能保证通信双方存储数据的方式是一样的,因此网络当中传输的数据必须考虑大小端问题。因此TCP/IP协议规定,网络数据流采用大端字节序,即低地址高字节。无论是大端机还是小端机,都必须按照TCP/IP协议规定的网络字节序来发送和接收数据。
- 如果发送端是小端,需要先将数据转成大端,然后再发送到网络当中。
- 如果发送端是大端,则可以直接进行发送。
- 如果接收端是小端,需要先将接收到数据转成小端后再进行数据识别。
- 如果接收端是大端,则可以直接进行数据识别。
在这个例子中,由于发送端是小端机,因此在发送数据前需要先将数据转成大端,然后再发送到网络当中,而由于接收端是大端机,因此接收端接收到数据后可以直接进行数据识别,此时接收端识别出来的数据就与发送端原本想要发送的数据相同了。
需要注意的是,所有的大小端的转化工作是由操作系统来完成的,因为该操作属于通信细节,不过也有部分的信息需要我们自行进行处理,比如端口号和IP地址。
为什么网络字节序采用的是大端而不是小端?
问题:网络字节序采用的是大端,而主机字节序一般采用的是小端,那为什么网络字节序不采用小端呢,毕竟如果网络字节序采用小端的话,发送端和接收端在发生和接收数据时就不用进行大小端的转换了。
答案:该问题有很多不同说法,下面列举了两种说法:
说法一: TCP在Unix时代就有了,以前Unix机器都是大端机,因此网络字节序也就采用的是大端,但之后人们发现用小端能简化硬件设计,所以现在主流的都是小端机,但协议已经不好改了。
说法二: 大端序更符合现代人的读写习惯。
网络字节序与主机字节序之间的转换
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,系统提供了四个函数,可以通过调用以下库函数实现网络字节序和主机字节序之间的转换:
(头文件都是#include <arpa/inet.h>)
- uint32_t htonl(uint32_t hostlong);
- uint16_t htons(uint16_t hostshort);
- uint32_t ntohl(uint32_t netlong);
- uint16_t ntohs(uint16_t netshort);
函数名当中的h表示host,n表示network,l表示32位长整数,s表示16位短整数。例如htonl表示将32位长整数从主机字节序转换为网络字节序。如果主机是小端字节序,则这些函数将参数做相应的大小端转换然后返回。如果主机是大端字节序,则这些函数不做任何转换,将参数原封不动地返回。
字符串IP和整数IP
IP地址的表现形式有两种:
- 字符串IP:类似于
192.168.233.123
这种字符串形式的IP地址,叫做基于字符串的点分十进制IP地址。 - 整数IP:IP地址在进行网络传输时所用的形式,用一个32位的整数来表示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相互转换的方式
inet_addr函数(会自动将转化出的整数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、还会将整数从主机字节序变为网络字节序。
inet_ntoa函数(会自动先把从网络中读取到的整数IP从网络字节序转化成主机字节序)
函数用于【先将整数IP从网络字节序转化成主机字节序,然后将主机字节序的整数IP转换成字符串IP】,该函数的函数原型如下:
char *inet_ntoa(struct in_addr in);
需要注意的是,传入inet_ntoa函数的参数类型是in_addr,因此我们在传参时不需要选中in_addr结构当中的32位的成员传入,直接传入in_addr结构体即可。
sockaddr、sockaddr_in、sockaddr_un结构体
套接字不仅支持跨网络的进程间通信,还支持本地的进程间通信(域间套接字)。在进行跨网络通信时我们需要传递的端口号和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的补充说明
sockaddr_in结构体的定义如下图右半部分,可以看到struct sockaddr_in中的成员有:
- sin_family:表示协议家族。
- sin_port:表示端口号,是一个16位的整数。
- sin_addr:表示IP地址,是一个32位的整数。
剩下的字段一般不做处理,当然你也可以进行初始化。其中sin_addr的类型是struct in_addr,实际该结构体当中就只有一个成员(如上图左半部分),该成员就是一个32位的整数,IP地址实际就是存储在这个整数当中的。
socket编程的常见函数
UDP和TCP共通的常见函数
socket函数
int socket(int domain, int type, int protocol);
参数说明:
- domain:创建套接字的域或者叫做协议家族,也就是创建套接字的类型。该参数就相当于struct sockaddr结构的前16个位。如果是本地通信就设置为AF_UNIX,如果是网络通信就设置为AF_INET(IPv4)或AF_INET6(IPv6)。
- type:创建套接字时所需的服务类型。其中最常见的服务类型是SOCK_STREAM和SOCK_DGRAM,如果是基于UDP的网络通信,我们采用的就是SOCK_DGRAM,叫做用户数据报服务,如果是基于TCP的网络通信,我们采用的就是SOCK_STREAM,叫做流式套接字,提供的是流式服务。
- protocol:创建套接字的协议类别。你可以指明为TCP或UDP,但该字段一般直接设置为0就可以了,设置为0表示的就是默认,此时会根据传入的前两个参数自动推导出你最终需要使用的是哪种协议。
返回值说明:
- 套接字创建成功返回一个文件描述符,创建失败返回-1,同时错误码会被设置。
功能说明:
- 说简单点就是创建了一个文件,并返回了一个指向该文件的文件描述符,之后我们就可以在当前进程中向这个文件里写入数据并向网络中发送,或者从网络中读取数据到这个文件里并再将数据从文件中读到当前进程里。
问题: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会为不同的文件都分配一块内存,用于暂时在内存中保存属于文件的数据:
- 比如在当前情景下,网络文件的文件缓冲区就是OS为网络文件分配的一块内存,用于在内存中暂时保存属于该网络(对应某台主机上的某个进程)的数据,之后会根据属于网络文件的文件缓冲区的刷新策略,将文件缓冲区中的数据刷新到OS为网卡文件分配的一块内存(即网卡文件的缓冲区)上,网卡缓冲区再根据自己的刷新策略将数据刷新到内核,再由内核刷新到网卡设备上,网卡就可以根据自己的刷新策略向网络中发送信息了。
- 再比如普通磁盘文件的文件缓冲区就是OS为磁盘文件分配的一块内存,用于在内存中暂时保存属于磁盘设备(或者说磁盘文件)的数据,之后会根据属于磁盘文件的文件缓冲区的刷新策略,将文件缓冲区中的数据刷新到内核,再由内核刷新到磁盘设备(或者说磁盘文件)上,这就完成了一次写入磁盘的操作。
bind函数(以及需要进行bind的原因)
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
调用完socket函数成功创建了套接字文件后,需要调用bind函数将【当前进程】和【某个ip与某个port】进行绑定,原因为:
- (结合下图思考)现在套接字已经创建成功了,但作为一款服务器来讲,如果只是把套接字创建好了,那我们也只是在系统层面上打开了一个文件,操作系统将来并不会知道是要将该文件的数据写入到磁盘还是刷到网卡,因为此时该文件还没有与网卡或者说网络关联起来。
-
套接字socket文件用于通信,首先,如果想要网络通信,则必须通过网卡,如果是作为收信息的一方,则你必须得指定从哪个网卡(ip)读取数据送到socket文件,这就是bind ip的原因,数据读取完毕后,送到哪个端口(进程)呢?所以你必须指定一个端口号,这是bind port的原因;如果是发信息的一方,则你必须得指定把哪个端口(port)对应的进程中的数据发送到socket文件中,这是bind port的原因,进程中的数据到了socket文件中后,你得指定从哪个网卡(ip)将socket文件中的数据送到网络中,这是bind ip的原因。
参数说明:
- int sockfd。发信息需要一个通信通道,这个通道为【当前进程--->sockfd指向的文件的文件缓冲区(即分配给该文件的某块内存)--->内核缓冲区--->网卡--->网络--->对方的网卡--->对方的内核缓冲区--->对方的sockfd指向的文件的文件缓冲区--->对方的进程】。接收信息需要一个通信通道,这个通道为【对方进程--->对方进程的sockfd指向的文件的文件缓冲区(即分配给该文件的某块内存)--->对方的内核缓冲区--->对方的网卡--->网络--->当前主机的网卡--->当前主机的内核缓冲区--->当前进程的sockfd指向的文件的文件缓冲区--->当前的进程】,可以看到在收或者发信息时,sockfd指向的文件是作为通信通道的一环的,所以调用bind函数需要sockfd就是在告诉bind函数,我需要将哪个文件设置成通信通道的一环。
- const struct sockaddr *addr。bind函数用于将【当前进程】和【某个ip与某个port】进行绑定,ip和port信息就包含在addr指向的sockaddr对象中。
- socklen_t addrlen。为上一个参数addr指针指向的sockaddr对象的大小,传入sizeof(上一个参数addr指针指向的sockaddr对象)即可。
返回值说明:
-
如果bind函数成功执行,它将返回
0
。这表示套接字成功绑定到指定的地址和端口。 -
如果bind函数执行失败,它将返回
-1
。这表示绑定操作未成功,并且通常会伴随着设置全局变量errno来指示错误的原因。通过检查bind函数的返回值和检查errno变量的值,你可以确定bind失败的原因,以便进行适当的错误处理。一些常见的bind失败原因包括:1、端口已经被占用:如果指定的端口已经被其他程序占用,bind将失败,并且errno可能会被设置为EADDRINUSE
。2、无效的地址或端口:如果指定的地址或端口无效,bind也会失败,并且errno的值会指示具体的错误类型。3、权限不足:有些系统可能要求程序拥有特定的权限才能绑定到某些端口,如果权限不足,bind也会失败,并且errno的值可能会指示权限相关的错误。因此,当你调用bind函数时,应该检查其返回值,如果返回值是-1,则通过查看errno的值来确定失败的原因,并根据错误原因进行适当的错误处理。
基于UDP协议的socket编程的常见函数
sendto函数
如上图红框处。
参数说明:
- int sockfd,sendto函数是向某台机器上的某个进程发信息,发信息需要一个通信通道,这个通道为【当前进程--->sockfd指向的文件的文件缓冲区(即分配给该文件的某块内存)--->内核缓冲区--->网卡--->网络--->对方的网卡--->对方的内核缓冲区--->对方的sockfd指向的文件的文件缓冲区--->对方的进程】,所以也就能够理解sockfd这个参数的作用了,即给sendto函数提供文件描述符,以找到其指向的文件的缓冲区,提供通信的媒介。
- void *buf,sendto函数是向某台机器上的某个进程发信息,那么需要发出的信息是什么呢?buf指针指向的数据就是这个待发的信息。buf的类型是void*,方便sendto发送不同种类的信息。
- size_t len,sendto函数是向某台机器上的某个进程发信息,len就用于指定发送多大长度的信息。注意这个len不一定是实际发送信息的长度,只是用户指定并期望发这么多,如果用户指定发送的长度远远大于了buf指针指向数据的长度,那实际只会发送buf指针指向数据的长度个数据。实际发送的数据的长度可以通过sendto的返回值获取。
- int flags,设置为0即可,不必关心。
- const struct sockaddr *dest_addr,sendto函数是向某台机器上的某个进程发信息,向哪台机器和哪个进程发送就是通过dest_addr指针(dest即destination,翻译为目的地)指向的sockaddr对象决定的,sockaddr对象里包含了标识目标主机的ip地址和标识目标进程的端口号port。
- socklen_t addrlen就是上一个指针参数指向的sockaddr对象所占的空间大小,传入sizeof(sockaddr对象即可)。
返回值说明:
- 在参数中已经隐含了该信息,即表示当前进程实际发送给对方进程的数据的长度。如果发生错误,返回值为-1。
recvfrom函数
如上图红框处。
参数说明:
- int sockfd。recvfrom函数是用于接收某台机器上的某个进程发过来的信息,接收信息需要一个通信通道,这个通道为【对方进程--->对方进程的sockfd指向的文件的文件缓冲区(即分配给该文件的某块内存)--->对方的内核缓冲区--->对方的网卡--->网络--->当前主机的网卡--->当前主机的内核缓冲区--->当前进程的sockfd指向的文件的文件缓冲区--->当前的进程】,所以也就能够理解sockfd这个参数的作用了,即给recvfrom函数提供文件描述符,以找到其指向的文件的缓冲区,提供通信的媒介。
- void *buf。recvfrom函数是用于接收某台机器上的某个进程发过来的信息,那么需要接收的信息需要存在哪里呢?buf指针指向的这块空间就用于存放这个接收到的信息。buf的类型是void*,方便recvfrom接收不同种类的信息。
- size_t len。recvfrom函数是用于接收某台机器上的某个进程发过来的信息,len就用于指定接收多大长度的信息。注意这个len不一定是实际接收信息的长度,只是用户指定并期望接收这么多,如果用户指定接收的长度远远大于了buf指针指向空间所能容纳的最大长度,那实际只会接收buf指针指向空间的最大长度个数据。实际接收的数据的长度可以通过recvfrom的返回值获取。
- int flags。设置为0即可,不必关心。
- const struct sockaddr *src_addr。src即sourcere,翻译为来源。recvfrom函数是用于接收某台机器上的某个进程发过来的信息的,那是哪台机器上的哪个进程给我发的信息呢?我们可以通过src_addr指针指向的sockaddr对象得知。说一下,src_addr是个输出型参数,我们需要先设置一个sockaddr的对象,无所谓是否初始化它,然后把该sockaddr对象的地址传入recvfrom函数,函数调用结束后,src_addr指针指向的这个sockaddr对象中就包含了【是哪台机器的哪个进程给我发信息】的信息,即sockaddr对象里包含了标识【给我发信息的主机】的ip地址和标识【给我发信息的进程】的端口号port。
- socklen_t *addrlen。其是个输入输出型参数,在调用recvfrom函数前,addrlen指针指向【表示上一个参数src_addr指向对象大小】的socklen_t对象,所以调用recvfrom函数时给addrlen传入一个值初始化成了sizeof(scr_addr指向的sockaddr对象)的socklen_t对象的地址即可;调用recvfrom函数结束后,addrlen指针指向【表示实际填充进上一个参数scr_addr指向的sockaddr对象的数据的大小】的socklen_t对象。既然addrlen是个输入输出型参数,那么使用它的方式为:调用recvfrom函数前,我们得先设置一个socklen_t的对象,然后将它初始化成sizeof(scr_addr指向的sockaddr对象),然后将该socklen_t对象的地址传给recvfrom函数的形参addrlen,recvfrom函数调用完毕后,addrlen指向的socklen_t对象的值就变成了实际填充进上一个参数scr_addr指向的sockaddr对象的数据的大小。
返回值说明:
- 在参数中已经隐含了该信息,即表示实际接收到的对方进程发过来的数据的长度。如果发生错误,返回值为-1。
不要混淆socket套接字和文件的概念
阅读到这里,我们已经阅读完了上面socket、sendto、recvfrom这3个函数,在上面的讲解中,我们把socket套接字称为socket套接字文件,即认为socket套接字是文件,如果从不严格的视角上看,的确是可以这么认为的;但如果从严格的视角上看,这是不完全对的,只能算半对。上文讲解这3个函数时我们把socket套接字称为socket套接字文件、即认为socket套接字是文件,只是为了方便读者理解函数的底层实现、方便让读者理解通信双方的数据是需要在套接字中传输的,并且数据在套接字中传输的方式类似于数据在文件中传输,套接字是作为通信信道的一环。
这里我们以严格的视角,再重新更深入的认识一下socket套接字和文件的区别。
正确的认识如下:
问题1:在上文中讲解socket函数时说过一句话,观点是这样的:“当我们调用socket函数创建套接字时,实际相当于我们打开了一个“网络文件”,打开后在内核层面上就形成了一个对应的struct file结构体,同时该结构体被连入到了该进程对应的文件双链表,并将该结构体的首地址填入到了fd_array数组当中下标为3的位置,此时fd_array数组中下标为3的指针就指向了这个打开的“网络文件”,最后3号文件描述符作为socket函数的返回值返回给了用户。” 但现在你告诉我说套接字不是文件,我该如何理解呢?
答案1如下:
-
套接字不是文件: 套接字和文件是两种不同的概念。文件通常是磁盘上存储数据的持久性对象,而套接字是用于网络通信的虚拟通信端点。套接字在操作系统内部有自己的实现方式,用于处理网络数据的传输和接收,而不是像文件一样在磁盘上存储数据。
-
文件描述符与套接字: 在UNIX和类UNIX系统中,文件描述符是一个整数,用于标识打开的文件、套接字和其他I/O资源。当你调用socket函数创建套接字时,操作系统会返回一个文件描述符,这个描述符用于标识该套接字。这个文件描述符与文件描述符数组(通常是fd_array)中的一个位置相关联,但并不意味着套接字就是一个文件。
-
struct file 结构体与文件描述符: 在UNIX-like系统中,操作系统维护一个文件表,其中包括一个 struct file 结构体数组。每个 struct file 结构体表示一个已打开的文件、套接字或其他I/O资源。文件描述符(例如,3号文件描述符)是一个索引,用于访问文件表中的 struct file 结构体。套接字和文件都可以在文件表中有对应的 struct file 结构体,但它们的实现和用途不同。
- 总之,虽然套接字和文件都可以用文件描述符来标识和操作,但它们在底层实现和用途上是不同的。套接字是用于网络通信的抽象通信端点,而文件是用于数据存储和读写的持久性对象。因此,尽管它们都与文件描述符相关联,但不应将套接字等同于文件。
问题2:在进程中除了打开文件会创建struct file,还会有其他情况会创建struct file吗?
答案2:在进程中除了打开文件可能创建 struct file
结构外,还有其他情况可能会创建这种结构。 struct file
是用于表示已打开文件的数据结构,但在不同的操作系统和情况下,也可以用于表示其他类型的 I/O 资源,而不仅限于磁盘上的文件。以下是一些可能创建 struct file
结构的情况:
-
打开文件: 当进程打开一个文件时(例如通过
open()
系统调用),操作系统会创建一个struct file
结构来表示这个已打开的文件,以便后续对文件的读写等操作。 -
创建套接字: 当进程调用
socket()
等套接字相关的系统调用创建套接字时,操作系统会创建一个struct file
结构来表示这个套接字。这个结构可能在某些系统中称为套接字描述符,但与普通文件描述符一样,它们可以与struct file
相关联。 -
创建管道: 管道是一种特殊的文件,用于进程间通信。当进程调用
pipe()
创建管道时,可能会创建相应的struct file
结构来表示管道。 -
创建字符设备或块设备文件: 进程可能会打开字符设备或块设备文件,例如硬盘分区或串口设备。这些设备文件也可能涉及创建
struct file
结构。 -
网络设备和虚拟文件系统(VFS): 在网络编程中,涉及网络设备时可能会创建
struct file
结构。同时,在虚拟文件系统中可能有自定义的文件类型,对应的创建也可能涉及创建struct file
。 -
总的来说,
struct file
结构用于表示进程中打开的各种 I/O 资源,不仅限于普通文件。这些资源可以是磁盘文件、套接字、管道、设备文件等。每种资源类型都可能涉及创建相应的struct file
结构来进行管理。
问题3:Linux中不是一切皆文件吗?现在你跟我说套接字不是文件?
答案3:在Linux和类UNIX操作系统中,"一切皆文件" 是一个广泛传播的概念,意味着大多数系统资源(包括设备、文件、套接字等)都可以通过文件描述符进行访问和操作。这个概念是UNIX哲学的一部分,它使得编程更加一致和灵活,因为它允许开发者使用类似的API来处理各种资源。
但注意从严格的视角上看,这个观点并不一定正确:虽然"一切皆文件"是一个重要的概念,但不是所有的资源都是严格的文件的,而是它们可以以类似文件的方式被进行访问和管理。套接字就是一个例子。套接字是用于网络通信的抽象通信端点,它们并不是磁盘上的文件,而是用于数据交换的网络连接。尽管套接字可以使用文件描述符进行标识和操作,但它们的底层实现与普通文件不同,因为它们是用于网络通信的。所以,可以说在Linux中,一切都可以通过文件描述符进行访问,但并不是所有的资源都是严格的文件。套接字是一种特殊的资源,它们以类似文件的方式进行访问,但在内核中的实现与普通文件不同。
但如果从不严格的视角上看,这个观点又是正确的:在Linux和类UNIX系统中,"一切皆文件" 是一个常见的概念,表示许多系统资源都可以通过文件描述符进行访问和操作,包括文件、设备和套接字等。套接字也是一种资源,它可以像文件一样通过文件描述符进行访问和操作。所以,从这个角度来看,套接字也可以被视为一种文件。综上所述,套接字确实符合"一切皆文件"的概念,因为它们可以像文件一样通过文件描述符进行操作。因此,你可以相信"一切皆文件"这个概念,包括套接字在内。
问题4:既然从严格视角下看,套接字并不是文件,那它到底是什么?底层又是怎么实现的呢?
到底是什么呢?
- 套接字不是一个文件,它是操作系统提供的一种通信机制,用于在网络上进行数据传输。套接字与文件在本质上有很大的不同。
- 文件通常是一个存储在磁盘上的持久化数据对象,可以按照顺序读取和写入。文件通常具有持久性,它们的内容可以长期保存在磁盘上,并且可以在不同的时间点被多个程序访问。
- 套接字,与文件不同,是用于实时通信的虚拟端点。套接字允许两台计算机上的程序通过网络进行实时数据交换。套接字通常不具有持久性,它们代表了一个临时的通信通道,用于在通信的两端传输数据。
- 具体来说,套接字是一组网络编程 API(例如,在C语言中是Socket API)的一部分,它允许应用程序创建、配置、连接和通信。套接字在操作系统内部实现了网络协议的细节,包括数据封装、路由、错误处理等。套接字允许应用程序通过网络发送和接收数据,但不是文件系统的一部分,也没有文件的特性。
- 虽然套接字可以使用类似于文件的读取和写入操作,但它们的底层实现与文件系统完全不同。套接字提供了一种用于网络通信的抽象接口,而文件用于存储和管理数据。因此,套接字和文件是两种不同的概念,用于不同的用途。
底层又是怎么实现的呢?
套接字的底层实现是由操作系统提供的,它涉及操作系统内核中的网络协议栈和网络套接字库。不同的操作系统(如Windows、Linux、macOS等)可能有不同的实现细节,但一般来说,套接字的底层实现包括以下几个关键方面:
-
网络协议栈: 操作系统内核包含一个网络协议栈,用于处理网络通信。这个协议栈包括各种网络协议,如TCP(传输控制协议)、UDP(用户数据报协议)、IP(Internet协议)等。这些协议协同工作以确保数据在网络上的可靠传输。
-
套接字库: 操作系统提供了一个套接字库(Socket API),它是应用程序与底层网络协议之间的接口。开发者可以使用套接字库中提供的函数来创建、配置、连接和管理套接字。这些函数允许应用程序发送和接收数据,以及执行网络通信的各种操作。
-
套接字的创建和配置: 应用程序通过调用套接字库中的函数来创建套接字。套接字可以是TCP套接字或UDP套接字,它们可以绑定到特定的网络地址和端口。配置套接字包括设置套接字选项(例如,超时设置、缓冲区大小等)以满足应用程序的需求。
-
数据传输和路由: 当应用程序通过套接字发送数据时,数据被传递给操作系统内核的网络协议栈。协议栈根据目标地址和协议来选择正确的路由,将数据传输到目标机器上的套接字。在目标机器上,数据经过协议栈的处理,最终传递给接收套接字。
-
错误处理和状态管理: 套接字库和操作系统内核处理各种网络错误和异常情况,以确保通信的可靠性。套接字可以处于不同的状态,例如监听状态、已连接状态、关闭状态等,这些状态由套接字库和操作系统管理。
总的来说,套接字的底层实现涉及操作系统内核中的网络协议栈、套接字库以及与网络通信相关的各种数据结构和算法。这些组件协同工作,以实现应用程序之间的网络通信。不同的操作系统和编程语言提供不同的套接字API,但它们的底层实现原理大致相似。
基于TCP协议的socket编程的常见函数
listen函数
双方进程基于TCP协议进行网络通信时就需要用到该函数,用于让通信的双方在正式通信前进行连接。
UDP服务端的初始化操作只有两步,第一步就是创建套接字,第二步就是绑定;而TCP服务器和前面不一样,TCP服务器除了需要做这两步的操作外,因为TCP服务端是面向连接的,客户端在正式向TCP服务端发送数据之前,需要先与TCP服务端建立连接,然后才能与服务端进行通信,所以TCP服务端还需要时刻注意是否有客户端发来连接请求,此时就需要通过listen函数将TCP服务端进程创建(通过socket函数)并和当前进程进行过绑定(通过bind函数)的套接字文件设置为监听状态。做完这三步,TCP的服务端的初始化才算完成。
说一下,只完成上面3步虽然完成了TCP服务端的初始化工作,但还不能直接让双方通信,刚才通过listen函数将TCP服务端进程创建并绑定的套接字文件设置为监听状态只是让通信的双方建立连接的第一步,后序还要通过accept函数才能真正的完成双方建立连接。
参数说明:
参数1、sockfd:也就是【需要被设置为监听状态的套接字文件】所对应的文件描述符。是哪个套接字文件需要被设置成监听状态呢?哪个套接字文件需要作为当前进程与其他主机上的某台进程通信的通信通道的一环,哪个套接字文件就需要被设置成监听状态,所以是当前进程(服务端)创建(通过socket函数)并和当前进程进行过绑定(通过bind函数)的套接字文件需要被设置成监听状态。
说一下,在TCP通信中,这个被设置成监听状态的套接字文件虽然也作为双方进程通信的通信通道的一环,但这个套接字文件并不用于直接传输双方收发的信息,该套接字文件做的工作为:
- 监听是否有其他主机的某个进程的连接请求过来(即是否有人调用send或者read函数向我发信息),如果没有,则当前进程阻塞在listen函数中,等待连接请求过来;
- 如果有,则跳出listen函数后继续向下执行代码,后序代码中会通过accpet函数创建一个socket套接字文件(这是需要程序员编码控制的,如果在编写代码时listen函数的下方没有调用accept函数,那程序员编码就有问题),让accept函数创建出的套接字文件去作为当前进程和某台主机上的某个进程之间通信的通信通道的一环,而我(通过listen函数被设置成监听状态的套接字文件)则继续监听是否有其他主机的某个进程的连接请求过来,有连接过来以及没有连接过来的对应的处理方式和之前一样。
- 综上所述,因为被设置成监听状态的套接字文件并不直接用于传输当前进程和对端进程双方收发的信息(假设把这个操作称为任务),而是把这个任务交给了其他套接字文件,自己只是做了监听的工作,所以一般我们把被设置成监听状态的套接字文件称呼为监听套接字文件、把该文件对应的文件描述符称为listen_sock,把给监听套接字 “打工” 的套接字文件称呼为服务套接字文件、把该文件对应的文件描述符称呼为service_sock。
问题:有人可能会说【被设置成监听状态的socket套接字文件不过是个文件,文件只能进行读写操作,为什么该套接字文件可以做上面所说的那些工作呢?】答案:在上文中说过,不要混淆socket套接字和文件的概念,在不严格的视角下我们可以认为套接字就是文件,但在严格视角下并不能直接将套接字直接和文件划上等号。所以问题的答案也就很明显了,因为严格意义来说套接字并不是一个文件,所以通过套接字可以做到通过文件做不到的事情,所以套接字可以做上面所说的那些工作。
参数2、backlog:可以通过该参数指定连接请求队列的最大长度。如果有多个客户端同时发来连接请求,如果队列未满,则此时未被服务器处理的连接就会放入连接队列;如果队列已满,新的连接请求将被拒绝。该参数代表的就是连接请求队列的最大长度,通过设置适当的 backlog
值,可以控制服务器同时处理的连接请求数量,一般不要设置太大,设置为5或10即可,以防止过多的请求导致服务器资源耗尽。
返回值说明:
- 监听成功返回0,监听失败返回-1,同时错误码会被设置。
listen函数的功能说明:
在TCP中,listen
函数用于设置监听套接字(listening socket)进入被动监听状态。监听套接字是服务器端用来接受客户端连接请求的套接字。当一个套接字处于被动监听状态时,它会一直等待客户端的连接请求,而不主动发起连接。
具体来说,以下是 listen
函数在TCP中的作用:
-
设置套接字为监听套接字: 在服务器端程序中,首先需要创建一个套接字并将其绑定到一个特定的端口,然后使用
listen
函数将该套接字设置为监听套接字。这告诉操作系统该套接字用于接受客户端的连接请求。 -
指定请求队列的最大长度:
listen
函数的第二个参数backlog
指定了连接请求队列的最大长度。连接请求队列是一个等待服务器处理的连接请求的队列。通过设置适当的backlog
值,可以控制服务器同时处理的连接请求数量,以防止过多的请求导致服务器资源耗尽。 -
等待客户端连接: 一旦套接字被设置为监听状态,它就会一直等待客户端的连接请求。当客户端尝试连接到服务器的端口时,操作系统会将连接请求放入连接请求队列,然后服务器可以使用
accept
函数从队列中接受连接请求,并创建一个新的套接字来处理与客户端之间的通信。
总之,listen
函数在TCP中用于告诉操作系统某个套接字用于接受客户端的连接请求,并指定了连接请求队列的最大长度。这是建立服务器程序的基础步骤之一,以便能够处理多个客户端的连接请求。
accept函数
双方进程基于TCP协议进行网络通信时就需要用到该函数,用于让通信的双方在正式通信前进行连接。
TCP服务端进程初始化后就可以开始运行了,但TCP服务端在与客户端进行网络通信之前,服务端需要先获取到客户端的连接请求。如上图,获取连接的函数叫做accept,该函数的基本信息如下。
参数说明:
- sockfd:该参数即是在讲解listen函数时所说的监听套接字文件所对应的文件描述符listen_sock,表示从该监听套接字文件中获取连接。
- addr:作为一个输出型参数,用于在获取对端的连接请求时获取对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addrlen:作为一个输入输出型参数,调用时传入期望读取的addr结构体的长度,返回时代表实际读取到的addr结构体的长度。
返回值说明:
- 获取连接成功时(即accpet成功时)返回接收到的套接字的文件描述符(即在讲解listen函数时所说的给监听套接字文件 “打工” 的套接字文件对应的文件描述符service_sock);
- 获取连接失败返回-1,同时错误码会被设置。
read函数
本地环回地址和INADDR_ANY地址
本地环回地址
本地环回地址就是值为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,并且指定的端口号port对应的是当前进程,本机上的当前进程才能收到该信息;
而如果本机上的当前进程不和某个固定的ip地址进行bind绑定,而是bind绑定INADDR_ANY地址,那么当前进程可以从本机的任意一个ip对应的网卡中接收该信息,那么以后其他主机上的进程想向本机上的当前进程发送信息时,只要其他主机上的进程在发送信息时指定的ip是属于本机的、指定的端口号port对应的是当前进程,那么不管是从哪个网卡(ip)中收到的数据,都统统交给当前进程,本机上的当前进程都能收到该信息。
因此服务端绑定INADDR_ANY这种方案也是强烈推荐的方案,所有的服务器具体在操作的时候用的也就是这种方案。在编写服务端进程时,除了一些特殊场景,基本都是让服务端进程bind在绑定ip时绑定INADDR_ANY地址。
为什么云服务器上的进程在bind绑定INADDR_ANY后,其他主机就可以通过云服务器的虚拟的ip地址访问该进程了呢?
(说一下,当前进程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的,只能bind绑定【本地回环ip地址127.0.0.1】和【INADDR_ANY地址0.0.0.0】。为什么呢?因为云服务器上的公网IP实际上是厂商虚拟出来的,并不是真正的公网IP,当然无法bind成功了。
有人可能会说【既然云服务器上的进程不能绑定云服务器的公网IP,那我就bind绑定一个普通主机的公网IP】,这里笔者想说的是:因为权限问题或者其他原因,本机上的进程如果绑定其他主机的ip一般都是绑定失败,并且就算绑定成功也没有意义,因为本机上的进程绑定了其他主机的ip,那么给本机发送信息的进程就不会把信息发到本机上,而是发到了其他主机上,而其他主机压根不会搭理这个信息,最终就导致本应该通信的双方进程压根就无法正常通信。