Linux Socket编程实战:mysocket封装TCP通信与避坑
简介:面向Linux网络编程初学者、计算机专业学生及需要复习TCP/IP套接字机制的开发者,这份文档以Socket接口为主线,系统梳理网络中进程通信的唯一标识问题、Socket“一切皆文件”的设计理念,以及socket()、bind()、listen()、connect()、accept()、read()/write()、close()等基本操作。内容结合协议域、套接字类型与协议参数的选择,说明地址绑定、监听、连接接受和数据收发的衔接关系,并对TCP三次握手建立连接、四次挥手释放连接的过程进行拆解,配有服务端与客户端协作的实践示例,便于读者把函数调用顺序与连接状态变化对应起来,形成较完整的知识框架。
包内仅含1个docx文档,压缩包约77KB,文件数量少、篇幅精炼,适合作为高校课程实验、Linux系统编程自学或后端面试复习的专题资料随时查阅。文档围绕原理说明、函数原型、连接流程与实践示例组织内容,末段还留有思考问题,便于自测和延伸讨论。目前已有241人学习,适合具备C语言与Linux基础、希望快速入门或查漏补缺Socket编程的读者使用。
1. 为什么这套 mysocket 封装值得先跑一遍再谈 Linux Socket 编程
把示例里写死的 IP 改成本机地址,服务端还是抛connect to server error——这是不少人第一次拆这套 Linux Socket 编程实例时卡住的地方。它不是什么框架,就是四个文件:一个mysocket类把socket()/bind()/listen()/accept()/connect()/send()/recv()串起来,再加server.cpp和client.cpp两个入口。但它把网络编程里最容易玄学化的部分——协议族怎么选、字节序怎么转、监听描述字和已连接描述字差在哪、三次握手究竟卡在哪个函数上——全部摊开在不到两百行代码里。适合两类人:一是刚学完 TCP 理论、想看见数据真正在两个进程间流动的人;二是写过业务、但一直把 socket 当成黑匣子调库的开发者。
2. 工程骨架拆解:四个文件如何撑起一套可复现的 TCP 通信
这套代码的组织方式很朴素,没有 CMake,没有 Makefile,就是两个.cpp加一对头/实现。但正是这种朴素让它特别适合拿来做第一份可调试的网络工程:编译命令短、依赖少、断点能直接打在accept()上。
2.1 文件职责划分:谁负责建连、谁负责跑起来
先看目录里到底有什么:
mysocket/ ├── mysocket.h # 类声明:四个成员变量 + 五个成员函数 ├── mysocket.cpp # 类实现:createserver / createclient / recvmsg / sedmsg ├── server.cpp # 服务端入口,只做两件事:建服务、进接收循环 └── client.cpp # 客户端入口,只做两件事:连服务、进发送循环职责切得很干净:mysocket类只关心「连接的生命周期」,server.cpp和client.cpp只关心「连接建好之后跑什么业务」。这种切法在实际项目里也成立,我一般会把网络层封成独立的一两个类,业务层拿到描述字之后自己玩,改协议的时候不用动建连逻辑。
对比一下常见的错误组织方式:把所有东西塞进main(),socket()后面紧跟bind(),bind()后面直接accept(),出错就return -1。这种写法跑得通,但一旦要加第二个客户端、要换 UDP、要加日志,整个main就得重写。这套示例虽然简单,但已经把「可扩展的边界」画出来了。
需要注意的是mysocket类里没有虚函数、没有继承,也不需要。它就是个薄封装,加继承反而增加阅读成本。真正要做多协议支持时,我会选择把createserver拆成模板方法或者策略类,而不是现在就把类层次搭起来——过早抽象是另一个坑。
2.2 头文件设计:四个成员变量为什么够用
#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; // 协议族,如 AF_INET int m_isoctype; // 套接字类型,如 SOCK_STREAM int m_iprotype; // 具体协议,0 表示按类型取默认 int m_isocket; // socket() 返回的描述字 }; #endif四个成员变量其实只干两件事:m_idomain / m_isoctype / m_iprotype是「创建参数的回填」,m_isocket是「唯一真正有用的状态」。前三个留着是方便后续扩展时不用再传一遍参数,属于可留可删的设计;m_isocket是必须的,因为recvmsg()和sedmsg()都要拿它做accept()或send()的入参。
这里有个细节值得说:m_isocket在服务端和客户端语义完全不一样。服务端里它存的是监听描述字,客户端里它存的是已连接描述字。同一个变量名、两种含义,这是新手最容易读混的地方,后面第 3 章还会专门拆。
另外recvmsg()和sedmsg()名字里的msg有点误导,它们内部实际调的是recv()和send(),不是recvmsg(2)那个通用接口。改代码时别被名字带偏。
提示:
#ifndef _MYSOCKET_H这种以下划线开头的宏名在 C++ 标准里是保留给实现的,正式项目里建议改成MYSOCKET_H_这类形式,虽然当前编译器不会报错。
2.3 编译与运行:两条命令跑通收发
不需要构建系统,直接两个g++命令:
# 编译服务端,-g 留调试符号,方便 gdb 打断点 g++ -g -o server server.cpp mysocket.cpp # 编译客户端 g++ -g -o client client.cpp mysocket.cpp两个可执行文件各自链接同一份mysocket.cpp,类实现被编译进两个二进制。这种「一份实现、两个入口」的链接方式在 demo 里很方便,多文件项目里就要注意别把mysocket.cpp里定义的非 inline 全局函数重复链接,通常拆成静态库更稳妥。
跑的时候严格按顺序:先./server,看到======waiting for client's request======说明bind()和listen()都过了;再另开一个终端./client。服务端会持续打印connfd=...; rec msg from client: ...,客户端会持续打印send msg succ。两边都不断刷屏,说明链路通了。
参数上唯一需要动的就是端口。服务端createserver(AF_INET, SOCK_STREAM, 0, 6666)和客户端createclient(AF_INET, SOCK_STREAM, 0, 6666)里的6666必须一致。几个参数的含义分别是:AF_INET走 IPv4,SOCK_STREAM走 TCP,第三个参数给0让内核按前两个自动选协议(这里会选到IPPROTO_TCP)。把SOCK_STREAM换成SOCK_DGRAM就是 UDP,但那样listen()和accept()全都用不了,代码要大改,不是改一个常量的事。
3. 服务端链路:从 socket() 到 accept() 的每一步到底发生了什么
服务端是这套代码里信息密度最高的部分。createserver()一个函数里塞了三个系统调用,每个都有独立的失败分支,读懂它等于把 TCP 服务端的启动流程走了一遍。
3.1 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; } // ... 后续 bind / listen }三个参数的作用是层层收窄的:domain定地址格式,决定后面bind()里塞的结构体是sockaddr_in还是sockaddr_in6;type定通信语义,SOCK_STREAM是有序可靠字节流,SOCK_DGRAM是保留边界的报文;protocol一般填0,让内核按前两个组合自动匹配。
这里有个硬约束:type和protocol不能乱配。SOCK_STREAM配IPPROTO_UDP会在socket()阶段就返回-1,errno是EPROTONOSUPPORT。我在排查线上问题时见过有人为了「让 TCP 快点」把 protocol 改成 0 以外的值,结果服务起不来,日志里只有一句 create socket error——错误信息太薄,等于自己把后悔药扔了。
返回值m_isocket是进程内的文件描述字,跟open()返回的东西是同一套命名空间。这意味着你可以对它用close(),也可以用select()/poll()/epoll()监听可读可写事件——后面做并发改造就靠这个性质。
3.2 bind() 与字节序:htons/htonl 与 INADDR_ANY
struct sockaddr_in servaddr; memset(&servaddr, 0, sizeof(servaddr)); // 清零,避免 sin_zero 残留 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; }memset那行不能省。sockaddr_in里有个sin_zero[8]填充字段,某些实现会对整块内存做比较,不清零可能出现「明明填对了却 bind 失败」的玄学现象。
htonl(INADDR_ANY)等价于INADDR_ANY(值是 0),但写上转换表达的是意图:这个字段的含义是网络序地址。INADDR_ANY表示绑定本机所有网卡,客户端从哪个网卡进来都能被接住;如果只写死某一个网卡地址,那就只能从那个网卡访问,本机127.0.0.1都连不上。
htons(port)是这件事里最容易翻车的一步。端口号在结构体里必须是网络字节序(大端),而 x86 主机是小端,不转换的话 6666(0x1A0A)会被解释成0x0A1A,也就是 2586。现象是服务端「明明起来了」,客户端连 6666 一直connection refused,用ss -lntp一看监听的是个完全没见过的端口。这个坑我踩过一次就记住了:凡是往协议结构体里写多字节整数,先问一句转没转。
注意:客户端不需要显式
bind(),connect()时内核会自动分配一个临时端口和源 IP。硬要给客户端 bind 固定端口,反而会导致同一台机器起第二个客户端时EADDRINUSE。
3.3 listen() 的 backlog 与 accept() 返回的新描述字
if (listen(m_isocket, 10) == -1) { // 第二个参数:全连接队列上限 printf("listen socket error\n"); return false; } printf("======waiting for client's request======\n"); return true;listen()干的事只有一件:把socket()创建出来的主动套接字翻成被动套接字,让它开始接受连接请求。第二个参数backlog是已完成三次握手、等待accept()取走的连接队列长度,不是「最大并发连接数」。写 10 意味着同时有 11 个客户端完成握手时,第 11 个可能被内核丢掉或者按tcp_abort_on_overflow的配置回 RST。生产环境这个值一般给 128 到 1024,具体看队列监控。
真正容易读错的是accept():
void mysocket::recvmsg() { int connfd = -1; char buff[1024] = {0}; int n = 0; while (1) { connfd = accept(m_isocket, (struct sockaddr*)NULL, NULL); if (connfd == -1) { printf("accept socket error: %s(errno: %d)", strerror(errno), errno); continue; } break; // 注意:只接一个客户端就跳出 } // ... 后面进入 recv 循环 }第一个参数是监听描述字m_isocket,返回值connfd是内核新建的已连接描述字。两者生命周期完全不同:监听描述字在整个服务进程存活期间都存在,一个服务只创建一次;connfd每个客户端一个,服务完就该close()。这套示例里只接了一个就break,所以第二个客户端连上来会一直挂在队列里等——不是连不上,是没人取。
第二个、第三个参数传NULL表示不关心对端地址。真要记录客户端 IP 和端口,就得传sockaddr_in和socklen_t变量的地址,accept()会把对端信息填进去,再用inet_ntop()转成可读字符串。做访问日志和限流都依赖这个。
3.4 recv 循环:缓冲区、返回值与 0 的含义
while ((n = recv(connfd, buff, 1023, 0)) > 0) { memset(buff, 0, 1024); // 坑:这里把刚收到的数据清了 buff[n] = '\0'; printf("connfd=%d; rec msg from client: %s.msglen=%d\n", connfd, buff, n); n = 0; }recv的第二个参数是缓冲区,第三个1023是「最多读多少字节」,留一个字节给结尾的\0。第四个参数flags给0表示阻塞读,没数据就卡在这里。
返回值有三种情况,必须分开处理:大于 0 是实际读到的字节数,但这不等于「一条完整消息」——TCP 是字节流,没有消息边界,客户端发两次 8 字节,服务端可能一次读到 16 字节;等于 0 表示对端调用了close(),是正常的连接结束信号;小于 0 是出错,需要看errno,EINTR是被信号打断可以重试,ECONNRESET是对端 RST。
这段代码里有个真实的 bug:memset(buff, 0, 1024)写在printf之前,把刚recv进来的内容整个清空了,所以打印出来的消息内容永远是空的,只有msglen是对的。正确顺序应该是先打印、再清缓冲区,或者干脆用n控制打印长度、不依赖\0。这类错误不会让程序崩,只会让日志骗人,排查时最费时间。
4. 客户端链路:connect() 阻塞与 send() 循环的细节
客户端代码更短,但坑一点不少。尤其是那个写死的 IP 和那个永不退出的while(1),属于「跑通容易、改对难」的典型。
4.1 createclient:inet_pton 与那个必须改的 IP
bool mysocket::createclient(int domain, int soctype, int protype, int port) { struct sockaddr_in servaddr; m_isocket = socket(domain, soctype, protype); 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); // 这一行的 IP 必须改成服务端实际地址 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; }inet_pton的返回值有三种:1 表示转换成功,0 表示字符串不是合法地址,-1 表示af参数不支持且errno被设置。示例里只判断了<= 0并统一报错,够用但不利于定位——如果 IP 写成了192.168.1.256,你会以为是网络问题,实际是字符串解析失败。
connect()在 TCP 下是阻塞的,它会触发三次握手的第一包 SYN,然后卡住直到收到服务端的 SYN+ACK 并回完 ACK 才返回。这也是「为什么客户端比服务端晚返回」的根源:客户端的connect()在三次握手的第二次交互后就返回了,服务端的accept()要等第三次 ACK 到达才返回。
192.168.1.18这个地址是整套代码里唯一必须改的地方。同机测试填127.0.0.1,跨机测试填服务端的实际内网地址。改完记得重新编译客户端,光改源码不重编是最常见的「我明明改了啊」。
4.2 sedmsg:死循环发送与 send 返回值判断
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; // 出错还继续,会疯狂刷日志 } printf("send msg succ\n"); msgid++; } }sprintf拼出来的内容是msg = 0.\n,strlen算长度时把换行也算进去了,所以服务端读到的字节数是 9 而不是 8。这种细节在协议设计里必须统一,用定长包头还是分隔符,客户端和服务端得按同一套规则来。
send()的返回值要分三段看:等于len表示全发完了;大于 0 小于len表示只发了一部分,剩余部分要自己循环补发;小于 0 是错误。这段代码只判断了< 0,把「部分发送」当成功处理了。小消息在本地几乎不会触发,一上真实网络、缓冲区满了就会丢数据,属于典型的「测试环境好好的,上线就漏字节」。
出错之后continue而不是break也是个隐患。连接已经断了的情况下,send会立刻返回EPIPE,于是循环以 CPU 满载的速度刷错误日志,一会儿就把磁盘写满。我一般会改成break,或者加个失败计数,连续失败 N 次就退出。
另外msgid是无符号自增,理论上会溢出回绕,实际跑不到那一步。但如果要做长时间压测,这里得用uint64_t,否则日志里会出现负数,看着像 bug。
4.3 三次握手、四次挥手分别卡在哪几个函数上
把服务端和客户端的函数按时间轴对齐,TCP 状态迁移就很清楚了:
| 阶段 | 客户端动作 | 服务端动作 | 报文 |
|---|---|---|---|
| 建连第 1 步 | connect()进入阻塞 | 已listen(),处于 LISTEN | SYN |
| 建连第 2 步 | connect()返回 | accept()被唤醒,回 SYN+ACK | SYN+ACK |
| 建连第 3 步 | 内核自动回 ACK | accept()返回新connfd | ACK |
| 数据 | send()/recv() | recv()/send() | PSH+ACK |
| 断连第 1 步 | close()发 FIN | recv()返回 0 | FIN |
| 断连第 2 步 | 等对端 FIN | close()发 FIN | ACK+FIN |
| 断连第 3 步 | 回 ACK,进 TIME_WAIT | 收到 ACK | ACK |
记住两个结论就够了:客户端connect()在第二次交互后返回,服务端accept()在第三次交互后返回;四次挥手是因为 TCP 全双工,两个方向各关一次,主动关闭方最后会进入TIME_WAIT停留 2MSL。
这套示例里其实没有正确走完四次挥手:~mysocket()是空的,connfd从来没被close()过。进程退出时内核会兜底回收,所以看不出来问题。但一旦改成常驻服务,就变成描述字泄漏,跑几天就EMFILE。
5. 避坑与排查:五个让 demo 直接翻车的点
下面这五条都是这套代码实际会撞上的,不是泛泛而谈。每条按「现象 → 原因 → 解决」走一遍,遇到同类问题时可以直接对号入座。
5.1 现象:server 启动就报 bind socket error
现象:./server一运行就打印bind socket error然后退出,ss -lntp | grep 6666也看不到监听。
原因:端口被占。最常见的是上一个./server没退干净——Ctrl+Z挂起而不是Ctrl+C终止,进程还在后台,端口还被它占着。另一种是TIME_WAIT状态残留,尤其是反复重启服务的调试阶段。
解决:先ss -lntp | grep 6666找到占用进程 PID,kill掉再启。如果确认是TIME_WAIT,在bind()之前加一行setsockopt:
int on = 1; // 允许绑定处于 TIME_WAIT 的地址,调试期必加 setsockopt(m_isocket, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));这行要加在socket()之后、bind()之前,顺序错了不生效。SO_REUSEADDR只解决TIME_WAIT复用,不解决两个活进程抢同一端口——那属于逻辑错误,得改端口或者改架构。
5.2 现象:客户端 connect 一直返回失败,服务端毫无反应
现象:服务端已经打印了 waiting,客户端仍然connect to server error,服务端日志一条新记录都没有。
原因:请求根本没到服务端。可能是客户端里写的 IP 不是服务端实际地址,也可能是中间有防火墙拦了入站,还可能是服务端跑在容器里、端口没映射出来。
解决:按顺序排查。先在服务端机器上ss -lntp | grep 6666确认监听在0.0.0.0:6666而不是127.0.0.1:6666——如果是后者,说明bind时写死了回环地址,外部进不来。再从客户端机器telnet <服务端IP> 6666或者nc -vz <服务端IP> 6666,通了说明网络层没问题,问题在应用层;不通就查防火墙和端口映射。别一上来就怀疑代码,connect失败九成是地址或通路问题。
5.3 现象:同机测试通过,换台机器就报 inet_pton error
现象:在本机跑127.0.0.1一切正常,换到局域网另一台机器上改 IP 后,客户端直接打印inet_pton error for ...并退出。
原因:inet_pton返回 0 表示字符串不是合法的 IPv4 点分十进制。常见写法错误包括多打了一位(192.168.1.256)、用了中文句号、复制粘贴时带了空格或者全角字符。另一种是AF_INET配了 IPv6 地址字符串,族和格式对不上。
解决:把 IP 字符串打出来逐字符看,尤其是从文档里复制的时候。代码层面可以把inet_pton的三种返回值分开处理,0报「地址格式非法」,-1报「协议族不支持」,别混成一句话。更稳的做法是把 IP 做成命令行参数,用getopt解析,避免每次改 IP 都要重新编译——改源码忘了重编,是另一个高频翻车点。
5.4 现象:服务端打印的消息内容是空的,msglen 却不为 0
现象:客户端明明在发msg = 0.,服务端打印出来是rec msg from client: .msglen=9,内容一片空白。
原因:recvmsg()里memset(buff, 0, 1024)写在了printf之前,把刚读进来的数据清掉了。n是局部变量,不受memset影响,所以长度还对,内容没了。
解决:调整顺序,先打印再清;或者不要清整个缓冲区,改成每次recv前清、recv后按n手动补\0:
while ((n = recv(connfd, buff, sizeof(buff) - 1, 0)) > 0) { buff[n] = '\0'; // 只补结尾,不动已有数据 printf("recv: %s (len=%d)\n", buff, n); memset(buff, 0, sizeof(buff)); // 本轮处理完再清,给下一轮用 }这类 bug 的恶劣之处在于不崩不报错,只是数据静默消失。凡是「日志里字段长度对、内容空」的情况,第一反应就该是缓冲区被提前清了。
5.5 现象:端口号明明写的 6666,ss 显示监听了另一个端口
现象:代码里写createserver(..., 6666),ss -lntp看到的却是 2586 或者别的数字,客户端连 6666 一直失败。
原因:servaddr.sin_port忘了做htons(),主机序的小端值被当成网络序解析。6666 的十六进制是0x1A0A,按小端字节序排是0A 1A,读成大端就是0x0A1A= 2586。
解决:所有往sockaddr_in里写的多字节整数都必须过一遍htons(16 位端口)或htonl(32 位地址)。反向从结构体里读出来展示时,要过ntohs/ntohl。养成习惯:看到sin_port就条件反射加htons,看到sin_addr.s_addr就加htonl。这条没有捷径,只能靠肌肉记忆,我当年因为这个在联调现场耗掉一下午。
6. 二次开发:把它改成能收多个客户端的小服务
这套代码最值钱的地方不是它能跑,而是它的骨架足够干净,改起来不用推倒重来。我拿它做过几次原型验证,改造路径基本固定在三个方向上。
第一步是把错误处理抽出来。现在每个失败分支都是一句printf加return false,没法定位是哪一步、哪个errno。我的做法是包一层带errno的日志函数,输出函数名 + 行号 + strerror(errno),然后把~mysocket()改成真正close(m_isocket),顺手把connfd也管起来。这一步做完,后面所有问题都能靠日志定位,不用再靠猜。
第二步是解决「只接一个客户端」。当前accept()拿到一个连接就break,本质是单连接串行服务。最小改动是用fork():父进程继续accept,子进程拿着connfd跑recv循环,处理完自己退出。改动量不到二十行,但要注意父进程必须close(connfd),否则描述字会越积越多。连接数再往上走就该换select/poll/epoll了,单线程事件循环,不用考虑进程间同步。我一般先fork验证业务逻辑,确认没问题再重写成epoll,两次改动的关注点分开,出问题好定位。
第三步是补上消息边界。recv不保证一次读到一条完整消息,现在靠\n分隔只是碰巧能用。稳妥做法是加长度前缀:包头固定 4 字节存消息体长度,收的时候先读 4 字节、再按长度读满。改造时要注意recv可能只读到半个包头,得写个recv_n循环读满再解析,这是粘包处理里最容易漏的一环。
| 改造点 | 改动位置 | 难度 | 适用场景 |
|---|---|---|---|
| 错误日志 + 描述字回收 | mysocket.cpp全局 | 低 | 所有场景,优先做 |
fork多客户端 | recvmsg() | 低 | 连接数少、逻辑独立 |
epoll事件循环 | 新增事件层 | 中 | 长连接、高并发 |
| 长度前缀协议 | recvmsg()/sedmsg() | 中 | 消息体可能超缓冲区 |
| 参数化 IP 和端口 | server.cpp/client.cpp | 低 | 必须做,否则每次重编 |
验证方法很土但有效:起服务端,用for i in $(seq 1 50); do ./client & done压五十个客户端,看ss -tnp | grep 6666的连接数、看lsof -p <pid> | wc -l的描述字数量是否稳定。描述字只增不减就是泄漏,连接数上不去就是 backlog 或者accept位置有问题。
从那以后我每次改这种网络 demo,都强制走一遍「先改 IP 和端口成参数、再加SO_REUSEADDR、最后补close」这三步,不管多急都不跳——跳过任何一步,后面都会用两倍时间还回来。希望帮到你。
本文还有配套的精品资源,点击获取