目录
前言(必读)
网络字节序
网络中的大小端问题
为什么网络字节序采用的是大端而不是小端?
网络字节序与主机字节序之间的转换
字符串IP和整数IP
整数IP存在的意义
字符串IP和整数IP相互转换的方式
inet_addr函数(会自动将转化出的整数IP从主机字节序变为网络字节序)
inet_ntoa函数(会自动先把从网络中读取到的整数IP从网络字节序转化成主机字节序)
sockaddr、sockaddr_in、sockaddr_un结构体
对sockaddr_in的补充说明
socket编程的常见函数
socket函数
sendto函数
recvfrom函数
bind函数
前言(必读)
注意本文中说明的是套接字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编程的常见函数
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为磁盘文件分配的一块内存,用于在内存中暂时保存属于磁盘设备(或者说磁盘文件)的数据,之后会根据属于磁盘文件的文件缓冲区的刷新策略,将文件缓冲区中的数据刷新到内核,再由内核刷新到磁盘设备(或者说磁盘文件)上,这就完成了一次写入磁盘的操作。
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。
bind函数
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
调用完socket函数成功创建了套接字文件后,需要调用bind函数将【当前进程】和【某个ip与某个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的值来确定失败的原因,并根据错误原因进行适当的错误处理。