计算机网络概念入门资料,从入门到进阶,依次如下:

一个视频讲清楚家庭网络通信流程,折腾软路由前必看的计算机网络通识教程 | Youtube

你管这破玩意叫网络 | 闪客

图解计算机网络| 小林 coding

图解计算机网络| 小林 coding | Github 开源版本

Linux 网络编程

网络编程的核心——Socket

无论是 UDP 还是 TCP,在 Linux 中都是通过“Socket(套接字)”这一统一的接口来实现的。可以把 Socket 理解为一个“通信端点”,我们的程序通过读写这个“文件”来收发网络数据。

本笔记将围绕 Socket 展开,分为两大核心部分:

  • UDP 编程:学习如何使用数据报套接字(SOCK_DGRAM),实现简单、高效但不可靠的通信
  • TCP 编程:学习如何使用流式套接字(SOCK_STREAM),实现复杂、可靠、面向连接的通信

我会先从各自的协议特性讲起,然后深入学习核心 API 的使用,最后探讨各自的高级应用场景和服务器模型

UDP 协议

一、传输层协议与 UDP 基础

1. UDP 协议的核心特点

UDP (User Datagram Protocol) 是传输层的一个核心协议,其设计哲学是简单、高效。根据其标准文档 RFC 768,UDP 的主要特点可以总结如下:

  • 无连接 (Connectionless):发送数据前,双方不需要像打电话一样先“建立连接”。发送端直接将数据打包发出,接收端直接接收。
  • 不可靠 (Unreliable):UDP 不保证数据包一定能送达,也不保证数据包的顺序,更不会在数据包丢失后进行重传。所有可靠性保障都需要应用层自己实现。
  • 面向数据报 (Datagram-Oriented):应用层交下来多大的数据块,UDP 就直接打包成一个数据报发送,它不会对数据进行拆分或合并。
  • 高效率、低延迟:由于没有建立连接、确认、重传等复杂的机制,UDP 的头部开销小,处理速度快,延迟低。

一句话总结:UDP 提供的是一种“尽力而为”的、高效的、简单的信息传送服务。

2. UDP 协议的报头结构

UDP 的报头非常简单,仅有 8 个字节,这也是其高效的原因之一UDP Header

  • 源端口号 (16 位):发送方进程的端口号。可选,若不使用则为 0。
  • 目标端口号 (16 位):接收方进程的端口号。必需
  • 包总长度 (16 位):UDP 报头 + UDP 数据的总长度(字节)。最大值为 65535 字节。
    • 实际数据最大值:65535 - 8 (UDP 头) - 20 (IP 头) = 65507 字节。
    • 实践建议:为避免在网络层被分片(分片会增加丢包风险),UDP 数据包的大小最好控制在 MTU (通常为 1500 字节) 以内,即数据大小不超过 1472 字节。
  • 校验和 (16 位):用于检查数据在传输过程中是否出错。可选,若不使用则为 0。

二、UDP 核心 Socket API

在 Linux 中,网络通信是通过 Socket (套接字) 文件来实现的。“一切皆文件”的思想在这里也适用。

UDP 通信基本流程

  • 服务器 (接收端)socket() -> bind() -> recvfrom() -> close()
  • 客户端 (发送端)socket() -> sendto() -> close()

1. socket() - 创建套接字

man 2 socket 查看接口函数。

1
2
3
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
参数分别为: 协议族 , 套接字类型 , 具体协议
  • domain: 协议族。对于 IPv4 网络,使用 AF_INET
  • type: 套接字类型。
    • SOCK_DGRAM: 用于 UDP 协议 (数据报套接字)。
    • SOCK_STREAM: 用于 TCP 协议 (流式套接字)。
  • protocol: 具体协议。通常设为 0,让系统自动选择。
  • 返回值:成功返回一个文件描述符 (sockfd),失败返回 -1。

创建 UDP 套接字示例

1
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

2. bind() - 绑定地址和端口

man 2 bind 查看接口函数。

1
2
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数分别为: 套接字文件描述符 , 地址信息结构体 , 结构体长度
  • 作用:将一个套接字与一个具体的 IP 地址和端口号绑定起来。这通常是服务器端在接收数据前必须做的
  • sockfd: socket() 返回的文件描述符
  • addr: 指向一个 struct sockaddr 结构体的指针,但实际使用时我们通常填充一个 struct sockaddr_in 结构体,然后进行强制类型转换,这样方便管理 ipv4 数据
  • addrlen: addr 结构体的长度。
struct sockaddr_in 结构体
1
2
3
4
5
6
7
8
9
10
#include <netinet/in.h>
struct sockaddr_in {
sa_family_t sin_family; // 协议族,必须为 AF_INET
in_port_t sin_port; // 16位端口号
struct in_addr sin_addr; // 32位IPv4地址
};

struct in_addr {
uint32_t s_addr; // IPv4地址
};

