Linux Socket 编程入门
关于计算机网络以及Socket编程相关的概念性知识,可以参考相关资料,这里只列举了常见函数的用法,以及两个简单的例子。
Socket 相关函数
socket 函数
创建socket(套接字)
1 |
|
参数:
family
:协议族,通常取AF_INET
(IPv4) 或AF_INET6
(IPv6)type
:套接字类型,通常取SOCK_STREAM
(TCP) 或SOCK_DGRAM
(UDP)protocol
:协议,可以取IPPROTO_TCP
、IPPTOTO_UDP
,但是更建议直接取0,表示自动使用默认的协议
返回值:
- 创建成功:返回一个新的套接字文件描述符
sockfd
- 创建失败:返回 -1,通过
errno
获取错误信息
因此最常见的两种用法以及错误处理如下
1 | // TCP |
sockaddr 结构体
在socket网络通讯中,需要定义一个通用的sockaddr
结构体来保存对方的地址信息,sockaddr
定义如下
1
2
3
4
5
6
struct sockaddr {
sa_family_t sa_family; // 地址族(2 字节)
char sa_data[14]; // 具体协议的地址信息(14 字节)
};
但是上述结构体太通用了,实际上我们对于IPv4使用如下更具体的结构体
1
2
3
4
5
6
7
8
9
10
11
12
13
struct sockaddr_in {
sa_family_t sin_family; // 地址族(2 字节)
in_port_t sin_port; // 端口号(2 字节)
struct in_addr sin_addr; // IPv4 地址(4 字节)
char sin_zero[8]; // 填充字节,以保证和 sockaddr 大小一致
};
// IPv4 地址结构体(4 字节)
struct in_addr {
uint32_t s_addr; // IPv4 地址(4 字节)
};
其中:
sin_family
:地址族,通常取AF_INET
(IPv4)或AF_INET6
(IPv6)sin_port
:端口号,通常取大于1024
且小于65536
的整数,过小的端口号会被系统自动分配,或者需要sudo权限。如果将端口设置为0,表示使用随机端口,一般不推荐sin_addr.s_addr
:IPv4 地址,例如可以取127.0.0.1
(本地地址),0.0.0.0
(任意地址,可以使用INADDR_ANY
),或者某个具体的IPv4地址sin_zero
:填充字节,以保证和sockaddr
大小一致,不需要设置
对于IPv6来说,需要的结构体略有不同,这里不再赘述。在传递给bind
和connect
等API时,通常会将sockaddr_in
强制转换成sockaddr
类型。
在创建socketaddr_in
结构体时,通常需要使用大小端转换和IP地址的格式转换,下面提供几个常见的例子。
服务端使用,指定任意 IP 的 PORT 端口 1
2
3
4
5
6
7
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr)); // 清空结构体
server_addr.sin_family = AF_INET; // IPv4
server_addr.sin_port = htons(PORT); // 端口号(主机字节序转换为网络字节序)
server_addr.sin_addr.s_addr = INADDR_ANY; // 监听所有可用 IP 地址
客户端使用,指定 SERVER_IP 的 PORT 端口 1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr)); // 清空结构体
server_addr.sin_family = AF_INET; // IPv4
server_addr.sin_port = htons(PORT); // 端口号
// IPv4 地址格式转换并设置
if(inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr) <= 0){
perror("Invalid address");
// close(sock);
return -1;
}
bind 函数
bind 可以用于绑定套接字到指定的地址(IP
地址和端口号),用于服务器端的监听(包括TCP和UDP的服务端)。
1
2
3
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
sockfd
:套接字描述符addr
:指向sockaddr
结构体 的指针,通常由sockaddr_in
结构体转换addrlen
:addr
的长度,一般用sizeof
获取
返回值:
- 成功:返回 0
- 失败:返回 -1,通过
errno
获取错误信息
使用以及错误处理例如 1
2
3
4
5if (bind(sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
perror("Binding failed");
close(sock);
return -1;
}
listen 函数
对于TCP服务端,在bind之后可以使用listen函数开启监听,也就是将套接字从CLOSE状态转换为LISTEN状态。
1 |
|
参数:
sockfd
:要操作的socketbacklog
:为服务器处于LISTEN状态下维护的已完成连接队列长度的最大值,例如5或者10(当然在系统中也存在一个上限)
返回值:
- 成功:返回 0
- 失败:返回 -1,通过
errno
获取错误信息
使用以及错误处理例如 1
2
3
4if (listen(sockfd, 5) == -1) {
perror("Listen failed");
return -1;
}
connect 函数
connect 函数用于客户端主动连接到服务器,通常在TCP客户端的连接过程中使用(UDP也可以使用,但是主要适用于TCP)
1 |
|
参数:
sockfd
:套接字描述符addr
:指向sockaddr
结构体 的指针,通常由sockaddr_in
结构体转换addrlen
:addr
的长度,一般用sizeof
获取
返回值:
- 成功:返回 0
- 失败:返回 -1,通过
errno
获取错误信息
使用以及错误处理例如 1
2
3
4
5if (connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
perror("Connect failed");
close(sockfd);
return -1;
}
accept 函数
当TCP服务端调用 bind 和 listen 之后,服务器会在 accept 处阻塞,等待客户端发起连接,在连接成功后返回新的 socket 用于通信,并且可以通过输出参数获取客户端连接信息。(因为accept存在阻塞行为,TCP服务器可以使用fork多进程的方式来实现同时处理多个客户端连接,但是UDP服务器是不需要的)
1 |
|
参数:
sockfd
:套接字描述符addr
:(输出参数)指向sockaddr
结构体 的指针,通常由sockaddr_in
结构体转换,可填 NULL,表示不获取addrlen
:(输出参数)指向addr
长度的指针,可填 NULL,表示不获取
返回值:
- 成功:返回新的socket(注意这里socket也需要手动关闭)
- 失败:返回 -1,通过
errno
获取错误信息
使用以及错误处理例如
1 | struct sockaddr_in client_addr; |
不获取客户端信息时,可以更加简单 1
2
3
4
5int client_fd = accept(server_fd, NULL, NULL);
if (client_fd == -1) {
perror("Accept failed");
// ...
}
close 函数
close函数可以用于关闭套接字,就像文件关闭一样。
1 |
|
注意:
- 由于socket允许复制,close操作只是减少socket的引用计数,只有当引用计数为0的时候,才会触发TCP客户端向服务器发送终止连接请求。
- 由于异常退出可能造成资源泄露,我们可能需要捕获
ctrl+c
等触发的异常信号,以便及时释放资源。
Socket IO 函数
网络IO操作有下面几组函数:
write
/read
writev
/readv
send
/recv
sendto
/recvfrom
sendmsg
/recvmsg
前两组是通用的IO操作,可以用于文件读写等,第二组是适合多个缓冲区的IO操作,自动针对多个缓冲区进行,多个缓冲区按照顺序连接。 这些操作也支持socket的读写操作,但是通常不适用于UDP,这里不做讨论。 我们重点关注后三组函数。
需要说明的是, 考虑到缓冲区大小限制和网络原样,发送和接收数据的时候,无法保证不会一次性发送和接收完全部数据,可能需要套一层循环,但是这里简化讨论,不考虑这个问题。
send 和 recv 函数
这两个函数是最基础的IO函数,通常只用于TCP,因为TCP是有连接的。
1
2
3
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
参数:
sockfd
:套接字描述符buf
:发送数据缓冲区len
:发送数据缓冲区长度flags
:标志位,用于控制读写行为,一般取0即可,也可使用MSG_*
标志位
返回值:
- 成功:返回实际发送的字节数
- 连接关闭:返回 0
- 发送失败:返回 -1,通过
errno
获取错误信息
使用示例 1
2
3
4
5ssize_t sent_bytes = send(sockfd, buffer, strlen(buffer), 0);
if (sent_bytes < 0) {
perror("send failed");
break;
}
1 |
|
参数:
sockfd
:套接字描述符buf
:(输出参数)接收数据缓冲区len
:接收数据缓冲区长度(最大可接收的字节数)flags
:标志位,用于控制读写行为,一般取0即可,也可使用MSG_*
标志位
返回值:
- 成功:返回实际接收的字节数(可能小于
len
) - 连接关闭:返回 0
- 发送失败:返回 -1,通过
errno
获取错误信息
使用示例 1
2
3
4
5
6
7
8ssize_t recv_bytes = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (recv_bytes < 0) {
perror("recv failed");
break;
} else if (recv_bytes == 0) {
printf("Server closed connection.\n");
break;
}
sendto 和 recvfrom 函数
这两个函数只用于UDP,由于UDP通常是无连接的,在读写中都加入了dest_addr
和addrlen
。
1 |
|
参数:
sockfd
:套接字描述符buf
:发送数据缓冲区len
:发送数据缓冲区长度flags
:标志位,用于控制读写行为,一般取0即可,也可使用MSG_*
标志位dest_addr
:接收方地址addrlen
:接收方地址长度
返回值:
- 成功:返回实际发送的字节数
- 发送失败:返回 -1,通过
errno
获取错误信息
使用例如 1
2
3
4
5
6
7ssize_t sent_len = sendto(sockfd, message, strlen(message), 0,
(struct sockaddr *)&server_addr, sizeof(server_addr));
if (sent_len < 0) {
perror("sendto failed");
close(sockfd);
exit(EXIT_FAILURE);
}
1 |
|
参数:
sockfd
:套接字描述符buf
:(输出参数)接收数据缓冲区len
:接收数据缓冲区长度(最大可接收的字节数)flags
:标志位,用于控制读写行为,一般取0即可,也可使用MSG_*
标志位src_addr
:(输出参数)发送方地址addrlen
:(输出参数)发送方地址长度
返回值:
- 成功:返回实际接收的字节数(可能小于
len
) - 连接关闭:返回 0
- 发送失败:返回 -1,通过
errno
获取错误信息
使用示例 1
2
3
4
5
6
7ssize_t recv_len = recvfrom(sockfd, buffer, BUFFER_SIZE, 0,
(struct sockaddr *)&client_addr, &addr_len);
if (recv_len < 0) {
perror("recvfrom failed");
close(sockfd);
exit(EXIT_FAILURE);
}
sendmsg 和 recvmsg 函数
最后的这两个函数更具有一般性,行为更加灵活,实际上可以完全替代前两组函数,因为它引入了struct msghdr
这个结构体参数。
1 |
|
参数:
sockfd
:套接字描述符msg
:消息结构体,打包了所有信息flags
:标志位,用于控制读写行为,一般取0即可,也可使用MSG_*
标志位
返回值:
- 成功:返回实际发送的字节数
- 连接关闭:返回 0
- 失败:返回 -1,通过
errno
获取错误信息
1 |
|
参数:
sockfd
:套接字描述符msg
:(输出参数)消息结构体,打包了所有信息flags
:标志位,用于控制读写行为,一般取0即可,也可使用MSG_*
标志位
返回值:
- 成功:返回实际接收的字节数
- 连接关闭:返回 0
- 失败:返回 -1,通过
errno
获取错误信息
这里我们需要关注 iovec
和 msghdr
结构体的具体内容,iovec
结构体定义如下 1
2
3
4
5
6
struct iovec {
void *iov_base; // 缓冲区的起始地址
size_t iov_len; // 缓冲区的大小(字节数)
};
使用例如 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// 发送数据准备
struct iovec iov[2];
iov[0].iov_base = "Hello, ";
iov[0].iov_len = 7;
iov[1].iov_base = "World!";
iov[1].iov_len = 6;
// 接收数据准备
struct iovec iov[2];
char buf1[10];
char buf2[10];
iov[0].iov_base = buf1;
iov[0].iov_len = sizeof(buf1);
iov[1].iov_base = buf2;
iov[1].iov_len = sizeof(buf2);
msghdr
结构体定义如下 1
2
3
4
5
6
7
8
9
10
11
12
13
struct msghdr {
void *msg_name; // 地址结构体指针(仅用于 UDP)
socklen_t msg_namelen; // 地址结构体的大小(仅用于 UDP)
struct iovec *msg_iov; // 指向 iovec 数组的指针
size_t msg_iovlen; // iovec 数组的元素个数
void *msg_control; // 指向额外控制信息的指针(如文件描述符)
size_t msg_controllen; // 额外控制信息数据大小
int msg_flags; // 额外标志位
};
其中的很多成员都是可选的,TCP只需要关注核心的msg_iov
以及msg_iovlen
即可,UDP还需要关注msg_name
和msg_namelen
。
TCP使用例如 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// 发送准备
struct iovec iov[2];
char part1[] = "Hello, ";
char part2[] = "this is TCP message.\n";
iov[0].iov_base = part1;
iov[0].iov_len = strlen(part1);
iov[1].iov_base = part2;
iov[1].iov_len = strlen(part2);
struct msghdr msg;
memset(&msg, 0, sizeof(msg));
msg.msg_iov = iov;
msg.msg_iovlen = 2;
// 接收准备
struct iovec iov[2];
char buf1[16];
char buf2[32];
iov[0].iov_base = buf1;
iov[0].iov_len = sizeof(buf1);
iov[1].iov_base = buf2;
iov[1].iov_len = sizeof(buf2);
struct msghdr msg;
memset(&msg, 0, sizeof(msg));
msg.msg_iov = iov;
msg.msg_iovlen = 2;
UDP使用例如 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// 发送准备
struct iovec iov[2];
char part1[] = "UDP message: ";
char part2[] = "Hello!";
iov[0].iov_base = part1;
iov[0].iov_len = strlen(part1);
iov[1].iov_base = part2;
iov[1].iov_len = strlen(part2);
struct msghdr msg;
memset(&msg, 0, sizeof(msg));
msg.msg_name = &server_addr;
msg.msg_namelen = sizeof(server_addr);
msg.msg_iov = iov;
msg.msg_iovlen = 2;
// 接收准备
struct iovec iov[1];
char buf[64];
iov[0].iov_base = buf;
iov[0].iov_len = sizeof(buf);
struct msghdr msg;
memset(&msg, 0, sizeof(msg));
msg.msg_name = &client_addr;
msg.msg_namelen = sizeof(client_addr);
msg.msg_iov = iov;
msg.msg_iovlen = 1;
实例——echo服务器
我们考虑实现一个最简单的echo服务器,分别通过TCP和UDP实现。 echo服务器的逻辑很简单,将收到的信息原样发送回去,这里我们顺便在发送信息时附带了时间,在返回信息中加上了双方的地址信息。
由于TCP的accpet函数存在阻塞,使用fork多进程的方式来实现同时处理多个客户端连接。
TCP 服务端
1 |
|
UDP 服务端
1 |
|
客户端
使用 TCP 和 UDP
的客户端代码非常相似,因此写在了一起,使用USE_TCP
宏来区分
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
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
int sockfd;
// 处理 Ctrl+C 关闭客户端
void handle_sigint(int sig) {
close(sockfd);
write(STDOUT_FILENO, "\nClient shutting down...\n", 25);
exit(0);
}
// 获取格式化的当前时间
void get_timestamp(char *buffer, size_t size) {
time_t now = time(NULL);
struct tm *tm_info = localtime(&now);
strftime(buffer, size, "[%H:%M:%S] ", tm_info);
}
int main() {
// 处理 Ctrl+C
signal(SIGINT, handle_sigint);
// 创建套接字
sockfd = socket(AF_INET, SOCK_STREAM, 0);
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
perror("Socket creation failed");
exit(EXIT_FAILURE);
}
// 配置服务器地址
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERVER_PORT);
inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr);
// 连接到服务器
if (connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr))
< 0) {
perror("Connection failed");
close(sockfd);
exit(EXIT_FAILURE);
}
// do nothing
write(STDOUT_FILENO, "Connected to server. Type your messages...\n", 44);
char buffer[BUFFER_SIZE];
char send_buffer[BUFFER_SIZE + 20]; // 额外空间存放时间戳
// do nothing
struct sockaddr_in from_addr;
socklen_t from_len = sizeof(from_addr);
while (1) {
write(STDOUT_FILENO, "You: ", 5);
// 读取用户输入
if (fgets(buffer, BUFFER_SIZE, stdin) == NULL) {
write(STDOUT_FILENO, "\nClient exiting...\n", 19);
break;
}
// 获取时间戳并拼接
get_timestamp(send_buffer, sizeof(send_buffer));
strncat(send_buffer, buffer, BUFFER_SIZE);
// 发送数据
send(sockfd, send_buffer, strlen(send_buffer), 0);
// 接收回应
ssize_t bytes_received = recv(sockfd, buffer, BUFFER_SIZE, 0);
// 发送数据
sendto(sockfd, send_buffer, strlen(send_buffer), 0,
(struct sockaddr *)&server_addr, sizeof(server_addr));
// 接收回应
ssize_t bytes_received =
recvfrom(sockfd, buffer, BUFFER_SIZE, 0,
(struct sockaddr *)&from_addr, &from_len);
if (bytes_received > 0) {
buffer[bytes_received] = '\0';
write(STDOUT_FILENO, "Server: ", 8);
write(STDOUT_FILENO, buffer, bytes_received);
}
else {
write(STDOUT_FILENO, "Server disconnected.\n", 21);
break;
}
}
close(sockfd);
return 0;
}
实例——极简http服务器
这个极简的http服务器的内容很简单:
- GET 请求:
- 主页 (/):显示欢迎信息,并提供一个跳转到 /ask 页面的链接。
- 问答页 (/ask):包含一个 HTML 表单,允许用户输入内容并提交。
- 404 处理:对于未知路径,服务器返回 404 Not Found。
- 来自问答页的 POST 处理:用户提交的内容将被服务器接收,并显示在返回的 HTML 页面中。
http服务器就不需要客户端了,直接使用浏览器访问即可。
1 |
|
这里还有一个稍微复杂一点的http服务器:Tinyhttpd, 这是J. David Blackstone在1999年写的一个不到 500 行的超轻量型 Http Server,读一遍可以更好地理解http服务器的原理。 它除了可以根据请求返回本地目录中的文件内容,还支持 CGI:从请求中提取参数并传递给脚本执行,脚本计算并生成html返回。
补充
大小端数值转换
首先,不同的机器上对于多字节变量的字节存储顺序是不同的,有大端字节序和小端字节序两种,目前常见的机器都是小端序,但是在网络编程中统一使用大端序,也成为网络字节序。
在Linux中,提供了四个用于主机字节序和网络字节序之间相互转换的函数:
1
2
3
4
5
6
7
8
9
10
11
// host to network (short)
uint16_t htons(uint16_t value);
// host to network (long)
uint32_t htonl(uint32_t value);
// network to host (short)
uint16_t ntohs(uint16_t value);
// network to host (long)
uint32_t ntohl(uint32_t value);
IP地址格式转换
IP地址一共有两种格式:
- Presentation(表达格式):也就是我们能看得懂的格式,例如"192.168.19.12"这样的字符串
- Numeric(数值格式):可以存入套接字地址结构体的格式,数据类型为整型
Linux 提供了两个函数用于IP地址格式的相互转换的函数:
inet_pton()
:将IP地址从表达格式转换为数值格式inet_ntop()
:将IP地址从数值格式转换为表达格式
1 |
|
参数:
family
:地址族,通常取AF_INET
(IPv4)或AF_INET6
(IPv6)。strptr
:(输入参数,表达格式)指向以点分隔的十进制(IPv4)或冒号分隔(IPv6)的 IP 地址字符串的字符指针。addrptr
:(输出参数,数值格式)指向struct in_addr
(IPv4)或struct in6_addr
(IPv6)的通用指针,用于存储转换后的二进制 IP 地址。
返回值:
- 如果转换成功,则返回 1。
- 如果表达格式的 IP 地址格式有误,则返回 0。
- 如果转换出错则返回 -1,并设置相应的
errno
。
1 |
|
参数:
family
:地址族,AF_INET
(IPv4)或AF_INET6
(IPv6)。addrptr
:(输入参数,数值格式)指向struct in_addr
(IPv4)或struct in6_addr
(IPv6)的通用指针。strptr
:(输出参数,表达格式)用于存储转换后的 IP 地址字符串的缓冲区字符指针。len
:strptr
的缓冲区长度。
返回值:
- 如果转换成功,则返回
strptr
指针。 - 如果出错,则返回
NULL
,并设置相应的errno
。
输出缓冲区的大小建议设置为如下值 1
2
3
4