
一、理解套接字
1、socket 是一个套接字。在TCP/IP协议中,“IP地址+TCP或UDP端口号”在网络通信中唯一标识一个进程,“IP地址+TCP或UDP端口号”用于socket。
2、在TCP协议中,建立连接的两个进程(客户端和服务器)各有一个socket来标识它,而这两个socket组成的socket对唯一标识一个连接。
3、Socket本身就有“socket”的意思,所以用来描述网络连接的一对一关系。为 TCP/IP 协议设计的应用层编程接口称为套接字 API。
二、网络字节序
内存中的多字节数据分为大小端,磁盘文件中的多字节数据相对于文件中的偏移地址也分为大小端。同样linux的通信方式套接字,网络数据流也有大小端。
网络数据流的地址规定:先发送的数据为低地址,后发送的数据为高地址。发送主机通常按照内存地址从低到高的顺序发送发送缓冲区中的数据。为了防止数据流乱序,接收主机也会按照内存地址从低到高的顺序保存从网络接收到的数据。在接收缓冲区中。
TCP/IP 协议规定网络数据流应采用大端字节序,即低地址高字节。
(PS:如果不了解big endian的little-endian字节顺序,可以看这篇文章供参考:)
由于两端的两台主机大小不一定相同,为了让这些网络数据更具可移植性,让相同的代码在big-endian和little-endian主机上都能正常运行,我们可以调用如下库函数 进行网络字节序和主机字节序的相关转换:
#包括
//将主机字节顺序转换为网络字节顺序
uint32_t htonl(uint32_t hostlong);//将32个长整数从主机字节序转换为网络字节序,
//如果主机字节序是little endian,函数会做相应的大小
// 端到端转换后返回;如果主机字节顺序是大端,函数
//数字不转换,参数原样返回。. . 下同
uint16_t htons(uint16_t hostshort);
//将网络字节序转换为主机字节序
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
// h代表主机(host),n代表网络(net),l代表32位长整数,s代表16位短整数。
三、TCP协议通信的实现
TCP协议通信流程:
在这里写图片描述
我们先介绍几个函数:
1、创建套接字
int 套接字(int 域,int 类型,int 协议);
//domain:该参数一般设置为AF_INET,表示使用IPv4地址。有更多选项可以使用 man 查看功能
//type: 这个参数也有很多选项。例如,SOCK_STREAM 代表面向流的传输协议,SOCK_DGRAM 代表数据报。我们这里实现的是TCP,所以选择SOCK_STREAM。如果实现了 UDP,SOCK_DGRAM 是可选的。
//protocol:协议类型,一般使用默认,设置为0
该函数用于打开网络通信接口。如果有错误则返回-1,成功返回一个socket(文件描述符)。应用进程可以像读写文件一样调用read/write在网络上发送和接收数据。
2、绑定
int bind(int sockfd,const struct sockaddr*addr,socklen_t addrlen);
//sockfd:服务器打开的sock
//最后两个参数可以参考第四部分的介绍
服务器监控的网络地址和端口号一般是固定的。客户端程序知道服务器程序的地址和端口号后,就可以发起与服务器的连接。因此,服务器需要调用bind来绑定一个固定的网络地址和端口号。bind 成功返回 0,错误返回 -1。
bind()的作用:将参数sockfd和addr绑定在一起,就是sockfd描述的地址和端口号,一个用于网络通信的文件描述符,用来监听addr。
3、监视器
int 听(int sockfd,int backlog);
//sockfd 和bind中的意思一样。
//backlog参数被内核解释为辅助socket的最大队列数。这个大小一般为5~10,不宜过大(防止SYN攻击)
此功能仅供服务器端使用。listen() 声明 sockfd 处于监听状态,最多允许 backlog 客户端处于连接等待状态,如果收到更多连接请求则忽略。listen() 成功返回 0,失败返回 -1。
4、接收连接
int accept(int sockfd,struct sockaddr* addr,socklen_t* addrlen);
//addrlen为传入传出参数,传入为调用者缓冲区cliaddr的长度,避免缓冲区溢出问题;传出的是客户端地址结构的实际长度(它可能不是完整的调用方提供的缓冲区)。如果您将 NULL 传递给 cliaddr 参数,则表示您不关心客户端的地址。
一个典型的服务器程序可以同时为多个客户端提供服务。当客户端发起连接时,服务器调用accept()返回并接收连接。如果有大量客户端发起请求,服务器无法及时处理,也就没有客户端被接受。终端处于连接等待状态。
三次握手完成后,服务器调用accept()接收连接。如果服务器调用accept()时没有来自客户端的连接请求,它会阻塞并等待,直到客户端连接。
5、请求连接
int connect(int sockfd,const struct sockaddr* addr,socklen_t addrlen);
该函数只需要由客户端程序调用。调用该函数后,表示服务器已连接。这里的参数是对方的地址。connect() 成功返回 0,错误返回 -1。
了解了这些功能之后,我们再来看看客户端程序和服务端程序建立连接的过程:
服务端:首先调用socket()创建socket进行通信,其次调用bind()绑定文件描述符,调用listen()监听端口上的客户端请求,如果有则调用accept()连接,否则它将继续阻塞,直到客户端连接。一旦建立连接,就可以进行通信。
客户端:调用socket()分配通信端口,然后调用connect()发送SYN请求并等待服务器响应,服务器响应SYN-ACK段,客户端从connect()接收返回,同时响应一个ACK段,服务器收到后从accept()返回,连接建立成功。客户端一般不会调用 bind() 来绑定端口号。不是不允许bind(),服务器也不必bind()。
思考题:为什么不建议客户端执行bind()?
A:客户端不自己绑定时,系统会随机给客户端分配一个端口号,分配时操作系统不会与已有的端口号冲突。但是,如果你自己绑定,客户端程序很容易出现问题。假设您在 PC 上打开多个客户端进程。如果用户自己绑定端口号,必然会造成端口冲突,影响通信。
有了一些理论知识后,我们就可以编写代码了:
“服务器.c”
#包括
#包括
#包括
#包括
#包括
#包括
#包括
int 启动(int _port, const char* _ip)
{
int sock = socket(AF_INET,SOCK_STREAM,0);
如果(袜子
{
错误(“套接字”);
退出(1);
}
结构 sockaddr_in 本地;
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = inet_addr(_ip);
socklen_t len = sizeof(local);
如果(绑定(袜子,(结构 sockaddr*)&local , len)
{
perror(“绑定”);
退出(2);
}
如果(听(袜子,5)
{
perror(“听”);
退出(3);
}
返回袜子;
}
int main(int argc, const char* argv[])
{
if(argc != 3)
{
printf(“用法:%s [loacl_ip] [loacl_port]\n”,argv[0]);
返回 1;
}
int listen_sock = startup(atoi(argv[2]),argv[1]);//初始化
//用于接收客户端的socket地址结构
struct sockaddr_in 远程;
socklen_t len = sizeof(struct sockaddr_in);
而(1)
{
int sock = accept(listen_sock, (struct sockaddr*)&remote, &len);
如果(袜子
{
错误(“接受”);
继续;
}
printf(“获取客户端,ip:%s,端口:%d\n”,inet_ntoa(remote.sin_addr),ntohs(remote.sin_port));
字符缓冲区[1024];
而(1)
{
ssize_t _s = read(sock, buf, sizeof(buf)-1);
如果(_s > 0)
{
buf[_s] = 0;
printf(“client:%s”,buf);
}
别的
{
printf(“客户端退出!\n”);
休息;
}
}
}
返回0;
}
“客户端.c”
#包括
#包括
#包括
#包括
#包括
#包括
#包括
int main(int argc, const char* argv[])
{
if(argc != 3)
{
printf(“使用:%s [ip] [端口]\n”,argv[0]);
返回0;
}
//创建一个用于通信的套接字
int sock = socket(AF_INET,SOCK_STREAM, 0);
如果(袜子
{
错误(“套接字”);
返回 1;
}
//需要连接的是对端的地址,所以这里定义了服务器的地址结构
结构 sockaddr_in 服务器;
server.sin_family = AF_INET;
server.sin_port = htons(atoi(argv[2]));
server.sin_addr.s_addr = inet_addr(argv[1]);
socklen_t len = sizeof(struct sockaddr_in);
if(connect(sock, (struct sockaddr*)&server, len)
{
错误(“连接”);
返回 2;
}
//连接成功接收数据
字符缓冲区[1024];
而(1)
{
printf(“发送###”);
fflush(标准输出);
ssize_t _s = read(0, buf, sizeof(buf)-1);
buf[_s] = 0;
写(袜子,buf,_s);
}
关闭(袜子);
返回0;
}
但是这种实现方式只能进行单进程通信,也就是说一次只能连接一个客户端进行数据通信,显然不能满足服务端的基本要求。我们可以想办法在服务端修改代码。每次accept成功后,都会创建一个子进程,让子进程可以处理读写数据,而父进程继续监听和接受。
具体代码:
修改后的代码在服务端程序的socket()和bind()之间增加了如下代码:
诠释选择=1;
setsockopt(sock,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
//设置sockfd的选项SO_REUSEADDR为1,表示允许创建多个端口号相同但IP不同的端口
//套接字描述符
但是如果创建子进程很浪费资源,我们可以修改创建线程
四、sockaddr 数据结构
IPv4和IPv6的地址格式在“netinet/in.h”中定义,IPv4用sockaddr_in结构表示,包括16位端口号和32位IP地址;IPv6 由 sockaddr_in6 结构体表示,包括 16 位端口号、128 位 IP 地址 IP 地址和一些控制字段。
UNIX Domain Socket 的地址格式在 sys/un.h 中定义,由 sockaddr_un 结构体表示。每个套接字地址结构的开头都是相同的。前 16 位表示整个结构的长度(并非所有 UNIX 实现都有长度字段linux的通信方式套接字,如 Linux 没有),后 16 位表示地址类型。IPv6 和 UNIX Domain Socket 的地址类型分别定义为常量 AF_INET 和 AF_INET6、AF_UNIX。这样,只要获取到某个sockaddr结构的首地址,就可以根据地址类型字段确定该结构的内容,而无需知道它是哪种类型的sockaddr结构。所以socket API可以接受各种类型的sockaddr结构指针作为参数,比如bind、accept、connect等函数。
sockaddr_in 中的成员 struct in_addr sin_addr 代表一个 32 位的 IP 地址。但是我们通常使用点分十进制字符串来表示IP地址,下面的函数可以在字符串表示和in_addr表示之间进行转换。
in_addr 函数的字符串:
在这里写图片描述
原文链接:
请登录后发表评论
注册
社交帐号登录