24.SOCKET编程

发布时间 2023-09-06 19:43:12作者: CodeMagicianT

24.SOCKET编程

传统的进程间通信借助内核提供的IPC机制进行,但是只能限于本机通信,若要跨机通信,就必须使用网络通信。( 本质上借助内核-内核提供了socket伪文件的机制实现通信----实际上是使用文件描述符),这就需要用到内核提供给用户的socket API函数库。

既然提到socket伪文件,所以可以使用文件描述符相关的函数read write

可以对比pipe管道讲述socket文件描述符的区别。

使用socket会建立一个socket pair。

如下图,一个文件描述符操作两个缓冲区,这点跟管道是不同的,管道是两个文件描述符操作一个内核缓冲区。

2.1 socket编程预备知识

  • 网络字节序:

 ▶大端和小端的概念

  ▷大端: 低位地址存放高位数据,高位地址存放低位数据

  ▷小端: 低位地址存放低位数据,高位地址存放高位数据

  • 大端和小端的使用使用场合???

大端和小端只是对数据类型长度是两个及以上的,如int short,对于单字节没限制,在网络中经常需要考虑大端和小端的是IP和端口。

思考题: 0x12345678如何存放???

如何验证本机上大端还是小端??-----使用共用体。

编写代码endian.c进行测试, 测试本机上是大端模式还是小端模式?

网络传输用的是大端法,如果机器用的是小端法,则需要进行大小端的转换。

下面4个函数就是进行大小端转换的函数:

#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,s表示short,l表示long

上述的几个函数,如果本来不需要转换函数内部就不会做转换。

低端字节序

  • IP地址转换函数:

 p->表示点分十进制的字符串形式

 to->到

 n->表示network网络

int inet_pton(int af, const char *src, void *dst);

 ▶函数说明: 将字符串形式的点分十进制IP转换为大端模式的网络IP(整形4字节数)

 ▶参数说明:

  af: AF_INET

  src: 字符串形式的点分十进制的IP地址

  dst: 存放转换后的变量的地址

 例如: inet_pton(AF_INET, "127.0.0.1", &serv.sin_addr.s_addr);

手工也可以计算:如192.168.232.145,先将4个正数分别转换为16进制数,

192--->0xC0 168--->0xA8 232--->0xE8 145--->0x91,

最后按照大端字节序存放: 0x91E8A8C0,这个就是4字节的整形值。

const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

函数说明:网络IP转换为字符串形式的点分十进制的IP

参数说明:

 af:AF_INET

 src:网络的整形的IP地址

 dst:转换后的IP地址,一般为字符串数组

 size:dst的长度

返回值:

 成功--返回指向dst的指针

 失败--返回NULL,并设置errno

例如:IP地址为010aa8c0,转换为点分十进制的格式:

01---->1 0a---->10 a8---->168 c0---->192

由于从网络中的IP地址是高端模式,所以转换为点分十进制后应该为:192.168.10.1

socket编程用到的重要的结构体:struct sockaddr

struct sockaddr结构说明:

struct sockaddr 
{
	sa_family_t sa_family;
	char   sa_data[14];
}

struct sockaddr_in结构:

struct sockaddr_in
{
	sa_family_t  sin_family; /* address family: AF_INET */
	in_port_t   sin_port;  /* port in network byte order */
	struct in_addr sin_addr;  /* internet address */
};
/* Internet address. */
struct in_addr 
{
    uint32_t  s_addr;     /* address in network byte order */
};	 //网络字节序IP--大端模式

通过man 7 ip可以查看相关说明

2.2 socket编程主要的API函数介绍

  • int socket(int domain, int type, int protocol);

 ▶函数描述: 创建socket

 ▶参数说明:

  domain: 协议版本

   AF_INET IPV4

   AF_INET6 IPV6

   AF_UNIX AF_LOCAL本地套接字使用

 ▶type:协议类型

   SOCK_STREAM 流式,默认使用的协议是TCP协议

   SOCK_DGRAM 报式,默认使用的是UDP协议

 ▶protocal:

  一般填0,表示使用对应类型的默认协议。

 ▶返回值:

  成功:返回一个大于0的文件描述符

  失败:返回-1,并设置errno

