本节重点
认识IP地址, 端口号, 网络字节序等网络编程中的基本概念;
学习socket api的基本用法;
能够实现一个简单的udp客户端/服务器;
能够实现一个简单的tcp客户端/服务器(单连接版本, 多进程版本, 多线程版本);
理解tcp服务器建立连接, 发送数据, 断开连接的流程;
0.预备知识
0.1理解源IP地址和目的IP地址
| 概念 | 英文 | 定义 | 比喻 |
|---|---|---|---|
| 源 IP 地址 | Source IP | 数据包的发送方的网络标识。在 TCP 连接中,它是发起连接 / 发送数据的一方。 | 寄件人地址 |
| 目的 IP 地址 | Destination IP | 数据包的接收方的网络标识。在 TCP 连接中,它是等待连接 / 接收数据的一方。 | 收件人地址 |
0.2认识端口号
端口号是传输层(TCP/UDP)用来标识同一台主机上不同应用进程 的编号,和 IP 地址一起,构成了网络通信的 "IP: 端口" 唯一标识。
- 核心定义
- 作用 :IP 地址找到主机 ,端口号找到主机上的具体应用进程。
- 范围 :16 位无符号整数,取值
0 ~ 65535(共 65536 个)。 - 比喻:IP 是 "大楼地址",端口号是 "房间号"------ 数据送到大楼后,靠端口号找到要进的房间(应用程序)。
- 端口号分类(按范围划分)
| 端口范围 | 名称 | 用途 | 示例 |
|---|---|---|---|
| 0 ~ 1023 | 知名端口(Well-known Ports) | 系统 / 标准服务专用,需要 root 权限才能绑定 | 22(SSH)、80(HTTP)、443(HTTPS)、53(DNS) |
| 1024 ~ 49151 | 注册端口(Registered Ports) | 通用应用程序使用,无需 root 权限 | 3306(MySQL)、6379(Redis)、8080(Tomcat / 自定义服务) |
| 49152 ~ 65535 | 动态 / 私有端口(Ephemeral Ports) | 客户端临时使用,连接建立后自动分配 | 客户端 connect 时,系统自动分配一个此范围端口 |
0.3理解**"端口号"和"进程ID"**
端口号 和 进程 ID 的区别
- 一句话区别
- PID(进程 ID):系统用来识别 "哪个程序在跑"
- Port(端口号):网络用来识别 "数据要给哪个服务"
一个管系统内部 ,一个管网络通信。
- 超形象比喻
你的电脑 = 一栋大楼
- IP = 大楼地址
- PID = 公司员工编号(系统内部用)
- Port = 公司门牌号(网络外面找过来用)
流程:
-
外面的人想访问你的服务
-
先通过 IP 找到大楼
-
再通过 端口号 找到对应服务的 "门"
-
系统内部通过 PID 找到真正处理数据的进程
-
核心区别(重点)
① 作用不同
-
**PID:**给操作系统看的。用来区分:
- 浏览器
- 你的 TCP 服务端
- 你的 TCP 客户端
-
Port: 给网络数据包看的。用来区分:
- HTTP 服务
- SSH 远程
- 你的 8080 服务端
② 范围不同
- PID: 每次运行程序都会变,系统自动分配
- Port: 你自己固定写死(比如 8080)
③ 一个端口 对应 一个进程
一个端口同一时间只能被一个进程占用。
- 端口 8080 → 你的服务端程序(PID=1234)
但:一个进程可以占用多个端口
- 一个服务可以同时开 80、443、8080
④ 谁来分配?
- PID:操作系统自动分配
- Port:程序员自己指定
- 和你写的 TCP 服务端关系(最重要)
你写的:
#define PORT 8080
运行后:
- 系统给它一个 PID(比如 5678)
- 程序占用 端口 8080
外部客户端连接:
- 只需要知道 IP:8080
- 不需要知道 PID!
系统内部:
- 看到 8080 端口 → 找到对应 PID → 交给你的服务端处理
用一句精炼点的话说就是客户端拿着ip地址和端口号,IP地址找到我们访问的主机(服务器),然后在操作系统中有一张,端口号映射进程pid的哈希表,你拿着端口号就能找到这个进程的pid,从而找到这个进程,反之也一样,但是我们的代码中客户端常常不用bind端口号,那么这个端口号从哪里来呢?
服务端能找到客户端,靠的就是:
客户端的 IP + 操作系统自动分配的临时端口号, 在 TCP 三次握手时,自动发给服务端。
-
客户端没有 bind 端口 客户端代码里确实 不写 bind。
-
操作系统自动分配临时端口 当客户端调用
connect()时,内核会:- 自动分配一个 动态端口(49152~65535)
- 自动绑定到客户端的 socket 上
-
TCP 连接时,客户端会把自己的「源 IP + 源端口」发给服务端 这是 TCP 协议强制规定 的:每个 TCP 报文头里都有:
- 源 IP
- 源端口
- 目的 IP
- 目的端口
-
服务端 accept () 的时候,内核就拿到了客户端的 IP + 端口
accept(server_fd, (struct sockaddr*)&client_addr, &len);client_addr里就存着:- 客户端 IP
- 客户端自动分配的端口
-
服务端要回消息时直接用这个 客户端 IP + 客户端端口 发回去就行。
0.4理解源端口号和目的端口号
源端口 = 我是谁
目的端口 = 我要找谁
- 客户端端口:自动分配,叫「临时端口 / 源端口」
- 客户端 不 bind
- 调用
connect()时,系统自动给一个端口(49152~65535) - 这个端口就是 客户端的源端口
- 服务端端口:固定写死,叫「目的端口」
- 服务端
bind(8080) - 客户端要访问它,目的端口必须写 8080
- TCP 报文里永远带这 4 个东西
- 源 IP
- 源端口
- 目的 IP
- 目的端口
服务端就是靠 源 IP + 源端口 找到客户端并回复数据。
1.认识TCP协议
TCP 是一种 面向连接、可靠、基于字节流的传输层协议。
- 面向连接
- 必须先建立连接(三次握手),才能通信。
- 必须断开连接(四次挥手)。
- 就像打电话:先拨号接通 → 说话 → 挂电话。
- 可靠传输
- 不丢包
- 不乱序
- 损坏重传
- 确认应答(ACK)
你写代码时只管 send /read,不用管丢没丢。
- 基于字节流
- 数据像水流一样连续传输
- 没有消息边界
- 所以不会自动加
\0 - 所以可能一次发、多次收
- 全双工
- 双方可以同时收发
- 不需要 "你发完我才能发"
- 通过四元组唯一标识一条连接
源 IP + 源端口 + 目的 IP + 目的端口
2.认识UDP协议
UDP 是一种 无连接、不可靠、基于数据报的传输层协议。
-
无连接 不用三次握手,想发就发,不管对方在不在。
-
不可靠
- 不保证一定送到
- 不保证顺序
- 不保证不丢包发出去就不管了。
-
基于数据报
- 发一次,收一次
- 有明确边界
- 不会粘包(和 TCP 最大区别)
-
速度快、开销小 没有确认、重传、排序,所以比 TCP 快很多。
-
一对一、一对多、多对一都支持支持广播、多播,TCP 做不到。
3.网络字节序
发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;
接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址.
TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节.
不管这台主机是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据;
如果当前发送主机是小端, 就需要先将数据转成大端; 否则就忽略, 直接发送即可;

- 大端序(big-endian):高位字节存放在低地址,低位字节存放在高地址(符合人类阅读习惯)。
- 小端序(little-endian):低位字节存放在低地址,高位字节存放在高地址(符合计算机存储习惯)。
✏️ 数据拆分
0x1234abcd 是 32 位整数,按字节拆分:
- 高位字节 → 低位字节:
0x12、0x34、0xab、0xcd
🧮 内存布局计算
| 内存地址 | 大端序(big-endian) | 小端序(little-endian) |
|---|---|---|
0x0000 |
最高位 0x12 |
最低位 0xcd |
0x0001 |
次高位 0x34 |
次低位 0xab |
0x0002 |
次低位 0xab |
次高位 0x34 |
0x0003 |
最低位 0xcd |
最高位 0x12 |
✅ 最终结果
- 大端序 :
0x0000=0x12,0x0001=0x34,0x0002=0xab,0x0003=0xcd - 小端序 :
0x0000=0xcd,0x0001=0xab,0x0002=0x34,0x0003=0x12
💡 记忆口诀
- 大端:高字节 → 低地址(和数字书写顺序一致)
- 小端:低字节 → 低地址(和数字书写顺序相反)
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换

