当前位置: 首页 > news >正文

《TCP 客户端代码逐行寻宝:三次握手、死循环 close 的谜底全拆解》

一、文档说明

很多同学入门 TCP 网络编程时,写下的第一份客户端代码往往只有短短几十行:创建套接字、连接服务端、循环收发数据。看似简单的代码里,却藏着好几个新手必问的灵魂问题:

  • 三次握手到底是哪个函数执行的?我没写 SYN 相关的代码啊?
  • 明明 TCP 和 UDP 代码长得差不多,为啥一个可靠一个不可靠?
  • 主逻辑是while(1)死循环,结尾的close根本跑不到,写了不是多余吗?
  • 调用close就等于直接触发四次挥手吗?

本文逐行拆解这份经典 TCP 客户端代码,把语法规则、系统调用、内核 TCP 协议行为全部讲透,顺便把所有疑问一次性解答清楚。

二、完整代码总览

这是一份 Linux 环境下标准的TCP 回显客户端:连接本地 8888 端口的服务端,读取用户键盘输入并发送,接收服务端返回的回显内容并打印,连接断开后自动退出。

c

运行

// tcp_client.c #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #define PORT 8888 #define BUF_SIZE 1024 int main() { // 1. 创建通信socket int sock_fd = socket(AF_INET, SOCK_STREAM, 0); if (sock_fd < 0) { perror("socket创建失败"); exit(EXIT_FAILURE); } // 2. 配置服务端地址 struct sockaddr_in server_addr; memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_port = htons(PORT); if (inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr) <= 0) { perror("IP地址格式错误"); close(sock_fd); exit(EXIT_FAILURE); } // 3. 发起连接(底层对应TCP三次握手) if (connect(sock_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) { perror("连接服务端失败"); close(sock_fd); exit(EXIT_FAILURE); } printf("连接服务端成功,请输入要发送的内容:\n"); char buf[BUF_SIZE]; while (1) { memset(buf, 0, BUF_SIZE); // 从终端读取用户输入 fgets(buf, BUF_SIZE, stdin); // 发送数据到服务端 send(sock_fd, buf, strlen(buf), 0); // 接收服务端回显 memset(buf, 0, BUF_SIZE); ssize_t recv_len = recv(sock_fd, buf, BUF_SIZE - 1, 0); if (recv_len <= 0) { printf("服务端断开连接\n"); break; } printf("收到服务端回显:%s", buf); } close(sock_fd); // 关闭连接(底层对应TCP四次挥手) return 0; }

三、逐模块逐行深度详解

3.1 头文件与宏定义:编程的前置工具箱

c

运行

#include <stdio.h> // 标准输入输出:printf、perror、fgets #include <stdlib.h> // 标准库:程序退出exit、状态码宏 #include <string.h> // 内存/字符串操作:memset、strlen #include <unistd.h> // Unix系统调用:close、基础IO操作 #include <sys/socket.h> // Socket核心API:socket、connect、send、recv #include <netinet/in.h> // IPv4地址结构:sockaddr_in、字节序转换函数 #include <arpa/inet.h> // IP地址转换:inet_pton(字符串转二进制IP) #define PORT 8888 // 目标服务端端口 #define BUF_SIZE 1024 // 收发缓冲区最大字节数

这是 Linux Socket 编程的标配头文件,所有网络相关的系统调用、数据结构都定义在这里。缓冲区 1024 是入门示例的常用值,实际项目会根据业务消息大小灵活调整。

3.2 创建套接字:申请一条专属通信线路

c

运行

int sock_fd = socket(AF_INET, SOCK_STREAM, 0); if (sock_fd < 0) { perror("socket创建失败"); exit(EXIT_FAILURE); }
函数核心说明

socket()函数的作用是向操作系统内核申请一个网络套接字,返回一个文件描述符(fd)。Linux 下 “一切皆文件”,socket 本质也是一个文件,后续所有收发、连接操作都通过这个 fd 来标识。

三个参数分别对应:

  1. AF_INET:协议族,指定使用 IPv4 协议;对应 IPv6 则填AF_INET6
  2. SOCK_STREAM:套接字类型,指定流式传输,对应 TCP 协议
  3. 0:使用对应类型的默认协议,SOCK_STREAM默认就是 TCP,无需额外指定
灵魂追问:TCP 和 UDP 代码开头几乎一样,区别到底在哪?

很多同学觉得 TCP 和 UDP 代码长得像,核心原因是Socket 是操作系统设计的一套通用编程接口,不管底层用什么传输协议,创建套接字、绑定地址的 API 格式都是统一的。

两者真正的分水岭,就是第二个参数:

  • SOCK_STREAM:内核会加载完整的 TCP 协议栈,自动处理连接管理、可靠重传、流量控制、拥塞控制
  • SOCK_DGRAM:内核加载 UDP 协议栈,只负责发送数据报,不保证送达、不保证顺序

可以理解为:同样是 “买一部手机”,外观、按键操作都差不多,但一个插的是有线保障专线,一个插的是无保障广播频道,底层能力天差地别。

