ARTICLE DETAIL

资讯详情

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

TCP粘包与半包处理:C++长度前缀拆帧源码实践

TCP粘包与半包处理:C++长度前缀拆帧源码实践

简介:这是一套面向C++网络编程学习者的TCP粘包处理完整示例工程,围绕服务器与客户端通信展开,重点演示如何借助封装良好的接口屏蔽底层收发细节,让应用层只需定义协议头、消息结构体和回调函数,即可较优雅地应对粘包、丢包等常见问题。工程共36个文件,包含16个头文件、8个CPP源文件及工程配置、资源、静态库等辅助文件,压缩包约49KB,体积轻量但结构完整,便于直接编译运行和对照调试。已有1650人浏览学习,适合具备一定C++基础、希望深入理解网络消息边界处理与回调机制的开发者参考。通过阅读源码,可梳理客户端与服务端的模块划分、协议定义与消息分发流程,理解传输层封装思路,并把它作为课程设计、小型通信项目或网络编程练习的可复用基础代码,在排错与扩展时获得清晰参照。

1. 一次 recv 收到三条消息:TCP粘包到底卡在哪

网络通讯里最容易被误判的现场,是一次 recv 返回后缓冲区里躺着三条业务消息——这就是 TCP粘包。这个 C++ 源码包把服务端、客户端、帧编解码、压测脚本和边界测试放在一起,目标不是讲概念,而是让你编译运行后亲眼看到半包、粘包和拆帧循环怎么配合。它适合刚接触 socket 编程、准备把 Demo 改成可用服务的人,也适合已经写过收包逻辑、但总在长度边界上翻车的熟手。

粘包不是 TCP 的错,TCP 只负责字节流;真正要补的是应用层帧边界。源码包用长度前缀做帧定界,把“读多少、什么时候拆、拆完怎么消费”拆成可调试的步骤。先跑通一条消息,再压测一千条,最后用边界帧把编解码逼到墙角,这条路径比背概念更接近实际项目。

2. 拆开源码看协议:4 字节长度头比分隔符稳在哪

TCP粘包处理的第一道分水岭是帧格式。常见方案有三种:固定长度、分隔符、长度前缀。固定长度适合定长指令,业务字段一变就要改协议;分隔符适合文本日志,但二进制 payload 里出现分隔符就要转义,转义状态机一复杂,排查成本立刻上来。长度前缀把“这一帧有多长”提前告诉接收端,接收端先读 4 字节头,再判断缓冲区里够不够一整帧,粘包和半包都落在同一个循环里处理。这份 C++ 源码包选的是 4 字节大端长度头加 payload,头里只存 payload 长度,不包含头自身,规则简单,抓包时也容易对照。

2.1 项目目录与构建入口

源码包的结构不复杂,核心是把缓冲区、编解码、服务端、客户端分开,压测脚本和边界测试单独放。拆包时先看文件职责,比一头扎进main更省时间。

路径职责关键点
include/buffer.h连接级读写缓冲区读下标、写下标、可读长度
include/codec.h帧头编解码4 字节网络序、长度上限
src/server.cppepoll 服务端边缘触发、循环读、拆帧
src/client.cpp阻塞客户端发送封装、TCP_NODELAY
scripts/blast.py压测脚本连续发帧、故意拆半包
tests/boundary_test.cpp边界测试0 长度、超长、半包

构建入口用 CMake,最低版本写 3.10,实际用 3.16 以上更稳。packet_core静态库把缓冲区和编解码抽出来,服务端和客户端都链接它,避免两边各写一套长度转换。

