有关 socket 编程的一些笔记

回顾一下 Linux 中与 socket 相关的常用 API。

这篇文章也是几年前自己的一些笔记,现在重新在整理一下。
所以本文的主要内容就是介绍一些基本 API 的用法,然后在最后根据这些 API,做出最基本的服务器和客户端程序。

好了,废话不多说,直接进入主题了。

API

socket

原型:

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

功能:创建 socket,返回该 socket 的文件描述符(fd)。
参数:domain用于指定通信协议;type用于指定通信类型;protocol通常设为0,由系统根据domaintype自动选择。
返回值:成功返回新创建 socket 的文件描述符(fd),失败返回-1,并设置 errno。
头文件:

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

bind

原型:

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

功能:将 socket 关联到特定 ip 和端口上。
参数:sockfd就是 socket 的文件描述符,可以通过socket()函数创建;addrsockaddr类型的结构体指针,用于绑定 ip 和端口;addrlen用于指定addr结构体的实际大小。
返回值:函数调用成功返回0,失败返回-1,并设置 errno。
头文件:

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

listen

原型:

1
int listen(int sockfd, int backlog);

功能:将绑定后的 socket 设置为被动监听状态,准备接收客户端连接请求(说明这个函数是服务器使用的)。
参数:sockfd对应 socket 的文件描述符;backlog表示服务器单次连接的最大客户端数量,Linux 系统版本小于 4.3 时这个值的最大值是 128,Linux 系统版本大于等于 4.3 时,这个值的最小值大于SOMAXCONN
返回值:调用成功返回0,失败返回-1,并设置 errno。
头文件:

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

PS:这个函数是服务端调用的函数,客户端不需要。

accept

原型:

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

功能:从监听队列中取出已建立的 TCP 连接,创建新的 socket 用于与客户端传输数据。
参数:sockfd是调用listen()函数返回的 socket 的文件描述符;addr用于接收客户端地址信息;addrlen表示缓冲区addr的大小,调用时这个值由用户先初始化,函数执行完成后,这个值会被操作系统改成所接收的addr的大小。
返回值:调用成功返回用于与客户端通信的新 socket 的文件描述符,失败返回-1,并设置 errno。
头文件:

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

PS:这个函数是一个阻塞函数,是服务端与客户端建立连接的核心函数,客户端不需要调用这个函数。

connect

原型:

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

功能:用于建立与目标服务器的连接(TCP)或指定默认目标地址(UDP),是客户端通信的核心函数。
参数:sockfd是通过socket()函数创建的 socket 的文件描述符;addr是服务端的地址信息;addrlen是服务端地址信息的长度。
返回值:调用成功返回0,失败返回-1,并设置 errno。
头文件:

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

PS:这个函数是客户端调用的函数,服务端不需要。

recv

原型:

1
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

功能:用于从通信的 socket 接收数据。
参数:sockfd是用于通信的 socket 的文件描述符;buf是接收数据的内存首地址;lenbuf指向的内存的大小;flags用于指定接收数据的行为。
返回值:调用成功返回接收的数据大小,返回0时,关闭套接字,返回-1时,出现错误,并设置 errno。
头文件:

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

PS:客户端和服务端都需要用这个函数接收数据,系统调用read函数也可以用于接收数据。

send

原型:

1
ssize_t send(int sockfd, const void *buf, size_t len, int flags);

功能:用于向通信的 socket 发送数据。
参数:sockfd是用于通信的 socket 的文件描述符;buf是发送数据的内存首地址,使用const修饰意味着函数不会修改这块内存的内容;lenbuf指向的内存的大小;flags用于指定发送数据的行为。
返回值:调用成功返回发送的数据大小,失败返回-1,表示发送错误,并设置 errno。
头文件:

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

PS:客户端和服务端都需要用这个函数发送数据,系统调用write也可用于发送数据。

close

原型:

1
int close(int fd);

功能:用于关闭文件描述符并释放关联的资源。
参数:fd需要关闭的文件描述符。
返回值:成功关闭,返回0,失败返回-1,并设置 errno。
头文件:

1
#include <unistd.h>

htonl/htons/ntohl/ntohs

1
2
3
4
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);

这是一组大小端转换函数,不做过多说明了。
头文件:

1
#include <arpa/inet.h>

Structure

与上述 API 关联的还有两个结构体。

sockaddr

sockaddr是一个结构体,其定义一般为:

1
2
3
4
struct sockaddr {
sa_family_t sa_family; // 地址族协议,比如 ipv4
char sa_data[14];// port(2 bytes) + ip(4 bytes) + 填充(8 bytes)
}

头文件:

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