核心概念:网络字节序 (Big Endian)
不同的计算机体系结构存储多字节数据的方式可能不同(大端 vs. 小端)。为了在网络中统一标准,所有网络协议都规定使用大端序。因此,在填充 sockaddr_in 结构体时,必须将主机字节序的端口和地址转换为网络字节序。

  • htons(): Host to Network Short (16 位,用于端口号)。
  • htonl(): Host to Network Long (32 位,用于 IP 地址)。
  • ntohs(), ntohl(): Network to Host,反向转换。
  • inet_addr(): 将点分十进制的 IP 地址字符串转换为网络字节序的 32 位整数。
setsockopt()设置端口复用

在网络程序开发,尤其是在频繁重启调试时,你几乎一定会遇到 bind failed: Address already in use 这个错误。

  • 问题根源:当你关闭一个网络程序时,操作系统为了确保网络中残余的数据包能被正确处理,会使该程序占用的端口进入一个名为 TIME_WAIT 的状态,并持续一小段时间。在这段时间内,该端口被视为“仍在使用中”,任何新程序都无法立即绑定它。

  • 解决方案:使用 setsockopt() 函数为套接字设置 SO_REUSEADDR (地址复用) 选项。这个选项向操作系统声明:“我允许将此套接字绑定到一个正在 TIME_WAIT 状态的地址上”。

setsockopt() 函数

1
2
3
#include <sys/socket.h>
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
参数分别为: 文件描述符 , 协议层等级 , 选项名 , 选项值 , 选项值长度
  • 作用:设置套接字的各种选项,以改变其默认行为。
  • level: 选项所属的协议层。SOL_SOCKET 表示套接字本身。
  • optname: 选项名称,例如 SO_REUSEADDR
  • optval: 指向一个变量的指针,该变量的值就是选项要设置的值。

代码实现

socket() 创建套接字之后、bind() 绑定地址之前,加入以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 在 socket() 之后,bind() 之前
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);


// 设置地址复用选项,解决 TIME_WAIT 问题
int optval = 1; // 1 表示开启该选项
if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval)) < 0) {
perror("setsockopt failed");
close(sockfd);
return -1;
}

// 继续执行 bind()
bind(sockfd, ...);

重要提示SO_REUSEADDR 选项主要用于解决 TIME_WAIT 状态导致无法立即重启程序的问题。它不能让两个程序在同一时刻都成功 bind 并监听同一个端口(除非在特定的组播场景下)。如果错误持续存在,请检查是否已有另一个程序实例正在后台运行。

3. sendto() & recvfrom() - 收发数据

man 2 sendtoman 2 recvfrom 查看接口函数

1
2
3
4
5
6
7
8
9
10
11
// 发送数据
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
参数分别为: 文件描述符 ,要发送数据 , 发送数据长度 , 控制标准(一般为0
目标地址结构体指针 , 目标地址结构体长度

// 接收数据
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
参数分别为: 文件描述符 ,要发送数据 , 发送数据长度 , 控制标准(一般为0
自身地址结构体指针 , 自身地址结构体长度
  • sendto() 需要指定目标地址 dest_addr
  • recvfrom() 会阻塞程序,直到收到数据,并能获取到源地址 src_addr

示例:简单的客户端/服务器

  • 服务器 (接收端) udp_server.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
    #include <stdio.h>
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #include <unistd.h>
    #include <string.h>

    int main(void) {
    // 1. 创建套接字
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

    // 2. 绑定地址和端口
    struct sockaddr_in my_addr;
    my_addr.sin_family = AF_INET;
    my_addr.sin_port = htons(8888); // 绑定8888端口
    my_addr.sin_addr.s_addr = inet_addr("192.168.59.198"); // 绑定本机IP
    bind(sockfd, (struct sockaddr *)&my_addr, sizeof(my_addr));

    // 3. 接收数据
    char buf[128] = {0};
    struct sockaddr_in client_addr;
    socklen_t len = sizeof(client_addr);
    recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr *)&client_addr, &len);

    printf("收到来自 %s 的消息: %s\n", inet_ntoa(client_addr.sin_addr), buf);

    // 4. 关闭套接字
    close(sockfd);
    return 0;
    }
  • 客户端 (发送端) udp_client.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
    #include <stdio.h>
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #include <unistd.h>
    #include <string.h>

    int main(void) {
    // 1. 创建套接字
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

    // 2. 准备目标地址并发送数据
    struct sockaddr_in dest_addr;
    dest_addr.sin_family = AF_INET;
    dest_addr.sin_port = htons(8888);
    dest_addr.sin_addr.s_addr = inet_addr("192.168.59.198");

    char *msg = "Hello, UDP Server!";
    sendto(sockfd, msg, strlen(msg), 0, (struct sockaddr *)&dest_addr, sizeof(dest_addr));

    // 3. 关闭套接字
    close(sockfd);
    return 0;
    }

示例:使用线程实现收发一体

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
#include <arpa/inet.h>
#include <netinet/in.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

#define SEND_PORT 50001
#define SEND_IP "192.168.59.198"
#define RECV_PORT 50003

// 线程任务:接收消息
void* receive_thread(void* arg) {
// 1. 创建 socket
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd == -1) {
perror("socket creation failed");
return NULL; // 线程异常退出
}

