ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C++ TCP粘包/半包/拆包处理:固定头、长度字段与增量解析实践

C++ TCP粘包/半包/拆包处理:固定头、长度字段与增量解析实践

简介:面向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 字节,字段如下:

字段长度说明
magic4 字节固定魔数0x54435031,用于快速识别错连或脏数据
version2 字节协议版本,示例为 1
type2 字节消息类型,文本、心跳等
body_len4 字节消息体长度,网络字节序
seq4 字节请求序号,便于日志对照

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.cppfeed循环、读偏移、缓冲区压缩
src/tcp_server.cppaccept、每连接一个解析器、回显业务
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 tcpdemo

kHeaderSize是 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 tcpdemo

appendU16和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 tcpdemo

MessageCallback用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 tcpdemo

available是当前可读字节数,不是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字节数和解析条数。改协议头最容易翻车的是“发送端改了,接收端偏移没改”,单测能挡住大部分。从那以后我每次改协议字段,都先把编码解码单测和半包脚本跑一遍,再谈业务联调。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表