cmake_minimum_required(VERSION 3.10) project(tcp_packet_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_library(packet_core src/buffer.cpp src/codec.cpp ) target_include_directories(packet_core PUBLIC include) add_executable(tcp_server src/main_server.cpp src/server.cpp ) target_link_libraries(tcp_server PRIVATE packet_core) add_executable(tcp_client src/client.cpp) target_link_libraries(tcp_client PRIVATE packet_core) add_executable(boundary_test tests/boundary_test.cpp) target_link_libraries(boundary_test PRIVATE packet_core)

这段 CMake 的逻辑是把协议层做成库,tcp_server和tcp_client只负责网络 IO。参数上,CMAKE_CXX_STANDARD 17是为了用std::string_view和结构化绑定;target_include_directories用PUBLIC,链接方不用再手写 include 路径。编译命令走一遍:

cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j ./build/tcp_server 9000

-S .指定源码目录,-B build指定构建目录,-DCMAKE_BUILD_TYPE=Release打开优化,-j并行编译。服务端参数是监听端口,客户端在后面用127.0.0.1 9000 1000连接并发一千条。端口被占用时先换端口,不要急着改代码。

2.2 帧格式:4 字节长度头加负载

帧头只放一个字段:payload 长度,网络字节序,4 字节无符号整数。接收端先读满 4 字节,拿到body_len,再判断缓冲区可读长度是否达到4 + body_len。这套规则下,长度头自身也可能半包,所以不能一上来就memcpypayload。

偏移字段长度说明
0body_len4 字节网络序,仅表示 payload 长度
4payloadbody_len 字节业务数据,二进制安全

长度上限必须存在。没有上限,对端发一个0xFFFFFFFF,接收端就会等一个永远到不齐的帧,或者直接按这个长度扩容把内存打满。源码包里用kMaxFrameSize卡住,常见取值 1MB 到 4MB,按业务最大消息调整。

#pragma once #include <cstdint> #include <cstring> #include <string> #if defined(_WIN32) #include <winsock2.h> #else #include <arpa/inet.h> #endif constexpr uint32_t kMaxFrameSize = 4 * 1024 * 1024; class FrameCodec { public: static std::string Encode(const std::string& payload) { if (payload.size() > kMaxFrameSize) return {}; std::string out; out.resize(4 + payload.size()); uint32_t net = htonl(static_cast<uint32_t>(payload.size())); std::memcpy(&out[0], &net, 4); std::memcpy(&out[4], payload.data(), payload.size()); return out; } static bool TryDecode(const char* data, size_t len, size_t& consumed, std::string& payload) { consumed = 0; if (len < 4) return false; uint32_t body_len = 0; std::memcpy(&body_len, data, 4); body_len = ntohl(body_len); if (body_len > kMaxFrameSize) return false; if (len < 4 + body_len) return false; payload.assign(data + 4, body_len); consumed = 4 + body_len; return true; } };

Encode先检查 payload 是否超过上限,再一次性分配4 + payload.size(),写头时用htonl转网络序。TryDecode的consumed是给缓冲区消费用的:返回 true 时,调用方按consumed把整帧从缓冲区摘掉;返回 false 时,可能是半包,也可能是非法长度,调用方要结合长度上限判断是否断开连接。参数上,kMaxFrameSize不是越大越好,4MB 意味着单个连接最坏要预留 4MB 加头部空间,连接数一多,内存账要先算。

提示:长度字段一定要固定“包含头”还是“不包含头”。这份代码选择不包含头,解码时总长度是4 + body_len。协议文档里不写清楚,后面换人维护必翻车。

2.3 编码与解码的函数边界

编解码层只做两件事:把 payload 变成带长度头的字节流,把字节流还原成 payload 并告诉调用方消费多少字节。它不负责recv,也不负责业务分发。这个边界划清楚,服务端拆帧循环才不会和业务逻辑缠在一起。

TryDecode返回 false 时有一个坑:半包和非法长度都返回 false,调用方如果只看布尔值,会把非法长度当成“再等等”,连接就一直挂着。实际项目里我一般把它拆成三态:需要更多数据、成功、协议错误。源码包为了演示收敛成一个布尔返回值,但在服务端拆帧循环里先用body_len > kMaxFrameSize判断一次,非法长度直接关连接,避免半包分支被滥用。

编码侧还要注意空 payload。body_len = 0是合法帧,payload为空字符串,解码后consumed = 4,缓冲区消费 4 字节继续下一帧。如果业务把空帧当心跳,这个分支必须测试;如果业务不允许空帧,编码侧直接拒绝,不要留给解码侧猜。

3. 服务端收包:把半包和粘包塞进同一个缓冲区

服务端用 epoll 边缘触发,连接 socket 设非阻塞。边缘触发的规则很硬:一次事件到来,必须循环recv直到返回EAGAIN,否则缓冲区里剩下的数据不会再触发事件。TCP粘包在这个循环里不是特殊事件,一次recv读到多条消息很正常;半包也不是异常,长度头读了一半、payload 差几个字节,都靠连接级缓冲区兜住。缓冲区设计得好,拆帧循环就是“能拆就拆,拆不动就等下一次读”。

3.1 边缘触发下的读循环

读循环先看recv返回值。大于 0 表示读到字节,追加到连接缓冲区后立刻尝试拆帧;等于 0 表示对端关闭,必须close并从 epoll 删除;小于 0 时区分EINTR、EAGAIN和其他错误。EINTR重试,EAGAIN表示这次读干净了,其他错误直接关连接。

void Server::OnReadable(int fd) { auto it = conns_.find(fd); if (it == conns_.end()) return; Connection* conn = it->second.get(); char stack_buf[65536]; while (true) { ssize_t n = ::recv(fd, stack_buf, sizeof(stack_buf), 0); if (n > 0) { conn->buffer.Append(stack_buf, static_cast<size_t>(n)); if (!ProcessFrames(conn)) { CloseConn(fd); return; } continue; } if (n == 0) { CloseConn(fd); return; } if (errno == EINTR) continue; if (errno == EAGAIN || errno == EWOULDBLOCK) break; CloseConn(fd); return; } }

这段代码的关键在while (true)和continue。读到数据后不退出循环,继续读,直到内核缓冲区空掉返回EAGAIN。stack_buf用 64KB 栈空间,避免每个连接维护一块读缓冲;Append负责把数据复制进连接缓冲区。参数上,recv的第三个参数是单次最多读多少字节,64KB 是常见折中,太小会增加系统调用次数,太大栈空间吃紧。ProcessFrames返回 false 表示协议错误,直接关连接,不给对端继续发垃圾数据的机会。

注意:边缘触发下recv只读一次就退出,是服务端“卡死”的高频原因。客户端明明发了三条,服务端只处理一条,后面两条躺在内核缓冲区里等不到新事件。

3.2 环形缓冲区与可读长度判断

连接缓冲区用std::vector<char>加读写下标,逻辑上是一个可伸缩的线性缓冲区。ReadableBytes()返回write_idx_ - read_idx_,Peek()返回读位置的指针,Retrieve(n)前移读下标。追加数据时先看尾部空间,不够就把可读数据搬到头部,再不够才扩容。

class Buffer { public: size_t ReadableBytes() const { return write_idx_ - read_idx_; } const char* Peek() const { return data_.data() + read_idx_; } void Retrieve(size_t n) { if (n >= ReadableBytes()) { read_idx_ = write_idx_ = 0; } else { read_idx_ += n; } } void Append(const char* p, size_t n) { EnsureWritable(n); std::memcpy(data_.data() + write_idx_, p, n); write_idx_ += n; } private: void EnsureWritable(size_t n) { if (data_.size() - write_idx_ >= n) return; size_t readable = ReadableBytes(); if (read_idx_ + (data_.size() - write_idx_) >= n) { std::memmove(data_.data(), data_.data() + read_idx_, readable); write_idx_ = readable; read_idx_ = 0; return; } data_.resize(write_idx_ + n); } std::vector<char> data_{4096}; size_t read_idx_{0}; size_t write_idx_{0}; };

EnsureWritable分三步:尾部空间够就直接写;头部空洞加尾部空间够,就把可读数据memmove到头部;都不够才resize。Retrieve在消费完整帧后调用,n等于整帧长度。参数上,初始 4096 字节是经验值,小消息多的时候够用,大消息会触发扩容。Peek()返回的指针在Append或resize后可能失效,所以拆帧循环里每次重新Peek(),不要缓存指针跨追加使用。

3.3 拆帧循环与回调分发

拆帧循环先判断 4 字节头是否完整,再读body_len,接着判断4 + body_len是否完整。不完整就break,等下一次recv追加数据;完整就复制 payload、消费整帧、回调业务。这里最容易犯的错是“解码成功但忘记消费”,循环会一直读到同一帧,CPU 直接打满。

bool Server::ProcessFrames(Connection* conn) { while (true) { Buffer* buf = &conn->buffer; if (buf->ReadableBytes() < 4) break; uint32_t body_len = 0; std::memcpy(&body_len, buf->Peek(), 4); body_len = ntohl(body_len); if (body_len > kMaxFrameSize) return false; size_t total = 4 + body_len; if (buf->ReadableBytes() < total) break; std::string payload(buf->Peek() + 4, body_len); buf->Retrieve(total); OnMessage(conn, payload); } return true; }

循环里每一轮都重新拿buf->Peek(),因为上一轮Retrieve可能改变读位置。body_len从网络序转主机序后再和上限比较,避免把超大值带进total计算。payload先复制再Retrieve,回调里拿到的是独立字符串,不受后续缓冲区移动影响。参数上,total用size_t防止 32 位溢出;OnMessage里做业务分发,不要在回调里做长时间阻塞操作,边缘触发下读循环会被拖住。

服务端注册连接时用EPOLLIN | EPOLLET | EPOLLRDHUP,accept4带SOCK_NONBLOCK。EPOLLRDHUP让对端半关闭时也能触发事件,配合recv返回 0 做清理。监听 fd 用水平触发也可以,但连接 fd 用边缘触发后,读循环必须读干净,这一点不能省。

4. 客户端发包与压测:Nagle、发送缓冲和分片现场

服务端拆帧正确,不代表客户端发送就“一帧一个包”。TCP 发送缓冲区、Nagle 算法、接收窗口都会把应用层的多次send合并或拆分。Nagle 算法会把小包攒到有确认再发,延迟确认又会把确认拖后,网络通讯里常见的“小包延迟 40ms”就是这么来的。关掉 Nagle 用TCP_NODELAY能降低延迟,但它不会消除粘包;粘包是接收端必须处理的帧边界问题,不是靠一个 socket 选项能绕开的。

4.1 客户端连接与发送封装

客户端为了演示简单,用阻塞 socket,连接成功后设置TCP_NODELAY,发送时循环send直到整帧发完。真实项目里客户端也可能是非阻塞的,发送缓冲要挂EPOLLOUT,这里先用短等poll兜住EAGAIN,把逻辑讲清楚。

bool SendAll(int fd, const char* data, size_t len) { size_t sent = 0; while (sent < len) { ssize_t n = ::send(fd, data + sent, len - sent, 0); if (n > 0) { sent += static_cast<size_t>(n); continue; } if (n < 0 && errno == EINTR) continue; if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { pollfd pfd{fd, POLLOUT, 0}; if (::poll(&pfd, 1, 1000) <= 0) return false; continue; } return false; } return true; } int ConnectAndSend(const char* host, int port, int count) { int fd = ::socket(AF_INET, SOCK_STREAM, 0); if (fd < 0) return -1; int one = 1; ::setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(static_cast<uint16_t>(port)); ::inet_pton(AF_INET, host, &addr.sin_addr); if (::connect(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) { ::close(fd); return -1; } for (int i = 0; i < count; ++i) { std::string body = std::to_string(i) + ":ORDER|BUY|100|12.50"; std::string frame = FrameCodec::Encode(body); if (frame.empty() || !SendAll(fd, frame.data(), frame.size())) { ::close(fd); return -1; } } ::shutdown(fd, SHUT_WR); return fd; }

SendAll的sent记录已发字节,send返回部分发送时继续发剩余部分;EINTR重试;EAGAIN用poll等可写,最多等 1 秒。ConnectAndSend设置TCP_NODELAY,循环编码并发送,最后shutdown(SHUT_WR)发 FIN,让服务端读到 0 后关闭。参数上,count控制发送条数,压测时从 10 调到 1000,观察服务端日志里一次recv拆出多少帧。

4.2 用压测脚本制造粘包

C++ 客户端能跑,但制造边界场景用 Python 脚本更快。脚本连续sendall多个帧,再故意把一帧拆成两次发,服务端日志里就能看到“一次读多帧”和“半包等待”两种现场。

import socket import struct import sys import time host, port, count = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]) body = b"ORDER-2024-0001|BUY|100|12.50" with socket.create_connection((host, port)) as s: for i in range(count): payload = f"{i}:".encode() + body s.sendall(struct.pack("!I", len(payload)) + payload) # 故意拆半包:先发 2 字节长度头,再发剩余部分 raw = struct.pack("!I", len(body)) + body s.sendall(raw[:2]) time.sleep(0.05) s.sendall(raw[2:]) s.shutdown(socket.SHUT_WR) while s.recv(4096): pass

