Socket编程

ZaynPei Lv6

预备知识

网络字节序

在计算机内存中,当一个数据类型(比如一个 32 位的整数 int)占用多个字节时,就存在一个如何排列这些字节的顺序问题,这就是字节序 (Byte Order) 或 Endianness (端序)。

主要有两种字节序:

  • 小端字节序 (Little-Endian):将数据的低位字节存放在内存的低地址处。这是目前绝大多数本地个人电脑(如 Intel, AMD 的 x86/x64 架构)使用的方式。
  • 大端字节序 (Big-Endian):将数据的高位字节存放在内存的低地址处。这更符合人类的阅读习惯(从高位读到低位)。一些服务器、网络设备和早期的 PowerPC, MIPS 架构使用这种方式。

举个例子:存储 16 位的整数 0x1234 (十进制的 4660)

这里 0x12 是高位字节, 0x34 是低位字节。

小端存储:内存低地址 -> 0x34; 内存高地址 -> 0x12 大端存储:内存低地址 -> 0x12; 内存高地址 -> 0x34

如果只是本地数据的读取, 小端序或者大端序都可以自洽; 但是假如涉及到不同端序设备的网络通信, 双方的端序不同步会产生难以想象的后果. 为了避免这种混乱,TCP/IP 协议规定:所有在网络上传输的数据都必须统一使用大端字节序。这个统一的标准就被称为 网络字节序 (Network Byte Order)。

操作系统提供了一套标准的函数来在这两种字节序之间进行转换: | 函数 | 功能 | 说明 | |——|——|——| | htons() | Host to Network Short | 将一个 16 位(short)的数从主机字节序转换到网络字节序。主要用于端口号。| | htonl() | Host to Network Long | 将一个 32 位(long)的数从主机字节序转换到网络字节序。主要用于 IPv4 地址。| | ntohs() | Network to Host Short | 将一个 16 位的数从网络字节序转换到主机字节序。| | ntohl() | Network to Host Long | 将一个 32 位的数从网络字节序转换到主机字节序。|

核心原则:在发送数据前,所有非字符型数据(如端口号、IP地址整数值)都应该使用 hton() 系列函数转换为网络字节序(操作系统在调用 hton() 和 ntoh() 系列函数时,会自动根据主机的字节序进行正确的转换);在接收到数据后,应该使用 ntoh() 系列函数转换回主机字节序再使用。

IP 地址转换函数

我们已经知道,IP 地址(这里还是指传统的IPv4)有两种表示格式:

  • 点分十进制字符串格式 (Presentation Format):方便人类阅读,例如 “192.168.10.1”。
  • 整数格式 (Network Format):方便计算机处理,是一个 32 位的无符号整数,并且是网络字节序。

我们需要一组函数来在这两种格式之间进行转换, 这些函数定义在 <arpa/inet.h> 中:

  • int inet_pton(int af, const char src, void dst);
    • 功能:将字符串 (p) 格式的 IP 地址转换为网络 (n) 整数格式。pton 即 “IP to net”。
    • af: 地址族,AF_INET 或 AF_INET6。
    • src: 指向 IP 地址字符串的指针。
    • dst: 指向转换后存放结果的内存地址(例如 struct in_addr 的地址)。
    • 返回值为1代表成功; 0代表传入的src没有指向一个有效的IP地址; -1代表失败
  • const char inet_ntop(int af, const void src, char *dst, socklen_t size);
    • 功能:将网络 (n) 整数格式的 IP 地址转换为字符串 (p) 格式。ntop 即 “net to IP”。

sockaddr 数据结构

socket 编程接口(如 bind, connect)需要被设计成通用的,以便能够处理多种不同的网络协议(IPv4, IPv6, UNIX Domain Socket 等)。每种协议的地址结构都不同,例如:

  • IPv4 地址需要:协议族、16 位端口号、32 位 IP 地址。
  • IPv6 地址需要:协议族、16 位端口号、128 位 IP 地址,以及流信息和范围 ID。

如果为每种协议都设计一套独立的函数,如 bind_ipv4(), bind_ipv6(),那将非常繁琐。

为了解决这个问题,Socket API 设计了一个“基类”结构体 struct sockaddr