// 2. 绑定本地地址和端口用于接收
struct sockaddr_in my_addr;
memset(&my_addr, 0, sizeof(my_addr));
my_addr.sin_family = AF_INET;
my_addr.sin_port = htons(RECV_PORT);
// 用INADDR_ANY,接收发送到本机任何IP地址上指定端口的数据
my_addr.sin_addr.s_addr = htonl(INADDR_ANY);

if (bind(sockfd, (struct sockaddr*)&my_addr, sizeof(my_addr)) == -1) {
perror("bind failed");
close(sockfd);
return NULL; // 线程异常退出
}
printf("接收线程启动,正在监听端口 %d\n", RECV_PORT);

char buffer[512];
struct sockaddr_in src_addr;
socklen_t src_addr_len = sizeof(src_addr);

// 3. 循环接收消息
while (1) {
memset(buffer, 0, sizeof(buffer));
ssize_t recv_len = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0,
(struct sockaddr*)&src_addr, &src_addr_len);
if (recv_len > 0) {
printf("\n[收到消息]: %s\n> ", buffer);
fflush(stdout); // 刷新输出缓冲区,确保提示符 "> " 能正确显示
if (strncmp(buffer, "quit", 4) == 0) {
printf("对方已退出,接收线程关闭。\n");
break;
}
} else {
perror("recvfrom error");
break; // 出现错误时退出循环
}
}

// 4. 关闭 socket
close(sockfd);
return NULL;
}

// 线程任务:发送消息
void* send_thread(void* arg) {
// 1. 创建 socket
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd == -1) {
perror("socket creation failed");
return NULL;
}

// 2. 设置目标地址和端口
struct sockaddr_in dest_addr;
memset(&dest_addr, 0, sizeof(dest_addr));
dest_addr.sin_family = AF_INET;
dest_addr.sin_port = htons(SEND_PORT); // 注意:这里是发送目标端口
dest_addr.sin_addr.s_addr = inet_addr(SEND_IP);
printf("发送线程启动,准备向 %s:%d 发送消息\n", SEND_IP, SEND_PORT);

char message[100];
printf("> ");
fflush(stdout);

// 3. 循环发送消息
while (fgets(message, sizeof(message), stdin) != NULL) {
// 移除 fgets 读取到的换行符
message[strcspn(message, "\n")] = 0;

if (strlen(message) == 0) { // 如果用户只输入回车
printf("> ");
fflush(stdout);
continue;
}

sendto(sockfd, message, strlen(message), 0, (struct sockaddr*)&dest_addr,
sizeof(dest_addr));

if (strncmp(message, "quit", 4) == 0) {
printf("你已退出,发送线程关闭。\n");
break;
}
printf("> ");
fflush(stdout);
}

// 4. 关闭 socket
close(sockfd);
return NULL;
}

int main(void) {
pthread_t tid_receive, tid_send;
pthread_create(&tid_receive, NULL, receive_thread, NULL);
pthread_create(&tid_send, NULL, send_thread, NULL);
pthread_join(tid_receive, NULL);
pthread_join(tid_send, NULL);
printf("所有线程已结束,主程序退出。\n");
return 0;
}

三、UDP 的广播与组播

1. UDP 广播 (Broadcast)

  • 概念:向局域网内的所有主机发送数据包。
  • 广播地址:一个特殊的 IP 地址,通常是网络号不变,主机号全为 1。例如,对于 192.168.5.0/24 网段,广播地址是 192.168.5.255
  • 实现
    1. 发送方:必须使用 setsockopt() 函数为套接字开启广播权限
    2. 接收方bind() 时绑定的 IP 地址应为 INADDR_ANY,表示接收发送到本机任意网卡的数据包。
setsockopt() - 设置套接字

man 2 setsockopt 查看接口函数。

1
2
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
参数分别为: 文件描述符 , 协议层级 , 选项名 , 选项值指针 , 选项值长度
  • 例如设置开启广播权限

    1
    2
    int on = 1;
    setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &on, sizeof(on));

2. UDP 组播/多播 (Multicast)

  • 概念:向一个特定“小组”内的所有成员发送数据包,只有加入了该组的主机才能收到。这比广播更高效,因为它只影响感兴趣的主机。

  • 组播地址:D 类 IP 地址,范围从 224.0.0.0239.255.255.255

  • 实现

    1. 发送方:与广播类似,需要开启广播权限,并向一个组播地址发送数据。
    2. 接收方:必须使用 setsockopt() 加入一个或多个组播组
  • 加入组播组

    1
    2
    3
    4
    struct ip_mreq mreq;
    mreq.imr_multiaddr.s_addr = inet_addr("224.0.0.10"); // 组播组地址
    mreq.imr_interface.s_addr = htonl(INADDR_ANY); // 使用哪个网卡加入,INADDR_ANY表示由系统选择
    setsockopt(sockfd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq));

四、UDP 的应用

1. 适用场景

UDP 的高效和低延迟特性使其非常适合以下场景:

  • 音视频流传输:如直播、视频会议。偶尔丢失一两帧数据不影响整体体验。
  • 实时游戏:对实时性要求极高。
  • DNS (域名解析):一次简短的请求和响应,追求速度。
  • 网络发现协议:如 DHCP。

2. 示例:DNS 域名解析

