二、网络socket编程

发布时间:2024年01月16日

网络socket编程(open-write/read-close)

使用TCP/IP协议的应用程序通常采用应用编程接口:UNIX BSD的套接字(socket)来实现网络进程之间的通信。

socket是应用层与TCP/IP协议族通信的中间软件抽象层
socket
Socket起源于Unix,而Unix/Linux基本哲学之一就是“一切皆文件”。在许多操作系统中,套接字API最初是作为UNIX操作系统的一部分而开发的,所以套接字API与系统的其他I/O设备集成在一起。应用程序要为因特网通信而创建一个套接字(socket)时,操作系统就返回一个小整数作为描述符(descriptor)来标识这个套接字。然后应用程序以该描述符作为传递参数,通过调用相应函数(如read、write、close等)来完成某种操作(如从套接字中读取或写入数据)。

服务器端与客户端通信基本流程如下:

CS通信流程

socket操作API函数

socket是“open-write/read-close”模式的一种实现
以TCP为例介绍几个基本的socket接口函数

·socket()创建一个socket描述符

int socket(int domain, int type, int protocol);
  • domain 协议域(协议族)其决定了socket的地址类型,在通信过程中必须采用对应的地址
    常用的协议族有:AF_INET(即地址类型为ipv4地址与端口号的组合)、AF_INET6、AF_LOCAL(亦称AF_UNIX,Unix域socket)其一般用在命名socket中,用一个绝对路径名作为地址
  • type指定socket类型,常见的类型有:SOCK_STREAM(其为TCP类型)、SOCK_DGRAM(其为UDP类型)、SOCK_RAW、SOCK_PACKET、SOCK_EQPACKET
  • protocol 指定协议,常用的协议有:IPPROTO_TCP、IPPPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC
    注意:type和protocol并不是可以随意组合的,比如:SOCK_STREAM不可以和IPPPTOTO_UDP组合。当protocol为零时,会自动选择type类型对应的默认协议

服务器端中socket()的创建

listen_fd = socket(AF_INET, SOCK_STREAM, 0);

客户端中socket()的创建

con_fd = socket(AF_INET, SOCK_STREAM, 0);

·bind() 给返回的socket描述字赋值一个地址

调用socket创建一个socket时,返回的socket描述字它存在于协议族(address family,AF_XXX)空间中,但没有一个具体的地址。如果想要给它赋值一个地址,就必须调用bind()函数。通常服务器在启动的时候都会绑定一个众所周知的地址(如ip地址+端口号),用于提供服务,客户就可以通过它来接连服务器;而客户端就不用指定,由系统自动分配一个端口号和自身的ip地址组合。这就是为什么通常服务器端在listen之前会调用bind(),而客户端就不会调用,而是在connect()时由系统随机生成一个。当然客户端也可以在调用connect()之前bind一个地址和端口,这样就能使用特定的IP和端口来连服务器了。

 int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
  • sockfd:即socket描述字,它是通过socket()函数创建了,唯一标识一个socket。bind()函数就是将给这个描述字绑定一个名字。
  • addr:一个const struct sockaddr*指针,指向要绑定给sockfd的协议地址。这个地址结构根据地址创建socket时的地 址协议族的不同而不同,但最终都会强制转换后赋值给sockaddr这种类型的指针传给内核;
  • addrlen:对应的是地址的长度。