| 函数 | 作用 | 适用场景 |
|---|---|---|
htons() |
主机字节序 → 网络字节序(16 位) | 端口号(如 8080) |
htonl() |
主机字节序 → 网络字节序(32 位) | IP 地址(32 位整数) |
ntohs() |
网络字节序 → 主机字节序(16 位) | 解析客户端端口 |
ntohl() |
网络字节序 → 主机字节序(32 位) | 解析客户端 IP |
这些函数名很好记,h表示host,n表示network,l表示32位长整数,s表示16位短整数。
例如htonl表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。
如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回 ;
如果主机是大端字节序,这些 函数不做转换,将参数原封不动地返回。
4.socket****编程接口
4.1socket常见API
cpp
// 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器)
int socket(int domain, int type, int protocol);
// 绑定端口号 (TCP/UDP, 服务器)
int bind(int socket, const struct sockaddr *address,
socklen_t address_len);
// 开始监听socket (TCP, 服务器)
int listen(int socket, int backlog);
// 接收请求 (TCP, 服务器)
int accept(int socket, struct sockaddr* address,
socklen_t* address_len);
// 建立连接 (TCP, 客户端)
int connect(int sockfd, const struct sockaddr *addr,
socklen_t addrlen);
socket()- 创建通信的 "文件句柄"
作用
创建一个套接字(socket)文件描述符,是所有网络通信的起点(客户端 / 服务端都要先调用)。
原型
int socket(int domain, int type, int protocol);
核心参数
| 参数 | 常用值 | 含义 |
|---|---|---|
domain |
AF_INET |
IPv4 协议(几乎 99% 场景用这个) |
type |
SOCK_STREAM |
TCP 类型(面向连接、可靠)SOCK_DGRAM → UDP 类型 |
protocol |
0 |
自动匹配 type 对应的默认协议(TCP/UDP) |
返回值
- 成功:返回非负整数(socket 文件描述符,和普通文件 fd 一样用);
- 失败:返回
-1,设置errno。
示例(TCP)
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) {
perror("socket failed");
exit(EXIT_FAILURE);
}
bind()- 服务端 "绑定端口 + IP"
作用
将 socket 绑定到固定的 IP + 端口号(仅服务端需要,客户端不需要),告诉操作系统:"这个 socket 要监听这个端口的连接"。
原型
int bind(int socket, const struct sockaddr *address, socklen_t address_len);
核心参数
| 参数 | 说明 |
|---|---|
socket |
socket() 返回的文件描述符 |
address |
指向 sockaddr_in 结构体的指针(需强转),包含要绑定的 IP 和端口 |
address_len |
结构体长度(sizeof(struct sockaddr_in)) |
关键注意点
- 服务端绑定 IP 通常设为
INADDR_ANY(监听本机所有网卡); - 端口必须转网络字节序 (
htons(PORT));
示例
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有IP
addr.sin_port = htons(8080); // 端口转网络字节序
if (bind(sockfd, (struct sockaddr*)&addr, sizeof(addr)) == -1) {
perror("bind failed");
close(sockfd);
exit(EXIT_FAILURE);
}
listen()- 服务端 "开始监听端口"
作用
将 socket 从 "主动态" 转为 "被动态",开始监听绑定的端口,等待客户端连接(仅 TCP 服务端需要,UDP / 客户端不用)。
原型
int listen(int socket, int backlog);
核心参数
| 参数 | 说明 |
|---|---|
socket |
已绑定的 socket 描述符 |
backlog |
半连接队列长度(如 5/10,表示最多同时等待 5 个未完成的连接) |
返回值
- 成功:
0;失败:-1。
示例
if (listen(sockfd, 5) == -1) {
perror("listen failed");
close(sockfd);
exit(EXIT_FAILURE);
}
accept()- 服务端 "接收客户端连接"
作用
阻塞等待客户端的连接请求,建立连接后返回新的 socket 描述符(专门用于和这个客户端通信),原 socket 继续监听新连接。
原型
int accept(int socket, struct sockaddr* address, socklen_t* address_len);
核心参数
| 参数 | 说明 |
|---|---|
socket |
监听的 socket 描述符(listen() 后的 fd) |
address |
输出参数,存储客户端的 IP + 端口 (sockaddr_in 结构体) |
address_len |
输入输出参数,传入结构体长度,返回实际长度 |
关键注意点
- 是阻塞函数(没有连接时会一直等);
- 返回的新 fd 才是和客户端通信的句柄,原监听 fd 不能收发数据;
示例
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
// 阻塞等待连接
int connfd = accept(sockfd, (struct sockaddr*)&client_addr, &client_len);
if (connfd == -1) {
perror("accept failed");
close(sockfd);
exit(EXIT_FAILURE);
}
// client_addr 里存着客户端的 IP 和端口(需用 ntohs/inet_ntoa 解析)
connect()- 客户端 "主动连接服务端"
作用
客户端主动向服务端的 IP + 端口 发起连接请求(TCP 三次握手的起点),仅客户端需要。
原型
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
核心参数
| 参数 | 说明 |
|---|---|
sockfd |
客户端的 socket 描述符 |
addr |
服务端的 sockaddr_in 结构体(包含服务端 IP + 端口) |
addrlen |
结构体长度 |
关键注意点
- 客户端不需要
bind(),调用connect()时系统会自动分配临时端口; - 服务端 IP / 端口 必须正确(端口转网络字节序);
示例
struct sockaddr_in serv_addr;
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(8080); // 服务端端口
// 转换服务端 IP 为网络字节序
inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr);
if (connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1) {
perror("connect failed");
close(sockfd);
exit(EXIT_FAILURE);
}
| 函数 | 服务端 | 客户端 | 核心作用 |
|---|---|---|---|
socket() |
✅ | ✅ | 创建通信句柄 |
bind() |
✅ | ❌ | 绑定固定端口 / IP |
listen() |
✅ | ❌ | 开始监听端口 |
accept() |
✅ | ❌ | 接收连接,返回通信 fd |
connect() |
❌ | ✅ | 主动连接服务端 |
5.sockaddr****结构
socket API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、IPv6,以及后面要讲的UNIX DomainSocket. 然而, 各种网络协议的地址格式并不相同