3.3 配置服务端地址:填好对方的 “通信门牌”

c

运行

struct sockaddr_in server_addr; memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_port = htons(PORT); if (inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr) <= 0) { perror("IP地址格式错误"); close(sock_fd); exit(EXIT_FAILURE); }

这一步是在内存中组装目标服务端的 “IP + 端口” 地址,后续connect要靠这个地址找到网络上的目标程序。

关键细节拆解
  1. 内存清零memset把结构体内存全部置 0,避免内存脏数据导致地址解析错误,是网络编程的标准安全写法。
  2. 字节序转换htons: 不同 CPU 的内存存储字节顺序(大端 / 小端)不同,网络传输统一规定使用大端字节序(网络字节序)htons= host to network short,把主机字节序的 16 位端口号转换成网络字节序,保证跨设备兼容性。
  3. IP 地址转换inet_pton: 把人类可读的点分十进制字符串 IP(如"127.0.0.1")转换成内核能识别的二进制网络地址。p代表字符串展示格式,n代表网络二进制格式。
  4. 出错兜底:IP 格式错误时,先调用close(sock_fd)再退出 —— 因为前面已经成功申请了套接字,直接退出会导致文件描述符泄漏。

3.4 发起连接:按下拨号键,完成三次握手

c

运行

if (connect(sock_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) { perror("连接服务端失败"); close(sock_fd); exit(EXIT_FAILURE); } printf("连接服务端成功,请输入要发送的内容:\n");
函数核心说明

connect()是客户端发起 TCP 连接的入口函数,三个参数分别是套接字 fd、目标服务端地址、地址结构体长度。 注意地址参数必须强转为struct sockaddr*通用类型 —— 这是历史设计原因:Socket API 要兼容所有协议族,所以用通用地址结构体做入参,实际传入对应协议的地址结构体即可。

灵魂追问:三次握手是这个函数执行的吗?我没写发 SYN 的代码啊?

三次握手全程由操作系统内核的 TCP 协议栈自动完成,应用层代码不需要手动发任何控制报文connect()只是触发三次握手的启动开关。

调用connect()后,内核会自动完成三步:

  1. 发送第一次握手的 SYN 报文,客户端进入SYN_SENT状态
  2. 阻塞等待服务端返回 SYN+ACK 报文
  3. 收到后自动回复第三次握手的 ACK 报文,客户端进入ESTABLISHED已连接状态,connect()函数返回

补充对应服务端的逻辑:

  • 服务端调用listen()后,内核就开始自动响应 SYN 请求,完成握手的前两步
  • 服务端的accept()根本不参与握手,只从内核的「已完成连接队列」里取出已经握手完成的连接
  • 半连接队列:存放只收到 SYN、还没完成三次握手的连接
  • 全连接队列:存放完成三次握手、等待应用层取用的连接

3.5 核心收发循环:24 小时待命的通信专线

c

运行

char buf[BUF_SIZE]; while (1) { memset(buf, 0, BUF_SIZE); fgets(buf, BUF_SIZE, stdin); // 读取用户键盘输入 send(sock_fd, buf, strlen(buf), 0); // 发送数据到服务端 memset(buf, 0, BUF_SIZE); ssize_t recv_len = recv(sock_fd, buf, BUF_SIZE - 1, 0); if (recv_len <= 0) { printf("服务端断开连接\n"); break; // 跳出循环的唯一出口 } printf("收到服务端回显:%s", buf); }

这是程序的核心业务逻辑,进入死循环后,一直重复「读输入→发消息→等回复→打印」的流程,就像 24 小时值班的客服坐席。

关键细节拆解
  1. 缓冲区清空:每次收发前用memset清空缓冲区,避免上一次的数据残留导致内容错乱。
  2. send函数:把应用层数据拷贝到内核的 TCP 发送缓冲区。注意:send成功返回,不代表对方已经收到数据,只代表数据已经成功交给内核,后续的发送、重传、流量控制都由内核在后台自动完成。
  3. recv返回值的三种含义
    • 返回值 > 0:正常收到数据,值为实际读取到的字节数
    • 返回值 = 0:对端主动关闭了连接(收到了 FIN 报文),属于正常断开
    • 返回值 < 0:连接异常,比如网络中断、连接被强制重置
灵魂追问:while (1) 是死循环,正常运行走不到后面的 close,这行是不是多余的?

绝对不是多余的,反而是必须写的规范操作。我们觉得 “跑不到”,只是只考虑了 “程序正常运行、连接永远稳定” 的理想场景,而真实编程必须覆盖所有退出路径:

  1. 服务端主动断开:服务端关闭、重启、主动踢掉客户端时,会发送 FIN 报文,客户端recv返回 0,触发break跳出循环,顺理成章走到close。这是最常见的正常退出场景。
  2. 网络异常中断:网线断开、防火墙拦截、网络波动导致连接失效,recv返回 - 1,同样触发break跳出循环。
  3. 后续扩展退出逻辑:如果要加 “输入 quit 退出” 的功能,只需要在循环里加判断执行break,结尾的close可以直接复用,不需要重复写关闭逻辑。
  4. 资源泄漏兜底:就算极端场景下永远走不到,这行也是安全底线。就像大楼的消防通道,平时可能用不上,但必须有 —— 一旦有退出路径走到这里,就能保证释放文件描述符,避免资源耗尽。

3.6 关闭套接字:优雅挂断,走完收尾流程

c

运行

close(sock_fd); return 0;

跳出循环后,程序最终执行close关闭套接字,正常结束。

灵魂追问:调用 close 就立刻触发四次挥手吗?

close发起关闭流程的开关,不是 “立刻掐断线路”,真正的四次挥手依然由内核在后台自动完成:

  1. 调用close后,内核会先把发送缓冲区里残留的数据全部发送完毕
  2. 数据发完后,内核向对端发送 FIN 报文,代表 “我这边不再发数据了”,触发四次挥手流程
  3. 后续的 ACK 回复、对端 FIN 回复、最终确认,都由内核自动完成

补充一个易错点:TCP 是全双工协议,FIN 只代表 “关闭发送方向”,接收方向依然可以正常收数据。完整的四次挥手,就是双方各自关闭一次发送通道,每次关闭都需要「发 FIN + 回 ACK」,所以一共四次交互。

四、新手避坑清单

  1. 出错分支别忘了 close:创建套接字失败、地址错误、连接失败时,只要前面已经成功申请了资源,退出前都要记得释放,避免文件描述符泄漏。
  2. 不要迷信 send 的返回值:send 成功只代表数据进了内核缓冲区,不代表对方已经收到,可靠交付由 TCP 内核保证,但应用层业务级确认需要自己设计。
  3. 不要忽略 recv 返回 0 的情况:这是对端正常断开的信号,不是错误,需要做好连接释放的收尾逻辑。
  4. 不要以为 TCP 天然有消息边界:TCP 是字节流协议,这段示例能正常运行是因为回显简单、数据量小;复杂业务中必须处理粘包问题,通过包头长度、分隔符等方式拆分完整消息。
谢谢
http://www.jsqmd.com/news/1106976/

相关文章:

  • Java中String.valueOf(null)的惊天大坑:对比两个数时,日志打印的两数都是null,但Objects.equals()返回false!
  • 拆解大健康爆火七人拼团,P1 到 P10 晋升逻辑全曝光
  • 房颤史患者用匹妥布替尼Pirtobrutinib,出血风险比伊布替尼低吗
  • AI大模型面试高频题:20道API工程化考点详解
  • 最初的需求
  • GPU内核融合技术:性能优化原理与实践
  • 计算机毕业设计之基于弹幕文本大数据的情感分析与可视化
  • 计算机毕业设计之基于大数据技术的新能源汽车销售数据可视化平台设计与实现
  • 【课程设计/毕业设计】基于 Java 的高中生多元素质评价管理系统的设计与实现【附源码、数据库、万字文档】
  • 低门槛股票量化工具横评:回测盯盘风控和条件单怎么分工
  • IPv6改造后,如何验证全国用户是否都能正常访问
  • 苹果重启iRing传言背后:健康监测优势凸显,欲在医疗健康市场分一杯羹
  • 大数据算法——布隆过滤器
  • 关于ThreadLocal为何不能在webflux中使用的问题
  • 生产级AI Agent系统架构:开源、可观测、可运维的六层栈
  • Java毕设项目: 基于 SpringBoot 的智能机器人企业官网管理系统的设计与实现 基于 SpringBoot 的协作机器人案例展示平台(源码+文档,讲解、调试运行,定制等)
  • 广州小程序开发十大品牌哪家好?
  • Java毕设选题推荐:基于 Java 的高中生德育实践档案管理系统的设计与实现 基于 Java 的高中学生学业素质综合档案系统【附源码、mysql、文档、调试+代码讲解+全bao等】
  • 传统包装仅起保护作用,编程包装文案视觉溢价测算,高颜值文化包装,提升礼盒服饰成交单价。
  • 还不懂Redis?看完这个故事就明白了!
  • Nuke Survival Toolkit:150+专业插件终极指南,彻底改变你的Nuke合成工作流
  • 转移癌原发灶难定?CK7/20 组合拳精准锁定“元凶”
  • 【课程设计/毕业设计】基于 SpringBoot 的智慧校园助学兼职发布平台的设计与实现【附源码、数据库、万字文档】
  • Scapy,网络数据包的瑞士军刀
  • 程序员不想只靠死工资增收!盘点 5 类适合技术人深耕的优质副业,闲暇时间额外增加收入
  • Java毕设选题推荐:基于 SpringBoot 的应急物资库存监控预警系统的设计与实现 基于 SpringBoot 的公共应急物资出入库溯源系【附源码、mysql、文档、调试+代码讲解+全bao等】
  • Playwright与MCP协议结合:AI驱动的浏览器自动化新范式
  • 制造业MES系统哪个好用?中小工厂选型看这几个维度就够了
  • KMR221与PIC32MZ的高精度电压监测方案解析
  • 微信小程序开发学习文档(十)