struct.pack("!I", len(payload))里的!表示网络字节序,I是 4 字节无符号整数,和 C++ 侧htonl对齐。连续sendall不保证每次系统调用对应一个 TCP 段,内核发送缓冲区会合并,服务端一次recv读到多帧很正常。后半段先发 2 字节再发剩余,服务端第一次读只有长度头的一半,ProcessFrames会在ReadableBytes() < 4处break,等第二次数据到达再继续。参数上,count不要一上来设十万,先用 100 看日志,再逐步加压。

4.3 观察 tcpdump 与日志对齐

服务端日志只记录“拆出多少帧”,看不到 TCP 段怎么合并。用tcpdump抓回环流量,和日志时间戳对齐,能确认粘包发生在哪个环节。

sudo tcpdump -i lo -nn -A 'tcp port 9000' -c 40

-i lo抓回环接口,-nn不解析主机名和端口名,-A以 ASCII 显示 payload,-c 40抓 40 个包后退出。过滤表达式tcp port 9000只抓服务端端口。观察时重点看一次 TCP 段里是否出现多个长度头,以及半包场景下第一个段是否只有 2 字节。参数上,生产环境不要随手抓全量包,过滤器先收窄到端口和主机,抓包文件也要控制大小。

提示:TCP_NODELAY只影响发送端什么时候把数据推给内核,不改变接收端“一次 recv 可能读到多帧”的事实。客户端加了这个选项,服务端照样要拆帧。

