网络编程:开发TCP服务器

上一小节,我们学习了如何编写 TCP 客户端程序,掌握了 socketconnectsendrecvclose 等系统调用的用法。本节我们再接再厉,学习 bindaccept 系统调用,开发一个 TCP 服务器。

监听套接字

编写 TCP 服务器同样离不开套接字——服务器需要先创建一个套接字,并监听服务端口:

监听套接字保存着服务器要监听的地址和端口信息,等待处理客户端的连接请求。当客户端执行三次握手建立连接,SYNC 分组根据目的地址和端口即可找到对应的监听套接字。

如果服务器没有监听该地址或端口,内核协议栈则回复 RST 分组告诉客户端连接建立失败。正常情况下,协议栈会跟客户端完成三次握手交互,并由此建立新连接。

此外,内核将创建一个新的套接字,来维护新连接的全部上下文。后续,服务器程序可以通过 accept 系统调用来接受新连接,并取得该套接字。通过这个连接套接字,服务器便可跟客户端进行通信。

那么,如何创建监听套接字呢?关键步骤如下:

  1. 创建一个 TCP 套接字;
  2. 分配地址结构体,填充监听地址和端口信息;
  3. 执行 bind 系统调用,为套接字绑定监听地址和端口;
  4. 执行 listen 系统调用,开始监听客户端发过来的连接请求;

绑定监听地址

创建 TCP 套接字上节介绍过,执行 socket 系统调用即可。此后,我们需要将它改造成监听套接字,当务之急要为它绑定监听地址。那么,监听地址应该如何理解呢?

监听地址顾名思义就是服务器程序要监视的地址和端口,当有客户端请求连接时,与它完成三次握手建立连接。以 tcp-upper-server 为例,实验中它监听了 9999 端口。客户端连接 9999 端口,即可与之通信。

监听地址同样通过 sockaddr_in 结构体告诉内核,因此需要先填充好:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 准备sockaddr_in结构体用于保存监听地址(绑定地址)
struct sockaddr_in bind_addr;
bzero(&bind_addr, sizeof(bind_addr));

// 设置地址族
bind_addr.sin_family = AF_INET;
// 设置绑定的IP地址
bind_addr.sin_addr.s_addr = INADDR_ANY;
// 设置绑定的端口
bind_addr.sin_port = htons(9999);

注意到,监听 IP 被设置成 INADDR_ANY ,表示接收从任何 IP 进来的连接请求。当然了,这里也可以将其设置为具体的服务器 IP 地址。

接下来,即可执行 bind 系统调用,将套接字和监听地址进行绑定:

1
2
3
4
if (bind(s, (struct sockaddr *)&bind_addr, sizeof(bind_addr)) == -1) {
    perror("Failed to bind address");
    // ...
}

bind 系统调用在 UDP服务器开发 一节中介绍过,它接收 3 个参数,本文不再赘述:

  • sockfd ,套接字的文件描述符;
  • addrsockaddr_in 地址结构体的地址;
  • addrlensockaddr_in 地址结构体的长度;

通配地址

INADDR_ANY 是一个宏定义,它的值是 0.0.0.0 。这是一个非常特殊的 IP 地址,表示匹配任何 IP 地址,因此被称为 通配地址 。那么,通配地址的作用是什么呢?

假设服务器有两块网卡,分别接入 ab 两个网络。其中,接入网络 a 的网卡地址为 $IP_a$ ;接入网络 b 的网卡地址为 $IP_b$ 。如果 TCP 服务器只监听其中一个 IP 地址,那么客户端就无法通过另一个 IP 地址连过来。

举个例子,假设服务器以 $IP_a$ 为监听地址运行了一个 TCP 服务器。那么,网络 b 上的客户端无法通过 $IP_b$ 来访问它。但网络 a 上的客户端是可以正常访问的,因为它们与服务器通信使用的地址 $IP_a$ 。

如果服务器想同时通过 $IP_a$ 和 $IP_b$ 对外提供服务,以便 ab 两个网络都能访问,则可以将监听地址设为通配地址。如此一来,不管客户端连的是哪个 IP ,服务器都会接受。

开始监听

