一、预备知识
1.1 端口号
socket通信本质
通过IP地址和MAC地址能够将数据发送到对端主机了,但实际上是想将数据发送给对端主机上的某个服务进程,此外,数据的发送者也不是主机,而是主机上的某个进程,比如当使用浏览器访问时,实际就是浏览器进程向对端服务进程发起的请求
socket通信本质上就是两个进程之间在进行通信,只不过这里是跨网络的进程间通信。比如逛淘宝和刷抖音的动作,实际就是手机上的淘宝客户端进程和抖音客户端进程在和对端服务器主机上的淘宝服务进程和抖音服务进程之间进行通信
进程间通信的方式除了管道、消息队列、信号量、共享内存等方式外,还有套接字,只不过前者是不跨网络的,而后者即可以跨网络也可以不跨网络
端口号
两台主机上可能会同时存在多个正在进行跨网络通信的进程,因此当数据到达对端主机后,必须要通过某种方法找到该主机上对应的服务进程,然后将数据交给该进程处理。而当该进程处理完数据后还要对发送端进行响应,因此对端主机也需要知道是发送端上的哪一个进程发送的数据请求
而端口号的作用正是标识一台主机上的某一个进程:
端口号是传输层协议的内容
端口号是一个2字节16位的整数
端口号用来标识一个进程,使得操作系统知道当前数据要交给哪一个进程处理
一个端口号只能被一个进程占用
当数据在传输层进行封装时,就会添加上对应源端口号和目的端口号的信息。这时通过源IP地址+源端口号就能够在网络上唯一标识发送数据的进程,通过目的IP地址+目的端口号就能够在网络上唯一标识接收数据的进程,此时就实现了跨网络的进程间通信
注意: 因为端口号是隶属于某台主机的,所以端口号可以在两台不同的主机当中重复,但是在同一台主机上进行网络通信的进程的端口号不能重复。此外,一个进程可以绑定多个端口号,但是一个端口号不能被多个进程同时绑定
prot VS PID
端口号(port)可唯一标识一台主机上的某个进程,进程ID(PID)的作用也是唯一标识一台主机上的某个进程,那在进行网络通信时为什么不直接用PID来代替port呢?
进程ID(PID)是用来标识系统内所有进程的唯一性的,属于系统级的概念;端口号(port)是用来标识需要对外进行网络数据请求的进程的唯一性的,属于网络的概念
虽然可以使用PID来标识网络进程的唯一性,但会使得系统部分与网络部分交错,导致耦合度较高
并且一个进程可以绑定多个端口号,但一个进程只能对应一个PID
如何通过port找到对应的进程?
实际底层采用哈希的方式建立了端口号和进程PID或PCB之间的映射关系,当底层拿到端口号时就可以直接执行对应的哈希算法,自然能找到该端口号对应的进程
1.2 初步认识TCP协议与UDP协议
网络协议栈贯穿整个体系结构,在应用层、操作系统层和驱动层都有存在。使用系统调用实现网络通信时,不得不面对的协议层就是传输层,而传输层最典型的两种协议就是TCP协议和UDP协议
TCP协议
TCP协议被称为传输控制协议(Transmission Control Protocol),TCP协议是一种面向连接的、可靠的、基于字节流的传输层通信协议
TCP协议是面向连接的,若两台主机之间想要进行数据传输,那么必须先建立连接,连接建立成功后才能进行数据传输。其次,TCP协议是保证可靠的协议,数据在传输过程中若出现了丢包、乱序等情况,TCP协议都有对应的解决方案
UDP协议
UDP协议被称为用户数据报协议(User Datagram Protocol),UDP协议是一种无需建立连接的、不可靠的、面向数据报的传输层通信协议
使用UDP协议进行通信时无需建立连接,若两台主机之间想要进行数据传输,直接将数据发送给对端主机即可,但也意味着UDP协议是不可靠的,数据在传输过程中若出现了丢包、乱序等情况,UDP协议是不知情的
既然UDP协议是不可靠的,那为什么还要有UDP协议的存在?
可靠是需要做更多工作的,TCP协议虽然是一种可靠的传输协议,但TCP协议在底层需要做更多的工作,因此TCP协议底层的实现较为复杂
UDP协议虽然是一种不可靠的传输协议,但UDP协议在底层不需要做过多的工作,因此UDP协议底层的实现比TCP协议更为要简单,UDP协议虽然不可靠,但能够快速的将数据发送给对方
编写网络通信代码时具体采用TCP协议还是UDP协议,完全取决于上层的应用场景。若应用场景严格要求数据在传输过程中的可靠性,此时就采用TCP协议;若应用场景允许数据在传输出现少量丢包,那么优先选择UDP协议,因为UDP协议足够简单且足够快捷
注意: 一些优秀的网站在设计网络通信算法时,会同时采用TCP协议和UDP协议,当网络流畅时就使用UDP协议进行数据传输,而当网速不好时就使用TCP协议进行数据传输,动态的调整后台数据通信的算法
1.3 网络字节序
计算机在存储数据时存在大小端的概念:
大端模式: 数据的高字节内容保存在内存的低地址处,数据的低字节内容保存在内存的高地址处
小端模式: 数据的高字节内容保存在内存的高地址处,数据的低字节内容保存在内存的低地址处
若编写的程序只在本地机器上运行,是不需要考虑大小端问题的,同一台机器上的数据采用的存储方式是一样的,要么采用的都是大端存储模式,要么采用的都是小端存储模式。
但若涉及网络通信,那就必须考虑大小端的问题,否则对端主机识别出来的数据可能与发送端想要发送的数据是不一致的
例如,两台主机之间进行网络通信,发送端是小端机,接收端是大端机。发送端将发送缓冲区中的数据按内存地址从低到高的顺序发出后,接收端从网络中获取数据依次保存在接收缓冲区时,也是按内存地址从低到高的顺序保存的,但小端机与大端机对内存中数据的解读方式是不相同的
对于内存地址从低到高为 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位长整数从主机字节序转换为网络字节序
若主机是小端字节序,则函数将参数做相应的大小端转换然后返回
若主机是大端字节序,则这些函数不做任何转换,将参数原封不动地返回
二、socket编程接口
2.1 常见socketAPI
创建套接字:(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);
2.2 sockaddr结构
sockaddr结构的出现
套接字不仅支持跨网络的进程间通信,还支持本地的进程间通信(域间套接字)。在进行跨网络通信时需要传递的端口号和IP地址,而本地通信则不需要,因此套接字提供了sockaddr_in结构体和sockaddr_un结构体,sockaddr_in结构体用于跨网络通信,sockaddr_un结构体用于本地通信
为了让套接字的网络通信和本地通信能够使用同一套函数接口,于是就出现了 sockaddr 结构体,该结构体与sockaddr_in和sockaddr_un的结构都不相同,但这三个结构体头部的16个bit位一样,这个字段被称为协议家族
此时传参时,就不用传入sockeaddr_in或sockeaddr_un结构体,而统一传入sockeaddr这样的结构体。在设置参数时就可以通过设置协议家族这个字段,来表明是要进行网络通信还是本地通信。在这些API内部会提取sockeaddr结构头部的16位进行识别,进而得出是要进行网络通信还是本地通信,然后执行对应的操作。此时就通过通用sockaddr结构,将套接字网络通信和本地通信的参数类型进行了统一
注意: 实际在进行网络通信时,定义的还是sockaddr_in结构体变量,只不过在传参时需要将该结构体变量地址的类型强转为sockaddr*
IPv4和IPv6的地址格式定义在 netinet/in.h 中,IPv4地址用sockaddr_in结构体表示,包括16位地址类型,16位端口号和32位IP地址
IPv4、IPv6地址类型分别定义为常数AF_INET、AF_INET6。只要取得sockaddr结构体的首地址,不需要知道是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容
socket API可以都用struct sockaddr* 类型表示,在使用的时候需要强制转化成sockaddr_in;这样的好处是程序的通用性,可以接收IPv4、IPv6,以及UNIX Domain Socket各种类型的sockaddr结构体指针做为参数
为什么不使用void*代替struct sockaddr*类型?
可以将这些函数的struct sockaddr*参数类型改为void*,此时在函数内部也可以直接指定提取头部的16个比特位进行识别,最终也能够判断是需要进行网络通信还是本地通信,那为什么还要设计出sockaddr这样的结构呢?
实际在设计这一套网络接口的时候C语言还不支持void*,于是就设计出了sockaddr这样的解决方案。并且在C语言支持了void*之后也没有将它改回来,因为这些接口是系统接口,系统接口是所有上层软件接口的基石,系统接口是不能轻易更改的,否则引发的后果是不可想的,这也就是为什么现在依旧保留sockaddr结构的原因