5. 避坑排查:五条血泪经验对应五个现场

TCP粘包代码看起来短,翻车点却很集中。下面五条是实际排查里最常见、也最容易在压测时放大的问题。每条按“现象、原因、解决”走一遍,照着日志对,比盲改参数快。

5.1 长度字段没做字节序转换

现象:本机测试正常,换一台机器或抓包一看,长度字段变成16777216这种离谱值,服务端一直等半包。原因:编码时直接memcpy了主机序整数,小端机器上1写成01 00 00 00,对端按大端读成0x01000000。解决:编码统一htonl,解码统一ntohl,测试用例里专门放一个0x01020304的长度值,断言解码后还原。

uint32_t net = htonl(static_cast<uint32_t>(payload.size())); std::memcpy(&out[0], &net, 4); // 解码侧 uint32_t body_len = 0; std::memcpy(&body_len, data, 4); body_len = ntohl(body_len);

参数上,htonl只处理 32 位,长度字段别用 64 位又只转一半。抓包时看到00 00 00 0C才是 12 字节 payload 的正常网络序。

5.2 把 recv 返回值 0 当成可重试

现象:客户端关闭后服务端 CPU 占用飙高,日志里没有新消息,epoll 却一直触发。原因:边缘触发下recv返回 0 表示对端关闭,连接仍然注册在 epoll 里,内核会持续报告可读。解决:n == 0立即CloseConn(fd),内部先EPOLL_CTL_DEL再close,最后从连接表删除。不要对 0 做continue,也不要把 0 当EAGAIN。