通用套接字 sockaddr 类型定义:
typedef unsigned short int sa_family_t;
struct sockaddr {
sa_family_t sa_family; /* 2 bytes address family, AF_xxx */
char sa_data[14]; /* 14 bytes of protocol address */
}
ipv4对应的是sockaddr_in类型定义:
typedef unsigned short sa_family_t;
typedef uint16_t in_port_t;
struct in_addr {
uint32_t s_addr;
};
struct sockaddr_in {
sa_family_t sin_family; /* 2 bytes address family, AF_xxx such as AF_INET */
in_port_t sin_port; /* 2 bytes port*/
struct in_addr sin_addr; /* 4 bytes IPv4 address*/
/* Pad to size of `struct sockaddr'. */unsigned char sin_zero[8]; /* 8 bytes unused padding data, always set be zero */
};
ipv6对应的sockaddr_in6类型定义:
typedef unsigned short sa_family_t;
typedef uint16_t in_port_t;
struct in6_addr
{
union
{
uint8_t __u6_addr8[16];
uint16_t __u6_addr16[8];
uint32_t __u6_addr32[4];
} __in6_u;
}
struct sockaddr_in6 {
sa_family_t sin6_family; /*2B*/
in_port_t sin6_port; /*2B*/
uint32_t sin6_flowinfo; /*4B*/
struct in6_addr sin6_addr; /*16B*/
uint32_t sin6_scope_id; /*4B*/
};
Unix域对应的sockaddr_un类型定义
#define UNIX_PATH_MAX 108
struct sockaddr_un {
sa_family_t sun_family;
char sun_path[UNIX_PATH_MAX];
};

其实只需要知道每种不同协议格式地址(sockaddr_in、sockaddr_in6、sockaddr_un)前两个字节(sin_family、sin6_family、sun_family)对应到通用套接字结构体(struct sockaddr)中的sa_family值以及传进来的结构体变量的首地址就可以了,对于struct sockaddr中sa_data成员占多少个自己、或要不要都无所谓,因为强制类型转换我们只需要相应的协议地址格式的结构体(sockaddr_in、sockaddr_in6、sockaddr_un)以及首地址就行了。
以ipv4为例:

 #define LISTEN_PORT		9999;

 ...

 struct sockaddr_in 	serv_addr;
 
 ...
 
 memset(&serv_addr, 0, sizeof(serv_addr))
 serv_addr.sin_family = AF_INET;	//ipv4
 serv_addr.sin_port = htons(LISTEN_PORT);	//监听端口号
 serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);	//监听所有IP
 if( bind(listen_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0 )
 {
 	printf("create socket failure: %s\n", strerror(errno));
 	return -2;
 }
 /*(struct sockaddr *)&serv_addr:serv_addr为struct sockaddr_in类型,
 bind()中要求const struct sockaddr *addr,
 故(struct sockaddr *)&serv_addr实现强制转换 */
网络字节序(大端字节序)和主机字节序(小端字节序)
  1. Big-Endian就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端。
  2. Little-Endian就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。
    网络通信中一般是大端字节序
    当前系统中一般是小端字节序
    所以将一个地址绑定给socket的时候,要先将主机字节序转换成网络字节序
 serv_addr.sin_port = htons(LISTEN_PORT);	//监听端口号
 serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);	//监听所有IP

上述调用 **htons()**和 **htonl()**两个函数实现将端口号和IP地址由主机字节序转换成网络字节序
h表示host, n表示network, s表示short(2字节/16位), l表示long(4字节/32位)。因为端口号是16位的,所以用htons()把端口号从主机字节序转换成网络字节序, 而IP地址是32位的,所以htonl()函数把IP地址从主机字节序转换成网络字节序。INADDR_ANY就是指定地址为0.0.0.0的地址,这个地址事实上表示不确定地址,或“所有地址”、“任意地址”。 一般来说,在各个系统中均定义成为0值。这里也就意味着监听所有的IP地址。

·listen()

注意:socket有两种类型:

  1. 主动发出请求类型(客户端的)
  2. 被动监听类型(服务器端的)

socket被创建以后默认为active主动模式,所以对于服务器端而言,写socket后一定要调用listen()函数,其可将socket由主动切换到被动监听模式,等待客户的连接请求。