Linux 提供了 gethostbyname() 函数来查询域名对应的 IP 地址。
man 3 gethostbyname 查看接口函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
#include <netdb.h>
#include <arpa/inet.h>

int main() {
struct hostent *host_info;
char *hostname = "www.baidu.com";

host_info = gethostbyname(hostname);
if (host_info == NULL) {
herror("gethostbyname");
return -1;
}

printf("%s 的 IP 地址列表:\n", hostname);
for (int i = 0; host_info->h_addr_list[i] != NULL; i++) {
// inet_ntoa 将网络字节序的地址转换为点分十进制字符串
printf(" %s\n", inet_ntoa(*(struct in_addr*)host_info->h_addr_list[i]));
}

return 0;
}

TCP 协议

一、TCP 协议基础

1. TCP 协议的核心特点

TCP (Transmission Control Protocol) 是传输层与 UDP 并列的核心协议,但它的设计目标是可靠性

  • 面向连接 (Connection-Oriented):在数据传输前,通信双方必须先通过“三次握手”建立一个逻辑连接。通信结束后,通过“四次挥手”断开连接。
  • 可靠传输 (Reliable):TCP 通过多种机制确保数据不丢失、不重复、无差错、按顺序到达。
  • 全双工通信 (Full-Duplex):连接建立后,双方可以同时进行数据的发送和接收。
  • 面向字节流 (Byte Stream-Oriented):应用程序发送的数据会被 TCP 视为一连串无边界的字节流。TCP 可能会根据网络状况对数据进行分段或合并,接收方需要自己处理“粘包”和“半包”问题。

一句话总结:TCP 就像打电话,必须先拨号接通,通话过程稳定可靠,说完后挂断,只支持一对一通话。

2. TCP 如何保证可靠性?

TCP 的可靠性并非凭空而来,而是依赖于其精巧的报头设计和一系列机制:

  • 序列号 (Sequence Number):TCP 为每个发送的字节都编上号。接收方可以根据序列号来检测数据包的丢失、重组乱序的数据包。
  • 确认应答 (ACK):接收方每收到一个数据包,都会发送一个 ACK 确认包,告知发送方“我收到了序列号 xxx 之前的所有数据”。
  • 超时重传 (Timeout Retransmission):发送方在发出数据后会启动一个计时器。如果在规定时间内没有收到对方的 ACK,就会认为数据包丢失,并重新发送该数据包。
  • 校验和 (Checksum):发送方和接收方都会对报头和数据进行校验和计算,以检测数据在传输过程中是否损坏。
  • 流量控制 (Flow Control):通过报头中的“窗口大小”字段,接收方可以告知发送方自己还能接收多少数据,防止发送方发送过快导致接收方缓冲区溢出。

3. TCP 报头格式

TCP 的报头比 UDP 复杂得多,标准长度为 20 字节,包含了实现其可靠性所需的各种字段。TCP 首部结构

关键字段说明

  • 源/目标端口号:各 16 位,标识通信的两个进程。
  • 序列号 (Sequence Number):32 位,标记本报文段数据第一个字节的序号。
  • 确认应答号 (Acknowledgement Number):32 位,期望收到的下一个字节的序列号。
  • 头部长度:4 位,表示 TCP 头部的长度,单位是 4 字节(32 位)。
  • 控制位 (Flags):6 位,至关重要。
    • ACK: 确认位。
    • SYN: 同步位,在建立连接时使用。
    • FIN: 终止位,在断开连接时使用。
  • 窗口大小 (Window Size):16 位,用于流量控制。

二、TCP 连接生命周期

1. 三次握手 (建立连接)

TCP 连接的建立过程由客户端发起,在 connect()accept() 函数内部自动完成。

  1. 客户端 -> 服务器:客户端发送一个 SYN 包(SYN=1),并携带一个随机生成的初始序列号 seq=x。客户端进入 SYN_SENT 状态。
  2. 服务器 -> 客户端:服务器收到 SYN 包后,回复一个 SYN+ACK 包(SYN=1, ACK=1),并携带自己的初始序列号 seq=y 和确认号 ack=x+1。服务器进入 SYN_RCVD 状态。
  3. 客户端 -> 服务器:客户端收到服务器的确认后,再发送一个 ACK 包(ACK=1),并携带确认号 ack=y+1。连接建立成功,双方进入 ESTABLISHED 状态。

2. 四次挥手 (断开连接)

连接的断开可以由任意一方发起,在 close() 函数调用时触发。

  1. 主动方 -> 被动方:主动方发送一个 FIN 包(FIN=1),表示我这边的数据已经发完了。
  2. 被动方 -> 主动方:被动方回复一个 ACK 包,表示收到了你的断开请求。
  3. 被动方 -> 主动方:被动方处理完自己要发送的数据后,也发送一个 FIN 包。
  4. 主动方 -> 被动方:主动方回复最后一个 ACK 包。

TIME_WAIT 状态
主动断开方在发送完最后一个 ACK 后,会进入 TIME_WAIT 状态,并持续 2*MSL (报文最大生存时间,通常是 60 秒) 的时间。
目的

  1. 确保最后一个 ACK 包能成功到达对方(如果丢失,对方会重传FIN,我方可以再次回复ACK)。
  2. 防止网络中延迟的旧连接数据包干扰新建立的连接。

