简介一份面向Linux网络编程初学者的Socket编程详解文档系统梳理网络进程间通信的核心问题如何通过IP、协议、端口三元组唯一标识一台主机上的进程并由此引出Socket在Unix/Linux中“一切皆文件”的设计理念。文档逐一拆解socket()、bind()、listen()、connect()、accept()、read()/write()、close()等关键接口的参数含义与调用关系结合TCP协议详解三次握手建立连接和四次挥手释放连接的完整流程对SYN、ACK、FIN等控制位的交互过程做了清晰说明。最后通过一个可直接运行的代码实例串联服务端与客户端的通信过程并留下一个思考题引导读者进一步讨论内容兼具原理讲解与工程实践价值。资源为1个docx文件压缩包仅77KB整体精炼紧凑适合正在学习网络编程、备考或准备从事Linux服务端开发的人群快速入门。已有241人学习过该资源可作为一份高性价比的参考笔记留存。1. 一切皆 Socket先搞懂网络进程通信的起点如果你刷过 Linux 面试题、做过嵌入式 Linux 项目或者只是把玩过几行网络代码一定听过“一切皆 Socket”。这话有点夸张但很贴切浏览器打开网页、IM 收发消息、云端日志上报底层几乎都是 Socket 在搬运数据。这份资源把 Linux 下 TCP Socket 的原理、六个基本接口、三次握手和四次挥手都串起来了还给了完整的 C 示例代码适合两种人一种是急着把服务端和客户端跑通的新手另一种是自己写过不少 Socket 代码、但想回头把“连接建立/释放到底发生在哪个函数”彻底理清的老手。它解决的不是“看懂”而是“能跑、能改、能讲清楚”。2. Socket 是什么文件模型延伸与网络字节序这道坎2.1 “打开-读写-关闭”模型在 Socket 上的映射Unix/Linux 的基本哲学是“一切皆文件”普通文件可以用 open → read/write → close 的模式操作。Socket 也是这种模式的一种实现它本质上是一种特殊文件内核会为它创建一个描述符后续所有收发操作都靠这个描述符来完成。这个映射关系很直接socket() 对应 open()负责创建描述符read()/write()、recv()/send() 对应文件的读写操作close() 对应关闭。这套抽象的好处是网络 I/O 可以像文件 I/O 一样处理不需要为网络协议栈另搞一套心智模型。你在应用中拿到的 int 类型的 socket 描述符和文件描述符在数值上共享同一个 fd 空间这也是为什么某些没区分类型的误用——比如把普通文件 fd 当 socket 传——会带来奇怪问题的根源。2.2 协议族、类型与协议创建 Socket 前先选型socket(int domain, int type, int protocol) 三个参数分别决定地址格式、传输方式和具体协议。domain 控制地址族AF_INET 对应 IPv4 的“32 位 IP 16 位端口”组合AF_INET6 对应 IPv6AF_UNIX/AF_LOCAL 则用绝对路径名作为地址用于同一主机内的进程通信。type 决定数据流形态SOCK_STREAM 是字节流TCP 用SOCK_DGRAM 是报文UDP 用SOCK_RAW 是原始套接字能直接读写 IP 层及以下的数据。protocol 通常填 0让系统根据 type 自动选择默认协议比如 SOCK_STREAM 0 就是 IPPROTO_TCPSOCK_DGRAM 0 就是 IPPROTO_UDP。需要强调的是type 和 protocol 不能随意组合SOCK_STREAM 搭配 IPPROTO_UDP 是非法的编译期不报错运行期行为无法预料这是最早遇到的坑之一。下表列出最常用的三组domaintypeprotocol典型用途AF_INETSOCK_STREAM0自动选 TCPWeb 服务器、数据库连接AF_INETSOCK_DGRAM0自动选 UDPDNS 查询、音视频通话AF_INET6SOCK_STREAM0自动选 TCP支持 IPv6 的服务端创建出来的 socket 描述符存在于协议族空间中但还没有具体地址。想挂地址就得调 bind()否则在调用 connect() 或 listen() 时系统会自动随机分配一个端口。2.3 主机字节序与网络字节序出过“血案”的地方字节序是 Socket 编程里最容易被新手无视、却最容易引发线上问题的一个点。主机的整数在内存里有两种排列方式高位字节在低地址叫大端Big-Endian低位字节在低地址叫小端Little-Endian。而 TCP/IP 协议规定传输层和网络层头部所有二进制整数的传输顺序必须是大端也就是网络字节序。最典型的场景是端口号赋值如果你直接把 int 型的 6666 塞进 sin_port在 x86 这类小端机器上字节序和网络要求正好相反包发出去对端解析出来的端口就不是 6666。原文提到公司项目里因字节序问题引发过“血案”导致一堆莫名其妙的问题这不是夸张。提示对主机字节序不要做任何假定凡是写入 sockaddr_in 的多字节整数一律先转成网络字节序用 htonl/htons 处理读取时用 ntohl/ntohs 转回来。3. 六个基础函数打通 TCP 通信从 bind 到 close3.1 socket()创建描述符像 open 一样// 服务端和客户端的第一步都是创建 socket 描述符 int sockfd socket(AF_INET, SOCK_STREAM, 0); if (-1 sockfd) { perror(socket create error); exit(EXIT_FAILURE); }socket 函数返回的是唯一标识这个 socket 的描述字后续 bind、listen、connect、read、write 全拿它做第一个参数。三个参数的含义在第 2.2 节已经拆过这里只强调一点protocol 填 0 时内核按 type 自动匹配默认协议这是最省事的写法。正常情况下 socket() 几乎不会失败一旦返回 -1多半是权限或资源受限用 perror 看一眼 errno 基本能定位。3.2 bind()把地址绑定到描述符struct sockaddr_in servaddr; memset(servaddr, 0, sizeof(servaddr)); servaddr.sin_family AF_INET; // IPv4 地址族 servaddr.sin_addr.s_addr htonl(INADDR_ANY); // 监听所有本机网卡 IP servaddr.sin_port htons(6666); // 端口转网络字节序 if (bind(sockfd, (struct sockaddr *)servaddr, sizeof(servaddr)) -1) { perror(bind socket error); close(sockfd); exit(EXIT_FAILURE); }sin_family 要与创建 socket 时的 domain 保持一致s_addr 填 INADDR_ANY 表示不指定具体 IP内核自动绑定本机所有可用地址这种写法在多网卡环境下更省心也是服务端最常见的做法sin_port 必须经过 htons()这是第 2.3 节血案的根源所在。bind() 的目的就是把协议族中的一个具体地址赋给 socket 描述字。客户端不必也不会在 connect 之前手动 bind系统会在 connect() 时自动分配一个空闲端口和本机 IP 组合所以第 3.3 节的 connect 调用里不再有 bind 步骤。3.3 listen() 与 connect()被动与主动的分水岭// 服务端把主动 socket 变为被动监听backlog 设为 10 if (listen(sockfd, 10) -1) { perror(listen socket error); exit(EXIT_FAILURE); } // 客户端发起连接请求 struct sockaddr_in servaddr; memset(servaddr, 0, sizeof(servaddr)); servaddr.sin_family AF_INET; servaddr.sin_port htons(6666); inet_pton(AF_INET, 192.168.1.18, servaddr.sin_addr); if (connect(clientSock, (struct sockaddr *)servaddr, sizeof(servaddr)) -1) { perror(connect to server error); exit(EXIT_FAILURE); }socket() 创建出来的 socket 默认是主动类型的listen() 把它变成被动类型等待客户端的连接请求。第二个参数 backlog 表示内核为这个监听 socket 排队的最大连接数在 Linux 上它还受/proc/sys/net/core/somaxconn的上限约束你设 100内核可能只允许 64压测时 Connection refused 往往就是队列满了。connect() 在客户端调用第一个参数是客户端 socket 描述字第二个参数是服务器的地址。注意 inet_pton 把点分十进制的 IPv4 字符串转成网络字节序的二进制地址返回 0 说明地址格式非法这里也是一处常见的出错点。3.4 accept()监听描述字与已连接描述字int connfd accept(listenfd, (struct sockaddr *)NULL, NULL); if (-1 connfd) { perror(accept socket error); continue; }重点在返回值accept() 的第一个参数是监听 socket 描述字它是服务器启动时 socket() 创建的那个生命周期贯穿服务器始终而 accept() 成功返回的连接 socket 描述字是内核为新到达的 TCP 连接临时生成的服务完一个客户就要 close 一个。一个服务器通常只创建一个监听 socket多个并发连接靠 accept 逐个取回已连接描述字。这里把第二个参数传 NULL会让内核不返回客户端的协议地址对只想验证连通性的场景足够生产环境一般要填一个 sockaddr_storage 来记录对方 IP 和端口便于做日志和来源检查。提示把监听描述字误当成数据收发描述字去 read/write是最常见的新手翻车动作。accept 之后务必用返回值 connfd 收发数据。3.5 read()/write()/recv()/send()数据通道与返回值语义ssize_t n read(connfd, buf, sizeof(buf)); if (n 0) { // 对端关闭连接 close(connfd); break; } else if (n 0) { // EINTR: 被信号中断重试ECONNRESET: 对端异常断开 perror(read error); break; } else { // 实际读到的字节数为 n }网络 I/O 有六组函数read()/write()、recv()/send()、readv()/writev()、recvmsg()/sendmsg()、recvfrom()/sendto()。其中 recvmsg()/sendmsg() 是最通用的 I/O 函数前几组基本都能由它们替代——它们能携带辅助数据、多缓冲 iovec 和源地址信息底层实现也最完整。返回值语义必须背熟read() 返回 0 表示对端已关闭连接返回负数要分 EINTR信号中断可重试和 ECONNRESET对端进程崩溃或 reset返回正数是实际读到的字节数且不保证一次读满请求长度。write()/send() 返回正数只表示部分或全部写入内核缓冲不代表对端应用进程已经拿到返回 -1 且 errno 为 EPIPE 时说明对端已经关闭连接继续写只会收获 SIGPIPE 信号好一点的库会主动忽略它然后从 write 返回值里发现错误。4. 三次握手与四次挥手Socket 函数背后的 TCP 语义4.1 connect 在第二次握手返回accept 在第三次握手返回TCP 建连的三次握手是面试最爱考的过程但要落到 Socket 函数上才真正有用。流程是这样客户端调用 connect()内核发送 SYN Jconnect() 进入阻塞服务端内核收到 SYN J放入半连接队列accept() 尚未返回服务端发送 SYN K ACK J1客户端收到 SYN K ACK J1connect() 返回同时发送 ACK K1服务端收到 ACK K1三次握手完成accept() 返回。结论很反直觉客户端的 connect() 在第二次握手时返回服务端的 accept() 在第三次握手之后返回。换句话说connect() 返回时对端服务端进程还阻塞在 accept() 里。这个时间差在写超时逻辑时很重要——connect 成功不代表服务端应用层的 accept 已经执行完毕只是代表内核完成了握手。高并发服务里accept() 返回晚于 connect() 是很正常的。4.2 四次挥手谁先 close 谁就进入 TIME_WAIT连接释放的四个步骤对应的也是函数调用默认从 close() 触发。以客户端主动关闭为例客户端进程调用 close()TCP 发送 FIN M这个方向进入半关闭服务端收到 FIN M内核回复 ACK M1并且把“收到 FIN”当作文件结束符传递给应用进程服务端 read() 会返回 0服务端进程随后调用 close()发送 FIN N客户端收到 FIN N回复 ACK N1进入 TIME_WAIT 状态等待 2MSL 后连接才彻底释放。注意 close() 的默认行为只是把 socket 描述字的引用计数减一只有计数归零才会真的触发 FIN。如果父进程 fork 出一个子进程共用同一个 socket 描述字光关掉父进程那个副本连接不会断开必须父子都 close 才算数。想要精确控制只关写方向可以用 shutdown()它不依赖引用计数直接关掉指定方向的数据流。4.3 backlog 与连接队列不设参数就翻车listen() 的第二个参数 backlog 在 Linux 上影响的是全连接队列accept 队列的长度。握手完成的连接会从半连接队列移到全连接队列accept() 才从这里取。如果 backlog 太小高并发下客户端 connect() 会报 Connection refused设太大又受内核 somaxconn 限制。常规做法是监听端口设 128 或 256并且启动时读一下/proc/sys/net/core/somaxconn两者取较小值。这份资源里的例子 listen() 用了 10单机演示足够拿到压测场景就要调大这是最容易踩的性能盲区。提示不要把 backlog 当成“最大并发连接数”。它只是内核里等待 accept() 取走的已完成握手连接数上限真正的并发连接数由进程模型和资源决定两者不是一回事。5. 避坑记录字节序、端口占用、写死 IP 三个高频翻车点5.1 字节序不转换本机正常跨机乱码现象同一台机器上跑 server 和 client 收发正常换个机器就出现端口错乱或数据内容对不上。原因在 x86 小端机器上直接给 sin_port 和 sin_addr 赋整型值没有经过 htons/htonl 转换发出的报文里端口和地址字节序与 TCP/IP 要求不一致。本机回环测试时因为收发都在同一台机器上错误被对称地掩盖了。解决所有写入 sockaddr_in 的多字节整数一律用 htons/htonl或者用 inet_pton 处理点分十进制字符串读取时用 ntohs/ntohl 转回来。写代码时需要养成习惯看到 sin_port 就先问一句“转没转”。5.2 bind 报 Address already in use重启服务第二遍就崩现象服务端第一次启动正常CtrlC 停掉后马上再启动bind() 返回 -1errno 提示 Address already in use。原因主动关闭一方进入 TIME_WAIT 状态TCP 连接的四元组仍占着端口。服务端作为被动关闭方多数情况下会走 TIME_WAIT端口没被系统回收前不能立刻重新绑定。解决在 bind() 之前设置 SO_REUSEADDR。这个选项允许 TIME_WAIT 状态下的端口被重新绑定是生产服务端启动代码里的标配。写法如下int on 1; setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, on, sizeof(on));注意 setsockopt 要在 bind() 之前调用顺序反过来不起作用。5.3 客户端 IP 写死在代码里换网段就连不上现象示例代码里inet_pton(AF_INET, 192.168.1.18, servaddr.sin_addr)是写死的字符串把 client 拷贝到另一台机器跑connect() 立刻失败或者连去不存在的地址。原因IP 是硬编码进二进制代码的没有做成启动参数或配置文件。这个 192.168.1.18 是原作者当时调试环境的地址对每个人来说都不一样很多时候甚至和当前网段毫无关系。解决改成命令行参数传入比如./client 127.0.0.1或者至少在代码里留一个宏让使用者可以改。同机回环测试直接用 127.0.0.1跨机测试填服务端实际 IP别嫌麻烦把它写成变量。大部分从网上粘贴的 Socket 代码都有类似的写死 IP 问题提示看到 inet_pton 的第二个参数是字符串字面量就该考虑改成参数了。5.4 accept 只接一次就退出误把监听描述字当万能钥匙现象服务器处理完第一个客户端的连接和消息后日志不再有新连接打印进程要么退出要么卡住第二个终端里的 client 连不上。原因accept() 被放在了循环之外或者循环条件写错导致只执行一次就跳出。一个常驻服务端的 accept() 必须用无限循环包住每次取回新的已连接描述字处理完立刻 close然后回到 accept() 等下一个连接。解决while (1) { int connfd accept(listenfd, NULL, NULL); if (-1 connfd) { continue; // 被信号打断或临时失败重试 } // 处理这个连接读完数据后 close(connfd) close(connfd); }close(connfd) 一定要在一个连接处理完时及时执行否则打开的描述字会一路涨到进程 fd 上限之后 accept() 就会报 EMFILE。5.5 示例代码里的两处手打小瑕疵\\n和buff[n]现象原文的 mysocket.cpp 里有一句printf(Begin sending msg to server\\n)它输出来的字符串尾部带着字面的反斜杠和 n而不是换行。另外 recvmsg() 里buff[n] \0写在 memset 之后等于把刚清空的缓冲区的第 n 个字节又置为 0。原因老博文复制到 HTML 时反斜杠被转义成\\n编译解析成两个字符\和nbuff[n] \0的赋值位置应该在 recv 之后、memset 之前他写反了。更关键的是示例代码没有约定客户端何时断开recvmsg() 里 recv 会一直阻塞等待数据而客户端的 while(1) 又永远不退出两边的循环都没有出口。解决下载源码后用 gcc 编译如果看到输出带反斜杠 n把\\n改回\n把buff[n] \0挪到 recv() 返回之后、memset 之前给客户端加一个发送轮次上限发完调用 close() 关闭 socket服务端 recv() 返回 0 时退出循环并 close(connfd)。改动不大但能让代码跑得“有始有终”。6. 把示例代码跑起来编译、双端验证与抓包确认6.1 文件结构与编译命令下载包里是四个有效文件mysocket.h、mysocket.cpp、server.cpp、client.cpp另外还有两个空文件 server.h 和 client.h直接忽略。mysocket 类封装了服务端和客户端的连接建立逻辑createserver() 负责创建 socket、绑定 6666 端口并进入监听createclient() 负责创建 socket 并 connect 到写死的 IPstd::vector 编译环境最低要求 C11 编译器工作正常。在 Linux 命令行下编译g -o server server.cpp mysocket.cpp g -o client client.cpp mysocket.cpp如果编译器较老加上-stdc11基本不会有兼容问题。需要说明的是这段代码用的是类封装但内部是大量 C 函数没有引入任何外部依赖库g 编译无额外链接参数。两个可执行文件分别是服务端和客户端入口编译成功后把 server 和 client 放在同一台机器的同一个网段内或者都在本机。6.2 先 server 后 client运行与观察# 终端 1启动服务端 ./server # 终端 2启动客户端同机测试前把代码里的 IP 改成 127.0.0.1 ./client服务端先打印waiting for clients request说明 socket、bind、listen 三步都成功了。客户端启动后服务端如果收到连接就会打印connfd...; rec msg from client:...客户端窗口持续输出send msg succ。这是最简单的验证粘包、半包问题在这个量级的例子里不会出现数据小于 1024 字节且一次 send 一次 recv 正好对上。但要注意运行日志刷屏后 CtrlC 关闭服务端下一次再启动就可能碰到第 5.2 节的 Address already in use这就直接把踩坑环节的 SO_REUSEADDR 用上。6.3 用抓包确认三次握手与四次挥手光看打印还不能确认 TCP 层的握手过程抓包是最可靠的验证方式。在另一个终端运行 tcpdump过滤本地回环口上 6666 端口的流量# -nn 不做域名和端口反解-i lo 抓回环口如果跨机改成对应网卡名 sudo tcpdump -i lo port 6666 -nn正常能看到三行核心记录客户端发 SYN、服务端回 SYNACK、客户端再发 ACK三包两两对应三次握手。用 CtrlC 停掉 client 后再看包结尾会出现 FIN 和 ACK 成对交换的记录对应四次挥手。如果只看到 SYN 没有后续说明服务端没走到 accept() 或者 backlog 太低如果 FIN 之后出现大量重传多半是 close 引用计数没归零。这套抓包手段比打印日志可靠得多排查 socket 问题时我基本都会先开一次 tcpdump。验证完成后顺便确认 socket 状态ss -tn | grep 6666可以看到连接处于 ESTABLISHED。我就吃过一次亏写服务端时忘了加 SO_REUSEADDR调试时每改一次代码重启服务都要等两分钟 TIME_WAIT 过期后来把那行 setsockopt 写进所有服务端模板里再也没在这个地方浪费时间。从那以后我每次写完 socket 代码都要强制走一遍“字节序检查、IP 检查、accept 循环检查、退出机制检查”这四步确认无误才跑长度测试。希望帮到你。本文还有配套的精品资源点击获取