int listen(int sockfd, int backlog);
  • sockefd: socket()系统调用创建的要监听的socket描述字
  • backlog: 相应socket可以在内核里排队的最大连接个数
    backlog说明:
    TCP建立连接是要进行三次握手,但是否完成三次握手后,服务器需要维护这种状态:
  • 半连接状态为:服务器处于Listen状态时收到客户端SYN报文时放入半连接队列中,即SYN
    queue(服务器端口状态为:SYN_RCVD)。
  • 全连接状态为:TCP的连接状态从服务器(SYN+ACK)响应客户端后,到客户端的ACK报文到达服务器之前,则一直保留在半连接状态中;

backlog其实是一个连接队列,在Linux内核2.2之前,backlog大小包括半连接状态和全连接状态两种队列大小,当服务器接收到客户端的ACK报文后,该条目将从半连接队列搬到全连接队列尾部,即 accept queue (服务器端口状态为:ESTABLISHED)。在Linux内核2.2之后,分离为两个backlog来分别限制半连接(SYN_RCVD状态)队列大小和全连接(ESTABLISHED状态)队列大小。
  SYN queue 队列长度由 /proc/sys/net/ipv4/tcp_max_syn_backlog 指定,默认为2048。
  Accept queue 队列长度由 /proc/sys/net/core/somaxconn 和使用listen函数时传入的参数,二者取最小值。默认为128。
  在Linux内核2.4.25之前,是写死在代码常量 SOMAXCONN ,在Linux内核2.4.25之后,在配置文件/proc/sys/net/core/somaxconn 中直接修改,或者在 /etc/sysctl.conf 中配置 net.core.somaxconn = 128 。

accept()函数 接收来自客户端的连接申请

该函数属于阻塞函数,即一旦调用该函数,若不满足条件,该函数则不会返回(等待客户端连接,若客户端不连接则默认为阻塞模式)

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • sockfd: 服务器开始调用socket()函数生成的,称为监听socket描述字;
  • *addr: 用于返回客户端的协议地址,这个地址里包含有客户端的IP和端口信息等;
  • addrlen: 返回客户端协议地址的长度

accept函数的返回值是由内核自动生成的一个全新的描述字(fd),代表与返回客户的TCP连接。如果想发送数据给该客户端,则我们可以调用write()等函数往该fd里写内容即可;而如果想从该客户端读内容则调用read()等函数从该fd里读数据即可。一个服务器通常通常仅仅只创建一个监听socket描述字,它在该服务器的生命周期内一直存在。内核为每个由服务器进程接受的客户连接创建了一个新的socket描述字,当服务器完成了对某个客户的服务,就应当把该客户端相应的的socket描述字关闭。
参考如下:

  1. 不关心客户端的IP地址和端口号的信息
client_fd = accept(listen_fd, NULL, NULL);
  1. 要获取客户端的IP地址和端口号
int main()
{
	struct sockaddr_in		cli_addr;
	socklen_t				cliaddr_len;//局部变量,存放在栈中,不赋初始值时每次运行时给个随机值
	cliaddr_len = sizeof(cli_addr);//若不赋值,有时会运行成功,有时会报错提示为无效参数
	client_fd = accept(listen_fd, (struct sockaddr*)&cli_addr, &cliaddr_len);
	if(client_fd < 0)
 {
	printf("accept new socket failure: %s\n", strerror(errno));
 	return -2;
 }
 	printf("Accept new client[%s:%d] with fd [%d]\n", inet_ntoa(cli_addr.sin_addr),
 	ntohs(cli_addr.sin_port), client_fd);
}

accept()系统调用将会把客户端的信息保存在cli_addr这个结构体变量中,其中cli_addr是struct sockaddr_in 这种IPv4地址类型,客户端的IP地址和端口号都保存在该结构体中。在该结构体中IP地址是以32位整形值的形式存放,端口号也是以网络字节序的形式存放。这时可以使用inet_ntoa() 函数将32位整形的IP地址转换成点分十进制字符串格式的IP地址“127.0.0.1”,我们也可以调用**ntohs()**函数将网络字节序的端口号转换成主机字节序的端口号。
强调:

  1. socket()函数创建的监听socket字符字仅仅用做客户端连接的使用,不会做为数据的通信使用
  2. 一旦accept接收了客户端的连接请求成功后,会返回一个新的文件描述符,并且每一个文件描述符都会有一个发送和接收的buf,故同时会出现TCP粘包现象