三、TCP 核心 Socket API

TCP 通信基本流程

  • 服务器socket() -> bind() -> listen() -> accept() -> recv() / send() -> close()
  • 客户端socket() -> connect() -> send() / recv() -> close()

linux Socket通信时序图

1. 通用函数 socket()

man 2 socket 查看接口函数。

1
2
int socket(int domain, int type, int protocol);
参数分别为: 协议族 , 套接字类型 , 具体协议
  • 作用:创建一个套接字(网络通信端点)。对于 TCP,type 必须是 SOCK_STREAM

2. 客户端专用函数 connect()

man 2 connect 查看接口函数。

1
2
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数分别为: 套接字文件描述符 , 服务器地址信息 , 地址结构体长度
  • 作用:客户端使用此函数向服务器发起连接请求。函数内部会自动完成三次握手。成功返回 0,失败返回-1。

3. 服务器专用函数

a. bind() - 绑定地址和端口

man 2 bind 查看接口函数。

1
2
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数分别为: 套接字文件描述符 , 本机地址信息 , 地址结构体长度
  • 作用:将套接字与服务器的 IP 地址和端口号关联起来,告诉内核监听哪个端口。
b. listen() - 设置监听状态

man 2 listen 查看接口函数。

1
2
int listen(int sockfd, int backlog);
参数分别为: 监听的套接字 , 等待连接队列的最大长度
  • 作用:将一个主动套接字转换为被动监听套接字,准备接受客户端的连接。backlog 定义了内核为这个套接字维护的、已完成三次握手但尚未被 accept() 的连接队列大小。
c. accept() - 接受连接

man 2 accept 查看接口函数。

1
2
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数分别为: 监听的套接字 , 客户端地址信息(输出) , 地址长度(输入输出)
  • 作用:从已完成连接队列中取出一个连接。如果队列为空,accept()阻塞
  • 返回值:成功时返回一个全新的、已连接的套接字描述符,用于与该客户端进行通信。原来的监听套接字 sockfd 保持不变,继续监听其他连接。

4. 数据收发函数

a. send() & write()

man 2 send 查看接口函数。

1
2
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
参数分别为: 已连接的套接字 , 数据缓冲区 , 数据长度 , 标志位
  • 作用:在已连接的套接字上发送数据。flags 通常为 0,此时功能与 write() 几乎相同。
b. recv() & read()

man 2 recv 查看接口函数。

1
2
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
参数分别为: 已连接的套接字 , 接收缓冲区 , 缓冲区长度 , 标志位
  • 作用:从已连接的套接字接收数据。如果缓冲区无数据,recv()阻塞
  • 返回值
    • > 0: 成功接收的字节数。
    • = 0: 对方已正常关闭连接(发送了 FIN)。
    • < 0: 发生错误。

四、TCP 编程示例

  • 服务器 tcp_server.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
    #include <stdio.h>
    #include <sys/socket.h>
    #include <arpa/inet.h>
    #include <unistd.h>
    #include <string.h>

    int main(void) {
    // 1. 创建监听套接字
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);

    // 2. 绑定地址和端口
    struct sockaddr_in server_addr;
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(8888);
    server_addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡
    bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));

    // 3. 设置为监听状态
    listen(listen_fd, 5);
    printf("服务器正在监听 8888 端口...\n");

    // 4. 接受客户端连接
    struct sockaddr_in client_addr;
    socklen_t len = sizeof(client_addr);
    int conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &len);
    printf("接受来自 %s:%d 的连接\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));

    // 5. 接收数据
    char buf[128];
    int n = recv(conn_fd, buf, sizeof(buf), 0);
    buf[n] = '\0';
    printf("收到消息: %s\n", buf);

    // 6. 关闭套接字
    close(conn_fd);
    close(listen_fd);
    return 0;
    }
  • 客户端 tcp_client.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
    #include <stdio.h>
    #include <sys/socket.h>
    #include <arpa/inet.h>
    #include <unistd.h>
    #include <string.h>

    int main(void) {
    // 1. 创建套接字
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);

    // 2. 连接服务器
    struct sockaddr_in server_addr;
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(8888);
    server_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 连接本机

    if (connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
    perror("connect failed");
    return -1;
    }

    // 3. 发送数据
    char *msg = "Hello, TCP Server!";
    send(sockfd, msg, strlen(msg), 0);
    printf("消息已发送。\n");

    // 4. 关闭套接字
    close(sockfd);
    return 0;
    }

五、高级 TCP 特性

1. 缓冲区管理

TCP 套接字在内核中拥有独立的发送缓冲区 (Send Buffer)接收缓冲区 (Receive Buffer)send() 实际上是将数据写入发送缓冲区,而 recv() 是从接收缓冲区读取数据。我们可以通过 setsockopt 来调整这些缓冲区的行为。

