C++网络编程入门:手写TCP服务端/客户端的完整指南 写C网络编程TCP服务端/客户端几乎是绕不开的第一站。很多人在啃完语法、容器、文件操作之后想写点真正跟系统底层打交道的程序第一反应往往就是网络通信。TCP作为最可靠的传输层协议既能把“两个程序怎么互通消息”这件事讲透彻又能让你扎扎实实摸到socket、文件描述符、网络字节序、阻塞与非阻塞这些底层概念所以不管你未来是做后端开发、嵌入式通信还是深入学习Linux系统编程都会从这个经典小项目开始。这篇文章我不会只贴一份能跑的代码而是把建立TCP服务端/客户端的完整思路、每一步为什么这么设计、实际踩过哪些坑尽量清楚地过一遍。内容适合有C语法基础、但还没系统接触过网络编程的同学也适合那些写通了代码但对细节一知半解、想补上原理的人。1. 先把TCP这件事想明白协议特性与通信模型1.1 TCP协议到底解决了什么问题网络通信的本质是让两个进程之间能够交换数据。但真实的网络链路天然不可靠数据在传输中可能丢失、乱序、重复光靠IP层的“尽力而为”是远远不够的。TCPTransmission Control Protocol就是为了解决这个问题而生的传输层协议它通过三次握手建立连接、累积确认与重传机制保证数据不丢不乱再通过滑动窗口控制发送速率最终给上层应用提供一个“可靠的字节流管道”。用生活化的类比来说TCP就像两个人打一通电话先拨号对方接通这就对应三次握手通话期间你说一句我回一句对方能听到对应可靠的流式传输最后一方说“那我挂了”双方依次收线对应四次挥手。而UDP更像是发一叠一次性飞盘扔出去就不管了对方有没有接到只能下次见面再确认。理解了这一点就明白为什么实际业务里几乎都跑在TCP之上登录、订单、转账、聊天、文件上传下载这些数据少一个字节都不行。像HTTP、FTP、SMTP这些我们每天都在打交道的应用层协议底层几乎也都是TCP。所以做C网络编程先把这个管道特性摸清楚后面学习就会顺畅得多。1.2 服务端与客户端的角色分工TCP通信双方天然不对称分为服务端和客户端两个角色服务端提前启动监听某个固定的IP和端口等待客户端来连接。服务端不主动发起联系它是“被找的一方”。客户端主动向服务端的IP和端口发起连接请求连接建立后与服务端交换数据结束后主动关闭连接。这个关系很像一家餐厅和一位顾客餐厅把门牌号端口挂出来顾客按地址找上门、坐下点菜、吃完离开。餐厅通常要同时接待很多顾客这就引出了“并发处理多个连接”的问题服务端的真正难点也在这里。通信模型上服务端的核心是“四步走”创建socket、绑定地址bind、监听listen、接受连接accept。客户端的核心是“三步走”创建socket、发起连接connect、收发数据send/recv。一旦连接建立两边就进入对称的传输阶段谁都可以主动发消息。我强烈建议你花十分钟把这套模型画在纸上。因为后面遇到的大多数问题比如accept为什么会阻塞、客户端为什么连不上、粘包怎么处理、连接半关状态怎么判断根源全在这个模型里。先有全局图再动手写代码效率会高很多。2. 开发环境准备与必知的socket API2.1 开发环境与编译方式做C/C网络编程Linux环境是最顺手的。原因很简单socket接口本身就是POSIX标准的一部分Linux下的头文件、库、错误码都非常规整而且netstat、strace、tcpdump这些调试工具一应俱全出了问题能看得非常清楚。Windows下也有WinSock但接口细节有差异比如需要WSAStartup做初始化、部分函数名带WSA前缀、头文件也不一样作为学习第一阶段的主力平台会平添不少干扰。假设演示环境是Ubuntu 20.04使用GCC编译。建议建一个项目目录分别写server.cpp和client.cpp然后编译g -stdc11 -Wall -O2 -g -o server server.cpp -lpthread g -stdc11 -Wall -O2 -g -o client client.cpp -lpthread注意这里把-pthread加上了其实单线程版本不链接也能编过但后面做多线程并发时必须加我习惯直接写进命令避免中途忘了。加-Wall可以把警告开起来能提示很多“未使用变量”“隐式类型转换”之类的问题这个习惯要趁早养成。头文件方面主要涉及这几个#include sys/types.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include unistd.h #include string.h #include errno.hsys/socket.h是socket接口的核心头文件netinet/in.h定义了struct sockaddr_in地址结构体arpa/inet.h提供inet_pton、ntohl这类地址和字节序转换函数。第一次记不住没关系跟着代码写几遍自然就熟悉了。2.2 核心API逐一拆解socket编程的核心API数量非常少加起来不到十个但每个都值得认真理解。API作用关键说明socket()创建套接字文件描述符返回fd-1表示失败bind()把fd绑定到指定IP和端口0成功-1失败listen()将fd转为被动监听状态0成功-1失败accept()从已完成连接队列取一个连接返回新的通信fdconnect()客户端发起连接0成功-1失败send()发送数据到对端返回实际发送字节数recv()从对端接收数据返回接收字节数0表示对端关闭先说socket()它返回的是“套接字描述符”本质是一个int和文件描述符很像。Linux“一切皆文件”的思想在这里体现得很彻底socket的读写和普通文件的read/write在形式上是统一的数据来源不同而已。socket()的三个参数分别是地址族AF_INET、套接字类型SOCK_STREAM、协议0SOCK_STREAM就代表流式套接字对应TCP。bind()的作用是把套接字绑定到具体的网络地址。这里新手最容易踩的坑是字节序网络字节序统一是大端而x86机器是小端所以端口需要用htons()转换IP地址用htonl()或inet_addr()凡是涉及到IP和端口的赋值都要注意。后面代码里我会专门演示。listen()和accept()是服务端独有的。listen将socket从“主动连接”切换为“被动监听”内核会为监听fd维护两个队列未完成握手的SYN队列和已完成握手的accept队列。accept的作用就是从已完成队列里取出一个连接返回一个新的fd用于后续通信。注意监听fd本身从不收发数据真正收发走的是accept返回的那个新fd。connect()是客户端的动作向服务端发起连接内核帮你完成三次握手。connect默认是阻塞的如果对方不可达或者端口没监听会阻塞直到超时或直接返回错误。send和recv在Linux下也可以等价替换为write和read但推荐用send/recv因为多一个flags参数后面处理非阻塞、带外数据时会用到。3. 服务端实现从监听连接到接受连接3.1 服务端完整流程分步走服务端代码逻辑可以用下面几步概括调用socket()创建监听fd。初始化sockaddr_in结构体填入地址族、IP、端口。调用bind()把监听fd和这个地址绑定。调用listen()进入监听状态。循环调用accept()接受客户端连接。对每一条连接进行数据收发。“循环调用accept”这步是核心因为真实服务端不可能只服务一个客户端。最简单的写法就是单线程迭代式每accept到一个连接就进入收发循环等这个客户端关闭后再回到accept等待下一个。这种模型代码量最小逻辑最直观非常适合入门。不过它的缺点也很明显如果当前客户端的收发是阻塞的而对方一直不发数据那么整个服务端就卡在recv里后来排队的客户端哪怕已经完成了握手也迟迟得不到处理。所以在教学阶段先用它把流程跑通之后必须升级成并发模型。第三小节我会讲两种实际可用的并发方案。3.2 服务端基础代码与关键细节先上一个最基础的单线程版本代码骨架是完整的非常适合理解整个流程#include sys/types.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include unistd.h #include string.h #include stdio.h #include errno.h int main() { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); return 1; } // 设置端口复用避免重启时提示Address already in use int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in server_addr; 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(9000); if (bind(listen_fd, (struct sockaddr*)server_addr, sizeof(server_addr)) 0) { perror(bind); close(listen_fd); return 1; } if (listen(listen_fd, 128) 0) { perror(listen); close(listen_fd); return 1; } printf(server listening on 0.0.0.0:9000\n); while (1) { struct sockaddr_in client_addr; socklen_t client_len sizeof(client_addr); int conn_fd accept(listen_fd, (struct sockaddr*)client_addr, client_len); if (conn_fd 0) { perror(accept); continue; } char client_ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, client_addr.sin_addr, client_ip, sizeof(client_ip)); printf(new connection from %s:%d\n, client_ip, ntohs(client_addr.sin_port)); // 简单回显收到什么就发回什么 char buf[1024]; while (1) { memset(buf, 0, sizeof(buf)); ssize_t n recv(conn_fd, buf, sizeof(buf), 0); if (n 0) { if (n 0) { printf(client closed\n); } else { perror(recv); } break; } printf(recv: %s, buf); send(conn_fd, buf, n, 0); } close(conn_fd); } close(listen_fd); return 0; }这段代码里有两个细节值得单独拿出来说。第一server_addr.sin_addr.s_addr htonl(INADDR_ANY)。INADDR_ANY是0.0.0.0表示监听本机所有网卡地址这样无论客户端访问127.0.0.1还是访问本机局域网IP都能连上。如果只想测试回环可以写成inet_addr(127.0.0.1)注意inet_addr返回的已经是网络字节序不需要再调用htonl初学者经常在这里重复转换导致IP地址变成奇怪的值。第二setsockopt设置SO_REUSEADDR。这个坑我到现在都记得第一次写完服务端CtrlC退出后立刻重启直接报“Address already in use”。原因是前一个进程的四次挥手还没彻底结束端口处于TIME_WAIT状态。设置SO_REUSEADDR后内核允许新socket绑定到还处于TIME_WAIT的端口上开发调试会舒服一大截。另外注意accept返回的conn_fd最后要close否则每次连接都会泄漏一个fd跑久了系统会报告“too many open files”。如果你在练习中发现连接数到几百就上不去了多半就是fd泄漏了。3.3 多客户端并发处理的两种常用方案单线程迭代服务端无法面对真实业务所以至少需要掌握两种并发方案多线程和IO复用。多线程方案的理解门槛最低。accept返回conn_fd后立刻把它交给一个独立线程去处理主线程马上回到accept等待新连接。线程函数的骨架如下void* handle_client(void* arg) { int conn_fd *(int*)arg; char buf[1024]; while (1) { memset(buf, 0, sizeof(buf)); ssize_t n recv(conn_fd, buf, sizeof(buf), 0); if (n 0) break; send(conn_fd, buf, n, 0); } close(conn_fd); delete (int*)arg; return nullptr; } // accept之后 int* pfd new int(conn_fd); pthread_t tid; pthread_create(tid, nullptr, handle_client, pfd); pthread_detach(tid);这里两种细节需要重点提醒。一是pthread_detach。子线程处理结束后由系统自动回收资源主线程不需要join。当然你也可以用一个std::vector把pthread_t存起来等全体退出时统一join但对于长连接场景detach更省心。二是给线程传参时不能直接把conn_fd的int值传进去而要new出一个int指针再传递。为什么因为conn_fd一旦在下次循环被重新赋值所有线程共享的同一个栈变量就变掉了你传进去的fd可能已经被覆盖成别的新连接收发就会错乱。用new出来的独立int指针每个线程拿着自己那份就不会互相干扰。这个坑在“多线程共享同一变量”里非常典型建议直接记下来。不过多线程方案的瓶颈也很明显线程数量随连接数线性增长成千上万个连接就会创建成千上万个线程内存占用和上下文切换开销都扛不住。生产环境更常见的是select/poll/epoll这类IO复用方案。select的核心思路是把listen_fd和所有conn_fd放进一个fd集合让内核监听哪些fd可读可写然后单线程轮询处理。当listen_fd可读时去accept当某个conn_fd可读时去recv单线程就能支撑几百上千条连接。学完这个基础项目后我强烈建议你把单线程版改成select版这个改造过程能让你真正理解“事件驱动”到底是什么也为后面学epoll打下基础。想绕开select直接上epoll的问题在于你很难理解epoll那些边沿触发、水平触发的设计意图因为它们就是为了解决select的效率问题而设计的。4. 客户端实现与数据收发细节4.1 客户端流程与完整代码客户端的流程比服务端简单太多创建socketconnect连上去然后send/recv完事。看看完整代码#include sys/types.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include unistd.h #include string.h #include stdio.h int main() { int sock_fd socket(AF_INET, SOCK_STREAM, 0); if (sock_fd 0) { perror(socket); return 1; } struct sockaddr_in server_addr; memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr inet_addr(127.0.0.1); server_addr.sin_port htons(9000); if (connect(sock_fd, (struct sockaddr*)server_addr, sizeof(server_addr)) 0) { perror(connect); close(sock_fd); return 1; } const char* msg hello tcp server\n; send(sock_fd, msg, strlen(msg), 0); char buf[1024] {0}; ssize_t n recv(sock_fd, buf, sizeof(buf), 0); if (n 0) { buf[n] \0; printf(server echo: %s, buf); } close(sock_fd); return 0; }connect时的地址参数必须强转为struct sockaddr*因为socket API要面向多种协议族IPv4、IPv6、Unix域等统一接收一个通用地址结构。这是Unix网络编程的历史设计第一次看会觉得别扭习惯就好。客户端注意一个点如果connect返回失败一定要close(sock_fd)。有些代码省略了这一步虽然进程退出后会释放fd但如果你在一个长循环里反复发起连接每次失败都泄漏一个fd最终会导致进程无法再创建socket。这也是我在帮别人review代码时经常看到的问题。4.2 数据收发中的“粘包”与“半包”基础收发看起来简单但真正写业务时一个绕不开的经典问题就是粘包和半包。什么是粘包TCP是面向字节流的它不像UDP那样保留消息边界。你调用send发送“hello”再调用send发送“world”对端底层可能一次性读到“helloworld”也可能分成“hell”和“oworld”两次读到。对端视角就是一个连续字节流它根本不关心你在应用层调用了几次send。为什么会有这种拆并因为send只是把数据拷贝进内核发送缓冲区TCP协议栈会根据当前窗口大小、拥塞状态以及Nagle算法的延迟合并策略决定何时发、发多大。所以“一次send对应一次recv”在TCP里是完全不成立的你必须自己解决边界问题。解决办法的核心原则只有一条应用层自己定义消息边界。最常见的两种做法固定长度消息。每条消息都定死1024字节不足补空格。逻辑最简单但浪费带宽如果消息可能超过固定长度还得另选方案所以实际业务用得少。长度前缀法。在每条消息头部先用4个字节表示消息体长度接收方先读4字节得N再读N字节读完一条再处理下一条。这是工业界最通用的方式很多RPC框架的协议都这么设计。发端编码uint32_t len htonl(msg.size()); send(fd, len, sizeof(len), 0); send(fd, msg.data(), msg.size(), 0);收端解码uint32_t len; recv(fd, len, sizeof(len), 0); len ntohl(len); // 然后循环读满 len 个字节这里必须强调收端不能只调用一次recv来读消息体因为TCP是流式的一大坨数据可能分多个包到达recv一次只返回当前内核缓冲区里的一部分。这就是“半包”问题。正确的做法是写一个循环读函数确保读到指定的字节数为止bool readn(int fd, char* buf, size_t n) { size_t left n; while (left 0) { ssize_t ret recv(fd, buf, left, 0); if (ret 0) { if (errno EINTR) continue; return false; } if (ret 0) return false; // 对端关闭 buf ret; left - ret; } return true; }这个函数每次从内核取到现在能取到的数据累加到目标缓冲区直到凑满n个字节。很多新手只调用一次recv数据一多就丢然后百思不得其解其实根源就在半包。5. 常见问题速查与避坑指南5.1 高频错误场景先列表我总结了一份高频错误对照表排查时可以直接对号入座现象错误码/信号常见原因解决思路bind失败EADDRINUSE端口被占用或有TIME_WAIT残留换端口或设置SO_REUSEADDRconnect失败ECONNREFUSED服务端未启动或端口未监听确认服务端状态和端口号connect失败ETIMEDOUTIP不可达或防火墙拦截ping测试检查防火墙规则recv返回-1EAGAIN非阻塞模式下暂时无数据不是错误忽略并继续等待事件send触发进程退出SIGPIPE对端已关闭连接后继续send忽略SIGPIPE或检查send返回值连接数到几百就崩EMFILE进程fd数达到上限调大ulimit -n或改用IO复用服务端重启报地址已被占用EADDRINUSETIME_WAIT状态残留设置SO_REUSEADDR这里特别说一下SIGPIPE。很多人没意识到对端关闭连接后如果你这边继续调用send内核默认会向进程发送SIGPIPE信号这个信号的默认处理是终止进程。也就是说你的服务端会毫无征兆地“挂掉”连error日志都没有。解决方式很简单在启动代码里加一句signal(SIGPIPE, SIG_IGN);或者更稳妥一点把send的返回值检查好如果返回-1且errno是EPIPE就停止发送、清理连接。把这两种手段结合起来线上出问题的概率会小很多。5.2 三个非常实用的调试工具网络程序调试不能只靠printf我每次排查问题都会用到三个工具每个都值得你花半小时亲手玩一遍。第一个是netstat查看当前端口监听和连接状态netstat -tlnp | grep 9000-t表示TCP-l表示只显示监听socket-n表示用数字显示地址端口-p表示显示对应进程PID和程序名。它能一眼看出服务端是否在监听9000监听地址是0.0.0.0还是127.0.0.1非常直白。第二个是telnet。这是一个再经典不过的TCP测试客户端。服务端启动后在另一终端执行telnet 127.0.0.1 9000连上之后你随便敲几个字符如果回显服务端写对了数据会原样返回。整个过程不需要写任何客户端代码是验证服务端是否正常工作的最快方式。在一些系统里默认没有安装telnet可以用ncnetcat替代nc 127.0.0.1 9000nc功能更全不仅能当客户端还能当简易服务端建议顺便装一下。第三个是strace。当程序行为诡异——比如收不到数据、accept迟迟不返回、连接建立后立刻断开——用strace追踪系统调用能直接看到内核视角发生了什么strace -f -o trace.log ./server-f表示跟踪子线程-o把结果保存到文件。跑完看trace.log里recv、accept、send这些调用都返回了什么。我曾经遇到一个诡异bug程序卡死在accept怎么都不返回用strace一看发现监听socket的地址族是IPv6而测试客户端连接用的是IPv4协议族完全对不上accept自然永远等不到连接。这种问题靠肉眼审代码很难定位但系统调用日志一照立刻真相大白。5.3 我反复踩过、印象极深的几个坑最后分享几个我在实际开发里反复踩过的坑每一个都付出过不少调试时间。第一个坑忘记处理recv返回0。recv返回0表示对端正常关闭了连接。有些新手循环读数据时只处理n0的情况忽略n0结果连接关闭后程序还在死循环里重复recv每次都拿到0白耗CPU。更严重的还会导致连接fd不释放资源慢慢耗光。正确处理是在n0分支里立即break、close fd。第二个坑把非阻塞模式的EAGAIN当成致命错误。用fcntl把socket设为非阻塞后recv在没有数据时会立即返回-1errno是EAGAIN或EWOULDBLOCK。在select或epoll的写法里这是完全正常的“现在没有数据下次事件再读”的信号。如果你把EAGAIN当作错误处理要么直接关闭连接要么输出一堆无意义日志都会让程序行为变得很怪。第三个坑缓冲区长度和协议不匹配。你用char buf[1024]做接收缓冲区但协议里允许消息体长达4096字节。结果收到一条长消息前1024字节进buffer后面部分滞留在内核缓冲区下次recv又被当作“新消息”读出来整个消息边界立刻错乱。设计协议时要么限制消息最大长度要么写一个可扩展的动态缓冲区这是做网络库的基本功。第四个坑多线程共享fd的惨痛教训。我第一次写多线程服务端时主线程accept拿到conn_fd传给工作线程之后又顺手在主线程里把这个conn_fd关掉了。结果工作线程一收发就得到“fd invalid”的错误。要记住fd本质上是一个整数背后是内核对象它带有引用计数。close一次计数减一所有引用都close了才真正销毁。收发归哪个线程管、关闭归哪个线程管必须职责清晰否则就会出这种非常难查的bug。第五个坑listen的backlog设得太小。listen(fd, 5)表示已完成握手队列最多放5个连接如果客户端瞬间并发很多超过队列容量的连接会被内核直接丢弃客户端表现为connect超时或重置。做实验无所谓但生产场景backlog建议至少128甚至更大同时配合负载考量。这个参数不直接影响“已经建立连接”的客户端但决定了系统抗连接突发的上限。结尾我自己写了这些年网络程序最大的体会是TCP服务端/客户端这个基础项目代码量不大却把操作系统的进程模型、内核协议栈、并发模型、协议设计这些知识点全串在了一起。刚能把回显程序跑通的时候很多细节其实一知半解是后来反复踩SIGPIPE、粘包、TIME_WAIT、fd泄漏这些坑才慢慢理解背后的机制。所以我建议你学完这个案例后别停在“能跑通”就满足。试着把单线程改成多线程再改成select版给自己加“心跳包”和“断线重连”需求甚至基于这个框架去写一个简单的聊天室或文件传输工具边写边用netstat观察连接状态变化。等你把这些延伸做完再回头看最初的代码会发现当初那些弄不明白的“为什么”答案基本都藏在协议设计和内核行为里。按我平时带新人的习惯这篇内容从“先想清楚再动手”讲起每一步都给了能直接编译运行的代码也给了足够多的避坑提醒。你在实验时遇到报错不要先怀疑编译器先从这些坑里对照找一遍多半就能定位到问题所在。祝你顺利跑通自己的第一个TCP服务端和客户端。