当调用socket函数以后,返回一个文件描述符,内核会提供与该文件描述符相对应的读和写缓冲区,同时还有两个队列,分别是请求连接队列和已连接队列

  • int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

 ▶函数描述: 将socket文件描述符 和IP、PORT绑定

 ▶参数说明:

  sockfd: 调用socket函数返回的文件描述符

  addr: 本地服务器的IP地址和PORT,

   struct sockaddr_in serv;

  serv.sin_family = AF_INET;

   serv.sin_port = htons(8888);//转换成高端字节序

   //serv.sin_addr.s_addr = htonl(INADDR_ANY);

   //INADDR_ANY: 表示使用本机任意有效的可用IP

   inet_pton(AF_INET, "127.0.0.1", &serv.sin_addr.s_addr);

  addrlen: addr变量的占用的内存大小

 ▶返回值:

  成功: 返回0

  失败: 返回-1,并设置errno

服务端提供服务需要向外提供一个固定端口(一般不想允许随便动)

这是 bind() 函数的声明,它是 UNIX 和类 UNIX 系统(例如 Linux)中的套接字 API 的一部分。这个函数为一个套接字分配(或“绑定”)一个本地地址,通常用在服务器端的程序中,以便该套接字可以在指定的地址和端口上接收客户端的连接请求。

以下是这个函数参数的详细解析:

  1. sockfd:

    • 这是一个整数值,代表要绑定的套接字的文件描述符。这个描述符通常由之前的 socket() 函数调用返回。
  2. addr:

    • 这是一个指向结构体 struct sockaddr 的指针,但实际上,你通常会使用一个特定于地址类型的结构(如 struct sockaddr_in 用于 IPv4)并进行类型转换。
    • 这个结构体保存了你希望绑定到的本地地址和端口号。
  3. addrlen:

    • 这是一个值,指定 addr 结构体的大小。例如,如果 addr 指向一个 struct sockaddr_in 的实例,则 addrlen 应该被设置为 sizeof(struct sockaddr_in)

该函数的返回值:如果成功,bind() 返回0。如果失败,它返回 -1,并在全局变量 errno 中设置一个错误代码,以指示发生了什么错误。

以下是一个简单的示例,展示如何使用 bind() 函数为一个流套接字(TCP)绑定一个IPv4地址和端口:

#include <sys/socket.h>
#include <netinet/in.h>
#include <stdio.h>

int main() {
    int sockfd;
    struct sockaddr_in serv_addr;

    // 创建套接字
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        perror("socket error");
        return 1;
    }

    // 设置地址结构体
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);  // 绑定到任意地址
    serv_addr.sin_port = htons(8080);  // 使用端口8080

    // 绑定套接字
    if (bind(sockfd, (struct sockaddr *) &serv_addr, sizeof(serv_addr)) < 0) {
        perror("bind error");
        return 1;
    }

    // ... 其他代码,例如开始监听连接等

    return 0;
}

上述示例中,服务器创建一个套接字,并将其绑定到所有可用的 IPv4 地址(通过 INADDR_ANY)和端口 8080 上。

  • int listen(int sockfd, int backlog);

 ▶函数描述: 将套接字由主动态变为被动态

 ▶参数说明:

  sockfd: 调用socket函数返回的文件描述符

  backlog: 同时请求连接的最大个数(还未建立连接) (填一个大于0的数)

 ▶返回值:

  成功: 返回0

  失败: 返回-1,并设置errno

listen函数仅由TCP服务器调用

它做两件事:

1.当socket函数创建一个套接字时,它被假设为一个主动套接字,也就是说,它是一个将调用connect发起连接的客户套接字。listen函数把一个未连接的套接字转换为一个被动套接字,指示内核应该接受指向该套接字的连接请求。根据TCP状态转换图,调用listen导致套接字从CLOSED状态转换到LISTEN状态。

2.listen函数的第二个参数规定了内核应该为相应套接字排队的最大连接个数:

#include<sys/socket.h>
int listen(int sockfd, int backlog);
返回:若成功则为0,若出错则为-1

为了理解其中的backlog参数,我们必须认识到内核为任何一个给定的监听套接字维护两个队列:

(1)未完成连接队列,每个这样的SYN分节对应其中一项:已由某个客户发出并到达服务器,而服务器正在等待完成相应的TCP三路握手过程。这些套接字处于SYN_RECV状态