1
2
3
4
struct sockaddr {
sa_family_t sa_family; // 地址族 (AF_INET, AF_INET6, ...)
char sa_data[14]; // 存放地址数据的区域
};
这是一个通用的、但内容模糊的结构体。我们几乎从不直接填充它。它的 sa_data 区域设计得足够大,可以容纳当时最常见的地址类型。

更常见的是, 我们使用专门用于 IPv4 的更清晰的 struct sockaddr_in:

1
2
3
4
5
6
7
8
9
#include <arpa/inet.h> // 需要引入socket头文件
struct sockaddr_in {
sa_family_t sin_family; // 地址族, 必须是 AF_INET
in_port_t sin_port; // 16位端口号 (必须是网络字节序, 因此要使用htons转换一下)
struct in_addr sin_addr; // 32位IP地址结构体 (内含一个整数, 必须是网络字节序)
char sin_zero[8]; // 填充位, 必须全部置为0(默认不需要处理), 为了让此结构与sockaddr等长
};

// 这里 struct in_addr 内部只有一个成员:uint32_t s_addr;

在实际使用过程中, 我们需要明确初始化sockaddr_in结构体, 根据客户端和服务端分别初始化如下:

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
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring> // for memset

// 假设我们已经创建好了 socket 文件描述符 fd
// int fd = socket(AF_INET, SOCK_STREAM, 0);

// 1. 声明一个 sockaddr_in 结构体变量
struct sockaddr_in server_addr;

// 2.将结构体清零
memset(&server_addr, 0, sizeof(server_addr));

// 3. 设置地址族为 IPv4
server_addr.sin_family = AF_INET;

// 4. 设置服务器的端口号
// htons(9527) -> 将端口号从主机字节序转换到网络字节序
server_addr.sin_port = htons(9527);

// 5. 设置服务器的 IP 地址
// 使用 inet_pton 将点分十进制的 IP 字符串转换为网络字节序的整数
// 并直接存入 server_addr.sin_addr 结构体中
if (inet_pton(AF_INET, "192.157.22.45", &server_addr.sin_addr) <= 0) {
// 转换失败处理
perror("inet_pton failed");
// exit or return
}

// 6. 现在,server_addr 已经准备就绪,可以用于 connect 函数
// connect(fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
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
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring> // for memset

// 假设我们已经创建好了 socket 文件描述符 fd
// int fd = socket(AF_INET, SOCK_STREAM, 0);

// 1. 声明一个 sockaddr_in 结构体变量
struct sockaddr_in local_addr;

// 2.【关键】将结构体清零
memset(&local_addr, 0, sizeof(local_addr));

// 3. 设置地址族为 IPv4
local_addr.sin_family = AF_INET;

// 4. 设置服务器要监听的端口号
local_addr.sin_port = htons(9527);

// 5. 设置服务器的 IP 地址
// 使用 INADDR_ANY 这个宏,它代表 "0.0.0.0", 这意味着监听本机所有网络接口(如有线网卡、无线网卡)上的连接请求
// htonl() 将这个 32 位的地址从主机字节序转换到网络字节序
local_addr.sin_addr.s_addr = htonl(INADDR_ANY);

// 6. 现在,local_addr 已经准备就绪,可以用于 bind 函数
// bind(fd, (struct sockaddr *)&local_addr, sizeof(local_addr));

网络套接字函数

socket模型创建流程

如图, 一个完整的通信流程会涉及到(至少)三个socket,其中客户端一个, 服务端两个. 客户端的比较直接, 我们主要关注服务端

具体流程是, 当服务器端调用socket()函数时会产生一个监听套接字 (Listening Socket),而accept函数会阻塞监听套接字监听客户端连接,当有客户端过来连接的时候 该函数会返回一个新的套接字, 称作已连接套接字 (Connected Socket)去和客户端连接

监听套接字 (Listening Socket) - 创建者:socket() 函数。 - 配置者:bind() 和 listen() 函数。 - 核心职责:作为一个“连接工厂”,它的唯一使命是在指定的 IP:Port 上监听并接收客户端发来的连接请求(TCP协议中的 SYN 包)。 - 数据交互:它从不参与应用层数据的收发。你永远不会对一个监听套接字使用 send() 或 recv() 函数。 - 生命周期:通常在服务器程序启动时创建,并一直存在,直到服务器程序关闭。对于一个服务器而言,一个端口上通常只有一个监听套接字。

