一.网络准备

1.套接字

TCP/IP 协议中,“ip 地址+TCP 或UDP 端口号”唯一标识网络通讯中的一个进程。“IP 地址+端口号”就对应一个socket。欲建立连接的两个进程各自有一个 socket 来标识,那么这两个 socket 组成的 socket pair 就唯一标识一个连接。因此可以用 socket 来描述网络连接的一对一关系。

在这里插入图片描述

简单来说,用数学里面的坐标系类比一下,(IP地址,端口号)表示一个进程即一个套接字,两个套接字连线表示通信。

  • 一个文件描述符指向一个套接字(该套接字内部由内核借助两个缓冲区实现)。
  • 在通信过程中, 套接字一定是成对出现的。

2.网络字节序列

计算机(主机)对于多字节序列采用的是小端法存储,而网络通信的多字节序列是大端法存储的,因此在进行网络通信时,主机把数据送到客户端的发送缓冲区前需要把数据进行网络字节转换,同样的接受主机在服务器的接收端取出数据后,又要把数据转化成主机字节序列。

#include <arpa/inet.h>

uint32_t htonl(uint32_t hostlong);                    //本地---》网络(IP地址)
uint16_t htons(uint16_t hostshort);                   //本地---》网络(端口)
uint32_t ntohl(uint32_t netlong);                     //网络---》本地(IP地址)
uint16_t ntohs(uint16_t netshort);                    //网络---》本地(端口)

解释:

  • h表示本地主机(host)
  • to表示变换
  • n表示网络(net)
  • l表示IP地址(IP地址用32位表示)
  • s表示端口

注意:我们平时用点分十进制表示IP地址,所以要想进行网络字节转换,先要使用atoistring转化成int

3.IP地址转换函数

为了直接从点分十进制进行转化成网络字节序,所以有了IP地址转换函数。

#include <arpa/inet.h>

int inet_pton(int af, const char *src, void *dst);                               //IP地址---》网络

解释:

  • af:表示版本协议号,只能取AF_INET表示IPv4或AF_INET6表示IPv6
  • src:传入的IP地址(点分十进制,string类型)
  • dst:传出的转换后的IP地址(网络字节序)
  • 返回值:1表示成功;0表示异常,说明传入的src不是一个IP地址;-1表示失败
#include <arpa/inet.h>

const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);       //网络---》IP地址

解释:

  • src:传入的转换后的IP地址(网络字节序)
  • dst:传出的IP地址(点分十进制,string类型)
  • size:dst缓冲区的大小
  • 返回值:成功返回dst,失败返回NULL

4.sockaddr数据结构

直接说结论:后面的bind()函数的参数要用到strcut sockaddr这种结构体的指针,但是现在IPv4使用的结构体普遍是strcut sockaddr_in,所以现在的使用方式是我们一般先定义strcut sockaddr_in的结构体,使用的时候进行强转。比如:

struct sockaddr_in servaddr;
bind(listen_fd, (struct sockaddr *)&servaddr, sizeof(servaddr));	

其余不要管,重点看bind()的第二个参数。

所以,现在我们重点学习一下strcut sockaddr_in结构体:

struct sockaddr_in {
	__kernel_sa_family_t sin_family; 			  	//地址结构类型
	__be16 sin_port;					 		  //端口号(网络字节序)
	struct in_addr sin_addr;					   //IP地址(网络字节序)
};

struct in_addr {						
	__be32 s_addr;                                  //IP地址(网络字节序)
};

注意:这里的sockaddr_in的第三个参数是一个结构体的形式,所以赋值时需要注意,例如:

struct sockaddr_in addr;
addr.sin_family = AF_INET/AF_INET6;
addr.sin_port = htons(9527);
int dst;
inet_pton(AF_INET,"192.157.22.45",(void *)&dst);
addr.sin_addr.s_addr = dst;

这里使用强转把dst变成void *类型,赋值是不要忘了.s_addr进入对应结构体内部赋值。

一般的时候,我们用的是本地主机写代码,所以可以将:

int dst;
inet_pton(AF_INET,"192.157.22.45",(void *)&dst);
addr.sin_addr.s_addr = dst;

写成

addr.sin_addr.s_addr = htonl(INADDR_ANY);

这里的INADDR_ANY是一种宏,表示取出系统中有效的任意IP地址,但是是二进制类型。

二.网络套接字函数

1.socket模型

在这里插入图片描述

  • socket():创建套接字
  • bind():绑定服务器的IP和端口
  • listen():设置同时监听上限
  • accept():阻塞监听客户端连接
  • connect():客户端绑定IP和端口,进行连接

注意,在一个模型中其实有三个socket套接字,其中服务器刚开始的套接字在accept()函数里作为参数传入,返回另一个套接字来与客户端进行真正的连接。

2.socket函数

函数原型:

#include <sys/socket.h>

int socket(int domain, int type, int protocol);
  • domain:用来指定传输协议,AF_INETAF_INET6AF_UNIX(表示本地协议,使用在Unix和Linux系统上,一般都是当客户端和服务器在同一台及其上的时候使用)
  • type:用来指定协议类型,可以取SOCK_STREAM表示流式协议或SOCK_DGRAM表示报式协议
  • protocol:传0表示默认协议

type为SOCK_STREAM且protocol=0表示使用TCP传输,type为SOCK_DGRAM且protocol=0表示使用UDP传输。

3.bind函数

作用:给socket绑定一个地址结构(IP地址+端口)

#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfdsocket文件描述符,一般取上面socket()函数的返回值
  • *addr:上面介绍过,用来指定地址结构信息
  • addrlen:地址结构大小,总是取sizeof(addr)

4.listen函数

作用:设置同时与服务器建立连接的客户端数量

#include <sys/socket.h>

int listen(int sockfd, int backlog);
  • sockfd:socket文件描述符,一般取上面socket()函数的返回值
  • backlog:连接上限,最大为128

5.accept函数

作用:阻塞等待客户端连接,成功的话,返回一个成功与客户端连接的socket文件描述符

#include <sys/socket.h>

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • sockdf: socket文件描述符,一般取上面socket()函数的返回值,相当于把开始的socket传入
  • addr:传出参数,返回成功链接客户端地址信息,含IP地址和端口号
  • addrlen:传入传出参数(值-结果),传入sizeof(addr)大小,函数返回时返回真正接收到地址结构体的大小
  • 返回值: 成功返回一个新的socket文件描述符,用于和客户端通信,失败返回-1,设置errno

6.connect函数

作用:使用现有的socket与服务器建立连接

#include <sys/socket.h>

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfdsocket文件描述符,一般取上面socket()函数的返回值
  • *addr:服务器的地址结构,用来连接
  • addrlen:地址结构大小,总是取sizeof(addr)

7.read函数

read函数用于从socket中读取数据。它的原型如下:

ssize_t read(int fd, void *buffer, size_t count);
  • fd:是socket的文件描述符,用于标识一个打开的socket。
  • buffer:是一个指针,指向一个缓冲区,该缓冲区用于存储从socket读取的数据。
  • count:指定了buffer的大小,即希望读取的最大字节数。

read函数返回实际读取的字节数。如果read返回0,表示连接已经关闭。如果返回-1,表示发生了错误,此时可以通过errno变量查看错误类型。

8.write函数

write函数用于向socket写入数据。它的原型如下:

ssize_t write(int fd, const void *buffer, size_t count);
  • fd:同样是socket的文件描述符。
  • buffer:是一个指针,指向包含要发送数据的缓冲区。
  • count:指定了要发送的字节数。
  • write函数返回实际写入的字节数。如果返回-1,表示发生了错误,同样可以通过errno变量查看错误类型。

8.文件描述符

fd_set 是一个数据类型,用于在 select 系统调用中表示一组文件描述符。以下是与 fd_set 相关的一些常用宏函数,它们用于操作 fd_set:

  • FD_ZERO:
    作用:将 fd_set 清零,即初始化 fd_set,使其不包含任何文件描述符。
    使用方法:FD_ZERO(&fdset);
  • FD_SET:
    作用:将一个文件描述符添加到 fd_set 中。
    使用方法:FD_SET(fd, &fdset); 其中 fd 是要添加的文件描述符,fdset 是 fd_set 的实例。
  • FD_CLR:
    作用:从 fd_set 中移除一个文件描述符。
    使用方法:FD_CLR(fd, &fdset); 其中 fd 是要移除的文件描述符,fdset 是 fd_set 的实例。
  • FD_ISSET:
    作用:测试一个文件描述符是否在 fd_set 中。
    使用方法:FD_ISSET(fd, &fdset); 如果 fd 在 fdset 中,则返回非零值;否则返回0。

三.实现一个简单的通信

1.通信流程分析

TCP通信流程分析:

	server:1. socket()	创建socket
​		2. bind()	绑定服务器地址结构
​		3. listen()	设置监听上限
​		4. accept()	阻塞监听客户端连接
​		5. read(fd)	读socket获取客户端数据
​		6.--大写	toupper()7. write(fd)8. close();

​	client:1. socket()	创建socket
​		2. connect();	与服务器建立连接
​		3. write()	写数据到 socket
​		4. read()	读转换后的数据。
​		5. 显示读取结果
​		6. close()

2.实现服务器

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <ctype.h>

#define ser_port 9527
int main()
{
    //1.socket part
    int sfd=0,cfd=0;
    sfd = socket(AF_INET, SOCK_STREAM, 0);
    if(sfd==-1)
    {
        printf("socket error\n");
    }

    //2.bind part
    struct sockaddr_in ser_addr,cet_addr;
    ser_addr.sin_family=AF_INET;
    ser_addr.sin_port=htons(ser_port);
    ser_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    bind(sfd, (struct sockaddr *)&ser_addr, sizeof(ser_addr));

    //3.listen part
    listen(sfd,20);

    //4.accept part
    char client_ip[BUFSIZ];
    socklen_t cet_addr_len = sizeof(cet_addr);
    cfd = accept(sfd,(struct sockaddr *)&cet_addr,&cet_addr_len);
    if(cfd==-1)
    {
        printf("accept socket error\n");
    }
    
    //5.read part
    char buf[BUFSIZ];
    while(true){
        int ret = read(cfd,buf,sizeof(buf));
        write(STDOUT_FILENO,buf,ret);
        for(int i=0;i<ret;i++)
        {
            buf[i]=toupper(buf[i]);
        }
        write(cfd,buf,ret);
    }

    //6.close part
    close(sfd);
    close(cfd);
    return 0;
}

3.实现客户端

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define ser_port 9527
int main() {

    //1.socket part
    int hfd=0;
    hfd=socket(AF_INET, SOCK_STREAM, 0);
    if(hfd==-1)
    {
        printf("socket error\n");
    }

    //2.connect part
    struct sockaddr_in cil_addr;
    cil_addr.sin_family=AF_INET;
    cil_addr.sin_port=htons(ser_port);
    inet_pton(AF_INET,"192.168.242.128",&cil_addr.sin_addr.s_addr);
    connect(hfd,(struct sockaddr *)&cil_addr,sizeof(cil_addr));

    // 3. Write and Read part
    char buf[BUFSIZ];
    int count = 10;
    while (count-- > 0) {
        if (write(hfd, "hello", 5) == -1) {
            perror("write");
            close(hfd);
            exit(EXIT_FAILURE);
        }

        int ret = read(hfd, buf, sizeof(buf));
        if (ret == -1) {
            perror("read");
            close(hfd);
            exit(EXIT_FAILURE);
        } else if (ret == 0) {
            printf("Server disconnected\n");
            break;
        }
        write(STDOUT_FILENO, buf, ret);
    }
    close(hfd);
    return 0;
}

四.出错封装函数思想

1.封装思想

上面简单的写了一个实现大小写转换的通信服务器和客户端,但是并不完善,里面的很多函数调用都没有做错误处理,在实际编写过程中并不规范。

我们以accept()函数为例,在上面的例子中,我们是这样写的:

    char client_ip[BUFSIZ];
    socklen_t cet_addr_len = sizeof(cet_addr);
    cfd = accept(sfd,(struct sockaddr *)&cet_addr,&cet_addr_len);
    if(cfd==-1)
    {
        printf("accept socket error\n");
    }

但是实际过程中的main.cpp代码量其实是很少的,我们可以把accept()函数进行封装成类似自定义函数,存储在另一个源文件中,这样就可以在main.cpp调用自定义的Accept()函数啦。

一般这种错误函数的封装,我们把重新自定义的函数名取为原函数名基础上首字母大写,然后在自定义函数内部实现错误封装,所以上述代码可以改成:

int Accept(int fd, struct sockaddr *sa, socklen_t *salenptr)
{
	int n;
	again:
	if ( (n = accept(fd, sa, salenptr)) < 0) {
		if ((errno == ECONNABORTED) || (errno == EINTR))
			goto again;
		else
			perr_exit("accept error");
	}
	return n;
}

注意:自定义函数封装在wrcp.cpp文件中,不要忘了对应的wrcp.h头文件。

2.读写函数

我们在C语言文件操作时,学习了一些读写函数,但是强调过只有read()write()是满足系统调用的(即在socket通信过程中使用),所以平时的读写我们要自定义一些读(写)n字节函数或读(写)n行函数。下面代码仅供参考:

ssize_t Readn(int fd, void *vptr, size_t n)
{
	size_t nleft;
	ssize_t nread;
	char *ptr;

	ptr = vptr;
	nleft = n;

	while (nleft > 0) {
		if ( (nread = read(fd, ptr, nleft)) < 0) {
			if (errno == EINTR)
				nread = 0;
			else
				return -1;
		} else if (nread == 0)
			break;
		nleft -= nread;
		ptr += nread;
	}
	return n - nleft;
}

ssize_t Writen(int fd, const void *vptr, size_t n)
{
	size_t nleft;
	ssize_t nwritten;
	const char *ptr;

	ptr = vptr;
	nleft = n;

	while (nleft > 0) {
		if ( (nwritten = write(fd, ptr, nleft)) <= 0) {
			if (nwritten < 0 && errno == EINTR)
				nwritten = 0;
			else
				return -1;
		}
		nleft -= nwritten;
		ptr += nwritten;
	}
	return n;
}

static ssize_t my_read(int fd, char *ptr)
{
	static int read_cnt;
	static char *read_ptr;
	static char read_buf[100];

	if (read_cnt <= 0) {
again:
		if ((read_cnt = read(fd, read_buf, sizeof(read_buf))) < 0) {
			if (errno == EINTR)
				goto again;
			return -1;	
		} else if (read_cnt == 0)
			return 0;
		read_ptr = read_buf;
	}
	read_cnt--;
	*ptr = *read_ptr++;
	return 1;
}

ssize_t Readline(int fd, void *vptr, size_t maxlen)
{
	ssize_t n, rc;
	char c, *ptr;
	ptr = vptr;

	for (n = 1; n < maxlen; n++) {
		if ( (rc = my_read(fd, &c)) == 1) {
			*ptr++ = c;
			if (c == '\n')
				break;
		} else if (rc == 0) {
			*ptr = 0;
			return n - 1;
		} else
			return -1;
	}
	*ptr = 0;
	return n;
}
Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