您现在的位置是:首页 >学无止境 >【Hello Network】网络编程套接字(一)网站首页学无止境
【Hello Network】网络编程套接字(一)
网络编程套接字(一)
预备知识
源ip和目的ip
在互联网中每一台主机都有唯一标识的公网IP地址
在互联网中数据传输的时候报文中需要加上源ip和目的ip
源ip和目的ip本质上解决的是从哪里来到哪里去的问题 就像是唐僧每次都会说从东土大唐而来去西天取经一样 有了这两个ip数据在传输的时候就知道自己要去哪里 从而知道自己下一条需要跳去哪个mac设备
端口号
我们还是使用西游记的例子来帮助我们理解端口号
唐僧经历九九八十一难走到了西天它的任务就完成了吗? 显然不是
他需要从如来佛祖那里拿到经书并且返回大唐给唐太宗交差
我们在计算机的视角来解释上面的行为
数据从A主机到B主机不是目的 目的是要求B主机上的一个进程提供数据处理服务之后返回到A主机
那么我们把时间再往前面倒一点 数据是怎么产生的呢?
计算机本身不产生数据 数据是人通过客户端产生的
站在计算机小白的角度 计算机之前的通信就是人与人之间的通信
而站在程序员的角度 计算机之前的通信其实是进程与进程之间的通信
拿我们的抖音客户端(进程)和服务器(进程)举例
我们在刷抖音的时候不停的往下刷实际上就是一个数据 这个数据通过网络传输给抖音的服务器 之后服务器给我们返回视频数据到客户端
实际上ip协议只是保证了两台主机之间能够进行通信 而我们要保证通信双方用户能看到数据则需要通过进程来执行 所以我们后面要写的套嵌式编程就是使用编程语言写一个程序来让进程实现各种服务
回到抖音的例子上来 我们在刷抖音的时候有没有可能在使用其他app呢? app在计算机的角度来看是不是就是一个进程啊 那么数据处理完之后从主机B返回到主机A它怎么知道应该返回数据到哪个进程呢?
这里端口号的作用就体现出来了
端口号(port)的作用是唯一标识一台机器上的唯一一个进程
而我们之前说过IP地址能够在互联网中唯一标识一台主机
所以说IP + PORT是不是就能唯一标识互联网中的唯一一个进程了啊
我们将网络看作是一个大的系统 在整个OS中 所有的上网行为基本都是在这个OS内进行进程间通信
我们将IP地址+端口号叫做socket
为什么我们不直接用系统中进程的pid来唯一标识一个进程呢?
这个就跟我们有身份证为什么大学还要给我们创建一个学号一样
有两点原因
- 如果使用身份证 我们就不能一眼看出这个同学是几几年入学 是哪个学院的
- 如果使用身份证 假设国家修改一下身份证格式那么所有大学的学号就得重新编写了 而如果自己使用自己大学的学号则不需要 这是一种解藕行为
ip 和 port之间是什么关系
它们之间是一种相互促进的关系
- 我们通过ip来找到公网中唯一标识的主机
- 我们通过端口号来唯一确定该主机中的进程
一个端口号可以关联多个进程吗?
不可以 因为端口号的作用是可以唯一标识一个进程 关联多个进程的话端口号就失去它的意义了
一个进程可以关联多个端口号吗?
可以 只要这些端口号都唯一标识这个进程就行
TCP和UDP协议
网络协议栈是贯穿整个体系结构的 在应用层 操作系统层和驱动层各有一部分 当我们使用系统调用接口实现网络数据通信时 不得不面对的协议层就是传输层 而传输层最典型的两种协议就是TCP协议和UDP协议
TCP协议
TCP协议叫做传输控制协议(Transmission Control Protocol),TCP协议是一种面向连接的、可靠的、基于字节流的传输层通信协议。
TCP协议是面向连接的,如果两台主机之间想要进行数据传输,那么必须要先建立连接,当连接建立成功后才能进行数据传输。其次,TCP协议是保证可靠的协议,数据在传输过程中如果出现了丢包、乱序等情况,TCP协议都有对应的解决方法。
UDP协议
UDP协议叫做用户数据报协议(User Datagram Protocol),UDP协议是一种无需建立连接的、不可靠的、面向数据报的传输层通信协议。
使用UDP协议进行通信时无需建立连接,如果两台主机之间想要进行数据传输,那么直接将数据发送给对端主机就行了,但这也就意味着UDP协议是不可靠的,数据在传输过程中如果出现了丢包、乱序等情况,UDP协议本身是不知道的。
TCP和UDP协议
TCP协议是一种可靠的传输协议 使用TCP协议能够在一定程度上保证数据传输时的可靠性 而UDP协议是一种不可靠的传输协议
拿现实中的例子来讲 你是一个学校的辅导员你经常叫两个学生来帮你办事 一个叫做张三 一个叫做李四
张三办事比较认真 你交代的任务他每隔一段时间就会给你回信说办的怎么样了
而李四办事比较马虎 你交代的任务他答应了之后文件也不拿就在旁边看着 你也不知道他什么时候会去送
这时候有的同学可能会想 既然张三办事比较认真那么我们为什么不一直用张三呢?
这个时候我们要知道 认真是有代价的 由于张三办事比较认真所有说他使用的资源也会比较多 所以说如果没有特殊要求这个文件百分之一百要送到 我们一般情况下都是用李四
反应到TCP/UDP协议上 如果像是银行转账这种必须要保证数据可靠性的传输 我们选择使用TCP协议 否则为了减少成本考虑 我们也可以使用UDP协议
网络中的字节序
在网络中是有大小端之分的
- 大端法:数据的高字节保存在内存的低地址中
- 小端法:数据的低字节保存在内存的低地址中
具体的内容可以参考我的这篇博客 大端法和小端法
如果我们编写的程序代码都在本地运行我们是不用考虑大小端的问题的 因为同一台机器上采用的储存方式肯定是一致的
但是如果涉及到网络通信 我们就必须要考虑大小端的问题了 因为在互联网中一些机器使用的是大端法而一些机器使用的是小端法 如果我们不采取一些措施就会造成数据错乱的问题
比如说我们发出去的数据可能是0X11223344 但是由于对面主机的端口法和我们不一样数据就变成了 0X44332211
因此我们变制定了一个规则 不管你的机器是大端还是小端 在传入数据的时候统一转化为大端的数据
有了这个统一的规则之后发送方接收方就都明确了发送接受是大端的数据也就不会出现数据错乱的情况了
为什么网络字节序采用的是大端?而不是小端?
网上关于这个的说法有很多 其中比较可信的有两点
- TCP在Unix时代就有了,以前Unix机器都是大端机,因此网络字节序也就采用的是大端,但之后人们发现用小端能简化硬件设计,所以现在主流的都是小端机,但协议已经不好改了。
- 大端序更符合现代人的读写习惯。
socket编程接口
socket常见API
创建套接字:(TCP/UDP,客户端+服务器)
int socket(int domain, int type, int protocol);
绑定端口号:(TCP/UDP,服务器)
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
监听套接字:(TCP,服务器)
int listen(int sockfd, int backlog);
接收请求:(TCP,服务器)
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
建立连接:(TCP,客户端)
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockaddr结构
sockaddr结构的出现
套接字不仅支持进程间通信而且支持网络间通信
在跨网络通信的时候我们需要传入IP和端口号 而本地通信则不需要
所以说因此套接字提供了sockaddr_in
结构体和sockaddr_un
结构体
为了让套接字的网络通信和本地通信能够使用同一套函数接口 于是就出现了sockeaddr
结构体 该结构体与sockaddr_in和sockaddr_un的结构都不相同 但这三个结构体头部的16个比特位都是一样的 这个字段叫做协议家族
此时当我们在传递在传参时 就不用传入sockeaddr_in或sockeaddr_un这样的结构体 而统一传入sockeaddr这样的结构体
在设置参数时就可以通过设置协议家族这个字段 来表明我们是要进行网络通信还是本地通信 在这些API内部就可以提sockeaddr结构头部的16位进行识别 进而得出我们是要进行网络通信还是本地通信 然后执行对应的操作 此时我们就通过通用sockaddr结构 将套接字网络通信和本地通信的参数类型进行了统一
注意: 实际我们在进行网络通信时 定义的还是sockaddr_in这样的结构体 只不过在传参时需要将该结构体的地址类型进行强转为sockaddr*
为什么不用
void*
代替struct sockaddr*
类型?
如果在C++中我们可以使用多态来解决这个问题 但是C语言中没有没有办法能够解决这个问题呢? 答案是有的 就是使用void*
只要参数统一传入void* 之后在接口内部来判断是哪个类型就可以了
但是由于在设计这一套接口的时候c语言还不支持void* 所以说出现了这一套的接口
而由于设计的这一套接口是系统接口 牵一发而动全身 所以说在设计出了c语言的void*之后依旧没有改回来
简单的UDP程序
服务端创建套接字
我们把服务器封装成一个类,当我们定义出一个服务器对象后需要马上初始化服务器,而初始化服务器需要做的第一件事就是创建套接字。
socket函数
创建套接字的函数叫做socket,该函数的函数原型如下:
int socket(int domain, int type, int protocol);
返回值说明:
- 套接字创建成功返回一个文件描述符,创建失败返回-1,同时错误码会被设置。
参数说明:
- 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表示的就是默认,此时会根据传入的前两个参数自动推导出你最终需要使用的是哪种协议。
socket函数属于什么类型的接口?
网络协议栈是分层的,按照TCP/IP四层模型来说,自顶向下依次是应用层、传输层、网络层和数据链路层。而我们现在所写的代码都叫做用户级代码,也就是说我们是在应用层编写代码,因此我们调用的实际是下三层的接口,而传输层和网络层都是在操作系统内完成的,也就意味着我们在应用层调用的接口都叫做系统调用接口。
socket函数是被谁调用的?
socket这个函数是被程序调用的,但并不是被程序在编码上直接调用的,而是程序编码形成的可执行程序运行起来变成进程,当这个进程被CPU调度执行到socket函数时,然后才会执行创建套接字的代码,也就是说socket函数是被进程所调用的。
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结构体来维护的。而文件缓冲区对于打开的普通文件来说对应的一般是磁盘,但对于现在打开的“网络文件”来说,这里的文件缓冲区对应的就是网卡。
对于一般的普通文件来说,当用户通过文件描述符将数据写到文件缓冲区,然后再把数据刷到磁盘上就完成了数据的写入操作。而对于现在socket函数打开的“网络文件”来说,当用户将数据写到文件缓冲区后,操作系统会定期将数据刷到网卡里面,而网卡则是负责数据发送的,因此数据最终就发送到了网络当中。
服务端创建套接字
当我们在进行初始化服务器创建套接字时,就是调用socket函数创建套接字,创建套接字时我们需要填入的协议家族就是AF_INET,因为我们要进行的是网络通信,而我们需要的服务类型就是SOCK_DGRAM,因为我们现在编写的UDP服务器是面向数据报的,而第三个参数之间设置为0即可。
代码标识如下
class UdpServer
{
public:
bool InitServer()
{
//创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0){ //创建套接字失败
std::cerr << "socket error" << std::endl;
return false;
}
std::cout << "socket create success, sockfd: " << _sockfd << std::endl;
return true;
}
~UdpServer()
{
if (_sockfd >= 0){
close(_sockfd);
}
};
private:
int _sockfd; //文件描述符
};
注意: 当析构服务器时,我们可以将sockfd对应的文件进行关闭,但实际上不进行该操作也行,因为一般服务器运行后是就不会停下来的。
我们下面可以做一个简单的测试 来看看是否正确运行
int main()
{
auto* ser = new UdpServer();
ser->InitServer();
sleep(10);
return 0;
}
运行结果如下
我们发现这里文件描述符是3 因为012(标准输入 标准输出 标准错误)在我们的程序创建一瞬间就占用了
服务端绑定
现在套接字已经创建成功了,但作为一款服务器来讲,如果只是把套接字创建好了,那我们也只是在系统层面上打开了一个文件,操作系统将来并不知道是要将数据写入到磁盘还是刷到网卡,此时该文件还没有与网络关联起来。
由于现在编写的是不面向连接的UDP服务器,所以初始化服务器要做的第二件事就是绑定。
bind函数
绑定的函数叫做bind,该函数的函数原型如下:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
返回值说明:
- 绑定成功返回0,绑定失败返回-1,同时错误码会被设置。
参数说明:
- sockfd:绑定的文件的文件描述符。也就是我们创建套接字时获取到的文件描述符。
- addr:网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addrlen:传入的addr结构体的长度。
struct sockaddr_in结构体
在绑定时需要将网络相关的属性信息填充到一个结构体当中,然后将该结构体作为bind函数的第二个参数进行传入,这实际就是struct sockaddr_in结构体。
struct sockaddr_in
当中的成员如下:
- sin_family:表示协议家族。
- sin_port:表示端口号,是一个16位的整数。
- sin_addr:表示IP地址,是一个32位的整数。
剩下的字段一般不做处理,当然你也可以进行初始化。
其中sin_addr的类型是struct in_addr,实际该结构体当中就只有一个成员,该成员就是一个32位的整数,IP地址实际就是存储在这个整数当中的。
如何理解绑定?
在进行绑定的时候需要将IP地址和端口号告诉对应的网络文件,此时就可以改变网络文件当中文件操作函数的指向,将对应的操作函数改为对应网卡的操作方法,此时读数据和写数据对应的操作对象就是网卡了,所以绑定实际上就是将文件和网络关联起来。
增加IP地址和端口号
由于绑定时需要用到IP地址和端口号,因此我们需要在服务器类当中引入IP地址和端口号,在创建服务器对象时需要传入对应的IP地址和端口号,此时我们就可以根据传入的IP地址和端口号对对应的成员进行初始化。
class UdpServer
{
public:
UdpServer(std::string ip, int port)
:_sockfd(-1)
,_port(port)
,_ip(ip)
{};
~UdpServer()
{
if (_sockfd >= 0){
close(_sockfd);
}
};
private:
int _sockfd; //文件描述符
int _port; //端口号
std::string _ip; //IP地址
};
虽然这里端口号定义为整型,但由于端口号是16位的,因此我们实际只会用到它的低16位。
服务端绑定
套接字创建完毕后我们就需要进行绑定了,但在绑定之前我们需要先定义一个struct sockaddr_in结构,将对应的网络属性信息填充到该结构当中。由于该结构体当中还有部分选填字段,因此我们最好在填充之前对该结构体变量里面的内容进行清空,然后再将协议家族、端口号、IP地址等信息填充到该结构体变量当中。
需要注意的是,在发送到网络之前需要将端口号设置为网络序列,由于端口号是16位的,因此我们需要使用前面说到的htons
函数将端口号转为网络序列。此外,由于网络当中传输的是整数IP,我们需要调用inet_addr
函数将字符串IP转换成整数IP,然后再将转换后的整数IP进行设置。
当网络属性信息填充完毕后,由于bind函数提供的是通用参数类型,因此在传入结构体地址时还需要将struct sockaddr_in*
强转为struct sockaddr*
类型后再进行传入。
class UdpServer
{
public:
UdpServer(std::string ip, int port)
:_sockfd(-1)
,_port(port)
,_ip(ip)
{};
bool InitServer()
{
//创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0){ //创建套接字失败
std::cerr << "socket error" << std::endl;
return false;
}
std::cout << "socket create success, sockfd: " << _sockfd << std::endl;
//填充网络通信相关信息
struct sockaddr_in local;
memset(&local, '