已连接套接字 (Connected Socket) - 创建者:accept() 函数。 - 核心职责:作为与某一个特定客户端进行通信的专属通道。所有与该客户端的应用层数据(HTTP 请求、数据库查询、聊天消息等)都通过这个套接字进行 send() 和 recv()。 - 数据交互:它是数据传输的实际执行者。 - 生命周期:当一个客户端连接成功时被创建,当与这个客户端的通信结束时(客户端断开或服务器主动关闭),这个套接字就会被 close() 销毁。一个繁忙的服务器会频繁地创建和销毁成百上千个这样的套接字。

正是这种“一个监听,多个连接”的模式,构成了所有网络服务器能够高效服务于众多客户端的基础架构。

socket() 函数

socket套接字本意是插座, 意味着在数据通信过程中socket必须是成对出现的(客户端和服务端)

socket() 函数的作用是在操作系统内核中创建一个通信的端点,并返回一个指向该端点的文件描述符 (File Descriptor)。

你可以将这个过程理解为向操作系统申请一部“电话机”。这部“电话机”本身还不知道要打给谁(没有目标地址),也不知道自己的号码(没有绑定端口),但它是后续所有通信操作的基础。在类 Unix 系统(如 Linux)中,这个返回的文件描述符与其他文件(如磁盘文件、管道)的描述符一样,可以被 read(), write(), close() 等函数操作, 本质上也是一个文件。

函数原型 (Function Prototype)及参数和返回值

socket() 函数通常在 C 语言的头文件 <sys/socket.h> 中定义。其标准原型如下:

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

int socket(int domain, int type, int protocol);
这个函数接收三个整数类型的参数,并返回一个整数。

  1. domain:协议族 这个参数用于指定 Socket 使用的协议族 (Protocol Family)。它决定了通信的地址格式和底层协议的范畴。

换句话说, 这个参数告诉操作系统你打算进行哪一类的通信,例如是基于 IPv4 的互联网通信,还是基于 IPv6,或者是仅限于本机内部的通信。

常用值(主要是AF_INET):

  • AF_INET (Address Family Internet): 这是最常用的值,表示使用 IPv4 协议族。网络地址将由 32 位的 IPv4 地址和 16 位的端口号组成。

  • AF_INET6: 表示使用 IPv6 协议族。网络地址将由 128 位的 IPv6 地址和 16 位的端口号组成。

  • AF_UNIX (或 AF_LOCAL): 用于本机内部进程间通信 (IPC)。它不使用网络协议,而是通过文件系统中的一个特殊文件(socket 文件)来进行数据交换,效率非常高。

  1. type:套接字类型 这个参数用于指定 Socket 的服务类型 (Service Type),它决定了通信的语义和行为。使用这个参数需要明确你需要的通信方式是可靠的、面向连接的(像打电话),还是快速的、无连接的(像寄明信片)。

常用值: - SOCK_STREAM (Stream Socket): 流式套接字, 提供面向连接、可靠的、基于字节流的服务。它通常与 TCP 协议配合使用。 - 数据传输前必须先建立连接。它保证数据传输是有序的、无差错的、无重复的。数据像水流一样,没有边界,你发送 100 字节,对方可能一次性收到 100 字节,也可能先收到 40 字节再收到 60 字节。 - 适用场景:绝大多数应用,如网页浏览 (HTTP)、文件传输 (FTP)、邮件发送等。

  • SOCK_DGRAM (Datagram Socket): 数据报套接字, 提供无连接、不可靠的、基于数据报的服务。它通常与 UDP 协议配合使用。
    • 通信前不需要建立连接。每个数据包(数据报)都是独立的,有自己的目标地址。它不保证数据能到达,也不保证到达的顺序
    • 适用场景:对实时性要求高、能容忍少量丢包的场景,如在线游戏、视频直播、DNS 查询。
  1. protocol:具体协议 这个参数用于指定在前两个参数确定的协议族和套接字类型下,还想进一步使用的具体协议。因为在某些协议族中,可能有多种协议支持同一种套接字类型。这个参数允许你精确指定。