地址绑定完毕,即可执行 listen 系统调用,让套接字开始监听客户端连接请求:

1
2
3
4
if (listen(s, 100) == -1) {
    perror("Failed to listen");
    // ...
}

listen 系统调用接收 3 个参数,分别是:

  • sockfd ,套接字的文件描述符;
  • backlog ,指定待处理连接队列的最大长度;

客户端连接上来后,如果服务器进程繁忙未能及时处理,新连接则暂时进入待处理队列。backlog 参数用于限制该队列的最大长度,它会影响服务器的并发处理能力,本文先按下不表。

接受客户端连接

内核协议栈与客户端执行完三次握手,意味着新的 TCP 连接成功建立。连接上下文由一个新的连接套接字维护,套接字先放进待处理连接队列,等待服务器程序接受并处理。

服务器进程执行 accept 系统调用,即可接受一个新的客户端连接,需要传递 3 个参数:

  • sockfd ,监听套接字的文件描述符;
  • addr ,用来保存新连接对端地址的结构体,由此服务器可获得客户端的地址和端口信息;
  • addrlenaddr 结构体的长度;

因此,我们需要先准备 sockaddr_in 结构体,用来接收客户端的地址和端口信息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 用于保存对端(客户端)地址和端口的结构体
struct sockaddr_in peer_addr;
int addr_len = sizeof(peer_addr);

// 接受一个客户端连接,得到一个连接套接字
int conn = accept(s, (struct sockaddr *)&peer_addr, &addr_len);
if (conn == -1) {
    perror("Failed to accept");
    // ...
 }

// 处理该新连接,conn

如果有客户端尝试建立连接,内核会配合它完成三次握手,由 accept 返回连接套接字:

  1. 服务器程序执行 accept 系统调用,接受一个新的客户端连接;
    • accept 系统调用实际工作是从连接队列中取出一个并返回;
    • 如果连接队列为空,accept 将阻塞直到有客户端成功建立连接;
  2. 客户端执行三次握手建立到服务器的 TCP 连接,先发来 SYNC 分组;
  3. SYNC 分组承载在 IP 包和以太网帧中,被服务器网卡收到;
  4. 内核解析收到的以太网帧,知道这是一个承载 TCP 分组的 IP 包;
  5. 内核根据目的地址和端口,找到对应 TCP 监听套接字;
    • 如果服务器没有监听该地址或端口,内核协议栈将回复 RST 分组,告诉客户断开连接;
  6. 内核创建一个新的套接字,来保存新连接上下文,状态为 SYNC_RECV
    • SYNC_RECV 表示只收到 SYNC 包,三次握手还没完成;
    • 为防御 SYNC 包攻击,内核实际上需要等到三次握手完成才创建套接字;
  7. 内核配合执行三次握手,封装 SYNC/ACK 分组,承载在以太网帧中通过网卡发送出去;
  8. SYNC/ACK 分组在网络中传输,最终达到 TCP 客户端主机;
  9. TCP 客户端主机回复 ACK 分组,至此连接成功建立(在客户端看来);
  10. ACK 分组承载在以太网帧中,被服务器网卡收到;
  11. 内核协议栈解析 ACK 分组,根据地址信息找到连接套接字,并将它的状态改为 ESTABLISHED
    • 至此,在服务器看来,TCP 连接也建立完毕;
  12. 内核协议栈已经就绪的连接套接字,放入监听套接字连接队列;
  13. accept 系统调用从连接队列取出套接字描述符并返回,同时将客户端地址端口信息拷贝到用户空间;

accept 系统调用执行成功,返回代表新连接的套接字描述符。服务器程序后续操作该套接字,即可与客户端进行通信:执行 recv 接收客户端发来的数据;执行 send 向客户端发送数据。

accept 系统调用执行失败返回 -1 ,出错原因保存在 errno 变量中,可以通过 perror 打印出来。

服务器程序通常永久运行,需要不断接受并处理客户端连接,一般用永久循环来实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 永久循环(死循环)
for (;;) {
    // 用于保存对端(客户端)地址和端口的结构体
    struct sockaddr_in peer_addr;
    int addr_len = sizeof(peer_addr);

    // 接受一个客户端连接,得到一个连接套接字
    int conn = accept(s, (struct sockaddr *)&peer_addr, &addr_len);
    if (conn == -1) {
        // EINTR表示被信号打断,忽略
        if (errno == EINTR) {
            continue;
        }

        // 其他错误直接退出
        perror("Failed to accept");
        break;
    }

    // 处理该新连接,conn
}