5.3 缓冲区扩容后指针失效

现象:压测偶发 payload 乱码,或者程序在memcpy处崩溃,单条消息复现不了。原因:拆帧循环里缓存了Peek()返回的指针,回调或追加数据触发resize,旧指针指向已释放内存。解决:每轮循环重新Peek(),需要跨函数传递时用偏移量而不是裸指针;回调里拿到的 payload 必须是复制后的std::string。

5.4 边缘触发漏读导致卡死

现象:客户端连发三条,服务端只处理第一条,后面两条像丢了一样,重启客户端又能推进一点。原因:OnReadable里recv只调用一次就返回,内核缓冲区还有数据,但边缘触发不会再次通知。解决:读循环一直读到EAGAIN,EINTR重试,读到 0 关闭;每次读到数据都追加缓冲区并尝试拆帧。

5.5 粘包处理正确但业务层重复解析

现象:服务端日志显示同一订单被处理两次,数据库唯一键冲突,但抓包只有一帧。原因:解码成功后没有及时Retrieve,或者回调失败后重试又把同一帧传了一遍;也有业务在回调里再次解析 payload,导致一次消息走两条路径。解决:拆帧循环里“复制 payload、消费整帧、再回调”,消费动作只做一次;业务回调做幂等,用消息 ID 去重。不要既在协议层回调,又在业务层重新扫缓冲区。

6. 进阶验证:用边界帧和回放把编解码逼到墙角

协议代码写完,正常消息跑通只是及格线。我习惯先跑一组边界帧:0 长度、刚好等于上限、超过上限、长度头只到 2 字节、payload 差 1 字节。每组输入对应一个明确期望,测试失败时直接定位到TryDecode的哪个分支。

输入期望
00 00 00 00解出空 payload,消费 4 字节
00 00 00 03+ 3 字节成功,消费 7 字节
00 00 00 03+ 2 字节返回半包,不消费
长度头只有 2 字节返回半包,不消费
长度字段大于kMaxFrameSize协议错误,关连接
import socket, struct def frame(body: bytes) -> bytes: return struct.pack("!I", len(body)) + body with socket.create_connection(("127.0.0.1", 9000)) as s: s.sendall(frame(b"")) # 空帧 s.sendall(struct.pack("!I", 3) + b"ab") # 半包 s.sendall(b"c") # 补全 s.sendall(struct.pack("!I", 4 * 1024 * 1024 + 1)) # 超限

回放抓包文件时,我一般把tcpdump -w存下来的流按连接拆开,再喂给boundary_test的同一个解码函数。重点不是看它“能不能跑”,而是看失败时有没有把连接关掉、有没有把非法长度带进resize。从那以后我每次改codec都先跑边界帧,再压测一千条,最后才接业务回调;这套顺序能挡住大部分“本机正常、上线翻车”的玄学问题。源码包建议直接编译,不要只读代码,跑起来看到的半包比文字描述更直观。希望帮到你。

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

返回列表