简介这是一份面向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::vectorchar解析时从头扫解析完把已消费部分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::vectorchar 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 -stdc17 -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::functionvoid(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::functionvoid(const std::string) cb) { errCb_ std::move(cb); } private: RecvBuffer buf_; MessageCallback cb_; std::functionvoid(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_castuint32_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_castsize_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/selectEAGAIN时把 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 一路用到线上服务没出过粘包相关的线上事故希望帮到你。本文还有配套的精品资源点击获取