struct sockaddr------ 通用地址结构("父类")
- 作用 :作为所有地址结构的通用接口 ,用于函数参数的统一(比如
bind第二个参数类型是struct sockaddr*)。 - 结构 :
- 16 位地址类型(
sa_family_t):标识协议族(如AF_INET/AF_UNIX)。 - 14 字节地址数据:根据不同协议族存储具体地址信息(长度固定为 14 字节)。
- 16 位地址类型(
- 特点 :不直接使用,而是通过强制类型转换 来兼容具体协议的地址结构(如
sockaddr_in/sockaddr_un)。
struct sockaddr_in------ IPv4 地址结构(最常用)
-
作用 :专门用于 IPv4 网络通信 ,存储
IP地址 + 端口号 + 协议族。 -
结构 :
- 16 位地址类型:固定为
AF_INET(IPv4 协议族)。 - 16 位端口号:服务端监听 / 客户端连接的端口(必须转网络字节序
htons)。 - 32 位 IP 地址:IPv4 地址(如
127.0.0.1,需转网络字节序htonl或用inet_pton)。 - 8 字节填充:为了对齐内存,使
sockaddr_in总长度和sockaddr一致(16 字节)。
- 16 位地址类型:固定为
-
代码示例 :
struct sockaddr_in serv_addr; serv_addr.sin_family = AF_INET; // 地址类型 serv_addr.sin_port = htons(8080); // 端口号(转网络字节序) serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // IP地址(监听所有网卡)
struct sockaddr_un------ Unix 域套接字地址结构
- 作用 :用于 本地进程间通信(IPC),不经过网络协议栈,效率极高。
- 结构 :
- 16 位地址类型:固定为
AF_UNIX(或AF_LOCAL)。 - 108 字节路径名:本地文件系统路径(如
/tmp/my_socket),作为通信的 "地址"。
- 16 位地址类型:固定为
- 特点:仅用于同一台主机内的进程通信,没有 IP 和端口的概念。
🔗 三者关系与使用技巧
| 结构体 | 协议族 | 用途 | 函数参数处理 |
|---|---|---|---|
sockaddr |
通用 | 函数参数统一接口 | 作为 bind/connect/accept 的参数类型,需强转 |
sockaddr_in |
AF_INET |
IPv4 网络通信 | 定义后强转为 sockaddr* 传入函数 |
sockaddr_un |
AF_UNIX |
本地进程通信 | 定义后强转为 sockaddr* 传入函数 |
💡 核心总结
sockaddr是 "壳",sockaddr_in/sockaddr_un是 "具体内容" :函数只认sockaddr*,但实际存储的是具体协议的地址结构。- IPv4 编程必用
sockaddr_in:它是你写 TCP/UDP 服务端 / 客户端时,填充地址信息的核心结构体。 - 强制类型转换是关键 :定义
sockaddr_in后,必须转成(struct sockaddr*)才能传给bind/connect等函数。
5.1sockaddr****结构
5.2sockaddr_in****结构

虽然socket api的接口是sockaddr, 但是我们真正在基于IPv4编程时, 使用的数据结构是sockaddr_in; 这个结构里主要有三部分信息: 地址类型, 端口号, IP地址.
① __SOCKADDR_COMMON (sin_);
-
这是一个宏定义 ,展开后等价于:
sa_family_t sin_family; // 16位地址类型(协议族) -
作用:标识地址族,IPv4 必须赋值为
AF_INET。 -
对应通用结构
struct sockaddr的sa_family字段,保证结构兼容。
② in_port_t sin_port;
-
类型:
in_port_t(本质是uint16_t,16 位无符号整数)。 -
含义:端口号(0~65535)。
-
关键:必须转换为网络字节序(大端) ,用
htons()函数。serv_addr.sin_port = htons(8080); // 正确 // serv_addr.sin_port = 8080; // 错误(字节序不对)
③ struct in_addr sin_addr;
- 类型:
struct in_addr,内部只包含一个uint32_t s_addr。 - 含义:32 位 IPv4 地址 (如
127.0.0.1)。 - 赋值方式:
- 服务端:
htonl(INADDR_ANY)(监听所有网卡) - 客户端:
inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr)
- 服务端:
- 关键:IP 地址也必须是网络字节序。
④ unsigned char sin_zero[...]
-
作用:填充字节 ,让
struct sockaddr_in的总长度和struct sockaddr完全一致(16 字节)。 -
计算方式:
sizeof(struct sockaddr) - __SOCKADDR_COMMON_SIZE - sizeof(in_port_t) - sizeof(struct in_addr) -
注意:必须用
memset初始化为 0,否则可能包含脏数据:memset(&serv_addr, 0, sizeof(serv_addr));
🔗 与 struct sockaddr 的兼容关系
struct sockaddr_in |
struct sockaddr |
说明 |
|---|---|---|
sin_family |
sa_family |
完全对齐,16 位地址类型 |
sin_port + sin_addr + sin_zero |
sa_data[14] |
合并为 14 字节地址数据 |
-
总长度:两者都是 16 字节 ,所以可以安全地强制类型转换:
struct sockaddr_in serv_addr; bind(listen_fd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
5.3in_addr****结构

in_addr用来表示一个IPv4的IP地址. 其实就是一个32位的整数
6.简单的UDP网络程序
实现一个简单的英译汉的功能
封装****UdpSocket
udp_socket.hpp
cpp
#pragma once
// 标准库头文件
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <cassert>
#include <string>
// Linux 系统调用头文件
#include <unistd.h>
// 网络编程核心头文件
#include <sys/socket.h> // socket/bind/recvfrom/sendto 等函数
#include <netinet/in.h> // sockaddr_in 结构体、网络字节序转换
#include <arpa/inet.h> // inet_addr/inet_ntoa 等 IP 转换函数
// 类型别名:简化代码书写,兼容通用地址结构
typedef struct sockaddr sockaddr;
typedef struct sockaddr_in sockaddr_in;
// UDP 套接字封装类(面向对象风格,封装 UDP 核心操作)
class UdpSocket {
public:
// 构造函数:初始化套接字文件描述符为 -1(无效状态)
UdpSocket() : fd_(-1) {
}
// 创建 UDP 套接字
// 返回值:true-成功,false-失败
bool Socket() {
// socket 函数参数说明:
// AF_INET:IPv4 协议族
// SOCK_DGRAM:UDP 数据报类型(无连接、不可靠)
// 0:自动匹配 UDP 协议(IPPROTO_UDP)
fd_ = socket(AF_INET, SOCK_DGRAM, 0);
if (fd_ < 0) { // 创建失败(fd 为 -1)
perror("socket"); // 打印系统错误信息
return false;
}
return true;
}
// 关闭套接字
// 返回值:true-成功,false-失败(此处简化处理,close 失败也返回 true)
bool Close() {
close(fd_); // 关闭文件描述符,释放资源
fd_ = -1; // 重置为无效状态,避免重复关闭
return true;
}
// 绑定固定的 IP 和端口(UDP 服务端必调,客户端可选)
// 参数:
// ip:要绑定的 IP 地址(如 "0.0.0.0" 监听所有网卡,"127.0.0.1" 仅本地)
// port:要绑定的端口号(主机字节序)
// 返回值:true-成功,false-失败
bool Bind(const std::string& ip, uint16_t port) {
// 初始化 IPv4 地址结构体
sockaddr_in addr;
memset(&addr, 0, sizeof(addr)); // 清空结构体,避免脏数据
addr.sin_family = AF_INET; // 地址族:IPv4
// 转换 IP 地址:字符串 → 网络字节序的 32 位整数
addr.sin_addr.s_addr = inet_addr(ip.c_str());
// 转换端口号:主机字节序 → 网络字节序(UDP 必须转)
addr.sin_port = htons(port);
// 绑定套接字到指定 IP+端口
// 注意:sockaddr_in 需强转为通用 sockaddr* 类型
int ret = bind(fd_, (sockaddr*)&addr, sizeof(addr));
if (ret < 0) { // 绑定失败(如端口被占用)
perror("bind");
return false;
}
return true;
}
// 接收 UDP 数据(带客户端地址信息)
// 参数:
// buf:输出参数,存储接收到的数据
// ip:输出参数,可选,存储发送方的 IP 地址
// port:输出参数,可选,存储发送方的端口号
// 返回值:true-成功,false-失败
bool RecvFrom(std::string* buf, std::string* ip = NULL, uint16_t* port = NULL) {
char tmp[1024 * 10] = {0}; // 临时缓冲区(10KB),存储原始数据
sockaddr_in peer; // 存储发送方(对端)的地址信息
socklen_t len = sizeof(peer);// 地址结构体长度(输入输出参数)
// recvfrom 函数参数说明:
// fd_:UDP 套接字描述符
// tmp:接收缓冲区
// sizeof(tmp)-1:预留 1 字节避免越界,兼容字符串结束符
// 0:默认标志(无特殊行为)
// (sockaddr*)&peer:输出参数,接收对端地址
// &len:输入输出参数,传入结构体长度,返回实际长度
ssize_t read_size = recvfrom(fd_, tmp, sizeof(tmp) - 1, 0, (sockaddr*)&peer, &len);
if (read_size < 0) { // 接收失败(如套接字错误)
perror("recvfrom");
return false;
}
// 将接收到的原始数据赋值给输出参数 buf
buf->assign(tmp, read_size);
// 如果需要返回对端 IP,转换为字符串格式(网络字节序 → 点分十进制)
if (ip != NULL) {
*ip = inet_ntoa(peer.sin_addr);
}
// 如果需要返回对端端口,转换为主机字节序(网络字节序 → 主机字节序)
if (port != NULL) {
*port = ntohs(peer.sin_port);
}
return true;
}
// 发送 UDP 数据(指定目标地址)
// 参数:
// buf:要发送的数据
// ip:目标主机的 IP 地址(点分十进制)
// port:目标主机的端口号(主机字节序)
// 返回值:true-成功,false-失败
bool SendTo(const std::string& buf, const std::string& ip, uint16_t port) {
// 初始化目标地址结构体
sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET; // IPv4 协议族
// 转换目标 IP:字符串 → 网络字节序
addr.sin_addr.s_addr = inet_addr(ip.c_str());
// 转换目标端口:主机字节序 → 网络字节序
addr.sin_port = htons(port);
// sendto 函数参数说明:
// fd_:UDP 套接字描述符
// buf.data():要发送的数据起始地址
// buf.size():要发送的数据长度
// 0:默认标志
// (sockaddr*)&addr:目标地址
// sizeof(addr):地址结构体长度
ssize_t write_size = sendto(fd_, buf.data(), buf.size(), 0, (sockaddr*)&addr, sizeof(addr));
if (write_size < 0) { // 发送失败(如地址错误、套接字关闭)
perror("sendto");
return false;
}
return true;
}
private:
int fd_; // UDP 套接字文件描述符(核心成员,-1 表示未初始化)
};
UDP****通用服务器
udp_server.hpp
cpp
#pragma once
// 引入封装好的 UDP 套接字类(底层系统调用封装)
#include "udp_socket.hpp"
// ========== 回调函数类型定义 ==========
// C 式写法(仅支持普通函数指针)
// typedef void (*Handler)(const std::string& req, std::string* resp);
// C++11 式写法(推荐):兼容函数指针、仿函数、lambda 表达式
// 定义回调函数类型 Handler:
// 输入:const std::string& req - 客户端请求数据
// 输出:std::string* resp - 服务器响应数据(通过指针输出)
// 返回值:void
#include <functional>
typedef std::function<void (const std::string&, std::string* resp)> Handler;
// UDP 服务器封装类(基于 UdpSocket 封装,提供完整的服务器逻辑)
class UdpServer {
public:
// 构造函数:初始化服务器,创建 UDP 套接字
// 注意:assert 断言失败会直接终止程序,仅用于调试阶段
UdpServer() {
// 调用 UdpSocket 的 Socket 方法创建套接字,断言保证创建成功
assert(sock_.Socket());
}
// 析构函数:服务器销毁时自动关闭套接字,释放资源
~UdpServer() {
sock_.Close();
}
// 启动 UDP 服务器(核心接口)
// 参数:
// ip:服务器绑定的 IP 地址(如 "0.0.0.0" 监听所有网卡,"127.0.0.1" 仅本地)
// port:服务器绑定的端口号(主机字节序)
// handler:回调函数,用于处理客户端请求并生成响应(业务逻辑解耦)
// 返回值:true-启动成功(理论上不会返回,因为事件循环是死循环),false-绑定失败
bool Start(const std::string& ip, uint16_t port, Handler handler) {
// 2. 绑定端口号(UDP 服务器必须绑定固定端口,否则客户端无法找到服务器)
bool ret = sock_.Bind(ip, port);
if (!ret) { // 绑定失败(如端口被占用)
return false;
}
printf("UDP Server start success! [ip: %s, port: %d]\n", ip.c_str(), port);
// 3. 进入事件循环(死循环):持续接收客户端请求并处理
for (;;) {
// 4. 读取客户端的请求数据
std::string req; // 存储客户端请求内容
std::string remote_ip; // 存储客户端的 IP 地址
uint16_t remote_port = 0; // 存储客户端的端口号(系统自动分配)
// 调用 RecvFrom 接收数据,失败则跳过本次循环(继续等待下一个请求)
bool ret = sock_.RecvFrom(&req, &remote_ip, &remote_port);
if (!ret) {
continue;
}
// 5. 调用回调函数处理请求,生成响应(业务逻辑与网络层解耦)
std::string resp; // 存储服务器要返回的响应数据
handler(req, &resp); // 上层传入的业务逻辑:req → resp
// 6. 将响应发送回客户端(根据 RecvFrom 获取的客户端 IP+端口)
sock_.SendTo(resp, remote_ip, remote_port);
// 日志打印:记录客户端地址、请求和响应,便于调试
printf("[%s:%d] req: %s, resp: %s\n",
remote_ip.c_str(), remote_port,
req.c_str(), resp.c_str());
}
// 理论上不会执行到这里(因为 for(;;) 是死循环)
sock_.Close();
return true;
}
private:
UdpSocket sock_; // 封装的 UDP 套接字对象(底层网络操作)
};
实现英译汉服务器
以上代码是对 udp 服务器进行通用接口的封装. 基于以上封装, 实现一个查字典的服务器就很容易了.
dict_server.cc
cpp
// 引入封装好的 UDP 服务器类(网络层)
#include "udp_server.hpp"
// 标准库:无序哈希表(字典存储)、输入输出
#include <unordered_map>
#include <iostream>
// 全局字典:存储单词-释义的映射(模拟业务数据)
// 注意:全局变量仅用于演示,生产环境建议封装为类成员
std::unordered_map<std::string, std::string> g_dict;
// 字典翻译业务逻辑函数(符合 UdpServer 要求的 Handler 回调类型)
// 参数:
// req:客户端请求的单词(如 "hello")
// resp:输出参数,返回翻译结果(如 "你好" 或 "未查到!")
void Translate(const std::string& req, std::string* resp) {
// 在哈希表中查找请求的单词
auto it = g_dict.find(req);
// 未找到:返回提示信息
if (it == g_dict.end()) {
*resp = "未查到!";
return;
}
// 找到:返回对应的释义
*resp = it->second;
}
// 主函数:程序入口,负责初始化数据、解析参数、启动服务器
// 参数:
// argc:命令行参数个数
// argv:命令行参数数组(argv[0] 是程序名,argv[1] 是 IP,argv[2] 是端口)
int main(int argc, char* argv[]) {
// 1. 校验命令行参数(必须传入 IP 和端口)
if (argc != 3) {
// 提示正确的使用方式:./dict_server [ip] [port]
printf("Usage ./dict_server [ip] [port]\n");
return 1; // 非 0 返回值表示程序异常退出
}
// 2. 初始化字典数据(模拟业务配置)
g_dict.insert(std::make_pair("hello", "你好"));
g_dict.insert(std::make_pair("world", "世界"));
g_dict.insert(std::make_pair("c++", "最好的编程语言"));
g_dict.insert(std::make_pair("bit", "特别NB"));
printf("字典数据初始化完成,共 %lu 个单词\n", g_dict.size());
// 3. 创建 UDP 服务器对象并启动
UdpServer server;
// 调用 Start 启动服务器:
// argv[1]:传入的 IP 地址(字符串)
// atoi(argv[2]):将端口字符串转为整数(主机字节序)
// Translate:业务回调函数(处理单词翻译)
server.Start(argv[1], atoi(argv[2]), Translate);
// 理论上不会执行到这里(因为 server.Start 是死循环)
return 0;
}
UDP****通用客户端
udp_client.hpp
cpp
#pragma once
// 引入封装好的 UDP 套接字类(底层网络操作封装)
#include "udp_socket.hpp"
// UDP 客户端封装类(面向对象风格,简化与 UDP 服务器的交互)
class UdpClient {
public:
// 构造函数:初始化客户端,指定要连接的服务器地址
// 参数:
// ip:服务器的 IP 地址(如 "127.0.0.1"、"192.168.1.100")
// port:服务器的端口号(主机字节序)
UdpClient(const std::string& ip, uint16_t port) : ip_(ip), port_(port) {
// 创建 UDP 套接字,assert 断言保证创建成功(调试阶段用)
// 客户端无需绑定端口,系统会自动分配临时端口
assert(sock_.Socket());
}
// 析构函数:客户端销毁时自动关闭套接字,释放资源
~UdpClient() {
sock_.Close();
}
// 接收服务器的响应数据(仅读取数据,不关心对端地址)
// 参数:
// buf:输出参数,存储接收到的响应数据
// 返回值:true-接收成功,false-接收失败
bool RecvFrom(std::string* buf) {
// 调用底层 UdpSocket 的 RecvFrom,仅传递数据缓冲区(忽略对端 IP/端口)
return sock_.RecvFrom(buf);
}
// 向服务器发送请求数据(使用构造函数指定的服务器 IP+端口)
// 参数:
// buf:要发送的请求数据(如查询的单词 "hello")
// 返回值:true-发送成功,false-发送失败
bool SendTo(const std::string& buf) {
// 调用底层 UdpSocket 的 SendTo,自动填充服务器的 IP 和端口
return sock_.SendTo(buf, ip_, port_);
}
private:
UdpSocket sock_; // 封装的 UDP 套接字对象(底层网络操作)
std::string ip_; // 服务器的 IP 地址(客户端固定向该 IP 发送数据)
uint16_t port_; // 服务器的端口号(客户端固定向该端口发送数据)
};
实现英译汉客户端
cpp
// 引入封装好的 UDP 客户端类(网络层)
#include "udp_client.hpp"
// 标准库:输入输出(控制台交互)
#include <iostream>
// 主函数:程序入口,负责解析参数、创建客户端、与服务器交互
// 参数:
// argc:命令行参数个数
// argv:命令行参数数组(argv[0] 是程序名,argv[1] 是服务器 IP,argv[2] 是服务器端口)
int main(int argc, char* argv[]) {
// 1. 校验命令行参数(必须传入服务器 IP 和端口)
if (argc != 3) {
// 提示正确的使用方式:./dict_client [服务器IP] [服务器端口]
printf("Usage ./dict_client [ip] [port]\n");
return 1; // 非 0 返回值表示程序异常退出
}
// 2. 创建 UDP 客户端对象
// argv[1]:服务器 IP 地址(字符串)
// atoi(argv[2]):将端口字符串转为整数(主机字节序)
UdpClient client(argv[1], atoi(argv[2]));
printf("客户端启动成功,已连接服务器 %s:%d\n", argv[1], atoi(argv[2]));
// 3. 进入交互循环:持续接收用户输入,查询单词
for (;;) {
// 3.1 提示用户输入要查询的单词
std::string word;
std::cout << "请输入您要查的单词: ";
// 读取用户输入(空格/回车分隔,仅读取单个单词)
std::cin >> word;
// 3.2 处理输入异常(如用户输入 Ctrl+D 终止输入)
if (!std::cin) {
std::cout << "Good Bye" << std::endl;
break; // 退出循环,结束程序
}
// 3.3 向服务器发送查询请求(单词)
client.SendTo(word);
// 3.4 接收服务器的响应结果
std::string result;
client.RecvFrom(&result);
// 3.5 打印查询结果
std::cout << word << " 意思是 " << result << std::endl;
}
// 4. 程序正常退出(客户端对象析构时自动关闭套接字)
return 0;
}
7.地址转换函数
本节只介绍基于IPv4的socket网络编程,sockaddr_in中的成员struct in_addr sin_addr表示32位 的IP 地址但是我们通常用点分十进制的字符串表示IP 地址,以下函数可以在字符串表示 和in_addr表示之间转换;
一、核心函数对比(字符串 ↔ in_addr)
| 功能 | 推荐函数(兼容 IPv4/IPv6) | 老旧函数(仅 IPv4) |
|---|---|---|
| 字符串 → 网络字节序 | inet_pton() |
inet_addr() |
| 网络字节序 → 字符串 | inet_ntop() |
inet_ntoa() |
inet_pton()------ 点分十进制字符串 → in_addr(网络字节序)
函数原型
int inet_pton(int af, const char *src, void *dst);
参数解析
| 参数 | 说明 |
|---|---|
af |
地址族:AF_INET(IPv4)/ AF_INET6(IPv6) |
src |
输入:点分十进制 IP 字符串(如 "127.0.0.1") |
dst |
输出:指向 in_addr(IPv4)/ in6_addr(IPv6)的指针,存储转换后的网络字节序 IP |
返回值
1:转换成功;0:src不是有效的 IP 字符串;-1:参数错误(如af不支持)。
代码示例(IPv4)
struct sockaddr_in addr;
// 将 "127.0.0.1" 转为网络字节序,存入 addr.sin_addr
int ret = inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
if (ret != 1) {
perror("inet_pton failed");
return false;
}
inet_ntop()------ in_addr(网络字节序)→ 点分十进制字符串
函数原型
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
参数解析
| 参数 | 说明 |
|---|---|
af |
地址族:AF_INET(IPv4)/ AF_INET6(IPv6) |
src |
输入:指向 in_addr(IPv4)/ in6_addr(IPv6)的指针 |
dst |
输出:存储转换后的字符串缓冲区(如 char ip[INET_ADDRSTRLEN]) |
size |
缓冲区大小:INET_ADDRSTRLEN(IPv4,固定 16 字节)/ INET6_ADDRSTRLEN(IPv6) |
返回值
- 成功:返回
dst指针(字符串首地址); - 失败:返回
NULL,设置errno。
代码示例(IPv4)
struct sockaddr_in peer_addr;
char ip[INET_ADDRSTRLEN] = {0};
// 将 peer_addr.sin_addr(网络字节序)转为字符串 "xxx.xxx.xxx.xxx"
const char *ip_str = inet_ntop(AF_INET, &peer_addr.sin_addr, ip, sizeof(ip));
if (ip_str == NULL) {
perror("inet_ntop failed");
return false;
}
printf("客户端 IP:%s\n", ip_str);
二、为什么推荐 inet_pton/inet_ntop?
- 兼容 IPv6
老旧的 inet_addr/inet_ntoa 仅支持 IPv4,而 inet_pton/inet_ntop 可无缝切换到 IPv6(只需将 AF_INET 改为 AF_INET6,参数改为 in6_addr)。
- 更安全
inet_ntoa返回的是静态缓冲区,多线程 / 多进程调用会覆盖数据;inet_ntop使用用户提供的缓冲区,无线程安全问题。
- 错误处理更完善
inet_pton 可明确区分 "无效 IP" 和 "参数错误",而 inet_addr 失败仅返回 INADDR_NONE(无法区分 255.255.255.255 和真正的错误)。
三、老旧函数对比(了解即可,不推荐使用)
-
inet_addr()------ 字符串 → in_addr// 等价于 inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr)
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
// 缺点:失败返回 INADDR_NONE(0xFFFFFFFF),无法区分 255.255.255.255 -
inet_ntoa()------ in_addr → 字符串// 等价于 inet_ntop(AF_INET, &peer_addr.sin_addr, ip, sizeof(ip))
char *ip_str = inet_ntoa(peer_addr.sin_addr);
// 缺点:返回静态缓冲区,多线程不安全
四、核心总结
- 字符串转 in_addr :优先用
inet_pton(AF_INET, 字符串, &sin_addr); - in_addr 转字符串 :优先用
inet_ntop(AF_INET, &sin_addr, 缓冲区, 缓冲区大小); - 关键宏 :
INET_ADDRSTRLEN:IPv4 字符串最大长度(16 字节),避免缓冲区溢出;
AF_INET:固定指定 IPv4 地址族,适配sockaddr_in。
man手册上说, inet_ntoa函数, 是把这个返回结果放到了静态存储区. 这个时候不需要我们手动进行释放.那么问题来了, 如果我们调用多次这个函数, 会有什么样的效果呢? 参见如下代码:

运行结果如下:

因为inet_ntoa把结果放到自己内部的一个静态存储区, 这样第二次调用时的结果会覆盖掉上一次的结果.
8.简单的TCP网络程序
和刚才UDP类似. 实现一个简单的英译汉的功能
8.1TCP socket API****详解
下面介绍程序中用到的socket API,这些函数都在sys/socket.h中
socket():

socket()打开一个网络通讯端口,如果成功的话,就像open()一样返回一个文件描述符;
应用程序可以像读写文件一样用read/write在网络上收发数据;
如果socket()调用出错则返回-1;
对于IPv4, family参数指定为AF_INET;
对于TCP协议,type参数指定为SOCK_STREAM, 表示面向流的传输协议
protocol参数的介绍从略,指定为0即可。
bind():

服务器程序所监听的网络地址和端口号通常是固定不变的,客户端程序得知服务器程序的地址和端口号后就可以向服务器发起连接; 服务器需要调用bind绑定一个固定的网络地址和端口号;
bind()成功返回0,失败返回-1。
bind()的作用是将参数sockfd和myaddr绑定在一起, 使sockfd这个用于网络通讯的文件描述符监听
myaddr所描述的地址和端口号;
前面讲过,struct sockaddr *是一个通用指针类型,myaddr参数实际上可以接受多种协议的sockaddr结构体,而它们的长度各不相同,所以需要第三个参数addrlen指定结构体的长度
我们的程序中对myaddr参数是这样初始化的:

- 将整个结构体清零;
- 设置地址类型为AF_INET;
- 网络地址为INADDR_ANY, 这个宏表示本地的任意IP地址,因为服务器可能有多个网卡,每个网卡也可能绑定多个IP 地址, 这样设置可以在所有的IP地址上监听,直到与某个客户端建立了连接时才确定下来到底用哪个IP 地址;
- 端口号为SERV_PORT, 我们定义为9999
listen():

listen()声明sockfd处于监听状态, 并且最多允许有backlog个客户端处于连接等待状态, 如果接收到更多的连接请求就忽略, 这里设置不会太大(一般是5), 具体细节同学们课后深入研究;
listen()成功返回0,失败返回-1;
accept();

三次握手完成后, 服务器调用accept()接受连接;
如果服务器调用accept()时还没有客户端的连接请求,就阻塞等待直到有客户端连接上来;
addr是一个传出参数,accept()返回时传出客户端的地址和端口号;
如果给addr 参数传NULL,表示不关心客户端的地址;
addrlen参数是一个传入传出参数(value-result argument), 传入的是调用者提供的, 缓冲区addr的长度以避免缓冲区溢出问题, 传出的是客户端地址结构体的实际长度(有可能没有占满调用者提供的缓冲区);
我们的服务器程序结构是这样的:

理解accecpt的返回值:
返回的新 fd 才是和客户端通信的句柄,原监听 fd 不能收发数据
connect

客户端需要调用connect()连接服务器;
connect和bind的参数形式一致, 区别在于bind的参数是自己的地址, 而connect的参数是对方的地址;
connect()成功返回0,出错返回-1;
8.2封装****TCP socket
tcp_socket.hpp
cpp
#pragma once
// 标准库头文件
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <string>
#include <cassert>
// Linux 系统调用头文件
#include <unistd.h>
#include <sys/socket.h> // socket/bind/listen/accept/recv/send 等核心函数
#include <netinet/in.h> // sockaddr_in 结构体、网络字节序转换
#include <arpa/inet.h> // inet_addr/inet_ntoa IP 地址转换
#include <fcntl.h> // 文件控制(此处未使用,预留)
// 类型别名:简化代码书写,兼容通用地址结构
typedef struct sockaddr sockaddr;
typedef struct sockaddr_in sockaddr_in;
// 宏定义:条件检查,失败则直接返回 false(简化错误处理)
// 用法:CHECK_RET(函数调用),函数返回 false 时终止当前函数
#define CHECK_RET(exp) if (!(exp)) { \
return false; \
}
// TCP 套接字封装类(面向对象风格,封装 TCP 核心操作)
// 特性:面向连接、可靠传输、字节流,支持服务端/客户端双场景
class TcpSocket {
public:
// 构造函数1:默认初始化,套接字 fd 置为 -1(无效状态)
TcpSocket() : fd_(-1) { }
// 构造函数2:通过已有 fd 初始化(用于 accept 后创建客户端套接字)
// 参数:fd - 已建立连接的套接字描述符
TcpSocket(int fd) : fd_(fd) { }
// 创建 TCP 套接字(服务端/客户端通用)
// 返回值:true-成功,false-失败
bool Socket() {
// socket 函数参数:
// AF_INET:IPv4 协议族
// SOCK_STREAM:TCP 字节流类型(面向连接、可靠)
// 0:自动匹配 TCP 协议(IPPROTO_TCP)
fd_ = socket(AF_INET, SOCK_STREAM, 0);
if (fd_ < 0) { // 创建失败(fd 为 -1)
perror("socket"); // 打印系统错误信息
return false;
}
printf("open fd = %d\n", fd_); // 调试日志:打印创建的 fd
return true;
}
// 关闭套接字(释放资源)
// 返回值:true-成功,false-失败(此处简化处理,close 失败也返回 true)
bool Close() const {
close(fd_); // 关闭文件描述符
printf("close fd = %d\n", fd_); // 调试日志:打印关闭的 fd
return true;
}
// 绑定固定的 IP 和端口(仅服务端需要)
// 参数:
// ip:要绑定的 IP 地址(如 "0.0.0.0" 监听所有网卡,"127.0.0.1" 仅本地)
// port:要绑定的端口号(主机字节序)
// 返回值:true-成功,false-失败
bool Bind(const std::string& ip, uint16_t port) const {
// 初始化 IPv4 地址结构体
sockaddr_in addr;
memset(&addr, 0, sizeof(addr)); // 清空结构体,避免脏数据
addr.sin_family = AF_INET; // 地址族:IPv4
// 转换 IP 地址:字符串 → 网络字节序的 32 位整数
addr.sin_addr.s_addr = inet_addr(ip.c_str());
// 转换端口号:主机字节序 → 网络字节序(TCP 必须转)
addr.sin_port = htons(port);
// 绑定套接字到指定 IP+端口
// 注意:sockaddr_in 需强转为通用 sockaddr* 类型
int ret = bind(fd_, (sockaddr*)&addr, sizeof(addr));
if (ret < 0) { // 绑定失败(如端口被占用)
perror("bind");
return false;
}
return true;
}
// 监听端口(仅 TCP 服务端需要,将套接字转为被动监听状态)
// 参数:num - 半连接队列长度(如 5/10,最多同时等待 num 个未完成连接)
// 返回值:true-成功,false-失败
bool Listen(int num) const {
int ret = listen(fd_, num);
if (ret < 0) { // 监听失败
perror("listen");
return false;
}
return true;
}
// 接收客户端连接(仅 TCP 服务端需要,阻塞等待)
// 参数:
// peer:输出参数,用于存储新建立连接的客户端套接字
// ip:输出参数,可选,存储客户端的 IP 地址
// port:输出参数,可选,存储客户端的端口号
// 返回值:true-成功,false-失败
bool Accept(TcpSocket* peer, std::string* ip = NULL, uint16_t* port = NULL) const {
sockaddr_in peer_addr; // 存储客户端的地址信息
socklen_t len = sizeof(peer_addr);// 地址结构体长度(输入输出参数)
// accept 函数:阻塞等待客户端连接,成功返回新的 fd(用于和该客户端通信)
int new_sock = accept(fd_, (sockaddr*)&peer_addr, &len);
if (new_sock < 0) { // 接收连接失败
perror("accept");
return false;
}
printf("accept fd = %d\n", new_sock); // 调试日志:打印新客户端 fd
// 初始化客户端套接字对象(使用新创建的 fd)
peer->fd_ = new_sock;
// 可选:返回客户端 IP 地址(网络字节序 → 点分十进制字符串)
if (ip != NULL) {
*ip = inet_ntoa(peer_addr.sin_addr);
}
// 可选:返回客户端端口号(网络字节序 → 主机字节序)
if (port != NULL) {
*port = ntohs(peer_addr.sin_port);
}
return true;
}
// 接收数据(TCP 字节流,需处理粘包问题)
// 参数:buf - 输出参数,存储接收到的数据
// 返回值:true-成功,false-失败/连接关闭
bool Recv(std::string* buf) const {
buf->clear(); // 清空缓冲区,避免旧数据干扰
char tmp[1024 * 10] = {0}; // 临时缓冲区(10KB)
// 【重要注意】TCP 是字节流,一次 recv 不一定能读完所有数据
// 如需确保读完,需循环 recv 或使用 MSG_WAITALL 标志(参考 man recv)
ssize_t read_size = recv(fd_, tmp, sizeof(tmp), 0);
if (read_size < 0) { // 接收失败(如套接字错误)
perror("recv");
return false;
}
if (read_size == 0) { // 连接关闭(客户端主动断开)
return false;
}
// 将接收到的原始数据赋值给输出参数
buf->assign(tmp, read_size);
return true;
}
// 发送数据(TCP 字节流,需处理粘包问题)
// 参数:buf - 要发送的数据
// 返回值:true-成功,false-失败
bool Send(const std::string& buf) const {
// send 函数:发送数据,返回实际发送的字节数
ssize_t write_size = send(fd_, buf.data(), buf.size(), 0);
if (write_size < 0) { // 发送失败(如连接断开)
perror("send");
return false;
}
// 注意:write_size 可能小于 buf.size(),需循环发送确保全部发送(此处简化)
return true;
}
// 主动连接服务端(仅 TCP 客户端需要,触发三次握手)
// 参数:
// ip:服务端的 IP 地址
// port:服务端的端口号(主机字节序)
// 返回值:true-成功,false-失败
bool Connect(const std::string& ip, uint16_t port) const {
// 初始化服务端地址结构体
sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr(ip.c_str()); // 服务端 IP 转换
addr.sin_port = htons(port); // 服务端端口转换
// 发起连接请求(三次握手)
int ret = connect(fd_, (sockaddr*)&addr, sizeof(addr));
if (ret < 0) { // 连接失败(如服务端未启动、端口错误)
perror("connect");
return false;
}
return true;
}
// 获取套接字描述符(用于外部扩展,如设置非阻塞、多路复用)
// 返回值:当前套接字的 fd
int GetFd() const {
return fd_;
}
private:
int fd_; // TCP 套接字文件描述符(核心成员,-1 表示未初始化)
};
TCP****通用服务器
tcp_server.hpp
cpp
#pragma once
// C++11 函数对象:支持函数指针、仿函数、lambda,用于业务逻辑解耦
#include <functional>
// 引入封装好的 TCP 套接字类(底层网络操作)
#include "tcp_socket.hpp"
// 定义业务处理回调函数类型 Handler
// 参数:
// req:客户端发送的请求数据(输入)
// resp:服务器要返回的响应数据(输出,通过指针传递)
// 返回值:void
typedef std::function<void (const std::string& req, std::string* resp)> Handler;
// TCP 服务器封装类(基于 TcpSocket 封装,实现完整的 TCP 服务端逻辑)
// 特性:面向连接、单线程处理、一连接一循环读写
class TcpServer {
public:
// 构造函数:初始化服务器要绑定的 IP 和端口
// 参数:
// ip:服务器绑定的 IP 地址(如 "0.0.0.0" 监听所有网卡)
// port:服务器绑定的端口号(主机字节序)
TcpServer(const std::string& ip, uint16_t port) : ip_(ip), port_(port) {
}
// 启动 TCP 服务器(核心接口)
// 参数:handler - 业务处理回调函数(解耦网络层和业务层)
// 返回值:true-启动成功(理论上不会返回,因事件循环是死循环),false-初始化失败
bool Start(Handler handler) {
// 1. 创建监听套接字(TCP 服务端第一步)
CHECK_RET(listen_sock_.Socket());
// 2. 绑定固定 IP+端口(必须步骤,客户端通过该地址连接服务器)
CHECK_RET(listen_sock_.Bind(ip_, port_));
// 3. 监听端口:将套接字转为被动监听状态,等待客户端连接
// 参数 5:半连接队列长度,最多同时等待 5 个未完成的连接
CHECK_RET(listen_sock_.Listen(5));
printf("TCP Server start success! [ip: %s, port: %lu]\n", ip_.c_str(), port_);
// 4. 进入主事件循环:持续监听并处理客户端连接(死循环)
for (;;) {
// 5. 接收客户端连接(阻塞等待,有新连接时返回)
TcpSocket new_sock; // 存储新客户端的通信套接字
std::string client_ip; // 客户端 IP 地址
uint16_t client_port = 0; // 客户端端口号(系统自动分配)
// 接收失败则跳过本次循环,继续等待下一个连接
if (!listen_sock_.Accept(&new_sock, &client_ip, &client_port)) {
continue;
}
// 调试日志:客户端连接成功
printf("[client %s:%d] connect!\n", client_ip.c_str(), client_port);
// 6. 与该客户端的循环读写:持续处理该客户端的请求(一连接一循环)
for (;;) {
std::string req; // 存储客户端发送的请求数据
// 7. 读取客户端请求
bool ret = new_sock.Recv(&req);
// 读取失败(客户端断开/网络错误),终止与该客户端的通信
if (!ret) {
printf("[client %s:%d] disconnect!\n", client_ip.c_str(), client_port);
// 【关键】关闭客户端套接字,释放文件描述符(避免资源泄漏)
new_sock.Close();
break; // 退出当前客户端的读写循环,回到主循环等待新连接
}
// 8. 调用业务回调函数,根据请求生成响应(网络层与业务层解耦)
std::string resp;
handler(req, &resp);
// 9. 将响应写回客户端
new_sock.Send(resp);
// 调试日志:记录请求和响应
printf("[%s:%d] req: %s, resp: %s\n",
client_ip.c_str(), client_port,
req.c_str(), resp.c_str());
}
}
// 理论上不会执行到这里(主循环是死循环)
return true;
}
private:
TcpSocket listen_sock_; // 监听套接字(仅用于接收新连接,不负责通信)
std::string ip_; // 服务器绑定的 IP 地址
uint64_t port_; // 服务器绑定的端口号(注:建议改为 uint16_t,端口范围 0-65535)
};
英译汉服务器
cpp
// 标准库:无序哈希表(存储单词-释义映射,模拟字典数据)
#include <unordered_map>
// 引入封装好的 TCP 服务器类(网络层核心)
#include "tcp_server.hpp"
// 全局字典:存储单词到释义的映射(演示用,生产环境建议封装为类成员)
// unordered_map 保证 O(1) 时间复杂度的查询效率,适合字典场景
std::unordered_map<std::string, std::string> g_dict;
// 字典翻译业务逻辑函数(符合 TcpServer 要求的 Handler 回调类型)
// 参数:
// req:客户端发送的查询单词(如 "hello")
// resp:输出参数,返回翻译结果(如 "你好" 或 "未找到")
void Translate(const std::string& req, std::string* resp) {
// 在哈希表中查找请求的单词
auto it = g_dict.find(req);
// 未找到:返回提示信息
if (it == g_dict.end()) {
*resp = "未找到";
return;
}
// 找到:返回对应的释义
*resp = it->second;
return;
}
// 主函数:程序入口,负责参数校验、字典初始化、启动服务器
// 参数:
// argc:命令行参数个数
// argv:命令行参数数组(argv[0] = 程序名,argv[1] = 服务器IP,argv[2] = 服务器端口)
int main(int argc, char* argv[]) {
// 1. 校验命令行参数(必须传入 IP 和端口,否则提示用法并退出)
if (argc != 3) {
printf("Usage ./dict_server [ip] [port]\n");
return 1; // 非 0 返回值表示程序异常退出
}
// 2. 初始化字典数据(模拟业务配置,实际项目可从文件/数据库加载)
g_dict.insert(std::make_pair("hello", "你好"));
g_dict.insert(std::make_pair("world", "世界"));
g_dict.insert(std::make_pair("bit", "贼NB"));
printf("字典初始化完成,共加载 %lu 个单词\n", g_dict.size());
// 3. 创建 TCP 服务器对象(指定要绑定的 IP 和端口)
// argv[1]:服务器 IP 字符串(如 "0.0.0.0" 监听所有网卡)
// atoi(argv[2]):将端口字符串转为整数(主机字节序)
TcpServer server(argv[1], atoi(argv[2]));
// 4. 启动服务器,传入业务回调函数 Translate
// 启动后进入死循环:监听连接 → 接收客户端 → 处理查询请求
server.Start(Translate);
// 理论上不会执行到这里(server.Start 是死循环)
return 0;
}
TCP****通用客户端
tcp_client.hpp
cpp
#pragma once
// 引入封装好的 TCP 套接字类(底层网络操作封装)
#include "tcp_socket.hpp"
// TCP 客户端封装类(面向对象风格,简化与 TCP 服务器的交互)
// 特性:基于 TCP 面向连接的特性,一次连接可持续收发数据
class TcpClient {
public:
// 构造函数:初始化客户端,指定要连接的服务器地址并创建套接字
// 参数:
// ip:服务器的 IP 地址(如 "127.0.0.1"、"192.168.1.100")
// port:服务器的端口号(主机字节序)
TcpClient(const std::string& ip, uint16_t port) : ip_(ip), port_(port) {
// 【关键注意】TCP 客户端必须先创建套接字,再发起连接
// 客户端无需绑定端口,系统会自动分配临时端口(49152~65535)
sock_.Socket();
}
// 析构函数:客户端销毁时自动关闭套接字,释放资源
// 保证连接断开时资源正常释放,避免文件描述符泄漏
~TcpClient() {
sock_.Close();
}
// 主动连接服务器(触发 TCP 三次握手)
// 返回值:true-连接成功,false-连接失败(如服务端未启动、IP/端口错误)
bool Connect() {
// 调用底层 TcpSocket 的 Connect 方法,传入服务器 IP 和端口
return sock_.Connect(ip_, port_);
}
// 接收服务器的响应数据(TCP 字节流,需处理粘包问题)
// 参数:buf - 输出参数,存储接收到的响应数据
// 返回值:true-接收成功,false-接收失败/连接关闭
bool Recv(std::string* buf) {
// 复用底层 TcpSocket 的 Recv 方法,直接传递缓冲区
return sock_.Recv(buf);
}
// 向服务器发送请求数据(TCP 字节流,需处理粘包问题)
// 参数:buf - 要发送的请求数据(如查询的单词 "hello")
// 返回值:true-发送成功,false-发送失败/连接关闭
bool Send(const std::string& buf) {
// 复用底层 TcpSocket 的 Send 方法,直接传递要发送的数据
return sock_.Send(buf);
}
private:
TcpSocket sock_; // 封装的 TCP 套接字对象(底层网络操作核心)
std::string ip_; // 服务器的 IP 地址(客户端固定向该 IP 发起连接)
uint16_t port_; // 服务器的端口号(客户端固定向该端口发起连接)
};
英译汉客户端
dict_client.cc
cpp
// 引入封装好的 TCP 客户端类(底层网络操作封装)
#include "tcp_client.hpp"
// 标准库:输入输出(控制台交互)
#include <iostream>
// 主函数:程序入口,负责参数解析、创建客户端、与服务器持续交互
// 参数:
// argc:命令行参数个数
// argv:命令行参数数组(argv[0]=程序名,argv[1]=服务器IP,argv[2]=服务器端口)
int main(int argc, char* argv[]) {
// 1. 校验命令行参数(必须传入服务器 IP 和端口,否则提示用法并退出)
if (argc != 3) {
printf("Usage ./dict_client [ip] [port]\n");
return 1; // 非 0 返回值表示程序异常退出
}
// 2. 创建 TCP 客户端对象(指定要连接的服务器地址)
// argv[1]:服务器 IP 字符串(如 "127.0.0.1")
// atoi(argv[2]):将端口字符串转为整数(主机字节序)
TcpClient client(argv[1], atoi(argv[2]));
// 3. 发起 TCP 连接(三次握手),连接失败则直接退出
bool ret = client.Connect();
if (!ret) {
printf("连接服务器 %s:%d 失败!\n", argv[1], atoi(argv[2]));
return 1;
}
printf("成功连接服务器 %s:%d!\n", argv[1], atoi(argv[2]));
// 4. 进入交互循环:持续接收用户输入,向服务器查询单词(TCP 一次连接可持续通信)
for (;;) {
// 4.1 提示用户输入要查询的单词
std::cout << "请输入要查询的单词:" << std::endl;
std::string word;
// 读取用户输入(空格/回车分隔,仅读取单个单词)
std::cin >> word;
// 4.2 处理输入异常(如用户输入 Ctrl+D 终止输入)
if (!std::cin) {
std::cout << "退出查询,断开连接!" << std::endl;
break; // 退出循环,结束程序(析构函数自动关闭套接字)
}
// 4.3 向服务器发送查询单词(TCP 字节流,一次 Send 可能分多次发送)
client.Send(word);
// 4.4 接收服务器的响应结果(阻塞等待,直到收到数据/连接断开)
std::string result;
client.Recv(&result);
// 4.5 打印查询结果
std::cout << "查询结果:" << result << std::endl;
}
// 5. 程序正常退出(客户端对象析构时自动关闭套接字,触发 TCP 四次挥手)
return 0;
}
由于客户端不需要固定的端口号,因此不必调用bind(),客户端的端口号由内核自动分配
注意**:**
客户端不是不允许调用bind(), 只是没有必要调用bind()固定一个端口号. 否则如果在同一台机器上启动 多个客户端, 就会出现端口号被占用导致不能正确建立连接;
服务器也不是必须调用bind(), 但如果服务器不调用bind(), 内核会自动给服务器分配监听端口, 每次启动 服务器时端口号都不一样, 客户端要连接服务器就会遇到麻烦;
测试多个连接的情况
再启动一个客户端, 尝试连接服务器, 发现第二个客户端, 不能正确的和服务器进行通信.
分析原因, 是因为我们accecpt了一个请求之后, 就在一直while循环尝试read, 没有继续调用到accecpt, 导致不能接受新的请求.我们当前的这个TCP, 只能处理一个连接, 这是不科学的
9.简单的TCP网络程序**(多进程版本)**
通过每个请求, 创建子进程的方式来支持多连接;
tcp_process_server.hpp
cpp
#pragma once
// C++11 函数对象:解耦网络层与业务层
#include <functional>
// 信号处理头文件:处理子进程退出信号
#include <signal.h>
// 引入封装好的 TCP 套接字类(底层网络操作)
#include "tcp_socket.hpp"
// 定义业务处理回调函数类型 Handler
// 参数:
// req:客户端请求数据(输入)
// resp:服务器响应数据(输出,指针传递)
typedef std::function<void (const std::string& req, std::string* resp)> Handler;
// 多进程版本的 TCP 服务器
// 核心特性:每接收一个客户端连接,创建一个子进程专门处理该客户端的读写,父进程继续监听新连接
class TcpProcessServer {
public:
// 构造函数:初始化服务器地址,并设置子进程退出信号处理
// 参数:
// ip:服务器绑定的 IP 地址(如 "0.0.0.0" 监听所有网卡)
// port:服务器绑定的端口号(主机字节序)
TcpProcessServer(const std::string& ip, uint16_t port) : ip_(ip), port_(port) {
// 关键:设置 SIGCHLD 信号的处理方式为忽略(SIG_IGN)
// 作用:子进程退出时,内核自动回收其资源,避免产生僵尸进程
// 不设置的话,子进程退出后会变成僵尸进程,占用系统资源
signal(SIGCHLD, SIG_IGN);
}
// 处理单个客户端连接(创建子进程)
// 参数:
// new_sock:与客户端通信的套接字
// ip:客户端 IP 地址
// port:客户端端口号
// handler:业务回调函数(处理请求生成响应)
void ProcessConnect(const TcpSocket& new_sock, const std::string& ip, uint16_t port, Handler handler) {
// fork 创建子进程:父进程返回子进程 PID,子进程返回 0,失败返回 -1
int ret = fork();
if (ret > 0) {
// ====== 父进程逻辑 ======
// 父进程无需处理客户端通信,只需快速回到 accept 继续监听新连接
// 【核心注意】父进程必须关闭 new_sock!
// 原因:父子进程会共享文件描述符表,父进程不关闭会导致 new_sock 的引用计数>1,子进程关闭后仍无法释放资源
new_sock.Close();
return;
} else if (ret == 0) {
// ====== 子进程逻辑 ======
// 子进程专注处理当前客户端的持续读写,直到客户端断开
for (;;) {
std::string req; // 存储客户端请求数据
// 读取客户端请求:失败表示客户端断开/网络错误
bool ret = new_sock.Recv(&req);
if (!ret) {
// 客户端断开,打印日志并退出子进程
printf("[client %s:%d] disconnected!\n", ip.c_str(), port);
// 子进程退出:析构函数会自动关闭 new_sock,释放资源
exit(0);
}
// 调用业务回调函数,生成响应
std::string resp;
handler(req, &resp);
// 将响应发送回客户端
new_sock.Send(resp);
// 调试日志:记录请求和响应
printf("[client %s:%d] req: %s, resp: %s\n",
ip.c_str(), port, req.c_str(), resp.c_str());
}
} else {
// ====== fork 失败 ======
perror("fork"); // 打印创建子进程失败的错误信息
new_sock.Close(); // 失败时关闭套接字,避免资源泄漏
}
}
// 启动多进程 TCP 服务器(核心接口)
// 参数:handler - 业务处理回调函数
// 返回值:true-启动成功(死循环不会返回),false-初始化失败
bool Start(Handler handler) {
// 1. 创建监听套接字
CHECK_RET(listen_sock_.Socket());
// 2. 绑定 IP 和端口
CHECK_RET(listen_sock_.Bind(ip_, port_));
// 3. 监听端口(转为被动监听状态)
CHECK_RET(listen_sock_.Listen(5));
printf("多进程 TCP 服务器启动成功![ip: %s, port: %lu]\n", ip_.c_str(), port_);
// 4. 进入主事件循环:持续接收新客户端连接
for (;;) {
TcpSocket new_sock; // 存储新客户端的通信套接字
std::string client_ip; // 客户端 IP 地址
uint16_t client_port = 0; // 客户端端口号
// 接收客户端连接:失败则跳过,继续等待下一个连接
if (!listen_sock_.Accept(&new_sock, &client_ip, &client_port)) {
continue;
}
printf("[client %s:%d] connect!\n", client_ip.c_str(), client_port);
// 5. 创建子进程处理该客户端连接
ProcessConnect(new_sock, client_ip, client_port, handler);
}
return true;
}
private:
TcpSocket listen_sock_; // 监听套接字(父进程专用,仅接收新连接)
std::string ip_; // 服务器绑定的 IP 地址
uint64_t port_; // 服务器绑定的端口号(注:建议改为 uint16_t,端口范围 0-65535)
};
dict_server.cc 稍加修改
将 TcpServer 类改成 TcpProcessServer 类即可
10.简单的TCP网络程序**(多线程版本)**
通过每个请求, 创建一个线程的方式来支持多连接;
tcp_thread_server.hpp
cpp
#pragma once
// C++11 函数对象:解耦网络层与业务层
#include <functional>
// 线程库头文件:创建/分离线程(POSIX 线程,Linux 专用)
#include <pthread.h>
// 引入封装好的 TCP 套接字类(底层网络操作)
#include "tcp_socket.hpp"
// 定义业务处理回调函数类型 Handler
// 参数:
// req:客户端请求数据(输入)
// resp:服务器响应数据(输出,指针传递)
typedef std::function<void (const std::string&, std::string*)> Handler;
// 线程参数结构体:用于向线程入口函数传递多个参数
// 包含:通信套接字、客户端地址、业务回调函数
struct ThreadArg {
TcpSocket new_sock; // 与客户端通信的套接字
std::string ip; // 客户端 IP 地址
uint16_t port; // 客户端端口号
Handler handler; // 业务处理回调函数
};
// 多线程版本的 TCP 服务器
// 核心特性:每接收一个客户端连接,创建一个线程专门处理该客户端的读写,主线程继续监听新连接
class TcpThreadServer {
public:
// 构造函数:初始化服务器绑定的 IP 和端口
// 参数:
// ip:服务器绑定的 IP 地址(如 "0.0.0.0" 监听所有网卡)
// port:服务器绑定的端口号(主机字节序)
TcpThreadServer(const std::string& ip, uint16_t port) : ip_(ip), port_(port) {
}
// 启动多线程 TCP 服务器(核心接口)
// 参数:handler - 业务处理回调函数
// 返回值:true-启动成功(死循环不会返回),false-初始化失败
bool Start(Handler handler) {
// 1. 创建监听套接字(主线程专用)
CHECK_RET(listen_sock_.Socket());
// 2. 绑定 IP 和端口
CHECK_RET(listen_sock_.Bind(ip_, port_));
// 3. 监听端口(转为被动监听状态)
CHECK_RET(listen_sock_.Listen(5));
printf("多线程 TCP 服务器启动成功![ip: %s, port: %lu]\n", ip_.c_str(), port_);
// 4. 进入主线程事件循环:持续接收新客户端连接
for (;;) {
// 5. 动态分配线程参数结构体(避免栈内存随循环销毁)
ThreadArg* arg = new ThreadArg();
arg->handler = handler; // 赋值业务回调函数
// 6. 接收客户端连接:成功则填充 arg 中的 new_sock/ip/port
bool ret = listen_sock_.Accept(&arg->new_sock, &arg->ip, &arg->port);
if (!ret) {
delete arg; // 接收失败,释放参数内存,避免泄漏
continue;
}
printf("[client %s:%d] connect\n", arg->ip.c_str(), arg->port);
// 7. 创建子线程处理该客户端连接
pthread_t tid; // 线程 ID
// pthread_create 参数:
// &tid:输出线程 ID
// NULL:使用默认线程属性
// ThreadEntry:线程入口函数(必须是静态函数)
// arg:传递给线程入口函数的参数
pthread_create(&tid, NULL, ThreadEntry, arg);
// 8. 分离线程:线程退出时自动回收资源,无需主线程 pthread_join
pthread_detach(tid);
}
return true;
}
// 线程入口函数(必须是 static 成员函数)
// 原因:pthread_create 要求入口函数是 "void* (*)(void*)" 类型,非静态成员函数隐含 this 指针,类型不匹配
// 参数:arg - 传递的 ThreadArg 结构体指针
// 返回值:NULL(线程退出返回值)
static void* ThreadEntry(void* arg) {
// C++ 四种类型转换:
// 1. static_cast:静态转换(安全,编译期检查,如 int→double)
// 2. const_cast:移除 const 属性(仅修改 const 限定)
// 3. dynamic_cast:动态转换(仅用于继承体系,运行时检查,支持向下转型)
// 4. reinterpret_cast:重新解释转换(强制类型转换,如 void*→自定义类型,编译期处理,不安全但灵活)
ThreadArg* p = reinterpret_cast<ThreadArg*>(arg);
// 调用静态函数处理客户端通信
ProcessConnect(p);
// 9. 资源释放:必须手动关闭套接字 + 释放动态分配的参数内存
p->new_sock.Close();
delete p;
return NULL;
}
// 处理单个客户端的持续读写(必须是 static 函数)
// 原因:ThreadEntry 是静态函数,无法直接调用非静态成员函数(无 this 指针)
// 参数:arg - 线程参数结构体指针
static void ProcessConnect(ThreadArg* arg) {
// 循环读写:持续处理该客户端的请求,直到客户端断开
for (;;) {
std::string req; // 存储客户端请求数据
// 1. 读取客户端请求:失败表示客户端断开/网络错误
bool ret = arg->new_sock.Recv(&req);
if (!ret) {
printf("[client %s:%d] disconnected!\n", arg->ip.c_str(), arg->port);
break; // 退出循环,结束当前线程的处理逻辑
}
// 2. 调用业务回调函数,根据请求生成响应
std::string resp;
arg->handler(req, &resp);
// 3. 将响应发送回客户端
arg->new_sock.Send(resp);
// 调试日志:记录客户端地址、请求和响应
printf("[client %s:%d] req: %s, resp: %s\n",
arg->ip.c_str(), arg->port, req.c_str(), resp.c_str());
}
}
private:
TcpSocket listen_sock_; // 监听套接字(主线程专用,仅接收新连接)
std::string ip_; // 服务器绑定的 IP 地址
uint16_t port_; // 服务器绑定的端口号(主机字节序)
};
11.TCP****协议通讯流程