a. 缓冲区大小 (SO_SNDBUF / SO_RCVBUF)
  • 作用:获取或设置 TCP 发送/接收缓冲区的大小。调整此值可以影响 TCP 的吞吐量和性能,尤其是在高延迟或高带宽网络中。
  • 注意:设置的值只是给内核的一个“建议”,内核会自动将其调整(通常是加倍)以预留管理开销。设置应在 listen()connect() 之前进行。
b. 接收低水位标记 (SO_RCVLOWAT)
  • 作用:设置接收缓冲区的“水位线”。只有当缓冲区中待读取的数据量达到或超过这个阈值时,select/poll/epoll 等 I/O 复用函数才会认为该套接字是“可读”的。默认值为 1。
  • 场景:可以避免频繁地被少量数据唤醒,实现“攒够一定量的数据再处理”,提高处理效率。
getsockopt() / setsockopt() - 获取/设置套接字选项

man 2 getsockopt 查看接口函数。

1
2
3
int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen);
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
参数分别为: 套接字,协议层(SOL_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
// buffer_options.c
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>

int main(void) {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket");
return -1;
}

int rcv_buf_size, rcv_lowat_size;
socklen_t len = sizeof(int);

// 1. 获取默认值
getsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &rcv_buf_size, &len);
getsockopt(sockfd, SOL_SOCKET, SO_RCVLOWAT, &rcv_lowat_size, &len);
printf("默认接收缓冲区大小: %d 字节\n", rcv_buf_size);
printf("默认接收低水位标记: %d 字节\n\n", rcv_lowat_size);

// 2. 设置新值
printf("请输入新的接收缓冲区大小: ");
scanf("%d", &rcv_buf_size);
setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &rcv_buf_size, sizeof(rcv_buf_size));

printf("请输入新的接收低水位标记: ");
scanf("%d", &rcv_lowat_size);
setsockopt(sockfd, SOL_SOCKET, SO_RCVLOWAT, &rcv_lowat_size, sizeof(rcv_lowat_size));

// 3. 验证设置后的值
getsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &rcv_buf_size, &len);
getsockopt(sockfd, SOL_SOCKET, SO_RCVLOWAT, &rcv_lowat_size, &len);
printf("\n设置后,内核实际接收缓冲区大小: %d 字节 (通常为设置值的两倍)\n", rcv_buf_size);
printf("设置后,接收低水位标记: %d 字节\n", rcv_lowat_size);

close(sockfd);
return 0;
}

2. 带外数据 (Out-of-Band, OOB)

  • 作用:提供一种发送“紧急”数据的方式。它允许发送一个字节的数据,这个数据在逻辑上独立于正常的 TCP 字节流,可以被接收方优先处理,而无需等待缓冲区中的普通数据被读完。
  • 实现机制
    1. 发送方: 调用 send() 时,将 flags 参数设置为 MSG_OOB
    2. 接收方: 当 OOB 数据到达时,内核会向接收进程发送 SIGURG 信号。进程需要:
      a. 使用 signal()sigaction() 捕捉 SIGURG 信号。
      b. 使用 fcntl()F_SETOWN 命令,将套接字的所有权指定给当前进程,这样内核才知道该把信号发给谁。
      c. 在信号处理函数中,调用 recv() 并将 flags 参数设置为 MSG_OOB 来读取这个紧急字节。
示例代码:发送和接收带外数据
  • 服务器 (接收端) server_oob.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
    // server_oob.c
    #include <stdio.h>
    #include <signal.h>
    #include <fcntl.h>
    #include <unistd.h>
    #include <sys/socket.h>
    #include <arpa/inet.h>
    #include <string.h>

    int conn_fd;

    void oob_handler(int sig) {
    char oob_data;
    // 使用 MSG_OOB 标志接收紧急数据
    recv(conn_fd, &oob_data, 1, MSG_OOB);
    printf("\n>>> 收到紧急数据: %c <<<\n", oob_data);
    }

    int main(void) {
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);

    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(8888);
    addr.sin_addr.s_addr = INADDR_ANY;
    bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr));
    listen(listen_fd, 5);

    printf("等待连接...\n");
    conn_fd = accept(listen_fd, NULL, NULL);
    printf("客户端已连接。\n");

    // 1. 设置信号处理函数
    signal(SIGURG, oob_handler);
    // 2. 指定当前进程为信号的接收者
    fcntl(conn_fd, F_SETOWN, getpid());

    char buf[128];
    while (1) {
    int n = recv(conn_fd, buf, sizeof(buf) - 1, 0);
    if (n <= 0) break;
    buf[n] = '\0';
    printf("收到普通数据: %s\n", buf);
    }
    close(conn_fd);
    close(listen_fd);
    return 0;
    }
  • 客户端 (发送端) client_oob.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
    // client_oob.c
    #include <stdio.h>
    #include <unistd.h>
    #include <sys/socket.h>
    #include <arpa/inet.h>
    #include <string.h>

    int main(void) {
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(8888);
    addr.sin_addr.s_addr = inet_addr("127.0.0.1");

    connect(sockfd, (struct sockaddr *)&addr, sizeof(addr));

    send(sockfd, "normal_data_1", 13, 0);
    printf("发送普通数据 'normal_data_1'\n");
    sleep(1);

    send(sockfd, "A", 1, MSG_OOB); // 发送紧急数据
    printf("发送紧急数据 'A'\n");
    sleep(1);

    send(sockfd, "normal_data_2", 13, 0);
    printf("发送普通数据 'normal_data_2'\n");

    close(sockfd);
    return 0;
    }