不过大部分情况下使用 0 即可:这是最常用的值。表示让操作系统根据 domain 和 type 的组合自动选择默认的协议, 等同于下列手动指定: socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); // 创建tcp的sock socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); // 创建udp的sock

  1. 返回值 (Return Value)

成功时:返回一个非负整数,这个整数就是套接字描述符 (Socket Descriptor),也常被称为文件描述符。它是这个新创建的 Socket 的唯一标识。后续所有的 Socket 相关函数(如 bind(), connect(), listen() 等)都将使用这个描述符作为参数。

失败时:返回 -1。同时,全局变量 errno 会被设置为一个特定的错误码,以指示失败的原因。我们可以通过 perror() 函数或 strerror(errno) 来查看具体的错误信息。常见的错误原因包括:权限不足、协议不支持、系统资源耗尽等。

socket()函数简单示例

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
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <errno.h> // For errno variable
#include <string.h> // For strerror function

int main() {
int tcp_socket_fd;
int udp_socket_fd;

// --- 1. 创建一个用于 IPv4 的 TCP 套接字 ---
// 步骤说明:调用 socket 函数,指定协议族为 AF_INET (IPv4),
// 类型为 SOCK_STREAM (TCP),协议让系统自动选择 (0)。
tcp_socket_fd = socket(AF_INET, SOCK_STREAM, 0);

// 步骤说明:检查返回值。如果为 -1,则表示创建失败。
// 使用 perror 可以打印出更详细的错误原因。
if (tcp_socket_fd == -1) {
perror("Create TCP socket failed");
exit(EXIT_FAILURE);
}
printf("TCP socket created successfully! File Descriptor: %d\n", tcp_socket_fd);


// --- 2. 创建一个用于 IPv4 的 UDP 套接字 ---
// 步骤说明:与上面类似,只是将套接字类型改为 SOCK_DGRAM (UDP)。
udp_socket_fd = socket(AF_INET, SOCK_DGRAM, 0);

if (udp_socket_fd == -1) {
perror("Create UDP socket failed");
exit(EXIT_FAILURE);
}
printf("UDP socket created successfully! File Descriptor: %d\n", udp_socket_fd);

// ... 之后可以对这两个 socket_fd 进行 bind, connect, send, recv 等操作 ...

// 最后需要关闭套接字
// close(tcp_socket_fd);
// close(udp_socket_fd);

return 0;
}

bind()函数

bind 函数的作用是给套接字“绑定”一个地址。在我们之前的比喻中,socket() 函数只是创建了一个“电话机”,而 bind 函数就是向电信局申请一个具体的电话号码(IP 地址 + 端口号)并分配给这部电话机。

对于服务器来说,这是一个必须的步骤,因为客户端必须知道服务器的“地址”才能发起连接。

函数原型及参数解释如下:

1
2
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- int sockfd: 由 socket() 函数返回的套接字文件描述符。 - const struct sockaddr addr: 由const可知是传入参数, 是一个指向 sockaddr 结构体的指针。这正是我们之前讨论过的“预备知识”的应用。我们通常会创建一个 struct sockaddr_in (for IPv4) 变量,填充好 sin_family, sin_port (端口) 和 sin_addr (IP 地址),然后将其指针强制转换为 (struct sockaddr ) 再传递给 bind。 - socklen_t addrlen: addr 指向的地址结构体的确切大小,通常使用 sizeof(struct sockaddr_in)。 - 返回值: 成功返回 0; 失败返回 -1,并设置全局变量 errno。常见的失败原因包括:端口已被占用 (EADDRINUSE)、没有权限绑定该地址 (EACCES)等。

需要注意的是, bind 是服务器端专用的函数(客户端通常不需要),它在 socket() 创建套接字之后、listen() 开始监听之前被调用。

listen() 函数

listen 函数的作用是让套接字进入“被动监听”模式以及设置最多同时连接数。一个普通的套接字(由 socket() 创建)既可以主动发起连接(作为客户端),也可以被动接收连接(作为服务器)。一旦调用 listen,这个套接字就从一个“主动”套接字转变为一个“被动”的、专门用于接收连接请求的监听套接字。

