一篇写给 C++/MFC 工业现场开发者的 TCP 实战总结。从 TCP 的本质讲起,把 socket 函数、setsockopt 选项、粘包拆包、常见坑一次讲清楚。
一、开篇:为什么 TCP 编程总出问题
很多人写 TCP 代码,编译能过、本地能跑,一上现场就出问题:
明明发了一条消息,对方收到的却是两条粘在一起
明明发的是完整帧,对方
recv到的却是半截明明连接还在,
send却返回失败程序重启后
bind报Address already in use日志里每隔 20ms 刷一条
Error in receiving data
这些问题的根源,几乎都出在对 TCP 本质的理解上。TCP 不是一个“消息协议”,而是一个字节流协议。理解这一点,后面所有问题都能找到原因。
二、TCP 的本质:字节流,不是消息
TCP 只保证三件事:
可靠:丢包重传、乱序重排、重复去重
有序:发出去的字节,对方按同样顺序收到
有连接:先三次握手建连,再传数据,最后四次挥手断连
它不保证:
你
send一次,对方就recv一次消息边界
举例:你发两条消息
cpp
send(s, "ABC", 3, 0); send(s, "DEF", 3, 0);
对方可能:
一次
recv收到"ABCDEF"(粘包)先收
"AB",再收"CDEF"(拆包)或任意其它切法
结论:TCP 上层必须自己定义“消息边界”。这就是应用层协议存在的意义。
三、TCP 通信的完整生命周期
服务端
text
socket() → 创建 socket bind() → 绑定 IP + 端口 listen() → 进入监听 accept() → 阻塞等待连接,返回新 socket recv()/send() → 收发数据 closesocket() → 关闭
客户端
text
socket() → 创建 socket connect() → 连接服务端(三次握手) send()/recv() → 收发数据 closesocket() → 关闭
三次握手 / 四次挥手
三次握手:客户端
SYN→ 服务端SYN+ACK→ 客户端ACK,完成后进入ESTABLISHED四次挥手:一方
FIN→ 对方ACK→ 对方FIN→ 本方ACK
关键点:recv返回 0,就是对方发了FIN,表示“我不再发数据了”,这不是错误。
四、核心 socket 函数逐个讲
1.socket()— 创建 socket
cpp
SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
AF_INET:IPv4SOCK_STREAM:TCP返回
INVALID_SOCKET表示失败
2.bind()— 绑定本地地址
cpp
sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_port = htons(8888); // 端口,网络字节序 addr.sin_addr.s_addr = INADDR_ANY; // 任意网卡 bind(s, (sockaddr*)&addr, sizeof(addr));
服务端必须
bind,客户端一般不用htons/htonl转网络字节序(大端)INADDR_ANY表示监听本机所有网卡
3.listen()— 进入监听
cpp
listen(s, SOMAXCONN);
让 socket 从“主动”变“被动”
第二个参数是等待队列长度,不是“最大连接数”
4.accept()— 接受连接
cpp
SOCKET client = accept(s, (sockaddr*)&clientAddr, &addrLen);
阻塞等待客户端连接
返回一个新 socket,专门和这个客户端通信;原来的
s继续监听一个服务端可以
accept出很多 client socket
5.connect()— 客户端连接服务端
cpp
connect(s, (sockaddr*)&serverAddr, sizeof(serverAddr));
触发三次握手
阻塞模式下会等到握手完成或失败
6.send()— 发送数据(重点)
cpp
int n = send(s, buf, len, 0);
必须理解的三点:
send不是“把数据发给对方”,而是“把数据拷贝进内核发送缓冲区”返回值
n是实际拷贝进去的字节数,可能小于len返回
SOCKET_ERROR才是失败,用WSAGetLastError()拿错误码
正确的发送必须循环:
cpp
int total = 0; while (total < len) { int n = send(s, buf + total, len - total, 0); if (n == SOCKET_ERROR) { /* 处理错误 */ break; } total += n; }send成功 ≠ 对方收到。数据可能还在内核缓冲区、还在网络里。要确认送达,需要应用层 ACK。
7.recv()— 接收数据(重点)
cpp
int n = recv(s, buf, buflen, 0);
三种返回值:
| 返回值 | 含义 |
|---|---|
> 0 | 实际收到的字节数,可能小于buflen |
0 | 对方正常关闭连接(发了 FIN),不是错误 |
SOCKET_ERROR | 出错,看WSAGetLastError() |
关键点:
一次
recv拿到的可能是半帧,也可能是多帧阻塞模式下没数据会一直等
非阻塞模式下没数据返回
SOCKET_ERROR+WSAEWOULDBLOCK必须自己维护缓冲区,把碎片拼成完整帧
8.closesocket()— 关闭
cpp
closesocket(s);
关闭后内核会发 FIN,进入四次挥手
关闭前如果有未发完的数据,默认会尝试发完(
SO_LINGER可控)
9.shutdown()— 半关闭
cpp
shutdown(s, SD_SEND); // 不再发送,但仍可接收
只关一个方向,比
closesocket温和
五、setsockopt:socket 选项详解
socket创建后,行为由一堆选项控制。setsockopt就是改这些选项:
cpp
int setsockopt(SOCKET s, int level, int optname, const char* optval, int optlen);
level:选项层级,常用SOL_SOCKET(通用)、IPPROTO_TCP(TCP 专用)optname:选项名optval/optlen:选项值
对应的读取函数是getsockopt。
常用选项
1.SO_REUSEADDR— 地址重用(服务端必设)
cpp
int yes = 1; setsockopt(s, SOL_SOCKET, SO_REUSEADDR, (char*)&yes, sizeof(yes));
必须在
bind()之前设置作用:服务端重启时,旧连接还处于
TIME_WAIT,bind会失败(Address already in use)。设了这个就能立即重新bind不加它,程序重启后要等几十秒到几分钟才能重新监听
2.SO_RCVTIMEO/SO_SNDTIMEO— 收发超时
cpp
int timeout = 3000; // 毫秒 setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (char*)&timeout, sizeof(timeout)); setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (char*)&timeout, sizeof(timeout));
设置后,
recv/send阻塞超过这个时间就返回SOCKET_ERROR,错误码WSAETIMEDOUT不设就是永久阻塞,程序可能卡死
3.TCP_NODELAY— 禁用 Nagle 算法(实时场景必设)
cpp
int yes = 1; setsockopt(s, IPPROTO_TCP, TCP_NODELAY, (char*)&yes, sizeof(yes));
Nagle 算法:为了减少小包,会把多个小
send合并成一个 TCP 段再发副作用:延迟。你
send一个小包,Nagle 可能等一会儿再发设
TCP_NODELAY = 1禁用 Nagle,小包立即发
重要:TCP_NODELAY不需要每次发送都设置!
它是一次性设置的 socket 选项:
在
connect()成功后设一次(客户端)在
accept()返回后设一次(服务端)重连后新 socket 要重新设置
之后所有
send都自动生效
4.SO_KEEPALIVE— 保活
cpp
int yes = 1; setsockopt(s, SOL_SOCKET, SO_KEEPALIVE, (char*)&yes, sizeof(yes));
长时间没数据时,内核定期发探测包
默认间隔很长(2 小时),实际场景建议用应用层心跳替代
5.SO_LINGER— 关闭行为
cpp
linger lin; lin.l_onoff = 1; lin.l_linger = 0; // 立即关闭,丢弃未发数据,发 RST setsockopt(s, SOL_SOCKET, SO_LINGER, (char*)&lin, sizeof(lin));
默认关闭时,内核会尝试把未发数据发完再断
l_linger = 0表示立即断,慎用
6.SO_ERROR— 读取并清除错误
cpp
int err; int len = sizeof(err); getsockopt(s, SOL_SOCKET, SO_ERROR, (char*)&err, &len);
读取 socket 上累积的异步错误,读完会清零
不能用来判断“当前连接好不好”,只能读一次性的错误状态
7.SO_SNDBUF/SO_RCVBUF— 缓冲区大小
cpp
int size = 64 * 1024; setsockopt(s, SOL_SOCKET, SO_SNDBUF, (char*)&size, sizeof(size)); setsockopt(s, SOL_SOCKET, SO_RCVBUF, (char*)&size, sizeof(size));
改内核缓冲区大小
高吞吐场景可以调大,但操作系统有上限
选项该放在哪里?一张表说清楚
| 选项 | 客户端 connect 成功后 | 服务端 accept 的 socket | 监听 socket |
|---|---|---|---|
TCP_NODELAY | ✅ | ✅ | ❌ 不需要 |
SO_RCVTIMEO | ✅ | ✅ | ❌ 不需要 |
SO_SNDTIMEO | ✅ | ✅ | ❌ 不需要 |
SO_REUSEADDR | ❌ | ❌ | ✅bind 之前 |
SO_KEEPALIVE | ✅ 可选 | ✅ 可选 | ❌ 不需要 |
核心原则:
每个 socket 的选项不会继承,重连后新 socket 要重设
监听 socket 不用于收发业务数据,不需要
TCP_NODELAYSO_REUSEADDR必须在bind之前设
六、阻塞 vs 非阻塞
阻塞模式(默认)
recv:没数据 →卡住,直到有数据或超时send:缓冲区满 →卡住accept:没连接 →卡住
优点:逻辑简单。缺点:一个线程只能处理一个连接。
非阻塞模式
cpp
u_long mode = 1; ioctlsocket(s, FIONBIO, &mode);
recv:没数据 → 立即返回SOCKET_ERROR,错误码WSAEWOULDBLOCKsend:缓冲区满 → 立即返回WSAEWOULDBLOCKaccept:没连接 →WSAEWOULDBLOCK
必须配合select/WSAEventSelect/ IOCP使用,否则会变成“忙轮询”,CPU 跑满。
错误码判断标准写法
cpp
int n = recv(s, buf, len, 0); if (n == SOCKET_ERROR) { int err = WSAGetLastError(); if (err == WSAEWOULDBLOCK) { // 非阻塞,暂时没数据,正常 } else if (err == WSAETIMEDOUT) { // 阻塞模式下超时 } else { // 真错误,连接可能断了 } }七、粘包 / 拆包:TCP 编程最核心的坑
现象
你发两帧:
text
send("ABC") send("DEF")对方可能:
一次
recv收到"ABCDEF"(粘包)先收
"AB",再收"CDEF"(拆包)其它任意切法
三种解决方案
方案 1:长度前缀(最可靠)
text
[4字节长度][数据][4字节长度][数据]...
接收端先读 4 字节长度,再读那么长的数据。没有任何歧义。
方案 2:固定长度
每条消息固定长度,读满就切。
方案 3:分隔符
text
@$...$*@$...$*...
用@$开头、$*结尾。接收端维护缓冲区,循环切出所有完整帧。缺点是内容里不能出现分隔符,否则会误切。
接收端标准处理流程
text
1. 把收到的数据无条件追加到缓冲区 2. 循环: a. 从缓冲区找帧头 b. 没有帧头 → 丢弃垃圾,break c. 找帧尾 d. 没有帧尾 → 帧不完整,break,等下次 e. 切出一帧,从缓冲区移除,处理这一帧 3. 缓冲区跨 recv 保留
关键点:
缓冲区必须是成员变量或静态变量,不能是函数局部变量,否则半帧会丢
切帧必须用
while而不是if,一次 recv 收到多帧要一次处理完如果缓冲区里没有帧头(垃圾数据),要清空,否则垃圾会永远卡在缓冲区开头
正确代码示例
cpp
// 成员变量 CString m_strRecvBuffer; // 每次收到数据 CString strChunk = GetData(); if (!strChunk.IsEmpty()) { m_strRecvBuffer += strChunk; while (true) { // 找帧头 int pos1 = m_strRecvBuffer.Find(_T("@$")); if (pos1 < 0) { m_strRecvBuffer.Empty(); // 没有帧头,清空 break; } if (pos1 > 0) m_strRecvBuffer = m_strRecvBuffer.Mid(pos1); // 丢弃帧头前的垃圾 // 找帧尾 int pos2 = m_strRecvBuffer.Find(_T("*"), 2); if (pos2 < 0) break; // 帧不完整,等下次 // 切出一帧 CString strFrame = m_strRecvBuffer.Left(pos2 + 1); m_strRecvBuffer = m_strRecvBuffer.Mid(pos2 + 1); // 处理这一帧 ProcessOneFrame(strFrame); } }八、如何判断 TCP 连接“还活着”
没有一种方法能 100% 判断连接是否活着,因为:
网络断了,双方都不知道,直到发数据失败
对方进程死了,操作系统会发 FIN/RST,但中间设备可能没转发
常用手段
发数据:
send返回错误,说明连接可能断了。最直接SO_KEEPALIVE:内核定期探测,但间隔太长应用层心跳:定期发
PING,等PONG,超时未回就重连。最可靠select/ 非阻塞recv:返回 0 表示对端关闭
工业现场的标准做法:心跳 + 超时重连。
九、实战踩坑清单
以下都是我在实际 MFC 项目中踩过的坑,列出来供大家避雷。
坑 1:send只调一次,大数据丢尾
cpp
// ❌ 错误 send(s, buf, len, 0); // ✅ 正确:循环发到发完 int total = 0; while (total < len) { int n = send(s, buf + total, len - total, 0); if (n == SOCKET_ERROR) break; total += n; }坑 2:recv缓冲区太小
cpp
// ❌ 错误:一帧超 50 字节就被拆 char buff[51]; recv(s, buff, 50, 0); // ✅ 正确:给足空间 char buff[4096]; recv(s, buff, sizeof(buff) - 1, 0);
坑 3:strlen用在 Unicode 工程
cpp
// ❌ 错误:Unicode 下 strlen 遇 0x00 字节就停 send(s, (char*)str, strlen((char*)str), 0); // ✅ 正确:按 TCHAR 大小算 int len = str.GetLength() * sizeof(TCHAR); send(s, (LPCSTR)str, len, 0);
坑 4:GetBuffer没配ReleaseBuffer
cpp
// ❌ 错误 char* p = str.GetBuffer(str.GetLength()); send(s, p, str.GetLength(), 0); // 忘记 ReleaseBuffer,CString 内部锁死 // ✅ 正确:纯发送不需要 GetBuffer const char* p = (LPCSTR)str; send(s, p, str.GetLength(), 0);
坑 5:阻塞recv没设超时
cpp
// ❌ 错误:没数据就永久卡死 recv(s, buf, len, 0); // ✅ 正确:设置超时 int timeout = 3000; setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (char*)&timeout, sizeof(timeout)); recv(s, buf, len, 0);
坑 6:SO_REUSEADDR没设
服务端重启后bind报Address already in use,因为旧连接还在TIME_WAIT。
cpp
// ✅ bind 之前设置 int yes = 1; setsockopt(s, SOL_SOCKET, SO_REUSEADDR, (char*)&yes, sizeof(yes)); bind(s, ...);
坑 7:没设TCP_NODELAY,小包延迟
实时场景下,Nagle 算法会合并小包导致延迟。
cpp
// ✅ connect 成功后设一次即可 int yes = 1; setsockopt(s, IPPROTO_TCP, TCP_NODELAY, (char*)&yes, sizeof(yes));
坑 8:切帧用if不用while
cpp
// ❌ 错误:粘包时只处理第一帧 if (buffer.Find("@$") >= 0) { ... } // ✅ 正确:循环切完所有完整帧 while (true) { ... }坑 9:缓冲区用局部变量
cpp
// ❌ 错误:函数退出后半帧丢失 void RecvThread() { CString buffer; // 局部变量 while (true) { buffer += recv(); ... } } // ✅ 正确:成员变量,跨循环保留 class CMyClass { CString m_strRecvBuffer; // 成员变量 };坑 10:把WSAEWOULDBLOCK当错误
cpp
// ❌ 错误:非阻塞下误判 int n = recv(s, buf, len, 0); if (n == SOCKET_ERROR) return false; // ✅ 正确 int n = recv(s, buf, len, 0); if (n == SOCKET_ERROR) { int err = WSAGetLastError(); if (err == WSAEWOULDBLOCK) return true; // 正常,没数据 return false; }坑 11:getsockopt(SO_ERROR)判断连接状态
cpp
// ❌ 错误:SO_ERROR 是“读一次清零”的异步错误状态 // 不能用来判断“当前连接好不好” int optval; getsockopt(s, SOL_SOCKET, SO_ERROR, (char*)&optval, &optlen); if (optval == 0) { /* 连接正常? */ } // ✅ 正确:直接看 send/recv 的返回值 int n = send(s, buf, len, 0); if (n == SOCKET_ERROR) { /* 出错 */ }坑 12:recv返回 0 当错误
cpp
// ❌ 错误:返回 0 是对端正常关闭(FIN) if (n == 0) return "Invalid Socket!"; // ✅ 正确:返回 0 表示对端关闭,应触发重连 if (n == 0) { // 对端关闭,关闭 socket,触发重连 return ""; // 空串,让上层处理 }坑 13:错误串混入数据缓冲区
如果 socket 出错了,recv返回一段错误字符串(比如"Error in receiving data"),如果你把它当数据累加进缓冲区,会污染缓冲区,导致后面正常帧也切不出来。
cpp
// ✅ 正确:错误串不入缓冲 CString strChunk = GetData(); if (strChunk.Find(_T("Error")) < 0 && !strChunk.IsEmpty()) { m_strRecvBuffer += strChunk; }坑 14:日志每 20ms 刷一次
cpp
// ❌ 错误:20ms 一次,一天 400 万条日志 strLog.Format("获取的信号---%s", strReturnValue); LOGENTRY(strLog); // ✅ 正确:判断非空 + 限流 if (!strReturnValue.IsEmpty()) { static DWORD s_dwLastLogTick = 0; DWORD dwNow = GetTickCount(); if (dwNow - s_dwLastLogTick > 1000) // 1 秒最多一条 { s_dwLastLogTick = dwNow; strLog.Format("获取的信号---%s", strReturnValue); LOGENTRY(strLog); } }十、完整流程示例
客户端
cpp
// 1. 创建 SOCKET s = socket(AF_INET, SOCK_STREAM, 0); // 2. 连接 sockaddr_in addr = {}; addr.sin_family = AF_INET; addr.sin_port = htons(8888); inet_pton(AF_INET, "192.168.1.100", &addr.sin_addr); connect(s, (sockaddr*)&addr, sizeof(addr)); // 3. 设置选项(connect 成功后) int yes = 1; setsockopt(s, IPPROTO_TCP, TCP_NODELAY, (char*)&yes, sizeof(yes)); int timeout = 3000; setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (char*)&timeout, sizeof(timeout)); setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (char*)&timeout, sizeof(timeout)); // 4. 发送(循环,处理部分发送) const char* msg = "Hello"; int len = strlen(msg); int total = 0; while (total < len) { int n = send(s, msg + total, len - total, 0); if (n == SOCKET_ERROR) break; total += n; } // 5. 接收(缓冲区 + 循环切帧) char buf[4096]; int n = recv(s, buf, sizeof(buf), 0); if (n > 0) { /* 追加到缓冲区,切帧 */ } else if (n == 0) { /* 对端关闭,重连 */ } else { /* 错误,看 WSAGetLastError() */ } // 6. 关闭 closesocket(s);服务端
cpp
// 1. 创建 SOCKET listenSock = socket(AF_INET, SOCK_STREAM, 0); // 2. 设置 SO_REUSEADDR(bind 之前) int yes = 1; setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR, (char*)&yes, sizeof(yes)); // 3. 绑定 sockaddr_in addr = {}; addr.sin_family = AF_INET; addr.sin_port = htons(8888); addr.sin_addr.s_addr = INADDR_ANY; bind(listenSock, (sockaddr*)&addr, sizeof(addr)); // 4. 监听 listen(listenSock, SOMAXCONN); // 5. 接受连接(循环) while (running) { SOCKET client = accept(listenSock, nullptr, nullptr); if (client == INVALID_SOCKET) continue; // 6. 设置客户端 socket 选项 int timeout = 3000; setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, (char*)&timeout, sizeof(timeout)); setsockopt(client, IPPROTO_TCP, TCP_NODELAY, (char*)&yes, sizeof(yes)); // 7. 收发 char buf[4096]; int n = recv(client, buf, sizeof(buf), 0); if (n > 0) { /* 处理数据 */ } else if (n == 0) { /* 对方关闭 */ } else { /* 出错 */ } // 8. 关闭 closesocket(client); } closesocket(listenSock);十一、核心结论速查
| 结论 | 说明 |
|---|---|
| TCP 是字节流 | 没有消息边界,必须应用层定义 |
send成功 ≠ 对方收到 | 只是拷进内核缓冲区 |
send可能部分发送 | 必须循环发到发完 |
recv可能收到半帧/多帧 | 必须缓冲区 +while循环切帧 |
recv返回 0 = 对方关闭 | 不是错误,是正常 FIN |
recv返回SOCKET_ERROR | 看WSAGetLastError() |
WSAEWOULDBLOCK | 非阻塞无数据,不是真错误 |
SO_REUSEADDR | 服务端必设,bind之前 |
TCP_NODELAY | 设一次即可,重连后要重设 |
SO_RCVTIMEO | 不设就永久阻塞 |
| 缓冲区必须跨 recv 保留 | 局部变量会丢半帧 |
| 判断连接活着 | 心跳 + 超时重连,没有银弹 |
| 错误串不能入缓冲 | 会污染缓冲区,导致后面帧切不出来 |
十二、写在最后
TCP 编程不难,难在理解它只是字节流。只要记住这一点:
发送端不假设对方一次收到
接收端不假设一次收到完整帧
缓冲区跨循环保留
切帧用
while错误串不入缓冲
连接状态靠
send/recv返回值判断,不靠getsockopt
基本上 90% 的 TCP 问题都能解决。剩下 10% 是网络环境和对方实现的问题,那就只能加日志、抓包慢慢查了。
希望这篇总结能帮到正在踩坑的你。
如果这篇文章对你有帮助,欢迎点赞、收藏、评论交流。
转载请注明出处。