- 连接建立阶段(三次握手)
| 阶段 | 客户端行为 | 服务器端行为 | TCP 状态变化 |
|---|---|---|---|
| 初始化 | socket() 创建文件描述符 |
socket() → bind() → listen(),进入 LISTEN 状态 |
客户端:CLOSED服务器:CLOSED → LISTEN |
| 第一次握手 | connect() 发起连接,发送 SYN ,进入 SYN_SENT |
接收 SYN,发送 SYN+ACK ,进入 SYN_RCVD | 客户端:SYN_SENT服务器:LISTEN → SYN_RCVD |
| 第三次握手 | 接收 SYN+ACK,发送 ACK ,connect() 返回,进入 ESTABLISHED |
接收 ACK,accept() 返回新连接描述符,进入 ESTABLISHED |
双方均进入 ESTABLISHED |
- 数据传输阶段
- 客户端:循环调用
write()发送数据、read()等待响应,TCP 保持 ESTABLISHED 状态。 - 服务器端:循环调用
read()接收客户端数据、write()发送响应,TCP 同样保持 ESTABLISHED 状态。 - 数据传输时,TCP 会对每个数据段进行 ACK 确认,保证可靠传输。
- 连接关闭阶段(四次挥手)
| 阶段 | 主动关闭方(客户端) | 被动关闭方(服务器) | TCP 状态变化 |
|---|---|---|---|
| 第一次挥手 | 调用 close(),发送 FIN ,进入 FIN_WAIT_1 |
接收 FIN,发送 ACK ,进入 CLOSE_WAIT | 客户端:FIN_WAIT_1服务器:CLOSE_WAIT |
| 第二次挥手 | 接收 ACK,进入 FIN_WAIT_2 | 调用 close(),发送 FIN ,进入 LAST_ACK |
客户端:FIN_WAIT_2服务器:LAST_ACK |
| 第三次挥手 | 接收 FIN,发送 ACK ,进入 TIME_WAIT | 接收 ACK,进入 CLOSED | 客户端:TIME_WAIT服务器:CLOSED |
| 最终 | 等待 2MSL 后进入 CLOSED | 已关闭 | 双方均进入 CLOSED |
服务器初始化:
调用socket, 创建文件描述符;
调用bind, 将当前的文件描述符和ip/port绑定在一起; 如果这个端口已经被其他进程占用了, 就会bind失败;
调用listen, 声明当前这个文件描述符作为一个服务器的文件描述符, 为后面的accept做好准备;
调用accecpt, 并阻塞, 等待客户端连接过来;
建立连接的过程:
调用socket, 创建文件描述符;
调用connect, 向服务器发起连接请求;
connect会发出SYN段并阻塞等待服务器应答; (第一次)
服务器收到客户端的SYN, 会应答一个SYN-ACK段表示"同意建立连接"; (第二次)
客户端收到SYN-ACK后会从connect()返回, 同时应答一个ACK段; (第三次)
这个建立连接的过程, 通常称为 三次握手;
数据传输的过程
建立连接后,TCP协议提供全双工的通信服务; 所谓全双工的意思是, 在同一条连接中, 同一时刻, 通信双方可以同时写数据; 相对的概念叫做半双工, 同一条连接在同一时刻, 只能由一方来写数据;
服务器从accept()返回后立刻调 用read(), 读socket就像读管道一样, 如果没有数据到达就阻塞等待;
这时客户端调用write()发送请求给服务器, 服务器收到后从read()返回,对客户端的请求进行处理, 在此期间客户端调用read()阻塞等待服务器的应答;
服务器调用write()将处理结果发回给客户端, 再次调用read()阻塞等待下一条请求;
客户端收到后从read()返回, 发送下一条请求,如此循环下去;
断开连接的过程:
如果客户端没有更多的请求了, 就调用close()关闭连接, 客户端会向服务器发送FIN段(第一次);
此时服务器收到FIN后, 会回应一个ACK, 同时read会返回0 (第二次);
read返回之后, 服务器就知道客户端关闭了连接, 也调用close关闭连接, 这个时候服务器会向客户端发送一个FIN; (第三次)
客户端收到FIN, 再返回一个ACK给服务器; (第四次)
这个断开连接的过程, 通常称为 四次挥手
在学习socket API时要注意应用程序和TCP协议层是如何交互的:
应用程序调用某个socket函数时TCP协议层完成什么动作,比如调用connect()会发出SYN段
应用程序如何知道TCP协议层的状态变化,比如从某个阻塞的socket函数返回就表明TCP协议收到了某些段,再比如read()返回0就表明收到了FIN段
12.TCP和UDP****对比
| 特性 | TCP (传输控制协议) | UDP (用户数据报协议) |
|---|---|---|
| 连接性 | 面向连接(三次握手建立连接,四次挥手关闭) | 无连接(无需建立连接,直接发数据) |
| 可靠性 | 可靠传输(丢包重传、按序到达、流量控制) | 不可靠(不保证送达、不保证顺序) |
| 传输方式 | 字节流(无边界,如 "水管流水") | 数据报(有边界,如 "寄包裹") |
| 拥塞控制 | 有(慢启动、拥塞避免等,适配网络状况) | 无(发数据不考虑网络拥塞) |
| 速度 | 较慢(可靠性带来额外开销) | 极快(极简头部,无额外校验) |
| 资源占用 | 高(需维护连接状态、缓冲区等) | 低(无状态,无需维护连接) |
| 适用场景 | 要求数据完整、有序的场景 | 要求低延迟、可容忍少量丢包的场景 |
- TCP:"可靠的快递"
TCP 就像寄顺丰快递:
-
寄件前要确认收件人地址有效(三次握手);
-
包裹丢了会重新寄(丢包重传);
-
多个包裹会按顺序送到(按序重组);
-
收件人确认收到才完结(确认应答)。
UDP 就像寄平邮明信片: -
不用确认对方地址是否有效,直接投寄(无连接);
-
丢了不会补寄,也不知道对方是否收到(不可靠);
-
每张明信片独立,按投递顺序可能乱序到达(数据报);
-
寄件速度极快,不管对方是否忙(无拥塞控制)