在做网络通信模块开发的时候有一段时间我被 TCP 层的各种异常搞得焦头烂额连接无故断开、对端收不到数据、服务端端口明明已经释放却一直报地址被占用。后来我把整个流程从头到尾重新梳理了一遍才发现大部分问题其实不是业务逻辑的问题而是对 TCP 网络编程里每个 API 的语义、每个连接状态的变化了解得不够透彻。这篇文章我会用 C 语言从零实现一个可运行的 TCP 服务端和客户端一边写代码一边把 socket、bind、listen、accept、connect、send、recv、close 这些核心 API 讲清楚同时把三次握手、四次挥手、内核缓冲区行为这些底层机制放到具体场景里解释。适合刚接触网络编程的开发者参考也适合写了一段时间但仍然说不清“为什么这样写”的人查漏补缺。1. 整体设计与思路拆解1.1 为什么用 C 写 TCP 程序很多同学学习网络编程的时候第一选择是高级语言里的 Socket 封装类用起来确实方便但底层到底发生了什么常常是一个黑盒。C 语言没有自带网络库socket、bind 这些接口基本都是操作系统提供的系统调用你写的每一个调用都会直接跟内核交互。正因为中间少了一层封装理解 TCP 时反而更接近本质。另外虽然 C 语言没有网络库但它面向系统编程非常直接头文件里声明、链接时找实现服务器端组件和嵌入式场景里大量使用 C 写网络层。理解这套 API 之后后续再去看 Java、Go 这类语言的网络源码就不会觉得抽象。所以这篇文章的代码虽然是用 C 写的但它本质上是在拆解所有 TCP 程序都要经历的通用路径换到其他语言时你依然能说出每一步在做什么。1.2 TCP 连接从建立到断开的核心流程一条 TCP 连接的生命周期可以分为三个阶段建立、传输数据、断开。建立阶段通常叫三次握手客户端先发一个 SYN 报文给服务端服务端收到后回复 SYNACK客户端再回一个 ACK。为什么不是两次握手简单理解三次握手是为了让双方都确认“自己能发、能收对方也能发、能收”。只有双方都拿到对方的确认信息连接才算真正建立后续数据才不会错乱。传输数据阶段TCP 把应用层交给它的数据看成字节流不保证写一次就一定能被读一次。它按序号组织报文、通过确认机制保证可靠性。比如你调用 send 发送 1024 字节可能一次就发出去也可能因为发送缓冲区不够只接收了其中一部分剩余部分要靠循环重发。这也是为什么很多教程都强调 send/recv 的返回值必须检查。断开阶段是四次挥手主动关闭的一方发送 FIN另一方回复 ACK然后另一方也发送 FIN主动方再回复 ACK。这里牵扯出 TIME_WAIT 状态很多服务端端口耗尽问题都跟它有关后面会在排查章节详细讲。先把这条主线印在脑子里后面看代码就不会迷路。1.3 服务端和客户端的主流程对比下面这个表格把两端的调用顺序列在一起后面每看到一个函数就知道它在整个生命周期里处于什么位置。阶段服务端客户端创建套接字socket()socket()绑定地址端口bind()一般不用 bind()转为监听listen()一般不调用建立连接accept() 接收连接connect() 发起连接数据收发recv() / send()send() / recv()关闭连接close() / shutdown()close() / shutdown()看起来很简单但有个细节值得注意。服务端的 accept 不是可选的它从内核待处理的连接队列里取出一条连接并返回一个新的文件描述符。原来那个监听 socket 继续留在原地等着接收下一个连接这个“一次监听、多次 accept”的模型是理解服务端并发的基础。客户端的 connect 会触发三次握手它不一定像很多人想的那样会一直等到服务端应用层处理完毕而是等内核收到 ACK 之后就会返回对应用层来说只要没返回错误就可以认为连接可用。2. 核心 API 拆解与实操要点2.1 socket 创建第一个 fd 的由来先看创建套接字的函数int server_fd socket(AF_INET, SOCK_STREAM, 0);参数 AF_INET 表示 IPv4 地址族SOCK_STREAM 表示流式套接字也就是面向连接的 TCP 语义第三个参数 0 表示让内核根据前两个参数自动选择传输协议。如果是 AF_INET SOCK_STREAM内核就会选 TCP如果是 AF_INET SOCK_DGRAM就会选 UDP。第三个参数在很多场合直接写 0 就行但如果你明确知道底层协议也可以显式传入 IPPROTO_TCP。返回值是一个文件描述符本质上是一个整数。后面所有操作都围绕这个整数展开。常见创建失败原因包括文件描述符耗尽EMFILE、权限问题EPERM比较少见、以及地址族不支持。判断返回值时注意是小于 0不要只判断等于 -1因为不同平台的错误码还可能有扩展养成检查返回值的习惯后面会少踩很多坑。2.2 bind、listen、accept服务端的三个台阶bind 的作用是把 socket 和一个具体的地址、端口绑定起来。函数原型是int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);这里有个让新手困惑的地方bind 接收的是 struct sockaddr *但我们实际构造的是 struct sockaddr_in。这属于 C 语言里常见的“类型擦除”手法两种结构体在内存布局上兼容所以需要把 sockaddr_in 强制转换成 sockaddr。写的时候先 memset 清零再给 sin_family、sin_addr、sin_port 赋值。listen 是把 socket 变成监听状态第二个参数 backlog 代表内核为这个监听 socket 维护的已完成连接队列的最大长度。注意它不是最大连接数而是“应用层还没 accept 的已完成三次握手连接”可以排队的数量。设置太小并发来了以后客户端握手会卡住设置太大又可能让应用层来不及处理时积压一堆连接。经典做法是设一个相对保守的值比如 5、16 或 32然后根据线上压力逐步调整。accept 是从队列里取一个已完成握手的连接。它返回的不是监听 socket 自己而是一个新的 fd这个新 fd 才对应一条具体的连接。很多人第一次看到 accept 里那两个 sockaddr 和 socklen_t 参数时不太明白其实它们就是用来带出对端 IP 和端口的方便你记录是谁连进来了。如果 accept 失败常见错误有 EMFILE进程 fd 耗尽时它会一直失败很多服务端程序写得不严谨就会在这里死循环。2.3 connect、send、recv客户端与数据通道connect 的函数原型int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);客户端调用 connect 后会触发三次握手返回 0 表示连接成功。但要注意成功不代表服务端应用层已经 ready而是内核层面已经完成握手如果连接的目的就是要确认应用层真的可用往往需要上层协议来做健康检查比如发起一次真正的请求看有没有响应。send 负责把数据从应用层缓冲区拷贝到内核发送缓冲区返回的是实际拷贝的字节数不一定是你要发的长度。recv 正好相反它从内核接收缓冲区把数据拷贝到你的 buffer返回 0 表示对端已关闭连接返回 -1 表示出错。这里特别提醒不要假设一次 send 就能把全部数据送出去也不要假设一次 recv 就能收到完整一条消息。在非阻塞场景下这两个函数可能返回 EAGAIN/EWOULDBLOCK在信号打断场景下可能返回 EINTR。这些都需要在代码里处理后面示例代码会给出简化的处理方式。2.4 close 与 shutdown断开连接的细节close 会让文件描述符的引用计数减一当引用计数归零时内核才真正发起关闭流程。如果你 fork 了子进程父子进程共享同一个 socket fd只有双方都 close 了连接才会关闭。shutdown 则不同它直接操作连接本身可以指定关闭发送方向还是接收方向。shutdown(sock, SHUT_WR) 表示我不会再发送数据了但对端发给我的数据我还能继续收。这在很多应用层协议里很常见比如 HTTP/1.1 或 FTP 中的半关闭场景。close 则会立刻释放 fd如果接收缓冲区里还有没读的数据可能会发送 RST而不是正常的 FIN这会直接影响对端看到的行为。所以重视“优雅关闭”的程序会先 shutdown 再 close。具体的四次挥手过程是主动方发 FIN被动方回 ACK被动方处理完数据后也发 FIN主动方回 ACK。主动方在收到被动方 FIN 后进入 TIME_WAIT会等待一段时间才真正释放端口这是为了保证最后的 ACK 能到达对端。TIME_WAIT 也是运行时遇到 “Address already in use” 报错最常见的来源之一。3. 完整实操一个 TCP 回声服务端和客户端3.1 环境准备与代码组织我这里的环境是 Linux 发行版加 gcc不需要任何第三方库直接用系统自带的头文件就能编译。我习惯把代码分成两个文件server.c 和 client.c避免把两端逻辑混在一起调试的时候也更清晰。代码里的端口我暂时定成 8080回环地址 127.0.0.1 只用于本机测试方便验证。写代码之前先把要包含的头文件列出来sys/socket.h 里是 socket 相关函数声明netinet/in.h 里定义了 sockaddr_in 结构和 htons/htonl 等字节序转换函数arpa/inet.h 里有 inet_ntoa/inet_pton 这类地址转换函数unistd.h 里是 close、read、write 这些系统调用。另外还需要 string.h、stdio.h、stdlib.h。这些头文件在不同 Linux 发行版上都是自带的不需要额外安装任何开发包。3.2 服务端代码实现与注释下面这段是完整可运行的服务端代码。演示目标很简单接收客户端发来的内容再原样返回也就是大家都知道的 echo 服务。#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 8080 #define BUFFER_SIZE 1024 int main() { int server_fd, client_fd; struct sockaddr_in server_addr, client_addr; socklen_t client_len sizeof(client_addr); char buffer[BUFFER_SIZE]; server_fd socket(AF_INET, SOCK_STREAM, 0); if (server_fd 0) { perror(socket); exit(EXIT_FAILURE); } int opt 1; setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr htonl(INADDR_ANY); server_addr.sin_port htons(PORT); if (bind(server_fd, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { perror(bind); close(server_fd); exit(EXIT_FAILURE); } if (listen(server_fd, 5) 0) { perror(listen); close(server_fd); exit(EXIT_FAILURE); } printf(Server listening on port %d\n, PORT); while (1) { client_fd accept(server_fd, (struct sockaddr *)client_addr, client_len); if (client_fd 0) { perror(accept); continue; } printf(Client connected: %s:%d\n, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); ssize_t n recv(client_fd, buffer, BUFFER_SIZE - 1, 0); if (n 0) { perror(recv); } else if (n 0) { printf(Client closed connection\n); } else { buffer[n] \0; printf(Received: %s\n, buffer); send(client_fd, buffer, n, 0); } close(client_fd); } return 0; }简单解读几个容易看漏的地方。bind 之前设置了 SO_REUSEADDR这个选项解决的是 TIME_WAIT 导致的重启绑定失败问题。server_addr.sin_addr.s_addr 用 htonl(INADDR_ANY)表示绑定所有本地地址这样服务端不管哪个网卡收到数据都交给这个监听 socket 处理。listen 的 backlog 我在这里填了 5演示环境完全够用。recv 之后立刻 close所以这个服务端是典型的“接收一次、回复一次、关闭连接”如果你想连续交互需要在外层再加循环并为每个连接单独处理。3.3 客户端代码实现与注释客户端相对简单创建 socket 后直接构造服务端地址并 connectconnect 成功之后发送数据然后接收服务端的回显。#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 8080 #define BUFFER_SIZE 1024 int main() { int sock; struct sockaddr_in server_addr; char buffer[BUFFER_SIZE]; sock socket(AF_INET, SOCK_STREAM, 0); if (sock 0) { perror(socket); exit(EXIT_FAILURE); } 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(inet_pton); close(sock); exit(EXIT_FAILURE); } if (connect(sock, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { perror(connect); close(sock); exit(EXIT_FAILURE); } printf(Connected to server\n); const char *msg Hello TCP Server; send(sock, msg, strlen(msg), 0); ssize_t n recv(sock, buffer, BUFFER_SIZE - 1, 0); if (n 0) { buffer[n] \0; printf(Echo: %s\n, buffer); } else if (n 0) { printf(Server closed connection\n); } else { perror(recv); } close(sock); return 0; }这里用 inet_pton 把点分十进制的 IP 转成网络字节序的 32 位整数比老式的 inet_addr 更安全因为 inet_addr 解析失败时返回 INADDR_NONE没法严格区分原始地址的值真的是这个还是解析失败。connect 的地址参数同样要做类型转换底层结构和 bind 保持一致。3.4 编译、运行与验证编译命令不复杂但注意一定要加上 -Wall让编译器把可疑代码提示出来gcc -Wall -o server server.c gcc -Wall -o client client.c先启动服务端./server再开一个终端启动客户端./client正常情况下服务端会打印 “Client connected: 127.0.0.1:xxxxx”随后打印 “Received: Hello TCP Server”客户端会打印 “Echo: Hello TCP Server”。如果你想看连接状态可以在运行过程中执行ss -tnp用 netstat 也可以但 ss 在 Linux 上更常用。你会看到一条 ESTABLISHED 状态的连接表示三次握手已经完成。客户端退出之后再执行一次 ss会发现连接消失如果服务端很快重启又因为 SO_REUSEADDR 的关系不会因为之前的 TIME_WAIT 状态而启动失败。4. 常见问题与排查技巧实录4.1 bind 报 EADDRINUSE 是什么情况“Address already in use”是最常见的服务端启动失败原因。端口被其他进程占用只是其中一种情况还有一个隐蔽来源是 TIME_WAIT。当连接主动关闭时主动关闭方会进入 TIME_WAIT 状态默认大约 60 秒到 120 秒具体取决于内核参数。在这段时间里如果直接重启服务端并 bind 同一个端口就可能失败。解决办法是给 socket 设置 SO_REUSEADDR。这个选项的含义是允许内核在 TIME_WAIT 状态下重新绑定同一个地址端口。注意必须在 bind 之前设置否则无效。另一个 SO_REUSEPORT 选项允许多个 socket 绑定同一端口主要用在负载均衡场景平时别乱用因为它的行为在不同内核版本上有差异。4.2 recv 一直阻塞、accept 返回失败默认创建的 socket 是阻塞模式recv 在没有数据时会一直等。这不是 bug但要小心使用。如果你希望 recv 等一段时间就返回可以用 SO_RCVTIMEO 设置接收超时或者把 socket 改成非阻塞模式再配合 select/poll/epoll。出现 EAGAIN/EWOULDBLOCK 表示现在没有数据可读不是真正的错误重试即可。accept 返回失败也分好几种如果是 EINTR说明进程被信号打断重新调用 accept 即可如果是 EMFILE说明文件描述符已经用完这种情况在写多线程/多进程服务端时很常见需要检查是否每个新连接都被正确 close或者考虑调整进程的 fd 上限。不要在 accept 失败后直接退出很多线上服务就是这么莫名其妙挂掉的。4.3 连接被重置 ECONNRESET 和 SIGPIPE如果对端已经关闭了连接你还在发送数据可能收到 ECONNRESET也就是 “Connection reset by peer”。底层原因往往是对端收到数据后发现自己已经关闭于是发回 RST 报文。RST 和 FIN 不一样FIN 是正常关闭RST 是异常终止应用层通常只能通过读写返回值来感知当前不可用了。还有一个非常容易被忽略的坑向关闭的连接写入数据时进程可能直接收到 SIGPIPE 信号并终止默认行为会杀掉你的程序。很多后台服务日志里看不到任何错误进程就消失了多半是 SIGPIPE 搞的鬼。解决办法是忽略 SIGPIPE 信号或者 send 时加上 MSG_NOSIGNAL 标志。4.4 粘包与半包问题TCP 是面向字节流的没有“消息边界”的概念。你在应用层调用 send 发送一条消息对端 recv 到的可能是这条消息的一部分也可能是多条消息粘在一起。这不是 TCP 的 bug而是流式传输的天然特性。很多新手一看到“粘包”这个词就认为是协议问题准确说法是应用层需要自己规定消息边界。常用拆包方案有三种一是固定长度每条消息都按同样大小发送不足补零简单但浪费空间二是特殊分隔符比如自定义的 \r\n需要注意内容里可能包含分隔符导致误拆三是在消息头部加长度字段这是实际项目中最常用、最可控的做法。比如定义一个结构前 4 字节存长度后面跟着具体数据接收时先读 4 字节再根据长度读取完整消息。4.5 问题排查速查表这里把我平时排查 TCP 问题的一个常用思路整理成速查表不一定覆盖所有场景但能解决大部分问题。现象可能原因处理建议bind 报 EADDRINUSE端口被占用或 TIME_WAIT设置 SO_REUSEADDR检查占用进程进程莫名退出无日志收到 SIGPIPEsignal(SIGPIPE, SIG_IGN) 或使用 MSG_NOSIGNALrecv 返回 0对端正常关闭按正常流程关闭 fd不要继续 recvrecv 返回 -1errno 是 EAGAIN非阻塞模式下暂无数据稍后重试或交给 epoll/select 处理发送数据对端收不到未循环处理 send 短写检查 send 返回值剩余数据继续发送多个消息混在一起TCP 是字节流缺乏边界设计定长/分隔符/长度字段协议accept 失败且报 EMFILE进程 fd 耗尽检查未关闭的连接调大 fd 上限客户端 connect 卡住网络不通或服务端未监听检查端口、防火墙、监听地址5. 真实环境中的进一步优化5.1 非阻塞 IO 与超时设置生产环境里阻塞模型能撑住的并发非常有限。你不可能为每个连接都开一个线程尤其是连接数上万的时候。所以第一步往往是给 socket 设置非阻塞模式用 fcntl 加上 O_NONBLOCK 标志int flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK);设置之后recv / send 在数据未就绪时会立即返回 -1errno 是 EAGAIN 或 EWOULDBLOCK。如果只是偶尔做一次网络通信不一定要立刻上 epoll先给阻塞 socket 设置接收超时也很有效struct timeval tv { .tv_sec 3, .tv_usec 0 }; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv));超时之后 recv 返回 -1errno 是 EAGAIN。设置发送超时用的是 SO_SNDTIMEO思路一样。要提醒的是超时值的单位是秒和微秒不要在 Linux 上把 .tv_usec 写成毫秒这个错位很容易让超时时间意外变成 0 或极小值。5.2 多连接处理从多线程到多路复用如果连接数不多多线程模型最简单主线程 accept 之后把连接 fd 丢给一个新线程处理处理完线程退出时务必 close fd保证 fd 不会泄漏。但线程创建销毁有成本连接数一多就不划算。而且多线程模型要考虑共享数据的同步问题锁用不好性能可能不升反降。更常见的生产方案是 epoll 事件循环。epoll 把 fd 交给内核统一监听当 fd 可读可写时再通知用户态程序。这个模型的入门门槛比多线程高但它能把进程打开的 fd 数量撑到很高而且不存在连接空闲时还占用一个线程的问题。对这篇文章来说先把阻塞 API 和流程弄熟练再学 epoll 会顺很多否则一上来就面对边沿触发、可读条件、事件回调用法这些概念很容易被劝退。5.3 一些 setsockopt 细节除了 SO_REUSEADDR有几个选项在真实环境里也很常用。SO_KEEPALIVE 可以让内核定时发送保活探测包检测对端是否已经宕机但默认探测间隔很长不能完全替代应用层心跳。TCP_NODELAY 会关闭 Nagle 算法适合小数据包实时性要求高的场景比如远程操作类协议但要注意可能增加网络传输的报文数量需要在实时性和带宽之间做权衡。最后想分享一个我踩过多次坑的细节所有 setsockopt 的设置都必须在对应动作之前完成。SO_REUSEADDR 要在 bind 之前设置TCP_NODELAY 要在连接建立之后、发送数据之前设置。顺序错了不会报编译错误但效果就是不对这类问题看代码很难发现抓包或者 strace 才容易定位。我自己现在写 TCP 程序基本都会先用 strace 看一遍系统调用顺序再判断参数有没有放错位置。这个习惯帮我省下大量排查时间也推荐你在遇到“代码看起来没问题但行为诡异”的情况时试一试。