3. 超时控制

  • 作用:为阻塞的套接字操作(如 accept, connect, recv)设置一个最长等待时间。如果在超时时间内操作未能完成(如没等到连接、没收到数据),函数将不再阻塞,而是立即返回一个错误,通常 errno 会被设为 EAGAINEWOULDBLOCK。这可以防止程序因网络问题而无限期挂起。
  • 实现:通过 setsockopt 设置 SO_RCVTIMEO (接收超时) 或 SO_SNDTIMEO (发送超时) 选项。超时时间通过 struct timeval 结构体指定。
struct timeval 结构体

man 3 timeval 查看接口函数。

1
2
3
4
5
#include <sys/time.h>
struct timeval {
time_t tv_sec; /* seconds */
long tv_usec; /* microseconds */
};
示例代码:带超时的服务器

这个服务器演示了两种超时:

  1. accept 超时:如果在 5 秒内没有客户端连接,accept 将超时返回。
  2. recv 超时:连接成功后,如果在 5 秒内没有收到客户端的任何数据,recv 将超时返回。
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
// timeout_server.c
#include <stdio.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <errno.h>

int main(void) {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);

struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);
addr.sin_addr.s_addr = INADDR_ANY;
bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr));
listen(listen_fd, 5);

// 1. 设置 accept 的超时时间
struct timeval timeout = {5, 0}; // 5秒
setsockopt(listen_fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));

printf("等待客户端连接 (5秒超时)...\n");
int conn_fd = accept(listen_fd, NULL, NULL);
if (conn_fd < 0) {
perror("accept 超时或出错");
close(listen_fd);
return -1;
}
printf("客户端已连接。\n");

// 2. 设置 recv 的超时时间
setsockopt(conn_fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));

char buf[128];
while(1) {
printf("等待接收数据 (5秒超时)...\n");
int n = recv(conn_fd, buf, sizeof(buf), 0);
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
printf("recv 超时!\n");
break; // 超时后退出循环
}
perror("recv error");
break;
} else if (n == 0) {
printf("客户端断开连接。\n");
break;
} else {
buf[n] = '\0';
printf("收到数据: %s\n", buf);
}
}

close(conn_fd);
close(listen_fd);
return 0;
}

六、服务器 I/O 模型

当服务器需要同时处理多个客户端时,如何高效地管理这些连接就成了关键问题。

  1. 非阻塞轮询:将所有套接字设为非阻塞,然后在一个死循环中不断轮询每个套接字是否有数据。缺点:CPU 空转,效率低下。
  2. 多任务并发 (多进程/多线程):每当 accept() 一个新连接,就创建一个新的进程或线程专门为这个客户端服务。缺点:资源开销大,能支持的并发连接数有限。
  3. I/O 多路复用 (Multiplexing)推荐方案。使用 select(), poll(), epoll() 等函数,让一个线程可以同时监视多个文件描述符(套接字)的状态。当某个或某些描述符就绪(可读/可写)时,函数返回,程序再去处理这些就绪的描述符。
    • 优点:资源开销小,能够高效地处理大量并发连接。

select() 模型详解

man 2 select 查看接口函数。

1
2
3
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
参数分别为: 最大描述符+1,读集合,写集合,异常集合,超时时间
  • 作用:阻塞地等待一组文件描述符中的一个或多个变为就绪状态。
  • fd_set: 一个位图,用于表示文件描述符的集合。需要使用以下宏来操作:
    • FD_ZERO(fd_set *set): 清空集合。
    • FD_SET(int fd, fd_set *set): 将一个 fd 加入集合。
    • FD_CLR(int fd, fd_set *set): 将一个 fd 从集合中移除。
    • FD_ISSET(int fd, fd_set *set): 判断一个 fd 是否仍在集合中(即是否就绪)。
select 工作流程
  1. 创建一个 fd_set (例如 readfds)。
  2. 使用 FD_ZERO 清空它。
  3. 将所有需要监视的 fd (监听套接字、所有已连接的客户端套接字) 通过 FD_SET 加入集合。
  4. 调用 select()。函数会阻塞。
  5. 当有 fd 状态就绪时,select 返回,并修改传入的 fd_set,只保留那些就绪的 fd
  6. 遍历所有原始的 fd,使用 FD_ISSET 检查哪个 fd 仍然在集合中,然后对其进行相应的读写操作。
  7. 重复以上步骤select会修改集合,所以每次循环前都必须重新设置)。

核心考点总结


第一部分:UDP (用户数据报协议)

1. UDP 核心特性 (必考)
  • 无连接:发送前不需要建立连接(不“握手”)。
  • 不可靠:不保证数据送达,不保证顺序,不重传。可靠性需应用层自己实现。
  • 面向数据报:保持应用层消息的边界,不会拆分或合并数据包。
  • 效率高、延迟低:开销小,适用于实时性要求高的场景。
  • 报头简单:仅 8 字节
