TCP 协议与编程:面向连接的可靠传输
TCP(Transmission Control Protocol,传输控制协议)是传输层 “面向连接、可靠传输” 的核心协议,通过三次握手建立连接、四次挥手关闭连接,配合确认重传、流量控制、拥塞控制机制,保障数据有序、不丢失、不重复传输,适用于对可靠性要求高的场景(如网页浏览、文件传输、即时通信)。以下从协议特性、通信框架、编程流程到代码示例,系统梳理 TCP 编程核心内容。
1. TCP 的核心特性(与 UDP 对比)
TCP 作为 “可靠传输协议”,核心特性围绕 “连接管理” 和 “可靠性保障” 设计,与 UDP 的无连接、不可靠形成显著差异:
特性维度 | TCP 协议 | UDP 协议 |
---|---|---|
连接模式 | 面向连接:通信前需通过 “三次握手” 建立专用连接,通信后需 “四次挥手” 关闭连接 | 无连接:直接发送数据,无需建立 / 关闭连接 |
传输可靠性 | 可靠:通过 “确认重传”(收到 ACK 才继续发)、“序号 / 确认号”(保证有序)、“校验和”(检测错误)实现数据不丢失、不重复、有序 | 不可靠:仅校验头部,无重传 / 确认机制,可能丢包、乱序 |
数据边界 | 无边界:数据以 “字节流” 形式传输,发送端多次 send (),接收端可一次 recv () 读取 | 有边界:数据以 “数据报” 为单位,发送次数与接收次数需匹配 |
流量控制 | 支持:通过 “滑动窗口” 机制,根据接收端缓冲区大小调整发送速率,避免接收端溢出 | 不支持:按发送端速率传输,可能导致接收端缓冲区满丢包 |
拥塞控制 | 支持:通过 “慢启动”“拥塞避免”“快速重传”“快速恢复” 机制,避免网络拥堵 | 不支持:无拥塞控制,可能加剧网络拥堵 |
头部大小 | 20-60 字节(含序号、确认号、窗口大小等字段) | 8 字节(仅含源端口、目标端口、长度、校验和) |
适用场景 | 对可靠性要求高的场景(网页 HTTP/HTTPS、文件传输 FTP、即时通信) | 对延迟敏感的场景(视频通话、游戏、DNS 查询) |
2. TCP 连接管理:三次握手与四次挥手
TCP 的 “面向连接” 核心是通过 “三次握手建立连接” 和 “四次挥手关闭连接”,确保通信双方状态同步,避免无效数据传输。
(1)三次握手(建立连接,确保双方收发能力正常)
目的:确认客户端和服务器的 “发送能力” 和 “接收能力” 均正常,分配连接所需资源(如缓冲区、序号)。
流程(客户端主动发起,服务器被动监听):
- 客户端 → 服务器:发送 SYN(同步)报文,携带初始序号(如 seq=100),表示 “我要建立连接,我的初始序号是 100”;此时客户端进入
SYN_SENT
状态。 - 服务器 → 客户端:发送 SYN+ACK(同步 + 确认)报文,携带服务器初始序号(如 seq=200)和对客户端 SYN 的确认号(ack=101,即 “我收到你的 100,下次请发 101”);此时服务器进入
SYN_RCVD
状态。 - 客户端 → 服务器:发送 ACK(确认)报文,携带对服务器 SYN 的确认号(ack=201,即 “我收到你的 200,下次请发 201”);此时客户端进入
ESTABLISHED
(连接建立)状态,服务器收到后也进入ESTABLISHED
状态,双方可开始传输数据。
(2)四次挥手(关闭连接,确保数据传输完成)
目的:确保双方已传输完所有数据,释放连接占用的资源(如缓冲区、端口)。
流程(通常客户端主动发起关闭,也可服务器发起):
- 客户端 → 服务器:发送 FIN(结束)报文,携带序号(如 seq=300),表示 “我已无数据要发,请求关闭连接”;此时客户端进入
FIN_WAIT_1
状态。 - 服务器 → 客户端:发送 ACK(确认)报文,携带确认号(ack=301,即 “我收到你的关闭请求,已停止接收你的数据”);此时服务器进入
CLOSE_WAIT
状态,客户端进入FIN_WAIT_2
状态(等待服务器发送剩余数据)。 - 服务器 → 客户端:服务器发送完所有剩余数据后,发送 FIN 报文,携带序号(如 seq=400),表示 “我也无数据要发,请求关闭连接”;此时服务器进入
LAST_ACK
状态。 - 客户端 → 服务器:发送 ACK(确认)报文,携带确认号(ack=401,即 “我收到你的关闭请求”);此时客户端进入
TIME_WAIT
状态(等待 2MSL,确保服务器收到 ACK,避免服务器重发 FIN),服务器收到后进入CLOSED
状态;客户端等待 2MSL 后也进入CLOSED
状态,连接完全关闭。
3. TCP 通信框架(C/S 模式)
TCP 采用 “客户端 - 服务器(C/S)” 模型,服务器需提前启动并监听指定端口,客户端主动发起连接请求,连接建立后双方通过 “字节流” 传输数据,流程如下:
- 服务器端:启动 → 创建套接字 → 绑定 IP + 端口 → 监听端口 → 接受客户端连接 → 收发数据 → 关闭连接 ;
- 客户端:启动 → 创建套接字 → 发起连接(连接服务器) → 收发数据 → 关闭连接;
核心差异(与 UDP 对比):
- 服务器需多一步 “监听(listen)” 和 “接受连接(accept)” 操作,UDP 无需;
- 连接建立后,双方通过
read()
/write()
或recv()
/send()
收发数据(基于已连接套接字),无需每次指定目标地址(UDP 需每次用sendto()
/recvfrom()
指定地址); - 数据传输为 “字节流”,接收端需根据应用层协议(如 HTTP 的
Content-Length
)判断数据是否接收完整,UDP 无需(数据报有边界)。
4. TCP 编程流程(以 C 语言 Socket API 为例)
TCP 编程依赖标准 Socket API,核心函数包括socket()
(创建套接字)、bind()
(绑定地址)、listen()
(监听端口)、accept()
(接受连接)、connect()
(发起连接)、send()
/recv()
(收发数据)、close()
(关闭连接)。
(1)核心函数说明
函数原型 | 功能描述 | 关键参数与返回值 |
---|---|---|
int socket(int domain, int type, int protocol); |
创建 TCP 套接字(向内核申请网络通信端点) | - domain:AF_INET(IPv4); - type:SOCK_STREAM(TCP 流式套接字); - protocol:0(自动适配 TCP); - 返回值:成功返回套接字 ID(int),失败返回 - 1。 |
int bind(int sockfd, struct sockaddr *my_addr, socklen_t addrlen); |
服务器端:将套接字与指定 IP + 端口绑定(确定监听地址) | - sockfd:socket () 返回的套接字 ID; - my_addr:sockaddr_in 结构体(含 IP、端口、协议族); - addrlen:结构体长度(sizeof (struct sockaddr_in)); - 返回值:成功返回 0,失败返回 - 1。 |
int listen(int sockfd, int backlog); |
服务器端:将套接字设为 “监听状态”,等待客户端连接 | - sockfd:绑定后的套接字 ID; - backlog:监听队列大小(未完成连接队列 + 已完成连接队列的最大长度,通常设 5-10); - 返回值:成功返回 0,失败返回 - 1。 |
int accept(int sockfd, struct sockaddr *cli_addr, socklen_t *addrlen); |
服务器端:从监听队列中接受一个客户端连接,返回 “已连接套接字”(用于与该客户端通信) | - sockfd:监听状态的套接字 ID(仅用于接受连接,不直接收发数据); - cli_addr:存储客户端地址(可选,NULL 表示不关心); - addrlen:客户端地址长度指针; - 返回值:成功返回 “已连接套接字 ID”,失败返回 - 1。 |
int connect(int sockfd, struct sockaddr *serv_addr, socklen_t addrlen); |
客户端:向服务器发起 TCP 连接请求(触发三次握手) | - sockfd:socket () 返回的套接字 ID; - serv_addr:服务器地址结构体(含服务器 IP、端口); - addrlen:结构体长度; - 返回值:成功返回 0(连接建立),失败返回 - 1。 |
ssize_t send(int sockfd, const void *buf, size_t len, int flags); |
收发双方:通过已连接套接字发送数据(TCP 字节流) | - sockfd:accept ()(服务器)或 connect ()(客户端)返回的 “已连接套接字”; - buf:发送数据缓冲区; - len:数据长度; - flags:0(阻塞发送); - 返回值:成功返回发送字节数,失败返回 - 1。 |
ssize_t recv(int sockfd, void *buf, size_t len, int flags); |
收发双方:通过已连接套接字接收数据(TCP 字节流) | - 参数同 send (),buf 为接收缓冲区; - 返回值:成功返回接收字节数,0 表示对方关闭连接,失败返回 - 1。 |
int close(int sockfd); |
关闭套接字,释放资源(触发四次挥手) | - sockfd:已连接套接字或监听套接字; - 返回值:成功返回 0,失败返回 - 1。 |
(2)TCP 编程流程对比(服务器端 vs 客户端)
角色 | 核心步骤(按执行顺序) | 关键说明 |
---|---|---|
服务器端 | 1. socket() :创建 TCP 套接字2. bind() :绑定服务器 IP + 端口3. listen() :将套接字设为监听状态4. accept() :阻塞等待并接受客户端连接(返回通信套接字)5. recv() /send() :通过通信套接字与客户端收发数据6. close() :关闭通信套接字(与该客户端断开),可循环执行 4-6 接受新客户端 |
- 监听套接字(socket() 返回)仅用于接受连接,不直接收发数据;- 每个客户端连接对应一个 “通信套接字”,服务器需通过多线程 / 多进程处理多客户端(避免单客户端阻塞其他客户端)。 |
客户端 | 1. socket() :创建 TCP 套接字2. connect() :发起连接(连接服务器 IP + 端口)3. send() /recv() :与服务器收发数据4. close() :关闭套接字(断开连接) |
- 客户端无需bind() :系统自动分配临时端口;- connect() 失败表示三次握手未完成(如服务器未启动、端口错误)。 |
5. TCP服务器/客户端 代码示例
服务器端代码
#include <netinet/in.h>
#include <netinet/ip.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#include <time.h>
typedef struct sockaddr *(SA); // 定义sockaddr指针别名
int main(int argc, char **argv)
{
// 创建监听套接字
int listfd = socket(AF_INET, SOCK_STREAM, 0);
if (-1 == listfd)
{
perror("scoket error\n");
return 1;
}
// 初始化服务器地址结构
struct sockaddr_in ser, cli;
bzero(&ser, sizeof(ser)); // 清零结构体
bzero(&cli, sizeof(cli));
ser.sin_family = AF_INET; // IPv4协议
ser.sin_port = htons(50000); // 端口号转换为网络字节序
ser.sin_addr.s_addr = INADDR_ANY; // 监听所有网络接口
// 绑定地址和端口
int ret = bind(listfd, (SA)&ser, sizeof(ser));
if (-1 == ret)
{
perror("bind");
return 1;
}
// 开始监听(队列长度3)
listen(listfd, 3);
socklen_t len = sizeof(cli);
// 接受客户端连接
int conn = accept(listfd, (SA)&cli, &len);
if (-1 == conn)
{
perror("accept");
return 1;
}
time_t tm;
while (1)
{
char buf[1024] = {0}; // 初始化接收缓冲区
// 接收客户端数据
int ret = recv(conn, buf, sizeof(buf), 0);
if (ret <= 0) // 连接关闭或出错
{
break;
}
time(&tm); // 获取当前时间
// 在消息后附加时间戳
sprintf(buf, "%s %s", buf, ctime(&tm));
// 发送响应数据
send(conn, buf, strlen(buf), 0);
}
// 关闭所有套接字
close(listfd);
close(conn);
return 0;
}
运行结果:
- 服务器启动后阻塞在
accept()
等待连接 - 客户端连接后,服务器接收消息并添加时间戳返回
- 示例输出:
hello,this is tcp test Wed Jun 12 10:30:45 2024
客户端代码
#include <netinet/in.h>
#include <netinet/ip.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <time.h>
#include <unistd.h>
typedef struct sockaddr *(SA); // 定义sockaddr指针别名
int main(int argc, char **argv)
{
// 创建通信套接字
int conn = socket(AF_INET, SOCK_STREAM, 0);
if (-1 == conn)
{
perror("socket");
return 1;
}
// 初始化服务器地址结构
struct sockaddr_in ser;
bzero(&ser, sizeof(ser));
ser.sin_family = AF_INET; // IPv4协议
ser.sin_port = htons(50000); // 服务器端口
ser.sin_addr.s_addr = INADDR_ANY; // 本地回环地址
// 连接服务器
int ret = connect(conn, (SA)&ser, sizeof(ser));
if (-1 == ret)
{
perror("connect error\n");
return 1;
}
int i = 10;
while (i--)
{
char buf[1024] = "hello,this is tcp test"; // 发送消息
send(conn, buf, strlen(buf), 0); // 发送数据
bzero(buf, sizeof(buf)); // 清空缓冲区
recv(conn, buf, sizeof(buf), 0); // 接收响应
printf("from ser:%s\n", buf); // 打印服务器响应
sleep(1); // 间隔1秒
}
close(conn); // 关闭连接
return 0;
}
运行结果:
- 客户端连接服务器成功
- 每秒发送"hello,this is tcp test"并接收带时间戳的响应
- 示例输出:
from ser:hello,this is tcp test Wed Jun 12 10:30:45 2024
6.TCP数据粘包问题
问题描述:
TCP粘包问题是指由于TCP是面向流的传输协议,数据在传输过程中没有明确的消息边界,导致:
多条消息合并:接收方可能一次性收到发送方多次发送的数据
消息被拆分:一个完整的消息可能被拆分成多个数据包接收
数据混乱:无法准确区分每条消息的起始和结束位置
解决方案:
- 设置边界:使用特殊字符(如
\0
)作为消息结束标志 - 固定大小:每次发送固定长度的数据块
- 自定义协议:在消息头部添加长度字段