在我们之前的比喻中,这相当于把公司的总机电话设置为“等待来电”状态,并告诉交换机系统,可以开始向这个号码派发来电了。

函数原型及参数解释:

1
2
#include <sys/socket.h>
int listen(int sockfd, int backlog);
- int sockfd:: 已经被 bind() 绑定了地址的套接字文件描述符。 - int backlog:一个非常重要的参数,它规定了内核为这个监听套接字维护的“待处理连接队列”的最大长度。当服务器非常繁忙,来不及 accept() 新的连接时,新来的连接请求会先被放入这个队列中排队。如果队列已满,新的客户端连接请求可能会被拒绝。这个值的大小需要根据服务器的负载能力来设置,一个常见的值是 SOMAXCONN (一个由系统定义的较大值)。 - 返回值: 成功返回 0; 失败返回 -1,并设置 errno。

同样, listen 也是服务器端专用的函数,在 bind() 之后、accept() 之前被调用。

accept() 函数

accept 函数是服务器从“待处理连接队列”中取出一个连接请求,并创建一个全新的套接字来与该客户端通信。accept 接收的 sockfd 是监听套接字,而它返回的是一个全新的已连接套接字。

这是一个阻塞函数:如果队列中没有已完成的连接,程序会在这里暂停,直到有客户端连接进来为止。

函数原型及参数解释:

1
2
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- int sockfd: 正在监听的那个监听套接字的文件描述符。 - struct sockaddr addr (可选): 没有const, 因此很大可能(实际上也就是)传出参数, 是一个指向 sockaddr 结构体的指针,用于接收客户端的地址信息。当函数成功返回时,内核会把发起连接的客户端的 IP 和端口填充到这个结构体中。如果你不关心客户端的地址,可以把它设为 NULL。 - socklen_t addrlen (可选): 一个指向 socklen_t 变量的指针。在调用前,你需要把它指向的变量设置为 addr 指向的缓冲区的最大长度 (sizeof(struct sockaddr_in))。函数返回后,这个变量的值会变为客户端地址结构体的实际长度。如果 addr 是 NULL,这个参数也应为 NULL。 - 返回值; 成功返回一个新的非负整数,这个整数就是新创建的已连接套接字的文件描述符。后续与该客户端的所有通信(send/recv)都将使用这个新的描述符; 失败返回 -1,并设置 errno。

accept 也是服务器端专用的函数,通常在 listen() 之后的一个主循环中被反复调用。

connect() 函数

connect 函数由客户端调用,用于向指定的服务器地址发起一个主动的连接请求

这个函数会触发 TCP 协议的三次握手过程。它也是一个阻塞函数,在三次握手成功建立连接、或者超时/失败之前,程序会一直等待。

函数原型及参数解释:

1
2
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- int sockfd: 客户端自己的、由 socket() 创建的套接字文件描述符。 - const struct sockaddr *addr: 传入参数, 一个指向 sockaddr 结构体的指针,里面包含了服务器的 IP 地址和端口号。客户端必须准确地填充这个结构体,才能找到正确的服务器。 - socklen_t addrlen: addr 指向的服务器地址结构体的确切大小。 - 返回值: 成功返回 0。此时 TCP 连接已成功建立; 失败返回 -1,并设置 errno。常见的失败原因包括:服务器拒绝连接 (ECONNREFUSED)、网络不可达 (ENETUNREACH)、连接超时 (ETIMEDOUT)等。

connect 是客户端专用的函数,在 socket() 创建套-接字之后被调用。

Client-Server 示例

下面是一个简单的 TCP 客户端-服务器示例,展示了如何使用上述的 socket 函数进行基本的网络通信。

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
/*
* 程序名:server.cpp,一个简单的TCP回显服务器。
* 功能:接收客户端的请求报文,将其转换为大写后,再发回给客户端。
*/
#include <iostream>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>

// 处理业务的主函数
void HandleRequest(int client_sockfd);

int main(int argc, char *argv[])
{
if (argc != 2)
{
std::cout << "Using: ./server port\nExample: ./server 5005\n\n";
return -1;
}

// 第1步:创建服务端的socket。
int listen_sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sockfd == -1)
{
perror("socket");
return -1;
}

