简介:面向C++网络编程学习者与需要搭建通信原型的开发者,这份代码示例围绕TCP粘包、丢包等常见棘手问题,演示从服务器到客户端的完整实现路径。压缩包共36个文件,约49KB,以16个头文件、8个cpp源文件为核心,辅以dsw/dsp工程文件、资源脚本、库文件与说明文本,工程组织完整,便于直接编译运行和对照调试。已有1650人学习,说明该主题在实际开发中关注度较高。代码通过较清晰的函数封装,把消息收发细节隐藏在传输层,使用者只需定义协议头、消息结构体及网络消息回调,即可专注应用层业务;同时保留客户端与服务端两套模块,适合用来理解粘包边界处理、消息分帧、回调分发与工程目录划分,对缓冲区管理、网络字节序等底层细节也有相应体现,也便于二次改造。
1. TCP粘包处理这套C++工程适合谁:从一次“收不全”的联调日志说起
一次本地联调,客户端连续发了三条日志,服务端只解析出一条半,剩下半条卡在缓冲区里,日志看着像玄学。问题不在recv,而在 TCP 是字节流,没有消息边界。这个 C++ 工程围绕 TCP粘包、半包、拆包做了一套能编译运行的最小闭环:固定头加长度字段的协议、PacketCodec编解码、PacketParser增量解析、服务端客户端示例、单元测试和粘包脚本。它适合正在写网络通讯模块、被“一次读多个包”或“一个包读一半”折腾过的 C++ 开发者,也适合想拿一份可运行代码改造成业务帧的人。工程不追求大而全,重点是把粘包处理链路讲透、跑通、能改。
2. 协议设计与工程目录:把无边界字节流切成可验证的消息帧
TCP粘包处理的第一步不是写recv,而是先定协议。字节流本身没有“包”的概念,应用层不划线,接收端就只能靠猜。这个工程采用“固定头 + 长度字段 + 消息体”的做法:先读固定长度的头,从头里取出body_len,再判断缓冲区里是否凑够了kHeaderSize + body_len。凑够就切出一个完整消息,不够就保留残余,等下一次recv继续拼。这个思路不新鲜,但胜在稳定、可测试、容易排查,尤其适合 C++ 网络通讯里对性能和可控性都有要求的场景。
2.1 为什么选“固定头 + 长度字段 + 消息体”
固定头的好处是接收端不用扫描分隔符,也不用依赖消息体内容。分隔符方案遇到消息体里本身带分隔符就会翻车,固定长度方案又不够灵活。长度字段方案把“包有多大”提前告诉接收端,解析逻辑变成一段确定性的状态判断:头够不够、长度合不合法、体够不够。只要协议头字段顺序和字节序统一,粘包和半包都只是缓冲区里的数据排列问题,不会变成业务层猜谜。
这个工程的包头设计为 16 字节,字段如下:
| 字段 | 长度 | 说明 |
|---|---|---|
magic | 4 字节 | 固定魔数0x54435031,用于快速识别错连或脏数据 |
version | 2 字节 | 协议版本,示例为 1 |
type | 2 字节 | 消息类型,文本、心跳等 |
body_len | 4 字节 | 消息体长度,网络字节序 |
seq | 4 字节 | 请求序号,便于日志对照 |
body_len必须限制上限,示例里用kMaxBody = 4 * 1024 * 1024。没有上限的解析器,遇到脏数据或恶意长度字段会把内存直接顶爆,这类问题在联调环境不容易出现,一到公网就很容易变成黑匣子。
2.2 工程目录与模块职责
这套代码按“协议定义、编解码、解析器、服务端、客户端、测试”拆开,目录不深,但边界清楚。你拿到源码后先对照目录,不要急着改业务逻辑,先把编译和测试跑通。
| 路径 | 职责 |
|---|---|
include/protocol.h | 魔数、版本、包头字段、消息结构体、长度上限 |
include/packet_codec.h | 编码、解头、解包接口 |
include/packet_parser.h | 增量解析器,负责半包缓存和粘包循环 |
include/tcp_server.h | 服务端监听和连接处理 |
include/tcp_client.h | 客户端连接和发送示例 |
src/packet_codec.cpp | 字节序转换、字段读写、编码与解包实现 |
src/packet_parser.cpp | feed循环、读偏移、缓冲区压缩 |
src/tcp_server.cpp | accept、每连接一个解析器、回显业务 |
src/tcp_client.cpp | 连续发送三条消息,制造粘包场景 |
tests/test_codec.cpp | 编码解码往返测试 |
scripts/sticky_client.py | 构造粘包、半头、半体,验证拆包 |
CMakeLists.txt | 构建静态库、服务端、客户端和测试 |
目录里最容易被忽视的是packet_parser。很多人把拆包逻辑直接塞进recv的while里,短期能跑,后面加心跳、加压缩、加多线程就会乱。把解析器独立出来,服务端只负责收字节和分发消息,测试也能直接喂半包数据,不用起网络。
2.3 编译环境与构建入口
示例以 Linux/POSIX socket 为主,依赖 g++、CMake 和 pthread。C++ 标准按 C++11 起步,够用且移植成本低。构建文件把编解码抽成静态库,服务端和客户端都链接它,测试也链接它,避免三份实现各写各的。
cmake_minimum_required(VERSION 3.10) project(tcp_packet_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_library(packet_codec STATIC src/protocol.cpp src/packet_codec.cpp src/packet_parser.cpp ) target_include_directories(packet_codec PUBLIC include) add_executable(tcp_server src/main_server.cpp src/tcp_server.cpp) target_link_libraries(tcp_server PRIVATE packet_codec pthread) add_executable(tcp_client src/main_client.cpp src/tcp_client.cpp) target_link_libraries(tcp_client PRIVATE packet_codec pthread) enable_testing() add_executable(test_codec tests/test_codec.cpp) target_link_libraries(test_codec PRIVATE packet_codec) add_test(NAME codec COMMAND test_codec)packet_codec是静态库目标,target_include_directories把include暴露给所有使用者。tcp_server和tcp_client只链接库和pthread,不重复编译编解码源文件。test_codec走add_test,方便后面接 CI。第一次构建建议用 Debug 类型,断言和日志都更直接:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug cmake --build build -j如果编译时报htons、htonl找不到,先检查是否包含了<arpa/inet.h>;如果链接时报pthread_create未定义,检查目标有没有链接pthread。这两个问题在 Linux 下很常见,和粘包逻辑无关,但会挡住第一次运行。
3. 核心编码实现:PacketCodec 如何拼出固定头、长度字段和消息体
编码阶段只做一件事:把业务消息变成一段带边界的字节序列。这个工程不直接memcpy整个结构体,而是按字段逐个写入缓冲区。结构体直接落盘或上网会受对齐、填充和主机字节序影响,换编译器或换平台就可能错位。手动写字段虽然多几行,但字段偏移固定,排查时也能对着十六进制直接数。
3.1 包头结构体与字节序处理
protocol.h里只放字段定义和常量,不承担序列化。Header结构体用于代码可读性,真正写缓冲区时仍然按偏移写。
#pragma once #include <cstdint> #include <cstddef> #include <string> namespace tcpdemo { constexpr uint32_t kMagic = 0x54435031; // "TCP1" constexpr uint16_t kVersion = 1; constexpr size_t kHeaderSize = 16; constexpr uint32_t kMaxBody = 4 * 1024 * 1024; enum class MsgType : uint16_t { kText = 1, kPing = 2, kPong = 3 }; struct Header { uint32_t magic; uint16_t version; uint16_t type; uint32_t body_len; uint32_t seq; }; struct Message { uint16_t type = 0; uint32_t seq = 0; std::string body; }; } // namespace tcpdemokHeaderSize是 16,后面所有偏移都围绕它算:magic在 0,version在 4,type在 6,body_len在 8,seq在 12。kMaxBody是解析器的安全阀,编码时也要检查,避免业务层塞进超长消息。Message里用std::string存消息体,示例够用;如果消息体是二进制,可以换成std::vector<uint8_t>,字段偏移不变。
字节序统一用网络字节序。发送端 16 位字段走htons,32 位字段走htonl;接收端反过来走ntohs、ntohl。不要一边发主机序一边收网络序,小包可能碰巧正常,大包长度字段一翻转就会读出几个 GB 的假长度。
3.2 encode 的拼包逻辑与边界检查
编码函数先检查消息体长度,再按字段顺序追加。reserve提前分配kHeaderSize + body.size(),减少多次扩容。追加 16 位和 32 位字段时,先转换字节序,再把内存按字节拷进vector。
#include "packet_codec.h" #include <arpa/inet.h> #include <cstring> #include <stdexcept> namespace tcpdemo { void PacketCodec::appendU16(std::vector<uint8_t>& out, uint16_t v) { uint16_t n = htons(v); const uint8_t* p = reinterpret_cast<const uint8_t*>(&n); out.insert(out.end(), p, p + sizeof(n)); } void PacketCodec::appendU32(std::vector<uint8_t>& out, uint32_t v) { uint32_t n = htonl(v); const uint8_t* p = reinterpret_cast<const uint8_t*>(&n); out.insert(out.end(), p, p + sizeof(n)); } uint16_t PacketCodec::readU16(const uint8_t* p) { uint16_t n = 0; std::memcpy(&n, p, sizeof(n)); return ntohs(n); } uint32_t PacketCodec::readU32(const uint8_t* p) { uint32_t n = 0; std::memcpy(&n, p, sizeof(n)); return ntohl(n); } std::vector<uint8_t> PacketCodec::encode(const Message& msg) { if (msg.body.size() > kMaxBody) { throw std::invalid_argument("body too large"); } std::vector<uint8_t> out; out.reserve(kHeaderSize + msg.body.size()); appendU32(out, kMagic); appendU16(out, kVersion); appendU16(out, msg.type); appendU32(out, static_cast<uint32_t>(msg.body.size())); appendU32(out, msg.seq); out.insert(out.end(), msg.body.begin(), msg.body.end()); return out; } } // namespace tcpdemoappendU16和appendU32的入参是主机序,函数内部完成网络序转换。readU16和readU32从字节指针读,先memcpy到本地变量再转主机序,避免直接解引用未对齐指针。encode里的body.size()强转uint32_t之前已经用kMaxBody卡住上限,不会截断。返回的vector里前 16 字节是头,后面是体,发送端直接send这段连续内存即可。
3.3 解头与解包:先校验,再取体
接收端不能一上来就按body_len分配内存,要先校验魔数、版本和长度上限。parseHeader只解头,不要求消息体已经到齐;decodeHeaderAndBody在头合法且总长度足够时再取消息体。这样解析器可以先拿头判断“还差多少字节”,不会把半包当坏包丢掉。
Header PacketCodec::parseHeader(const uint8_t* data, size_t total_len) { if (total_len < kHeaderSize) { throw std::runtime_error("short header"); } Header h; h.magic = readU32(data); if (h.magic != kMagic) { throw std::runtime_error("bad magic"); } h.version = readU16(data + 4); if (h.version != kVersion) { throw std::runtime_error("bad version"); } h.type = readU16(data + 6); h.body_len = readU32(data + 8); h.seq = readU32(data + 12); if (h.body_len > kMaxBody) { throw std::runtime_error("body too large"); } return h; } Message PacketCodec::decodeHeaderAndBody(const uint8_t* data, size_t total_len) { Header h = parseHeader(data, total_len); size_t need = kHeaderSize + h.body_len; if (total_len < need) { throw std::runtime_error("incomplete packet"); } Message msg; msg.type = h.type; msg.seq = h.seq; msg.body.assign(reinterpret_cast<const char*>(data + kHeaderSize), h.body_len); return msg; }parseHeader的total_len只需要大于等于 16,解析器可以在只收到头的时候先调用它,拿到body_len后判断剩余数据够不够。decodeHeaderAndBody会再次调用parseHeader,这里多一次校验换来接口独立,测试和业务调用都更直接。msg.body.assign的第二个参数是长度,不是结束指针,避免消息体里出现\0时被截断。
单元测试不要只测“编码再解码能还原”,还要测字段偏移和长度。下面这个测试构造seq=42、body="payload",断言总长度等于头长加体长,再解回来比对字段。
#include "packet_codec.h" #include <cassert> #include <iostream> int main() { tcpdemo::Message msg; msg.type = static_cast<uint16_t>(tcpdemo::MsgType::kText); msg.seq = 42; msg.body = "payload"; auto bytes = tcpdemo::PacketCodec::encode(msg); assert(bytes.size() == tcpdemo::kHeaderSize + msg.body.size()); auto parsed = tcpdemo::PacketCodec::decodeHeaderAndBody(bytes.data(), bytes.size()); assert(parsed.type == msg.type); assert(parsed.seq == 42); assert(parsed.body == "payload"); std::cout << "codec ok" << std::endl; return 0; }这个测试跑通,只能说明单包编码解码没问题,不能说明粘包处理没问题。粘包的关键在接收端解析器,下一章把feed的循环和半包缓存拆开看。
4. 接收端拆包链路:PacketParser 一次 recv 读到多个包也不慌
网络通讯里recv的返回长度不可控,可能一次给你三个包,也可能只给半个头。解析器要做的不是假设“一次 recv 一个包”,而是把收到的字节追加到缓冲区,然后循环尝试切出完整消息。切不出来就保留,切出来就回调业务,切完继续看缓冲区里还有没有下一个包。这个循环写得对不对,直接决定粘包场景是正常解析还是只出第一条。
4.1 增量解析状态机与半包缓存
PacketParser内部维护一个std::vector<char> buffer_和一个read_pos_。feed先把新数据追加到buffer_,然后进入循环:剩余可读字节不够 16 字节,退出;够 16 字节,解头拿body_len;总长度不够kHeaderSize + body_len,退出;够了,解出消息,移动read_pos_,回调业务。循环条件只看缓冲区,不看recv次数。
#pragma once #include "packet_codec.h" #include <functional> #include <vector> namespace tcpdemo { class PacketParser { public: using MessageCallback = std::function<void(const Message&)>; void feed(const char* data, size_t len, const MessageCallback& cb); void reset(); private: std::vector<char> buffer_; size_t read_pos_ = 0; void compactIfNeeded(); }; } // namespace tcpdemoMessageCallback用std::function接收完整消息,服务端可以在回调里做回显、写日志或投递业务队列。buffer_存的是原始字节,read_pos_表示已经消费到的位置。reset用于连接关闭或协议错乱后清空状态,避免旧数据污染新连接。
#include "packet_parser.h" #include <stdexcept> namespace tcpdemo { void PacketParser::feed(const char* data, size_t len, const MessageCallback& cb) { if (len > 0) { buffer_.insert(buffer_.end(), data, data + len); } while (true) { size_t available = buffer_.size() - read_pos_; if (available < kHeaderSize) { break; } const uint8_t* p = reinterpret_cast<const uint8_t*>(buffer_.data() + read_pos_); Header h = PacketCodec::parseHeader(p, available); size_t need = kHeaderSize + h.body_len; if (available < need) { break; } Message msg = PacketCodec::decodeHeaderAndBody(p, available); read_pos_ += need; cb(msg); } compactIfNeeded(); } void PacketParser::compactIfNeeded() { if (read_pos_ == buffer_.size()) { buffer_.clear(); read_pos_ = 0; return; } if (read_pos_ > 64 * 1024 && read_pos_ * 2 > buffer_.size()) { buffer_.erase(buffer_.begin(), buffer_.begin() + static_cast<long>(read_pos_)); read_pos_ = 0; } } } // namespace tcpdemoavailable是当前可读字节数,不是buffer_.size()。parseHeader抛异常时,说明当前流已经不可信,调用方应该关闭连接并记录原始十六进制,不要继续往后读。cb(msg)在移动read_pos_之后调用,业务回调里不要直接改buffer_,否则解析器状态会乱。compactIfNeeded在读完一批数据后统一压缩,避免每个包都erase一次导致 O(n²) 搬移。
4.2 避免频繁 erase 的读偏移设计
很多示例解析器每切一个包就buffer_.erase(buffer_.begin(), buffer_.begin() + packet_len),小包少的时候看不出问题,一旦一次recv读到几千个小包,内存搬移就会拖慢线程。这个工程用read_pos_标记消费位置,解析过程中只移动下标,不搬数据。等read_pos_超过 64KB 且超过缓冲区一半时,才做一次压缩,把已消费的前缀删掉。
64 * 1024是压缩阈值,不是协议字段。小连接可能一直不压缩,缓冲区里保留少量已消费数据,内存占用可以接受;大连接频繁收包时,压缩次数被摊薄。read_pos_ * 2 > buffer_.size()用来避免“只消费了一点点就压缩”的无效搬移。如果你的业务消息体很大,可以把阈值调高,或者换成环形缓冲区;示例保留vector是为了代码短、容易对照。
这里有一个边界:read_pos_是size_t,buffer_.begin() + static_cast<long>(read_pos_)在 32 位平台可能有符号问题。示例按 64 位 Linux 写,真要在 32 位环境跑,建议用std::vector<char>::difference_type做转换。这个细节不影响粘包逻辑,但会影响跨平台编译。
4.3 服务端会话如何接入 parser 并回调业务
服务端示例用“每连接一个线程 + 每连接一个PacketParser”的模型,代码直白,适合先跑通。每个连接独立解析器,避免多个连接共享缓冲区导致串包。recv收到字节后直接parser.feed,回调里打印消息并回显。
void TcpServer::handleClient(int fd) { PacketParser parser; char buf[4096]; try { while (true) { ssize_t n = ::recv(fd, buf, sizeof(buf), 0); if (n <= 0) { break; } parser.feed(buf, static_cast<size_t>(n), [fd](const Message& msg) { std::cout << "recv seq=" << msg.seq << " type=" << msg.type << " body=" << msg.body << std::endl; auto out = PacketCodec::encode(msg); ::send(fd, out.data(), out.size(), 0); }); } } catch (const std::exception& e) { std::cerr << "session error: " << e.what() << std::endl; } ::close(fd); }buf大小是 4096,只是单次读取上限,不代表消息上限。recv返回 0 表示对端关闭,返回小于 0 要结合errno判断是中断还是错误;示例为了短,直接退出循环。回调里send回显,真实业务里如果发送数据较大,send可能只发一部分,需要循环发送并处理EAGAIN,这一点在第 5 章会展开。每个连接一个parser是底线,不要把parser放到全局。
服务端启动入口只负责解析端口和调用start。端口默认 9000,命令行可以覆盖。客户端连续发三条消息,服务端应该打印三条,顺序是 1、2、3。这个顺序不是 TCP 保证消息边界,而是 TCP 保证字节顺序,解析器按协议切出来的顺序。
int main(int argc, char** argv) { uint16_t port = argc > 1 ? static_cast<uint16_t>(std::stoi(argv[1])) : 9000; tcpdemo::TcpServer server(port); if (!server.start()) { std::cerr << "server start failed" << std::endl; return 1; } std::cout << "server listen on " << port << std::endl; std::this_thread::sleep_for(std::chrono::hours(24)); return 0; }std::this_thread::sleep_for只是让main不立刻退出,真实项目里应该用信号或条件变量控制生命周期。server.start()内部创建监听线程,accept后为每个连接起处理线程。这个模型在连接数少时够用,连接数一多就要换epoll或线程池,但解析器逻辑不变。
5. 避坑与排查:TCP粘包示例工程里最容易翻车的 5 个点
粘包处理的代码看起来短,翻车点却不少。下面 5 条是这类工程里最常见的现场:有的表现为“只收到第一条”,有的表现为“日志乱码”,有的跑一会儿内存就上去。每条按现象、原因、解决拆开,方便你对照日志排查。
5.1 现象:客户端连发三条,服务端只打印一条
现象很典型:客户端send三次,服务端recv一次读到三段拼在一起,但日志只有第一条。原因是服务端把“一次recv”当成“一个包”,解析完第一条就退出循环,剩下两条留在缓冲区里没处理。TCP 是字节流,一次recv读到多个包是正常行为,尤其在小包连续发送时。
解决方式是让解析器在feed内部循环,直到剩余字节不够一个完整头或不够一个完整体才退出。第 4 章的while (true)就是干这个的。排查时可以打印每次recv的字节数和parser的缓冲区剩余长度,如果recv返回 48 字节而只解析了一条 16+body 的消息,基本就是循环没写全。
5.2 现象:长度字段读出来是 16777216 这种怪数
长度字段读成16777216、65536这类数,通常是字节序写反了。发送端用htonl转网络序,接收端直接memcpy到uint32_t就用,没有ntohl,在小端机器上就会翻转。小消息体可能因为长度小看起来正常,一旦长度字段超过 255 或 65535,问题就明显。
解决方式是统一在编解码层做转换,业务层不碰字节序。发送端appendU32调htonl,接收端readU32调ntohl。单测里构造body_len=0x01020304的缓冲区,断言解出来是0x01020304,不要只测短字符串。抓包时看十六进制,网络序的00 00 00 0D对应 13,主机序反了就会读成0D 00 00 00。
5.3 现象:坏包之后日志全是乱码,连接也不断
某个连接发了一条脏数据,魔数对不上,解析器抛异常,但外层捕获后继续recv,后面的字节已经错位,解析器把业务数据当头读,日志全是乱码。更麻烦的是,这种连接可能一直不关,缓冲区越积越多。
解决方式是坏包直接关闭连接。TCP 流一旦错位,没有“后悔药”能可靠找回边界,继续读只会把错误放大。feed里parseHeader抛异常时,会话层记录原始十六进制和已收字节数,然后close(fd)。如果业务需要容错,可以在协议里加 CRC 或消息长度二次校验,但不要在校验失败后继续沿用同一个解析器状态。
5.4 现象:收到一个超长长度字段,进程内存瞬间涨上去
现象是服务端刚上线不久内存暴涨,日志里只有一个长度字段很大的包头。原因是解析器拿到body_len后直接reserve或resize,没有上限校验。恶意连接或脏数据可以构造一个接近 4GB 的长度,程序还没收到消息体就先分配内存。
解决方式是先校验body_len <= kMaxBody,再判断缓冲区够不够。parseHeader里做上限检查,decodeHeaderAndBody里也保留一次。kMaxBody按业务最大消息设置,不要为了“以后可能用”设成几个 GB。真正需要大文件传输时,拆成多个消息或走独立通道,不要在一个包头里塞超大长度。
5.5 现象:多连接偶发串包,或者大回显只发出去一半
串包通常是因为多个连接共享了一个PacketParser,A 连接的半包和 B 连接的半包拼在一起,解析出来的消息自然对不上。另一个常见问题是回显大消息时只调用一次send,send返回的字节数小于out.size(),剩下部分没发,客户端看到截断。
解决方式是每个连接一个parser,连接关闭就销毁,不要跨连接复用。发送端要循环写,直到out.size()发完;非阻塞 socket 还要处理EAGAIN,把未发送数据挂到写事件里。示例的回显消息很短,一次send通常能发完,但真实业务不能依赖这个假设。C++ 网络通讯里,接收拆包和发送拼包是两件事,别只修一半。
6. 二次开发:把示例协议改成业务帧并保留可验证的拆包链路
把这份工程改成业务协议,优先动protocol.h和packet_codec.cpp,不要先动服务端循环。常见改造是加msg_id、flags、crc32,或者把std::string body换成 protobuf 序列化后的字节。加字段时保持“固定头 + 长度字段”的结构,把新字段放在body_len前后都行,但接收端偏移要同步改,单测里的总长度断言也要跟着改。比如加 4 字节msg_id,头长从 16 变 20,parseHeader里readU32(data + 16),decodeHeaderAndBody里消息体起点改成data + 20。
| 改造点 | 建议 | 验证方式 |
|---|---|---|
| 增加业务字段 | 固定头内追加,保持字段偏移表 | 单测断言头长和字段值 |
| 消息体换二进制 | std::string改std::vector<uint8_t> | 构造含\0的 body 往返 |
| 增加压缩标志 | 放在flags位,解包后解压 | 粘包脚本发混合压缩/非压缩包 |
| 换 epoll | 解析器不变,会话层改事件驱动 | 压测 1000 连接半包场景 |
验证顺序我一般固定三步:先跑test_codec,确认单包编解码和字段偏移;再跑scripts/sticky_client.py,确认粘包、半头、半体都能拆出三条;最后用tcpdump或日志对比recv字节数和解析条数。改协议头最容易翻车的是“发送端改了,接收端偏移没改”,单测能挡住大部分。从那以后我每次改协议字段,都先把编码解码单测和半包脚本跑一遍,再谈业务联调。希望帮到你。
本文还有配套的精品资源,点击获取