今天是网络编程学习的第二天,从 UDP 的无连接不可靠,正式进入 TCP 的面向连接可靠传输。本文从 TCP 协议原理、核心函数接口、C/S 架构设计,到实战中踩的粘包、退出、多线程等坑,系统整理一天的学习成果
文章目录
- 一、TCP 协议基础
- 1.1 什么是 TCP
- 1.2 三次握手(建立连接)
- 1.3 四次挥手(断开连接)
- 二、TCP 核心函数接口详解
- 2.1 socket() — 创建套接字
- 2.2 connect() — 客户端主动发起连接
- 2.3 bind() — 绑定本地地址和端口
- 2.4 listen() — 开启监听
- 2.5 accept() — 接受连接
- 2.6 send() — 发送数据
- 2.7 recv() — 接收数据
- 三、TCP C/S 架构与完整流程
- 3.1 为什么必须有客户端和服务端之分
- 3.2 服务端完整流程
- 3.3 客户端完整流程
- 四、TCP 全双工与多线程聊天
- 4.1 什么是全双工
- 4.2 为什么需要多线程
- 4.3 聊天程序的退出机制(重点踩坑)
- 五、TCP 粘包问题(文件传输必踩)
- 5.1 什么是粘包
- 5.2 粘包的解决方案
- 5.3 文件传输的另一个坑:recv 返回 0 不处理
- 六、TCP vs UDP 核心对比
- 七、实战踩坑总结
- 八、学习总结
一、TCP 协议基础
1.1 什么是 TCP
TCP(Transmission Control Protocol,传输控制协议)是传输层的核心协议,和 UDP 并列,但特性完全不同:
- 面向连接:通信前必须通过三次握手建立连接,通信结束通过四次挥手断开
- 可靠传输:有确认机制、超时重传、排序、流量控制、拥塞控制,保证数据不丢、不乱、不重复
- 面向字节流:数据被看作连续的字节流,没有消息边界,会出现粘包
- 全双工:同一个连接建立后,双方可以同时发送和接收数据,两条数据流独立
1.2 三次握手(建立连接)
TCP 连接的建立必须一方主动、一方被动,通过三次握手完成:
客户端 服务端
|── SYN ─────────────→| ① 客户端:我要连你
|←── SYN+ACK ─────────| ② 服务端:可以,我也准备好了
|── ACK ─────────────→| ③ 客户端:好的,连上了
| |
|==== 连接建立,可以收发数据 ====|
- connect() 函数触发三次握手,connect 成功返回代表握手完成
- accept() 只是从内核已完成握手的连接队列中取出一个连接,不参与握手过程
- 必须一方 listen 被动等待,一方 connect 主动发起,两边都 connect 或都 listen 是连不上的
1.3 四次挥手(断开连接)
TCP 是全双工的,两个方向要分别关闭,所以是四次挥手:
客户端 服务端
|── FIN ─────────────→| ① 我发完了,要断开
|←── ACK ─────────────| ② 好的,知道了
|←── FIN ─────────────| ③ 我也发完了,断开
|── ACK ─────────────→| ④ 好的,彻底断开
- 一方调用 close() 会触发 FIN 发送
- 对方 recv() 会返回 0,代表对端关闭了连接(这不是错误)
- recv() 返回 -1 才是真正的出错
二、TCP 核心函数接口详解
2.1 socket () — 创建套接字
int tcpsocket = socket(AF_INET, SOCK_STREAM, 0);
- AF_INET:IPv4 协议族
- SOCK_STREAM:流式套接字,对应 TCP(UDP 用SOCK_DGRAM)
- 0:默认协议
- 返回值:成功返回文件描述符,失败返回 – 1
2.2 connect () — 客户端主动发起连接
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 功能:向服务端发送连接请求,触发三次握手
- 参数:
- sockfd:套接字文件描述符
- addr:服务端的地址(IP + 端口)
- addrlen:地址结构体长度
- 返回值:成功返回 0,失败返回 – 1
- 谁调用:只有客户端调用,服务端不需要
2.3 bind () — 绑定本地地址和端口
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 功能:将 IP + 端口与套接字绑定,在内核建立端口到套接字的映射
- 谁必须调用:服务端必须 bind,固定端口让客户端知道连哪里;客户端一般不 bind,内核自动分配临时端口
- 服务端推荐绑定 htonl(INADDR_ANY),监听所有网卡,兼容性最好
2.4 listen () — 开启监听
int listen(int sockfd, int backlog);
- 功能:将套接字标记为被动监听模式,准备接收连接请求
- 参数:
- sockfd:套接字文件描述符
- backlog:尚未处理的连接请求的最大排队个数
- 返回值:成功返回 0,失败返回 – 1
- 谁调用:只有服务端调用,客户端不需要
2.5 accept () — 接受连接
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- 功能:从监听队列中取出第一个已完成握手的连接请求,返回一个新的文件描述符
- 参数:
- sockfd:监听套接字(socket 返回的那个)
- addr:输出参数,存放连接客户端的地址信息
- addrlen:输入输出参数,传入地址长度,返回实际地址长度
- 返回值:成功返回新的通信文件描述符,失败返回 – 1
- 关键点:服务端有两个 fd—— 监听 fd(只用来接客)和通信 fd(真正收发数据),不能用监听 fd 收发数据
2.6 send () — 发送数据
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
- 功能:向已连接的套接字发送数据
- 参数:
- sockfd:已连接的套接字(客户端用 socket 的 fd,服务端用 accept 返回的 fd)
- buf:发送数据首地址
- len:发送数据长度
- flags:属性,默认为 0
- 返回值:成功返回实际发送字节数,失败返回 – 1
2.7 recv () — 接收数据
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
- 功能:从已连接的套接字接收数据
- 参数:
- sockfd:已连接的套接字
- buf:存放数据的缓冲区
- len:最多接收字节数
- flags:属性,默认为 0
- 返回值:
- 成功:返回实际接收字节数
- 失败:返回 – 1
- 对方关闭连接:返回 0(重要!这是正常断开,不是错误)
三、TCP C/S 架构与完整流程
3.1 为什么必须有客户端和服务端之分
TCP 连接的建立天然不对称:必须一方listen+accept被动等待,一方connect主动发起。这不是代码设计选择,是 TCP 协议本身的要求。
表格
| 服务端 | socket→bind→listen→accept,被动等待 | 用 accept 返回的 fd,send/recv 收发 |
| 客户端 | socket→connect,主动连接 | 用 socket 的 fd,send/recv 收发 |
连接建立之前角色不对称,连接建立之后两边完全平等,都是全双工收发。"谁是客户端谁是服务端" 只在连接建立那一刻有意义 —— 主动 connect 的是客户端,被动 accept 的是服务端。
3.2 服务端完整流程
socket() → bind() → listen() → accept()(阻塞等连接)
→ 得到通信fd → 循环 send/recv 收发数据
→ 通信结束 close(通信fd) → close(监听fd)
3.3 客户端完整流程
socket() → connect()(触发三次握手)
→ 连接建立 → 循环 send/recv 收发数据
→ 通信结束 close(fd)
四、TCP 全双工与多线程聊天
4.1 什么是全双工
TCP 连接有两条独立的数据流(A→B 和 B→A),可以同时收发,互不干扰。同一个 fd 既能调用 send 也能调用 recv,这就是全双工。
4.2 为什么需要多线程
虽然 TCP 内核层面支持同时收发,但阻塞式的 recv 会卡住当前线程。单线程里 recv 阻塞了,就没法去读键盘调用 send 了。所以聊天程序必须开两个线程:
- 发送线程:读键盘输入 → send 发给对方
- 接收线程:recv 收消息 → 打印到屏幕
4.3 聊天程序的退出机制(重点踩坑)
TCP 聊天的退出是学习中踩坑最多的地方,核心是搞清楚close和recv返回0的关系:
fd 是进程级别的,不是线程级别的。 一个线程调用close(fd)后,整个进程里这个 fd 就无效了,另一个线程再用它 recv 会返回 – 1(EBADF),不是返回 0。
正确的退出方案(不用 shutdown,纯应用层消息通知):
注意:发送线程必须先发.quit 消息再 close,如果先 close 再 send,消息发不出去。
五、TCP 粘包问题(文件传输必踩)
5.1 什么是粘包
TCP 是面向字节流的,没有消息边界。连续 send 多次,数据可能被内核合并发送;recv 一次可能收到多条数据的拼接。
比如文件传输时,先发文件名"src.jpg"(7 字节),再发文件内容:
send端:[文件名7字节][文件内容…]
recv端第一次recv可能收到:src.jpg\\xff\\xd8\\xff\\xe0…
└文件名┘└──文件内容──┘
文件名和文件内容粘在一起了,直接当文件名 open 就会出错。
UDP 是面向数据报的,一次 sendto 对应一次 recvfrom,有明确边界,不会粘包。
5.2 粘包的解决方案
方法 1:固定长度(最简单,适合文件名) 约定文件名固定占 32 字节,不够补 0。接收端循环读满 32 字节再当文件名。
// 发送端
char filename[32] = {0};
strcpy(filename, "src.jpg");
send(sockfd, filename, sizeof(filename), 0); // 发满32字节
// 接收端:循环读满32字节
int total = 0;
while(total < 32) {
sret = recv(confd, filename + total, 32 – total, 0);
if(sret <= 0) break;
total += sret;
}
方法 2:先发长度再发内容 先发 4 字节整数表示数据长度,接收端先读长度,再按长度读完整内容。工业界最常用。
方法 3:特殊分隔符 约定每条消息以\\n或#结尾,接收端按字节读直到遇到分隔符。
5.3 文件传输的另一个坑:recv 返回 0 不处理
发送端读完文件后 close (sockfd),接收端 recv 返回 0 代表传输完毕。如果只判断recv==-1不判断==0,接收端会死循环,close(fd)执行不到,文件内容可能不完整。
while(1) {
sret = recv(confd, tmpbuff, sizeof(tmpbuff), 0);
if(sret == 0) { printf("接收完毕\\n"); break; } // 必须加
if(sret == -1) { perror("recv"); break; }
write(fd, tmpbuff, sret);
}
close(fd); // 现在能执行到了
六、TCP vs UDP 核心对比
| 连接 | 面向连接,三次握手建立,四次挥手断开 | 无连接,直接收发 |
| 可靠性 | 可靠,确认 + 重传 + 排序 + 流量控制 + 拥塞控制 | 不可靠,丢了就丢了 |
| 数据形式 | 面向字节流,会粘包 | 面向数据报,有边界不粘包 |
| 收发接口 | connect/accept 后用 send/recv | 直接用 sendto/recvfrom |
| 架构 | C/S,必须一方 listen 一方 connect | 两边对称,不需要中间服务器 |
| 对方退出感知 | recv 返回 0,天然感知 | 完全感知不到,必须应用层发消息通知 |
| 资源占用 | 较大,维护连接状态 | 较小,无连接状态 |
| 适用场景 | 文件传输、网页、聊天等需要可靠数据的场景 | 直播、游戏、DNS 等对实时性要求高、可容忍丢包的场景 |
七、实战踩坑总结
| strcmp 判断写反 | 输入任何消息都退出 | if(strcmp(a,b)) 含义是 "不相等就为真" | 写成 if(strcmp(a,b)==0) |
| gets 危险函数 | 编译报警告,输入过长崩溃 | gets 不检查缓冲区长度 | 改用 fgets |
| 发送线程直接 close | 对方感知不到退出,程序卡死 | close 后 fd 全进程无效,recv 返回 – 1 而非 0 | 先发.quit 消息再 close,或用 shutdown |
| recv 不判断返回 0 | 文件传输死循环,文件不完整 | 对方 close 后 recv 返回 0,只判断了 – 1 | 加 if(sret==0) break; |
| TCP 粘包 | 文件名乱码,open 失败 | 文件名和文件内容粘在一起 | 固定长度 / 先发长度 / 分隔符 |
| 服务端用监听 fd 收发 | 收发失败 | listen fd 只用于监听,不能通信 | 用 accept 返回的新 fd 收发 |
| bind 写死具体 IP | 换网卡后 bind 失败 | IP 变了 | 用 htonl (INADDR_ANY) |
| 同目录运行文件传输 | 源文件被清空 | O_TRUNC 覆盖了同名源文件 | 分目录运行,或接收端改输出文件名 |
| open 缺 O_WRONLY | write 返回 – 1,文件为空 | 只写 O_CREAT|O_TRUNC,默认只读模式 | 加上 O_WRONLY |
八、学习总结
TCP 比 UDP 复杂,但可靠性带来的价值是巨大的,文件传输、网页浏览、即时通讯这些场景都依赖 TCP。理解了连接管理、可靠传输、字节流这三个核心点,TCP 编程就入门了。