// 第2步:把服务端用于通信的地址和端口绑定到socket上。
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 任意ip地址
serv_addr.sin_port = htons(atoi(argv[1])); // 指定端口
if (bind(listen_sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) != 0)
{
perror("bind");
close(listen_sockfd);
return -1;
}

// 第3步:把socket设置为监听模式。
if (listen(listen_sockfd, 5) != 0)
{
perror("listen");
close(listen_sockfd);
return -1;
}
std::cout << "Server is listening on port " << argv[1] << "..." << std::endl;

// 第4步:接受客户端的连接。
while (true) // 主循环,使服务器可以一直接收新的连接
{
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
// accept()会阻塞,直到有客户端连接上来
int client_sockfd = accept(listen_sockfd, (struct sockaddr *)&client_addr, &len); // 当有客户端连接时,accept() 函数会把客户端的地址信息(如 IP 和端口)填充到 client_addr 结构体里, 可以用于后续日志、鉴权等操作。
if (client_sockfd < 0)
{
perror("accept");
continue; // 继续等待下一个连接
}

char ipstr[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client_addr.sin_addr, ipstr, sizeof(ipstr));
std::cout << "Client " << ipstr << " connected." << std::endl;

// 调用主函数处理与客户端的通信
HandleRequest(client_sockfd);
// 然而只有HandleRequest函数返回后,才会继续回到这里,等待下一个客户端连接, 这意味着服务器是串行处理每个客户端的请求的
}

// 在实际应用中,服务器通常不会执行到这里,除非有明确的关闭指令
close(listen_sockfd);
return 0;
}

// 主函数,与客户端进行读写交互
void HandleRequest(int client_sockfd)
{
char buffer[1024];
while (true)
{
memset(buffer, 0, sizeof(buffer));
// 接收客户端的请求报文 (read)
ssize_t bytes_received = read(client_sockfd, buffer, sizeof(buffer) - 1);

if (bytes_received > 0)
{
std::cout << "Received from client: " << buffer << std::endl;
// 处理请求:将字符串转换为大写
for (int i = 0; buffer[i]; ++i)
{
buffer[i] = toupper(buffer[i]);
}

// 回应数据给客户端 (write)
if (write(client_sockfd, buffer, strlen(buffer)) <= 0)
{
perror("write");
break;
}
std::cout << "Sent to client: " << buffer << std::endl;
}
else if (bytes_received == 0)
{
// read()返回0表示客户端已关闭连接
std::cout << "Client disconnected." << std::endl;
break;
}
else
{
// read()返回-1表示发生错误
perror("read");
break;
}
}

// 结束连接 (close)
close(client_sockfd);
}
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
/*
* 程序名:client.cpp,一个简单的TCP客户端。
* 功能:向服务端发送请求,并接收服务端的回应。
*/
#include <iostream>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>

int main(int argc, char *argv[])
{
if (argc != 3)
{
std::cout << "Using: ./client ip port\nExample: ./client 127.0.0.1 5005\n\n";
return -1;
}

// 第1步:创建客户端的socket。
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1)
{
perror("socket");
return -1;
}

// 第2步:向服务器发起连接请求。
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(atoi(argv[2])); // 服务器端口
if (inet_pton(AF_INET, argv[1], &serv_addr.sin_addr) <= 0)
{
perror("inet_pton");
close(sockfd);
return -1;
}

// connect()会阻塞,直到连接成功或失败
if (connect(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) != 0)
{
perror("connect");
close(sockfd);
return -1;
}
std::cout << "Connected to server " << argv[1] << ":" << argv[2] << std::endl;

// 第3步:与服务端通讯。
char buffer[1024];
std::string input;

std::cout << "Enter a message (or 'exit' to quit): ";
while (getline(std::cin, input) && input != "exit")
{
// 请求数据 (write)
if (write(sockfd, input.c_str(), input.length()) <= 0)
{
perror("write");
break;
}

// 接收服务端的回应报文 (read)
memset(buffer, 0, sizeof(buffer));
ssize_t bytes_received = read(sockfd, buffer, sizeof(buffer) - 1);
if (bytes_received > 0)
{
std::cout << "Server response: " << buffer << std::endl;
}
else if (bytes_received == 0)
{
std::cout << "Server disconnected." << std::endl;
break;
}
else
{
perror("read");
break;
}
std::cout << "\nEnter a message (or 'exit' to quit): ";
}

