目录
前情提要
IP地址和端口(一看就会)
前文说到IP地址是标注了全世界唯一的一台计算机,两边计算机通过这个地址传递数据(送快递)
端口是什么呢?它标注了这台计算机唯一一个进程。就像送快递你知道了地址,但不知道具体是哪个人,那我送个勾啊。所以端口就像是地址里这个具体的人,而IP地址呢?就是这个人住的地址。
官话:
端口号(port)是传输层协议的内容
端口号是一个2字节16位的整数;
端口号用来标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理;
IP地址 + 端口号能够标识网络上的某一台主机的某一个进程;
一个端口号只能被一个进程占用
端口号和进程ID的秘密
进程ID是标注在计算机中唯一的一个进程,而端口号也刚好也是标注了唯一的进程
在我看来,二者基本一样,只是在于一个是网络中用,一个是操作系统中用。
但是他们仍然存在区别。
1.端口号在网络中用,进程ID是操作系统用的。
2.端口号是用户可自己指定和系统随机给的,而进程ID是系统分配的不能用户给
3.进程ID的数字范围特别大,而端口号规定是一个2字节->16个比特位,所以较小。
网络字节序 (知道)
大端小端
因为在内存中数据显示是低地址走到高地址,所以大端说的是数字中在高位的放到低地址这里在查看内存时看到的就是正的,而小端就是把高位放到高地址。
数据为什么要分大小端
早期,大伙造计算机时,并没有考虑到大小端问题,每个大佬都用了自己的方案(闭门造车),一些用大端一些用小端,且各有己见,谁都不服谁,最后在我们使用过程中,由于已经开始出现分歧,所以现在大端和小端已经是不可控的了。所以为什么不统一而是没有办法统一。
网络数据用的大端还是小端
“一流的企业做标准,二流的企业做品牌,三流的企业做产品”
后来,不行啊,如果不规定大小端,那造软件时我发数据是大端对面是小端,乱套了。所以后来一流企业就来了,定个标准把,后面就规定发出的数据必须是大端(不是大端我不给发,大佬就是有底气) ,接收方如果是大端那就不用转换,如果是小端那就转为大端。那现在对于使用小端的企业可是相当不友好啊。我造个软件我传的时候还得把数据再写一块内容转为大端才可以。大佬考虑到这个于是就有了以下接口!!!
字节序转换接口(大小端转换)
这些接口可以自动识别你现在的主机是大端还是小端并且将数据转换为需要的大端还是小端,所以之后的程序员写程序时带上这些端口,就可以无师自通。不用管大小端的差异了。
我嘞个爆啊,4个接口怎么记?其实有技巧。
h 是 host的缩写 翻译过来是主机的意思,n 是 net 的缩写翻译过来是网络的意思
l 是 long的缩写表示的长整型,s 是 short的缩写表示的短整型。
一开始我也觉得一个长的不就够了。但是对于port一个2字节数据,所以用s短的才能避免浪费空间和错误。
套接字启动!!(重点)
套接字是什么
是socket。他的英文就是socket,干嘛用的。首先套接字的使用是一套流程的,不同的套接字有不同的流程:
- 流式套接字(SOCK_STREAM):基于TCP协议,是一个面向连接的套接字类型。它保证数据的顺序和可靠性,适用于需要可靠传输的应用场景。
- 数据报套接字(SOCK_DGRAM):基于UDP协议,是一个无连接的套接字类型。它不保证数据的顺序,但可以快速发送和接收数据,适用于对实时性要求较高但数据可靠性要求不高的应用场景。
- 原始套接字:与标准套接字不同,原始套接字可以读写内核没有处理的IP数据包,因此它可以用于一些协议的开发和进行比较底层的操作。
所以说了这堆你还是没说是什么?
套接字就是在传输层中基于不同协议(TCP UDP)的一个端点,在这个端点里你可以发数据也可以收数据。套接字上面是应用层,下面是网络协议栈是应用程序与网络协议栈进行交互的接口。反正就是说你要走网络你就必须用我,socket就是网络的代理人。
套接字的接口
#include<sys/socket.h>
// 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器)
int socket(int domain, int type, int protocol);
// 绑定端口号 (TCP/UDP, 服务器)
int bind(int socket, const struct sockaddr *address,
socklen_t address_len);
// 开始监听socket (TCP, 服务器)
int listen(int socket, int backlog);
// 接收请求 (TCP, 服务器)
int accept(int socket, struct sockaddr* address,
socklen_t* address_len);
// 建立连接 (TCP, 客户端)
int connect(int sockfd, const struct sockaddr *addr,
socklen_t addrlen);
1.socket,创建一个socket(代理人),然后返回这个代理人的编号socketID。
2.bind 绑定端口号,让代理人知道那个进程给我的之后我好返回给你。
3.listen 监听socket,监视代理人!!如果对方回我了,及时从代理人那里知道。
4.accept 接收请求,通过代理人拿到对方的请求。
5.connect 建立连接,OK了老铁,我知道你要干嘛了,现在让代理人给我们两个建立一个连接,方便后续沟通。
什么是struct sockaddr (接口里的参数)
上面的图是在不同的网络协议版本IPV4 IPV6等等,首先他们的前16位一定都是地址类型。用于区分是哪个版本的sockaddr。可以把原始的sockaddr看做是一个模版,之后在编程时不同的协议版本就把不同协议的sockaddr强转为原始的sockaddr,就像:
bool Bind(const std::string& ip, uint16_t port) {
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr(ip.c_str());
addr.sin_port = htons(port);
int ret = bind(fd_, (sockaddr*)&addr, sizeof(addr));
}
在绑定时因为我们要用IPV4的协议,所以定义时要定义的是sockaddr_in结构体,定义结束后,在使用bind接口时,我们把&addr强转为sockaddr* 。
TIPS:既然说是强转那为什么后面这两种版本的sockaddr大于原始sockaddr,那不会出错吗?其实在最开始的地址类型中就解决了,强转为sockaddr*只是为了让参数一致,但实际上在各种接口使用sockaddr时,首先先拿出的是前16位再根据这16位再来确定用哪个结构体
为什么不用void*而是用sockaddr*
void* 不是万能的吗?什么指针都能收,也都能转换,想要多少字节直接取多少字节的数据即可。为什么还要用sockaddr*。
原因很简单在早期c语言中void*并没有设计出来,所以用的是sockaddr*。很多企业用的是sockaddr*如果更改很麻烦
简易UDP网络程序
接口详解
1.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的底层工作
如图,对于进程PCB(task_struct),里面有个数组存的是这个进程打开的文件。
有三个默认打开的输出和错误流都是打印在屏幕上的,输入则是我们键盘,鼠标输入的东西。
对于linux来说,一切都是文件(你打开使用的硬件,键盘屏幕鼠标等等)。所以我们用socket,其实就是打开一个网络文件,通过这个文件,发出信息接收信息。
那有老铁可能会想这里对应的是不是网卡呢?
不一定,我们是将文件放到socket中,这时可以走两条路
第一条网络:Socket会使用网卡进行数据传输
第二条本地:这时候如果我们的目的ip是127.0.0.1或localhost。然后就又回到了socket。
socket其实就是打开了一个文件,在files_struct中将这个文件加入。
struct inode: 就是一个对当前文件的情况的描述的结构体,里面包含了对这个文件位置的索引,是否删除或者正在使用等等。
struct file_operations : 就是打开的这个文件(硬件)的功能,就是一堆函数。
文件缓冲区:对输入的内容在内存进行储存,到达某一条件后传入对应的设备,一般文件最后都是传到硬盘,而对于网络文件则是传入网卡中。
2.bind
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfd: 绑定的文件的文件描述符。也就是我们创建套接字时获取到的文件描述符。
addr : 网络相关的属性信息,包括协议家族、IP地址、端口号等。
addrlen : addr 的字节长度。
成功返回0,失败返回-1
服务端创建套接字
所谓网络就是机器之间远程传输数据。可以想象现在我要刷抖音,我打开应用然后从抖音那里的机器把视频通过网络传输给我。所以应用应该是有两端的。一边就是服务端,一边就是客户端。
uint16_t defaultport = 8080;
std::string defortip = "0.0.0.0";
const int size = 1024;
class UdpServer
{
public:
//初始化
UdpServer(const u_int16_t &port = defaultport,const std::string &ip = defortip)
:_port(port),_ip(ip),_sockfd(0),_isrunning(false)
{}
//套接字初始化
void Init()
{
//获取套接字的fd
_sockfd = socket(AF_INET,SOCK_DGRAM,0);
if(_sockfd < 0) exit(SOCKET_ERR);
//bind socket
struct sockaddr_in local;
local.sin_family = AF_INET;
local.sin_port = htons(_port);
//将ipstring转换为uint32_t.因为要用到的是数字
local.sin_addr.s_addr = inet_addr(_ip.c_str());
if(bind(_sockfd,(const struct sockaddr*)&local,sizeof(local)) < 0)
exit(BIND_ERR);
//绑定成功
}
~UdpServer()
{
if(_sockfd>0) close(_sockfd);
}
private:
int _sockfd;
uint16_t _port;
std::string _ip;
bool _isrunning;
};
代码解析:
上面的代码Init中我们用socket获取了sockfd,之后创建了sockaddr_in local变量(用于保存端口号ip和用的协议),这个变量上面有介绍。将local里必要的参数都进行了初始化。之后bind绑定sockfd和local变量。
之后就代表了sockfd 这里的网卡设备bind绑定了要发送到的ip 端口 和用的协议。
对于上方的部分函数进行解析:
htons : host to net short 主机转网络序列 short代表16字节数据,前面说到网络传输分大小端,这里其实就是一层保险,让我们传出去的数据符合网络需要的大小端属性。
inet_addr: in_addr_t inet_addr(const char *cp);
里面传的是 ip的字符串。其实就是把ip转换为一个数字。
char *inet_ntoa(struct in_addr in);
这个函数是把设置好的 in_addr 结构体里的ip转换为一个字符串
所以说为什么不直接用字符串传输而是要转换为数字呢?对于一个ip192.168.233.123,如果用字符串传输就是15字节,一字符一字节。如果用数字传输的话如果一个位置就一个字节8个比特位,那一个位置就能存0-255
那4个位置就只用32个比特位,4个字节。大大减少了消耗,它的转换原理是什么呢?
可以看到一个ip是由4个0-255数字组成。相当于4个字节。对于其存储方式来说其实是一个联合体(一块空间多个变量用)
联合体:代码结构类似结构体。不同的点在于联合体的成员变量用的都是一块空间,空间的大小是最大的成员空间大小。所以联合体里的成员一次只能使用一个
位段:打破类型约束,在结构体中变量后面冒号带数字,可以限定一次使用多少个比特位。
8个比特位就是一字节。
- 当我们想以整数IP的形式设置IP时,直接将其赋值给联合体的第一个成员就行了。
- 当我们想以字符串IP的形式设置IP时,先将字符串分成对应的四部分,然后将每部分转换成对应的二进制序列依次设置到联合体中第二个成员当中的p1、p2、p3和p4就行了。
- 当我们想取出整数IP时,直接读取联合体的第一个成员就行了。
- 当我们想取出字符串IP时,依次获取联合体中第二个成员当中的p1、p2、p3和p4,然后将每一部分转换成字符串后拼接到一起就行了
服务器的运行
recvfrom(服务器端接收)
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
参数说明:
- sockfd:对应操作的文件描述符。(和客户端连接的文件代号)
- buf:读取数据的存放位置。(客户端发来的数据)
- len:期望读取数据的字节数。(一次从buf中读多少)
- flags:读取的方式。一般设置为0,表示阻塞读取。
- src_addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等,输入输出型参数。
- addrlen:调用时传入期望读取的src_addr结构体的长度,返回时代表实际读取到的src_addr结构体的长度,这是一个输入输出型参数。
输入输出型参数:传入一个这个类型的变量,在函数中更改后传出。
返回值说明:
- 读取成功返回实际读取到的字节数,读取失败返回-1,同时错误码会被设置。
sendto(服务器发出)
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
参数说明:
- sockfd:对应操作的文件描述符。表示将数据写入该文件描述符索引的文件当中。
- buf:待写入数据的存放位置。
- len:期望写入数据的字节数。
- flags:写入的方式。一般设置为0,表示阻塞写入。
- dest_addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addrlen:传入dest_addr结构体的长度
返回值说明:
- 写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置。
服务器运行
代码中有解释
void Start(func_t func)
{
_isrunning = true;
//1.开一个buffer用于接收数据
char buffer[size];
//2.一直接收客户端数据
while(_isrunning)
{
//1.创建结构体用于接收客户端的信息(端口号,ip等等)
struct sockaddr_in in;
socklen_t len = sizeof(in);
//2.接收数据
ssize_t ssize = recvfrom(_sockfd,buffer,sizeof(buffer) - 1,0,(struct sockaddr*)&in,&len);
//3.判断是否接收成功
if(ssize > 0)
{
//1.拿到数据
buffer[ssize] = 0;//数据最后加入\0.
std::string inbuf = buffer;
std::string echo_buf = func(inbuf);//将收到的指令传入要执行的方法中。传出结果
//2.将结果发回客户端
sendto(_sockfd,echo_buf.c_str(),echo_buf.size(),0,(struct sockaddr*)&in,len);
}
}
}
func :是要执行的功能函数,比如抖音,功能就是用户要看视频,服务器接收到这个指令。然后func执行。
启动服务器
#include"UdpServer.hpp"
int main(int argc, char* argv[])
{
if (argc != 2){
std::cerr << "Usage: " << argv[0] << " port" << std::endl;
return 1;
}
std::string ip = "127.0.0.1"; //本地环回
int port = atoi(argv[1]);
UdpServer* svr = new UdpServer(port,ip);
svr->Init();
svr->Start(暂无);
return 0;
}
关于main函数的参数:
argv【0】存储了 程序的名称或路径 至于后面从1开始的就是 你输入的参数 这里是8081
argc 则表示 argv中有多少个,这里有程序路径和 参数8081,所以为2.
查看当前网络进程的属性
netstat
常用选项说明:
- -n:直接使用IP地址,而不通过域名服务器。
- -l:显示监控中的服务器的Socket。
- -t:显示TCP传输协议的连线状况。
- -u:显示UDP传输协议的连线状况。
- -p:显示正在使用Socket的程序识别码和程序名称。
上图中 Proto 表示用的是什么协议。Recv-Q表示的是当前接收队列 Send-Q表示的是发送队列二者表示的是数量。foreign address 表示的是外部地址 state表示状态,PID表示的是当前进程的id。program name表示的是进程的名字。
客户端创建套接字
同理 和 服务器一样,客户端也是创建一个进程用来接收网络信息。所以也是封装一个类然后利用套接字来接收信息。
class UdpClient
{
public:
bool InitClient()
{
//创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0){
std::cerr << "socket create error" << std::endl;
return false;
}
return true;
}
~UdpClient()
{
if (_sockfd >= 0){
close(_sockfd);
}
}
private:
int _sockfd; //文件描述符
};
上图创建套接字和服务器端相同,协议家族是AF_INET表示用的ipv4,SOCK_DGRAM表示用的UDP协议。
客户端的绑定
和服务器端不同的是,客户端并不需要绑定特定的端口号而是交给操作系统分配。
就像我们都用手机刷抖音,服务器端长时间打开程序等待其他人来连接。这时要找到程序,如果是系统分配的随机端口号,我并不知道你的端口号,那就没然后了。所以端口号要确定
对于客户端相当于我现在打开抖音,这时我要去连接抖音服务器,系统随便给我一个端口号,我去连接服务器,因为是我发出的请求,服务器也就知道他要给我返回在哪个端口号里。所以并不需要特定分配。
客户端启动
class UdpClient
{
public:
UdpClient(std::string server_ip, int server_port)
:_sockfd(-1)
,_server_port(server_port)
,_server_ip(server_ip)
{}
~UdpClient()
{
if (_sockfd >= 0){
close(_sockfd);
}
}
private:
int _sockfd; //文件描述符
int _server_port; //服务端端口号
std::string _server_ip; //服务端IP地址
};
对客户端需要的ip 端口 文件描述符做初始化。文件描述符实际上已经在上面创建套接字处创建好了。
对于当代的应用来说客户端和服务器端有两必做 1.发数据2.收数据。
服务器接收(收)客户端请求(发),做出回应发出(发),客户端收到回应(收)
所以客户端也用
sendto函数(客户端发出)
和服务器相同
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
参数说明:
- sockfd:对应操作的文件描述符。表示将数据写入该文件描述符索引的文件当中。
- buf:待写入数据的存放位置。
- len:期望写入数据的字节数。
- flags:写入的方式。一般设置为0,表示阻塞写入。
- dest_addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。 addrlen:传入dest_addr结构体的长度。
返回值说明:
- 写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置。
客户端启动的函数
class UdpClient
{
public:
void Start()
{
std::string msg;
struct sockaddr_in peer;
memset(&peer, '\0', sizeof(peer));
peer.sin_family = AF_INET;
peer.sin_port = htons(_server_port);
peer.sin_addr.s_addr = inet_addr(_server_ip.c_str());
for (;;){
std::cout << "Please Enter# ";
getline(std::cin, msg);
sendto(_sockfd, msg.c_str(), msg.size(), 0, (struct sockaddr*)&peer, sizeof(peer));
}
}
private:
int _sockfd; //文件描述符
int _server_port; //服务端端口号
std::string _server_ip; //服务端IP地址
};
如上,和服务器端类似,我们创建一个peer,为什么用peer这个名字因为有一个英文短语是
peer-to-peer意思是网络对点技术。所以用peer作为名称。
其他步骤和服务器端一样,htons 主机转网络序列 inet_addr ip字符串转换(必须转换)。
之后就是循环打开,getline获取键盘打印的内容直到收到回车将其放入msg中。
命令行参数
一样的我们客户端,一切准备就绪后也需要main函数来启动程序
int main(int argc, char* argv[])
{
if (argc != 3){
std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;
return 1;
}
std::string server_ip = argv[1];
int server_port = atoi(argv[2]);
UdpClient* clt = new UdpClient(server_ip, server_port);
clt->InitClient();
clt->Start();
return 0;
}
为什么这里的argc变成3了,因为对于客户端来说我们需要知道服务器的ip 和 端口号才能找到服务
所以现在argv中有的是argv【0】程序的地址,argv【1】程序的ip,argv【2】程序的端口号
INADDR_ANY
对于上述代码而言,我们在本地环回127.0.0.1做测试时是可以完成的。
但是对于云服务器而言,它是不可以的,由于云服务器的IP地址是由对应的云厂商提供的,这个IP地址并不一定是真正的公网IP,这个IP地址是不能直接被绑定的,如果需要让外网访问,此时我们需要bind 0。系统当当中提供的一个INADDR_ANY
,这是一个宏值,它对应的值就是0。
只有绑定了0,那我们的机器才可以被外网访问
好处:
对于网卡硬件而言,每张网卡对应了一个ip。正常来说bind函数只能绑定一个ip即一张网卡。如果我们用INADDR_ANY如果我们有多张网卡,我们此时绑定INADDR_ANY后,我们这里对应的端口号,每个网卡都会记住,之后在不同的网卡中接收到这个端口的信息都会向上传给程序。而之前绑定指定ip时,只有对应的网卡会发送数据上来。
所以说我们就可以把服务器端的bind时绑定的ip转为INADDR_ANY
struct sockaddr_in local;
memset(&local, '\0', sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY; //绑定INADDR_ANY
最后对于客户端而言我们可以在加一个recvfrom的函数,用于接收服务器端的回应。这里不做解释只给出代码
void Start()
{
std::string msg;
struct sockaddr_in peer;
memset(&peer, '\0', sizeof(peer));
peer.sin_family = AF_INET;
peer.sin_port = htons(_server_port);
peer.sin_addr.s_addr = inet_addr(_server_ip.c_str());
for (;;){
std::cout << "Please Enter# ";
getline(std::cin, msg);
sendto(_sockfd, msg.c_str(), msg.size(), 0, (struct sockaddr*)&peer, sizeof(peer));
#define SIZE 128
char buffer[SIZE];
struct sockaddr_in tmp;
socklen_t len = sizeof(tmp);
ssize_t size = recvfrom(_sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&tmp, &len);
if (size > 0){
buffer[size] = '\0';
std::cout << buffer << std::endl;
}
}
}