简介:这是一份面向C++网络编程学习者的实战源码资源,聚焦TCP/IP通信中粘包与丢包这一棘手问题,适合已掌握C++基础、希望深入理解网络消息收发机制的开发者。资源通过服务器与客户端两端的完整实现,逐步演示网络编程的完整流程,并将底层收发细节封装为设计良好的函数,应用层只需定义协议头、消息结构体与回调函数即可,无需关心数据如何被接收和发送。压缩包共36个文件,包含16个h头文件、8个cpp源文件,以及dsw、dsp工程文件、rc资源脚本、lib静态库和ico图标等,整体约49KB,工程结构清晰,可直接编译运行。目前已有1649人学习下载。读者可从中获得一套可复用的粘包处理方案、协议与消息结构设计范例、客户端与服务器通信的完整代码框架,以及便于二次开发的目录组织方式,是理解并解决TCP粘包问题的实用参考。
1. TCP 粘包不是 bug,是字节流在替你背锅
很多人第一次写 C++ TCP 服务端,都会遇到一个诡异现象:客户端明明分三次send,每次发一条 JSON,服务端recv一次却把三条消息全读出来了,或者一条消息被劈成两半。新手第一反应是「TCP 有 bug」,老手会告诉你这叫粘包和拆包。它根本不是错误,而是 TCP 作为字节流协议的天性——它只保证字节顺序和可靠到达,不保证消息边界。你在应用层不划边界,内核就按自己的节奏把字节塞给你。
这个标题要解决的就是这件事:用一套能编译运行的 C++ 源代码,把「长度字段 + 缓冲区」这套最稳的拆包方案讲透。适合两类人:一是刚学完 socket 编程、被粘包折磨到怀疑人生的新手;二是想找一个可以直接抄进项目的工业级收发框架的熟手。下面从协议设计一路写到编译运行,代码全部可复现,不玩虚的。
2. 先定协议再写代码:长度字段为什么比分隔符靠谱
2.1 粘包拆包的本质与三种主流方案对比
TCP 是面向字节流的,发送端调用send只是把数据交给内核发送缓冲区,内核怎么分段、什么时候发,取决于 MSS、Nagle 算法和当前拥塞窗口。接收端recv返回的是「当前内核缓冲区里已有的字节」,可能多可能少。所以「一条消息」这个概念,TCP 层根本不存在,必须由应用层协议自己定义。
常见三种划边界方案:
| 方案 | 做法 | 优点 | 致命缺点 |
|---|---|---|---|
| 固定长度 | 每条消息定长 | 实现最简单 | 变长业务直接废掉 |
| 分隔符 | 用\n或\0分隔 | 可读性好 | 消息体含分隔符就翻车,需转义 |
| 长度字段 | 头部写 body 长度 | 二进制安全、效率高 | 需要处理头部本身拆包 |
我一般直接选长度字段,因为它是二进制安全的,图片、protobuf、压缩数据都能塞。分隔符方案在文本协议里能用,但一旦业务数据里出现\n,就是血泪经验现场。长度字段唯一的麻烦是「头部本身也可能被拆开」,这个后面用读缓冲解决。
协议格式我定成这样,简单到不会记错:
+--------+--------+----------------+ | 4 字节 | 4 字节 | N 字节 | | magic | length | body | +--------+--------+----------------+magic固定值用于快速校验,length表示 body 字节数,网络字节序。接收端先攒够 8 字节头部,读出 length,再攒够 length 字节 body,一条完整消息才算到齐。
2.2 用环形缓冲区还是 std::vector 攒数据
攒数据这一步是核心。最朴素的做法是每次recv后append到一个std::vector<char>,解析时从头扫,解析完把已消费部分erase掉。这个方案能跑,但erase会搬移内存,高频小包场景下性能很难看。
我一般用「读偏移 + 定期压缩」的折中方案:vector 只增不删,维护一个readPos,解析时从readPos开始看,消费后readPos前移。当readPos超过某个阈值(比如 64KB)且超过容量一半时,才做一次erase压缩。这样绝大多数情况下没有内存搬移,实现又比环形缓冲区简单得多,不容易写出越界 bug。
下面这段是缓冲区骨架,先看结构,完整代码在下一章给全:
class RecvBuffer { public: void append(const char* data, size_t len) { buf_.insert(buf_.end(), data, data + len); } // 可读字节数 size_t readable() const { return buf_.size() - readPos_; } // 可读起始指针 const char* peek() const { return buf_.data() + readPos_; } // 消费 n 字节 void consume(size_t n) { readPos_ += n; // 超过阈值才压缩,避免频繁搬移 if (readPos_ > 64 * 1024 && readPos_ * 2 > buf_.size()) { buf_.erase(buf_.begin(), buf_.begin() + readPos_); readPos_ = 0; } } private: std::vector<char> buf_; size_t readPos_ = 0; };append负责把新收到的字节追加进来,peek返回当前未消费数据的起点,consume前移读指针。压缩条件readPos_ > 64KB && readPos_*2 > size的意思是:浪费的空间既绝对值够大、又占比够高,才值得搬一次。这个阈值可以按业务调,小消息密集就调小,大消息为主就调大。
提示:
peek返回的指针在下次append后可能失效(vector 扩容),所以解析时要么先把头部拷出来,要么保证解析期间不再 append。
3. 完整可编译的 C++ 收发框架:从头部解析到消息分发
3.1 项目文件结构与编译命令
整个项目就三个文件,不依赖任何第三方库,g++ 直接编:
tcp_demo/ ├── protocol.h // 协议常量与消息结构 ├── connection.h // 连接类:收发与拆包 ├── connection.cpp └── main.cpp // 服务端 + 客户端演示编译命令,Linux 和 macOS 通用:
g++ -std=c++17 -O2 -Wall -pthread main.cpp connection.cpp -o tcp_demoWindows 上用 MinGW 或 MSVC 也行,把-pthread去掉,socket 相关头文件换成winsock2.h,初始化时调一次WSAStartup。下面代码以 POSIX 为准,Windows 差异我会在注释里点出来。
3.2 协议头定义与字节序处理
网络字节序是大端,x86 是小端,所以 length 字段必须转换。别自己手写移位,用htonl/ntohl最稳:
// protocol.h #pragma once #include <cstdint> #include <arpa/inet.h> // Windows: winsock2.h constexpr uint32_t kMagic = 0x54435031; // "TCP1" constexpr size_t kHeaderLen = 8; // magic(4) + length(4) constexpr uint32_t kMaxBody = 16 * 1024 * 1024; // 单条消息上限 16MB struct Header { uint32_t magic; uint32_t length; }; // 把 8 字节头部解析成 Header,调用前保证有 kHeaderLen 字节 inline Header parseHeader(const char* p) { Header h; uint32_t netMagic, netLen; std::memcpy(&netMagic, p, 4); std::memcpy(&netLen, p + 4, 4); h.magic = ntohl(netMagic); h.length = ntohl(netLen); return h; } // 把 Header 序列化成 8 字节 inline void writeHeader(char* p, uint32_t length) { uint32_t netMagic = htonl(kMagic); uint32_t netLen = htonl(length); std::memcpy(p, &netMagic, 4); std::memcpy(p + 4, &netLen, 4); }kMaxBody是必须的防御:如果对端发来一个 length 是 4GB 的头部,你不校验就直接分配,内存瞬间被打爆。这是最常见的攻击面之一。parseHeader用memcpy而不是强制类型转换,是因为p可能不对齐,直接reinterpret_cast在某些 ARM 平台上会崩。
3.3 拆包主循环:一个 while 把粘包拆干净
这是整个方案的心脏。核心逻辑:只要缓冲区里够一个完整包,就取出来,循环直到不够:
// connection.h #pragma once #include "protocol.h" #include <vector> #include <functional> #include <string> class Connection { public: using MessageCallback = std::function<void(const std::string&)>; void onMessage(MessageCallback cb) { cb_ = std::move(cb); } // 收到原始字节后调用 void feed(const char* data, size_t len) { buf_.append(data, len); // 循环拆包,直到剩余不足一个完整包 while (true) { if (buf_.readable() < kHeaderLen) break; Header h = parseHeader(buf_.peek()); if (h.magic != kMagic) { // 协议错乱,直接断开,别硬撑 onError("bad magic"); return; } if (h.length > kMaxBody) { onError("body too large"); return; } if (buf_.readable() < kHeaderLen + h.length) break; // 完整包到齐,取出 body std::string body(buf_.peek() + kHeaderLen, h.length); buf_.consume(kHeaderLen + h.length); if (cb_) cb_(body); } } void onError(std::function<void(const std::string&)> cb) { errCb_ = std::move(cb); } private: RecvBuffer buf_; MessageCallback cb_; std::function<void(const std::string&)> errCb_; };逐段说逻辑。feed先把新字节追加进缓冲区,然后进入while。第一个break条件:连 8 字节头部都不够,等下次数据。第二个break:头部够但 body 没到齐,也等。只有readable >= kHeaderLen + h.length时才真正取出一条消息,consume掉,回调出去,然后继续循环——这一步就是「拆粘包」:如果一次recv收到三条消息,这个 while 会连续回调三次。
magic校验和kMaxBody校验是两道防线,任何一道不过直接走错误回调断开连接。生产环境里,协议错乱继续读下去只会读到更多垃圾,早断早干净。
3.4 发送端:一次 send 未必发完,必须循环
发送比接收更容易被忽视。send返回值可能小于请求长度,尤其是大包或发送缓冲区满时。必须循环发:
// 发送一条完整消息,返回是否全部发出 bool sendMessage(int fd, const std::string& body) { std::string packet; packet.resize(kHeaderLen + body.size()); writeHeader(&packet[0], static_cast<uint32_t>(body.size())); std::memcpy(&packet[kHeaderLen], body.data(), body.size()); size_t sent = 0; while (sent < packet.size()) { ssize_t n = ::send(fd, packet.data() + sent, packet.size() - sent, 0); if (n < 0) { if (errno == EINTR) continue; // 被信号打断,重试 if (errno == EAGAIN || errno == EWOULDBLOCK) { // 非阻塞模式下缓冲区满,实际项目应注册可写事件 // 这里简化处理:短暂等待后重试 continue; } return false; } sent += static_cast<size_t>(n); } return true; }关键点:send返回EINTR要重试,返回EAGAIN说明发送缓冲区满。真实项目里非阻塞 socket 应该把剩余数据挂到可写事件上,等epoll通知可写再继续发。这里为了演示拆包,用忙等简化,但你要知道生产环境不能这么干,否则 CPU 空转。
注意:
packet用std::string存二进制没问题,但别用strlen或c_str()去算长度,二进制里可能有\0,一律用size()。
4. 编译运行与联调:把粘包场景亲手复现一遍
4.1 服务端与客户端最小可运行代码
main.cpp里放一个服务端和一个客户端,服务端收消息打印,客户端故意用「一次发三条」和「一条拆两次发」两种方式制造粘包和拆包:
// main.cpp #include "connection.h" #include <sys/socket.h> #include <netinet/in.h> #include <unistd.h> #include <cstring> #include <cstdio> #include <thread> int makeServer(uint16_t port) { int fd = ::socket(AF_INET, SOCK_STREAM, 0); int opt = 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = INADDR_ANY; addr.sin_port = htons(port); bind(fd, (sockaddr*)&addr, sizeof(addr)); listen(fd, 8); return fd; } int main() { int srv = makeServer(9000); printf("server listening on 9000\n"); std::thread client([]{ std::this_thread::sleep_for(std::chrono::milliseconds(200)); int fd = ::socket(AF_INET, SOCK_STREAM, 0); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(9000); inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr); ::connect(fd, (sockaddr*)&addr, sizeof(addr)); // 场景一:连续发三条,制造粘包 sendMessage(fd, "hello-1"); sendMessage(fd, "hello-2"); sendMessage(fd, "hello-3"); // 场景二:一条消息拆成两次发,制造拆包 std::string body = "split-me"; std::string packet; packet.resize(kHeaderLen + body.size()); writeHeader(&packet[0], (uint32_t)body.size()); std::memcpy(&packet[kHeaderLen], body.data(), body.size()); ::send(fd, packet.data(), 5, 0); // 先发半个头 std::this_thread::sleep_for(std::chrono::milliseconds(50)); ::send(fd, packet.data() + 5, packet.size() - 5, 0); // 再发剩下的 ::close(fd); }); int conn = accept(srv, nullptr, nullptr); Connection c; c.onMessage([](const std::string& msg){ printf("recv message: %s\n", msg.c_str()); }); c.onError([](const std::string& e){ printf("error: %s\n", e.c_str()); }); char tmp[4096]; while (true) { ssize_t n = ::recv(conn, tmp, sizeof(tmp), 0); if (n <= 0) break; c.feed(tmp, (size_t)n); } client.join(); ::close(conn); ::close(srv); return 0; }4.2 运行结果与拆包验证
编译后运行,你应该看到:
server listening on 9000 recv message: hello-1 recv message: hello-2 recv message: hello-3 recv message: split-me四条消息一条不多一条不少。第一条recv很可能一次性收到三条 hello 的全部字节,但feed里的 while 循环把它们拆成了三次回调,这就是粘包被正确处理。split-me那条先发了 5 字节(半个头部),服务端第一次feed时readable < 8,直接 break 等待;50ms 后剩余字节到达,第二次feed才凑齐头部和 body,回调一次。这就是拆包被正确处理。
想更直观地看粘包,可以在服务端feed入口加一行打印n,你会看到n经常大于单条消息长度。这不是 bug,是 TCP 在正常合并小包(Nagle 算法 + 延迟确认)。你的拆包逻辑只要正确,n多大都无所谓。
4.3 用 tcpdump 观察真实字节流
想彻底搞明白,抓包看最直接:
sudo tcpdump -i lo -X 'tcp port 9000' -c 20-i lo抓本地回环,-X以十六进制加 ASCII 显示。你会看到54 43 50 31(就是 "TCP1" 的十六进制)后面跟着长度字段,三条 hello 的头部和 body 可能挤在同一个 TCP 段里。这一步能帮你建立「应用层消息」和「TCP 段」是两回事的直觉,以后遇到粘包就不会慌。
5. 避坑与排查:这五个坑我全踩过
5.1 现象:收到消息后程序偶发崩溃
原因:peek()返回的指针在append触发 vector 扩容后失效,解析时还拿着旧指针读。解决:解析头部时先把 8 字节memcpy到局部变量再解析,或者保证解析期间不 append。我现在的习惯是parseHeader内部直接memcpy,从根上杜绝悬空指针。
5.2 现象:大消息收一半就断了
原因:kMaxBody设太小,或者对端 length 字段没做字节序转换,读出来是个天文数字。解决:发送端和接收端统一用htonl/ntohl,并且把kMaxBody设成业务真实上限的 2 倍。调试时打印一下解析出的 length 原始值,一眼就能看出是不是字节序问题。
5.3 现象:客户端发完就关,服务端最后一条消息丢了
原因:客户端close后,内核可能还有数据没发完,或者服务端recv返回 0 时缓冲区里还有未解析的完整包。解决:服务端recv返回 0 后,不要立刻退出循环,先调一次feed把缓冲区里剩余数据解析完,再关闭。客户端如果在意可靠性,close前用shutdown(fd, SHUT_WR)半关闭,等服务端确认。
5.4 现象:非阻塞 socket 下 CPU 跑满
原因:send返回EAGAIN后用continue忙等,或者recv返回EAGAIN后没挂起直接重试。解决:非阻塞模式下必须配合epoll/select,EAGAIN时把 fd 注册到可读/可写事件,等通知再操作。演示代码里的忙等只是为了简化,生产环境这么写会被运维找上门。
5.5 现象:多线程下消息顺序错乱
原因:多个线程同时对一个连接调feed,缓冲区被并发修改。解决:一个连接绑定一个线程,或者给feed加锁。我一般用「one loop per thread」模型,每个连接的所有读写都在同一个事件循环线程里,天然无锁。如果非要跨线程发消息,用任务队列把发送请求投递到连接所属线程,别直接调send。
6. 进阶:把拆包框架接进 epoll 事件循环
上面演示用的是阻塞 socket + 忙等,真实项目必须换成 epoll 非阻塞。核心改动就三处:socket 设O_NONBLOCK,用epoll_wait驱动,recv返回EAGAIN时停止读、等下次可读事件。拆包逻辑一行不用改,feed照常调。
// epoll 事件循环骨架 int ep = epoll_create1(0); epoll_event ev{}; ev.events = EPOLLIN | EPOLLET; // 边缘触发 ev.data.fd = conn; epoll_ctl(ep, EPOLL_CTL_ADD, conn, &ev); epoll_event events[64]; while (true) { int n = epoll_wait(ep, events, 64, -1); for (int i = 0; i < n; ++i) { int fd = events[i].data.fd; // 边缘触发必须一次读到 EAGAIN,否则事件会丢 while (true) { char tmp[8192]; ssize_t r = ::recv(fd, tmp, sizeof(tmp), 0); if (r > 0) { connMap[fd].feed(tmp, (size_t)r); } else if (r == 0) { // 对端关闭,先解析完剩余数据再清理 connMap[fd].feed(nullptr, 0); closeConn(fd); break; } else { if (errno == EAGAIN || errno == EWOULDBLOCK) break; if (errno == EINTR) continue; closeConn(fd); break; } } } }边缘触发(EPOLLET)下必须循环读到EAGAIN,否则内核不会再通知你,剩下的数据就烂在缓冲区里了。这是从阻塞模型切到 epoll 最容易翻车的地方,我当年在这上面浪费了一整个下午。发送侧同理,把sendMessage里没发完的剩余数据存到连接的发送缓冲区,注册EPOLLOUT,可写时继续发。
验证方法很简单:把演示代码的客户端改成循环发 10 万条小消息,服务端用 epoll 版本接收,统计收到条数是否正好 10 万。如果少了,八成是边缘触发没读到EAGAIN,或者feed里漏了循环拆包。我现在的习惯是,任何新写的拆包代码,先跑一遍「10 万条小消息 + 1 条 10MB 大消息」的混合压测,两个都过才算稳。这套长度字段加缓冲区的方案,我从 demo 一路用到线上服务,没出过粘包相关的线上事故,希望帮到你。
本文还有配套的精品资源,点击获取