(2)已完成连接队列,每个已完成TCP三路握手过程的客户对应其中一项。这些套接字处于ESTABLISHED状态。

下图描绘了监听套接字的两个队列

每当在未完成连接队列中创建一项时,来自监听套接字的参数就复制到即将建立的连接中,连接的创建机制是完全自动的。无需服务器进程插手。下图展示了用这两个队列建立连接时所

交换的分组:

当来自客户的SYN到达时,TCP在未完成连接队列中创建一个新项,然后响应以三路握手的第二个分节:服务器的SYN响应,其中捎带对客户SYN的ACK。这一项一直保留在未完成连接队列中,直到三路握手的第三个分节(客户对服务器的SYN的ACK)到达或者该项超时为止。

如果三路握手正常完成,该项从未完成连接队列移到已完成连接队列的队尾。当进程调用accept时,已完成连接队列中的队头项将返回给进程,或者该队列为空,那么进程就被投入睡眠,直到TCP在该队列中放入一项才唤醒它。

总结:

1.accept()函数不参与三次握手,而只负责从建立连接队列中取出一个连接和socketfd进行绑定;

2.backlog参数决定了未完成队列和已完成队列中连接数目之和的最大值(从内核的角度,是否这个和就是等于sock->recv_queue ?);

3.accept()函数调用,会从已连接队列中取出一个“连接”(可以说描述的数据结构listensocket->sock->recv_queue[sk_buff] ? ),未完成队列和已完成队列中连接目之和将减少1;即accept将监听套接字对应的sock的接收队列中的已建立连接的sk_buff取下(从该sk_buff中可以获得对端主机的发送过来的tcp/ip数据包)

4.监听套接字的已完成队列中的元素个数大于0,那么该套接字是可读的。

5.当程序调用accept的时候(设置阻塞参数),那么判定该套接字是否可读,不可读则进入睡眠,直至已完成队列中的元素个数大于0(监听套接字可读)而唤起监听进程)





















  • int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

 ▶函数说明:获得一个连接,若当前没有连接则会阻塞等待。

 ▶函数参数:

  sockfd:调用socket函数返回的文件描述符

  addr:传出参数,保存客户端的地址信息

  addrlen:传入传出参数,addr变量所占内存空间大小

 ▶返回值:

  成功:返回一个新的文件描述符,用于和客户端通信

  失败:返回-1,并设置errno值。

accept函数是一个阻塞函数,若没有新的连接请求,则一直阻塞。

从已连接队列中获取一个新的连接,并获得一个新的文件描述符,该文件描述符用于和客户端通信。 (内核会负责将请求队列中的连接拿到已连接队列中)

既然服务端已经很虔诚了,很真诚了,处于倾听状态,那么该是去尝试接受客户端请求的时候了,别只顾着倾听,不去接纳别人。

接纳客户端请求的函数是accept, 我们先来看看函数的原型:

WINSOCK_API_LINKAGE
SOCKET
WSAAPI
accept(
    SOCKET s,
    struct sockaddr FAR * addr,
    int FAR * addrlen
    );

函数的第一个参数用来标识服务端套接字(也就是listen函数中设置为监听状态的套接字),第二个参数是用来保存客户端套接字对应的“地方”(包括客户端IP和端口信息等), 第三个参数是“地方”的占地大小。返回值对应客户端套接字标识。

实际上是这样的: accept函数指定服务端去接受客户端的连接,接收后,返回了客户端套接字的标识,且获得了客户端套接字的“地方”(包括客户端IP和端口信息等)。

accept函数非常地痴情,痴心不改:

如果没有客户端套接字去请求,它便会在那里一直痴痴地等下去,直到永远(注意, 此处讨论的是阻塞式的socket. 如果是非阻塞式的socket, 那么accept函数就没那么痴情了, 而是会立即返回, 并意犹未尽地对未来的客户端扔下一句话: 我等了你, 你不来, 那就算了, 我懒得鸟你)。

来看看accpt函数的用法:

unsigned int sockConn = accept(sockSrv,(SOCKADDR*)&addrClient, &len);
  • int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

 ▶函数说明: 连接服务器

 ▶函数参数:

  sockfd:调用socket函数返回的文件描述符

  addr:服务端的地址信息

  addrlen:addr变量的内存大小

 ▶返回值:

  成功:返回0

  失败:返回-1,并设置errno值

