2020-11-21计网实验:Socket 编程
点此下载实验代码
1. 实验目的
- 掌握 TCP 和 UDP 协议主要特点和工作原理
- 理解 socket 的基本概念和工作原理
- 编程实现 socket 网络通信(C/Python/Java)
2. 实验代码实现
2.1 代码组织结构
本次实验实现的 3 个项目(6 个程序)的代码分别为:
server_reg.c
和client_reg.c
:简单的网络注册服务器和客户端server_check.c
和client_check.c
:简易的网络签到服务器和客户端server_chat.c
和client_chat.c
:UDP 聊天室服务器和客户端
其中这些代码需要依赖以下的自己写的函数库:
csutil.c
:Client & Server Utility Function,客户端和服务器间通信所需的函数,封装了很多对 socket 的操作stuutil.c
:Student Utility Function,建立、查询、写入学生数据库所需函数的封装chatutil.c
:Chatting Utility Function,基于 UDP 的自定义聊天应用层协议的实现
也包括了这三个函数库的头文件:csutil.h
、stuutil.h
和chatutil.h
。
为了方便一起编译和链接数量众多的代码文件,同时写了 Makefile。其中本次实验的 6 个程序分别编译输出为server_reg
、client_reg
、server_check
、client_check
、server_chat
、client_chat
。
2.2 csutil.c
中的 socket 操作封装
由于本次实验都围绕 socket 编程展开,因此可以把常规操作写进csutil.c
一些函数里。这样之后有重复的功能使用时,只需添加#include "csutil.h"
,即可复用这些代码。
2.2.1 异常处理函数
使用 socket 时的很多函数都可能会产生错误。由 UNIX 程序设计可知,当使用 UNIX 提供的函数出现异常时,异常代码会保存在extern
的全局变量errno
中,需要包含errno.h
头文件。而要获取该错误代码对应的字符串信息,可使用perror
函数,需要包含string.h
头文件。
这里由于程序逻辑简单,因此一旦遇到错误,则打印是哪个函数出错,以及错误信息,并退出程序,返回值为 1:
| // 异常处理。如果有异常,打印错误信息,直接退出
// funcname: 出错的函数名称
void throw_exception(char *funcname)
{
perror(funcname);
exit(1);
}
|
2.2.2 IP:port
和sockaddr
结构体的转换函数
在我们使用 UNIX 的bind
、connect
和sendto
函数时,需要传入sockaddr
结构体。而通常我们只是手动输入 IP 和端口号。因此编写了get_addr
函数进行 IP+端口号到sockaddr_in
结构体的转换:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 | // 将 IPv4:Port 转换为 `struct sockaddr_in`
// ip: 待转换 IP 地址
// port: 待转换端口号
// addr: 存储结果的结构体指针
void get_addr(char *ip, int port, struct sockaddr_in *addr)
{
int ip_net, port_net;
if (inet_pton(AF_INET, ip, &ip_net) < 0)
throw_exception("inet_pton");
port_net = htons(port);
addr->sin_family = AF_INET;
addr->sin_addr.s_addr = ip_net;
addr->sin_port = port_net;
bzero(addr->sin_zero, sizeof(addr->sin_zero));
}
|
同时,在使用 UNIX 的accept
和recvfrom
函数时,我们需要获取是哪个 IP 的哪个端口上的客户端向我们发来数据。因此也需要将从这两个函数中获得的sockaddr
结构体转换为 IP+端口号:
| // 将 `struct sockaddr_in` 转换为 IPv4:Port
// ip: 存储 IP 地址指针
// port: 存储端口号的指针
// addr: 待转换的结构体指针
void get_ip_port(char *ip, int *port, struct sockaddr_in *addr)
{
if (inet_ntop(AF_INET, &addr->sin_addr.s_addr, ip, INET_ADDRSTRLEN) == NULL)
throw_exception("inet_ntop");
*port = ntohs(addr->sin_port);
}
|
这里的转换函数没有使用实验 PPT 上提供的inet_addr
、inet_ntoa
等函数,因为在《UNIX 环境高级编程》一书的第 16 章中提到,这两个函数没有这里使用的inet_pton
、inet_ntop
有更好的兼容性
这里的转换都只是考虑 IPv4 地址字符串,且不支持输入域名再解析 IP 地址。如果是 IPv6 地址,应将sockaddr_in
结构体换成sockaddr_in6
,且将AF_INET
换成AF_INET6
。
2.2.2 服务器的建立函数
2.2.2.1 函数原型
由于建立服务器的代码在本实验中也经常复用,因此将建立服务器的过程封装成make_server
函数。函数原型如下:
| // 建立一个运行在 `sip`:`sport` 的服务器
// sip: 服务器 IP
// sport: 服务器端口
// protocol: 应用层协议,可以是 TCP 或 UDP
// handler: 处理数据收发函数
// block: 是否阻塞连接(是否为迭代模式)
void make_server(char *sip, int sport, int protocol, void (*handler)(int), bool block);
|
sip
和sport
:该函数在sip:sport
上运行服务器。下文中出现的变量名,前缀带有s
的意为服务端的信息,前缀带有c
的意为客户端的信息
protocol
:服务器的传输层协议由protocol
指定,可以是TCP
或UDP
。这两个宏常量在csutil.h
里分别被定义为SOCK_STREAM
和SOCK_DGRAM
handler
:服务器遇到用户连接时的处理函数。该函数需要接受一个参数,为用于该连接的(套接字)文件描述符fd
,也即accept
函数的返回值(对于 TCP 协议)或socket
函数的返回值(对于 UDP 协议)
block
:服务器是否在处理用户连接的过程中阻塞。即服务器能否多进程同时处理并发请求
block
参数只适用于 TCP 的情况。对于 UDP 聊天室而言,由于 UDP 是无连接的,因此不会发生和客户端连接时的阻塞情况,该参数可以任意设置
该函数具体实现如下。首先,进行固定的socket
、bind
操作:
| char cip[INET_ADDRSTRLEN];
int cport, sfd, cfd, caddr_len;
struct sockaddr_in saddr, caddr;
pid_t pid;
if ((sfd = socket(AF_INET, protocol, 0)) < 0)
throw_exception("socket");
get_addr(sip, sport, &saddr);
if (bind(sfd, (struct sockaddr *)&saddr, sizeof(saddr)) < 0)
throw_exception("bind");
printf("Serving at %s:%d\n", sip, sport);
|
这里用于存储客户端 IP 地址的cip
数组长度设置为了 IPv4 地址的最大长度INET_ADDRSTRLEN
,即 16(包括最后的\0
)。这一常量在arpa/inet.h
头文件里定义。
2.2.2.2 迭代 TCP 服务器
之后,针对不同的选项进行不同的操作。首先是迭代的 TCP 服务器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 | // TCP 迭代
if (protocol == TCP && block)
{
if (listen(sfd, SOMAXCONN) < 0)
throw_exception("listen");
while (1)
{
if ((cfd = accept(sfd, (struct sockaddr *)&caddr, &caddr_len)) < 0)
throw_exception("accept");
get_ip_port(ip, &port, &caddr);
printf("\nAccepted connection from %s:%d\n", ip, port);
handler(cfd);
close(cfd);
}
}
|
需要先listen
,之后只需一个循环处理一次accept
即可。并在accept
之后友好的打印客户端的地址信息,再调用handler
函数实现自定义的套接字读写操作与客户端通信。
这里的listen
的第二个参数并没有像实验 PPT 那样设置为 0。根据 UNIX 的文档,TCP 的最大连接数在sys/socket.h
头文件中有SOMAXCONN
的宏定义。在本地的 Linux 系统上为 128
2.2.2.3 并发 TCP 服务器
其次是并发的 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 | // TCP 并发
else if (protocol == TCP && !block)
{
if (listen(sfd, SOMAXCONN) < 0)
throw_exception("listen");
signal(SIGCHLD, SIG_IGN); // 避免僵尸进程
while (1)
{
if ((cfd = accept(sfd, (struct sockaddr *)&caddr, &caddr_len)) < 0)
throw_exception("accept");
get_ip_port(ip, &port, &caddr);
printf("\nAccepted connection from %s:%d\n", ip, port);
if ((pid = fork()) < 0)
throw_exception("fork");
else if (pid == 0)
{
close(sfd);
handler(cfd);
close(cfd);
exit(0);
}
close(cfd);
}
}
|
与迭代 TCP 服务器不同的是,程序在accept
之后,立即调用fork
创建子进程,并在子进程中处理这一新建的连接,而父进程继续循环等待其他的连接。
注意到一些细节问题。在 UNIX 系统中,如果在子进程结束之前,父进程没有来得及调用sys.wait.h
中的一系列函数获取子进程的结束信息,则子进程会变成僵尸进程,占用系统资源。但是我们这里不可以在父进程中的while
循环中使用wait
,因为一旦在循环中wait
后,我们的父进程就也被阻塞了,成为了迭代式的 TCP 服务器。因此有两种避免的方法:
- 一种是
fork
两次,在子进程的子进程中处理请求,并提前结束子进程,使用wait
获取子进程信息。这样子进程的子进程就会被init
进程接管,避免了僵尸进程的产生 - 另外是通过信号处理函数。当子进程终止时,父进程会收到
SIGCHLD
信号。因此如果我们在收到该信号时进行wait
,或者直接忽略该信号,让子进程的信息直接从系统的进程表项中删除,都可以避免僵尸进程的产生。这里使用和实验 PPT 上一样的忽略该信号的做法:在父进程中调用signal(SIGCHLD, SIG_IGN)
,需要包含signal.h
头文件
同时,为了进一步节约资源,当一个进程fork
时,其文件表项也会被复制一份,就像调用了dup
函数一样。但是这里在子进程中,我们不需要父进程的sfd
(套接字文件描述符),在父进程中我们不需要子进程的cfd
(针对一个 TCP 连接的文件描述符),因此可以在各自的分支中关闭。
2.2.2.4 UDP 服务器
由于 UDP 是无连接的,因此其实现最简单,既不要listen
也不要accept
,直接循环调用handler
进行数据收发即可:
| // UDP
else if (protocol == UDP)
{
while (1)
handler(sfd);
}
|
2.2.3 客户发起与服务器的通信函数
客户端与服务器发起通信也是一个在本实验中经常复用的函数。这里和建立服务器类似,函数需要提供服务器 IP、服务器端口号、运输层协议、以及handler
函数这几个参数。之后函数通过connect
与服务器建立连接,获取到用于该连接的文件描述符cfd
,之后调用handler
,传入cfd
参数进行数据的收发处理。具体实现如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 | // 向 `sip`:`sport` 处的服务器发起连接
// sip: 服务器 IP
// sport: 服务器端口
// protocol: 应用层协议,可以是 TCP 或 UDP
// handler: 处理数据收发函数
void contact_server(char *sip, int sport, int protocol, void (*handler)(int))
{
int fd;
struct sockaddr_in saddr;
if ((fd = socket(AF_INET, protocol, 0)) < 0)
throw_exception("socket");
get_addr(sip, sport, &saddr);
if (connect(fd, (struct sockaddr *)&saddr, sizeof(saddr)) < 0)
// UDP works too
throw_exception("connect");
handler(fd);
close(fd);
}
|
注意到这里的connect
并没有排除 UDP 协议!这是因为根据《UNIX 环境高级编程》第 16 章 16.4 节,UDP 协议下也可调用connect
函数。这样做的效果是每次使用send
发送数据时,会默认向connect
函数指定的地址发送。且调用recv
函数时,也只会从connect
函数指定的地址接收数据报。不需要我们每次使用sendto
和recvfrom
函数指明地址。
2.2.4 数据收发函数
由于 UNIX 提供的收发函数仍然较为底层,且没有异常处理,因此针对recv
、send
、recvfrom
、sendto
这几个函数编写了更友好的封装:recv_data
、send_data
、recv_data_from
、send_data_to
,并添加了异常处理。以recv_data_from
为例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 | // 封装的更方便的`recvfrom`
// fd: socket 文件描述符
// buf: 缓冲区
// ip: 存放发送方 IP 的指针
// port: 存放发送方端口的指针
void recv_data_from(int fd, void *buf, char *ip, int *port)
{
struct sockaddr_in caddr;
int caddr_len = sizeof(caddr);
bzero(buf, MAXLINE);
again:
if (recvfrom(fd, buf, MAXLINE, 0, (struct sockaddr *)&caddr, &caddr_len) < 0)
{
if (errno == EINTR)
goto again; // 防止 interruped system call
throw_exception("recvfrom");
}
get_ip_port(ip, port, &caddr);
}
|
可见其不需要传入sockaddr
结构体,而直接传入接受 IP 和端口号的地址即可。并且不需要指定缓冲区长度,因为在csutil.h
里,统一将发送方和接收方的缓冲区的长度设置为MAXLINE
,8192。也不需要每次在接收前都手动对缓冲区进行 0 初始化,因为函数中已经帮我们通过bzero
进行 0 填充。
注意到该函数中使用了goto
,这里是避免recv
系列函数在执行的过程中被打断。因为后续的程序中有用到信号处理函数,当程序接收到信号时,对于普通的系统调用,会让它执行完再执行信号处理函数,但是对于慢的系统调用,如网络套接字上的读写,当收到信号时,该系统调用会被中断,并返回错误码EINTR
。因此,如果这个时候直接报错退出,是无法实现程序所需功能的。解决方法是,一旦遇到这个错误,就重新进行一遍当前的系统调用,而最简便的解决方法就是利用goto
。
这也是《UNIX 环境高级编程》一书在第 10 章中的示例做法
同样的,以send_data
函数为例:
| // 封装的更方便的`send`
// fd: socket 文件描述符
// buf: 缓冲区
// len: 发送的数据部分的长度,为 0 则自动计算
void send_data(int fd, void *buf, int len)
{
if (len == 0)
len = strlen(buf) + 1;
if (send(fd, buf, len, 0) < 0)
throw_exception("send");
}
|
可见其提供了一个选项,如果将长度设置为 0,则不需要我们手动计算需要发送数据的长度。这对发送字符串常量时非常友好。
2.3 简单的网络注册服务(迭代)
2.3.1 stuutil.c
学生数据库
为了实现学生的信息存储、添加、查询,因此编写了stuutil.c
。这里采用文本文件形式存储学生信息:第一行为学生姓名,第二行为学生的学号。每个学生信息之间也用一行隔开。文本文件的位置为stuutil.h
里定义的DB_PATH
,即服务端程序运行目录下的student.txt
。
这里需要用到add_student
函数,向数据库中添加学生信息,即直接在该文本文件后追加信息:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 | // 向数据库中添加学生
// name: 姓名
// number: 学号
void add_student(char *name, char *number)
{
FILE *fp;
fp = fopen(DB_PATH, "at");
fputs(name, fp);
fputs("\n", fp);
fputs(number, fp);
fputs("\n", fp);
fclose(fp);
}
|
2.3.2 服务端程序
有了stuutil.h
和之前的csutil.h
后,接下来的写代码过程就异常轻松。因为我们只要从命令行中获取 IP 和端口,指定协议后,自行编写handler
函数即可。例如,服务端的完整代码如下:
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 | /* 注册服务器 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "csutil.h"
#include "stuutil.h"
// 与客户端的数据收发处理
// cfd: socket 文件描述符
void handler(int cfd)
{
char buf_name[MAXLINE], buf_number[MAXLINE];
while (1)
{
// 第一轮收发
send_data(cfd, "Please input student name:", 0);
recv_data(cfd, buf_name);
if (strcmp(buf_name, "bye") == 0)
return;
printf("Student name:\t%s\n", buf_name);
// 第二轮收发
send_data(cfd, "Please input student number:", 0);
recv_data(cfd, buf_number);
printf("Student number:\t%s\n", buf_number);
add_student(buf_name, buf_number);
}
}
int main(int argc, char *argv[])
{
char *ip;
int port;
if (argc != 3)
{
printf("Usage: ./server_reg server_ip server_port\n");
return 0;
}
ip = argv[1];
port = atoi(argv[2]);
make_server(ip, port, TCP, handler, true); // 建立迭代 TCP 服务器
return 0;
}
|
其中,在make_server
函数中,设置协议为 TCP,并设置为迭代模式。而在处理客户端请求的handler
函数中,每次循环进行两轮收发,分别对应姓名和学号。直到输入的姓名为bye
时退出。同时,针对接收到的姓名和学号,在服务端的屏幕上打印,并接着通过 2.3.1 中提到的add_student
函数存储。
2.3.3 客户端程序
而对于客户端,其结构大致类似。只需将main
函数的make_server
换成 2.2.3 中提到的contact_server
,并且实现处理和服务器连接handler
即可。客户端的handler
函数实现如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23 | // 与服务器的数据收发处理
// cfd: socket 文件描述符
void handler(int cfd)
{
char buf[MAXLINE];
while (1)
{
// 第一轮收发,输入姓名
recv_data(cfd, buf);
printf("[Server] %s\n[Client] ", buf);
fgets(buf, MAXLINE, stdin);
buf[strlen(buf) - 1] = '\0';
send_data(cfd, buf, 0);
if (strcmp(buf, "bye") == 0)
break;
// 第二轮收发,输入学号
recv_data(cfd, buf);
printf("[Server] %s\n[Client] ", buf);
fgets(buf, MAXLINE, stdin);
buf[strlen(buf) - 1] = '\0';
send_data(cfd, buf, 0);
}
}
|
同样进行两轮的收发。并在输入为bye
的情况下退出。
2.4 简易的网络签到服务(并发)
2.4.1 stuutil.c
学生数据库
和 2.3.1 节同样,在stuutil.c
中实现简单的学生查询函数has_student
:
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 | // 数据库中是否存在该学生
// name: 姓名
// return: true 为存在,false 为不存在
bool has_student(char *name)
{
char buf[MAXLINE];
int len;
FILE *fp;
fp = fopen(DB_PATH, "rt");
while (!feof(fp))
{
fgets(buf, MAXLINE, fp);
buf[strlen(buf) - 1] = '\0';
if (strcmp(buf, name) == 0)
{
fclose(fp);
return true;
}
fgets(buf, MAXLINE, fp);
buf[strlen(buf) - 1] = '\0';
}
fclose(fp);
return false;
}
|
由于签到只考虑姓名,因此如果学生姓名存在,则返回true
。
2.4.2 服务端程序
服务端程序的主函数只需将make_server
中的最后一个参数由 2.3.2 中的true
改为false
,即建立并发式的 TCP 服务器:
| make_server(ip, port, TCP, handler, false);
|
服务端程序的handler
函数如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 | // 与客户端的数据收发处理
// cfd: socket 文件描述符
void handler(int cfd)
{
char buf[MAXLINE];
send_data(cfd, "Please input student name:", 0);
while (1)
{
recv_data(cfd, buf);
if (strcmp(buf, "bye") == 0) // bye 的情况
return;
if (has_student(buf)) // 查找学生,存在的情况
{
send_data(cfd, "Successfully checked in! Next name:", 0);
printf("%s checked in\n", buf);
}
else // 查找学生,不存在的情况
send_data(cfd, "No such student! Next name:", 0);
}
}
|
每次循环只进行一轮收发。如果收到的学生姓名存在,则也同时输出在服务端的屏幕上(方便老师后台查看签到情况)。如果不存在也会向客户端发送错误信息。如果接收到bye
,则退出。
2.4.3 客户端程序
客户端的handler
更为简单,每次循环进行一轮收发,如果输入bye
则退出:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 | // 与服务器的数据收发处理
// cfd: socket 文件描述符
void handler(int cfd)
{
char buf[MAXLINE];
while (1)
{
// 接收服务器提示
recv_data(cfd, buf);
printf("[Server] %s\n[Client] ", buf);
// 向服务器发送姓名
fgets(buf, MAXLINE, stdin);
buf[strlen(buf) - 1] = '\0';
send_data(cfd, buf, 0);
if (strcmp(buf, "bye") == 0) // 如果输入 bye,退出循环
break;
}
}
|
2.5 基于 UDP socket 的聊天室
2.5.1 chatutil.c
应用层协议
2.5.1.1 协议报文格式
为了在 UDP 协议的基础上实现聊天室用户注册、客户端向服务器的消息传送、服务端对消息的广播,因此在chatutil.h
里定义了自己设计的应用层协议(后文暂且称为简单聊天协议)的报文格式。头部部分结构体为struct heaer
:
| #pragma once
#pragma pack(push, 1)
struct header // 应用层报文头部
{
uint16_t id;
uint8_t flags;
uint8_t len;
};
#pragma pack(pop)
|
采用#pragma pack(1)
强制一字节对齐。其对应如下:
0-7bit | 8-15bit |
---|
客户端 ID 高 8 位 | 客户端 ID 低 8 位 |
标志位 | 数据部分长度 |
头部之后紧接着就是数据部分
0x80 | 0x40 | 0x20 | 0x10 | 0x08 | 0x04 | 0x02 | 0x01 |
---|
保留 | 保留 | BRD | FIN | SND | REG | NAK | ACK |
2.5.1.2 协议确认机制
为了确保客户端能及时检测到服务器挂了,而不是一直接收,不知道是没有人发送消息还是服务器挂了,因此针对客户端向服务器的通信引入了确认机制:
- 服务器会对客户端发送的 REG 报文进行 REG ACK 或 REG NAK 确认
- 服务器会对客户端发送的 SND 报文进行 SND ACK 确认
- 服务器会对客户端发送的 FIN 报文进行 FIN ACK 确认
- 如果一段时间内客户端没有收到确认,则认为服务器已经挂了。超时时间在
chatutil.h
里定义为TIMEOUT
,为 8 秒
2.5.1.2 应用层的收发函数
为了处理自己设计的应用层协议,还需设计针对该协议的收发函数。而服务器的收发和客户端的收发又有所不同,因为 UDP 的无连接性质,服务器需要知道另一方的地址,即要使用sendto
和recvfrom
。以服务器的发送函数举例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 | // 服务器发送简单聊天协议报文
// cfd: socket 文件描述符
// cip: 接收方客户端 IP 地址
// cport: 接收方客户端端口号
// id: 接收方客户端 ID
// flags: 协议报文标志位
// data: 协议报文数据部分
void server_send_chat(int cfd, char *cip, int cport, int id, int flags, char *data)
{
int len = strlen(data) + 1;
struct header hd;
char buf[sizeof(hd) + len];
hd.id = id;
hd.flags = flags;
hd.len = len;
memcpy(buf, &hd, sizeof(hd));
memcpy(buf + sizeof(hd), data, len);
send_data_to(cfd, buf, cip, cport, sizeof(hd) + len);
}
|
需要知道要发送的客户端的 ID、IP 地址、端口号,以及需要指明的标志。之后该函数通过这些信息先构建简单聊天协议的头部,再将头部和数据部分拼接起来,复制到buf
中,再使用csutil.c
中的send_data_to
函数发送。
再以客户端的接收函数举例。由于在 2.2.3 中,客户端事先已经通过connect
函数指明服务器的地址,因此客户端不需要在收发函数中再指明服务器的地址:
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 | // 客户端接收简单聊天协议报文
// cfd: socket 文件描述符
// flags: 协议报文标志位
// data: 协议报文数据部分
// timeout: 等待 ACK 的时间,超过此时间则认为服务器挂了
// return: 发送方客户端 ID
int client_recv_chat(int cfd, int *flags, char *data, int timeout)
{
struct timeval tv;
struct header hd;
char buf[MAXLINE];
tv.tv_usec = 0;
tv.tv_sec = timeout;
if (setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) < 0) // 设置接收的等待时间
throw_exception("setsockopt");
recv_data(cfd, buf);
tv.tv_sec = 0;
if (setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) < 0) // 恢复接收的等待时间
throw_exception("setsockopt");
memcpy(&hd, buf, sizeof(hd));
memcpy(data, buf + sizeof(hd), hd.len);
*flags = (int)hd.flags;
return hd.id;
}
|
同样,客户端收到数据后,先存放到buf
中,之后依次复制给header
头部结构体和data
数据部分。再将header
中的flags
字段给flags
指针指向的变量,并返回header
中的客户端 ID。
为了考虑服务器突然掉线的情况,本函数接收timeout
参数,单位秒。通过setsockopt
函数设置了recv
系列函数的超时时间。如果超时,则recv
会产生 Resource not avaliable 的错误信息,并推出程序。
2.5.2 服务端程序
2.5.2.1 主函数部分
与 2.3.2 和 2.4.2 的服务端程序类似,主函数中只需将make_server
的协议参数改为 UDP 即可:
| make_server(ip, port, UDP, handler, false);
|
2.5.2.2 客户端信息存储
定义client
结构体存放单个用户信息,包括 IP、端口号、名称,以及是否存活:
| // 客户端信息结构体
struct client
{
bool alive; // 是否存活
char ip[INET_ADDRSTRLEN]; // 客户端 IP
char name[MAXNAME]; // 客户端名称
int port; // 客户端端口号
};
|
这里引入是否存活字段alive
的机理是,初始化时,所有的alive
都为false
。当客户端连接时,该客户端 ID 对应的alive
变为true
。当客户端向服务器发送 FIN 时,服务器就将该客户端 ID 对应的alive
设为false
。待新用户加入时,如果发现有 ID 对应的内容为false
,即可将该 ID 分配给新的用户,直接覆盖原有数据。这省去了数组元素的添加、删除操作。
之后,建立clis
数组,数组下标即为客户端 ID。这样只需通过 ID 一次寻址即可获得客户信息,而不用写个循环进行查找:
| struct client clis[MAXCLIENT];
|
为了在用户注册时查找是否有名称重复,因此编写了has_name
函数:
| // 当前已连接的客户端中是否存在该名称
// name: 待查找的名称
// return: true 为存在,false 为不存在
bool has_name(char *name)
{
for (int i = 1; i < MAXCLIENT; i++)
if (clis[i].alive && strcmp(clis[i].name, name) == 0)
return true;
return false;
}
|
在同意用户注册后,需要给该用户分配一个 ID,并且在该 ID 对应的结构体中存放用户信息。因此编写了cli_alloc
函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 | // 为刚加入的客户端分配 ID
// name: 客户端姓名
// ip: 客户端 IP 地址
// port: 客户端端口号
// return: 分配到的客户端 ID,0 表示已满
int cli_alloc(char *name, char *ip, int port)
{
for (int i = 1; i < MAXCLIENT; i++)
if (!clis[i].alive)
{
clis[i].alive = true;
strcpy(clis[i].name, name);
strcpy(clis[i].ip, ip);
clis[i].port = port;
return i;
}
return 0;
}
|
从下标 1 开始检测到第一个alive
字段为false
的客户端,分配给新的客户端使用。如果客户端已满,则返回 0。
2.5.2.3 应用层报文处理
在应用层的handler
函数中,首先调用chatutil.c
中的server_recv_chat
接受一个客户端发来的一个简单聊天协议的应用层报文,并打印该报文信息:
| char cip[INET_ADDRSTRLEN], data[MAXDATALEN], appended_data[MAXDATALEN]; // appended_data 为服务器修改后的发送内容
int cport, id, flags;
// 打印服务器收到的应用层报文信息
id = server_recv_chat(sfd, cip, &cport, &flags, data);
printf("%s:%d, id=%d, flags=%d, data=%s\n", cip, cport, id, flags, data);
|
之后,根据该报文的标志位不同,做不同的处理。首先是 REG 标志位,处理用户注册的情况:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 | // 收到注册报文情况
if (flags & F_REG)
{
if (has_name(data)) // 名称冲突,发送 NAK 报文反馈
server_send_chat(sfd, cip, cport, id, F_REG | F_NAK, "Name has already used");
else
{
id = cli_alloc(data, cip, cport);
if (id != 0)
{
server_send_chat(sfd, cip, cport, id, F_REG | F_ACK, ""); // 注册成功,发送 ACK 报文反馈
sprintf(appended_data, "%s (%s:%d) joins the conversation", clis[id].name, clis[id].ip, clis[id].port);
for (int i = 1; i < MAXCLIENT; i++) // 向其他客户端广播该成员已经加入
if (clis[i].alive)
server_send_chat(sfd, clis[i].ip, clis[i].port, i, F_BRD, appended_data);
}
else // 服务器已满,发送 NAK 报文反馈
server_send_chat(sfd, cip, cport, id, F_REG | F_NAK, "Server is full");
}
}
|
当用户注册成功时,服务器会像所有的客户端(包括注册的客户端)广播该客户加入聊天的信息。
接下来考虑 SND 标志位:
| // 收到发送报文情况
else if (flags & F_SND)
{
server_send_chat(sfd, cip, cport, id, F_SND | F_ACK, ""); // 发送 ACK 报文反馈
sprintf(appended_data, "[%s] %s", clis[id].name, data);
for (int i = 1; i < MAXCLIENT; i++) // 向其他客户端广播该客户端发送的信息
if (clis[i].alive && i != id)
server_send_chat(sfd, clis[i].ip, clis[i].port, i, F_BRD, appended_data);
}
|
服务器首先向发送方回复 ACK,然后将从客户端收到的聊天信息转发给其他的客户端。
最后考虑 FIN 标志位:
| // 收到结束报文情况
else if (flags & F_FIN)
{
server_send_chat(sfd, cip, cport, id, F_FIN | F_ACK, ""); // 发送 ACK 报文反馈
clis[id].alive = false;
sprintf(appended_data, "%s (%s:%d) leaves the conversation", clis[id].name, clis[id].ip, clis[id].port);
for (int i = 1; i < MAXCLIENT; i++) // 向其他客户端广播该成员已经离开
if (clis[i].alive && i != id)
server_send_chat(sfd, clis[i].ip, clis[i].port, i, F_BRD, appended_data);
}
|
服务器也是先回复 ACK,然后将该客户都标志位置为false
,最后向其他成员广播该成员离开的信息。
2.5.3 客户端程序
2.5.3.1 主函数部分
因为普通的程序输入和输出只能依次进行,就像之前的注册和签到服务一样,各自进行一轮收发。但是聊天室的用户应该想发就发。因此主函数部分首先注册 Ctrl+C 信号的处理函数switch_mode
,用于客户端可以在按下 Ctrl+C 后立即切换到输入模式。
而连接服务器部分只需稍微修改contact_server
的参数变成 UDP 即可:
| signal(SIGINT, switch_mode); // 注册 Ctrl+C 信号的处理程序。在聊天过程中,Ctrl+C 为切换到输入模式
...
contact_server(ip, port, UDP, handler);
|
之后的注册和接受消息环节靠handler
实现,而发送消息环节靠信号处理实现。
2.5.3.2 注册和接收消息环节
首先是注册环节:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 | // 注册环节
while (1)
{
printf("Please input your name: ");
fgets(name, MAXNAME, stdin);
name[strlen(name) - 1] = '\0';
client_send_chat(cfd, 0, F_REG, name);
while (1) // 等待服务端的 F_ACK,如果超时则直接报错退出
{
id = client_recv_chat(cfd, &flags, data, TIMEOUT);
if ((flags & F_REG) && (flags & (F_ACK | F_NAK)))
break;
}
if (flags & F_ACK) // 如果服务器同意注册,则退出循环,否则重新起名字
break;
printf("%s\n", data);
}
|
可见,在注册环节中也引入了超时机制。如果超时未收到 ACK 或 NAK 回复,则认为服务器挂了,退出。
接收消息环节很简单,只需一直循环接收并打印即可:
| // 接收消息环节
while (1)
{
client_recv_chat(cfd, &flags, data, 0);
if (flags & F_BRD)
printf("%s\n", data);
}
|
注意这里的超时时限被设置为 0,即无限长,因为可能存在大家都潜水一直不说话的情况。因此若要检测服务器是否正常运作,需要靠接下来的发送消息环节的确认实现。
2.5.3.4 发送消息环节
以下是switch_mode
的部分代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 | // 切换到输入消息模式(按下 Ctrl+C 触发)
void switch_mode()
{
...
// 打印自己的名称,提示输入,获取输入
printf("\r[%s] ", name);
fgets(data, MAXDATALEN, stdin);
data[strlen(data) - 1] = '\0';
...
// 输入为其他聊天信息的情况
client_send_chat(cfd, id, F_SND, data);
while (1) // 等待服务端的 F_ACK,如果超时则直接报错退出
{
client_recv_chat(cfd, &flags, data, TIMEOUT);
if ((flags & F_SND) && (flags & F_ACK))
break;
else if (flags & F_BRD)
printf("%s\n", data);
}
}
|
可以看到,按下 Ctrl+C 后,首先屏幕上会打印出自己的名字提示输入,之后按下回车,客户端就将该输入信息发送给服务端,并启动超时等待服务端 ACK 报文。如果超时未收到该报文,则认为服务器挂了,结束。如果没有先收到 ACK,而是收到 BRD 广播消息,则也正常打印该消息。
3. 代码运行和探究
使用make
命令,借助之前写的 Makefile 进行自动化编译,之后输入./程序名 ip 端口
即可运行客户端或服务器。
3.1 简单的网络注册服务(迭代)
3.1.1 常规运行效果
打开 1 个服务器(右中),4 个客户端(左边和右上角),以及一个查看netstat
命令的终端。效果如下。
步骤一:开启服务器,运行在本地 6666 端口。然后按照左上、左中、左下、右上的顺序开启客户端。之后可见左上的服务端优先和服务器通信,其他的则进行等待。由netstat
可知,所有的连接均处于ESTABLISHED
状态,即三次握手完成。并且由服务端输出的信息可知,当前是端口号 36656 的进程在和服务器通信。如下图所示:

步骤二: 在左上输入bye
,之后左中的客户端立即和服务器开始通信。同时由netstat
,原来 6666 到 36656 的连接关闭,而 36656 到 6666 端口的连接处于关闭前的TIME_WAIT
状态。这是因为如课本 5-29 图所示,服务器收到客户的 FIN 和 FIN ACK 后,就关闭了连接,而客户端要在收到服务器的 FIN ACK 后,等待 2MSL 再关闭连接。且由服务器输出信息可知,当前客户端端口号为 36658。如下图所示:

步骤三:在左中输入bye
,左下角立即开始连接。可见其是按发起连接的先后顺序来的。此时的netstat
中 56658 到 6666 端口也处于TIME_WAIT
状态,而右上角的可怜的客户端仍在等待中。如下图所示:

步骤四:在左下输入bye
,右上和服务器通信,输入完后再输入bye
。等待一段时间后(让这段时间超过 2MSL),再使用netstat
查看,则以上的连接全部释放。如下图所示:

可见其表现符合预期。
上文提到,这里的 TCP 连接的backlog
设置为了SOMAXCONN
,在本地系统上是 128,因此这几个客户端的连接均被接受,且一直在等待中
3.1.2 TCP 连接数限制情况
之前在csutil.c
的make_server
函数中,将 TCP 连接数(即listen
的backlog
参数)设置为SOMAXCONN
,为 128,既不是 0 也不是 1。若将其改为 0,重复上述步骤,如下图所示:

可以发现,右下角用netstat
的时候,端口号最高的那个客户端 36690(左下)、36692(右上)对 6666 的连接为SYN_SENT
状态,只完成一次握手。并且不久之后,这两个就出现了 Connection timed out 的错误,可见他们并没有成功和服务器建立连接。这是因为由课本图 5-28 所示,TCP 建立连接前要进行三次握手。而由于 TCP 最大连接数的限制,后两个客户端发送 SYN 后,服务端没有响应 SYN ACK,导致只完成一次握手,并且因为握手超时而结束链接
现在将其改为 1,重复上述步骤,如下图所示:

可见这次多了一个客户端的连接容量。据此推测最大可连接的客户端数可能是backlog + 2
。
3.1.3 客户机绑定固定端口情况
为此,在csutil.c
的contact_server
函数中插入以下代码,将客户端绑定在本地的 5555 端口:
| struct sockaddr_in caddr;
get_addr("127.0.0.1", 5555, &caddr);
if (bind(fd, (struct sockaddr *)&caddr, sizeof(caddr)) < 0)
throw_exception("bind");
|
之后,使用make
重新编译,先让客户端给服务器通信,然后关闭,再给服务器通信。第一次通信时一切正常,但如果结束后,立即通信,客户端程序会被提示 Address already in use,说明之前绑定的 5555 端口号已被占据,如下图所示:

可以看到,之前建立的连接仍处于TIME_WAIT
状态。因此如果在连接完全释放之前再用同样的端口绑定,则会因为之前的端口号还在使用中而无法绑定。但是如果等待一段时间,经过 2MSL 后,再发起连接,则又可以通信:

因此,不建议客户端绑定同样的端口号,是因为客户端关闭连接后,连接需要等待一段时间完全释放,因此端口号不能立即复用,使得在一段时间内无法建立连接。
3.1.4 IP 地址和端口的字节序转换
由于inet_ntop
和inet_pton
两个函数自带网络字节序转换。为此,只能演示端口的网络字节序不转换的情况。将csutil.c
中的get_addr
函数中的
改为
使用make
编译后,仍然在 6666 端口运行服务器:

可以看到,仍然能够正常通信,这是因为客户端和服务器都使用到get_addr
函数,因此名为 6666 端口,实际均当作了 2586 端口。但是在实际应用上,这种错误是不允许的。
接下来分析一下原因。将 6666 转成 16 位二进制数,分成两个字节为 $$ 6666_{10}=0001\;1010\quad0000\;1010_2 $$ 而将这两个字节反序,而字节内的顺序不变,再转回 10 进制,就是 2586: $$ 0000\;1010\quad0001\,1010_2=2586_{10} $$ 可见在网络和在本地机器上使用了不同的端序来存放数据,因此转换是必要的。
3.2 简易的网络签到服务(并发)
3.2.1 常规运行效果
打开 1 个服务器(右下),也是运行在 6666 端口;5 个并发客户端(左边和右上、右中)。效果如下:
图 1:五个客户端均可同时与服务器建立连接,服务器也展示了 5 个客户端连入的信息。

图 2:5 个客户端可以同时发送姓名给服务器签到,服务器打印签到信息。对于不存在的姓名,也能提示错误:

可见其表现符合预期。
3.2.2 父进程中是否关闭cfd
的影响
在上述的代码中,服务器通过socket
获取的描述符为sfd
,而通过accept
获取的面向单个连接的描述符为cfd
。上述代码在父进程中关闭了cfd
,其解释已经在 2.2.2.3 中提到一些。
以下左侧的 6666 端口是父进程关闭cfd
的情况,右侧的 8888 端口是父进程不关闭cfd
的情况,在通信时没有差异,但是当客户端退出时,两侧的netstat
结果不同:

左侧的正常进入了TIME_WAIT
,不久后连接被完全释放。而右侧的服务器处于CLOSE_WAIT
状态,客户端处于FIN_WAIT2
状态,这说明只完成了 FIN 和 ACK 的两次握手。也就是说服务器的应用进程并没有关闭该连接,没有主动发起 FIN ACK。
这可以通过 UNIX 的文件描述符复制来解释。当进程被fork
后,子进程中的文件描述符就像是被dup
函数过了一样,即父进程和子进程的这两个文件描述符都指向一个 file table,其中包含了文件状态,当前偏移量等文件信息。而只有当指向某个 file table 的文件描述符为 0 时,该文件会真正关闭,否则只是对某个进程“关闭”,从进程中的 process table 中移除。因此,如果只在子进程中调用close
,则其虽然在子进程中的 process table 被移除了,但是父进程中并没有,因此该连接的 file table 仍然存在,也就意味着连接并未关闭。因此,需要在父进程中关闭cfd
,否则浪费系统资源,也让客户端不能完全释放连接。
3.3 基于 UDP socket 的聊天室
3.3.1 运行效果
也是在本地的 6666 端口开一个服务器(右下),之后五个客户端进行聊天。默认是接收信息,如果要发送,则需要按 Ctrl+C:

可见,左下角的在注册姓名时,尝试使用重复的姓名,导致注册失败。并且服务器会发送用户加入和退出的提示及其地址。服务器端也会记录日志,保留每一次的客户端发来的简单聊天协议应用层报文。
可以看到,在注册输入姓名时,服务器收到的报文的id
为 0,flags
为 4,代表 REG。而发送消息时,服务器收到各自用户的id
,且flags
为 8,代表 SND。用户退出时,flag
为 16,代表 FIN。
3.3.2 异常捕捉
紧接着上述聊天。如果服务器突然挂了(按 Ctrl+C 结束),则客户端再向其发消息的时候,就会报错。这里因为端口已经关闭,而不是服务器无响应,因此会立即有 Connection refused 错误:

如果我们乱输一个公网的地址,则可能该报文永远不会得到回应,这时候之前引入的超时机制就起作用了。会在超时 8 秒之后报错退出,错误内容是 Resources unavailable:

3.3.3 真实环境:UDP 的不可靠传输
接下来,在自己的服务器 f5soft.site 的 6666 端口(公网地址为 39.97.114.106:6666)部署该服务器,同时在自己的电脑上启动 4 个客户端,连接公网服务器。可以发现,绝大部分功能均正常运行。但是由于 UDP 的不可靠传输,且处于公网环境,其下层也是不可靠的(不像本地网络的下层是可靠的),因此在公网的环境中出现了 UDP 数据报的丢失!

可见 UDP 确实不可靠。如果要彻底解决这一问题,需要在我们的应用层(简单聊天协议)实现可靠传输,因此除了之前的超时机制外,还要引入重传机制等。
4. 实验小结
本次实验,体会并掌握了
- 通过合理的代码复用降低程序的代码量
- UNIX 网络套接字编程基础
- UNIX 多进程并行程序设计
- Socket 网络地址的获取与转换
- TCP、UDP 服务器的建立
- TCP、UDP 客户端与服务器的通信
- TCP 建立连接时的三次握手过程和释放连接时的四次握手过程
- 使用
netstat
命令查看本机的连接和端口状况 - 实现自己的应用层协议——简单聊天协议
- 生产环境和本地环境的区别及 UDP 的不可靠传输验证
5. 相关文件记录
- 代码文件位于
src
文件夹中,其中 server_reg.c
和client_reg.c
:简单的网络注册服务器和客户端server_check.c
和client_check.c
:简易的网络签到服务器和客户端server_chat.c
和client_chat.c
:UDP 聊天室服务器和客户端csutil.c
和csutil.h
:Client & Server Utility Function,客户端和服务器间通信所需的函数,封装了很多对 socket 的操作stuutil.c
和stuutil.h
:Student Utility Function,建立、查询、写入学生数据库所需函数的封装chatutil.c
和chatutil.h
:Chatting Utility Function,基于 UDP 的自定义聊天应用层协议的实现Makefile
:Makefile 自动化编译文件
若要编译运行,在 Linux 环境下,在src
目录下输入make
,即可生成编译后的文件
点此下载实验代码