处理连接

服务器接受新连接后,就可以开始处理该连接,为客户端提供服务。客户端和服务器通过新建立的 TCP 连接交换数据,数据格式属于应用层的范畴。以 Web 为例,客户端(浏览器)向服务器发送 HTTP 请求,而服务器处理后回复 HTTP 响应。

HTTP 协议是一个挺复杂的应用层协议,本文以更为简单 tcp-upper 服务为例,讲解服务器处理客户端连接的方法。请看 process_connection 处理函数:

 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
void process_connection(int s) {
    // 永久处理循环,不断接收客户端发来的数据并处理
    for (;;) {
        // 分配缓冲区用于接收客户端发来的数据
        char input[BUFFER_SIZE];

        // 通过连接套接字接收客户端发来的数据
        int bytes = recv(s, input, sizeof(input), 0);
        if (bytes == -1) {
            if (errno == EINTR) {
                continue;
            }

            perror("Failed to recv");
            break;
        }

        // 数据长度为0,说明客户端关闭了连接,退出处理循环
        if (bytes == 0) {
            break;
        }

        // 将数据改成大写
        uppercase(input, bytes);

        // 调用send_data想客户端发送数据
        send_data(s, input, bytes);
    }
}

process_connection 以客户端连接套接字为参数,用一个永久循环不断接收客户端发来的数据,对数据进行大写化处理,并发回给客户端。

如果发送缓冲区已满,send 系统调用可能只写入部分数据就返回了。因此,我们需要对发送逻辑进行封装,循环执行 send 系统调用,知道数据全部写入:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
void send_data(int s, void *data, int bytes) {
    // bytes大于0说明数据还没发完,继续循环发送
    while (bytes > 0) {
        // 执行send系统调用进行发送
        int sent = send(s, data, bytes, 0);
        if (sent == -1) { // 检查错误
            // 如果是被信号终端,继续重试
            if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) {
                continue;
            }

            // 其他错误退出
            perror("Failed to send");
            break;
        }

        // 更新数据指针,跳过已发部分
        data += sent;
        // 更新剩余字节数,减去已发字节数
        bytes -= sent;
    }
}

send_data 函数需要传 3 个参数:

  • s ,连接套接字描述符;
  • data ,大发送数据的首地址;
  • bytes ,待发送数据的字节数;

bytes 大于零说明还有数据未成功,继续执行 send 系统调用。变量 sent 保存 send 成功写入的字节数,每次成功写入数据后,更新 data 地址跳过已写入部分,更新 bytes 减去已写入字节数。当 bytes 减为 0 ,说明全部数据均写入成功,跳出发送循环。

完整程序

我们在实验中使用 tcp-upper-server 客户端程序提供服务,它的完整源码如下:

  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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
#include <arpa/inet.h>
#include <ctype.h>
#include <errno.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>

#include "argparse.h"

#define BUFFER_SIZE 102400

// 将数据转成大写
void uppercase(void *input, int bytes) {
    char *buffer = (char *)input;

    while (bytes > 0) {
        bytes--;
        buffer[bytes] = toupper(buffer[bytes]);
    }
}

// 发送数据
void send_data(int s, void *data, int bytes) {
    // bytes大于0说明数据还没发完,继续循环发送
    while (bytes > 0) {
        // 执行send系统调用进行发送
        int sent = send(s, data, bytes, 0);
        if (sent == -1) { // 检查错误
            // 如果是被信号终端,继续重试
            if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) {
                continue;
            }

            // 其他错误退出
            perror("Failed to send");
            break;
        }

        // 更新数据指针,跳过已发部分
        data += sent;
        // 更新剩余字节数,减去已发字节数
        bytes -= sent;
    }
}