PS:这个结构体一般不常用,因为把端口和 ip 存到sa_data是一件比较麻烦的事情:首先得先转成网络字节序(大端),然后再写入这个字符数组中。

sockaddr_in

sockaddr_in是一个结构体,其定义一般为:

1
2
3
4
5
6
7
8
9
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 都需要转换成网络字节序(大端)。
头文件:

1
#include <netinet/in.h>

PS:这个结构体比sockaddr用的更多,之所以能类型转换,是因为二者的内存布局类似。

Client

回顾完基本的 API 后,我们来考虑创建一个基本的客户端程序,这个客户端程序的执行步骤要尽可能的简单:

  1. 创建 socket
  2. 连接服务器
  3. 向服务器发送消息
  4. 接收服务器消息
  5. 连接断开,关闭文件描述符

具体的代码如下:

myclient.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <stdio.h>
#include <string.h>

#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <netinet/in.h>

int main() {
// 1. 创建 socket
int fd = socket(AF_INET, SOCK_STREAM, 0);
if(fd == -1) {
perror("socket error\n");
return -1;
}

// 2. 连接服务器 ip 和 port
struct sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_port = htons(2048); // 假设服务器端口是 1024
inet_pton(AF_INET, "192.168.3.105", &saddr.sin_addr.s_addr); // 设置服务器ip
int ret = connect(fd, (struct sockaddr*)&saddr, sizeof(saddr));
if(ret == -1) {
perror("connect error");
return -1;
}

int number = 1;
while(1) {
// 3. 向服务器发送消息
printf("第 %d 次向服务器发送消息...\n", number++);
char buf[1024];
scanf("%s", buf);
printf("len = %d\n", strlen(buf));
printf("客户端发送:%s\n", buf);
send(fd, buf, strlen(buf) + 1, 0);

// 4. 接收服务器消息
memset(buf, 0, sizeof(buf));
int len = recv(fd, buf, sizeof(buf), 0);
if(len > 0) {
printf("服务器返回:%s\n", buf);
} else if(len == 0) {
printf("server closed");
break;
} else {
perror("recv error");
}
}
//5. 断开连接,关闭文件描述符
close(fd);

return 0;
}

在上述代码中,我们实现了一个简单的客户端程序,使用scanf读取用户输入,然后直接将其发送给服务器,接着直接调用recv函数持续等待服务器返回消息,并根据recv函数的返回值判断连接的有效性。
实际上,在这段代码中,还存在一些问题,比如scanf函数读取输入的字符串时,会被空格截断,如果输入内容含有空格,那么可能会造成多次发送;同时,如果服务器没有及时返回消息,那么客户端程序会一直被阻塞在recv函数这里。
不过我们的目的只是简单应用一下 API,所以这些问题也无伤大雅。

Server

Ver 1

客户端程序创建好后,我们继续考虑创建一个基本的服务端程序,同样这个服务器程序的执行步骤也要尽可能的简单:

  1. 创建 socket
  2. 绑定服务器 ip 和 port
  3. 监听客户端请求
  4. 与客户端建立连接
  5. 接收客户端消息
  6. 向客户端发送消息
  7. 连接断开,关闭文件描述符

具体的代码如下:

myserver.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#include <stdio.h>
#include <ctype.h>
#include <string.h>

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

int main() {
// 1. 创建 socket
int fd = socket(AF_INET, SOCK_STREAM, 0);
if(fd == -1) {
perror("socket error");
return -1;
}

// 2. 绑定服务器 ip 和 port
struct sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_port = htons(2048);
saddr.sin_addr.s_addr = INADDR_ANY;
int ret = bind(fd, (struct sockaddr*)&saddr, sizeof(saddr));
if(ret == -1) {
perror("bind error");
return -1;
}

// 3. 监听客户端请求
ret = listen(fd, 128);
if(ret == -1) {
perror("listen error");
return -1;
}

// 4. 与客户端建立连接
struct sockaddr_in caddr;
int addrlen = sizeof(caddr);
int cfd = accept(fd, (struct sockaddr*)&caddr, &addrlen);
if(cfd == -1) {
perror("accept error");
return -1;
}

char buf[1024];
while(1) {
memset(buf, 0, sizeof(buf));
// 6. 接收客户端消息
int len = recv(cfd, buf, sizeof(buf), 0);
if(len > 0) {
printf("从客户端接收:%s\n", buf);
printf("len = %d\n", len);
printf("buf[0] = %d\n", buf[0]);
// 7. 向客户端发送消息
// 将从客户端接收到的小写字符转换为大写后发送给客户端
for(int i = 0; i < strlen(buf); ++i) {
buf[i] = toupper(buf[i]);
}
printf("发送给客户端:%s\n", buf);
send(cfd, buf, strlen(buf) + 1, 0);
} else if(len == 0) {
printf("client closed");
break;
} else {
perror("recv error");
break;
}
}
close(fd);
close(cfd);
return 0;
}

在上述代码中,我们实现了一个简单的服务器程序,这个服务器程序可以与任意 IP 地址的客户端通信,但端口必须是 1024。同时,还可以发现,监听客户端请求的 socket 文件描述符与向客户端发送请求的文件描述符是两个不同的文件描述符。
与客户端类似,服务器也使用recv函数接收客户端消息,并在收到消息后,将消息中的小写字符立刻转换为大写字符,并发送给客户端。在处理字符串时,使用strlen(buf)而不是len的原因是len的大小也包括了\0这个字符。
另外,在上述代码中,并没有对接收到的消息进行错误判断,目的也是为了尽可能简化这段代码。

Ver 2

在上一个版本的服务器程序中,服务器与客户端是一对一的,但正常来讲,服务器与客户端的对应关系应该是一对多的,所以我们需要借助多线程把服务器改成能同时与多个客户端连接。

既然是 Linux,那么直接使用 pthread 线程库即可,对应的用法在之前的文章已经提过了,这里不再赘述。

thread_myserver.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include <stdio.h>
#include <ctype.h>
#include <stdlib.h>
#include <string.h>

#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <pthread.h>

void* working(void* arg) {
int cfd = *(int*)arg;
free(arg);
char buf[1024];
while(1) {
memset(buf, 0, sizeof(buf));
// 接收客户端消息
int len = recv(cfd, buf, sizeof(buf), 0);
if(len > 0) {
printf("线程 %ld,从客户端接收:%s\n", pthread_self(), buf);
printf("len = %d\n", len);
// 向客户端发送消息
// 将从客户端接收到的小写字符转换为大写后发送给客户端
for(int i = 0; i < len - 1; ++i) {
buf[i] = toupper(buf[i]);
}
printf("线程 %ld,发送给客户端:%s\n", pthread_self(), buf);
send(cfd, buf, len, 0);
} else if(len == 0) {
printf("client closed\n");
break;
} else {
perror("recv error");
break;
}
}
close(cfd);
printf("线程 %ld 退出\n", pthread_self());
return NULL;
}

int main() {
// 创建 socket
int fd = socket(AF_INET, SOCK_STREAM, 0);
if(fd == -1) {
perror("socket error");
return -1;
}

// 绑定服务器 ip 和 port
struct sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_port = htons(2048);
saddr.sin_addr.s_addr = INADDR_ANY;
int ret = bind(fd, (struct sockaddr*)&saddr, sizeof(saddr));
if(ret == -1) {
perror("bind error");
return -1;
}

// 监听客户端请求
ret = listen(fd, 128);
if(ret == -1) {
perror("listen error");
return -1;
}

// 主线程与客户端建立连接
// 子线程与客户端进行通信
while(1) {
// 动态分配文件描述符,避免不同子线程访问到同一块内存
int *cfd = (int*)malloc(sizeof(int));
*cfd = accept(fd, NULL, NULL);
if(*cfd == -1) {
perror("accept error");
free(cfd);
continue;
}
pthread_t tid;
pthread_create(&tid, NULL, working, cfd);
pthread_detach(tid);
}
close(fd);
return 0;
}

与 Ver1 版本相比,Ver2 有几点需要注意:

  1. 主线程与客户端建立连接,子线程与客户端进行通信,但没有考虑线程创建失败的情况。
  2. 没有设置最大连接数,也没有连接超时的处理。
  3. 为了避免产生竞态条件,需要将主线程内调用accept函数得到的用于与子线程通信的文件描述符绑定到堆内存中。
  4. 不要使用pthread_join函数,否则会阻塞主线程。

与 Ver1 版本类似的是,Ver2 也没有对接收到的消息做错误处理,也没有对缓冲区的边界进行检测,这些都不是这里的重点。
另外,尽管 Ver2 版本是支持多线程并发的,但显然不足以满足高并发需求,当然也可以换成线程池或 IO 多路复用来做高并发处理。而且,由于 Ver2 这个服务器程序过于简单,所以也不需要考虑线程同步的问题。

Summary

总结一下,本文主要是做了两件事:

  1. 介绍 Linux 系统下与 socket 相关的 API。
  2. 围绕这些 API,实现了单线程版的客户端和服务器和一个多线程版的服务器。

整体内容不难,都是基础内容,算是又复习了一遍。


Buy me a coffee ? :)
0%