C++网络编程入门:从socket到实现基础TCP服务端客户端
发布时间:2026/10/17 14:44:37 锦皓数字建站

最近又有几个学C的朋友跑来问我明明照着书敲了TCP代码为什么客户端一连接就崩为什么服务端按完CtrlC再重启就报端口被占用为什么对方只发了十个字节我这边read却读出来二十个这些问题我在刚接触网络编程那会儿全都撞过。与其一个个回复不如把这条最常走的入门路径完整写下来围绕“C网络编程学习建立基础TCP服务端/客户端”这件事从socket调用到能跑通一个最简单的echo服务再把Windows和Linux的差异、收发边界、调试心得都讲清楚。文章里给的代码是完整可编译的适合刚学完C基础语法、想接触网络编程的人也适合写过一点socket但总在奇怪问题上卡壳的同学。1. 先搞懂三件事服务端模型、阻塞与非阻塞、文件描述符1.1 服务端到底在“等服务”什么TCP服务端的工作方式用电话总机来类比特别直观。你要提供服务第一步是向交换机申请一条电话线这条线对应一个socket第二步是把这条线接到某个对外公布的号码上也就是bind到IP和端口第三步是告诉交换机“我已经准备好了可以接电话了”对应listen之后就是坐在那儿等电话进来对应accept。值得一提的是accept接过一个电话之后并不是把这条总机线路交给这个通话而是另外接出一条新的线路来处理当前通话的人。总机线路继续留在门口等着下一个通话进来这就是多连接能够共存的根本原因。理解了这套模型再去看API顺序就不会错。新手最常见的错误就是把listen当成了“开始接受数据”以为listen之后就能直接recv。其实listen只是让socket进入被动监听状态它不产生连接不接收数据。真正产生连接的是accept它会从内核的完成队列里取出一个已经完成握手的连接然后返回一个全新的、专门用于此连接收发的文件描述符。1.2 阻塞与非阻塞一次API调用会挂住整个进程网络编程里最容易让新手懵掉的是“阻塞”这个词。默认创建的socket是阻塞模式的意味着当你调用accept时如果没有新连接进来进程会停在这一行不走也不退出当你调用read时如果对端没有发数据进程同样会卡在read这一行。这跟本地文件读写很不一样——读本地文件哪怕再慢最终也会读到EOF结束但网络socket的“EOF”要等到对端关闭连接才会出现。这种阻塞特性是好事也是坏事。好处是程序逻辑简单一次只处理一个连接不容易写出数据竞争坏处是单个连接会占住整个进程如果客户端连上来之后一直不说话服务端就被“吊死”在read里后面的连接全部排队等着。后面我们会看到这个特性是所有“假死”问题的根源。入门阶段先用阻塞式通信跑通全流程等理解了状态转换再去处理非阻塞、事件驱动这些进阶话题。1.3 为什么socket返回的是“文件描述符”在Linux上socket函数返回的是一个整数这个整数叫文件描述符。很多C初学者会对“网络连接为什么是个整数”感到疑惑。可以这么理解操作系统把网络连接也抽象成了一种“文件”你读写这个文件就等于往网络上发数据或收取数据。所以read(conn_fd, buf, n)读的是从TCP对端收到的数据write(conn_fd, buf, n)写的是要发给TCP对端的数据。这也是为什么网络程序会大量复用文件读写的概念close关闭连接、select/poll等待描述符可读可写、send/recv只是带了标志位的读写变体。把网络抽象成文件是Unix几十年来的设计哲学理解了这一点你再看accept返回的整数、函数签名里大量出现的int形参就不会觉得莫名其妙了。2. 服务端骨架从socket到accept每一步都在做什么2.1 创建socketAF_INET与SOCK_STREAM的含义先看一个最朴素的TCP服务端骨架完整代码后面会逐步拆解#include iostream #include cstring #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h int main() { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); return 1; } std::cout socket created, fd listen_fd std::endl; close(listen_fd); return 0; }socket有三个参数。第一个AF_INET表示使用IPv4网络如果你的环境是纯IPv6场景这里就应该换成AF_INET6但入门统一用IPv4最稳。第二个参数SOCK_STREAM表示TCP流式协议它的特点是数据像水管里的水一样没有边界地源源不断流过去也因此才有了后面要讲的粘包问题。第三个协议参数填0表示让内核根据前两个参数自动选择TCP协议。2.2 端口绑定前的三件小事清零、转字节序、复用地址有了socket还不够服务端必须绑定一个固定的端口客户端才能找到它。绑定的核心是bind函数但新手往往栽在sockaddr_in的准备上int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); sockaddr_in addr; std::memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(9000); if (bind(listen_fd, (sockaddr*)addr, sizeof(addr)) 0) { perror(bind); return 1; }三件小事缺一不可。第一件是memset清零。sockaddr_in里面除了已知字段之外还有填充字节不清零的话这些字节是栈上的随机值内核在比较地址时可能报“地址已被占用”或者莫名其妙绑定失败。第二件是字节序转换。网络字节序是大端而绝大多数PC是小端。htonl(INADDR_ANY)把“任意本机IP”转成网络序htons(9000)把端口号转成网络序。不要自己手写交换函数直接调用系统接口是最保险的。第三件是SO_REUSEADDR。它告诉内核如果当前端口上有连接处于TIME_WAIT状态也允许我重新绑定。没有这一行你每次CtrlC再立即重启服务端大概率会撞见Address already in use。额外说一句INADDR_ANY表示绑定到本机所有网卡地址。如果只想让本机访问可以改成127.0.0.1如果想让局域网其他机器也能访问用INADDR_ANY是最省事的。修改地址时需要用inet_pton(AF_INET, 192.168.1.10, addr.sin_addr)把字符串转成二进制不过入门阶段用INADDR_ANY就够了。2.3 listen监听队列与accept的“分身”机制绑定端口后进入监听与接受连接阶段if (listen(listen_fd, 16) 0) { perror(listen); return 1; } std::cout listening on 0.0.0.0:9000 std::endl; while (true) { sockaddr_in client_addr; socklen_t addr_len sizeof(client_addr); int conn_fd accept(listen_fd, (sockaddr*)client_addr, addr_len); if (conn_fd 0) { perror(accept); continue; } std::cout new client connected, fd conn_fd std::endl; char buf[1024]; while (true) { ssize_t n read(conn_fd, buf, sizeof(buf)); if (n 0) { break; } write(conn_fd, buf, n); } close(conn_fd); }listen的第二个参数是未完成连接队列和已完成连接队列的总容量上限。这里填16在并发不高的测试场景完全够用。如果填0内核仍会给出一个能用的默认值但按照规范填0后行为在不同系统上并不一致所以不要觉得“填0就代表无限”。accept会阻塞在这里等待新连接一旦有连接完成TCP三次握手它就从已完成队列里取出一个连接同时把对端的IP和端口写入到client_addr这个结构体里。accept返回的conn_fd是“分身”listen_fd是“大门”。一个进程里可以同时有多个conn_fd但只有一个listen_fd。正因为这个设计accept之后对conn_fd的读写完全不影响listen_fd继续接受新连接。上面这个服务端是串行处理连接的先处理完当前conn_fd的所有数据把对端发来的数据原样写回去直到对端关闭连接read返回0才跳出内层循环去accept下一个连接。这个模型简单但并发能力等于零后面第6章会聊怎么处理。3. 客户端连接与数据收发connect的另一面3.1 connect发起一条TCP连接的三次握手客户端这一侧比服务端简单一些因为它不需要bind、listen、accept。它只需要创建socket然后调用connect去主动连接服务端。connect做的事可以理解为向服务端发起一次TCP三次握手的请求客户端先发送SYN服务端收到后回复SYNACK客户端再回一个ACK连接建立完成。这个握手过程由内核自动完成程序员不需要手动处理这些包。需要注意connect是阻塞调用。如果服务端没启动、被防火墙丢弃、或者IP不可达connect可能等很久才返回超时错误。在Linux上这个超时时间通常由内核参数和系统配置决定动辄几秒到几十秒。所以测试时最好先确保服务端已经起来再运行客户端。3.2 send/recv和read/write的异同很多人看到两种写法send/recv和read/write不知道有什么区别。简单说read/write是通用的文件读写接口send/recv是socket专用的读写接口。send多了第四个参数flags平时填0即可它的返回值语义和write一样返回成功写入到内核发送缓冲区的字节数注意是“写入内核缓冲区的数量”不保证对端已经收到。recv的flags也可以填0返回值语义和read一样返回本次实际读到的字节数返回0表示对端关闭了连接返回-1表示出错。入门的代码里我混用了这两组函数。服务端用read/write客户端用send/recv目的是让大家都认识。实际项目里最好统一一下尤其是要用MSG_WAITALL这类标志位时必须走recv。但无论如何都要记住不可能通过一次read/recv保证读到对方逻辑上的“一条完整消息”。TCP是字节流不是消息流。3.3 客户端代码拆解下面是一个能跑通的客户端完整代码#include iostream #include cstring #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h int main() { int fd socket(AF_INET, SOCK_STREAM, 0); if (fd 0) { perror(socket); return 1; } sockaddr_in addr; std::memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(9000); inet_pton(AF_INET, 127.0.0.1, addr.sin_addr); if (connect(fd, (sockaddr*)addr, sizeof(addr)) 0) { perror(connect); return 1; } const char* msg hello, tcp\n; ssize_t send_len send(fd, msg, std::strlen(msg), 0); std::cout send bytes: send_len std::endl; char buf[1024]; ssize_t n recv(fd, buf, sizeof(buf), 0); if (n 0) { std::cout.write(buf, n); } close(fd); return 0; }客户端的地址结构里IP填的是127.0.0.1也就是本机回环地址。这样即使你的电脑没有接入网络服务端和客户端也都能在同一台机器上跑通。如果要在局域网内连接另一台机器把127.0.0.1换成那台机器的IP即可。inet_pton把点分十进制的IP字符串转换成网络字节序的二进制形式比老式的inet_addr更安全推荐使用。整个客户端就三件事建socket、connect、收发数据。它不需要监听也不需要accept因为连接的建立和接受都是服务端那边负责的。4. send/recv并不保证按消息边界交付半包与粘包4.1 一个read到底能读到多少数据上面的服务端代码虽然能跑但它隐藏着一个让无数新手崩溃的问题客户端如果发送了100字节服务端的一次read未必能读到100字节。为什么会这样因为TCP是字节流数据被内核拆分成多个报文段发送中间还可能经过路由、网络拥塞、发送方缓冲区合并等等。服务端调用read时只能拿到当前内核接收缓冲区里已经到达的数据这个数量可能小于你期望的“完整消息”也可能大于——比如发送方连续发了两条消息两条消息的数据可能一次性被read读出来。这就是半包和粘包。收到一半叫半包两条消息黏在一起叫粘包。它们不是bug而是TCP字节流特性的必然结果。真正解决它们的方式只有一个在应用层约定消息边界。也就是说TCP只保证字节的先后顺序不保证你对消息的分割。4.2 简单echo服务里的隐藏缺陷用前面的服务端去echo客户端发送“hello”服务端可能分两次读到“he”和“llo”然后分别把“he”和“llo”写回去。如果客户端只是简单打印收到的东西这两次加起来还是“hello”看起来没问题。但如果客户端发送的是两条独立的消息比如“left”和“right”服务端可能一次read就把“leftright”都读出来然后把“leftright”写回去。接收端就完全分不清这是一条消息还是两条了。所以网络编程入门之后要做的第一件事就是设计一个最简单的消息帧协议。最朴素的做法是“长度字段内容”每条消息前4个字节用二进制存放后续内容的长度接收方先读满4个字节再按长度继续读。为了处理简单可以用网络字节序保存长度。服务端读取的循环要改成这样uint32_t body_len 0; recv(conn_fd, body_len, 4, MSG_WAITALL); body_len ntohl(body_len); std::vectorchar body(body_len); recv(conn_fd, body.data(), body_len, MSG_WAITALL);使用带MSG_WAITALL标志的recv可以阻塞到读满指定长度。但要注意这个标志在非阻塞socket上不一定能保证读满而且客户端发送时也要以同样的格式先把长度发出去。这种方法虽然简陋但能回答最核心的问题消息到底从哪里结束下一条消息从哪里开始。4.3 从“能跑”到“能用”最朴素的定长包方案继续顺着上面的设计往下说。如果消息内容本身是文本可以约定“以换行符结尾作为一条消息”也就是一行一行地读读到\n就算一条完整的消息。很多线上协议就是这么做的比如Redis的协议、HTTP的头部本质都是“分隔符或长度”的思路。更通用的做法是TLV编码Type-Length-Value先约定类型和长度再读取主体。三种方案按复杂度排序是定长包、分隔符、长度前缀。入门阶段用长度前缀最直观也最接近真实项目。写一个自定义的小工具函数读取指定长度的数据并在断开时抛出错误bool recv_full(int fd, void* buf, size_t n) { size_t already 0; char* p static_castchar*(buf); while (already n) { ssize_t r recv(fd, p already, n - already, 0); if (r 0) return false; if (r 0) return false; already r; } return true; }这段代码是读完整条消息的关键。它解决的是半包问题一次recv拿不到n个字节就继续循环直到凑满为止。你可能会问这样会不会把下一条消息的数据也“吸”进来不会因为最多只读到n个字节读满了就退出循环多出来的数据留在内核缓冲区里等下一次调用再读。这就是应用层分包的基本功。建议把这几个函数写在一个demo_net.h里后续继续学的时候会反复用到。5. 编译运行与Windows差异一次编写两处修改5.1 Linux下的编译与联调在Linux环境比如Ubuntu或CentOS上编译只需要一条命令g -Wall -g demo_server.cpp -o demo_server g -Wall -g demo_client.cpp -o demo_client-g是为了方便调试-Wall会输出所有警告新手看到警告千万别忽略。编好之后先启动服务端./demo_server它会打印listening on 0.0.0.0:9000。然后另一个终端运行客户端./demo_client正常情况客户端会打印send bytes: 11同时把hello, tcp原样打回来。想快速验证服务端可以不用写客户端直接用系统里常见的网络测试命令连接nc 127.0.0.1 9000输入一行字看服务端是否原样返回。5.2 Windows下Winsock的“三处不同”很多同学在Windows的Visual Studio里编译上面的代码会报错因为Windows用的是Winsock接口和Linux的POSIX socket有区别。第一处不同是头文件与库要包含winsock2.h并在链接参数里加上ws2_32.lib如果使用MSVC可以在项目属性里配置或者用代码里的#pragma comment(lib, ws2_32.lib)。第二处不同是必须先调用WSAStartup初始化Winsock否则后面所有socket函数都返回SOCKET_ERROR。第三处不同是socket句柄类型是SOCKET而不是int关闭使用closesocket而且send/recv返回int类型。一个兼容Windows和Linux的最小初始化片段长这样#ifdef _WIN32 #include winsock2.h #include ws2tcpip.h #else #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include unistd.h #endif // 在main最开始调用 #ifdef _WIN32 WSADATA wsa; WSAStartup(MAKEWORD(2, 2), wsa); #endif // 结束时 #ifdef _WIN32 WSACleanup(); #else close(fd); #endif把这两段代码放到自己的工具头文件里Linux和Windows就能共用大部分业务代码。另外要注意Windows下close不存在要用closesocketunistd.h也不存在不要直接包含。入门时最好先在一套平台上跑通理解原理之后再移植不要一开始就追求跨平台。5.3 再谈一个“看起来没用”的地址结构清零在第2章提到的memset在Windows上同样重要。Winsock的sockaddr_in结构在内存布局上不同平台基本一致但万一你漏了清零MSVC的Debug模式可能会把未初始化内存填充成0xCD导致字段判断出错。学习阶段养成初始化任何一个网络结构体都先清零的习惯能避免一堆无意义的排查。这条经验同样适用于sockaddr_in6、addrinfo等结构体它们的填充字段更多更容易出错。6. 调试中我反复遇到的三个问题6.1 Address already in use为什么明明没连接却报端口占用这是新人遇到过最多次的报错。你跑完服务端CtrlC结束进程紧接着再启动bind告诉你端口已经被占用。原因在于TCP连接关闭后连接会在内核里进入一段名为TIME_WAIT的状态通常会持续几十秒到几分钟。这个阶段里端口并没有被“彻底释放”。解决办法就是第2章里那行SO_REUSEADDR选项。setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt))必须在bind之前调用。把它放在bind后面调用是无效的因为地址已经在绑定时被内核检查过了。这里有个细节有的人会写出SO_REUSEPORT这个选项和SO_REUSEADDR完全不是一回事。SO_REUSEPORT用于多个进程复用同一个端口用于负载均衡Linux较新内核才支持。入门阶段不要混着用就用SO_REUSEADDR解决重启问题。6.2 Broken pipe向已关闭的连接继续发消息服务端在并发场景下经常撞见的另一个报错是Broken pipe。它发生在对端已经关闭连接而当前进程仍然往这个conn_fd上write/send时。内核会发一个SIGPIPE信号给当前进程如果程序没有处理这个信号默认行为是终止进程。新手经常莫名其妙地看到服务端进程“挂了”没有core dump也没有明显的C异常其实是被SIGPIPE干掉的。处理方式有两种。一种是直接在main里忽略信号#include csignal signal(SIGPIPE, SIG_IGN);忽略后写入已经关闭的socket会返回-1并置errno为EPIPE。然后你的代码判断到返回值小于0就知道这个连接已经不可用了可以安全地关闭这个连接不影响整个进程。第二种方法是在发送前先检查对端是否关闭但要确认对端关闭往往需要一次读操作比较麻烦。所以实战里更常用的是忽略SIGPIPE加返回值判断这一行代码能让你的服务端稳定很多。6.3 客户端一多服务端就“假死”问题出在串行阻塞前面那个串行echo服务端一次只能处理一个连接。比如A连上来不发数据不发关闭只是挂着。这时服务端就阻塞在read(conn_fd)B想连也连不上accept根本不会执行。这不是网络的错也不是内核的错而是程序结构造成的。最直接的改进是用多线程accept到一个新连接就创建线程去处理它的读写主线程继续accept。再往后可以学线程池、poll/epoll、事件循环但这已经超出“基础TCP”的范围了。不过有一件事值得在入门时就记住阻塞式socket 串行循环 低并发。如果你只是想搞通机制它完全合格如果以后要写真实服务端应该尽早转入非阻塞事件驱动模型。但你也不必因为这点焦虑先把手头的echo跑熟再去学select和epoll这个顺序完全合理。我第一次跑通TCP echo程序时也有过“怎么这么简单”的错觉真正开始写更多协议时才发现坑都在字节流边界和阻塞模型上。不过反过来讲只要把这篇里的几个关键点——地址准备、收发边界、端口复用、SIGPIPE——都踩过一次并且弄懂了之后再学什么网络库、什么协议栈都会轻松很多。如果你也在学C网络编程代码里卡住的时候不妨先用最简单的工具连接服务端、把问题拆成“没连上”“连上但没数据”“连上但收不全”三段去排查很多困惑都能解决得比想象中快。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。