// 处理客户端连接
void process_connection(int s) {
    // 永久处理循环,不断接收客户端发来的数据并处理
    for (;;) {
        // 分配缓冲区用于接收客户端发来的数据
        char input[BUFFER_SIZE];

        // 通过连接套接字接收客户端发来的数据
        int bytes = recv(s, input, sizeof(input), 0);
        if (bytes == -1) {
            if (errno == EINTR) {
                continue;
            }

            perror("Failed to recv");
            break;
        }

        // 数据长度为0,说明客户端关闭了连接,退出处理循环
        if (bytes == 0) {
            break;
        }

        // 将数据改成大写
        uppercase(input, bytes);

        // 调用send_data想客户端发送数据
        send_data(s, input, bytes);
    }
}

int main(int argc, char *argv[]) {
    // 解析命令行参数
    const struct server_cmdline_arguments *arguments = parse_server_arguments(argc, argv);
    if (arguments == NULL) {
        fprintf(stderr, "Failed to parse cmdline arguments\n");
        return -1;
    }

    // 创建套接字
    int s = socket(PF_INET, SOCK_STREAM, 0);
    if (s == -1) {
        perror("Failed to create socket");
        return -1;
    }

    // 分配地址结构体,并填充监听地址和端口
    struct sockaddr_in bind_addr;
    bzero(&bind_addr, sizeof(bind_addr));

    bind_addr.sin_family = AF_INET;
    bind_addr.sin_addr.s_addr = INADDR_ANY;
    bind_addr.sin_port = htons(arguments->port);

    // 解析IP地址
    if (arguments->bind_ip != NULL) {
        if (inet_aton(arguments->bind_ip, &bind_addr.sin_addr) == 0) {
            fprintf(stderr, "Invalid IP: %s\n", arguments->bind_ip);
            return -1;
        }
    }

    // 将监听地址和端口与套接字绑定
    if (bind(s, (struct sockaddr *)&bind_addr, sizeof(bind_addr)) == -1) {
        perror("Failed to bind address");
        close(s);
        return -1;
    }

    // 开始监听新连接
    if (listen(s, 100) == -1) {
        perror("Failed to listen");
        close(s);
        return -1;
    }

    printf("listening at port: %s:%d, waiting for connections...\n", arguments->bind_ip, arguments->port);

    // 永久循环(死循环)
    for (;;) {
        // 用于保存对端(客户端)地址和端口的结构体
        struct sockaddr_in peer_addr;
        int addr_len = sizeof(peer_addr);

        // 接受一个客户端连接,得到一个连接套接字
        int conn = accept(s, (struct sockaddr *)&peer_addr, &addr_len);
        if (conn == -1) {
            // EINTR表示被信号打断,忽略
            if (errno == EINTR) {
                continue;
            }

            // 其他错误直接退出
            perror("Failed to accept");
            break;
        }

        printf("\n%s:%d connected\n", inet_ntoa(peer_addr.sin_addr), ntohs(peer_addr.sin_port));

        // 处理新连接,为客户端提供服务
        process_connection(conn);

        // 当客户端退出后,关闭套接字
        close(conn);

        printf("%s:%d disconnected\n", inet_ntoa(peer_addr.sin_addr), ntohs(peer_addr.sin_port));
    }

    // 关闭监听套接字
    close(s);

    return 0;
}

大家可以利用本节学到的知识,自己开发 tcp-upper-server 程序练练手。包含命令行参数处理函数在内的完整代码可从 Github 上获取,地址为:tcp-upper 。源代码结构大致如下:

  • argparse.c ,命令行参数处理函数源文件;
  • argparse.h 。命令行参数处理函数头文件;
  • server.cTCP 服务器程序源文件;
  • Makefile ,用于编译构建;

程序编译

Linux 系统上,进入 tcp-upper 源码目录,执行 make 命令即可完成编译:

1
make server

程序编译成功后,可以在当前目录下看到生成的可执行程序 server ,可以直接运行:

1
./server -h

如果不想使用 make 命令,也可以直接执行 gcc 命令进行编译:

1
gcc -o server server.c argparse.c

这个命令的意思是编译 server.cargparse.c ,生成的可执行程序命名为 server

小菜学网络】系列文章首发于公众号【小菜学编程】,敬请关注:

【小菜学网络】系列文章首发于公众号【小菜学编程】,敬请关注: