【Linux 网络编程】socket 实现服务器和客户端

IP 地址可以标识网络中的主机,协议类型(TCP或UDP)加端口号可以表示主机上的进程。

基本原理

文件类型

Linux 中有七种类型的文件,这些文件类型可以使用一些基本的函数,例如 read、write:

  • 普通文件
  • 目录
  • 链接文件
  • 字符设备
  • 块设备
  • 管道:pipe匿名管道,fifo有名管道
  • 套接字:socket

套接字是全双工的,虽然只有一个文件描述符,但是在 Linux 内核中读写操作分别对应不同的缓冲区。相比之下,管道则是半双工的,读写操作对应同一个缓冲区。

字节序

TCP/IP 协议规定,使用大端字节序。 即低地址放高字节。

常见字节序

处理器可以支持两种字节序:

  • 大端(big-endian):最低有效字节在高地址出现
  • 小端(little-endian):最低有效字节在低地址出现

例如,对于十六进制数据 0x12345678,总共占4个字节。大端存储时,最低地址存的是 0x12,小端则是0x78。

地址(假设从1001开始)大端小端
10047812
10035634
10023456
10011278

可以通过代码查看平台的字节序:

#include <stdio.h>

union u{
    char c;
    int i;
};

int main(void) {
    union u test;
    test.i = 0x12345678;
    printf("%x\n", test.c);
    return 0;
}

x86平台是小端存储,ARM则可以自由选择。

字节序转换

为使应用程序有更好的可移植性,需要在处理器字节序和网络字节序之间进行转换。常用的函数有4个,入参和返回值都是整数形式的 IP 地址或端口号:

#include <arpa/inet.h>

uint32_t htonl(uint32_t hostint32); // 返回网络字节序表示的32位整数
uint16_t htons(uint16_t hostint16);// 返回网络字节序表示的16位整数
uint32_t ntohl(uint32_t netint32); // 返回主机字节序表示的32位整数
uint16_t ntohs(uint16_t netint16); // 返回主机字节序表示的16位整数

IP地址是32位,端口号是16位。

地址格式

最开始所有的 socket 代码都是针对 IPv4 编写的,后来才兼容 IPv6 和 Unix 域。

sockaddr 结构体

socket 相关函数中,直接使用这个结构体。为了防止编译时报错,都需要强制转成这种结构体才能使用:

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

sockaddr_in 、sockaddr_in6、sockaddr_un 结构体

因特网地址定义在 <netinet/in.h> 头文件中。

  • sockaddr_in:IPv4 地址。
  • sockaddr_in6:IPv6 地址。
  • sockaddr_un:UNIX 域地址。
struct in_addr {
	in_addr_t s_addr; /* IPv4 address*/ 
};

struct sockaddr_in {
	sa_family sin_family;
	in_port_t sin_port;
	struct in_addr sin_addr;
};

地址格式转换

常用的 IP 地址格式是点分十进制字符串(例如192.168.1.1),计算机中用的是 32 位的 unsigned int 类型,并在通信前转成合适的字节序。这些常用的步骤已经被封装成立标准函数。

#include <arpa/inet.h>

// 返回地址字符串格式
const char *inet_ntop(int domain, const void *restrict addr, char *restrict str, socklen_t size);

// 成功返回1,格式无效返回0,失败返回-1
int inet_pton(int domain, const char *src, void *dst);

上面两个函数的参数 domain:只支持 AF_INET 和 AF_INET6。
示例:

unsigned char buf[sizeof(struct in6_addr)];
int domain = AF_INET;

s= inet_pton(domain, argv[2], buf);
if (s <= 0) {
    if (s == 0)
        fprintf(stderr, "Not in presentation format");
    else
        perror("inet_pton");
    exit(EXIT_FAILURE);
}

if (inet_ntop(domain, buf, str, INET6_ADDRSTRLEN) == NULL) {
    perror("inet_ntop");
    exit(EXIT_FAILURE);
}

常用函数

socket 创建套接字

#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>

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

参数:

  • domain:通信方式,AF_INET 表示 IPv4,AF_INET6 表示 IPv6,AF_UNIX 表示 unix 域
  • type:套接字类型,SOCK_STREAM 表示 TCP,SOCK_DGRAM 表示 UDP
  • protocol:协议,通常设为 0 表示使用默认协议。

返回值:

  • 出错返回 -1,否则返回套接字的描述符

bind 绑定地址和端口号

对于客户端,可以使用自动分配的端口号,不需要调用该函数。但是服务器通常工作在指定端口,必须使用 bind 来绑定。

#include <sys/types.h>        /* See NOTES */
#include <sys/socket.h>

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

参数:

  • sockfd:通过 socket 函数创建的套接字对应的描述符
  • addr:套接字地址。struct sockaddr 类型通常是通过类型强制转换得到,来源有:
    • struct sockaddr_in:IPv4 地址
    • struct sockaddr_in6:IPv6 地址
    • struct sockaddr_un:UNIX 域地址
  • addrlen:套接字结构体长度

返回值:失败返回 -1,成功返回 0。

套接字地址结构体的定义在不同的系统中有差异,例如,Linux 中定义如下:

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

struct in_addr {
	in_addr_t		s_addr; /* IPv4 address */
};

struct sockaddr_in {
	sa_family_t		sin_family;
	in_port_t		sin_port;
	struct in_addr	sin_addr;
	unsigned char	sin_zero[8];
};

listen 激活socket,指定用于建立连接的队列大小

#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>

int listen(int sockfd, int backlog);

参数:

  • sockfd:套接字描述符
  • backlog:等待连接的队列大小。如果等待建立连接的客户端数量超过此限制,服务器会忽略后面的请求。

accept 监听客户端连接

accept 每次监听到一个客户端的连接,就会创建一个新的指向该客户端的套接字,同时改写入参保存客户端套接字信息。仅用于 SOCK_STREAM, SOCK_SEQPACKET 类型的套接字。

#in	clude <sys/types.h>          /* See NOTES */
#include <sys/socket.h>

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

参数:

  • struct sockaddr:套接字地址结构体,跟具体通信类型有关
  • addrlen:结构体的大小

accept 函数会阻塞执行,直到有客户端请求。此时 accept 会创建一个新的套接字返回,并修改入参的 addr 和 addrlen 保存客户端信息。

connect 建立连接

需要把具体的socket地址类型强转为 struct sockaddr 类型。

#include <sys/types.h>
#include <sys/socket.h>

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

服务器代码示例

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

#define SERV_IP "127.0.0.1" /* 服务器可以用 htonl(INADDR_ANY) 自动选择可用IP */
#define SERV_PORT 8888
#define MAX_CONN 100 /* 连接队列最多可以有 100 个客户端在排队等待建立连接 */

int main() {
	char buf[1024];
	int n;
	int sockfd, clientfd;
	struct sockaddr_in serv_addr, client_addr;
	socklen_t socklen;

	sockfd = socket(AF_INET, SOCK_STREAM, 0); /* IPv4 协议, TCP 协议*/
	if (sockfd == -1) {
		perror("socket");
		return -1;
	}
	bzero(&serv_addr, sizeof(serv_addr));
	serv_addr.sin_family = AF_INET;
	serv_addr.sin_port = htons(SERV_PORT);  /* 指定端口号 */
	serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); /* 自动选择可用IP */
	if (bind(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
		perror("bind");
		return -1;
	}
	if (listen(sockfd, MAX_CONN) < 0) {
		perror("listen");
		return -1;
	}

	socklen = sizeof(client_addr);	
	clientfd = accept(sockfd, (struct sockaddr*)&client_addr, &socklen);
	if (clientfd < 0) {
		perror("accept");
		return -1;
	}
	inet_ntop(AF_INET, &client_addr, buf, INET_ADDRSTRLEN);
	printf("client IP is: %s, client port is: %d\n", buf, ntohs(client_addr.sin_port));
	while(1) {	
		n = read(clientfd, buf, sizeof(buf));
		for (int i = 0; i < n; i++) {
			buf[i] = toupper(buf[i]);
		}
		write(clientfd, buf, n);
	}
	close(clientfd);
	close(sockfd);

	return 0;
}

客户端代码示例

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

#define error_exit(msg) \
	do {perror(msg); exit(EXIT_FAILURE);} while(0)

#define SERV_IP "127.0.0.1"
#define SERV_PORT 8888

int main(int argc, char *argv[]) {
	int sockfd, ret;
	int n;
	char buf[BUFSIZ];
	struct sockaddr_in serv_addr;
	sockfd = socket(AF_INET, SOCK_STREAM, 0);
	if (sockfd == -1)
		error_exit("socket");

	memset(&serv_addr, 0, sizeof(serv_addr));
	ret = inet_pton(AF_INET, SERV_IP, &serv_addr.sin_addr.s_addr);
	if (ret <= 0) {
		if (ret == 0) 
			error_exit("format error");
		else
			error_exit("inet_pton");
	}

	serv_addr.sin_family = AF_INET;
	serv_addr.sin_port = htons(SERV_PORT);

	ret = connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
	if (ret == -1) {
		error_exit("connect");
	}
	while(1) {
		fgets(buf, sizeof(buf), stdin);
		write(sockfd, buf, strlen(buf));
		n = read(sockfd, buf, sizeof(buf));
		write(STDOUT_FILENO, buf, n);
	}
	close(sockfd);
	return 0;
}

错误处理

每个函数都可能发生错误,但是如果为每个函数都写错误处理代码,会导致逻辑很混乱。常用的有两种方式来处理错误。

定义统一的错误处理函数

针对函数返回值进行判断,出错时调用错误处理函数。

#define error_exit(msg) do {perror(msg); exit(EXIT_FAILURE);} while(0)

优点是实现方式统一,缺点是不够灵活。

重写所有会导致错误的函数,内部封装错误处理

可用把方法重新写为大写字母开头,这样在 vim 编辑器中仍然可以方便的查看 man 手册(大写 K,即 shift + k)。

例如 redis 源码封装了 anet.c 文件,将所有套接字相关函数重写:


static int anetGenericAccept(char *err, int s, struct sockaddr *sa, socklen_t *len) {
    int fd;
    while(1) {
        fd = accept(s,sa,len);
        if (fd == -1) {
            if (errno == EINTR)
                continue;
            else {
                anetSetError(err, "accept: %s", strerror(errno));
                return ANET_ERR;
            }
        }
        break;
    }
    return fd;
}

int anetTcpAccept(char *err, int s, char *ip, size_t ip_len, int *port) {
    int fd;
    struct sockaddr_storage sa;
    socklen_t salen = sizeof(sa);
    if ((fd = anetGenericAccept(err,s,(struct sockaddr*)&sa,&salen)) == -1)
        return ANET_ERR;

    if (sa.ss_family == AF_INET) {
        struct sockaddr_in *s = (struct sockaddr_in *)&sa;
        if (ip) inet_ntop(AF_INET,(void*)&(s->sin_addr),ip,ip_len);
        if (port) *port = ntohs(s->sin_port);
    } else {
        struct sockaddr_in6 *s = (struct sockaddr_in6 *)&sa;
        if (ip) inet_ntop(AF_INET6,(void*)&(s->sin6_addr),ip,ip_len);
        if (port) *port = ntohs(s->sin6_port);
    }
    return fd;
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值