2. UDP 编程流程与核心 API
  • 服务器流程: socket() -> bind() -> recvfrom() -> close()
  • 客户端流程: socket() -> sendto() -> close()
函数作用考点
socket()创建套接字socket(AF_INET, SOCK_DGRAM, 0);
bind()(服务器) 绑定 IP 和端口必须将主机字节序的端口和地址转为网络字节序(大端序)
sendto()(客户端) 发送数据需要指定目标地址 dest_addr
recvfrom()(服务器) 接收数据阻塞函数,会返回源地址 src_addr
3. 关键概念与函数
  • 网络字节序:网络协议规定使用大端序。必须使用 htons() (端口) 和 htonl() (IP) 函数进行转换。
  • TIME_WAIT 问题:程序关闭后端口会短时间被占用,导致重启失败,报 “Address already in use”。
  • setsockopt()SO_REUSEADDR:解决 TIME_WAIT 问题的关键。在 bind() 之前调用 setsockopt() 设置地址复用选项,可以立即重启程序。
  • 广播 (Broadcast):向局域网所有主机发送。
    • 发送方需要用 setsockopt() 开启 SO_BROADCAST 权限。
    • 目标地址是广播地址(如 192.168.1.255)。
  • 组播 (Multicast):向特定“小组”内的主机发送。
    • 使用 D 类 IP 地址 (224.0.0.0 - 239.255.255.255)。
    • 接收方需要用 setsockopt()IP_ADD_MEMBERSHIP 选项加入组播组。

第二部分:TCP (传输控制协议)

1. TCP 核心特性 (必考)
  • 面向连接:传输数据前必须通过“三次握手”建立连接,结束后“四次挥手”断开。
  • 可靠传输:通过序列号、确认应答(ACK)、超时重传、流量控制等机制保证数据不丢、不乱、无误。
  • 面向字节流:数据没有边界,可能会发生“粘包”或“半包”问题,需要应用层处理。
  • 全双工:连接建立后,双方可同时收发数据。
2. TCP 连接生命周期 (高频考点)
  • 三次握手 (建立连接)
    1. 客户端 SYN -> 服务器
    2. 服务器 SYN+ACK -> 客户端
    3. 客户端 ACK -> 服务器
  • 四次挥手 (断开连接)
    1. 主动方 FIN -> 被动方
    2. 被动方 ACK -> 主动方
    3. 被动方 FIN -> 主动方
    4. 主动方 ACK -> 被动方
  • TIME_WAIT 状态
    • 谁产生:主动断开连接的一方。
    • 目的:1. 确保最后一个 ACK 能成功送达;2. 防止延迟的旧数据包干扰新连接。
3. TCP 编程流程与核心 API
  • 服务器流程: socket() -> bind() -> listen() -> accept() -> recv()/send() -> close()
  • 客户端流程: socket() -> connect() -> send()/recv() -> close()
函数作用考点
socket()创建套接字socket(AF_INET, SOCK_STREAM, 0);
connect()(客户端) 发起连接请求内部自动完成三次握手,是阻塞函数。
listen()(服务器) 将套接字设为监听状态第二个参数 backlog 定义了等待连接队列的大小。
accept()(服务器) 从队列中接受连接阻塞函数。成功时会返回一个全新的已连接套接字用于通信。
recv()接收数据阻塞函数。返回 >0 (字节数),=0 (对方正常关闭),<0 (出错)。
send()发送数据-

第三部分:高级特性与 I/O 模型

1. 高级 TCP 特性
  • 缓冲区管理
    • SO_RCVBUF / SO_SNDBUF:设置接收/发送缓冲区大小,影响吞吐量。
  • 超时控制 (必考)
    • 作用:防止 acceptconnectrecv 等阻塞函数因网络问题无限期挂起。
    • 实现:使用 setsockopt() 设置 SO_RCVTIMEO (接收超时) 或 SO_SNDTIMEO (发送超时)。超时后函数返回 -1,errnoEAGAINEWOULDBLOCK
  • 带外数据 (OOB)
    • 发送一个字节的“紧急”数据,接收方会收到 SIGURG 信号。
    • 通过 send()MSG_OOB 标志发送,recv()MSG_OOB 标志接收。
2. 服务器 I/O 模型 (必考)
  • 问题:如何高效处理大量并发连接?
  • 解决方案I/O 多路复用(重点是 select)。
  • select() 模型
    • 作用:让一个线程同时监视多个套接字(文件描述符)的状态。
    • 核心:使用 fd_set 位图结构来管理文件描述符集合。
    • 关键宏FD_ZERO (清空), FD_SET (添加), FD_CLR (移除), FD_ISSET (检查)。
    • 工作流程
      1. FD_ZERO 清空 fd_set
      2. FD_SET 将所有要监视的套接字加入集合。
      3. 调用 select() 进行阻塞等待。
      4. select() 返回后,fd_set 中只剩下就绪的套接字。
      5. 使用 FD_ISSET 遍历,找出哪些套接字就绪并进行处理。
      6. 易错点:每次循环调用 select 之前,必须重新设置 fd_set,因为它会被内核修改。