接下来就可以使用write和read函数进行读写操作了。

除了使用read/write函数以外,还可以使用recv和send函数

  • 读取数据和发送数据:
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t send(int sockfd, const void *buf, size_t len, int flags); 

对应recv和send这两个函数flags直接填0就可以了.

注意:如果写缓冲区已满,write也会阻塞,read读操作的时候,若读缓冲区没有数据会引起阻塞。

使用socket的API函数编写服务端和客户端程序的步骤图示:

服务端开发流程:

1 创建socket,返回一个文件描述符lfd---socket()
--该文件描述符用于监听客户端连接
2 将lfd和IP  PORT进行绑定----bind()
3 将lfd由主动变为被动监听----listen()
4 接受一个新的连接,得到一个文件描述符cfd----accept()
---该文件描述符是用于和客户端进行通信的
5 while(1)
{
	接收数据---read或者recv
	发送数据---write或者send
}
6 关闭文件描述符----close(lfd)  close(cfd);

客户端的开发流程:

1 创建socket,返回一个文件描述符cfd---socket()
---该文件描述符是用于和服务端通信
2 连接服务端---connect() 
3 while(1)
{
	//发送数据---write或者send
	//接收数据---read或者recv
}
4 close(cfd)

根据服务端和客户端编写代码的流程,编写代码并进行测试。

测试过程中可以使用netstat命令查看监听状态和连接状态

netstat命令:

a表示显示所有,

n表示显示的时候以数字的方式来显示

p表示显示进程信息(进程名和进程PID)

作业:

​ 自己编写代码熟悉一下服务端和客户端的代码开发流程;

​ 设计服务端和客户端通信协议(属于业务层的协议)

​ 如发送结构体

typedef struct teacher_ 
{
	int tid;
	char name[30];
	int age;
	char sex[30];
	int sal;
} teacher;


typedef struct student_ 
{
	int sid;
	char name[30];
	int age;
	char sex[30];
}student;


typedef struct SendMsg_ 
{
	int type;//1 - teacher;2 - student
	int len;//
	char buf[0];//变长发送数据
}SendMsg;

例子:

//服务端程序
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <ctype.h>

int main()
{
	//创建socket
	//int socket(int domain, int type, int protocol);
	int lfd = socket(AF_INET, SOCK_STREAM, 0);
	if (lfd < 0)
	{
		perror("socket error");
		return -1;
	}

	//int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
	//绑定
	struct sockaddr_in serv;
	bzero(&serv, sizeof(serv));
	serv.sin_family = AF_INET;
	serv.sin_port = htons(8888);
	serv.sin_addr.s_addr = htonl(INADDR_ANY); //表示使用本地任意可用IP
	int ret = bind(lfd, (struct sockaddr*)&serv, sizeof(serv));
	if (ret < 0)
	{
		perror("bind error");
		return -1;
	}

	//监听
	//int listen(int sockfd, int backlog);
	listen(lfd, 128);

	//int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
	struct sockaddr_in client;
	socklen_t len = sizeof(client);
	int cfd = accept(lfd, (struct sockaddr*)&client, &len);  //len是一个输入输出参数
	//const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

	//获取client端的IP和端口
	char sIP[16];
	memset(sIP, 0x00, sizeof(sIP));
	printf("client-->IP:[%s],PORT:[%d]\n", inet_ntop(AF_INET, &client.sin_addr.s_addr, sIP, sizeof(sIP)), ntohs(client.sin_port));
	printf("lfd==[%d], cfd==[%d]\n", lfd, cfd);

	int i = 0;
	int n = 0;
	char buf[1024];

	while (1)
	{
		//读数据
		memset(buf, 0x00, sizeof(buf));
		n = read(cfd, buf, sizeof(buf));
		if (n <= 0)//读失败,对方关闭链接
		{
			printf("read error or client close, n==[%d]\n", n);
			break;
		}
		printf("n==[%d], buf==[%s]\n", n, buf);

		for (i = 0; i < n; i++)
		{
			buf[i] = toupper(buf[i]);
		}

		//发送数据
		write(cfd, buf, n);
	}

	//关闭监听文件描述符和通信文件描述符
	close(lfd);
	close(cfd);

	return 0;
}

这是一个基于TCP的服务器程序,使用了套接字API来实现。下面是对该代码的详细解析:

1.包含头文件

  • 各种系统头文件被引入以支持套接字编程和相关操作。

2.main函数开始

3.创建套接字

int lfd = socket(AF_INET, SOCK_STREAM, 0);

使用socket函数创建一个IPv4(AF_INET)的TCP套接字(SOCK_STREAM)。

4.检查套接字创建是否成功
如果lfd的值小于0,表示套接字创建失败,然后程序会打印错误并退出。

5.绑定

struct sockaddr_in serv;
bzero(&serv, sizeof(serv));
serv.sin_family = AF_INET;
serv.sin_port = htons(8888);
serv.sin_addr.s_addr = htonl(INADDR_ANY);//表示使用本地任意可用IP
int ret = bind(lfd, (struct sockaddr*)&serv, sizeof(serv));

使用bind函数绑定套接字lfd到指定的地址和端口。

  • 使用struct sockaddr_in来指定地址和端口。
  • IP地址设置为INADDR_ANY,这意味着服务器可以在任何网络接口上接收客户端连接。
  • 端口号设置为8888。

struct sockaddr 是一个通用的套接字地址结构体,而 struct sockaddr_in 是一个与IP专门相关的地址结构体。

代码使用 TCP/IP 协议族,所以需要特定的结构体来存储IP地址和端口号。这就是 struct sockaddr_in。但是,一些socket函数(如bind、accept)的参数设计时考虑到了通用性,所以它们接收一个指向 struct sockaddr的指针。
总结以下几点:
1. 为什么定义了`sockaddr_in:因为它是专门为IPv4设计的。它包含了IP地址和端口号。
2. 为什么函数参数使用sockaddr:这是为了通用性。在socket编程中,还有其他类型的地址结构体,例如sockaddr_in6(用于IPv6)和 sockaddr_un(用于Unix domain sockets)。使用通用的sockaddr可以确保这些函数与不同的地址结构体兼容。
3. 类型转换:当我们使用这些函数时,会将 sockaddr_in 或其他特定的地址结构体的指针转换为 `sockaddr` 指针。这就是为什么你经常在代码中看到(struct sockaddr*)&serv这样的类型转换。
因此,你的代码中使用sockaddr_in是因为你正在使用IP协议族,而函数参数中使用sockaddr是为了保持函数接口的通用性。

在这段代码中,8888是用于服务器套接字的端口号。

在TCP/IP网络中,端口号是用于区分同一台计算机上不同应用程序或进程的网络通信的方式。端口号是一个16位的数字,范围从0到65535。其中,0到1023的端口被定义为“众所周知的端口”(Well-Known Ports),通常用于标准服务。例如,HTTP使用端口80,HTTPS使用端口443等。

在这个服务器程序中,选择了8888作为监听端口,意味着该服务器将等待客户端在该端口上发起连接请求。当客户端尝试连接到服务器的IP地址上的8888端口时,这个服务器程序就可以接受该连接。

serv.sin_port = htons(8888);

这里的htons函数是用来将端口号从主机字节序转换为网络字节序。这是网络编程中的常见做法,因为不同的计算机架构可能有不同的字节序(大端和小端)。htons函数确保端口号在所有平台上都被正确解释。

6.开始监听

listen(lfd, 128);

使用listen函数,该函数使得lfd套接字变为被动套接字,等待客户端的连接请求。128是待处理连接的队列长度。

7.接受客户端连接
使用accept函数等待并接受客户端的连接。这会阻塞,直到有一个客户端连接。

8.显示客户端信息
使用inet_ntop函数将客户端的IP地址从二进制转换为点分十进制格式,并显示其IP和端口。

9.数据交互

  • 服务器进入一个无限循环,不断地读取客户端发送的数据。
  • 读取的数据存储在buf中。
  • 如果读取失败或客户端关闭连接,服务器将打印消息并跳出循环。
  • 读取到的数据被转换为大写。
  • 转换后的数据会发送回客户端。

10.关闭连接
使用close函数关闭与客户端的连接(cfd)和监听的文件描述符(lfd)。

总之,这是一个简单的TCP回显服务器,它接收客户端发送的数据,将其转换为大写,然后将其发送回客户端。

参考资料:

listen() 函数