资讯详情

Linux Socket网络编程实战:三次握手、核心函数与避坑指南

📅 2026/10/6 6:24:17 | 华诺云谱 👁 阅读
Linux Socket网络编程实战:三次握手、核心函数与避坑指南
简介一份面向Linux网络编程学习者的Socket入门资料系统讲解网络中进程如何通信、Socket的由来与本质并逐一梳理socket()、bind()、listen()、connect()、accept()、read()/write()、close()等核心函数的用途与调用场景。内容还结合TCP协议详细拆解三次握手建立连接与四次握手释放连接的完整过程并配套一个可直接上手的编程实例帮助读者把协议原理落实到代码层面。文档结构从进程通信标识问题逐步深入到函数实现和TCP状态迁移逻辑清晰末尾留有思考题便于自测巩固。资源为单个docx文档压缩包大小仅77KB内容精炼、便于查阅和打印已有241人学习下载适合刚开始接触Linux网络编程、希望快速回顾Socket要点或准备课程实训的开发者参考。1. 一切皆 SocketLinux 网络编程为什么绕不开它不管你做嵌入式、后端服务还是写运维脚本只要涉及 Linux 网络编程最终都会撞到同一堵墙Socket。它不是一个库而是一套内核提供的接口把 TCP/IP 协议栈的能力包装成“打开、读写、关闭”三个动作。很多初学者把 Socket 当 API 背但真正的问题是进程怎么在网络里互相找到对方三次握手到底在哪个函数里发生的为什么服务器端必须 bind 而客户端不用这套资源从理论到实例把整条链路讲清楚了适合两类人——刚入门想搞明白 TCP 通信原理的学生以及写服务端代码时遇到过“莫名其妙连不上、发不出去”的从业者。它能解决的不只是“会调函数”而是让你在出问题时知道该查哪里。2. Socket 核心函数与 TCP 连接建立释放从三元组到三次握手2.1 网络中进程怎么标识IP 协议 端口三元组本地进程间通信可以用 PID 标识进程但网络环境里 PID 没有意义因为进程属于不同主机。TCP/IP 协议族给出的方案是用 IP 地址标识主机用传输层协议和端口标识主机上的应用进程。于是“IP 地址 协议 端口”构成三元组唯一确定一个网络进程。Socket 就是围绕这个三元组设计的接口。这里有个容易忽略的点Socket 的类型不是随便配的。协议族 domain 决定地址格式比如 AF_INET 要求 IPv4 地址加 16 位端口AF_UNIX 则要求一个绝对路径名作为地址。类型 type 决定传输语义SOCK_STREAM 是流式可靠传输对应 TCPSOCK_DGRAM 是数据报对应 UDP。protocol 通常传 0让系统根据 type 自动选择默认协议。如果传了 IPPROTO_UDP 搭配 SOCK_STREAM内核直接报错因为流式 socket 只认 TCP/SCTP 这类面向连接的协议。2.2 socket() 到 close()九个基础函数一条链路以 TCP 服务器为例标准调用链是 socket() → bind() → listen() → accept()客户端是 socket() → connect()。连接建立后两边用 read/write 或 send/recv 传数据最后 close() 收场。socket() 创建的是“主动”描述字它还没有地址内核只是分配了一个存在于协议族空间的文件描述字。想给它固定地址必须调用 bind()。bind() 的三个参数分别是 sockfd、指向 sockaddr 的指针和地址长度。注意 sockaddr_in 的 sin_port 必须用 htons() 转成网络字节序sin_addr 用 inet_pton() 转换。如果服务器不调用 bind()那 listen() 时系统也会自动随机分配端口但客户就没法找到你了。listen() 把主动 socket 变成被动监听backlog 参数决定已完成三次握手但还没被 accept() 取走的连接队列上限。connect() 是客户端的主动连接请求调用后内核会发起 SYN。accept() 则从 listen 队列里取一个已完成握手的连接返回一个新的“已连接描述字”。这个新描述字和监听描述字是两个东西后面读写数据全部走新描述字。读写层面recvmsg()/sendmsg() 是万能接口因为数据用 msghdr 结构描述可以附带辅助数据。但日常最简单的场景用 read()/write() 就够。read() 返回 0 表示对端已关闭返回 -1 需要看 errnoEINTR 是中断ECONNRESET 是连接被重置。write() 同理EINTR 是中断EPIPE 是对方已经关闭连接。close() 并不是立刻发 FIN。它只是把描述字的引用计数减一只有当引用计数归零内核才会发起四次挥手。2.3 三次握手与四次挥手连接状态机长在哪几个函数里很多图解把三次握手画成“客户端发 SYN服务器回 SYNACK客户端回 ACK”但没人说这对应代码里的哪些步骤。实际对应关系是客户端调用 connect() 时内核发出 SYN随后 connect() 阻塞服务器 listen() 收到 SYN 后由内核完成 SYNACK 的回复同时 accept() 阻塞客户端收到 SYNACKconnect() 返回算法上称为“第二次握手返回”服务器收到客户端的 ACKaccept() 返回称为“第三次握手返回”。所以代码里你看到的现象是connect() 比 accept() 先返回。这往往让人误以为连接还没建立好就开始写数据其实 TCP 栈已经保证了握手完成。四次挥手更绕。主动关闭方调用 close() 发送 FIN被关闭方收到 FIN 后回 ACK此时被动方还可以继续发数据等它也 close() 时再发 FIN最后主动方回 ACK。整个过程中两个方向的 FIN 和 ACK 是独立的。如果两端同时调用 close()会出现两个 FIN 同时发送变成四次交叉挥手。提示理解三次握手只发生在 connect() 和 accept() 内部不要试图在应用层看到握手包除非你挂 tcpdump 抓包。3. 一个可运行的 TCP 实例mysocket 封装与编译运行3.1 文件结构与 mysocket 类设计这套资源里给了一个最小可运行的例子文件结构如下mysocket.h mysocket.cpp server.cpp client.cppmysocket 类把服务端和客户端的创建流程封装起来。它的头文件定义了四个私有成员domain、soctype、protype 和 isocket分别记录协议族、socket 类型、协议号和创建出来的描述字。公开接口是 createclient()、createserver()、recvmsg()、sedmsg()。注意这里的 sedmsg 是原文件里的拼写编译没问题但看着别扭你可以在自己的代码里改成 sendmsg()。mysocket.h 的完整代码#ifndef _MYSOCKET_H #define _MYSOCKET_H #include sys/socket.h class mysocket { public: mysocket(); ~mysocket(); bool createclient(int domain, int soctype, int protype, int port); bool createserver(int domain, int soctype, int protype, int port); void recvmsg(); void sedmsg(); private: int m_idomain; int m_isoctype; int m_iprotype; int m_isocket; }; #endif这里没有用虚析构也没有拷贝保护说明这个类只为单场景设计。实际项目里你会把它拆成 ClientSocket 和 ServerSocket 两个类但理解这个最小封装就足够了。3.2 服务端创建流程bind 到 listen 的细节mysocket.cpp 里 createserver() 做了三件事创建 socket绑定地址监听。代码如下bool mysocket::createserver(int domain, int soctype, int protype, int port) { m_idomain domain; m_isoctype soctype; m_iprotype protype; m_isocket socket(m_idomain, m_isoctype, m_iprotype); if (-1 m_isocket) { printf(create socket error\n); return false; } struct sockaddr_in servaddr; memset(servaddr, 0, sizeof(servaddr)); servaddr.sin_family domain; servaddr.sin_addr.s_addr htonl(INADDR_ANY); servaddr.sin_port htons(port); if (bind(m_isocket, (struct sockaddr*)servaddr, sizeof(servaddr)) -1) { printf(bind socket error\n); return false; } if (listen(m_isocket, 10) -1) { printf(listen socket error\n); return false; } printf(waiting for clients request\n); return true; }bind 那块有三个细节值得留意memset 整个 sockaddr_in 清零避免残留垃圾字段。sin_family 直接赋 domain这里 AF_INET 与 PF_INET 数值相同但严格说应该用 AF_INET。htonl(INADDR_ANY) 表示监听本机所有网卡不要写成固定 IP否则客户端只能连你写死的那个地址。backlog 设成 10含义是内核已完成握手等待 accept() 的连接队列最多 10 个。如果设 0有些内核会拒绝连接。3.3 客户端连接与发送connect 与 send 的正确姿势createclient() 的代码bool mysocket::createclient(int domain, int soctype, int protype, int port) { m_idomain domain; m_isoctype soctype; m_iprotype protype; struct sockaddr_in servaddr; m_isocket socket(m_idomain, m_isoctype, m_iprotype); if (-1 m_isocket) { printf(create socket error\n); return false; } memset(servaddr, 0, sizeof(servaddr)); servaddr.sin_family domain; servaddr.sin_port htons(port); if (inet_pton(AF_INET, 192.168.1.18, servaddr.sin_addr) 0) { printf(inet_pton error for 192.168.1.18\n); return false; } if (-1 connect(m_isocket, (struct sockaddr*)servaddr, sizeof(servaddr))) { printf(connect to server error 192.168.1.18\n); return false; } printf(Begin sending msg to server\n); return true; }客户端没有 bind()这是正常的。connect() 时内核会自动分配本机端口和 IP。inet_pton 把字符串 IP 转成网络字节序的二进制值。这里写死了服务器 IP你可以改成命令行参数传入否则换台机器就要重新编译。sedmsg() 是发送循环void mysocket::sedmsg() { int msgid 0; char buff[1024] {0}; while (1) { memset(buff, 0, 1024); sprintf(buff, msg %d.\n, msgid); if (send(m_isocket, buff, strlen(buff), 0) 0) { printf(send msg error: %s(errno: %d)\n, strerror(errno), errno); continue; } else { printf(send msg succ\n); msgid; } } }send() 返回小于 0 只代表本次调用失败不等于连接断开。这里 continue 会陷入死循环刷屏实际项目里应该根据 errno 判断EINTR 重试EPIPE 直接退出。3.4 编译运行与预期输出编译命令如下g -o server server.cpp mysocket.cpp g -o client client.cpp mysocket.cpp先跑 server再跑 client。server 端会打印waiting for clients request connfd4; rec msg from client: msg 0. msglen8client 端会打印Begin sending msg to server send msg succrecvmsg() 里的循环有个特点recv 返回大于 0 就打印但如果客户端一直发这段代码每收一条消息就把 buff 清零一次位置不对细节放到下一章讲。整体流程能跑通但这段代码只适合教学生产环境需要处理粘包、半包和 SIGPIPE。4. 避坑指南字节序、accept 阻塞与 close 语义的三个典型翻车现场4.1 字节序问题为什么必须用 htons/htonl现象服务器 bind 端口 6666客户端连的也是 6666但 connect 一直超时抓包发现目标端口变成了 0x1A0A 之类的乱值。原因x86 是小端序整数在内存里低位在前。直接赋值 sin_port 6666二进制是 0x1A0A十进制 6666 0x1A0A小端存储为 0A 1A内核按网络字节序大端解释端口就变成了 0x0A1A 2586。地址也一样字符串 IP 转成二进制后如果直接塞给 s_addr不经过 htonl就会反。解决端口一律 htons()IPv4 地址用 inet_pton() 或 htonl(INADDR_ANY)。这里不需要自己写字节序转换函数glibc 已经封装好。注意不要假定主机字节序等于网络字节序。哪怕你当前机器是大端换成小端 ARM 板子就翻车。4.2 accept 返回的“新描述字”和监听描述字搞混现象accept() 成功后用原来的监听描述字去 recv结果返回 -1errno 是 ENOTCONN 或 EINVAL。原因accept() 的两个描述字功能不同。监听描述字专门负责接受新连接它本身不承载数据已连接描述字才是为当前这个客户端建立的通信通道。代码里如果写成 recv(m_isocket, ...)等于试图从监听 socket 上收数据内核直接拒绝。解决在 recvmsg() 里把 accept() 的返回值存到 connfd后续 recv 全部用 connfd。服务端生命周期内监听描述字一直存在但每接受一个客户就要新建一个 connfd处理完立刻 close。4.3 close 不一定会触发 FIN引用计数陷阱现象服务端 close(connfd) 之后客户端 read() 没有返回 0而是继续阻塞过一段时间才超时。原因close() 只是引用计数减一。如果这个描述字被 fork 出来的子进程继承或者经过 dup/dup2 复制引用计数没有归零内核不会发 FIN。只有引用计数归零才真正关闭连接。解决如果确认不再使用连接先 shutdown(sockfd, SHUT_WR) 主动发 FIN再 close()。shutdown 不受引用计数影响直接关闭连接上的发送方向。多进程服务端尤其要养成这个习惯。4.4 recv 循环里的 memset 位置导致丢数据现象客户端连续发多条消息服务端只打印一部分或者打印出空串。原因看原代码的 recvmsg()while((n recv(connfd, buff, 1023, 0)) 0) { memset(buff, 0, 1024); buff[n] \0; printf(...%s..., buff); n 0; }这里每收一次就把 buff 清零但与此同时 recv 可能已经往 buff 写了新数据。memset 在 recv 之后执行会把刚收到的内容直接抹掉打印出来自然是空串。正确顺序是先接收再在接收到的数据末尾加 \0最后打印等下一轮循环再清零。解决把 memset 挪到 recv 之前while (1) { memset(buff, 0, 1024); n recv(connfd, buff, 1023, 0); if (n 0) break; printf(rec msg from client: %s\n, buff); }另外 recv 的第三个参数传 1023留一个字节给 \0这里是正确的。但循环里直接用 1024 大小的缓冲去收数据如果对方一次发超过 1023 字节会截断。对于这个例子够用生产环境要自行处理报文边界。4.5 listen 的 backlog 掩盖连接失败现象客户端 connect 偶尔失败errno 是 ECONNREFUSED但服务端明明在监听。原因backlog 设得不够大或者连接队列被占满。listen() 的 backlog 参数不只是“最大监听数”它决定内核中已完成三次握手但未 accept 的连接队列长度。如果应用层来不及 accept队列满后新的 SYN 会被内核丢弃客户端表现为连接超时或者被拒绝。解决按预期并发量设置 backlogLinux 内核会在传入值上做上限修正。必要时用 epoll 事件驱动即时 accept不要让连接在队列里排队。5. 验证与进阶用 strace 和 netstat 验证连接状态再谈非阻塞5.1 用 strace 看系统调用和三次握手编译运行之后很多人还是觉得“三次握手”是玄学。用 strace 直接把系统调用打出来strace -f -e tracesocket,bind,listen,accept,connect ./server你会看到服务端依次出现 socket()、bind()、listen()、accept()。accept() 会停在阻塞状态直到客户端连上来。再开一个终端启动 clientstrace -e tracesocket,connect ./clientconnect() 系统调用返回后TCP 握手实际已经完成。如果用 strace 看服务器端accept() 会在同一时刻返回新描述字。这正好对应之前的结论客户端的 connect 在第二次握手后返回服务端的 accept 在第三次握手后返回。抓包软件看的是报文流strace 看的是系统调用两者对照着看概念落地。5.2 用 netstat 观察连接状态程序运行中用 netstat 检查连接状态netstat -anp | grep 6666会看到类似这样的输出tcp 0 0 0.0.0.0:6666 0.0.0.0:* LISTEN 1234/server tcp 0 0 192.168.1.18:6666 192.168.1.10:54321 ESTABLISHED 1234/server tcp 0 0 192.168.1.10:54321 192.168.1.18:6666 ESTABLISHED 5678/clientLISTEN 表示服务端在等连接ESTABLISHED 表示三次握手已完成进入数据传输状态。如果看到 SYN_SENT说明客户端发出的 SYN 没有收到回应大概率是防火墙或 IP 错误看到 TIME_WAIT 则说明一方已经主动关闭连接进入等待回收状态这是四次挥手的正常产物。5.3 从阻塞到非阻塞一个最小改动原代码里 accept() 和 recv() 都是阻塞的一个服务端只能服务一个客户端。改成非阻塞其实不需要换整套框架先改 socket 属性和事件循环#include fcntl.h #include errno.h int oldflag fcntl(listenfd, F_GETFL, 0); fcntl(listenfd, F_SETFL, oldflag | O_NONBLOCK);accept() 在没有新连接时会立即返回 -1errno 是 EAGAIN。这时不要报错继续轮询。recv() 同理没有数据时返回 -1EAGAIN 表示暂无可读数据。这种写法简单但 CPU 空转只适合教学演示。真正生产环境用 epoll 注册 EPOLLIN 事件让内核主动通知可读状态。poll 和 epoll 的核心是把“等待多个描述字”从应用层挪到内核层struct pollfd fds[2]; fds[0].fd listenfd; fds[0].events POLLIN; fds[1].fd connfd; fds[1].events POLLIN; poll(fds, 2, -1);每次 poll 返回后检查 revents 里的 POLLIN就知道哪个描述字有数据可读。这样单线程也能同时处理多个连接是迈向高并发服务的第一步。从那以后我每次写 socket 程序都会先把 strace 打一遍确认 socket、bind、listen、accept 的顺序没有错再用 netstat 看一遍连接状态最后才写业务逻辑。这个习惯帮我省下了大量排查时间。如果这套实例能让你少走一半弯路那它就值了。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