·connect() 客户端调用连接服务器

connect() 函数也是一个阻塞函数。
TCP客户端程序调用socket()创建socket fd之后,就可以调用connect()函数来连接服务器。如果客户端这时调用connect()发出连接请求,服务器端就会接收到这个请求并使accept()返回,accept()返回的新的文件描述符就是对应到该客户的TCP连接,通过这两个文件描述符(客户端connect的fd和服务器端accept返回的fd)就可以实现客户端和服务器端的相互通信。

int connect(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
  • sockfd: 客户端的socket()创建的描述字
  • addr: 要连接的服务器的socket地址信息,这里面包含有服务器的IP地址和端口等信息
  • addrlen: socket地址的长度

在调用connect之前,需要先设置服务器端的IP地址和端口等信息到addr中去,参考示例:

 memset(&serv_addr, 0, sizeof(serv_addr));
 serv_addr.sin_family = AF_INET;
 serv_addr.sin_port = htons(SERVER_PORT);
 inet_aton( SERVER_IP, &serv_addr.sin_addr );

 if( connect(conn_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0)
 {
 printf("connect to server [%s:%d] failure: %s\n", SERVER_IP, SERVER_PORT, strerror(errno));
 return 0;
 }

SERVER_IP 可定义为“127.0.0.1”,这是一个回环测试IP地址,该地址不管在哪台机器上都表示本机,因为服务器和客户端都在同一台机器上运行,这时我们可以直接使用该地址。如果我们希望连接的服务器运行在另外一台主机上,则需将它改为服务器主机的IP地址即可,如“192.168.0.5”。
IP地址“127.0.0.1”这是点分十进制形式的字符串形式,而在结构体struct sockaddr_in 中IP地址是以32位(即4字节整形类型)数据保存的,这时我们可以调用 inet_aton() 函数将点分十进制字符串转换成 32位整形类型。同样,端口号也要使用 htons() 函数从主机字节序转成网络字节序。

【socket编程什么时候发起三路握手,什么时候完成三路握手?】

答:socket三路握手是在客户端开始调用connect()函数时发起的,但三路握手的完成不是在accept()阶段完成的;三路握手的完成由Linux操作系统帮忙完成的;三路握手完成以后将发出的申请放到队列中,即listen()函数中提到的backlog,accept()函数从队列中拿取第一个并返回一个socket_fd;

·read()、write()函数

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

read()函数调用的返回值就是实际读到的字节数,返回值小于0出错,等于0表示socket断开
write()函数调用的返回值就是实际写的字节数,返回值小于0出错,

·close()、shutdown()函数

在服务器与客户端建立连接之后,会进行一些读写操作,完成了读写操作就要关闭相应的socket描述字,好比操作完打开的文件要调用close()来关闭一样。close一个TCP socket的缺省行为时把该socket标记为以关闭,然后立即返回到调用进程。该描述字不能再由调用进程使用,也就是说不能再作为read或write的第一个参数。

int close(int fd);

如果对socket fd调用close()则会触发该TCP连接断开的四路握手,有些时候需要数据发送出去并到达对方之后才能关闭socket套接字,则可以调用shutdown()函数来半关闭套接字:

int shutdown(int sockfd, int how);

如果how的值为 SHUT_RD 则该套接字不可再读入数据了; 如果how的值为 SHUT_WR 则该套接字不可再发送数据了; 如果how的值为 SHUT_RDWR 则该套接字既不可以读,也不可以写数据了。

文章来源:https://blog.csdn.net/weixin_62491229/article/details/135585635
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。