
简介这份代码包面向具备 C 基础、正在学习网络编程或需要解决 TCP 粘包问题的开发者提供一套可编译运行的服务器与客户端完整示例。内容围绕收发函数封装、协议头与消息结构体定义、消息回调注册展开把粘包与丢包处理沉到通信层应用层只需关注自身协议和业务回调适合用于课程设计、项目原型和排错参考。压缩包共 36 个文件约 49KB以 16 个 .h 头文件和 8 个 .cpp 源文件为核心并含 dsw、dsp 工程文件及 lib、rc、ico 等配套项目录区分 Client、Server、Common 与 transport 等模块。已有 1650 人学习便于按现有工程结构对照理解客户端、服务端与公共协议层的组织方式。1. 从一次 40 字节黏成 200 字节的联调说起某开发者把一份处理 TCP 粘包的 C 源码拷到新机器上g 用默认参数顺利编译通过本地回环连发 2000 条短消息一条不差他以为这事结了换到两台机器之间跑每条消息前面都多带了半截上一条的尾巴日志里解析出来的字段整体错位排查到凌晨才发现问题根本不在业务代码。TCP 粘包不是玄学是网络通讯里最基础的一课TCP 面向字节流消息边界必须由应用层自己划出来内核不会替你保留任何一次send的边界。这套源码适合正在写服务端收包逻辑、被半包粘包反复折磨的人也适合想拿一份能直接编译运行的最小工程对照着改自己项目的人。装它之前值得先把环境理顺编译器标准差一档、缓冲区上限少一位编译过了照样跑不出正确结果。2. 环境清单g、CMake 与 C 标准的版本取舍这套源码是纯 C 实现的 TCP 粘包处理示例没有引入任何第三方网络库理论上只要有一个能编译 C11 以上标准的编译器就能跑。但“能编译”和“能稳定跑通”之间隔着一段距离老版本 g 的std::thread、std::regex在 4.8 上有过不少已知崩溃CMake 版本决定你能不能使用-S/-B这种现代写法C 标准决定源码里那些std::string_view、if constexpr到底能不能被识别。先把这张表对一遍比编到一半再回头查错要省事得多。组件最低可用版本推荐版本在本工程里的用途版本不够时的表现g / clang5.0 / 3.8g 9 及以上编译编解码库与服务端、客户端头文件找不到、标准特性报错CMake3.103.16 及以上生成构建文件、跑边界测试部分语法不支持配置阶段直接失败C 标准C11C17决定容器、字符串视图等特性可用性编译中断言string_view未定义gdb7.09.0 及以上定位解码器段错误与服务端崩溃只能看 core 文件名看不到栈tcpdump4.9任意稳定版确认抓到的字节流与预期一致只能靠日志猜排错效率低一半nc / netcat任意openbsd 版本手工发构造好的数据包做验证写临时脚本代替验证链路变长表格里最后三项不是编译必需的但我在实际排粘包问题的时候它们比调试器用得还多。抓包能看到“内核到底交给你多少个字节”nc能手工构造一个故意拆成两半的包这两件事比读十遍代码更快让你确认问题出在编码端还是解码端。2.1 编译器与 C 标准的实际取舍先确认你机器上的编译器到底支持哪个标准。最省事的办法不是查文档是直接让编译器自己回答# 用一个空 main 探测目标标准是否可用编译通过即代表支持 echo int main(){return 0;} /tmp/probe.cpp for std in c11 c14 c17 c20; do if g -std$std /tmp/probe.cpp -o /tmp/probe 2/dev/null; then echo $std supported else echo $std NOT supported fi done这段脚本对每个候选标准各编译一次空程序成功就把标准号打出来。它只能告诉你“语法层面支持”不能告诉你标准库实现有没有坑所以探测通过之后还要看g --version的主版本号主版本低于 7 的话std::filesystem和部分并发设施要么缺失要么行为不一致我一般会直接升级编译器而不是硬撑。标准选择上源码里如果只用了std::shared_ptr、std::vector、std::string这类 C11 就有的东西锁死 C11 是最稳的因为绝大多数发行版默认就能满足。但只要源码里出现std::string_view或者结构化绑定就必须上 C17。我的习惯是统一用 C17理由很实际主流发行版自带的 g 早就过了 9C17 的特性在编解码里确实好用尤其是用std::string_view切分包体的时候能少掉一次内存拷贝。CMake 里对应要写三个变量缺一个都可能被编译器悄悄降级# 强制使用 C17不允许编译器自行回退到更老的标准 set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 关掉 gnu17 扩展避免源码在 clang 上出现扩展相关告警 set(CMAKE_CXX_EXTENSIONS OFF)CMAKE_CXX_STANDARD_REQUIRED ON这个开关经常被漏掉。只写CMAKE_CXX_STANDARD 17的话遇到不支持的编译器CMake 会默默降级到它认为可用的标准然后你在编译错误里看到的是一堆莫名其妙的语法报错而不是一句“C17 不支持”。加上 REQUIRED配置阶段就会直接失败错误位置前移排查成本低很多。还有一个隐蔽的坑叫 ABI 版本。g 5 之后std::string和std::list换了一套实现由宏_GLIBCXX_USE_CXX11_ABI控制。如果你的工程要链接某个别人预编译好的静态库两边这个宏的取值不一致链接阶段就会报一堆undefined reference to std::__cxx11::basic_string...。这套源码是纯自编译不涉及外部预编译库所以不冲突但你把它嵌进自己项目的时候要留意编译命令里如果用-D_GLIBCXX_USE_CXX11_ABI0混着来报错会非常难读。2.2 依赖安装与工具链核对源码本身没有外部依赖但构建链路和验证链路需要几个包。Debian 系和 RHEL 系的命令不一样按自己机器选一条# Debian / Ubuntu 系build-essential 会把 g、make、libc 头文件一起带上 sudo apt-get update sudo apt-get install -y build-essential cmake gdb tcpdump netcat-openbsd # RHEL / CentOS 系包名是 gcc-c 而不是 build-essential sudo yum install -y gcc-c make cmake gdb tcpdump ncbuild-essential这个名字容易让人误会成“装个 IDE”实际它就是把编译器、链接器和标准库头文件打成一个元包缺了它g命令可能根本不存在。tcpdump需要 root 权限才能抓回环以外的网卡本地验证用lo就够不用改任何系统配置。装完之后按顺序过一遍版本任何一项输出为空都说明没装成功g --version | head -n 1 cmake --version | head -n 1 gdb --version | head -n 1这里有个小细节值得提前说如果你的 CMake 恰好落在 3.10 到 3.12 之间后面第 3 章里的cmake -S . -B build会直接报错因为-S和-B这两个参数是 3.13 才引入的。这不是源码问题是构建工具版本问题。遇到这种情况只有两种选择要么升级 CMake要么老老实实mkdir build cd build cmake ..。我一般选后者因为升级构建工具在某些长期维护的机器上会牵动别的项目。3. 工程骨架源码目录、CMakeLists 与第一次编译通过拿到一份源码压缩包最忌讳的操作是直接g *.cpp -o demo一把梭。这套工程里有服务端、客户端和测试三个可执行目标还有一个公共编解码库文件之间的包含关系是有顺序的扔给 shell 通配符去展开链接顺序全看文件名字母序很可能编出一个能启动但行为诡异的二进制。正确的做法是先看清楚目录结构再决定哪些文件编成库、哪些编成可执行文件。3.1 目录结构与各文件职责解压之后正常应该看到这样的布局如果你的目录和这个对不上先别急着编译确认一下是不是少解了一层嵌套目录tcp-framing-demo/ ├── CMakeLists.txt # 顶层构建脚本 ├── include/ │ ├── length_header_codec.h # 编解码器对外接口 │ └── ring_buffer.h # 收包缓冲区的简化实现 ├── src/ │ ├── length_header_codec.cpp # 编码、解码与半包缓存的实现 │ ├── server.cpp # 监听、收包、按帧回调 │ └── client.cpp # 构造粘包与半包的发送端 └── test/ └── boundary_test.cpp # 空帧、超长帧、拆分帧的边界用例include/放对外头文件src/放实现test/放不依赖网络、纯内存验证的用例这个划分不是形式主义。编解码逻辑如果能脱离 socket 单独测试你排查粘包问题时就能把“网络没收到”和“收到了但解错了”这两件事彻底分开效率差得很远。boundary_test.cpp就是干这个的它直接往解码器里喂字节不建任何连接。3.2 CMakeLists 逐行说明顶层构建脚本我按最小可用原则写每一行都有明确目的cmake_minimum_required(VERSION 3.10) project(tcp_framing_demo CXX) # 标准锁死避免不同机器编出行为不一致的二进制 set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) # 没指定构建类型时默认 Release否则 -O0 下压测数据没有参考价值 if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Release) endif() # 告警全开粘包处理的代码里类型转换很多-Wall 能挡掉一批 add_compile_options(-Wall -Wextra -g) # 编解码单独成静态库服务端、客户端、测试三处共用同一份实现 add_library(codec STATIC src/length_header_codec.cpp) target_include_directories(codec PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include) add_executable(server src/server.cpp) target_link_libraries(server PRIVATE codec) add_executable(client src/client.cpp) target_link_libraries(client PRIVATE codec) # 注册边界测试ctest 可以直接跑不用起网络 enable_testing() add_executable(boundary_test test/boundary_test.cpp) target_link_libraries(boundary_test PRIVATE codec) add_test(NAME boundary_test COMMAND boundary_test)target_include_directories里的PUBLIC是这段脚本里最值得注意的一处。用PUBLIC意味着凡是链接了codec这个库的目标都会自动继承include/这个头文件搜索路径所以后面server、client、boundary_test三处都不用再写一遍include_directories。如果写成PRIVATE编译server.cpp的时候就会找不到length_header_codec.h。这类链接作用域错误在 CMake 里非常常见报错信息通常只说“找不到头文件”不会提示你是作用域写错了。-g和Release同时存在看起来有点矛盾其实不冲突-g只是让二进制里保留调试符号优化级别由CMAKE_BUILD_TYPE控制。生产环境当然不会这么干但这是安装验证阶段你随时可能需要gdb挂上去看解码器里的buffer_到底攒了几个字节符号丢了就只能看地址。3.3 第一次编译与三类典型报错如果 CMake 版本在 3.13 以上用这条命令# -S 指定源码目录-B 指定构建目录构建产物全部落在 build/ 里 cmake -S . -B build -DCMAKE_BUILD_TYPERelease # -j 后面跟核数nproc 在 Linux 上直接给出可用核心数 cmake --build build -j$(nproc)-S和-B的好处是源码目录保持干净的构建产物、缓存文件全在build/下面删掉这个目录就等于彻底清理不存在“上次的 CMakeCache.txt 还留着旧路径”这种问题。老版本 CMake 就去掉这两个参数进build/目录里执行cmake ..效果一样。编不过的时候先看报错落在哪个阶段三个阶段对应的处理方式完全不同CMake Error: The source directory ... does not appear to contain CMakeLists.txt属于配置阶段说明-S指错了目录你可能在解压出来的外层目录执行而CMakeLists.txt在里头那一层。error: string_view is not a member of std属于编译阶段说明 C 标准没生效回头检查CMAKE_CXX_STANDARD有没有被后面某行覆盖掉。undefined reference to LengthHeaderCodec::encode(...)属于链接阶段说明target_link_libraries漏了codec或者链接顺序写反了。链接顺序这件事值得多说一句。GNU 链接器解析静态库是单向的从左到右扫一遍遇到未定义符号会去后面的库里找但不会回头。所以target_link_libraries(server codec)是对的写成target_link_libraries(codec server)就可能出现符号找不到。CMake 的target_link_libraries一般会帮你处理好依赖顺序但如果你手工写g命令-lcodec必须放在源文件后面。编过之后先别急着跑服务端用 ctest 把纯内存的边界测试过一遍cd build ctest --output-on-failure--output-on-failure这个参数很有用用例失败时它会把测试程序的完整输出打出来而不是只给你一个Failed。如果这一步就挂了说明解码器实现本身有问题跟网络完全无关先把这里调通再去联调能省掉大量“到底是网的问题还是代码的问题”的纠结。4. 编解码落地长度字段包头怎么定、怎么解粘包和半包在 TCP 上是同一件事的两面一次read可能读到半条消息也可能读到三条半。应用层的任务是把这条字节流重新切回消息序列。切法只有那么几种选哪种直接决定了后续代码的复杂度和出 bug 的概率所以这一章先把方案对比清楚再落到具体实现。4.1 三种分帧方案的对比与选型方案做法优点致命弱点适用场景定长包每条消息固定 N 字节实现最简单解析无需缓存消息长度不固定时浪费或不够用协议字段长度全部固定分隔符用\r\n等特殊字节结尾可读性好便于人工调试消息体里出现分隔符就误切纯文本协议长度字段包头放包体长度二进制安全、长度任意需要处理长度非法与半包绝大多数二进制通讯长度字段方案是这套源码采用的也是我自己写服务端时的默认选择。原因很直接JSON、protobuf 序列化出来的东西都是二进制里面出现\r\n的概率不低定长包又要求业务字段永远不变改一个字段就得动协议。长度字段多出来的复杂度主要在两处一是包头本身的字节序要统一二是收到超长或非法长度时要能果断拒绝不能傻乎乎地按对方给的数字去分配内存。包头我定为 4 字节无符号整数大端序。选 4 字节而不是 2 字节是因为 2 字节最大只能表示 65535稍微大一点的图片或文件分片就超了选大端而不是小端是因为网络字节序就是大端htonl系列函数直接对应跨 x86 和 ARM 混合部署时不用额外协商。如果你要做 protobuf 那种变长前缀思路一样只是把定长 4 字节换成 varint 编码解码循环要改成逐字节读。4.2 编码器实现与字节序处理编码部分很短但每个字节都不能错// include/length_header_codec.h #pragma once #include cstdint #include string #include vector class LengthHeaderCodec { public: // 单帧上限 8MB超过直接判为非法帧防止对端用超大长度值打爆内存 static constexpr uint32_t kMaxFrameSize 8 * 1024 * 1024; // 编码4 字节大端长度前缀 原始负载 static std::string encode(const std::string payload); // 解码喂入任意长度的字节吐出本次解出的所有完整帧 // 返回值为解出的帧数未凑齐一帧的尾巴留在内部缓存里 size_t decode(const char* data, size_t len, std::vectorstd::string out); private: std::string buffer_; // 半包缓存跨多次 read 累积 };// src/length_header_codec.cpp编码部分 #include length_header_codec.h #include cstring std::string LengthHeaderCodec::encode(const std::string payload) { // 先按 4 字节包头 负载总长一次性分配避免多次扩容 std::string out(4 payload.size(), \0); const uint32_t n static_castuint32_t(payload.size()); // 手工按大端序写入不依赖 htonl保证在任何平台上字节布局一致 out[0] static_castchar((n 24) 0xFF); out[1] static_castchar((n 16) 0xFF); out[2] static_castchar((n 8) 0xFF); out[3] static_castchar((n ) 0xFF); if (!payload.empty()) { std::memcpy(out[4], payload.data(), payload.size()); } return out; }这四行移位是整个协议的地基。n 24取最高 8 位放到第一个字节n 0xFF取最低 8 位放到最后写出来就是大端。用htonl也能达到同样效果但那要求发送方和接收方对“网络序”这个概念理解一致手工移位的好处是把字节序这件事显式写在代码里谁看都明白也不会因为某个平台没定义htonl而编不过。用std::memcpy而不是strcpy或者sprintf是关键细节。消息体里完全可能包含\0strcpy遇到它就停了你的二进制数据会被截断而且截断位置取决于内容表现得极其随机。memcpy按字节数拷贝对内容不做任何解释这才是处理二进制该有的方式。4.3 解码器与半包缓存的处理逻辑解码是整个模块里最容易写错的地方核心是一个带偏移量的循环// src/length_header_codec.cpp解码部分 size_t LengthHeaderCodec::decode(const char* data, size_t len, std::vectorstd::string out) { // 把本次新到的字节追加到半包缓存尾部 buffer_.append(data, len); size_t count 0; size_t offset 0; // 条件剩下的字节至少够一个完整包头 while (buffer_.size() - offset 4) { const unsigned char* p reinterpret_castconst unsigned char*(buffer_.data() offset); // 与编码端对应按大端序还原包体长度 const uint32_t body (static_castuint32_t(p[0]) 24) | (static_castuint32_t(p[1]) 16) | (static_castuint32_t(p[2]) 8) | static_castuint32_t(p[3]); // 长度越界说明流已错位整段丢弃让上层决定是否重连 if (body kMaxFrameSize) { buffer_.clear(); return count; } // 包体还没到齐保留现状等下一次 read不要在这里死等 if (buffer_.size() - offset static_castsize_t(4) body) { break; } out.emplace_back(buffer_, offset 4, body); offset 4 body; count; } // 把已经消费掉的前缀切掉剩下的就是还没凑齐的半包 if (offset 0) { buffer_.erase(0, offset); } return count; }while的条件buffer_.size() - offset 4用的是减法而不是先加偏移再比较原因是offset在循环里会增长如果每次都拿buffer_.size()和offset 4比逻辑上等价但容易在offset接近size()时写出边界溢出。减法的写法只要保证offset永远不超过size()就安全而这个不变式由下面offset 4 body那句和 break 条件共同维护。break那句是半包处理的关键。很多新手在这里写成return count或者干脆阻塞等待前者会丢掉后续已经到达的完整帧后者会把一个非阻塞的收包循环变成同步等待。正确做法就是跳出循环把没凑齐的部分留在buffer_里等下一次read到来时再追加。这个函数本身不持有任何 socket所以它是可重入的、可单测的这一点对排查问题帮助极大。提示buffer_.erase(0, offset)每次调用都会把剩余字节整段前移单次开销是剩余长度的线性拷贝。低频消息场景下这完全无所谓但如果你的服务每秒要处理几万帧就得换成环形缓冲区或者维护一个读偏移量、只在偏移超过缓冲区一半时才做一次压缩。4.4 缓冲区管理的取舍buffer_.erase的开销问题我一般这样权衡单帧平均大小在 1KB 以上、QPS 在几千以内直接 erase 完全够用代码可读性优先如果帧很小比如几十字节的指令包且频率很高剩余字节会被反复搬运这时候才值得引入偏移量方案。偏移量方案的做法是额外维护一个size_t read_pos_每次消费后只增加它只有在read_pos_超过buffer_.size() / 2时才做一次erase(0, read_pos_)把均摊成本压下去。另一个取舍是包体要不要拷出来。上面out.emplace_back(buffer_, offset 4, body)是从缓存里拷一份新字符串出来每帧一次拷贝。要避免的话可以改成回调式接口把const char*和长度传出去让上层在回调期间直接用回调返回后缓存就可以清理了。回调式省拷贝但要求上层不能在回调之外持有这个指针接口约束更强。这套源码选了拷贝式理由是可测试性好boundary_test.cpp里可以直接断言每次解出来的内容不用构造复杂的回调上下文。5. 排查记录五个把粘包处理写成新 bug 的现场编解码代码不长但每一个改动点都对应一类翻车方式。下面这五条是我自己以及身边人真实踩过的按现象、原因、解决三段记录遇到问题时可以先对着现象快速定位再去看原因和修法。5.1 现象本地回环全对跨机器就整体错位原因几乎总是字节序。有人为了省事把包头定义成struct Header { uint32_t len; }然后memcpy进buffer_直接强转成指针读len。在小端机器上发送、小端机器上接收恰好能对上一旦对端是大端或者编译选项不同导致结构体布局变化长度值就变成天文数字解码器立刻触发越界保护清空缓存表现为“所有帧都丢了”。结构体方案还有对齐填充的问题struct { uint8_t type; uint32_t len; }的实际大小往往是 8 而不是 5你按 5 字节去算偏移从第二帧开始就全乱了。解决方式就是彻底不用结构体直接映射回到逐字节拼装和还原。上面编码器和解码器里的八行移位看着啰嗦就是用来消除这个隐患的。如果确实想复用代码可以写一对write_be32/read_be32的小函数所有地方统一调用。5.2 现象小消息一切正常一超过 64KB 就断连原因通常是长度字段位宽不够。有人把包头改成 2 字节uint16_t图省内存结果 65536 字节的包长度回绕成 0解码端读出一个 0 长度帧接着把后面的真实数据当成包头解析整条连接立刻错位。另一种情况是长度字段够大但内部缓冲区设了上限比如buffer_用了固定 64KB 的数组超长帧永远凑不齐while循环一直 break表现为“大包永远收不到连接也不报错”。解决要分两步包头位宽按业务最大帧来定做文件分片至少留 4 字节缓冲区不要用固定数组用std::string或者可扩容的容器同时设一个明确的kMaxFrameSize上限超过上限时主动断开而不是无限等待。上限值不是我随便写的 8MB你可以按业务调整但一定要有没有上限的解码器等于把内存分配权交给了对端。5.3 现象服务端 CPU 跑满但什么数据都没收到原因多半是解码循环写成了死循环。典型写法是while (buffer_.size() 4) { ... }循环体里在“包体不齐”的情况下用了continue而不是break。缓冲区里只有 4 个字节包头、包体还没到时条件size() 4永远成立continue直接回到循环开头进度一点没变CPU 就原地打转。这种 bug 在本地测试时可能因为数据一次就到齐了而完全不出现一到真实网络分片就暴露。解决就是把所有“数据不够”的出口统一成break。判断完包头长度之后先检查剩余字节数够不够一帧不够就跳出循环、把控制权还给外层read。我还会在循环里加一个offset单调递增的断言debug 构建下只要发现某次迭代offset没变就立刻 abort比等 CPU 打满再来看gdb快得多。5.4 现象跑几小时内存一路涨重启就好原因是半包缓存只增不减或者错误路径没有清理。比较隐蔽的一种是解码器在长度非法时return count而没有清空buffer_下次新数据继续往后面追加非法前缀永远留在最前面后面的合法数据全部解不出来内存也一直涨。另一种是连接断开后这个连接对应的LengthHeaderCodec对象没有随连接一起销毁那个buffer_就跟着泄漏了。解决方式有两条非法长度分支里必须清空缓存并标记连接不可用编解码器对象的生命周期严格绑定到连接对象连接销毁时它跟着析构不要做成全局单例。如果你用的是连接池池里复用对象前记得显式重置一次状态。5.5 现象纯英文正常带中文或二进制内容就少一截原因是字符串处理函数用错了。有人收到数据后直接std::string s(buf)buf是char*这个构造会在第一个\0处停下。UTF-8 中文本身不含\0但如果消息里嵌套了序列化后的二进制结构或者长度字段后面紧跟的是压缩数据中断位置就完全随机。更常见的版本是用strlen算长度同样的问题。解决方式在全链路统一用“指针 长度”这对参数传递数据所有拷贝用memcpy或std::string(ptr, len)这种双参数构造绝不用依赖\0结尾的接口。日志打印时如果要输出内容先做十六进制转储别直接printf(%s)二进制数据打日志本身也是这类问题的常见来源。6. 跑通验证用边界用例确认收包真的完整编译通过只是第一步能证明这套粘包处理真的工作的是边界用例。我不会一上来就开服务端连客户端而是先用boundary_test在内存里把解码器喂饱因为纯内存测试的失败信息最干净不会掺进网络因素。测试覆盖这几种输入空负载、单字节负载、刚好等于kMaxFrameSize的帧、连续 100 帧一次性喂入、一帧拆成三个字节段分多次喂入、长度字段声明 9MB 的非法帧。每种输入的预期结果都能明确断言比如“空负载应该解出 1 帧且内容为空”“非法帧应该解出 0 帧且缓存被清空”。内存测试过了再起服务端做真实链路验证。这时候用一段 Python 脚本主动制造粘包和半包比用现成的客户端程序更可控import socket import struct HOST, PORT 127.0.0.1, 9000 def frame(payload: bytes) - bytes: # 与 C 端保持一致4 字节大端长度前缀 负载 return struct.pack(I, len(payload)) payload cases [b, bA, bB * 65536, bC * (1024 * 1024)] with socket.create_connection((HOST, PORT)) as s: # 故意把多帧拼成一次 send制造粘包 s.sendall(b.join(frame(c) for c in cases)) # 故意把一帧拆成两次 send制造半包 one frame(bsplit-frame) s.sendall(one[:2]) s.sendall(one[2:])struct.pack(I, ...)里的就是大端和 C 端的手工移位一一对应这一点必须对齐写成就会全盘错位。脚本里四次sendall的分布是刻意设计的第一次把四个长度差异极大的帧一次性推过去服务端的一次read大概率只能读到其中一部分粘包和半包同时出现后两次把一个帧从中间劈开专门验证半包缓存。服务端日志里应该正好打印出 6 条消息长度分别是 0、1、65536、1048576、11。用例构造方式期望结果失败时的第一怀疑点空负载长度 0无包体解出 1 帧内容为空解码循环把 0 长度当成半包 break 掉单字节长度 1解出 1 帧包头还原时偏移算错64KB 帧长度 65536完整收下长度字段位宽不足1MB 帧长度 1048576完整收下缓冲区上限设得太小拆分帧一帧分两次发只解出 1 帧不重复半包缓存被提前清空验证的时候我还会开着tcpdump -i lo -X port 9000看实际字节把抓到的十六进制和脚本构造的内容对一遍。有一次日志显示收到了帧但内容少两个字节抓包一看是客户端那边send的缓冲区拼接逻辑写错了服务端完全无辜。没有抓包这个锅大概率会甩到解码器头上白改半天。从那以后我每次把这套源码搬到新环境都强制走一遍「先 ctest 后 Python 构造包、再抓包对字节」的三步一步都不跳。装环境这件事最怕的就是“编过了就等于装好了”的错觉真正能让你睡安稳的是那几个边界用例全绿。希望这套流程能帮你把粘包问题一次性钉死在验证阶段而不是留到线上去猜。本文还有配套的精品资源点击获取