// 第4步:关闭socket,结束连接。
close(sockfd);
std::cout << "Connection closed." << std::endl;

return 0;
}

端口复用

在开发和测试服务器程序时,肯定会遇到一个经典问题:第一次启动了服务器,它成功 bind() 到 5005 端口并开始 listen(); 接着通过 Ctrl+C 强制关闭了服务器; 然后立刻尝试重新启动服务器。

此时,bind() 函数调用失败,程序打印出错误信息:bind: Address already in use。我们不得不等待几十秒甚至几分钟后,才能再次成功启动服务器。这个问题在开发调试阶段非常影响效率,在生产环境中也可能导致服务中断时间变长。

这个现象的根本原因在于 TCP 协议的一个重要状态:TIME_WAIT。

回想TCP 四次挥手:当一个 TCP 连接被关闭时(例如服务器或客户端程序退出),主动关闭连接的一方会进入 TIME_WAIT 状态。这个状态会持续一段时间,通常是 2 * MSL (Maximum Segment Lifetime,报文最大生存时间),在 Linux 系统上一般是 60 秒。

当我们的服务器程序关闭后,它所使用的套接字(绑定了例如 127.0.0.1:5005)就进入了 TIME_WAIT 状态。在此期间,操作系统认为这个端口仍然是“被占用的”,因此不允许任何新的套接字再次 bind() 到完全相同的地址和端口上。

为了解决这个问题,Socket API 提供了一个非常有用的选项:SO_REUSEADDR

在设置 SO_REUSEADDR 选项后,它会告诉操作系统内核:“请允许我 bind()到一个正处于 TIME_WAIT 状态的端口”。它放宽了 bind 函数的校验规则,使得服务器可以在关闭后立刻重启,绕过 TIME_WAIT 状态对 bind 的限制。

这对于需要高可用性和快速重启的服务器应用程序来说,是必须设置的一个选项。

要启用端口复用,我们需要在 bind() 函数被调用之前,使用 setsockopt() 函数来设置监听套接字的属性, 其函数原型如下:

1
2
#include <sys/socket.h>
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
- int sockfd: 要设置的套接字文件描述符(这里是我们的 listen_sockfd)。 - int level: 选项所在的协议层。对于端口复用,应设置为 SOL_SOCKET,表示在通用套接字层进行设置。 - int optname: 选项的名称。这里我们使用 SO_REUSEADDR。 - const void *optval: 一个指向变量的指针,该变量包含了我们想设置的选项的值。对于开关型选项 SO_REUSEADDR,我们通常用一个值为 1 的 int 变量来表示“开启”。 - socklen_t optlen: optval 指向的变量的大小,即 sizeof(int)。

下面是一个启用端口复用的示例代码片段,展示了如何在创建监听套接字后调用 bind() 之前设置 SO_REUSEADDR 选项:

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
/*
* server.cpp 中 main 函数的相关部分
*/

// 第1步:创建服务端的socket。
int listen_sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sockfd == -1)
{
perror("socket");
return -1;
}

// ======================= 在这里添加端口复用设置 =======================
// 作用:允许服务器在关闭后立即重启,而不会因为 TIME_WAIT 状态导致 "Address already in use"
int opt = 1;
if (setsockopt(listen_sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) != 0)
{
perror("setsockopt");
close(listen_sockfd);
return -1;
}
// ====================================================================

// 第2步:把服务端用于通信的地址和端口绑定到socket上。
struct sockaddr_in serv_addr;
// ... (后面的 bind, listen, accept 代码与之前完全相同)
if (bind(listen_sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) != 0)
{
// ...
}

总之, 端口复用 (SO_REUSEADDR) 是一个健壮的 TCP 服务器程序必备的特性。它通过 setsockopt 函数进行设置,允许程序重新绑定到处于 TIME_WAIT 状态的端口,从而解决了服务器因异常关闭而无法立即重启的问题,极大地提高了开发效率和服务的可用性。